diff --git a/.config/nextest.toml b/.config/nextest.toml index 2a4cd2cb93..11ec94cc1a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,3 +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/.gitattributes b/.gitattributes index 5db32cda22..a0543090cc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,9 @@ # signatures) and assets whose SHA-256 ATTRIBUTION.toml records (cargo xtask assets). * -text +# The craft-i18n pin: CI and the build read it as one SHA, so it stays LF everywhere (readers trim it too). +craft-i18n.rev text eol=lf + # Not text at all: no textual diffs or merges either. *.pdf binary *.p12 binary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5c2ba7f7f..9403a1e044 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 }} @@ -41,3 +42,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} diff --git a/AGENTS.md b/AGENTS.md index 1f90ec3d8f..3ab900aa95 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) @@ -54,14 +52,14 @@ Anything else is forbidden, including assets whose licence you cannot prove from - path or URL, title, author; - source (URL, with version or commit); - SPDX licence, and the licence file in the repo; - - SHA-256, except for translation catalogs (see below); + - SHA-256; - how it is used. -- **Translation catalogs** (`crates/ui-egui/src/i18n/*.tsv`, `kind = "translation"`) are project files under the project licence. Their entries carry no `sha256`: git history records every change and who made it. Every other kind (fonts, images, data) is hashed. +- **Translations** live in craft-i18n (`locales//common.tsv` and `pdfcraft.tsv`), embedded at build time from the checkout pinned in `craft-i18n.rev`; PdfCraft keeps no catalogs. `ATTRIBUTION.toml` records them as one `[[build_input]]` (`kind = "translation"`); craft-i18n's own `ATTRIBUTION.md` credits the translators. Every `[[asset]]` (fonts, images, data) is hashed. - **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand. - **`NOTICE`** keeps the licence notices that the licences require. - **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if: - an asset file in the repository has no entry; - - an entry's hash is missing or does not match (translation catalogs carry none); + - an entry's hash is missing or does not match; - a licence is not on the allowlist; - a licence file is missing; - an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone. @@ -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,20 +123,17 @@ 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. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 23ebcbbe82..c70d84e624 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -2,215 +2,187 @@ -Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). +Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (289) +## In this repository (256) | 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.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 | 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 | +| `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 | @@ -231,6 +203,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) | | `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | +| `crates/prc/src/acof/table.rs` | PRC Double prefix-code table (as a Rust table) | Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2) | MIT | Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header. | Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data. | | `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 | | `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | 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-logo-white.svg` | ArtCraft brand: artcraft-logo-white | 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. | @@ -240,22 +213,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 +242,28 @@ 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.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 | +| `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 | | `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.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.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 | +| `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 | ## Compiled in through dependencies (6) @@ -338,10 +305,11 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model | `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) +## Optional build inputs (2) Not in this repository and never downloaded by it: compiled in only when the build sets the option (official releases do). Each input attributes its own files. | Input | Option | Title | Author | Licence | Source | Attribution | Used for | |---|---|---|---|---|---|---|---| +| `craft-i18n` | `CRAFT_I18N_DIR` | Interface translations: the craft-i18n catalogs (locales//common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English | The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6) | MIT OR Apache-2.0 | https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read) | https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md | Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels | | `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7dcd882f84..9638749929 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -1,302 +1,21 @@ -[[asset]] -path = "crates/ui-egui/src/i18n/el.tsv" -title = "Greek interface translation catalog" -author = "Camthalion77 and contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -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`). # # [[asset]] a file in this repository (committed or vendored); `sha256` must match the file. -# Translation catalogs (kind = "translation", crates/ui-egui/src/i18n/*.tsv) are project files -# under the project licence and carry no `sha256`: git history records their provenance. # [[bundled]] a file compiled into PdfCraft by a Cargo dependency (its crate version must match Cargo.lock). # [[fetched]] downloaded at build time by xtask (never committed), pinned by `sha256`. -# [[build_input]] an optional build input outside this repository (e.g. craft-fonts via CRAFT_FONTS_DIR); +# [[build_input]] an optional build input outside this repository (craft-fonts via CRAFT_FONTS_DIR, craft-i18n via CRAFT_I18N_DIR); # never committed or downloaded here, its own repository attributes each file. # # licence: SPDX expression from the allowlist in AGENTS.md §1.2. adobe_data = true marks the closed list of # Adobe-authored non-visual technical data allowed by AGENTS.md §1.1. -[[asset]] -path = "crates/ui-egui/src/i18n/zh-hans.tsv" -title = "Simplified Chinese interface translation catalog" -author = "PdfCraft contributors" -source = "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" -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.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/zh-hant.tsv" -title = "Traditional Chinese (Taiwan) interface translation catalog" -author = "PdfCraft contributors" -source = "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" -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.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/cs.tsv" -title = "Czech interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/pt-br.tsv" -title = "Brazilian Portuguese interface translation catalog" -author = "André Cardoso (andrecards), PdfCraft contributors" -source = "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" -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.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/es.tsv" -title = "Spanish interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/fr.tsv" -title = "French interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/de.tsv" -title = "German interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/lo.tsv" -title = "Lao interface translation catalog" -author = "@bounkirdni-2025" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/te.tsv" -title = "Telugu interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/it.tsv" -title = "Italian interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/pl.tsv" -title = "Polish interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/ru.tsv" -title = "Russian interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/hu.tsv" -title = "Hungarian interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/bg.tsv" -title = "Bulgarian interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/kk.tsv" -title = "Kazakh interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/ja.tsv" -title = "Japanese interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/uk.tsv" -title = "Ukrainian interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/ar.tsv" -title = "Arabic interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/ckb.tsv" -title = "Central Kurdish (Sorani) interface translation catalog" -author = "PdfCraft contributors" -source = "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" -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.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/fa.tsv" -title = "Persian (Farsi) interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/tr.tsv" -title = "Turkish interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/lt.tsv" -title = "Lithuanian interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/hi.tsv" -title = "Hindi interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -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" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -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" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/ro.tsv" -title = "Romanian interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - - -[[asset]] -path = "crates/ui-egui/src/i18n/fi.tsv" -title = "Finnish interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -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 +26,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 +37,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 +48,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 +59,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 +70,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 +81,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 +92,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 +103,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 +114,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 +125,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 +136,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 +147,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 +158,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 +169,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 +180,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 +191,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 +202,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 +213,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 +224,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 +235,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 +246,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 +257,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 +268,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 +279,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 +290,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 +301,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 +312,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 +323,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 +334,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 +345,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 +356,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 +367,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 +378,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 +389,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 +400,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 +411,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 +422,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 +433,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 +444,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 +455,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 +466,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 +477,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 +488,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 +499,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 +510,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 +521,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 +532,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 +543,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 +554,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 +565,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 +576,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 +587,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 +598,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 +609,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 +620,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 +631,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 +642,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 +653,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 +664,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 +675,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 +686,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 +697,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 +708,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 +719,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 +730,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 +741,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 +752,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 +763,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 +774,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 +785,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 +796,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 +807,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 +818,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 +829,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 +840,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 +851,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 +862,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 +873,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 +884,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 +895,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 +906,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 +917,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 +928,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 +939,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 +950,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 +961,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 +972,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 +983,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 +994,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 +1005,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 +1016,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 +1027,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 +1038,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 +1049,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 +1060,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 +1071,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 +1082,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 +1093,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 +1104,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 +1115,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 +1126,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 +1137,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 +1148,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 +1159,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 +1170,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 +1181,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 +1192,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 +1203,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 +1214,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 +1225,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 +1236,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 +1247,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 +1258,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 +1269,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 +1280,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 +1291,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 +1302,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 +1313,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 +1324,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 +1335,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 +1346,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 +1357,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 +1368,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 +1379,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 +1390,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 +1401,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 +1412,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 +1423,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 +1434,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 +1445,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 +1456,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 +1467,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 +1478,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 +1489,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 +1500,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 +1511,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 +1522,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 +1533,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 +1544,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 +1555,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 +1566,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 +1577,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 +1588,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 +1599,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 +1610,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 +1621,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 +1632,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 +1643,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 +1654,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 +1665,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 +1676,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 +1687,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 +1698,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 +1709,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 +1720,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 +1731,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 +1742,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 +1753,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 +1764,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 +1775,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 +1786,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 +1797,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 +1808,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 +1819,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 +1830,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 +1841,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 +1852,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 +1863,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 +1874,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 +1885,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 +1896,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 +1907,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 +1918,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 +1929,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" @@ -2437,6 +2156,17 @@ adobe_data = true usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree." sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" +[[asset]] +path = "crates/prc/src/acof/table.rs" +title = "PRC Double prefix-code table (as a Rust table)" +author = "Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2)" +source = "Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header." +licence = "MIT" +licence_file = "licenses/prc-rs-LICENSE.txt" +kind = "data" +usage = "Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data." +sha256 = "8e705c5cde6a3ef778e8aca69759e38845b228b3f7f12949e587b19855d2114c" + [[bundled]] crate = "epaint_default_fonts" version = "0.37.0" @@ -2873,7 +2603,7 @@ 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" @@ -2884,7 +2614,7 @@ 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" @@ -2895,7 +2625,7 @@ 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" @@ -2906,7 +2636,7 @@ 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" @@ -2917,7 +2647,7 @@ 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" @@ -2950,7 +2680,7 @@ 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" @@ -2961,7 +2691,7 @@ 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" @@ -2972,7 +2702,7 @@ 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" @@ -2983,7 +2713,7 @@ 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" @@ -2994,7 +2724,7 @@ 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" @@ -3005,7 +2735,7 @@ 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" @@ -3016,7 +2746,7 @@ 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" @@ -3027,7 +2757,7 @@ 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" @@ -3038,7 +2768,7 @@ 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" @@ -3194,7 +2924,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 +2935,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 +2946,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 +2957,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 +2968,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 +2979,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 +2990,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 +3012,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 +3023,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 +3045,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" @@ -3381,6 +3111,17 @@ kind = "model" usage = "Scan & OCR: reads the words it finds (cargo xtask models)" sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5" +[[build_input]] +name = "craft-i18n" +option = "CRAFT_I18N_DIR" +title = "Interface translations: the craft-i18n catalogs (locales//common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English" +author = "The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6)" +source = "https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read)" +licence = "MIT OR Apache-2.0" +attribution = "https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md" +kind = "translation" +usage = "Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels" + [[build_input]] name = "craft-fonts" option = "CRAFT_FONTS_DIR" @@ -3408,32 +3149,12 @@ 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" sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" -[[asset]] -path = "crates/ui-egui/src/i18n/vi.tsv" -title = "Vietnamese interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/sl.tsv" -title = "Slovene interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs" - [[asset]] path = "vendor/vello_common/assets/probe.png" @@ -3446,22 +3167,12 @@ kind = "image" usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file" sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" -[[asset]] -path = "crates/ui-egui/src/i18n/nb.tsv" -title = "Norwegian Bokmål interface translation catalog" -author = "PdfCraft contributors" -source = "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" -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.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 +3183,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 +3194,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,38 +3205,9 @@ 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" sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" -[[asset]] -path = "crates/ui-egui/src/i18n/sq.tsv" -title = "Albanian interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/th.tsv" -title = "Thai interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs" - -[[asset]] -path = "crates/ui-egui/src/i18n/ca.tsv" -title = "Catalan interface translation catalog" -author = "PdfCraft contributors" -source = "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" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "translation" -usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs" diff --git a/CLAUDE.md b/CLAUDE.md index b6c91b1fe6..5c7034b7a4 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/`). @@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **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. +- **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 press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks 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_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **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. @@ -32,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. diff --git a/Cargo.lock b/Cargo.lock index cfcb9dc362..573b215b7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,17 +1178,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1211,7 +1211,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "egui", "serde", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-keymap", "craft-quit", @@ -1234,9 +1234,10 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-winprint-gdi", + "log", "miniz_oxide 0.9.1", "roxmltree 0.21.2", "tempfile", @@ -1247,7 +1248,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "dispatch2", "egui", @@ -1258,7 +1259,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1279,7 +1280,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "chrono", "dunce", @@ -4207,7 +4208,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.15.0" +version = "0.18.1" dependencies = [ "chrono", "craft-update", @@ -4231,7 +4232,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.15.0" +version = "0.18.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4243,7 +4244,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.15.0" +version = "0.18.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4254,7 +4255,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.15.0" +version = "0.18.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4265,7 +4266,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.15.0" +version = "0.18.1" dependencies = [ "base64 0.23.1", "flate2", @@ -4276,6 +4277,8 @@ dependencies = [ "pdfcraft-model", "pdfcraft-organize", "pdfcraft-platform", + "pdfcraft-prc", + "pdfcraft-prc-render", "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", @@ -4287,7 +4290,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.15.0" +version = "0.18.1" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4305,11 +4308,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.15.0" +version = "0.18.1" [[package]] name = "pdfcraft-content" -version = "0.15.0" +version = "0.18.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4317,7 +4320,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.15.0" +version = "0.18.1" dependencies = [ "chrono", "hayro-syntax", @@ -4332,11 +4335,10 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.15.0" +version = "0.18.1" dependencies = [ "encoding_rs", "hayro-ccitt", - "hayro-jbig2", "hayro-syntax", "image", "pdfcraft-cos", @@ -4352,7 +4354,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.15.0" +version = "0.18.1" dependencies = [ "aes", "md-5", @@ -4364,7 +4366,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.15.0" +version = "0.18.1" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4373,6 +4375,8 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-render", + "proptest", "sha2 0.11.1", "thiserror 2.0.21", "unicode-bidi", @@ -4380,7 +4384,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.15.0" +version = "0.18.1" dependencies = [ "chrono", "craft-docpath", @@ -4392,10 +4396,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", @@ -4422,14 +4428,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.15.0" +version = "0.18.1" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.15.0" +version = "0.18.1" dependencies = [ "fax", "flate2", @@ -4442,7 +4448,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.15.0" +version = "0.18.1" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4452,7 +4458,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.15.0" +version = "0.18.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4465,11 +4471,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.15.0" +version = "0.18.1" [[package]] name = "pdfcraft-js" -version = "0.15.0" +version = "0.18.1" dependencies = [ "boa_engine", "chrono", @@ -4477,7 +4483,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.15.0" +version = "0.18.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4490,14 +4496,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.15.0" +version = "0.18.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.15.0" +version = "0.18.1" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4508,7 +4514,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.15.0" +version = "0.18.1" dependencies = [ "image", "pdfcraft-annot", @@ -4524,11 +4530,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.15.0" +version = "0.18.1" [[package]] name = "pdfcraft-organize" -version = "0.15.0" +version = "0.18.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4539,7 +4545,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.15.0" +version = "0.18.1" dependencies = [ "async-channel", "futures-lite", @@ -4547,9 +4553,27 @@ dependencies = [ "zbus", ] +[[package]] +name = "pdfcraft-prc" +version = "0.18.1" +dependencies = [ + "flate2", + "proptest", + "thiserror 2.0.21", +] + +[[package]] +name = "pdfcraft-prc-render" +version = "0.18.1" +dependencies = [ + "flate2", + "pdfcraft-prc", + "thiserror 2.0.21", +] + [[package]] name = "pdfcraft-preflight" -version = "0.15.0" +version = "0.18.1" dependencies = [ "flate2", "pdfcraft-content", @@ -4560,7 +4584,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.15.0" +version = "0.18.1" dependencies = [ "craft-print", "pdfcraft-content", @@ -4572,7 +4596,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.15.0" +version = "0.18.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4589,7 +4613,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.15.0" +version = "0.18.1" dependencies = [ "bytemuck", "flate2", @@ -4608,7 +4632,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.15.0" +version = "0.18.1" dependencies = [ "aes", "aws-lc-rs", @@ -4644,17 +4668,18 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.15.0" +version = "0.18.1" [[package]] name = "pdfcraft-ui-egui" -version = "0.15.0" +version = "0.18.1" dependencies = [ "arboard", "base64 0.23.1", "block2 0.6.2", "bytemuck", "chrono", + "craft-i18n", "craft-keymap", "craft-menu", "craft-quit", @@ -4672,6 +4697,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", @@ -4682,6 +4708,7 @@ dependencies = [ "plist", "png 0.18.1", "pollster 1.0.1", + "proptest", "rfd", "rustix 1.1.5", "serde", @@ -4700,7 +4727,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.15.0" +version = "0.18.1" dependencies = [ "eframe", "egui", @@ -4712,7 +4739,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.15.0" +version = "0.18.1" dependencies = [ "base64 0.23.1", "image", @@ -4726,7 +4753,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.15.0" +version = "0.18.1" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -6765,7 +6792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 1b04ab5263..bf6a4e3146 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.15.0" +version = "0.18.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -13,52 +13,58 @@ 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", 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 } +# Interface translations: lookup, fallback, CLDR plurals and language names over the craft-i18n +# catalogs that `crates/ui-egui/build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, at +# the commit in `craft-i18n.rev`). Already in the tree through craft-ui. +craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } +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.15.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.15.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.15.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.15.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.15.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.15.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.15.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.15.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.15.0" } -pdfcraft-model = { path = "crates/model", version = "0.15.0" } -pdfcraft-content = { path = "crates/content", version = "0.15.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.15.0" } -pdfcraft-print = { path = "crates/print", version = "0.15.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.15.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.15.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.15.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.15.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.15.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.15.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.15.0" } -pdfcraft-js = { path = "crates/js", version = "0.15.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.15.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.15.0" } -pdfcraft-export = { path = "crates/export", version = "0.15.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.15.0" } -pdfcraft-create = { path = "crates/create", version = "0.15.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.15.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.15.0" } -pdfcraft-render = { path = "crates/render", version = "0.15.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.15.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.15.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.15.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.15.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.18.1" } +pdfcraft-filters = { path = "crates/filters", version = "0.18.1" } +pdfcraft-cos = { path = "crates/cos", version = "0.18.1" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.18.1" } +pdfcraft-organize = { path = "crates/organize", version = "0.18.1" } +pdfcraft-measure = { path = "crates/measure", version = "0.18.1" } +pdfcraft-annot = { path = "crates/annot", version = "0.18.1" } +pdfcraft-audit = { path = "crates/audit", version = "0.18.1" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.18.1" } +pdfcraft-model = { path = "crates/model", version = "0.18.1" } +pdfcraft-content = { path = "crates/content", version = "0.18.1" } +pdfcraft-redact = { path = "crates/redact", version = "0.18.1" } +pdfcraft-print = { path = "crates/print", version = "0.18.1" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.1" } +pdfcraft-sign = { path = "crates/sign", version = "0.18.1" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.18.1" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.18.1" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.18.1" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.18.1" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.18.1" } +pdfcraft-js = { path = "crates/js", version = "0.18.1" } +pdfcraft-compare = { path = "crates/compare", version = "0.18.1" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.18.1" } +pdfcraft-export = { path = "crates/export", version = "0.18.1" } +pdfcraft-edit = { path = "crates/edit", version = "0.18.1" } +pdfcraft-create = { path = "crates/create", version = "0.18.1" } +pdfcraft-forms = { path = "crates/forms", version = "0.18.1" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.18.1" } +pdfcraft-render = { path = "crates/render", version = "0.18.1" } +pdfcraft-engine = { path = "crates/engine", version = "0.18.1" } +pdfcraft-platform = { path = "crates/platform", version = "0.18.1" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" } +pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false } +pdfcraft-prc = { path = "crates/prc", version = "0.18.1" } +pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" } # pdf hayro = "0.7.1" diff --git a/LICENSES.toml b/LICENSES.toml new file mode 100644 index 0000000000..0617ca5d57 --- /dev/null +++ b/LICENSES.toml @@ -0,0 +1,166 @@ +# 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 = "licenses/prc-rs-LICENSE.txt" +licences = ["MIT"] +destination = "licenses/prc-rs-LICENSE.txt" +sha256 = "ddb846513d92153d8fa16b18ed3e7c86953a582f0d2f49f02b29f1bd569dac58" +origin = "https://raw.githubusercontent.com/ralovich/prc-rs/287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d/LICENSE" + +[[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 9599a54844..3c1b67936a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -545,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 069612ba31..00dd8fdee0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,24 @@ # 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 @@ -204,9 +223,45 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15. + +- **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 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged. + +- **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-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-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. @@ -238,6 +293,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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. @@ -357,6 +414,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. @@ -529,10 +588,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%). @@ -702,6 +761,10 @@ 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. | @@ -740,11 +803,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. | @@ -767,9 +830,18 @@ Newest first: the date, what moved, and the new overall estimate. - **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/src/main.rs b/apps/pdfcraft/src/main.rs index 814730f344..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 …) @@ -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())); } - let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file()); + if startup.help { + return print_and_exit(USAGE); + } + 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}"); } } 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 index c6e6f36518..3512f46b36 100644 --- a/apps/pdfcraft/src/tests.rs +++ b/apps/pdfcraft/src/tests.rs @@ -663,6 +663,29 @@ fn the_desktop_entry_names_the_window_class() { assert_eq!(class, Some(super::APP_ID)); } +/// Both flatpak manifests build with the craft-i18n commit `craft-i18n.rev` pins: CI and +/// releases read the file, and the manifests have to name the commit themselves. The pin may +/// have a trailing newline or CRLF (a Windows checkout), so it is trimmed, as every reader does. +#[test] +fn the_flatpak_manifests_pin_the_craft_i18n_rev() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let rev = std::fs::read_to_string(root.join("craft-i18n.rev")).unwrap(); + let rev = rev.trim(); + assert!(rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()), "craft-i18n.rev holds one full SHA: {rev:?}"); + for manifest in ["ai.storyteller.pdfcraft.yml", "ai.storyteller.pdfcraft.bundle.yml"] { + let text = std::fs::read_to_string(root.join("packaging/linux/flatpak").join(manifest)).unwrap(); + let source = + text.split("url: https://github.com/storytold/craft-i18n").nth(1).unwrap_or_else(|| panic!("{manifest}: a craft-i18n git source")); + let source = source.split("- type:").next().unwrap_or(source); + let commit = source + .lines() + .find_map(|l| l.trim().strip_prefix("commit:")) + .unwrap_or_else(|| panic!("{manifest}: the craft-i18n source pins a commit")); + assert_eq!(commit.trim().trim_matches(['\'', '"']), rev, "{manifest}"); + assert!(text.contains("CRAFT_I18N_DIR: /run/build/pdfcraft/craft-i18n"), "{manifest}: the build names the catalogs"); + } +} + // 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. diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 58ef07d37c..7c17ad0045 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -20,8 +20,7 @@ icons use the full-bleed tile. macOS icons put it on Apple's grid (an 824 px bod 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 diff --git a/contributors/people.toml b/contributors/people.toml index 53cc2391ed..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" @@ -183,3 +180,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.Combiler01] +sync_github_name = true diff --git a/craft-i18n.rev b/craft-i18n.rev new file mode 100644 index 0000000000..77b59ea817 --- /dev/null +++ b/craft-i18n.rev @@ -0,0 +1 @@ +575cfdef63227b14daf173b8cc87928bfdfa325e 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)); + } + 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 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())); } } - let decoded: Vec<&[u8]> = streams.iter().flatten().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() { + 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/tests.rs b/crates/a11y/src/tests.rs index a70ffbf385..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)) } 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/src/appearance.rs b/crates/annot/src/appearance.rs index abc746c0dd..89c0f70f48 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option { Some(res) } -fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { +pub(crate) fn form(bbox: [f64; 4], content: &[u8], 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")); @@ -288,6 +288,18 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { Stream::flate(d, content) } +/// The `cm` that draws a picture into `at`: an image (its unit square) or a form XObject whose +/// `/Matrix` maps it to `size` points (`None`, or a side that is not positive, draws nothing). +pub(crate) fn picture_place(at: [f64; 4], image: bool, size: Option<(f64, f64)>) -> Option { + let [x0, y0, x1, y1] = at; + let (w, h) = (x1 - x0, y1 - y0); + if image { + return Some(format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))); + } + let (sw, sh) = size.filter(|(sw, sh)| *sw > 0.0 && *sh > 0.0)?; + Some(format!("{} 0 0 {} {} {} cm", n(w / sw), n(h / sh), n(x0), n(y0))) +} + /// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written /// when the signature is added to a turned page): 0, 90, 180 or 270. pub(crate) fn picture_turn(d: &Dict) -> i64 { @@ -325,7 +337,7 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { } /// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. -fn helvetica_resource() -> Object { +pub(crate) fn helvetica_resource() -> Object { let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); @@ -392,9 +404,13 @@ struct AppearanceDrawing { } 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") { + 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"); } @@ -782,15 +798,12 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) - // 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)) - }; + let size = nums(d, b"PCPictureSize").and_then(|s| match s.as_slice() { + [w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)), + _ => None, + }); + let place = picture_place(rect, image, size)?; c.push_str(&format!("q {place} /Pic Do Q\n")); let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index a6db5b940b..1904745e95 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -33,6 +33,16 @@ 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 } @@ -87,17 +97,11 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr 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 } => { + 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)")); } - // 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) + grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0) } Shape::Polygon { vertices, cloud } => { let b = bounds(vertices.iter().copied()) @@ -164,6 +168,7 @@ fn subject(shape: &Shape) -> &'static str { 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", @@ -292,10 +297,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } } } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + 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 } => { diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index b56e0de20d..1a54dbc077 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -47,8 +47,12 @@ pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option 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) + 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 diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 7bd0894c66..4f24d04b08 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -22,7 +22,9 @@ mod create; mod draw; mod info; mod lookup; +mod model3d; mod modify; +mod threed; mod types; #[cfg(test)] @@ -36,11 +38,13 @@ 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 use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations}; 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 threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model}; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, @@ -70,6 +74,12 @@ pub enum AnnotError { /// with a font that has those characters. The message names the character. #[error("{0}")] Undrawable(String), + /// A 3D model file that is neither U3D (starts with `U3D` and a zero byte) nor PRC (starts `PRC`). + #[error("not a 3D model: the file must be U3D (ECMA-363) or PRC (ISO 14739-1)")] + NotAModel, + /// A 3D model larger than `MAX_MODEL_BYTES`. + #[error("the 3D model is {size} bytes; the largest accepted is {max}")] + ModelTooLarge { size: usize, max: usize }, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index c7a0fd9831..9af83b0542 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -137,11 +137,11 @@ 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") { + 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 subtype == b"Stamp" { + 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; } @@ -151,7 +151,7 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { return true; } } - subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") + 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 { diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs new file mode 100644 index 0000000000..b50342898e --- /dev/null +++ b/crates/annot/src/model3d.rs @@ -0,0 +1,77 @@ +//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a +//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares. + +use super::lookup::{annots, page_ref}; +use super::threed::MAX_MODEL_BYTES; +use super::{AnnotError, Dict, Document, Object}; + +/// The format of a 3D stream, from its `/Subtype`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Model3dKind { + /// Product Representation Compact (ISO 14739-1). + Prc, + /// Universal 3D (ECMA-363). + U3d, +} + +/// A 3D annotation's model: its format and the decoded stream. +#[derive(Debug, Clone, PartialEq)] +pub struct Model3d { + pub kind: Model3dKind, + pub data: Vec, +} + +/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order. +/// +/// # Errors +/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`]. +pub fn model_3d_annotations(doc: &Document, page: usize) -> Result, AnnotError> { + let list = annots(doc, page_ref(doc, page)?); + Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect()) +} + +/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded. +/// +/// # Errors +/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`] +/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or +/// U3D, or decodes past [`MAX_MODEL_BYTES`]. +pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result { + let list = annots(doc, page_ref(doc, page)?); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1)); + if annot.name(b"Subtype") != Some(b"3D") { + return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at()))); + } + let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?; + let Object::Stream(s) = stream.as_ref() else { + return Err(AnnotError::Invalid(format!("{} has no 3D stream", at()))); + }; + let kind = match s.dict.name(b"Subtype") { + Some(b"PRC") => Model3dKind::Prc, + Some(b"U3D") => Model3dKind::U3d, + other => { + let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned()); + return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at()))); + } + }; + let data = doc + .with_direct_parms(s) + .decoded_within(MAX_MODEL_BYTES) + .map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?; + Ok(Model3d { kind, data }) +} + +/// What an annotation's `/3DD` names: the 3D stream itself, or the `/3D` entry of a 3D reference +/// dictionary. A reference is followed once; the caller refuses anything that is not a stream. +fn stream_of(doc: &Document, annot: &Dict) -> Option> { + let obj = doc.resolve(annot.get(b"3DD")?); + match obj.as_ref() { + Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)), + _ => Some(obj), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/annot/src/model3d/tests.rs b/crates/annot/src/model3d/tests.rs new file mode 100644 index 0000000000..90addb1bb6 --- /dev/null +++ b/crates/annot/src/model3d/tests.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use super::*; + +/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on. +fn doc(annots: &str, extra: &[Vec]) -> Document { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(), + ]; + objs.extend(extra.iter().cloned()); + 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()); + Document::open(Arc::new(out)).expect("opens") +} + +fn stream(dict: &str, body: &[u8]) -> Vec { + let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes(); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +fn annot(dd: &str) -> Vec { + format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes() +} + +#[test] +fn a_plain_3d_stream_is_read_through_its_filters() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]); + assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() })); +} + +#[test] +fn a_3dref_leads_to_the_stream_it_names() { + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); + assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec())); +} + +#[test] +fn a_u3d_stream_is_read_and_says_so() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]); + assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d)); +} + +#[test] +fn a_3d_annotation_without_a_stream_is_invalid() { + let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); +} + +#[test] +fn an_unknown_format_is_invalid() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)"))); +} + +#[test] +fn a_3dref_is_followed_once_and_only_to_a_stream() { + // A reference naming another reference, or one that names itself, is damage: no stream. + let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); + // The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key. + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); +} + +#[test] +fn another_annotation_is_not_a_3d_one() { + let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation"))); + assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 })); + assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1])); + assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1))); +} diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2e3ae0b7d0..a615b8e013 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,5 +1,5 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, + 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}; @@ -529,7 +529,33 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } 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) + 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. @@ -576,10 +602,17 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } } 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.max(0.0))); + 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 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a3dc14e262..2ff781a3a5 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2194,3 +2194,218 @@ 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:?}"); + } +} + +/// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's +/// catalog says so, and a newer one is left as it is. +#[test] +fn a_3d_model_raises_an_older_document_to_its_version() { + let catalog_version = |doc: &Document| { + let root = doc.root().unwrap(); + doc.get(root).as_dict().unwrap().name(b"Version").map(<[u8]>::to_vec) + }; + let add = |doc: &mut Document, model: &[u8]| { + let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" }; + add_3d_model(doc, &new, &meta("m")).unwrap(); + }; + let mut doc = fixture(); + add(&mut doc, b"U3D\0rest"); + assert_eq!(catalog_version(&reopen(&doc)), None, "a 1.7 file needs nothing for U3D"); + for (model, version) in [(&b"U3D\0rest"[..], &b"1.6"[..]), (b"PRC rest", b"1.7")] { + let mut doc = fixture(); + doc.set_version("1.4"); + add(&mut doc, model); + assert_eq!(catalog_version(&reopen(&doc)).as_deref(), Some(version)); + } + let mut doc = fixture(); + doc.set_version("2.0"); + add(&mut doc, b"PRC rest"); + assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is"); +} + +/// Before 2.0, PRC is an Adobe extension to PDF 1.7 (§7.12): a PRC model declares it, keeping +/// other extensions and a later ADBE level; U3D and a 2.0 file need nothing. +#[test] +fn a_prc_model_declares_the_adobe_extension_before_pdf_2() { + let adbe = |doc: &Document| { + let root = doc.root().unwrap(); + let ext = doc.get(root).as_dict().unwrap().get(b"Extensions").map(|e| doc.resolve(e).as_dict().unwrap().clone()); + ext.map(|e| { + let adbe = e.get(b"ADBE").map(|a| a.as_dict().unwrap().clone()); + (e.get(b"XYZ").is_some(), adbe.map(|a| (a.name(b"BaseVersion").map(<[u8]>::to_vec), a.get(b"ExtensionLevel").and_then(Object::as_int)))) + }) + }; + let add = |doc: &mut Document, model: &[u8]| { + let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" }; + add_3d_model(doc, &new, &meta("m")).unwrap(); + }; + let mut doc = fixture(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((false, Some((Some(b"1.7".to_vec()), Some(1)))))); + let mut doc = fixture(); + add(&mut doc, b"U3Drest"); + assert_eq!(adbe(&reopen(&doc)), None, "U3D is in ISO 32000-1"); + let mut doc = fixture(); + doc.set_version("2.0"); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), None, "PRC is in ISO 32000-2"); + + let mut doc = fixture(); + let root = doc.root().unwrap(); + let mut level3 = Dict::new(); + level3.set(b"BaseVersion".to_vec(), Object::name("1.7")); + level3.set(b"ExtensionLevel".to_vec(), Object::Int(3)); + let mut ext = Dict::new(); + ext.set(b"ADBE".to_vec(), Object::Dict(level3)); + ext.set(b"XYZ".to_vec(), Object::Dict(Dict::new())); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext.clone()))).unwrap(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(3))))), "a later level is kept"); + ext.remove(b"ADBE"); + let mut doc = fixture(); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext))).unwrap(); + add(&mut doc, b"PRC rest"); + assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(1))))), "other extensions are kept"); + + let mut doc = fixture(); + let mut ext = Dict::new(); + ext.set(b"ADBE".to_vec(), Object::Array(vec![Object::Dict(Dict::new())])); + let ext = doc.add(Object::Dict(ext)); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Ref(ext))).unwrap(); + add(&mut doc, b"PRC rest"); + let doc = reopen(&doc); + let ext = doc.resolve(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Extensions").unwrap()); + assert!(ext.as_dict().unwrap().get(b"ADBE").unwrap().as_array().is_some(), "an array of ADBE extensions is left alone"); +} + +#[test] +fn a_picture_is_placed_by_its_box_and_a_form_needs_a_positive_size() { + let at = [10.0, 20.0, 110.0, 70.0]; + assert_eq!(appearance::picture_place(at, true, None).as_deref(), Some("100 0 0 50 10 20 cm"), "an image fills its unit square"); + assert_eq!(appearance::picture_place(at, false, Some((200.0, 25.0))).as_deref(), Some("0.5 0 0 2 10 20 cm")); + for size in [None, Some((0.0, 25.0)), Some((200.0, -1.0))] { + assert_eq!(appearance::picture_place(at, false, size), None, "{size:?}"); + } +} + +/// The poster's box must lie in the rectangle, and a form poster needs a size to scale by. +#[test] +fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() { + let picture = |at: [f64; 4], size: (f64, f64)| PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size, at }; + for (poster, why) in [ + (picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"), + (picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"), + (picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"), + (picture([100.0 - 2e-7, 150.0, 100.0 - 1e-7, 200.0], (10.0, 10.0)), "lies outside within the rounding slack"), + ] { + let mut doc = fixture(); + let new = NewModel3d { + page: 0, + rect: [100.0, 100.0, 300.0, 300.0], + model: b"PRC rest", + activation: Activation3d::default(), + poster: Some(poster), + contents: "", + }; + assert!(matches!(add_3d_model(&mut doc, &new, &meta("m")), Err(AnnotError::Invalid(_))), "{why}"); + assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added"); + } +} + +/// A poster box fitted to the rectangle in floating point ends an ulp outside it: a 7×3 picture +/// centred in [0 100 3.6 110.8] spans -2.2e-16 to 3.6000000000000005. It is taken, inside the +/// rectangle. +#[test] +fn a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken() { + let mut doc = fixture(); + let at = [-2.220446049250313e-16, 104.62857142857143, 3.6000000000000005, 106.17142857142858]; + let poster = PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size: (7.0, 3.0), at }; + let new = NewModel3d { + page: 0, + rect: [0.0, 100.0, 3.6, 110.8], + model: b"PRC rest", + activation: Activation3d::default(), + poster: Some(poster), + contents: "", + }; + assert_eq!(add_3d_model(&mut doc, &new, &meta("m")), Ok(1)); +} + +#[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/threed.rs b/crates/annot/src/threed.rs new file mode 100644 index 0000000000..cf828388c0 --- /dev/null +++ b/crates/annot/src/threed.rs @@ -0,0 +1,292 @@ +//! 3D annotations (ISO 32000-2 §13.6.2): a U3D or PRC model shown in a rectangle on a page, +//! with a poster (`/AP /N`) for printing, for readers without 3D, and until it is activated. + +use super::create::{finite, normalize, num_array}; +use super::lookup::{annots, page_ref, set_annots}; +use super::types::{FLAG_PRINT, Meta}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, Stream, appearance, n}; + +/// The largest model accepted, in bytes: well past real U3D/PRC files, and bounded because the +/// model is held and compressed in memory. +pub const MAX_MODEL_BYTES: usize = 256 << 20; + +/// What the poster says when there is no picture. +const PLACEHOLDER_LABEL: &str = "3D model"; + +/// A 3D stream's data format (§13.6.3): ECMA-363 U3D or ISO 14739-1 PRC. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModelFormat { + U3d, + Prc, +} + +impl ModelFormat { + /// The format a model file's first bytes declare: U3D files start `U3D\0` (ECMA-363 file + /// header block type), PRC files start `PRC`. + /// + /// # Errors + /// + /// [`AnnotError::NotAModel`] for anything else. + pub fn detect(bytes: &[u8]) -> Result { + if bytes.starts_with(b"U3D\0") { + Ok(Self::U3d) + } else if bytes.starts_with(b"PRC") { + Ok(Self::Prc) + } else { + Err(AnnotError::NotAModel) + } + } + + /// The 3D stream's `/Subtype`. + pub fn subtype(self) -> &'static str { + match self { + Self::U3d => "U3D", + Self::Prc => "PRC", + } + } + + /// The PDF version a document holding it needs (§13.6): a 3D annotation is 1.6, a PRC stream + /// 1.7 under Adobe's extension, declared by [`add_3d_model`] (PDF 2.0 takes PRC as standard). + pub fn version(self) -> &'static str { + match self { + Self::U3d => "1.6", + Self::Prc => "1.7", + } + } +} + +/// When a reader turns the poster into the live model (§13.6.2, the activation dictionary's +/// `/A`). The standard's default is [`Self::Click`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Activation { + /// `/XA`: when the user clicks it; it prints as its poster. + #[default] + Click, + /// `/PO`: when the page opens. + PageOpen, + /// `/PV`: when the page becomes visible. + PageVisible, +} + +impl Activation { + /// Every activation, with the word a tool argument names it by. + pub const ALL: [(Self, &'static str); 3] = [(Self::Click, "click"), (Self::PageOpen, "page_open"), (Self::PageVisible, "page_visible")]; + + /// The `/A` name. + pub fn name(self) -> &'static str { + match self { + Self::Click => "XA", + Self::PageOpen => "PO", + Self::PageVisible => "PV", + } + } +} + +/// A picture already in the document (an image XObject, or a form XObject whose `/Matrix` maps it +/// to `size` points) drawn into `at`, a box inside the annotation's rectangle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PosterPicture { + pub xobject: ObjRef, + pub image: bool, + pub size: (f64, f64), + pub at: [f64; 4], +} + +/// A 3D model to add on `page` (0-based) in `rect` (user space). +#[derive(Clone, Debug, PartialEq)] +pub struct NewModel3d<'a> { + pub page: usize, + pub rect: [f64; 4], + pub model: &'a [u8], + pub activation: Activation, + /// The poster's picture; `None` draws a labelled frame. + pub poster: Option, + /// `/Contents`: the annotation's alternate description (§12.5.2). + pub contents: &'a str, +} + +/// Check a model before anything is added: its size and its format. +/// +/// # Errors +/// +/// [`AnnotError::ModelTooLarge`] past [`MAX_MODEL_BYTES`], else as [`ModelFormat::detect`]. +pub fn check_model(model: &[u8]) -> Result { + check_model_size(model, MAX_MODEL_BYTES)?; + ModelFormat::detect(model) +} + +fn check_model_size(model: &[u8], max: usize) -> Result<(), AnnotError> { + if model.len() > max { + return Err(AnnotError::ModelTooLarge { size: model.len(), max }); + } + Ok(()) +} + +/// Add a 3D annotation: the model in a Flate-compressed 3D stream referenced from `/3DD` (so the +/// annotation has its own instance, §13.6.2), `/3DA` stating only `/A`, and the poster as `/AP /N`. +/// No view (`/3DV`, `/VA`) is written: readers open on the model's own default view. A document +/// older than [`ModelFormat::version`] is raised to it. Returns its index in the page's `/Annots`. +/// +/// # Errors +/// +/// As [`check_model`]; [`AnnotError::NoSuchPage`]; [`AnnotError::Invalid`] for a rectangle with no +/// area or a poster box outside it. +pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Result { + let format = check_model(new.model)?; + let page = page_ref(doc, new.page)?; + let rect = normalize(new.rect); + if !finite(&new.rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle for the 3D model (each side at least 1 pt)".into())); + } + let poster = match &new.poster { + Some(p) => picture_poster(rect, p)?, + None => placeholder_poster(rect), + }; + let mut stream = Dict::new(); + stream.set(b"Type".to_vec(), Object::name("3D")); + stream.set(b"Subtype".to_vec(), Object::name(format.subtype())); + let stream = doc.add(Object::Stream(Stream::flate(stream, new.model))); + let poster = doc.add(Object::Stream(poster)); + let d = annotation_dict(new, rect, page, stream, poster, meta); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, page); + let index = list.len(); + list.push(Object::Ref(r)); + set_annots(doc, page, list)?; + at_least_version(doc, format.version())?; + if format == ModelFormat::Prc && !at_least(doc, "2.0") { + declare_prc_extension(doc)?; + } + Ok(index) +} + +/// Whether the header or the catalog `/Version` says `min` or later (§7.7.2). +fn at_least(doc: &Document, min: &str) -> bool { + let reached = |v: &[u8]| v.len() >= 3 && v >= min.as_bytes(); + let catalog = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned())); + let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); + reached(doc.version().as_bytes()) || catalog.as_deref().is_some_and(reached) +} + +/// PRC came before PDF 2.0 as an Adobe extension to 1.7: such a file declares +/// `/Extensions /ADBE` with ExtensionLevel 1 or later (§7.12). Other extensions and an ADBE entry +/// that already has a level are kept; one that is not a dictionary (an array of them) is left alone. +fn declare_prc_extension(doc: &mut Document) -> Result<(), AnnotError> { + let Some(root) = doc.root() else { return Ok(()) }; + let catalog = doc.get(root); + // `None` when present but not a dictionary; an empty one when absent. + let dict_or_new = |o: Option<&Object>| match o.map(|o| doc.resolve(o)) { + None => Some(Dict::new()), + Some(o) => o.as_dict().cloned(), + }; + let Some(mut extensions) = dict_or_new(catalog.as_dict().and_then(|c| c.get(b"Extensions"))) else { return Ok(()) }; + let Some(mut adbe) = dict_or_new(extensions.get(b"ADBE")) else { return Ok(()) }; + if adbe.get(b"ExtensionLevel").map(|l| doc.resolve(l)).and_then(|l| l.as_int()).is_some_and(|l| l >= 1) { + return Ok(()); + } + adbe.set(b"BaseVersion".to_vec(), Object::name("1.7")); + adbe.set(b"ExtensionLevel".to_vec(), Object::Int(1)); + extensions.set(b"ADBE".to_vec(), Object::Dict(adbe)); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(extensions)))?; + Ok(()) +} + +/// A document whose header and catalog `/Version` both say less than `min` gets `/Version min` in +/// its catalog (§7.7.2: the later of the two applies). +fn at_least_version(doc: &mut Document, min: &str) -> Result<(), AnnotError> { + let Some(root) = doc.root() else { return Ok(()) }; + if !at_least(doc, min) { + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?; + } + Ok(()) +} + +fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: ObjRef, poster: ObjRef, 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("3D")); + d.set(b"Rect".to_vec(), num_array(&rect)); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); + if !new.contents.is_empty() { + d.set(b"Contents".to_vec(), PdfString::text(new.contents)); + } + if !meta.id.is_empty() { + d.set(b"NM".to_vec(), PdfString::text(&meta.id)); + } + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + d.set(b"3DD".to_vec(), Object::Ref(stream)); + let mut activation = Dict::new(); + activation.set(b"A".to_vec(), Object::name(new.activation.name())); + d.set(b"3DA".to_vec(), Object::Dict(activation)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(poster)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + d +} + +/// The poster drawing `p` into its box, which must lie within `rect`. +fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result { + let at = normalize(p.at); + // A box fitted to `rect` in floating point can end a few ulps outside it: allow that, then clamp. + let slack = rect.iter().fold(1.0_f64, |m, v| m.max(v.abs())) * 1e-9; + let near = finite(&p.at) && at[0] >= rect[0] - slack && at[1] >= rect[1] - slack && at[2] <= rect[2] + slack && at[3] <= rect[3] + slack; + let at = [at[0].max(rect[0]), at[1].max(rect[1]), at[2].min(rect[2]), at[3].min(rect[3])]; + if !(near && at[2] > at[0] && at[3] > at[1]) { + return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into())); + } + let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?; + let mut xobjects = Dict::new(); + xobjects.set(b"Pic".to_vec(), Object::Ref(p.xobject)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + Ok(appearance::form(rect, format!("q {place} /Pic Do Q\n").as_bytes(), resources)) +} + +/// A light frame with "3D model" centred in it, shrunk to fit (left out when even 2 pt won't). +fn placeholder_poster(rect: [f64; 4]) -> Stream { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let mut c = + format!("0.94 g {} {} {} {} re f\n0.45 G 1 w {} {} {} {} re S\n", n(x0), n(y0), n(w), n(h), n(x0 + 0.5), n(y0 + 0.5), n(w - 1.0), n(h - 1.0)); + let mut resources = Dict::new(); + let unit = appearance::text_width(PLACEHOLDER_LABEL, 1.0); + let size = if unit > 0.0 { (w * 0.8 / unit).min(h * 0.4).min(14.0) } else { 0.0 }; + if size >= 2.0 { + let (tx, ty) = (x0 + (w - unit * size) / 2.0, y0 + (h - size * 0.7) / 2.0); + c.push_str(&format!("BT 0.3 g /Helv {} Tf {} {} Td ({PLACEHOLDER_LABEL}) Tj ET\n", n(size), n(tx), n(ty))); + resources.set(b"Font".to_vec(), appearance::helvetica_resource()); + } + appearance::form(rect, c.as_bytes(), resources) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn model_size_is_capped() { + assert_eq!(check_model_size(&[0; 8], 8), Ok(())); + assert_eq!(check_model_size(&[0; 9], 8), Err(AnnotError::ModelTooLarge { size: 9, max: 8 })); + } + + #[test] + fn the_format_is_read_from_the_header() { + assert_eq!(ModelFormat::detect(b"U3D\0rest"), Ok(ModelFormat::U3d)); + assert_eq!(ModelFormat::detect(b"PRC8800"), Ok(ModelFormat::Prc)); + for junk in [&b""[..], b"U3D", b"U3DX", b"PR", b"%PDF-1.7", b"prc"] { + assert_eq!(ModelFormat::detect(junk), Err(AnnotError::NotAModel), "{junk:?}"); + } + } + + #[test] + fn the_placeholder_label_shrinks_to_fit_and_is_dropped_when_it_cannot() { + let wide = String::from_utf8(placeholder_poster([0.0, 0.0, 300.0, 200.0]).decoded().unwrap().to_vec()).unwrap(); + assert!(wide.contains("/Helv 14 Tf") && wide.contains("(3D model) Tj"), "{wide}"); + let tiny = placeholder_poster([0.0, 0.0, 4.0, 4.0]); + assert!(!String::from_utf8(tiny.decoded().unwrap().to_vec()).unwrap().contains("Tj")); + assert!(tiny.dict.get(b"Resources").and_then(Object::as_dict).is_none_or(|r| r.get(b"Font").is_none())); + } +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index bd3409b5ae..522995b9f9 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -355,6 +355,10 @@ pub enum Shape { start: LineEnding, end: LineEnding, }, + /// Translucent freehand highlighting, stored as ink with a distinct intent. + FreehandHighlight { + strokes: Vec>, + }, /// Freehand strokes (Draw tool). Ink { strokes: Vec>, @@ -474,6 +478,7 @@ pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { } impl Shape { + /// Return the PDF `/Subtype` name for this annotation shape. pub fn subtype(&self) -> &'static str { match self { Shape::Note { .. } => "Text", @@ -481,7 +486,7 @@ impl Shape { Shape::Rectangle { .. } => "Square", Shape::Oval { .. } => "Circle", Shape::Line { .. } => "Line", - Shape::Ink { .. } | Shape::Signature { .. } => "Ink", + 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", @@ -610,6 +615,7 @@ impl Style { } 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), 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/Cargo.toml b/crates/automation/Cargo.toml index a6e43de1f0..a825b1fe52 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -14,6 +14,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-organize = { workspace = true } pdfcraft-platform = { workspace = true } +pdfcraft-prc = { workspace = true } +pdfcraft-prc-render = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" diff --git a/crates/automation/README.md b/crates/automation/README.md index 176946c175..d218fbcda6 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 @@ -199,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` @@ -232,6 +250,15 @@ choices and values over 4096 UTF-8 bytes return an error. Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. +## Rendering a 3D model + +`model_render` draws a page's 3D model (a 3D annotation's PRC stream, directly or through a 3D +reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1); +`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye +(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true. +The model is framed to fill the image and shaded by a headlight on white. U3D models are refused +with an error, and the annotation's own views, lighting and render modes are not applied yet. + ## Viewer navigation history A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index e52768d535..5ae3d4fe46 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -6,6 +6,13 @@ impl Automation { 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, rtf or md)")))?; + let html_mode = a.opt_str("html_mode")?.unwrap_or("rich"); + let format = match (format, html_mode) { + (pdfcraft_engine::compare::OfficeFormat::Html, "rich") => pdfcraft_engine::compare::OfficeFormat::Html, + (pdfcraft_engine::compare::OfficeFormat::Html, "simple") => pdfcraft_engine::compare::OfficeFormat::HtmlSimple, + (_, "rich") => format, + (_, _) => return Err(ToolError::InvalidArgs("html_mode is only available for HTML exports (rich or simple)".into())), + }; 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). @@ -15,7 +22,7 @@ impl Automation { 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" + "pictures past what one export may hold or decode, or 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) diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs new file mode 100644 index 0000000000..3cd1d7849e --- /dev/null +++ b/crates/automation/src/bates.rs @@ -0,0 +1,214 @@ +use std::io::Write; + +use pdfcraft_engine::{Edit, HeaderFooter}; +use serde_json::{Value, json}; + +use super::error::{Result, ToolError, failed}; +use super::json::Args; +use super::paths::read_input; +use super::{Arc, Automation, Path, PathBuf}; + +struct FilePlan { + input: String, + source: PathBuf, + password: Option, + output: PathBuf, + pages: usize, + first: u64, + last: u64, +} + +impl Automation { + pub(crate) fn doc_bates_multi(&mut self, args: &Args) -> Result { + let files = args.0.get("files").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("files must be an array".into()))?; + if files.is_empty() || files.len() > 1000 { + return Err(ToolError::InvalidArgs("files must contain 1 to 1000 entries".into())); + } + let start = match args.0.get("start_number") { + None | Some(Value::Null) => 1, + Some(value) => value + .as_u64() + .filter(|n| *n >= 1) + .ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?, + }; + let digits = args.opt_int("digits")?.unwrap_or(6); + let digits = + u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?; + let prefix = args.opt_str("prefix")?.unwrap_or(""); + let suffix = args.opt_str("suffix")?.unwrap_or(""); + // `#` separates the token's fields and `>>` ends it: either would cut the token short. + if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) { + return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into())); + } + let dry_run = args.opt_bool("dry_run")?.unwrap_or(false); + + // Resolve and reserve the complete path list before opening or writing anything. + let mut sources = Vec::with_capacity(files.len()); + let mut targets = Vec::with_capacity(files.len()); + let mut passwords = Vec::with_capacity(files.len()); + for entry in files { + let input = entry + .get("input") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty input path".into()))?; + let output = entry + .get("output") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty output path".into()))?; + let source = self.resolve(input, false)?; + let target = self.resolve(output, true)?; + if target.exists() { + return Err(failed(format!("output already exists; refusing to replace it: {output}"))); + } + let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {output}")))?; + if !parent.is_dir() { + return Err(failed(format!("output directory does not exist: {}", parent.display()))); + } + sources.push((input.to_string(), source)); + targets.push((output.to_string(), target)); + passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned)); + } + let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect(); + let mut output_paths = std::collections::HashSet::new(); + for (name, path) in &targets { + if input_paths.contains(path) { + return Err(failed(format!("output {name} names an input file; choose a separate output path"))); + } + if !output_paths.insert(path.clone()) { + return Err(failed(format!("two files use the same output path: {name}"))); + } + } + + let mut plans = Vec::with_capacity(files.len()); + let mut next = start; + let mut input_bytes = 0u64; + for (index, ((input_name, input_path), (_, output_path))) in sources.iter().zip(&targets).enumerate() { + let size = std::fs::metadata(input_path).map_err(failed)?.len(); + input_bytes = input_bytes + .checked_add(size) + .filter(|total| *total <= 512 * 1024 * 1024) + .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; + let bytes = read_input(input_path)?; + input_bytes = input_bytes + .saturating_sub(size) + .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) + .filter(|total| *total <= 512 * 1024 * 1024) + .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; + let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let password = passwords.get(index).and_then(Option::as_deref); + let id = self.session.open(display_name, Some(input_path.to_string_lossy().into_owned()), Arc::new(bytes), password).map_err(failed)?; + let pages = + self.session.get(id).map(|doc| doc.info.pages.len()).ok_or_else(|| failed("opened document disappeared during Bates preflight"))?; + self.session.close(id); + if pages == 0 { + return Err(failed(format!("{input_name} has no pages to number"))); + } + let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?; + let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?; + plans.push(FilePlan { + input: input_name.clone(), + source: input_path.clone(), + password: passwords.get(index).cloned().flatten(), + output: output_path.clone(), + pages, + first: next, + last, + }); + if index + 1 < sources.len() { + next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?; + } + } + + let intervals: Vec = plans + .iter() + .zip(&targets) + .map(|(p, (name, _))| json!({"input":p.input,"output":name,"pages":p.pages,"first":p.first,"last":p.last})) + .collect(); + if dry_run { + return Ok(json!({"dry_run":true,"files":intervals})); + } + + let mut staged = Vec::with_capacity(plans.len()); + let mut results = Vec::with_capacity(plans.len()); + let mut output_bytes = 0u64; + for plan in &plans { + let source_bytes = match read_input(&plan.source) { + Ok(bytes) => bytes, + Err(error) => { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + continue; + } + }; + let name = plan.source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = match self.session.open(name, Some(plan.source.to_string_lossy().into_owned()), Arc::new(source_bytes), plan.password.as_deref()) + { + Ok(id) => id, + Err(error) => { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + continue; + } + }; + if self.session.get(id).is_none_or(|doc| doc.info.pages.len() != plan.pages) { + self.session.close(id); + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"input changed after preflight; page count differs"})); + continue; + } + let template = format!("<>", plan.first); + let mut settings = HeaderFooter::default(); + settings.text[4] = template; + let pages: Vec = (0..plan.pages).collect(); + let applied = self.session.apply(id, Edit::AddHeaderFooter { pages, settings, replace: false }); + let result = applied.and_then(|()| self.session.save_full_bytes(id)); + match result { + Ok(bytes) => { + let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); + if total > 512 * 1024 * 1024 { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"})); + } else { + output_bytes = total; + staged.push((plan.output.clone(), bytes)); + } + } + Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})), + } + self.session.close(id); + } + for (path, bytes) in staged { + match publish_new(&path, &bytes) { + Ok(()) => { + let plan = plans.iter().find(|p| p.output == path); + if let Some(plan) = plan { + results.push(json!({"input":plan.input,"output":path,"status":"ok","first":plan.first,"last":plan.last,"pages":plan.pages})); + } + } + Err(error) => { + let plan = plans.iter().find(|p| p.output == path); + if let Some(plan) = plan { + results.push(json!({"input":plan.input,"output":path,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + } + } + } + } + results.sort_by_key(|result| plans.iter().position(|plan| result["output"] == json!(plan.output)).unwrap_or(usize::MAX)); + let completed = results.iter().filter(|r| r["status"] == "ok").count(); + let failed_count = results.len().saturating_sub(completed); + Ok(json!({"dry_run":false,"completed":completed,"failed":failed_count,"files":results})) + } +} + +/// Publish a PDF atomically while refusing a target created by another process after preflight. +fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(failed)?; + temp.write_all(bytes).map_err(failed)?; + temp.as_file().sync_all().map_err(failed)?; + temp.persist_noclobber(path).map(|_| ()).map_err(|error| { + if error.error.kind() == std::io::ErrorKind::AlreadyExists { + failed(format!("output appeared after preflight; refusing to replace it: {}", path.display())) + } else { + failed(error.error) + } + }) +} diff --git a/crates/automation/src/bookmark_io.rs b/crates/automation/src/bookmark_io.rs new file mode 100644 index 0000000000..2a06d912f4 --- /dev/null +++ b/crates/automation/src/bookmark_io.rs @@ -0,0 +1,209 @@ +//! Bookmark trees in and out of documents: `bookmark_export`, `bookmark_import`, `bookmark_copy`. +//! +//! The portable form is JSON: `{"bookmarks": [node, …]}`, where a node is `title`, `page` +//! (1-based, or `null` for one that goes nowhere), `view` (`{"type", "values"}`: a destination +//! type and its values in PDF user space, as the file stores them), `color`, `bold`, `italic`, +//! `open` and `children`. What export writes, import takes back unchanged. A file also carries +//! `"truncated": true` when the export left out bookmarks nested past the listing limit. +use crate::{Args, Automation, Result, ToolError, failed}; +use pdfcraft_engine::{DocId, Edit, OutlineNode}; +use serde_json::{Value, json}; + +/// Deepest nesting a bookmark tree may have (the listing reaches 33 levels). +const MAX_DEPTH: usize = 33; + +/// The keys a node may have: the ones [`node_json`] writes. +const NODE_KEYS: [&str; 8] = ["title", "page", "view", "color", "bold", "italic", "open", "children"]; + +impl Automation { + pub(crate) fn bookmark_export(&self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let path = a.opt_path("path")?.unwrap_or_default(); + let tree = self.outline_of(id, &path)?; + let mut out = json!({ "bookmarks": tree.nodes.iter().map(node_json).collect::>(), "truncated": tree.truncated }); + if let Some(file) = a.opt_str("out")? { + let target = self.resolve(file, true)?; + let mut saved = json!({ "bookmarks": out["bookmarks"] }); + if tree.truncated { + saved["truncated"] = json!(true); + } + let text = serde_json::to_vec_pretty(&saved).map_err(failed)?; + crate::write_atomic(&target, &text)?; + out["path"] = json!(target); + } + Ok(out) + } + + pub(crate) fn bookmark_import(&mut self, a: &Args) -> Result { + let (nodes, truncated) = match (a.get("bookmarks"), a.opt_str("file")?) { + (Some(list), None) => (nodes_of(list)?, false), + (None, Some(file)) => { + let path = self.resolve(file, false)?; + let value: Value = serde_json::from_slice(&crate::read_input(&path)?).map_err(|e| failed(format!("not a bookmark file: {e}")))?; + let list = value.get("bookmarks").ok_or_else(|| failed("not a bookmark file: it has no \"bookmarks\" list"))?; + (nodes_of(list)?, value.get("truncated") == Some(&Value::Bool(true))) + } + _ => return Err(ToolError::InvalidArgs("give either bookmarks or file".into())), + }; + let mut out = self.write_tree(a, nodes)?; + out["truncated"] = json!(truncated); + Ok(out) + } + + pub(crate) fn bookmark_copy(&mut self, a: &Args) -> Result { + let from = a.int("from")?; + let from = DocId(u64::try_from(from).map_err(|_| ToolError::InvalidArgs("from must be positive".into()))?); + let path = a.opt_path("path")?.unwrap_or_default(); + let tree = self.outline_of(from, &path)?; + let mut out = self.write_tree(a, tree.nodes)?; + out["truncated"] = json!(tree.truncated); + Ok(out) + } + + fn outline_of(&self, id: DocId, path: &[usize]) -> Result { + match self.session.outline_tree(id, path) { + // The engine names the path 0-based; the caller gave it 1-based. + Some(tree) => tree.map_err(|e| match e { + pdfcraft_organize::OutlineError::NoSuchBookmark(_) => failed(format!("there is no bookmark at {:?}", one_based(path))), + e => failed(e), + }), + None => Err(failed(format!("document {} is not open, or its bookmarks can't be read for editing", id.0))), + } + } + + /// Write `nodes` into `doc` under `parent` at `position` (or in place of the outline with + /// `replace`), and say which of them went nowhere because the document lacks their page. + fn write_tree(&mut self, a: &Args, bookmarks: Vec) -> Result { + let parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| usize::try_from(p.max(1) - 1).unwrap_or(usize::MAX)); + let replace = a.opt_bool("replace")?.unwrap_or(false); + let count = self.doc(a)?.info.pages.len(); + let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, count); + let mut out = self.apply(a, Edit::ImportBookmarks { parent, index, bookmarks, replace })?; + out["unmapped"] = json!(unmapped.iter().map(|p| one_based(p)).collect::>()); + Ok(out) + } +} + +fn one_based(path: &[usize]) -> Vec { + path.iter().map(|i| i.saturating_add(1)).collect() +} + +/// A node as JSON, leaving out what is at its default (no view, black, regular, no children). +fn node_json(n: &OutlineNode) -> Value { + let mut v = json!({ "title": n.title, "page": n.page.map(|p| p.saturating_add(1)), "open": n.open }); + if let Some((kind, values)) = &n.view { + v["view"] = json!({ "type": String::from_utf8_lossy(kind), "values": values }); + } + if let Some(c) = n.color { + v["color"] = json!(c); + } + if n.bold { + v["bold"] = json!(true); + } + if n.italic { + v["italic"] = json!(true); + } + if !n.children.is_empty() { + v["children"] = json!(n.children.iter().map(node_json).collect::>()); + } + v +} + +fn nodes_of(list: &Value) -> Result> { + let mut count = 0; + list_of(list, &[], &mut count) +} + +/// The nodes of one level; `at` is the 1-based path of their parent, for messages. +fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result> { + let items = list.as_array().ok_or_else(|| bad(at, "children must be a list of bookmarks"))?; + if at.len() >= MAX_DEPTH && !items.is_empty() { + return Err(bad(at, &format!("bookmarks may nest at most {MAX_DEPTH} levels deep"))); + } + *count = count.saturating_add(items.len()); + if *count > pdfcraft_engine::MAX_IMPORT_BOOKMARKS { + return Err(ToolError::InvalidArgs(format!("at most {} bookmarks can be imported at once", pdfcraft_engine::MAX_IMPORT_BOOKMARKS))); + } + let mut out = Vec::with_capacity(items.len()); + for (i, item) in items.iter().enumerate() { + let mut path = at.to_vec(); + path.push(i.saturating_add(1)); + out.push(node_of(item, &path, count)?); + } + Ok(out) +} + +/// One node and, through [`list_of`], its children (recursion bounded by `MAX_DEPTH`). +fn node_of(v: &Value, at: &[usize], count: &mut usize) -> Result { + let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?; + if let Some(k) = o.keys().find(|k| !NODE_KEYS.contains(&k.as_str())) { + return Err(bad(at, &format!("unknown key {k:?} (a bookmark has {})", NODE_KEYS.join(", ")))); + } + let field = |k: &str| o.get(k).filter(|x| !x.is_null()); + // An empty title is kept: files have them, and export writes them as "". + let title = field("title").and_then(Value::as_str).ok_or_else(|| bad(at, "needs a title"))?.to_string(); + let page = match field("page") { + None => None, + Some(p) => Some(p.as_u64().filter(|p| *p >= 1).and_then(|p| usize::try_from(p - 1).ok()).ok_or_else(|| bad(at, "page must be 1 or more"))?), + }; + let flag = |k: &str| field(k).map_or(Ok(None), |x| x.as_bool().map(Some).ok_or_else(|| bad(at, &format!("{k} must be true or false")))); + let color = match field("color") { + None => None, + Some(c) => { + let rgb: Option> = c.as_array().and_then(|c| c.iter().map(|x| x.as_f64().map(|x| x as f32)).collect()); + match rgb.as_deref() { + Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), + _ => return Err(bad(at, "color must be [r, g, b], each from 0 to 1")), + } + } + }; + let children = match field("children") { + None => Vec::new(), + Some(list) => list_of(list, at, count)?, + }; + Ok(OutlineNode { + title, + page, + view: field("view").map(|x| view_of(x, at)).transpose()?, + color, + italic: flag("italic")?.unwrap_or(false), + bold: flag("bold")?.unwrap_or(false), + open: flag("open")?.unwrap_or(true), + children, + }) +} + +/// `{"type": "XYZ", "values": [left, top, zoom]}`: a destination type with exactly its values. +fn view_of(v: &Value, at: &[usize]) -> Result<(Vec, Vec>)> { + let kind = v.get("type").and_then(Value::as_str).ok_or_else(|| bad(at, "view needs a type such as XYZ or Fit"))?; + let values = match v.get("values").filter(|x| !x.is_null()) { + None => Vec::new(), + Some(list) => { + let list = list.as_array().filter(|l| l.len() <= 4).ok_or_else(|| bad(at, "view values must be a list of at most 4 numbers or nulls"))?; + list.iter() + .map(|x| match x { + Value::Null => Ok(None), + x => x.as_f64().map(Some).ok_or_else(|| bad(at, "view values must be numbers or null")), + }) + .collect::>()? + } + }; + let arity = pdfcraft_engine::outline_view_arity(kind.as_bytes()).ok_or_else(|| bad(at, VIEW_TYPES))?; + if values.len() != arity { + return Err(bad(at, &format!("a {kind} view takes {arity} values (numbers, or null to keep the reader's)"))); + } + if kind == "FitR" && values.iter().any(Option::is_none) { + return Err(bad(at, "a FitR view needs all four values: left, bottom, right, top")); + } + Ok((kind.as_bytes().to_vec(), values)) +} + +const VIEW_TYPES: &str = "view type must be XYZ (3 values: left, top, zoom), Fit, FitB (none), FitH, FitV, FitBH, FitBV (1) or FitR (4)"; + +fn bad(at: &[usize], what: &str) -> ToolError { + if at.is_empty() { + return ToolError::InvalidArgs(format!("bookmarks: {what}")); + } + ToolError::InvalidArgs(format!("bookmark {at:?}: {what}")) +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..135d0868ee 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -75,7 +75,7 @@ fn to_user(p: &PageInfo, x: f64, y: f64) -> [f64; 2] { [ux as f64, uy as f64] } -fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { +pub(crate) fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { let (a, b) = (to_user(p, r[0], r[1]), to_user(p, r[2], r[3])); [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] } @@ -183,6 +183,7 @@ impl Automation { "page": c.page + 1, "index": c.index + 1, "type": c.subtype, + "intent": c.intent, "author": c.author, "contents": c.contents, "modified": c.modified, @@ -328,7 +329,7 @@ impl Automation { let [start, end] = ending_pair(kind, endings.as_deref())?; Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end } } - "ink" => { + "ink" | "freehand_highlight" => { let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into()); let strokes = a.get("strokes").ok_or_else(|| ToolError::InvalidArgs("ink needs `strokes`".into()))?; let strokes = strokes @@ -346,7 +347,7 @@ impl Automation { .collect::>>() }) .collect::>>()?; - Shape::Ink { strokes } + if kind == "freehand_highlight" { Shape::FreehandHighlight { strokes } } else { Shape::Ink { strokes } } } "polygon" | "cloud" | "polyline" => { let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points")); @@ -538,8 +539,10 @@ impl Automation { return self.apply(a, edit); } let text_at = |t: &str| { - let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0); - let h = size * 1.2 + 6.0; + let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max); + let w = (longest + 8.0).clamp(20.0, 600.0); + let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); + let h = rows as f64 * size * 1.2 + 6.0; let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); Shape::Typewriter { rect, font_size: size } }; diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 7518072d1c..6b25c31f61 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -83,10 +83,10 @@ pub(crate) fn parse_align(al: &str) -> Result { /// Most entries `aligns` takes: one per paragraph, and no text has this many. const MAX_ALIGNS: usize = 4096; -/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`. +/// `aligns`: each paragraph's (line's) own alignment, or inherit/null where it follows `align`. pub(crate) fn opt_aligns(a: &Args) -> Result>>> { let Some(v) = a.get("aligns") else { return Ok(None) }; - let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?; + let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify, inherit or null"))?; if items.len() > MAX_ALIGNS { return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries"))); } @@ -94,8 +94,9 @@ pub(crate) fn opt_aligns(a: &Args) -> Result>>> { .iter() .map(|v| match v { Value::Null => Ok(None), + Value::String(s) if s == "inherit" => Ok(None), Value::String(s) => parse_align(s).map(Some), - _ => Err(bad("each entry of aligns is left, center, right, justify or null")), + _ => Err(bad("each entry of aligns is left, center, right, justify, inherit or null")), }) .collect::>>() .map(Some) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 36e0d00454..988461eac0 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -12,6 +12,35 @@ pub enum Content { Png { data: Vec, width: u32, height: u32 }, } +/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point +/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is +/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by +/// an affine map, so three corners of the crop box pin the result down. +fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> { + let [a, b, c, d, e, f] = m; + let det = a * d - b * c; + if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 { + return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into())); + } + let moved = |x: f32, y: f32| { + let [vx, vy] = info.user_to_view(x, y).map(f64::from); + info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from) + }; + let [x0, y0, x1, y1] = info.crop; + let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0)); + if w.abs() < 1e-3 || h.abs() < 1e-3 { + return Err(ToolError::InvalidArgs("the page has no area to transform in".into())); + } + let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1)); + let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h); + let (x0, y0) = (f64::from(x0), f64::from(y0)); + let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0]; + if !user.iter().all(|v| v.is_finite()) { + return Err(ToolError::InvalidArgs("the transform moves the image too far".into())); + } + Ok(user) +} + impl Automation { /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). pub fn call(&mut self, name: &str, args: &Value) -> Result> { @@ -24,6 +53,7 @@ impl Automation { tools::check_args(def, args)?; let a = Args(args); let out = match name { + "doc_bates_multi" => self.doc_bates_multi(&a)?, "doc_open" => self.doc_open(&a)?, "doc_batch" => self.doc_batch(&a)?, "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), @@ -39,6 +69,7 @@ impl Automation { self.apply(&a, edit)? } "page_render" => return self.page_render(&a), + "model_render" => return self.model_render(&a), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, @@ -166,6 +197,9 @@ impl Automation { out } "bookmark_list" => self.bookmark_listing(&a)?, + "bookmark_export" => self.bookmark_export(&a)?, + "bookmark_import" => self.bookmark_import(&a)?, + "bookmark_copy" => self.bookmark_copy(&a)?, "bookmark_add" => { let page = self.page(&a)?; let parent = a.opt_path("parent")?.unwrap_or_default(); @@ -294,13 +328,13 @@ impl Automation { let id = self.doc(&a)?.id; let before = self.doc(&a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; - let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; + let (bytes, merged, images_refused) = self.session.reduced_bytes(id).map_err(failed)?; // As in the app, a copy that isn't smaller is not written (#490). let written = bytes.len() < before; if written { write_atomic(&path, &bytes)?; } - json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged, "images_refused": images_refused }) } "doc_optimize" => self.doc_optimize(&a)?, "doc_initial_view" => self.doc_initial_view(&a)?, @@ -357,7 +391,7 @@ impl Automation { let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] }); - json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) + json!({ "visible_rect": visible, "has_editor_crop": im.has_editor_crop(), "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) }) .collect(); json!({ "page": page + 1, "count": list.len(), "images": list }) @@ -395,6 +429,16 @@ impl Automation { let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) } + "transform" => { + let m: Vec = a + .get("matrix") + .and_then(Value::as_array) + .and_then(|x| x.iter().map(Value::as_f64).collect()) + .ok_or_else(|| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?; + let m = <[f64; 6]>::try_from(m) + .map_err(|_| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?; + ImageEdit::Transform(view_transform_to_user(&self.doc(&a)?.info.pages[page], m)?) + } "rotate_by" => ImageEdit::RotateBy( a.get("degrees") .and_then(Value::as_f64) @@ -410,9 +454,18 @@ impl Automation { let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) } - "flip_horizontal" => ImageEdit::Flip { horizontal: true }, - "flip_vertical" => ImageEdit::Flip { horizontal: false }, + action @ ("flip_horizontal" | "flip_vertical") => { + let horizontal = action == "flip_horizontal"; + let info = self.doc(&a)?.info.pages.get(page).ok_or_else(|| ToolError::InvalidArgs("the page does not exist".into()))?; + // The tool names displayed axes; the engine names PDF user-space axes. + ImageEdit::Flip { horizontal: horizontal ^ (info.rotation % 180 == 90) } + } "delete" => ImageEdit::Delete, + "bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront), + "send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack), + "bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward), + "send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward), + "remove_crop" => ImageEdit::RemoveCrop, "replace" => { let path = self.resolve(a.str("path")?, false)?; let bytes = read_input(&path)?; @@ -624,6 +677,7 @@ impl Automation { } out } + "text_script" => self.text_script(&a)?, "doc_audit_space" => { let rows: Vec = self .doc(&a)? @@ -719,6 +773,7 @@ impl Automation { "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, "stamp_custom" => self.stamp_custom(&a)?, + "model_add" => self.model_add(&a)?, "comment_reply" => self.comment_reply(&a)?, "comment_set_status" => self.comment_set_status(&a)?, "sign_list" => self.sign_list(&a)?, @@ -815,4 +870,52 @@ impl Automation { }; Ok(vec![Content::Json(out)]) } + + /// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range + /// of its editor text, written as `text_edit` writes the paragraph. + fn text_script(&mut self, a: &Args) -> Result { + use pdfcraft_engine::text_script::{Script, toggle}; + let name = a.str("script")?; + let script = match name { + "superscript" => Script::Super, + "subscript" => Script::Sub, + other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))), + }; + let page = self.page(a)?; + let k = a.int("paragraph")?; + let text = { + let doc = self.doc(a)?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))? + }; + let range = match a.opt_ints("range")? { + None => None, + Some(r) => { + let bounds = match r.as_slice() { + [start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()), + _ => None, + }; + let range = bounds + .map(|(start, end)| start..end) + .filter(|r| r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end)); + let Some(range) = range else { + return Err(ToolError::InvalidArgs(format!( + "range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries", + text.len() + ))); + }; + Some(range) + } + }; + let (toggled, _) = toggle(&text, range.as_ref(), script) + .ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?; + let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled }); + let mut out = match self.call("text_edit", &args)?.into_iter().next() { + Some(Content::Json(out)) => out, + _ => json!({}), + }; + out["script"] = json!(name); + Ok(out) + } } diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 859c92da48..979b041a73 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -410,6 +410,7 @@ impl Automation { "images": o.images, "images_resampled": o.images_resampled, "images_recompressed": o.images_recompressed, + "images_refused": o.images_refused, "image_bytes_before": o.image_bytes_before, "image_bytes_after": o.image_bytes_after, "thumbnails": o.thumbnails, diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 067a593888..dab2760f5d 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -394,8 +394,18 @@ mod tests { // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its // inputs and out_dir, walks only inside them (skipping links) and resolves every // discovered file again before `process` reads it or `publish` writes it. - const RESOLVED_BY_CALLER: &[&str] = - &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"]; + const RESOLVED_BY_CALLER: &[&str] = &[ + "write_atomic", + "write_atomic_with", + "read_input", + "read_input_bytes", + "read_model", + "read_review_sidecar", + "process", + "linked", + "walk", + "publish", + ]; let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut files = vec![]; while let Some(dir) = dirs.pop() { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1910867c9..520d836b01 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -16,6 +16,7 @@ mod a11y; mod batch; +mod bates; mod comments; mod content; mod conventions; @@ -26,6 +27,8 @@ mod links; #[cfg(feature = "mcp")] pub mod mcp; mod measure; +mod model3d; +mod model_render; mod printing; mod reading; mod redact; @@ -49,6 +52,7 @@ use file_access::foreign_share; use text_cache::TextCache; mod assemble; +mod bookmark_io; mod dispatch; mod documents; mod error; diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 84cc21b258..b033871867 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -557,7 +557,7 @@ fn tool_json(t: &crate::ToolDef) -> Value { "title": t.title, "description": t.description, "inputSchema": t.input_schema, - "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save"), "openWorldHint": false }, + "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save" | "bookmark_export"), "openWorldHint": false }, }) } diff --git a/crates/automation/src/model3d.rs b/crates/automation/src/model3d.rs new file mode 100644 index 0000000000..2d89e4ae60 --- /dev/null +++ b/crates/automation/src/model3d.rs @@ -0,0 +1,114 @@ +//! 3D model tools (Acrobat: Rich Media ▸ Add 3D, AGENTS.md §3). + +use std::path::Path; + +use pdfcraft_engine::{Activation3d, Edit, MAX_3D_MODEL_BYTES, MarkFile, Model3dFormat}; +use serde_json::Value; + +use crate::comments::rect_to_user; +use crate::{Args, Automation, Result, ToolError, failed}; + +/// The largest side of a 3D model's box, in points: the largest page PDF allows (ISO 32000-2 Annex C). +const MAX_MODEL_SIDE: f64 = 14_400.0; + +impl Automation { + /// A U3D or PRC model in `rect` on `page`, its poster the picture at `poster` (a PDF page or an + /// image) fitted inside the box, or a labelled frame. + pub(crate) fn model_add(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("page {} does not exist", page + 1)))?; + let r = a.nums::<4>("rect")?.ok_or_else(|| ToolError::InvalidArgs("missing argument rect".into()))?; + let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs()); + if !((1.0..=MAX_MODEL_SIDE).contains(&w) && (1.0..=MAX_MODEL_SIDE).contains(&h)) { + return Err(ToolError::InvalidArgs(format!( + "rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_MODEL_SIDE} pt" + ))); + } + let activation = match a.opt_str("activation")? { + None => Activation3d::default(), + Some(word) => Activation3d::ALL + .iter() + .find(|(_, w)| *w == word) + .map(|(act, _)| *act) + .ok_or_else(|| ToolError::InvalidArgs(format!("activation must be one of: {}", Activation3d::ALL.map(|(_, w)| w).join(", "))))?, + }; + let path = self.resolve(a.str("path")?, false)?; + let model = read_model(&path, MAX_3D_MODEL_BYTES)?; + let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); + let poster = match a.opt_str("poster")? { + None => None, + Some(p) => { + let path = self.resolve(p, false)?; + let bytes = crate::read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let page = usize::try_from(a.opt_int("poster_page")?.unwrap_or(1).max(1)).unwrap_or(1).saturating_sub(1); + Some(MarkFile { name, bytes: std::sync::Arc::new(bytes), page }) + } + }; + let edit = Edit::AddModel3d { page, rect: rect_to_user(&info, r), model: std::sync::Arc::new(model), poster, activation, name }; + self.apply(a, edit) + } +} + +/// Read a resolved model file of at most `max` bytes. Its size (from the file system) and its first +/// bytes are checked before the rest is read, so a large file that is not a model is never loaded. +fn read_model(path: &Path, max: usize) -> Result> { + use std::io::Read as _; + let io = |e: std::io::Error| failed(format!("{}: {e}", path.display())); + let too_large = |size: u64| failed(format!("the 3D model is {size} bytes; the largest accepted is {max}")); + let cap = u64::try_from(max).unwrap_or(u64::MAX); + let mut file = std::fs::File::open(path).map_err(io)?; + if let Ok(meta) = file.metadata() + && meta.is_file() + && meta.len() > cap + { + return Err(too_large(meta.len())); + } + let mut bytes = Vec::new(); + (&mut file).take(4).read_to_end(&mut bytes).map_err(io)?; + Model3dFormat::detect(&bytes).map_err(failed)?; + let rest = cap.saturating_add(1).saturating_sub(bytes.len() as u64); + file.take(rest).read_to_end(&mut bytes).map_err(io)?; + if bytes.len() > max { + return Err(failed(format!("the 3D model is more than {max} bytes, the largest accepted"))); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path + } + + #[test] + fn a_model_is_read_whole_when_it_fits() { + let dir = tempfile::tempdir().unwrap(); + let mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[7; 40]); + assert_eq!(read_model(&file(dir.path(), "m.prc", &prc), 64).unwrap(), prc); + assert_eq!(read_model(&file(dir.path(), "m.u3d", b"U3D\0x"), 6).unwrap(), b"U3D\0x"); + } + + #[test] + fn a_model_past_the_cap_is_refused_by_its_size_before_it_is_read() { + let dir = tempfile::tempdir().unwrap(); + let path = file(dir.path(), "big.prc", &[b'P', b'R', b'C', 0, 0, 0, 0, 0, 0, 0]); + let err = read_model(&path, 4).unwrap_err().to_string(); + assert!(err.contains("is 10 bytes") && err.contains("largest accepted is 4"), "the file's own size, from the file system: {err}"); + } + + #[test] + fn a_file_that_is_not_a_model_is_refused_by_its_header() { + let dir = tempfile::tempdir().unwrap(); + for junk in [&b"%PDF-1.7 a page"[..], b"PR", b"", b"U3DX and more"] { + let err = read_model(&file(dir.path(), "junk.bin", junk), 64).unwrap_err().to_string(); + assert!(err.contains("not a 3D model"), "{junk:?}: {err}"); + } + } +} diff --git a/crates/automation/src/model_render.rs b/crates/automation/src/model_render.rs new file mode 100644 index 0000000000..dbfabc6708 --- /dev/null +++ b/crates/automation/src/model_render.rs @@ -0,0 +1,104 @@ +//! `model_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer. + +use pdfcraft_engine::model3d::Model3dKind; +use pdfcraft_prc::PrcFile; +use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render}; + +use super::Automation; +use super::dispatch::Content; +use super::error::{Result, ToolError, failed}; +use super::json::Args; +use super::pages::encode_png; + +/// Default and largest side of the image, in pixels. +pub(crate) const DEFAULT_SIDE: u32 = 512; +pub(crate) const MAX_SIDE: u32 = 4096; + +/// The default view: from the front, right and above, as an isometric view looks. +const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0]; + +impl Automation { + pub(crate) fn model_render(&mut self, a: &Args) -> Result> { + let page = self.page(a)?; + let width = side(a, "width")?; + let height = side(a, "height")?; + let direction = direction(a)?; + let perspective = a.opt_bool("perspective")?.unwrap_or(true); + let doc = self.doc(a)?; + let found = doc.model_3d_annotations(page).map_err(failed)?; + let index = match a.opt_int("model")? { + None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?, + Some(n) => { + let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?; + *found + .get(pos) + .ok_or_else(|| ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())))? + } + }; + let model = doc.model_3d(page, index).map_err(failed)?; + if model.kind == Model3dKind::U3d { + return Err(failed("this 3D model is in U3D, which can't be drawn yet; only PRC models can")); + } + let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?; + let meshes = assemble(&prc).map_err(failed)?; + let aspect = f64::from(width) / f64::from(height); + let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?; + let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?; + let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?; + Ok(vec![Content::Png { data, width: image.width, height: image.height }]) + } +} + +fn side(a: &Args, key: &str) -> Result { + match a.opt_int(key)? { + None => Ok(DEFAULT_SIDE), + Some(n) => u32::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)).ok_or_else(|| Args::wrong(key, &format!("between 1 and {MAX_SIDE}"))), + } +} + +/// `direction`: three finite numbers, not all zero, from the eye towards the model. +fn direction(a: &Args) -> Result<[f64; 3]> { + let Some(v) = a.get("direction") else { return Ok(DEFAULT_DIRECTION) }; + let wrong = || Args::wrong("direction", "three numbers [x, y, z], not all zero"); + let items = v.as_array().filter(|x| x.len() == 3).ok_or_else(wrong)?; + let mut out = [0.0; 3]; + for (o, x) in out.iter_mut().zip(items) { + *o = x.as_f64().filter(|f| f.is_finite()).ok_or_else(wrong)?; + } + if out.iter().all(|c| *c == 0.0) { + return Err(wrong()); + } + Ok(out) +} + +/// Z is up, as CAD models are drawn, unless the view looks along it. +fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] { + if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] } +} + +/// Straight RGBA to the premultiplied RGBA `encode_png` takes. +fn premultiplied(mut rgba: Vec) -> Vec { + for [r, g, b, alpha] in rgba.as_chunks_mut::<4>().0 { + let a = u16::from(*alpha); + for c in [r, g, b] { + *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX); + } + } + rgba +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn premultiplying_scales_colour_by_alpha() { + assert_eq!(premultiplied(vec![200, 100, 0, 255, 200, 100, 0, 128, 9, 9, 9, 0]), vec![200, 100, 0, 255, 100, 50, 0, 128, 0, 0, 0, 0]); + } + + #[test] + fn a_view_along_z_keeps_y_up() { + assert_eq!(up_for([0.0, 0.0, -1.0]), [0.0, 1.0, 0.0]); + assert_eq!(up_for(DEFAULT_DIRECTION), [0.0, 0.0, 1.0]); + } +} diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index e662bcac19..a146f3f8ff 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -201,8 +201,8 @@ impl Automation { } pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result { - let edit = match a.get("categories") { - None => Edit::Sanitize, + let which = match a.get("categories") { + None => None, Some(_) => { let which = a .strs("categories")? @@ -212,10 +212,24 @@ impl Automation { if which.is_empty() { return Err(ToolError::InvalidArgs("categories is empty".into())); } + Some(which) + } + }; + // One scan, for this check and for what was removed. The removal refuses when it counts + // every listed category as 0 (an AcroForm with no fields counts 1), with an error about + // redaction marks; this reads the same scan, so it refuses exactly then. A document that + // can't be edited has no scan: `apply` says why. + let info = self.doc(a)?.hidden_info(); + let edit = match which { + None => Edit::Sanitize, + Some(which) => { + if !info.is_empty() && which.iter().all(|h| info.iter().find(|c| c.0 == *h).is_none_or(|c| c.1 == 0)) { + return Err(failed("nothing to remove: the listed categories hold nothing (see doc_hidden_info)")); + } Edit::RemoveHidden { which } } }; - let before: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); + let before: usize = info.iter().map(|c| c.1).sum(); let mut out = self.apply(a, edit)?; let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); out["removed"] = json!(before.saturating_sub(after)); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..f6afb7966a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -121,6 +121,14 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ + t("doc_bates_multi", "Add Bates numbering across files", "Apply one consecutive Bates sequence to an ordered list of PDFs. Each file entry has input and output paths and an optional open or permissions password. The operation first validates every input/output and computes inclusive numeric intervals; dry_run returns that allocation without writing. Existing output files are never replaced. On application, files that cannot be edited or saved are reported individually and later files continue.") + .destructive() + .with(schema(json!({ + "files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}}, + "start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."}, + "prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."}, + "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."} + }), &["files"])), t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, @@ -173,9 +181,16 @@ pub fn tools() -> Vec { "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), + t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") + .ro() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 }, + "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }, + "perspective": { "type": "boolean" } + }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), - t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.") + t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}, "objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{ "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, @@ -315,6 +330,50 @@ pub fn tools() -> Vec { }), &["doc", "title", "page"], )), + t( + "bookmark_export", + "Export bookmarks", + "The bookmark tree, or the subtree at path, as a list of nodes bookmark_import takes back unchanged. A node has title, page (1-based, null when it goes nowhere), open, and when set view ({type, values}: a destination type from ISO 32000-2 §12.3.2.2 Table 149 and its values in PDF user space, as the file stores them), color ([r, g, b], 0-1), bold, italic and children. truncated is true when bookmarks nested deeper than 33 levels were left out. With out, the tree is also written there as a JSON file {\"bookmarks\": [...]}, with \"truncated\": true when that is so; out is overwritten.", + ) + .destructive() + .with(schema( + json!({ "doc": doc(), "path": path("The bookmark whose subtree to export (omit for the whole outline)"), "out": path_arg() }), + &["doc"], + )), + t( + "bookmark_import", + "Import bookmarks", + "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing; a key a node does not have is refused. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.", + ) + .destructive() + .with(schema( + json!({ + "doc": doc(), + "bookmarks": { "type": "array", "items": { "type": "object" }, "minItems": 1, "description": "Nodes as bookmark_export returns them." }, + "file": path_arg(), + "parent": path("Parent bookmark (omit for the top level)"), + "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." }, + "replace": { "type": "boolean", "description": "Replace the document's bookmarks instead of adding to them (default false; leave parent out)." } + }), + &["doc"], + )), + t( + "bookmark_copy", + "Copy bookmarks from another document", + "Copy the bookmarks of document from (or the subtree at path) into doc, by page number: under a parent bookmark (or at the top level) at a position, or with replace in place of doc's outline. A bookmark whose page doc lacks is kept without a destination and listed in unmapped (1-based paths into the copied tree). truncated is true when bookmarks nested deeper than 33 levels were left out of the copy. Undoable.", + ) + .destructive() + .with(schema( + json!({ + "doc": doc(), + "from": { "type": "integer", "minimum": 1, "description": "Id of the document to copy from." }, + "path": path("The bookmark whose subtree to copy (omit for the whole outline)"), + "parent": path("Parent bookmark in doc (omit for the top level)"), + "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." }, + "replace": { "type": "boolean", "description": "Replace doc's bookmarks instead of adding to them (default false; leave parent out)." } + }), + &["doc", "from"], + )), t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.") .with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "title": { "type": "string", "minLength": 1 } }), &["doc", "path", "title"])), t("bookmark_delete", "Delete a bookmark", "Delete a bookmark and the bookmarks under it. Undoable.") @@ -461,6 +520,20 @@ pub fn tools() -> Vec { }), &["doc", "page", "path"], )), + t("model_add", "Add a 3D model", "Add a 3D model (Acrobat: Rich Media ▸ Add 3D): the U3D or PRC file at path, recognised by its first bytes, as a 3D annotation in rect [x0, y0, x1, y1] (points from the top-left of the displayed page; each side 1–14400 pt; at most 256 MiB). Its poster, what prints and what readers show until the model is activated, is the picture at poster (a PDF page, poster_page default 1, or an image) fitted inside rect keeping its shape and centred; without poster, a frame labelled \"3D model\". activation: click (default), page_open or page_visible. name is its description (default: the file name). No views are added: readers open the model on its own default view. Undoable.") + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "path": { "type": "string" }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "poster": { "type": "string" }, + "poster_page": { "type": "integer", "minimum": 1 }, + "activation": { "type": "string", "enum": ["click", "page_open", "page_visible"] }, + "name": { "type": "string" }, + }), + &["doc", "page", "path", "rect"], + )), t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.") .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), t( @@ -562,7 +635,7 @@ pub fn tools() -> Vec { t( "doc_remove_hidden", "Remove hidden information / Sanitize", - "Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.", + "Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Fails when the listed categories hold nothing. Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.", ) .destructive() .with(schema( @@ -637,7 +710,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, }), &["doc", "page", "text"], @@ -660,7 +733,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." }, @@ -754,7 +827,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, - "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, + "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "freehand_highlight", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, "path": { "type": "string", "description": "attachment: the file to attach." }, "points": { "type": "array", "items": point(), "minItems": 2 }, "knee": point(), @@ -1194,9 +1267,9 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") + t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.") .cmd("export.docx") - .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), + .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") .ro() .cmd("standards.pdfa") @@ -1281,7 +1354,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2 inside other filters, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations), or past what one export may decode or hold, are listed under skipped, with the reason. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ @@ -1340,7 +1413,7 @@ pub fn tools() -> Vec { }), &["from"], )), - t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") + t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( @@ -1378,17 +1451,18 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") + t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, - "action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "degrees": { "type": "number" }, + "matrix": { "type": "array", "items": { "type": "number" }, "minItems": 6, "maxItems": 6 }, "path": { "type": "string" }, }), &["doc", "page", "image", "action"], @@ -1428,7 +1502,7 @@ pub fn tools() -> Vec { "size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#rrggbb" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "underline": { "type": "boolean" }, "line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." }, "dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." }, @@ -1438,11 +1512,19 @@ pub fn tools() -> Vec { "scale": { "type": "number", "description": "Horizontal scale in percent." } }), &["doc", "page"], )), + t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.") + .cmd("edit.edit_text") + .with(schema( + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, + "script": { "type": "string", "enum": ["superscript", "subscript"] }, + "range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }), + &["doc", "page", "paragraph", "script"], + )), t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.") .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..05f380dff1 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -664,6 +664,44 @@ fn tool_table_is_well_formed() { } } +fn assert_homogeneous_enums(schema: &Value, path: &str) { + fn json_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } + } + + if let Some(values) = schema.get("enum").and_then(Value::as_array) { + let first = values.first().map(json_kind); + assert!(values.iter().map(json_kind).all(|kind| Some(kind) == first), "mixed enum types at {path}: {values:?}"); + } + match schema { + Value::Object(object) => { + for (key, child) in object { + assert_homogeneous_enums(child, &format!("{path}.{key}")); + } + } + Value::Array(array) => { + for (index, child) in array.iter().enumerate() { + assert_homogeneous_enums(child, &format!("{path}[{index}]")); + } + } + _ => {} + } +} + +#[test] +fn tool_schema_enums_have_one_json_type() { + for tool in tools() { + assert_homogeneous_enums(&tool.input_schema, tool.name); + } +} + #[test] fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() { let dir = workdir("font-ranges"); @@ -2111,6 +2149,27 @@ fn comments_through_tools() { save_and_reopen_comments(&dir, &mut a, doc); } +#[test] +fn freehand_highlight_through_tools() { + let dir = workdir("freehand-highlight"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "freehand_highlight", "width": 100, + "strokes": [[[20, 100], [120, 100]]] }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1); + assert_eq!(list["comments"][0]["type"], "Ink"); + assert_eq!(list["comments"][0]["intent"], "FreehandHighlight"); + assert_eq!(list["comments"][0]["color"], "#FFF000"); + let undone = ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(undone["undone"], "Add freehand highlight"); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); +} + fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); @@ -2773,6 +2832,74 @@ fn headers_watermarks_and_backgrounds_through_tools() { assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier"))); } +#[test] +fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failures() { + let dir = workdir("bates-multi"); + let files = json!([ + {"input":"b.pdf","output":"b-numbered.pdf"}, + {"input":"a.pdf","output":"a-numbered.pdf"} + ]); + let mut a = auto(&dir); + let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true})); + assert_eq!(allocation["files"][0]["first"], 50); + assert_eq!(allocation["files"][0]["last"], 51); + assert_eq!(allocation["files"][1]["first"], 52); + assert_eq!(allocation["files"][1]["last"], 54); + assert!(!dir.join("a-numbered.pdf").exists()); + + let applied = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":17,"prefix":"CASE-"})); + assert_eq!(applied["completed"], 2); + let mut out = auto(&dir); + let first = ok(&mut out, "doc_open", json!({"path":"a-numbered.pdf"}))["doc"].as_u64().unwrap(); + let second = ok(&mut out, "doc_open", json!({"path":"b-numbered.pdf"}))["doc"].as_u64().unwrap(); + assert!(page_text(&mut out, second)[0].contains("CASE-000017")); + assert!(page_text(&mut out, first)[0].contains("CASE-000019")); + + let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX})); + assert!(overflow.to_string().contains("overflows")); + assert!(!dir.join("overflow.pdf").exists()); + // A range may end at u64::MAX itself. + let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true})); + assert_eq!(at_max["files"][0]["last"], u64::MAX); + for bad in + [json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})] + { + let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true}); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}"); + } + + use pdfcraft_engine::{Changes, Edit, Protection, Session}; + let mut session = Session::new(); + let id = session.open("locked.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap(); + session + .apply( + id, + Edit::Protect(Protection { + open_password: Some("open".into()), + permissions_password: Some("owner".into()), + changes: Changes::None, + ..Default::default() + }), + ) + .unwrap(); + std::fs::write(dir.join("locked.pdf"), session.save_bytes(id).unwrap().as_ref()).unwrap(); + let result = ok( + &mut a, + "doc_bates_multi", + json!({"files":[ + {"input":"b.pdf","output":"partial-numbered.pdf"}, + {"input":"locked.pdf","output":"locked-numbered.pdf","password":"open"} + ]}), + ); + assert_eq!(result["completed"], 1); + assert_eq!(result["failed"], 1); + assert_eq!(result["files"][1]["first"], 3); + assert!(result["files"][1]["error"].as_str().unwrap().contains("owner password"), "{result}"); + assert!(dir.join("partial-numbered.pdf").exists()); + assert!(!dir.join("locked-numbered.pdf").exists()); +} + #[test] fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() { let dir = workdir("hf-shrink"); @@ -3286,6 +3413,39 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +/// Format text ▸ Superscript and Subscript without the GUI: a range, then back again over a space +/// (which has no superscript form), each one undo step; refusals change nothing. +#[test] +fn superscript_and_subscript_through_tools() { + let dir = workdir("text-script"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let paragraph = + |a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 2 }))["paragraphs"][0]["text"].as_str().unwrap().to_string(); + let script = |script: &str, range: Value| json!({ "doc": doc, "page": 2, "paragraph": 1, "script": script, "range": range }); + // "Page 2": the 2 is byte 5. + let r = ok(&mut a, "text_script", script("superscript", json!([5, 6]))); + assert_eq!((r["script"].as_str(), r["paragraph"]["text"].as_str()), (Some("superscript"), Some("Page ²")), "{r}"); + assert_eq!(page_text(&mut a, doc)[1], "Page ²"); + // " ²": the space has no superscript form, and the range still toggles back. + ok(&mut a, "text_script", script("superscript", json!([4, 7]))); + assert_eq!(paragraph(&mut a), "Page 2"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text"); + assert_eq!(paragraph(&mut a), "Page ²"); + // Refused, changing nothing: a range that splits ², one past the end, an empty one, a range + // with nothing that has a subscript form, an unknown script. + for (name, range) in [ + ("superscript", json!([5, 6])), + ("superscript", json!([0, 99])), + ("superscript", json!([3, 3])), + ("subscript", json!([4, 5])), + ("italic", json!([0, 1])), + ] { + assert!(matches!(a.call("text_script", &script(name, range.clone())), Err(ToolError::InvalidArgs(_))), "{name} {range}"); + } + assert_eq!(paragraph(&mut a), "Page ²"); +} + #[test] fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() { if pdfcraft_fonts::document_japanese_font().is_none() { @@ -3453,6 +3613,13 @@ fn editing_page_images_through_tools() { assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); + // A single image is already in front and at the back; no-op arrange is refused without dirtying undo. + assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })).is_err()); + assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_to_back" })).is_err()); + assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_forward" })).is_err()); + assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_backward" })).is_err()); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "remove_crop" })); + assert_eq!(r["undo"], "Remove crop"); // Any angle: the box grows to the turned picture's bounds, and it undoes like a quarter turn. let before = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by", "degrees": 30.5 })); @@ -3472,6 +3639,80 @@ fn editing_page_images_through_tools() { assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn arranging_page_images_through_tools() { + let dir = workdir("arrange-page-images"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1, 2] })); + let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_image", json!({ "doc": made, "page": 1, "path": "src/a_page_2.png", "rect": [50, 50, 100, 100] })); + assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 2); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })); + assert_eq!(r["undo"], "Bring to front"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 2, "action": "send_to_back" })); + assert_eq!(r["undo"], "Send to back"); + ok(&mut a, "doc_save", json!({ "doc": made, "path": "arranged.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "arranged.pdf" }))["doc"].as_u64().unwrap(); + let after_reopen = ok(&mut a, "page_images", json!({ "doc": reopened, "page": 1 })); + assert_eq!(after_reopen["count"], 2); + assert_eq!(after_reopen["images"][0]["rect"], json!([0.0, 0.0, 50.0, 75.0])); + assert_eq!(after_reopen["images"][1]["rect"], json!([50.0, 50.0, 100.0, 100.0])); +} + +/// The transform a rotated-image resize sends (`ImageEdit::Transform`) is reachable headlessly, +/// in the same top-left-origin points as the other image tools, also on a turned page. +#[test] +fn transforming_page_images_through_tools() { + let dir = workdir("transform-page-images"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1] })); + let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap(); + let rect = |a: &mut Automation| ok(a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); + let transform = + |a: &mut Automation, m: Value| ok(a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": m })); + ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "move", "rect": [5, 5, 25, 35] })); + + // Right 10, down 20. + assert_eq!(transform(&mut a, json!([1, 0, 0, 1, 10, 20]))["undo"], "Transform image"); + assert_eq!(rect(&mut a), json!([15.0, 25.0, 35.0, 55.0])); + // Twice the size about the top-left corner of the page. + transform(&mut a, json!([2, 0, 0, 2, 0, 0])); + assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0])); + // A quarter turn, (x, y) → (200 − y, x): the box turns with it, and it undoes in one step. + transform(&mut a, json!([0, 1, -1, 0, 200, 0])); + assert_eq!(rect(&mut a), json!([90.0, 30.0, 150.0, 70.0])); + ok(&mut a, "edit_undo", json!({ "doc": made })); + assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0])); + + // On a turned page "right" is still right on screen. + ok(&mut a, "page_rotate", json!({ "doc": made, "pages": [1], "degrees": 90 })); + let numbers = |v: Value| [0, 1, 2, 3].map(|i| v[i].as_f64().unwrap()); + let [x0, y0, x1, y1] = numbers(rect(&mut a)); + transform(&mut a, json!([1, 0, 0, 1, 10, 0])); + let after = numbers(rect(&mut a)); + for (got, want) in after.into_iter().zip([x0 + 10.0, y0, x1 + 10.0, y1]) { + assert!((got - want).abs() < 0.015, "{after:?} is not ({x0}, {y0}, {x1}, {y1}) moved right by 10"); + } + + // Flat, non-finite, short and missing matrices are refused before anything changes. + let before = rect(&mut a); + for bad in [ + json!([1, 2, 2, 4, 0, 0]), + json!([0, 0, 0, 0, 0, 0]), + json!([1e300, 0, 0, 1e300, 0, 0]), + json!([1, 0, 0, 1]), + json!([1, 0, "invalid", 0, 1, 10, 0]), + json!([1, 0, null, 0, 1, 10, 0]), + ] { + let r = a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": bad })); + assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{bad}: {r:?}"); + } + assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform" })), Err(ToolError::InvalidArgs(_)))); + assert_eq!(rect(&mut a), before); +} + #[test] fn enhance_camera_image_through_tools() { let dir = workdir("enhance-camera"); @@ -4497,12 +4738,23 @@ fn removing_hidden_information_through_tools() { assert_eq!(r["undo"], "Remove hidden information"); assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); assert!(count(&ok(&mut a, "doc_hidden_info", json!({ "doc": doc })), "metadata") >= 1, "only comments went"); + // With none left, removing them again says so, and Optimize discarding them still runs. + let again = a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["comments"] })); + assert!(matches!(&again, Err(ToolError::Failed(m)) if m.starts_with("nothing to remove")), "{again:?}"); + let optimized = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf", "discard": ["comments", "bookmarks"] })); + assert_eq!(optimized["discarded"], json!([]), "{optimized}"); let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc })); assert_eq!(r["undo"], "Sanitize document"); ok(&mut a, "doc_save", json!({ "doc": doc, "path": "clean.pdf" })); let bytes = std::fs::read(dir.join("clean.pdf")).unwrap(); assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone"); assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_)))); + // A document that can't be edited has no scan: the refusal says why, not that nothing is there. + let junk = [vec![b'x'; 5000], std::fs::read(dir.join("a.pdf")).unwrap()].concat(); + std::fs::write(dir.join("junk.pdf"), junk).unwrap(); + let read_only = ok(&mut a, "doc_open", json!({ "path": "junk.pdf" }))["doc"].as_u64().unwrap(); + let refused = a.call("doc_remove_hidden", &json!({ "doc": read_only, "categories": ["comments"] })); + assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}"); } /// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle. @@ -6478,10 +6730,12 @@ fn optimizing_through_tools() { let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" })); assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap()); assert_eq!(reduced["written"], true); + assert_eq!(reduced["images_refused"], 0); assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap()); // Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off). let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" })); + assert_eq!(v14["images_refused"], 0); assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}"); let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap(); assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm")); @@ -7429,6 +7683,17 @@ fn exporting_to_word_html_and_rtf() { } let html = std::fs::read_to_string(dir.join("a.html")).unwrap(); assert!(html.contains("Page 1") && html.contains("Page 3") && html.matches("
").count() == 2, "{html}"); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "simple.html", "html_mode": "simple" })); + let simple_html = std::fs::read_to_string(dir.join("simple.html")).unwrap(); + assert!( + simple_html.contains("Page 1") + && !simple_html.contains(" Vec { let lines = ok(a, "text_lines", json!({"doc":id,"page":1})); let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap(); @@ -9772,10 +10041,13 @@ fn added_text_lines_take_their_own_alignments() { }; assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0); assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line")); + assert!((rect(&mut a, "Inherited line")[0] - 40.0).abs() < 1.0); // The whole box centred: the right-aligned line goes with it. ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"})); let right = rect(&mut a, "Right line"); assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}"); + let inherited = rect(&mut a, "Inherited line"); + assert!(((inherited[0] + inherited[2]) / 2.0 - 290.0).abs() < 1.0, "{inherited:?}"); assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err()); assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err()); } @@ -9922,6 +10194,31 @@ fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_ assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1"); } +/// Clearing a field's length limit through `form_set_props` (`"max_length": 0`) leaves it with +/// none, even when its parent field has one it would otherwise inherit. +#[test] +fn clearing_a_kids_length_limit_through_tools_does_not_bring_back_its_parents() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /T (address) /FT /Tx /MaxLen 3 /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /MaxLen 8 /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("kid-max-len"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "max_length": 0 })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Absalom" } })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone(); + assert!(city.get("max_length").is_none(), "no limit, not the parent's 3: {city}"); + assert_eq!(city["value"], "Absalom", "the value is not cut to the parent's 3 characters"); +} + /// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none, /// even when its parent field has one it would otherwise inherit. #[test] @@ -10190,3 +10487,348 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { } write_full(&doc, &SaveOptions::default()).unwrap() } + +#[test] +fn editor_crop_group_move_and_arrange_survive_added_item_deletion_through_tools() { + let dir = workdir("editor-crop-group-arrange"); + let mut a = auto(&dir); + let source = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({"doc":source,"folder":"src","dpi":18,"pages":[1]})); + let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["src/a_page_1.png"]}))["doc"].as_u64().unwrap(); + ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":[5,5,25,35]})); + ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"crop","rect":[10,10,20,30]})); + ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Movable label","rect":[2,40,45,55],"size":6})); + let inventory = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + let objects: Vec<_> = inventory["objects"].as_array().unwrap().iter().map(|o| json!({"kind":o["kind"],"index":o["index"]})).collect(); + assert_eq!(objects.len(), 2); + let moved = ok(&mut a, "object_move", json!({"doc":doc,"page":1,"objects":objects,"offset":[3,4]})); + assert_eq!(moved["moved"], 2); + let picture = ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0].clone(); + assert_eq!(picture["rect"], json!([8.0, 9.0, 28.0, 39.0])); + assert_eq!(picture["visible_rect"], json!([13.0, 14.0, 23.0, 34.0])); + assert!(page_text(&mut a, doc)[0].contains("Movable label")); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0]["visible_rect"], json!([10.0, 10.0, 20.0, 30.0])); + ok(&mut a, "edit_redo", json!({"doc":doc})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"grouped.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"grouped.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "page_images", json!({"doc":reopened,"page":1}))["images"][0]["visible_rect"], picture["visible_rect"]); + ok(&mut a, "image_edit", json!({"doc":reopened,"page":1,"image":1,"action":"bring_to_front"})); + assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 1); + ok(&mut a, "content_delete", json!({"doc":reopened,"page":1,"index":1})); + ok(&mut a, "doc_save", json!({"doc":reopened,"path":"independent.pdf"})); + let saved = ok(&mut a, "doc_open", json!({"path":"independent.pdf"}))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "page_images", json!({"doc":saved,"page":1})); + assert_eq!(after["count"], 1); + assert_eq!(after["images"][0]["rect"], picture["rect"]); + assert_eq!(after["images"][0]["visible_rect"], picture["visible_rect"]); + let rendered = a.call("page_render", &json!({"doc":saved,"page":1,"dpi":72})).unwrap(); + assert!(rendered.iter().any(|c| matches!(c, Content::Png {data,width,height} if !data.is_empty() && *width > 0 && *height > 0))); +} + +#[test] +fn image_flip_tools_follow_displayed_axes_on_every_page_rotation() { + fn corners(a: &Automation, id: u64) -> [[f64; 2]; 4] { + let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap(); + let [aa, b, c, d, e, f] = doc.page_images(0)[0].matrix; + [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)] + .map(|(x, y)| doc.info.pages[0].user_to_view((aa * x + c * y + e) as f32, (b * x + d * y + f) as f32).map(f64::from)) + } + fn raster(a: &mut Automation, id: u64) -> (usize, usize, Vec) { + let rendered = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(); + let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") }; + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut bytes = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut bytes).unwrap(); + bytes.truncate(frame.buffer_size()); + if frame.color_type == png::ColorType::Rgb { + bytes = bytes.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(); + } else { + assert_eq!(frame.color_type, png::ColorType::Rgba); + } + (frame.width as usize, frame.height as usize, bytes) + } + let dir = workdir("displayed-image-flip"); + let mut png_bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png_bytes, 8, 4); + encoder.set_color(png::ColorType::Rgb); + let colors = [[240, 20, 30], [20, 230, 40], [30, 40, 220], [220, 210, 20]]; + let pixels: Vec = (0..4).flat_map(|y| (0..8).flat_map(move |x| colors[usize::from(x >= 4) + 2 * usize::from(y >= 2)])).collect(); + encoder.write_header().unwrap().write_image_data(&pixels).unwrap(); + } + std::fs::write(dir.join("quadrants.png"), png_bytes).unwrap(); + let mut a = auto(&dir); + let mut failures = Vec::new(); + for rotation in [0, 90, 180, 270] { + let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["quadrants.png"]}))["doc"].as_u64().unwrap(); + if rotation != 0 { + ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":rotation})); + } + let before = corners(&a, doc); + let (width, height, pixels) = raster(&mut a, doc); + let xmin = before.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min); + let xmax = before.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max); + let ymin = before.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min); + let ymax = before.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max); + for (action, horizontal) in [("flip_horizontal", true), ("flip_vertical", false)] { + ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":action})); + let after = corners(&a, doc); + for (old, new) in before.into_iter().zip(after) { + let expected = if horizontal { [xmin + xmax - old[0], old[1]] } else { [old[0], ymin + ymax - old[1]] }; + if new.into_iter().zip(expected).any(|(actual, want)| (actual - want).abs() > 0.001) { + failures.push(format!("rotation {rotation} {action}: corner {old:?} became {new:?}, expected {expected:?}")); + break; + } + } + let (new_width, new_height, actual_pixels) = raster(&mut a, doc); + assert_eq!((new_width, new_height), (width, height)); + let mut expected_pixels = Vec::new(); + for y in 0..height { + for x in 0..width { + let source = if horizontal { (y * width + width - 1 - x) * 4 } else { ((height - 1 - y) * width + x) * 4 }; + expected_pixels.extend_from_slice(&pixels[source..source + 4]); + } + } + if actual_pixels.iter().zip(&expected_pixels).any(|(actual, want)| actual.abs_diff(*want) > 1) { + failures.push(format!("rotation {rotation} {action}: raster flipped the wrong displayed axis")); + } + assert_ne!(actual_pixels, pixels, "the asymmetric image makes both flip axes observable"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(corners(&a, doc), before, "one undo restores the original placement"); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() { + let dir = workdir("mixed-script-policy"); + let mut a = auto(&dir); + for (script, mixed, uniform, plain) in [("superscript", "Q²2", "Q²²", "Q22"), ("subscript", "H₂2", "H₂₂", "H22")] { + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":mixed,"size":14,"width":160})); + let paragraph = + |a: &mut Automation| ok(a, "text_paragraphs", json!({"doc":doc,"page":1}))["paragraphs"][0]["editor_text"].as_str().unwrap().to_owned(); + assert_eq!(paragraph(&mut a), mixed); + let before = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone(); + let args = json!({"doc":doc,"page":1,"paragraph":1,"script":script}); + let once = ok(&mut a, "text_script", args.clone()); + assert_eq!(once["paragraph"]["text"], uniform); + assert_eq!(paragraph(&mut a), uniform); + ok(&mut a, "text_script", args); + assert_eq!(paragraph(&mut a), plain); + assert_ne!(plain, mixed, "normalization does not promise to recreate the original mixture"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), uniform, "one Undo restores the preceding scripted text"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), mixed, "Undo restores the exact initial Unicode mixture"); + assert_eq!( + a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.as_ref(), + before.as_ref(), + "Undo also restores the exact prior document bytes" + ); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), uniform); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_eq!(paragraph(&mut a), plain); + let path = format!("mixed-{script}.pdf"); + ok(&mut a, "doc_save", json!({"doc":doc,"path":path})); + let reopened = ok(&mut a, "doc_open", json!({"path":path}))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, reopened)[0], plain); + } +} + +#[test] +fn multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance() { + let dir = workdir("fill-sign-multiline"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let text = "\nAda\n\nLovelace\n"; + ok(&mut a, "fill_sign_add", json!({"doc":doc,"page":1,"type":"text","at":[20,20],"text":text})); + let id = pdfcraft_engine::DocId(doc); + let annot = &a.session().get(id).unwrap().info.annotations[0]; + assert_eq!(annot.contents.as_deref(), Some(text), "typewriter contents keep outer line breaks"); + assert!(annot.rect[3] - annot.rect[1] >= 66.0, "five text lines need their full height, including blank lines: {:?}", annot.rect); + let pixels = a.render_pixels(doc, 1, 1.0).unwrap(); + // The fixture's page text is at y=150; only the typewriter draws at y=65..80. + assert!( + (65..80).any(|y| (20..80).any(|x| { + let i = (y * pixels.0 as usize + x) * 4; + pixels.2[i..i + 3].iter().all(|v| *v < 100) + })), + "the lower nonempty line appears inside its box" + ); + let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"multiline.pdf"})); + let mut re = auto(&dir); + let reopened = ok(&mut re, "doc_open", json!({"path":"multiline.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(re.session().get(pdfcraft_engine::DocId(reopened)).unwrap().info.annotations[0].contents.as_deref(), Some(text)); + assert_eq!(rendered, re.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), "saved appearances are identical"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Export, then import into another document and copy between open documents: the tree comes +/// back unchanged, a page the target lacks is reported, and the import undoes in one step. +#[test] +fn bookmarks_export_import_and_copy_between_documents() { + let dir = workdir("bookmark-io"); + let mut a = auto(&dir); + let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "Intro", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "Detail", "page": 3, "parent": [1] })); + ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "End", "page": 2 })); + ok(&mut a, "bookmark_set_style", json!({ "doc": src, "path": [1], "color": [1, 0, 0], "bold": true })); + let exported = ok(&mut a, "bookmark_export", json!({ "doc": src, "out": "marks.json" })); + assert_eq!(exported["truncated"], false); + let tree = exported["bookmarks"].clone(); + assert_eq!((tree[0]["title"].clone(), tree[0]["color"].clone(), tree[0]["bold"].clone()), (json!("Intro"), json!([1.0, 0.0, 0.0]), json!(true))); + assert_eq!((tree[0]["children"][0]["title"].clone(), tree[0]["children"][0]["page"].clone()), (json!("Detail"), json!(3))); + assert_eq!(tree[0]["view"]["type"], "XYZ"); + let subtree = ok(&mut a, "bookmark_export", json!({ "doc": src, "path": [1, 1] }))["bookmarks"].clone(); + assert_eq!(subtree, json!([tree[0]["children"][0]])); + + // b.pdf has two pages: Detail goes to the third, so it arrives without a destination. + let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + let imported = ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "marks.json" })); + assert_eq!(imported["unmapped"], json!([[1, 1]])); + let back = ok(&mut a, "bookmark_export", json!({ "doc": dst }))["bookmarks"].clone(); + assert_eq!(back[0]["children"][0]["page"], json!(null)); + assert_eq!((back[0]["title"].clone(), back[1].clone()), (tree[0]["title"].clone(), tree[1].clone())); + ok(&mut a, "edit_undo", json!({ "doc": dst })); + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": dst }))["bookmarks"], json!([])); + + // Into a document with every page, the tree is unchanged, wherever it is copied from. + let same = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": same, "title": "Old", "page": 1 })); + let copied = ok(&mut a, "bookmark_copy", json!({ "doc": same, "from": src, "replace": true })); + assert_eq!(copied["unmapped"], json!([])); + assert_eq!(ok(&mut a, "bookmark_export", json!({ "doc": same }))["bookmarks"], tree); + ok(&mut a, "bookmark_import", json!({ "doc": same, "bookmarks": subtree, "parent": [2], "position": 1 })); + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": same }))["bookmarks"][1]["children"][0]["title"], "Detail"); +} + +/// A one-page document whose outline has an untitled item, a /FitR destination with three of +/// its four numbers, and a chain nested `deep` levels below them. +fn odd_outline(deep: usize) -> Vec { + let first_deep = 7; + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Outlines /First 5 0 R /Last 6 0 R >>".into(), + "<< /Title () /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>".into(), + format!( + "<< /Title (Short) /Parent 4 0 R /Prev 5 0 R /Dest [3 0 R /FitR 0 0 10]{} >>", + if deep > 0 { format!(" /First {first_deep} 0 R /Last {first_deep} 0 R") } else { String::new() } + ), + ]; + for i in 0..deep { + let (me, parent) = (first_deep + i, if i == 0 { 6 } else { first_deep + i - 1 }); + let child = if i + 1 < deep { format!(" /First {0} 0 R /Last {0} 0 R", me + 1) } else { String::new() }; + objs.push(format!("<< /Title (Level) /Parent {parent} 0 R /Dest [3 0 R /Fit]{child} >>")); + } + pdf_of(&objs.iter().map(String::as_str).collect::>()) +} + +/// What a real file holds and the JSON form can't say exactly still copies and round-trips: an +/// empty title stays empty, a short /FitR keeps its page and drops the view, and bookmarks past +/// the listing depth are reported as left out at every step. +#[test] +fn odd_bookmarks_round_trip_and_a_cut_tree_says_so() { + let dir = workdir("bookmark-io-odd"); + std::fs::write(dir.join("odd.pdf"), odd_outline(0)).unwrap(); + std::fs::write(dir.join("deep.pdf"), odd_outline(40)).unwrap(); + let mut a = auto(&dir); + let src = ok(&mut a, "doc_open", json!({ "path": "odd.pdf" }))["doc"].as_u64().unwrap(); + let tree = ok(&mut a, "bookmark_export", json!({ "doc": src, "out": "odd.json" }))["bookmarks"].clone(); + assert_eq!((tree[0]["title"].clone(), tree[1]["page"].clone(), tree[1].get("view")), (json!(""), json!(1), None)); + let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "odd.json", "replace": true }))["truncated"], false); + // A destination with no view is written as XYZ with nulls: the same page, the reader's view. + let back = ok(&mut a, "bookmark_export", json!({ "doc": dst }))["bookmarks"].clone(); + assert_eq!((back[0].clone(), back[1]["title"].clone(), back[1]["page"].clone()), (tree[0].clone(), json!("Short"), json!(1))); + assert_eq!(back[1]["view"], json!({ "type": "XYZ", "values": [null, null, null] })); + let copied = ok(&mut a, "bookmark_copy", json!({ "doc": dst, "from": src, "replace": true })); + assert_eq!((copied["unmapped"].clone(), copied["truncated"].clone()), (json!([]), json!(false))); + match a.call("bookmark_export", &json!({ "doc": src, "path": [3] })) { + Err(ToolError::Failed(m)) => assert!(m.contains("no bookmark at [3]"), "{m}"), + other => panic!("expected no bookmark, got {other:?}"), + } + + let deep = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "bookmark_export", json!({ "doc": deep, "out": "deep.json" }))["truncated"], true); + let saved: Value = serde_json::from_slice(&std::fs::read(dir.join("deep.json")).unwrap()).unwrap(); + assert_eq!(saved["truncated"], true); + assert_eq!(ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "deep.json", "replace": true }))["truncated"], true); + assert_eq!(ok(&mut a, "bookmark_copy", json!({ "doc": dst, "from": deep, "replace": true }))["truncated"], true); + let export = tools().into_iter().find(|t| t.name == "bookmark_export").unwrap(); + assert!(export.destructive && !export.read_only, "bookmark_export overwrites out"); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn a_misspelt_bookmark_key_is_refused_not_dropped() { + let dir = workdir("bookmark-io-key"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let tree = json!([{ "title": "Parent", "children": [{ "title": "Red", "page": 1, "colour": [1, 0, 0] }] }]); + match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": tree })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("[1, 1]") && m.contains("\"colour\""), "{m}"), + other => panic!("expected InvalidArgs, got {other:?}"), + } + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([])); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() { + let dir = workdir("bookmark-io-bad"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let refused = |a: &mut Automation, bookmarks: Value| match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": bookmarks })) { + Err(ToolError::InvalidArgs(m)) => m, + other => panic!("expected InvalidArgs, got {other:?}"), + }; + let fine = json!({ "title": "Fine", "page": 1 }); + let m = refused(&mut a, json!([fine, { "title": "Parent", "children": [{ "page": 1 }] }])); + assert!(m.contains("[2, 1]") && m.contains("title"), "{m}"); + let m = refused(&mut a, json!([{ "title": 5 }])); + assert!(m.contains("[1]") && m.contains("title"), "{m}"); + let m = refused(&mut a, json!([fine, { "title": "Bad", "page": 0 }])); + assert!(m.contains("[2]") && m.contains("page"), "{m}"); + let m = refused(&mut a, json!([{ "title": "Bad", "color": [2, 0, 0] }])); + assert!(m.contains("color"), "{m}"); + let m = refused(&mut a, json!([{ "title": "Bad", "view": { "type": "XYZ", "values": [1, 2, 3, 4, 5] } }])); + assert!(m.contains("view"), "{m}"); + let mut deep = json!({ "title": "Leaf" }); + for _ in 0..40 { + deep = json!({ "title": "Level", "children": [deep] }); + } + assert!(refused(&mut a, json!([deep])).contains("33 levels")); + let m = refused(&mut a, json!([fine, { "title": "Z", "page": 1, "view": { "type": "Zoom" } }])); + assert!(m.contains("[2]") && m.contains("XYZ (3 values"), "{m}"); + let m = refused(&mut a, json!([fine, { "title": "Z", "page": 1, "view": { "type": "FitH", "values": [1, 2] } }])); + assert!(m.contains("[2]") && m.contains("FitH view takes 1"), "{m}"); + let m = refused(&mut a, json!([{ "title": "R", "view": { "type": "FitR", "values": [0, null, 1, 1] } }])); + assert!(m.contains("all four"), "{m}"); + assert!(matches!(a.call("bookmark_import", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": [fine], "file": "x.json" })), Err(ToolError::InvalidArgs(_)))); + // A real bookmark file outside the root is refused for being outside, not for being missing. + let outside = dir.with_extension("outside.json"); + std::fs::write(&outside, br#"{"bookmarks": [{"title": "Outside", "page": 1}]}"#).unwrap(); + let name = format!("../{}", outside.file_name().unwrap().to_string_lossy()); + match a.call("bookmark_import", &json!({ "doc": doc, "file": name })) { + Err(ToolError::Failed(m)) => assert!(m.contains("outside the allowed directory"), "{m}"), + other => panic!("expected the root to refuse it, got {other:?}"), + } + let _ = std::fs::remove_file(&outside); + let out = dir.with_extension("out.json"); + let name = format!("../{}", out.file_name().unwrap().to_string_lossy()); + match a.call("bookmark_export", &json!({ "doc": doc, "out": name })) { + Err(ToolError::Failed(m)) => assert!(m.contains("outside the allowed directory"), "{m}"), + other => panic!("expected the root to refuse it, got {other:?}"), + } + assert!(!out.exists()); + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([])); +} diff --git a/crates/automation/tests/it/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs index a96870ca02..e65c582a73 100644 --- a/crates/automation/tests/it/automation_roots.rs +++ b/crates/automation/tests/it/automation_roots.rs @@ -145,6 +145,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() { ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin"))); let out = s(&base.join("out/result.pdf")); + std::fs::write(base.join("in/m.prc"), b"PRC model").unwrap(); + let model = s(&base.join("in/m.prc")); for (tool, args) in [ ("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})), ("page_replace", json!({"doc": 1, "pages": [1], "path": secret})), @@ -153,6 +155,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() { ("doc_create", json!({"from": "images", "paths": [pic]})), ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})), ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 493, 300]})), + ("model_add", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 100, 100]})), + ("model_add", json!({"doc": 1, "page": 1, "path": model, "rect": [10, 10, 100, 100], "poster": pic})), ("page_add_image", json!({"doc": 1, "page": 1, "path": pic})), ("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})), ("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})), diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d36dbe93a9..3122fe5825 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -8,6 +8,8 @@ mod batch; mod button_caption; mod form_bulk; mod hostile_args; +mod model_3d; +mod model_render; mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/automation/tests/it/model_3d.rs b/crates/automation/tests/it/model_3d.rs new file mode 100644 index 0000000000..179192f421 --- /dev/null +++ b/crates/automation/tests/it/model_3d.rs @@ -0,0 +1,120 @@ +//! `model_add` embeds a U3D or PRC model as a 3D annotation (ISO 32000-2 §13.6.2) with a poster, +//! end to end: the tool, the saved file, undo, and the refusals. + +use pdfcraft_automation::{Automation, Content, ToolError}; +use pdfcraft_cos::{Dict, Document, Object, Stream}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +fn png(w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, w, h); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header().unwrap().write_image_data(&vec![90; (w * h * 3) as usize]).unwrap(); + out +} + +fn stream(doc: &Document, obj: &Object) -> Stream { + match doc.resolve(obj).as_ref() { + Object::Stream(s) => s.clone(), + other => panic!("expected a stream: {other:?}"), + } +} + +/// The 3D annotations on the first page of the saved file. +fn saved_3d(dir: &std::path::Path) -> (Document, Vec) { + let doc = Document::open(std::sync::Arc::new(std::fs::read(dir.join("out.pdf")).unwrap())).unwrap(); + let page = &pdfcraft_model::pages(&doc)[0]; + let list = page.dict.get(b"Annots").map(|a| doc.resolve(a).as_array().cloned().unwrap()).unwrap_or_default(); + let found = list.iter().map(|o| doc.resolve(o).as_dict().unwrap().clone()).filter(|d| d.name(b"Subtype") == Some(&b"3D"[..])).collect(); + (doc, found) +} + +#[test] +fn model_add_embeds_the_model_with_a_fitted_poster() { + let dir = tempfile::tempdir().unwrap(); + let mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[7; 200]); + std::fs::write(dir.path().join("bracket.prc"), &prc).unwrap(); + std::fs::write(dir.path().join("poster.png"), png(80, 40)).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + // A 300 × 300 box from the top-left of the page: user space [100, 400, 400, 700]. + let call = + json!({ "doc": doc, "page": 1, "path": "bracket.prc", "rect": [100, 100, 400, 400], "poster": "poster.png", "activation": "page_open" }); + tool(&mut a, "model_add", call); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + + let (saved, found) = saved_3d(dir.path()); + let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") }; + let rect: Vec = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(rect, [100.0, 400.0, 400.0, 700.0]); + assert_eq!(annot.get(b"Contents").and_then(Object::as_string).map(|s| s.to_text()).as_deref(), Some("bracket")); + let model = stream(&saved, annot.get(b"3DD").unwrap()); + assert_eq!(model.dict.name(b"Type"), Some(&b"3D"[..])); + assert_eq!(model.dict.name(b"Subtype"), Some(&b"PRC"[..])); + assert_eq!(model.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(model.decoded().unwrap().to_vec(), prc, "the model is stored unchanged"); + let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone(); + assert_eq!(activation.name(b"A"), Some(&b"PO"[..])); + // The 80 × 40 picture fitted in the 300 × 300 box: 300 × 150, centred top to bottom. + let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone(); + let poster = stream(&saved, ap.get(b"N").unwrap()); + let content = String::from_utf8(poster.decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains("300 0 0 150 100 475 cm /Pic Do"), "{content}"); + + assert_eq!(tool(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add 3D model"); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + assert!(saved_3d(dir.path()).1.is_empty(), "undo removes it"); +} + +#[test] +fn model_add_without_a_poster_draws_a_labelled_frame_and_activates_on_click() { + let dir = tempfile::tempdir().unwrap(); + let mut u3d = b"U3D\0".to_vec(); + u3d.extend_from_slice(&[0; 60]); + std::fs::write(dir.path().join("part.u3d"), &u3d).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + tool(&mut a, "model_add", json!({ "doc": doc, "page": 1, "path": "part.u3d", "rect": [50, 50, 250, 200], "name": "Part" })); + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + + let (saved, found) = saved_3d(dir.path()); + let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") }; + assert_eq!(stream(&saved, annot.get(b"3DD").unwrap()).dict.name(b"Subtype"), Some(&b"U3D"[..])); + let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone(); + assert_eq!(activation.name(b"A"), Some(&b"XA"[..]), "the standard's default, stated"); + let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone(); + let content = String::from_utf8(stream(&saved, ap.get(b"N").unwrap()).decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains("re S") && content.contains("(3D model) Tj"), "{content}"); +} + +#[test] +fn model_add_refuses_what_is_not_a_model_and_adds_nothing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"PR not a model").unwrap(); + std::fs::write(dir.path().join("part.prc"), b"PRC....").unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap(); + let not_a_model = a.call("model_add", &json!({ "doc": doc, "page": 1, "path": "notes.txt", "rect": [10, 10, 100, 100] })); + assert!(matches!(¬_a_model, Err(ToolError::Failed(m)) if m.contains("U3D") && m.contains("PRC")), "{not_a_model:?}"); + for (args, why) in [ + (json!({ "rect": [10, 10, 10, 100] }), "zero width"), + (json!({ "rect": [0, 0, 20000, 100] }), "wider than a PDF page can be"), + (json!({}), "no rect"), + (json!({ "rect": [10, 10, 100, 100], "activation": "now" }), "an unknown activation"), + ] { + let mut call = json!({ "doc": doc, "page": 1, "path": "part.prc" }); + call.as_object_mut().unwrap().extend(args.as_object().unwrap().clone()); + assert!(matches!(a.call("model_add", &call), Err(ToolError::InvalidArgs(_))), "{why}"); + } + tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + assert!(saved_3d(dir.path()).1.is_empty()); +} diff --git a/crates/automation/tests/it/model_render.rs b/crates/automation/tests/it/model_render.rs new file mode 100644 index 0000000000..615a5346a9 --- /dev/null +++ b/crates/automation/tests/it/model_render.rs @@ -0,0 +1,112 @@ +//! `model_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// One triangle at z = 0 facing +z, with corners (-1,-1), (1,-1) and (0,1), as a PRC file of one +/// file structure with no product tree, in hexadecimal for the stream's `/ASCIIHexDecode`. +const TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000cd000000d5000000dd000000e500000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c087758da2422b43084fe3140e038713464cd759381a162800006ed70828789c030000000001789c030000000001789c030000000001"; + +/// A one-page PDF whose page holds a 3D annotation over a stream of `/Subtype /{subtype}`. +fn pdf(subtype: &str, hex: &str) -> Vec { + let stream = format!("<< /Type /3D /Subtype /{subtype} /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 10 10] >> 4 0 R] >>".into(), + "<< /Type /Annot /Subtype /3D /Rect [20 20 180 180] /3DD 5 0 R >>".into(), + stream, + ]; + 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{o}\nendobj\n", i + 1).as_bytes()); + } + 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 +} + +/// A fresh folder with `prc.pdf` and `u3d.pdf` in `in`, `outside.pdf` beside it, and an +/// automation rooted at `in`. +fn setup(test: &str) -> (PathBuf, Automation) { + let dir = std::env::temp_dir().join(format!("pdfcraft-model3d-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("in")).unwrap(); + std::fs::write(dir.join("in/prc.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap(); + std::fs::write(dir.join("in/u3d.pdf"), pdf("U3D", "553344")).unwrap(); + std::fs::write(dir.join("outside.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap(); + let a = Automation::new().with_root(dir.join("in")).unwrap(); + (dir, a) +} + +fn open(a: &mut Automation, path: &str) -> u64 { + match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) { + Content::Json(v) => v["doc"].as_u64().unwrap(), + other => panic!("expected JSON: {other:?}"), + } +} + +/// The RGB of pixel (x, y) of a PNG. +fn rgb(data: &[u8], x: usize, y: usize) -> [u8; 3] { + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.line_size / info.width as usize; + let at = y * info.line_size + x * channels; + [pixels[at], pixels[at + 1], pixels[at + 2]] +} + +#[test] +fn a_prc_model_is_drawn_over_the_background() { + let (_dir, mut a) = setup("prc"); + let doc = open(&mut a, "prc.pdf"); + let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false }); + let out = a.call("model_render", &args).unwrap(); + let Content::Png { data, width, height } = &out[0] else { panic!("expected a PNG") }; + assert_eq!((*width, *height), (64, 48)); + assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background"); + assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on"); + // In perspective the headlight meets the face at a slight angle, so only not-background holds. + let out = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap(); + let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; + assert_ne!(rgb(data, 32, 24), [255, 255, 255], "the centre is drawn in perspective too"); + // The same model named explicitly, and the default isometric view, both draw. + assert!(a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); +} + +#[test] +fn a_u3d_model_or_a_missing_one_is_a_clear_error() { + let (_dir, mut a) = setup("u3d"); + let doc = open(&mut a, "u3d.pdf"); + let err = a.call("model_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string(); + assert!(err.contains("U3D"), "{err}"); + let doc = open(&mut a, "prc.pdf"); + let err = a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string(); + assert!(err.contains("has 1 3D models, not 2"), "{err}"); + for bad in [json!({ "direction": [0, 0, 0] }), json!({ "direction": [1, 2] }), json!({ "width": 0 }), json!({ "height": 5000 })] { + let mut args = json!({ "doc": doc, "page": 1 }); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(a.call("model_render", &args).is_err(), "{bad}"); + } +} + +#[test] +fn a_model_outside_the_root_is_never_opened_to_be_drawn() { + let (dir, mut a) = setup("root"); + let outside = dir.join("outside.pdf"); + for path in [Value::from("../outside.pdf"), Value::from(outside.to_string_lossy().into_owned())] { + assert!(a.call("doc_open", &json!({ "path": path })).is_err(), "{path} lies outside the root"); + } + // The tool takes no path of its own: only a document opened inside the root reaches it. + assert!(a.call("model_render", &json!({ "doc": 1, "page": 1 })).is_err()); + let doc = open(&mut a, "prc.pdf"); + assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok()); +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index c52916d332..4957f86861 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -730,11 +730,14 @@ impl Matrix { pub fn invert(&self) -> Option { let [a, b, c, d, e, f] = self.0; let det = a * d - b * c; - if det.abs() < 1e-12 || !det.is_finite() { + if det == 0.0 || !det.is_finite() || det.abs() <= 1e-12 * ((a * d).abs() + (b * c).abs()) { return None; } let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); - Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)])) + let inverse = [ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)]; + // A determinant that underflows toward zero can still pass the relative check above, + // and dividing by it overflows: such a matrix has no usable inverse. + inverse.iter().all(|v| v.is_finite()).then_some(Matrix(inverse)) } /// The bounding box of a rectangle `[x0 y0 x1 y1]` after this transform. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index c5c7a3aaac..9ac9297cd2 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -552,3 +552,17 @@ fn inline_images_end_where_the_renderer_ends_them() { assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data)); } } + +#[test] +fn invert_accepts_small_scale_and_rejects_singular() { + let m = Matrix([1e-7, 0., 0., 1e-7, 0., 0.]); + let inv = m.invert().expect("a uniform 1e-7 scale is invertible"); + let id = m.then(&inv).0; + for (got, want) in id.iter().zip(Matrix::IDENTITY.0) { + assert!((got - want).abs() < 1e-9, "{id:?}"); + } + assert!(Matrix([1., 2., 2., 4., 0., 0.]).invert().is_none()); + assert!(Matrix([f64::NAN, 0., 0., 1., 0., 0.]).invert().is_none()); + // A subnormal scale passes the relative check but its inverse overflows (CodeRabbit on #5703). + assert!(Matrix([1e-310, 0., 0., 1., 0., 0.]).invert().is_none()); +} diff --git a/crates/cos/README.md b/crates/cos/README.md index 2cf3342954..7b576f293f 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -90,6 +90,11 @@ recognisable in a diff and byte-stable when re-serialized. - Linearized output, Fast Web View (`core.linearization`, M11). - Structural validation against the Arlington model (`core.arlington-validation`), which will live in the reserved `arlington` crate. -- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax decoding - (`Document::decoded_image`); DCT, JPX and JBIG2 are still decoded through the bootstrap renderer - (`core.image-filters`, M2.6). +- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding + (`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB, + their filters undone once a document for the images that share them, up to 64 MiB kept, while + their segments are decoded for every image, and `ImageBudget` for an operation over many images, + which share one allowance of decoding, filters, decoders and pixels alike, and are refused past + it: `IMAGE_WORK`, 2^34 steps, or 2 to 5 minutes of JBIG2 and about 1,000 pages scanned at + 300 dpi, plus `IMAGE_WORK_PER_BYTE`, 2^13 steps for each byte of the file); + DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 1ddd42eaf5..65b750e7b3 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -6,16 +6,224 @@ use pdfcraft_filters::FilterError; use crate::{CosError, Document, ObjRef, Object, Stream}; +/// The steps of decoding an operation over a document's images may take whatever the file's size. +/// A step of JBIG2 decoding ([`hayro_jbig2::Image::decode_within`]) is about a pixel, and takes 6 to +/// 16 ns, so this is 2 to 5 minutes, and about as long again for what an operation does with the +/// pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again to +/// decode them, so this alone reads about 1,000 of them. A byte into or out of a filter is a step +/// too, and takes less: 3 ns at most, or up to 19 ns through a predictor on samples of a bit. +const IMAGE_WORK: u64 = 1 << 34; +/// The steps each byte of the file adds to [`IMAGE_WORK`], so that a long scan is read whole: such a +/// page pays for itself with about 2 KB of the file, about the smallest a page of text is stored +/// in, and a stream of any filter this reads can be decoded about 8 times at Flate's best, 1,032 to +/// one. So what a hostile file can ask for grows with its length, by about 14 minutes for 10 MB. +const IMAGE_WORK_PER_BYTE: u64 = 1 << 13; + /// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every /// form XObject it enters, each occurrence counted again. pub const MAX_PAGE_DECODED: usize = 256 << 20; /// Form XObjects one page's content walk may enter, each entry counted again. pub const MAX_PAGE_FORMS: usize = 50_000; +/// The most pixels an image redaction decodes, to clear the ones under a mark or to store again +/// without the `/JBIG2Globals` it shares with a redacted image (`pdfcraft_redact`). Optimize has +/// JBIG2 images share a dictionary only within it (`pdfcraft_optimize`), so that redaction can +/// store each of them again: one it couldn't would refuse the redaction. +pub const MAX_REDACTED_IMAGE_PIXELS: u64 = 64 << 20; + +/// The steps a sample of a JPEG costs: a few bytes can hold a page of one grey, and decoding, +/// resampling and storing it took about 20 to 45 ns a sample. +const JPEG_WORK: u64 = 2; +/// The steps a byte of a JPEG costs to read: its tables took 24 ns a byte before a size was known, +/// and images can share one, so reading it again for each has to count. +const JPEG_READ_WORK: u64 = 4; + /// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or /// the error, each with that count. type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usize)>; +/// What one operation over a document's images may still spend decoding them: exporting them, +/// optimizing them, and storing LZW streams with Flate as it does, clearing them for redaction. +/// +/// An image has its own limit, which doesn't bound a file of thousands of images made to reach it: +/// a few hundred bytes of JBIG2 can ask for seconds of decoding, 4 KB of Flate inside Flate for a +/// gigabyte, and an image that shares `/JBIG2Globals` decodes them again. So images share this +/// allowance, whatever they do with what they decode and whether or not it works, and past it they +/// aren't decoded. A caller then treats them as it does an image it can't read, and +/// [`ImageBudget::refused`] says how many there were. +/// +/// An image also pays for its pixels once decoded, as what reads it goes through them one by one +/// however few bytes coded them: blank rows of fax or Flate take almost none. It pays for no more +/// than its data holds, a bit a pixel at least, so an image declaring a size its data doesn't fill +/// costs what a smaller one would, not the run's allowance. A JPEG pays for its samples when +/// it is decoded ([`ImageBudget::pay_jpeg`]), and is otherwise not charged, as JPEG 2000 isn't: +/// their decoders don't run here. +#[derive(Debug)] +pub struct ImageBudget { + work: u64, + refused: usize, +} + +impl ImageBudget { + /// The allowance for an operation on `doc`, which a longer file makes larger. The streams made + /// or changed since it was opened count as the file's bytes do, so a document built in memory + /// (combined, created, or given pages of scans) gets what it would once saved and opened again. + pub fn new(doc: &Document) -> Self { + let length = |n: usize| u64::try_from(n).unwrap_or(u64::MAX); + let edited = doc + .scan_objects_subset(doc.modified_objects()) + .filter_map(|(_, object)| match &*object.ok()? { + Object::Stream(s) => Some(length(s.raw.len())), + _ => None, + }) + .fold(0u64, u64::saturating_add); + let bytes = length(doc.bytes().len()).saturating_add(edited); + Self::with_work(IMAGE_WORK.saturating_add(bytes.saturating_mul(IMAGE_WORK_PER_BYTE))) + } + + /// An allowance of `work` steps of decoding. + pub fn with_work(work: u64) -> Self { + Self { work, refused: 0 } + } + + /// The samples of image `s`, decoded and charged to the allowance: the one way to decode an + /// image outside the renderer, so that an operation over many images can't decode them without + /// one. + /// + /// They go through its filters ([`Stream::decoded`]), or, for a CCITT fax or JBIG2 image (its + /// last filter), through the filters before it and then [`pdfcraft_filters::decode_ccitt_within`] + /// with its [`Document::fax_params`], or [`pdfcraft_filters::decode_jbig2_within`] with its + /// `/JBIG2Globals`, as `CCITTFaxDecode` and `JBIG2Decode` give them (ISO 32000-2 §7.4.6, + /// §7.4.7). Parameters given by reference count, for every filter + /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`, and a + /// JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the whole page into + /// the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says; damaged + /// fax data gives fewer, so a caller checks it has the rows it needs. Damaged JBIG2 data, and + /// globals that can't be read (within [`MAX_JBIG2_GLOBALS`](crate::MAX_JBIG2_GLOBALS)), are an + /// error. + /// + /// No stage, the filters before a fax or JBIG2 decoder included, may produce more than `max` + /// bytes, nor more than [`MAX_DECODED`](crate::MAX_DECODED). A JBIG2 image's `/JBIG2Globals` + /// are another stream, shared between images, and are decoded within `MAX_JBIG2_GLOBALS` + /// whatever `max` is. Each filter as it runs, the globals when they have to be read, and the fax + /// or JBIG2 decoder spend from the allowance, and the image then pays for its pixels; with + /// nothing left an image is refused before anything is read, its globals included. + /// + /// ``` + /// # let doc = pdfcraft_cos::Document::new_empty(); + /// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new()); + /// let samples = pdfcraft_cos::ImageBudget::new(&doc).decode(&doc, &s, 1 << 20); + /// assert_eq!(samples.unwrap(), b""); + /// ``` + /// + /// `Document` has no way of its own to decode an image: + /// + /// ```compile_fail + /// # let doc = pdfcraft_cos::Document::new_empty(); + /// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new()); + /// let _ = doc.decoded_image(&s); + /// ``` + /// + /// ```compile_fail + /// # let doc = pdfcraft_cos::Document::new_empty(); + /// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new()); + /// let _ = doc.decoded_image_within(&s, 1 << 20); + /// ``` + pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { + let decoded = doc.decoded_image_spending(s, max, &mut self.work); + self.count_refused(s, &decoded); + self.pay_size(s, decoded?) + } + + /// Image `s` through its filters alone, charged to the allowance: [`Stream::decoded_within`] + /// with its `/DecodeParms` given by reference read too ([`Document::with_direct_parms`]), for an + /// image that has no fax or JBIG2 decoder to go through. + pub fn decode_filters(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { + let decoded = doc.with_direct_parms(s).decoded_spending(max, &mut self.work); + self.count_refused(s, &decoded); + let decoded = decoded?; + if s.filters().iter().all(|(f, _)| f.is_decodable()) { self.pay_size(s, decoded) } else { Ok(decoded) } + } + + /// `data` through `chain`, any corruption being an error ([`pdfcraft_filters::decode_within`]), + /// for a stream a run rewrites rather than an image it reads, charged to the allowance. + pub fn decode_chain( + &mut self, + chain: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)], + data: &[u8], + max: usize, + ) -> Result, FilterError> { + pdfcraft_filters::decode_within(chain, data, max, &mut self.work) + } + + /// Pays `steps` of other work on decoded data, or spends all there is and says so. + pub fn spend(&mut self, steps: u64) -> bool { + let left = self.work.checked_sub(steps); + self.work = left.unwrap_or(0); + left.is_some() + } + + /// Pays for `pixels` a decode goes on to work through, or spends all there is, and the image + /// is refused. + pub fn pay_pixels(&mut self, pixels: u64) -> bool { + let paid = self.spend(pixels); + self.refused += usize::from(!paid); + paid + } + + /// Pays for reading JPEG `data` and decoding its `samples`, as [`ImageBudget::pay_pixels`] does. + /// A caller pays before its tables are parsed, which can be most of the file. + pub fn pay_jpeg(&mut self, samples: u64, data: &[u8]) -> bool { + let reading = (data.len() as u64).saturating_mul(JPEG_READ_WORK); + self.pay_pixels(samples.saturating_mul(JPEG_WORK).saturating_add(reading)) + } + + /// Pays for the pixels of image `s` that its `decoded` data holds, those of its `/Width` and + /// `/Height` but no more than a bit of the data each, unless it is JBIG2, whose decoder pays for + /// its page, or isn't decoded here. What can't be paid for refuses the image. + fn pay_size(&mut self, s: &Stream, decoded: Vec) -> Result, CosError> { + if !charged(s) || matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { + return Ok(decoded); + } + let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok()).unwrap_or(0); + let held = u64::try_from(decoded.len()).unwrap_or(u64::MAX).saturating_mul(8); + if self.pay_pixels(side(b"Width").saturating_mul(side(b"Height")).min(held)) { + Ok(decoded) + } else { + Err(CosError::Filter(FilterError::Spent.to_string())) + } + } + + /// An image that failed with nothing left was refused, unless it has a filter that costs + /// nothing here, which fails whatever is left. + fn count_refused(&mut self, s: &Stream, decoded: &Result, CosError>) { + if decoded.is_err() && self.work == 0 && charged(s) { + self.refused += 1; + } + } + + /// The decodes refused for lack of allowance: an image read twice over is counted twice. + pub fn refused(&self) -> usize { + self.refused + } + + /// The steps of decoding still allowed. + pub fn work_left(&self) -> u64 { + self.work + } +} + +/// Whether `s` can be decoded here and so charged: general filters, a fax or JBIG2 decoder last at +/// most. Those decoders anywhere else can't run. +fn charged(s: &Stream) -> bool { + use pdfcraft_filters::Filter; + s.readable_filters().is_ok_and(|chain| { + chain.split_last().is_none_or(|((last, _), before)| { + (last.is_decodable() || matches!(last, Filter::Jbig2 | Filter::CcittFax)) && before.iter().all(|(f, _)| f.is_decodable()) + }) + }) +} + /// What one operation may still decode and enter across all the streams it reads. /// /// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose @@ -143,6 +351,401 @@ impl DecodeBudget { } } +#[cfg(test)] +mod image_tests { + use super::*; + use crate::Dict; + + fn jbig2(parms: Option) -> Stream { + 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"Filter".to_vec(), Object::name("JBIG2Decode")); + if let Some(parms) = parms { + dict.set(b"DecodeParms".to_vec(), parms); + } + Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()) + } + + /// Each image spends what it took from one allowance, and those past it are refused and + /// counted, whatever their filters. What can't be decoded here costs nothing, and fails + /// without being one of these. + #[test] + fn the_images_of_an_operation_share_its_allowance() { + let doc = Document::new_empty(); + let image = jbig2(None); + let mut budget = ImageBudget::with_work(u64::MAX); + let samples = budget.decode(&doc, &image, 1 << 20).unwrap(); + let cost = u64::MAX - budget.work_left(); + assert!(cost > 0); + let mut budget = ImageBudget::with_work(3 * cost); + for left in [2, 1, 0] { + assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples); + assert_eq!(budget.work_left(), left * cost); + } + assert_eq!(budget.refused(), 0, "the allowance held exactly three"); + for refused in [1, 2] { + assert!(budget.decode(&doc, &image, 1 << 20).is_err()); + assert_eq!(budget.refused(), refused); + } + assert!(budget.decode(&doc, &Stream::flate(Dict::new(), b"samples"), 1 << 20).is_err()); + assert!(budget.decode(&doc, &Stream::from_raw(Dict::new(), b"samples".to_vec()), 1 << 20).is_err()); + assert_eq!(budget.refused(), 4, "other images share it"); + for name in ["NoSuchFilter", "DCTDecode", "JPXDecode"] { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name(name)); + assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err()); + assert_eq!(budget.refused(), 4, "{name} isn't one of these"); + } + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Int(5)); + assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err()); + assert_eq!(budget.refused(), 4, "a /Filter that can't be read isn't either"); + } + + /// What a file of images each inflating a megabyte asks for: 4 KB of Flate inside Flate can ask + /// for a gigabyte, and these aren't JBIG2, so without the allowance nothing would stop them. + #[test] + fn filter_bombs_stop_at_the_allowance() { + let doc = Document::new_empty(); + let samples = vec![0; 1 << 20]; + let inner = pdfcraft_filters::encode_flate(&samples); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("FlateDecode")])); + let bomb = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&inner)); + let cost = (bomb.raw.len() + inner.len() + samples.len()) as u64; + let mut budget = ImageBudget::with_work(2 * cost + 1); + for left in [cost + 1, 1] { + assert_eq!(budget.decode(&doc, &bomb, 1 << 20).unwrap(), samples); + assert_eq!(budget.work_left(), left); + } + assert!(budget.decode(&doc, &bomb, 1 << 20).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1), "the first it can't pay for ends it"); + } + + /// Filters in front of a JBIG2 decoder pay as they run, on top of what the decoder takes, and + /// one that fails has still paid: nothing about the JBIG2 data is known by then, and each image + /// of a file can do this. + #[test] + fn filters_in_front_of_jbig2_pay_whether_or_not_they_work() { + let doc = Document::new_empty(); + let alone = jbig2(None); + let mut budget = ImageBudget::with_work(u64::MAX); + let samples = budget.decode(&doc, &alone, 1 << 20).unwrap(); + let decoding = u64::MAX - budget.work_left(); + let behind = |raw: Vec| { + let mut dict = alone.dict.clone(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("JBIG2Decode")])); + Stream::from_raw(dict, raw) + }; + + let image = behind(pdfcraft_filters::encode_flate(&alone.raw)); + let mut budget = ImageBudget::with_work(u64::MAX); + assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples); + assert_eq!(u64::MAX - budget.work_left(), decoding + (image.raw.len() + alone.raw.len()) as u64); + + // Past this image's limit: an error, with what finding out took paid and the rest left. + let bomb = behind(pdfcraft_filters::encode_flate(&vec![0; 1 << 16])); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode(&doc, &bomb, 1000).is_err()); + assert_eq!(u64::MAX - budget.work_left(), bomb.raw.len() as u64 + 1000); + assert_eq!(budget.refused(), 0, "that image's limit isn't the run's"); + + // A small allowance is a limit too: it stops there, and the image is refused. + let mut budget = ImageBudget::with_work(bomb.raw.len() as u64 + 10); + assert!(budget.decode(&doc, &bomb, 1 << 20).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + } + + /// Globals that are read again for every image pay every time, and fail having paid. Kept + /// ones have no filters left to undo, so they pay for being read and nothing more. + #[test] + fn globals_pay_for_their_filters_each_time_they_are_decoded() { + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let raw = pdfcraft_filters::encode_flate(&skipped); + let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + }; + let spent = |doc: &Document, s: &Stream| { + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(doc, s, 1 << 20).unwrap(); + u64::MAX - budget.work_left() + }; + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(dict, raw.clone()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let image = doc.add(Object::Stream(jbig2(Some(Object::Dict(parms))))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap(); + + // Added in memory, so not kept: the filters are undone for each read. + let s = stream(&doc, image); + let first = spent(&doc, &s); + assert_eq!(spent(&doc, &s), first, "not kept, so paid for again"); + + // Read from a file, so kept: the second read has no filters to undo. + let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let doc = Document::open(std::sync::Arc::new(file)).unwrap(); + let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap(); + let s = stream(&doc, image); + let filters = (raw.len() + skipped.len()) as u64; + assert_eq!(spent(&doc, &s), first, "the first read undoes them"); + assert_eq!(spent(&doc, &s), first - filters, "kept"); + + // Globals that fail pay too, with the image not refused: these go past their own limit. + let over = pdfcraft_filters::encode_flate(&vec![0; crate::MAX_JBIG2_GLOBALS + 1]); + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(dict, over.clone()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let image = jbig2(Some(Object::Dict(parms))); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode(&doc, &image, 1 << 20).unwrap_err().to_string().contains("globals")); + assert_eq!(u64::MAX - budget.work_left(), (over.len() + crate::MAX_JBIG2_GLOBALS) as u64); + assert_eq!(budget.refused(), 0); + } + + /// A fax image pays for what comes before its decoder and for the rows it asks for, and past + /// the allowance it is refused like any other. + #[test] + fn fax_images_share_the_allowance() { + let doc = Document::new_empty(); + let (w, h) = (4096u32, 64u32); + let coded = pdfcraft_filters::encode_g4(&vec![0xFF; 512 * h as usize], w, h).unwrap(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(w))); + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(i64::from(w))); + dict.set(b"Height".to_vec(), Object::Int(i64::from(h))); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + let image = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&coded)); + let pixels = u64::from(w) * u64::from(h); + let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64 + pixels; + let mut budget = ImageBudget::with_work(cost); + assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap().len(), 512 * h as usize); + assert_eq!(budget.work_left(), 0, "exactly that"); + assert!(budget.decode(&doc, &image, 1 << 20).is_err()); + assert_eq!(budget.refused(), 1); + } + + /// An image pays for its pixels however few bytes hold them, or a blank page of fax or Flate + /// would cost a run a second for a few bytes' worth of allowance; a JBIG2 image doesn't pay + /// twice, and one this doesn't decode pays nothing. Past what is left it isn't read. + #[test] + fn an_image_pays_for_its_pixels() { + let doc = Document::new_empty(); + let blank = |filter: Option<&str>, data: Vec| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(4096)); + dict.set(b"Height".to_vec(), Object::Int(1024)); + if let Some(filter) = filter { + dict.set(b"Filter".to_vec(), Object::name(filter)); + } + Stream::from_raw(dict, data) + }; + let rows = vec![0xFFu8; 512 * 1024]; + let flate = blank(Some("FlateDecode"), pdfcraft_filters::encode_flate(&rows)); + let cost = (flate.raw.len() + rows.len()) as u64 + 4096 * 1024; + let mut budget = ImageBudget::with_work(cost); + assert_eq!(budget.decode(&doc, &flate, 1 << 24).unwrap(), rows); + assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that"); + // Its filters alone pay the same. + let mut budget = ImageBudget::with_work(cost); + assert_eq!(budget.decode_filters(&doc, &flate, 1 << 24).unwrap(), rows); + assert_eq!(budget.work_left(), 0); + // Short of its pixels it is refused, having paid for its filters. + let mut budget = ImageBudget::with_work(4096 * 1024 - 1); + assert!(budget.decode(&doc, &flate, 1 << 24).unwrap_err().to_string().contains("more image decoding than")); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + let mut budget = ImageBudget::with_work(4096 * 1024 - 1); + assert!(budget.decode_filters(&doc, &flate, 1 << 24).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + // What isn't decoded here pays for nothing, and fails as it would. + let mut budget = ImageBudget::with_work(10); + assert!(budget.decode(&doc, &blank(Some("DCTDecode"), b"data".to_vec()), 1 << 24).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (10, 0)); + // Unfiltered data pays too, and a size that isn't one is no size. + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(&doc, &blank(None, rows.clone()), 1 << 24).unwrap(); + assert_eq!(u64::MAX - budget.work_left(), rows.len() as u64 + 4096 * 1024); + let mut odd = Dict::new(); + odd.set(b"Width".to_vec(), Object::Int(-4)); + odd.set(b"Height".to_vec(), Object::Int(9)); + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(&doc, &Stream::from_raw(odd, vec![1, 2]), 1 << 24).unwrap(); + assert_eq!(u64::MAX - budget.work_left(), 2); + // A JPEG pays twice over for each sample, and four times for each byte it is read from. + let mut budget = ImageBudget::with_work(30); + assert!(budget.pay_jpeg(9, b"ab")); + assert_eq!((budget.work_left(), budget.refused()), (4, 0)); + assert!(!budget.pay_jpeg(0, b"ab")); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + } + + /// An image pays for the pixels its data holds, not those its size declares: one claiming + /// 131072 by 131072 pixels with a few bytes of Flate costs what those bytes give, and the + /// images after it are decoded as they would be without it. + #[test] + fn an_image_pays_for_no_more_pixels_than_its_data_holds() { + let doc = Document::new_empty(); + let sized = |side: i64, data: &[u8]| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(side)); + dict.set(b"Height".to_vec(), Object::Int(side)); + Stream::flate(dict, data) + }; + let claims = sized(131_072, &[0; 16]); + let image = sized(64, &[7; 64 * 64]); + let cost = |s: &Stream| { + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(&doc, s, 1 << 24).unwrap(); + u64::MAX - budget.work_left() + }; + assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64, "its bytes, and a pixel a bit of them"); + assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + 64 * 64, "its bytes, and its pixels"); + for decode in [ImageBudget::decode, ImageBudget::decode_filters] { + let mut budget = ImageBudget::with_work(cost(&claims) + cost(&image)); + assert_eq!(decode(&mut budget, &doc, &claims, 1 << 24).unwrap(), [0; 16]); + assert_eq!(decode(&mut budget, &doc, &image, 1 << 24).unwrap(), [7; 64 * 64], "the image after it is decoded"); + assert_eq!((budget.work_left(), budget.refused()), (0, 0)); + } + } + + /// A stream a run rewrites is read strictly and charged, and what the caller does with it after + /// comes out of the same allowance: what can't be paid for spends all of it. + #[test] + fn a_rewritten_stream_shares_the_allowance() { + use pdfcraft_filters::{Filter, FilterError, Params}; + let samples = vec![7u8; 1000]; + let raw = pdfcraft_filters::encode_flate(&samples); + let chain = [(Filter::Flate, Params::default())]; + let cost = (raw.len() + samples.len()) as u64; + let mut budget = ImageBudget::with_work(cost + 10); + assert_eq!(budget.decode_chain(&chain, &raw, 1 << 20).unwrap(), samples); + assert_eq!(budget.work_left(), 10); + assert!(budget.spend(4)); + assert_eq!(budget.work_left(), 6); + assert!(budget.spend(6), "exactly what is left"); + assert_eq!(budget.work_left(), 0); + assert!(budget.spend(0)); + let mut budget = ImageBudget::with_work(5); + assert!(!budget.spend(6)); + assert_eq!(budget.work_left(), 0, "spends all of it"); + assert!(matches!(budget.decode_chain(&chain, &raw, 1 << 20), Err(FilterError::Spent))); + let cut = raw.get(..raw.len() / 2).unwrap(); + assert!(matches!(ImageBudget::with_work(u64::MAX).decode_chain(&chain, cut, 1 << 20), Err(FilterError::Corrupt { .. })), "strictly"); + } + + /// An image read through its filters alone reads parameters given by reference, is charged, and + /// is refused like the others. It isn't given to a decoder. + #[test] + fn filters_alone_are_charged_too() { + let mut doc = Document::new_empty(); + let p = pdfcraft_filters::Params { predictor: 12, columns: 4, ..pdfcraft_filters::Params::default() }; + let rows = vec![7u8; 400]; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(4)); + let parms = doc.add(Object::Dict(parms)); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + let raw = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &rows).unwrap(); + let image = Stream::from_raw(dict, raw); + let mut budget = ImageBudget::with_work(u64::MAX); + assert_eq!(budget.decode_filters(&doc, &image, 1 << 20).unwrap(), rows); + let cost = u64::MAX - budget.work_left(); + // The tag byte of each of its 100 rows counts, which the predictor takes off. + assert_eq!(cost, (image.raw.len() + rows.len() + 100) as u64); + let mut budget = ImageBudget::with_work(cost - 1); + assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode_filters(&doc, &jbig2(None), 1 << 20).is_err()); + assert_eq!(budget.work_left(), u64::MAX, "no decoder here, so nothing was read"); + // Nor for a fax image, nor for a decoder that isn't last, however large they say they are. + for chain in [vec!["CCITTFaxDecode"], vec!["CCITTFaxDecode", "FlateDecode"], vec!["JBIG2Decode", "FlateDecode"]] { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(1000)); + dict.set(b"Height".to_vec(), Object::Int(1000)); + dict.set(b"Filter".to_vec(), Object::Array(chain.iter().map(|f| Object::name(f)).collect())); + let image = Stream::from_raw(dict, b"data".to_vec()); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err()); + assert_eq!(budget.work_left(), u64::MAX, "{chain:?}"); + } + // Through `decode` a decoder that isn't last pays nothing either; one that is does. + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(1000)); + dict.set(b"Height".to_vec(), Object::Int(1000)); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("JBIG2Decode"), Object::name("FlateDecode")])); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err()); + assert_eq!(budget.work_left(), u64::MAX); + } + + /// The images of a file share its globals, so an image refused doesn't read them: this one's + /// can't be read, which is how to tell. + #[test] + fn an_image_past_the_allowance_does_not_read_its_globals() { + let mut doc = Document::new_empty(); + let mut unreadable = Dict::new(); + unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + let globals = doc.add(Object::Stream(Stream::from_raw(unreadable, b"data".to_vec()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let image = jbig2(Some(Object::Dict(parms))); + let read = ImageBudget::with_work(u64::MAX).decode(&doc, &image, 1 << 20).unwrap_err().to_string(); + assert!(read.contains("globals"), "{read}"); + let refused = ImageBudget::with_work(0).decode(&doc, &image, 1 << 20).unwrap_err().to_string(); + assert!(refused.contains("more image decoding than"), "{refused}"); + } + + /// A longer file gets more, by its length, and an empty one the fixed part. + #[test] + fn a_longer_file_allows_more_decoding() { + assert_eq!(ImageBudget::new(&Document::new_empty()).work_left(), IMAGE_WORK); + let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec(); + let len = pdf.len() as u64; + let doc = Document::open(std::sync::Arc::new(pdf)).unwrap(); + assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + len * IMAGE_WORK_PER_BYTE); + } + + /// A document made in memory, as Combine, Create and inserted scans make one, gets for the + /// streams it was given what it would get for them once saved: what isn't a stream adds nothing. + #[test] + fn streams_made_in_memory_allow_more_decoding_too() { + let mut doc = Document::new_empty(); + doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0; 3000]))); + doc.add(Object::Dict(Dict::new())); + doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 100]))); + let flate = pdfcraft_filters::encode_flate(&[1; 100]).len() as u64; + assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + (3000 + flate) * IMAGE_WORK_PER_BYTE); + // A stream of an opened file changed in memory counts with the file. + let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec(); + let len = pdf.len() as u64; + let mut doc = Document::open(std::sync::Arc::new(pdf)).unwrap(); + doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0; 500]))); + assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + (len + 500) * IMAGE_WORK_PER_BYTE); + } + + // What the constants' comments promise, for a page scanned at 300 dpi: about 1,000 of them + // whatever the file's size, and a page paying for itself with 2 KB of the file. + const PAGE: u64 = 17_000_000; + const _: () = assert!(IMAGE_WORK >= 1_000 * PAGE); + const _: () = assert!(IMAGE_WORK_PER_BYTE * 2_100 >= PAGE); + // And that a stream can be decoded about 8 times at Flate's best, 1,032 to one, and the byte in. + const _: () = assert!(IMAGE_WORK_PER_BYTE >= 7 * 1_033); +} + #[cfg(test)] mod tests { use super::*; @@ -198,6 +801,21 @@ mod tests { assert_eq!(budget.remaining(), (10 << 20) - partial); } + /// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it, + /// gives nothing back, and each reference still pays for what it decoded. + #[test] + fn a_decode_that_gives_nothing_back_still_pays() { + let mut data = vec![0x78, 0x01, 0x06, 0xFE, 0xF9]; + data.extend(vec![0u8; 0x0601]); + data.push(0x07); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let stream = Stream::from_raw(dict, data); + let mut budget = DecodeBudget::new(10 * 0x0601, 1); + assert!(budget.decode(&stream).unwrap().is_empty()); + assert_eq!(budget.remaining(), 9 * 0x0601); + } + /// A chain whose later stage shrinks the data pays for every stage's output: a Flate stage /// that inflates to hex text, which ASCIIHex then halves, charges both. #[test] diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 0d9b84b749..e52aba2ce3 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -10,7 +10,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex, Weak}; use crate::CosError; -use crate::object::{Dict, MAX_DECODED, ObjRef, Object, Stream}; +use crate::object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream}; use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; mod objects; @@ -19,6 +19,7 @@ mod reader; mod state; mod xref; +pub(crate) use objects::Held; #[cfg(test)] pub(crate) use reader::find; #[cfg(test)] @@ -27,7 +28,7 @@ pub(crate) use state::ObjectReader; use state::{ObjStm, Slot}; pub use state::{ReadState, SourceIdentity}; #[cfg(test)] -use state::{SECTION_READS, objstm_member, owned_bytes}; +use state::{SECTION_READS, held_bytes, objstm_member}; use xref::XrefIndex; pub use xref::{Revision, XrefEntry}; @@ -47,8 +48,11 @@ pub struct Document { /// `fill_xref_gaps`. located: Arc>, repair_log: Arc>, - cache: Arc>>>, + cache: Arc>, objstms: Arc>>>, + /// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan + /// share them, and each would otherwise decode them again (see `Document::jbig2_globals`). + jbig2_globals: Arc>>>>, stream_limit: usize, overlay: BTreeMap, next_num: u32, @@ -59,6 +63,10 @@ pub struct Document { security: Option>, /// Object number of the `/Encrypt` dictionary (never encrypted itself). encrypt_num: Option, + /// What that dictionary and `/ID` refer to, which the key comes from, and so is as stored. + encrypt_members: Arc>, + /// Object numbers a scan of the file didn't find: the file doesn't change, so it never will. + unfound: Arc>>, /// Encryption was added, changed or removed since opening: only a full save can apply it. encryption_changed: bool, /// Set by edits that must not leave earlier revisions in the file (redaction). diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 4f05d03ae0..62ef1e74cc 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,13 @@ use super::reader::find; -use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; +use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, held_bytes, objstm_member}; use super::xref::{Revision, XrefEntry}; -use super::{Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared}; +use super::{ + Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, +}; + +/// The most decoded `/JBIG2Globals` a document keeps for its images to share (see +/// `Document::jbig2_globals`): two streams at [`MAX_JBIG2_GLOBALS`]. +const JBIG2_GLOBALS_HELD: usize = 2 * MAX_JBIG2_GLOBALS; impl Document { pub fn bytes(&self) -> &Arc> { @@ -150,12 +156,16 @@ impl Document { self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null)) } - /// Follow a reference if `o` is one; otherwise return `o` itself. + /// Follow a reference if `o` is one; otherwise return `o` itself. An object can be nothing but + /// a reference to another, which is followed too, up to [`MAX_REFERENCE_HOPS`] of them. pub fn resolve(&self, o: &Object) -> Arc { - match o { - Object::Ref(r) => self.get(*r), - other => Arc::new(other.clone()), + let Object::Ref(r) = o else { return Arc::new(o.clone()) }; + let mut held = self.get(*r); + for _ in 0..MAX_REFERENCE_HOPS { + let Object::Ref(r) = &*held else { break }; + held = self.get(*r); } + held } /// `s` with the `/DecodeParms` its filters use made direct, so [`Stream::decoded`] reads them: @@ -212,7 +222,7 @@ impl Document { } /// `o`, or the object it refers to, without copying either. - fn held<'o>(&self, o: &'o Object) -> Held<'o> { + pub(crate) fn held<'o>(&self, o: &'o Object) -> Held<'o> { match o { Object::Ref(_) => Held::Resolved(self.resolve(o)), o => Held::Direct(o), @@ -233,18 +243,7 @@ impl Document { if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) { return None; } - // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the - // last filter but a /Crypt, which `filters` leaves out wherever it stands. - let index = match s.dict.get(b"Filter") { - Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), - _ => 0, - }; - let parms = match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { - Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), - Some(p) if index == 0 => p.as_dict().cloned(), - _ => None, - } - .unwrap_or_default(); + let parms = self.last_filter_parms(s); let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default); let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() { Some(Object::Bool(b)) => *b, @@ -262,42 +261,129 @@ impl Document { }) } - /// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a - /// CCITT fax image (its last filter), through the filters before it and then - /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], as `CCITTFaxDecode` - /// gives them (ISO 32000-2 §7.4.6). Parameters given by reference count, for every filter - /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`. Up to - /// its `/Height` rows are decoded, whatever `/Rows` says, within [`MAX_DECODED`] bytes; damaged - /// data gives fewer, so a caller checks it has the rows it needs. - pub fn decoded_image(&self, s: &Stream) -> Result, CosError> { + /// The `/DecodeParms` dictionary of stream `s`'s last filter, resolved: that of a lone filter, + /// or its entry in an array. Empty when there is none. + fn last_filter_parms(&self, s: &Stream) -> Dict { + // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the + // last filter but a /Crypt, which `filters` leaves out wherever it stands. + let index = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), + _ => 0, + }; + match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { + Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), + Some(p) if index == 0 => p.as_dict().cloned(), + _ => None, + } + .unwrap_or_default() + } + + /// [`Document::decoded_image_spending`] with no limit on the work, for tests of what an image + /// decodes to: everything else decodes images through an [`ImageBudget`](crate::ImageBudget). + #[cfg(test)] + pub(crate) fn decoded_image(&self, s: &Stream) -> Result, CosError> { self.decoded_image_within(s, MAX_DECODED) } - /// [`Document::decoded_image`] for an image whose decoding has a tighter bound than - /// [`MAX_DECODED`]: no stage, the filters before a fax decoder included, may produce more than - /// `max` bytes. - pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { + /// [`Document::decoded_image`] within `max` bytes. + #[cfg(test)] + pub(crate) fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { + let mut unbounded = u64::MAX; + self.decoded_image_spending(s, max, &mut unbounded) + } + + /// The samples of image `s`, decoded within `max` bytes ([`MAX_DECODED`] at most), spending from + /// `work`: see [`ImageBudget::decode`](crate::ImageBudget::decode), the way in from outside this + /// crate, which says what this decodes and how. + pub(crate) fn decoded_image_spending(&self, s: &Stream, max: usize, work: &mut u64) -> Result, CosError> { let max = max.min(MAX_DECODED); let s = &*self.with_direct_parms(s); - let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; + let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string()); + let chain = s.readable_filters().map_err(filter)?; + // The filters before the image codec, such as ASCII85Decode, are undone first. + let undo = |before: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)], work: &mut u64| match before { + [] => Ok(Cow::Borrowed(&s.raw[..])), + _ => pdfcraft_filters::decode_tolerant_within(before, &s.raw, max, work).map(Cow::Owned).map_err(filter), + }; + // `/Width` and `/Height` are read as given directly, as every caller reads them first: one + // given by reference has the image refused there, before it gets here. + let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()); + if let Some(((pdfcraft_filters::Filter::Jbig2, _), before)) = chain.split_last() { + let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else { + return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into())); + }; + // Nothing to read for what can't be decoded: the images of a file share its globals. + let globals = if *work == 0 { None } else { self.jbig2_globals(s, work)? }; + let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? }; + return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter); + } + let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) }; // As many rows as the image has: a /Rows below /Height would cut the picture short, and // rows past /Height aren't drawn. let p = pdfcraft_filters::CcittParams { rows: 0, ..p }; if s.dict.int(b"Width") != Some(p.columns) { return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into())); } - let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0); let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1); - let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height); - // The filters before it, such as ASCII85Decode, are undone first. - let chain = s.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?; - let data = match chain.split_last() { - Some((_, before)) if !before.is_empty() => { - Cow::Owned(pdfcraft_filters::decode_tolerant(before, &s.raw, max).map_err(|e| CosError::Filter(e.to_string()))?.0) - } - _ => Cow::Borrowed(&s.raw[..]), + let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height.unwrap_or(0)); + let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before), work)?; + pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter) + } + + /// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's + /// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without + /// globals. Redaction finds with it the other images that share a redacted image's globals. + pub fn jbig2_globals_ref(&self, s: &Stream) -> Option { + let chain = s.readable_filters().ok()?; + if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { + return None; + } + self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref) + } + + /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) + /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is + /// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read + /// without them, as the renderer draws it, so an image that needed them mostly fails to decode + /// and can decode to other pixels. A stream that is there and can't be read is an error, rather + /// than the image decoded without it. + /// + /// The images of a scan share one globals stream, so a stream of the file, not edited since + /// it was read (the same in every clone) and with its `/DecodeParms` direct (by reference they + /// are another object, which can change while this one doesn't), has its filters undone once a + /// document and is kept, up to [`JBIG2_GLOBALS_HELD`] in all; others are read each time. That + /// saves the filters only: the segments it holds are decoded again for every image. Undoing + /// the filters spends from `work`, so globals read for every image pay every time. + fn jbig2_globals(&self, s: &Stream, work: &mut u64) -> Result>>, CosError> { + let Some(entry) = self.last_filter_parms(s).get(b"JBIG2Globals").cloned() else { return Ok(None) }; + let held = match entry { + Object::Ref(r) if !self.overlay.contains_key(&r.num) => Some(r.num), + _ => None, }; - pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(|e| CosError::Filter(e.to_string())) + if let Some(hit) = held.and_then(|num| self.jbig2_globals.lock().ok()?.get(&num).cloned()) { + return Ok(Some(hit)); + } + let Object::Stream(g) = &*self.resolve(&entry) else { return Ok(None) }; + let readable = self.with_direct_parms(g); + let held = held.filter(|_| matches!(readable, Cow::Borrowed(_))); + let mut data = readable.decoded_spending(MAX_JBIG2_GLOBALS, work).map_err(|e| { + let detail = match e { + CosError::Filter(detail) => detail, + e => e.to_string(), + }; + CosError::Filter(format!("the JBIG2 globals can't be read: {detail}")) + })?; + // What is kept counts by its length, so that's all it may hold on to. + data.shrink_to_fit(); + let data = Arc::new(data); + // A poisoned lock only means nothing is kept. + if let (Some(num), Ok(mut kept)) = (held, self.jbig2_globals.lock()) { + let total = kept.values().map(|g| g.len()).fold(data.len(), usize::saturating_add); + if total <= JBIG2_GLOBALS_HELD { + kept.insert(num, data.clone()); + } + } + Ok(Some(data)) } pub fn try_get(&self, num: u32) -> Result, CosError> { @@ -313,12 +399,14 @@ impl Document { // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path. let _guard = LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?; + let _scope = crate::security::FollowScope::enter(); let obj = self.load(num, 0)?; let obj = Arc::new(obj); // The editing cache is bounded like the scan reader's: re-parsing a file-backed view // is cheap, and returned objects outlive eviction. - self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone()); - self.evict_editor_cache_if_over_budget(num, &obj)?; + let owned = held_bytes(&obj, &self.data, 0); + self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone(), owned); + self.evict_editor_cache_if_over_budget(num)?; Ok(obj) } @@ -327,10 +415,10 @@ impl Document { /// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes /// count; file-backed views re-parse for free. /// - /// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` × - /// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full - /// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. - /// The count cap always holds; byte accounting between passes is approximate. + /// Worst case per document: input bytes + `EDITOR_CACHE_BYTES` (32 MB) of owned object + /// data + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. Each object is measured + /// once, as it is cached ([`ObjectCache`](super::state::ObjectCache)), so both caps hold + /// after every insertion. /// Clones share these caches, so one clone's eviction drops another's hot set; /// that only costs re-parsing, never correctness. Eviction keeps just the newcomer: /// file-backed views re-parse cheaply and editor working sets stay far below 1024. @@ -343,37 +431,13 @@ impl Document { const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024; - /// Newcomers above this force a full accounting pass even below the count cap: - /// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the - /// byte budget can double. - const EDITOR_LARGE_OBJECT: usize = 256 * 1024; - - fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> { - // Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer - // below the count cap. `len` is copied out first; nothing here holds the lock. - let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len(); - if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT { - return Ok(()); - } - // Walk a snapshot off-lock; re-lock only to evict. - let live: Vec<(u32, Arc)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect(); - let data = &self.data; - let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0))); - if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } - // Re-check under the final lock: another thread may have inserted or evicted since - // the snapshot. Keep its newcomers, and skip the eviction if back within budget. - let snapshot: std::collections::HashSet = live.into_iter().map(|(n, _)| n).collect(); + fn evict_editor_cache_if_over_budget(&self, fresh_num: u32) -> Result<(), CosError> { let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?; - if cache.len() <= Self::EDITOR_CACHE_OBJECTS { - let data = &self.data; - let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))); - if owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } + if cache.len() <= Self::EDITOR_CACHE_OBJECTS && cache.owned() <= Self::EDITOR_CACHE_BYTES { + return Ok(()); } - cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n)); + // Eviction keeps just the newcomer: file-backed views re-parse cheaply. + cache.retain(|n| *n == fresh_num); Ok(()) } @@ -503,7 +567,11 @@ impl Document { let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok(); - let data = s.decoded_within(self.stream_limit)?; + // Its parameters can be given by reference, and can sit in another object stream. A scan of + // the file's object streams comes here without a read around it. + let _scope = crate::security::FollowScope::enter(); + let readable = crate::security::following(Cow::Borrowed(s), || self.with_direct_parms(s)); + let data = readable.decoded_within(self.stream_limit)?; let mut lx = Lexer::new(&data, 0); let mut index = Vec::with_capacity(n); for _ in 0..n { @@ -518,8 +586,14 @@ impl Document { Ok(stm) } - /// Last-resort lookup: scan the file for `num G obj`. + /// Last-resort lookup: scan the file for `num G obj`. What isn't there is remembered: each miss + /// reads the whole file, and a file can refer to such an object from everywhere. fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> { + if self.unfound.lock().is_ok_and(|unfound| unfound.contains(&num)) { + return None; + } + #[cfg(test)] + super::tests::FILE_SCANS.with(|scans| scans.set(scans.get() + 1)); let needle = format!("{num} "); let data = &self.data; let mut found = None; @@ -535,6 +609,11 @@ impl Document { found = Some((id, o)); // keep the last (newest) definition } } + if found.is_none() + && let Ok(mut unfound) = self.unfound.lock() + { + unfound.insert(num); + } found } @@ -600,8 +679,9 @@ impl Document { /// /// Used when the working snapshot was restored from an earlier revision and the next /// incremental save must still append to this file (the last saved revision). Objects - /// that already match are left untouched. Reads go through private readers so neither - /// document's editing cache is filled. + /// that already match are left untouched, except members of an object stream of this file + /// whose stream number `current` frees or reuses: those are written again on their own. + /// Reads go through private readers so neither document's editing cache is filled. pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> { let saved_nums = self.object_numbers(); let current_nums = current.object_numbers(); @@ -625,6 +705,22 @@ impl Document { } } } + // An object stream of this file that is freed or replaced takes its members with it, so + // a member that is otherwise unchanged is written again on its own. + let gone = |n: u32| freed.binary_search(&n).is_ok() || changed.binary_search(&n).is_ok(); + let orphaned: Vec = current_nums + .iter() + .copied() + .filter(|&num| changed.binary_search(&num).is_err()) + .filter(|&num| { + let packed_in = match self.xref_entry(num) { + Some(XrefEntry::InStream { stream, .. }) => Some(stream), + _ => None, + }; + packed_in.is_some_and(gone) + }) + .collect(); + changed.extend(orphaned); for num in freed { let generation = self.generation(num); self.free(ObjRef::new(num, generation)); @@ -727,9 +823,13 @@ const DECODE_PARMS_KEYS: [&[u8]; 13] = [ b"ColorTransform", ]; +/// How many references in a row [`Document::resolve`] follows: enough for any file, and it ends a +/// cycle of them. +const MAX_REFERENCE_HOPS: usize = 32; + /// An object read in place, or the object a reference names as the document holds it: either way /// nothing is copied ([`Document::held`]). -enum Held<'o> { +pub(crate) enum Held<'o> { Direct(&'o Object), Resolved(Arc), } diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 57d697ed66..70bbcc313e 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,7 +1,11 @@ use super::reader::find; use super::state::Credentials; use super::xref::XrefIndex; -use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashSet, ObjRef, Object}; + +/// More objects than `/Encrypt` and `/ID` refer to in any file, so that one which refers to the page +/// tree, say, doesn't have a whole document read to open it. +const MAX_ENCRYPT_MEMBERS: usize = 64; impl Document { /// A new document with an empty page tree (for split / extract / combine). It has no @@ -19,6 +23,7 @@ impl Document { repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), + jbig2_globals: Arc::default(), stream_limit: crate::object::MAX_DECODED, overlay: BTreeMap::new(), next_num: 1, @@ -26,6 +31,8 @@ impl Document { version: "1.7".into(), security: None, encrypt_num: None, + encrypt_members: Arc::default(), + unfound: Arc::default(), encryption_changed: false, full_save: false, out_security: None, @@ -102,6 +109,7 @@ impl Document { repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), + jbig2_globals: Arc::default(), stream_limit: stream_limit.min(crate::object::MAX_DECODED), overlay: BTreeMap::new(), next_num: 1, @@ -109,6 +117,8 @@ impl Document { version, security: None, encrypt_num: None, + encrypt_members: Arc::default(), + unfound: Arc::default(), encryption_changed: false, full_save: false, out_security: None, @@ -183,6 +193,8 @@ impl Document { }; let params = crate::security::encrypt_dict(self, &dict); let id0 = self.first_id(); + // Read here, while nothing is decrypted yet. + let members = self.encryption_members(enc); let handler = match credentials { Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e { pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, @@ -195,6 +207,7 @@ impl Document { }; self.security = Some(handler); self.encrypt_num = num; + self.encrypt_members = Arc::new(members); // Objects read while locating the catalog were read before decryption was possible. self.cache = Arc::default(); self.objstms = Arc::default(); @@ -203,10 +216,42 @@ impl Document { /// The first file identifier, part of the encryption key (empty when there is none). fn first_id(&self) -> Vec { - match self.trailer.get(b"ID") { - Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(), - _ => Vec::new(), + let id = self.trailer.get(b"ID").map(|id| self.resolve(id)); + let first = id.as_deref().and_then(Object::as_array).and_then(|a| a.first()).map(|s| self.resolve(s)); + first.as_deref().and_then(Object::as_string).map(|s| s.bytes.clone()).unwrap_or_default() + } + + /// The objects `/Encrypt`, given as `enc`, and `/ID` refer to, the ones they refer to, and so + /// on, up to [`MAX_ENCRYPT_MEMBERS`]. Their strings aren't encrypted (ISO 32000-2 §7.6.2): the + /// key is made from them, so they have to read as stored, and stay so. A number with no object + /// behind it, and a stream, which those dictionaries never hold, isn't one: an object added + /// under that number later, or a page's content a hostile `/Encrypt` names, is encrypted. + fn encryption_members(&self, enc: &Object) -> HashSet { + let mut pending = Vec::new(); + crate::writer::collect_refs(enc, &mut |r| pending.push(r)); + if let Some(id) = self.trailer.get(b"ID") { + crate::writer::collect_refs(id, &mut |r| pending.push(r)); } + let mut members = HashSet::new(); + while let Some(r) = pending.pop() { + if members.len() >= MAX_ENCRYPT_MEMBERS || members.contains(&r.num) { + continue; + } + let held = self.get(r); + if matches!(&*held, Object::Null | Object::Stream(_)) { + continue; + } + members.insert(r.num); + crate::writer::collect_refs(&held, &mut |x| pending.push(x)); + } + members + } + + /// Whether object `num` is written as it is held, being one of [`Document::encryption_members`], + /// unedited, and the save writing the same `/Encrypt`. What was edited or added is encrypted + /// whatever its number. + pub(crate) fn stored_as_is(&self, num: u32) -> bool { + !self.encryption_changed && !self.is_edited(num) && self.encrypt_members.contains(&num) } /// Lift the restrictions of a document opened with its user password by entering the @@ -268,8 +313,9 @@ impl Document { /// always a full rewrite. Returns the handler (authenticated as owner). pub fn set_encryption(&mut self, params: &pdfcraft_crypt::NewEncryption) -> Result<(), CosError> { // The file identifier is part of the key; make sure it exists and keep it. - let id0 = match self.trailer.get(b"ID") { - Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(), + let id = self.trailer.get(b"ID").map(|id| self.resolve(id)); + let id0 = match id.as_deref() { + Some(Object::Array(a)) if a.len() == 2 => self.first_id(), _ => { let id = pdfcraft_crypt::file_id(¶ms.seed); let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true }); @@ -336,7 +382,9 @@ impl Document { /// Decrypt a freshly parsed indirect object if the document is encrypted. pub(super) fn decrypted(&self, id: ObjRef, o: Object) -> Object { match &self.security { - Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt), + Some(h) if Some(id.num) != self.encrypt_num && !self.encrypt_members.contains(&id.num) => { + crate::security::transform(self, h, &o, id.num, id.generation, crate::security::Pass::Decrypt) + } _ => o, } } diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index b4323f982e..96dde01c0f 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -163,11 +163,10 @@ impl ObjectReader { // input bytes, which the document keeps anyway, so they don't count; decrypted // streams and strings do. Checked after failed loads too: resolving a /Length may // have cached large objects. - if let Ok(mut cache) = self.document.cache.lock() { - let data = &self.document.data; - if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES { - cache.clear(); - } + if let Ok(mut cache) = self.document.cache.lock() + && (cache.len() >= Self::OBJECTS || cache.owned() > Self::STREAM_BYTES) + { + cache.clear(); } object } @@ -179,26 +178,96 @@ thread_local! { pub(super) static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// Parsed objects by number, each with what it holds ([`held_bytes`]) measured once, when it goes +/// in, and their sum kept beside them. +/// +/// Both caches are bounded by those bytes. Summing them on every read walked every cached object +/// whole each time: a file whose page dictionaries carry kilobytes of private data (InDesign's +/// `/PieceInfo`) took seconds to open, almost all of it re-measuring objects already measured +/// (#5873). +#[derive(Default)] +pub(crate) struct ObjectCache { + entries: HashMap, usize)>, + owned: usize, +} + +impl ObjectCache { + pub(crate) fn get(&self, num: &u32) -> Option<&Arc> { + self.entries.get(num).map(|(object, _)| object) + } + + #[cfg(test)] + pub(crate) fn contains_key(&self, num: &u32) -> bool { + self.entries.contains_key(num) + } + + /// Keep `object` as `num`, which holds `owned` bytes (what [`held_bytes`] says of it). + pub(crate) fn insert(&mut self, num: u32, object: Arc, owned: usize) { + if let Some((_, before)) = self.entries.insert(num, (object, owned)) { + self.owned = self.owned.saturating_sub(before); + } + self.owned = self.owned.saturating_add(owned); + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The bytes the cached objects hold between them. + pub(crate) fn owned(&self) -> usize { + self.owned + } + + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.owned = 0; + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) { + self.entries.retain(|num, _| keep(num)); + self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned)); + } + + #[cfg(test)] + pub(crate) fn values(&self) -> impl Iterator> { + self.entries.values().map(|(object, _)| object) + } +} + +/// Nesting [`held_bytes`] walks: past the parser's own nesting limit, so every parsed object /// is measured in full. const OWNED_BYTES_DEPTH: usize = 256; -/// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured -/// and counts as too large to keep. -pub(super) fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { +/// What keeping `object` parsed costs: string and stream data that is not a view into `data` +/// (the input file), and the parsed tree around it -- an [`Object`] per value and a key per +/// dictionary entry. An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured and +/// counts as too large to keep. +/// +/// The tree counts. Measured by its strings and streams alone, a page dictionary carrying +/// InDesign's `/PieceInfo` -- thousands of small numeric arrays, parsed into tens of thousands of +/// values -- weighed nothing, so the caches kept a thousand of them: a 35 MB file held 1.5 GB +/// (#5873). +pub(super) fn held_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { if depth > OWNED_BYTES_DEPTH { return usize::MAX; } + let node = std::mem::size_of::(); + // A key, its slot beside the value, and room in the index a large dictionary keeps. + let entry = |key: &[u8]| key.len().saturating_add(std::mem::size_of::<(crate::Name, Object)>()).saturating_add(16); + let dict = + |d: &crate::Dict, start: usize| d.iter().fold(start, |n, (k, v)| n.saturating_add(entry(k)).saturating_add(held_bytes(v, data, depth + 1))); match object { - Object::String(s) => s.bytes.len(), - Object::Stream(s) => { - let own = if s.raw.shares(data) { 0 } else { s.raw.len() }; - s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))) - } - Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))), - Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))), - _ => 0, + Object::String(s) => node.saturating_add(s.bytes.len()), + Object::Name(n) => node.saturating_add(n.len()), + Object::Stream(s) => dict(&s.dict, node.saturating_add(if s.raw.shares(data) { 0 } else { s.raw.len() })), + Object::Array(a) => a.iter().fold(node, |n, v| n.saturating_add(held_bytes(v, data, depth + 1))), + Object::Dict(d) => dict(d, node), + _ => node, } } diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index f5e36f9c83..f12437baaf 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1,5 +1,147 @@ use super::*; +std::thread_local! { + /// How many times `Document::scan_for` read the file on this thread. + pub(super) static FILE_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A scan that doesn't find an object isn't made again: each reads the whole file, and a file can +/// refer to such an object from every object it has. +#[test] +fn what_a_scan_of_the_file_did_not_find_is_remembered() { + let body = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n"; + let mut out = body.to_vec(); + let xref = out.len(); + out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n"); + out.extend_from_slice(format!("{:010} 00000 n \n", 9).as_bytes()); + out.extend_from_slice(format!("{:010} 00000 n \n", body.iter().position(|b| *b == b'\n').unwrap() + 49).as_bytes()); + out.extend_from_slice(b"0000000001 00000 n \n"); + out.extend_from_slice(format!("trailer << /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + FILE_SCANS.with(|scans| scans.set(0)); + for _ in 0..5 { + assert!(doc.try_get(3).is_err()); + } + assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1); + let clone = doc.clone(); + assert!(clone.try_get(3).is_err()); + assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "a clone has the same file"); +} + +/// An object can be nothing but a reference to another. A chain of them is followed, and one that +/// goes round ends in what isn't a dictionary or a name, without hanging. +#[test] +fn references_to_references_are_followed() { + let mut doc = Document::new_empty(); + let name = doc.add(Object::name("Target")); + let one = doc.add(Object::Ref(name)); + let two = doc.add(Object::Ref(one)); + assert_eq!(doc.resolve(&Object::Ref(two)).as_name(), Some(b"Target".as_slice())); + let a = doc.add(Object::Null); + let b = doc.add(Object::Ref(a)); + doc.set(a, Object::Ref(b)); + assert_eq!(doc.resolve(&Object::Ref(a)).as_name(), None); +} + +/// An object stream's `/DecodeParms` can be given by reference: without them a predictor isn't +/// undone, so its members were lost. +#[test] +fn an_object_stream_with_parameters_by_reference_is_read() { + let members = b"3 0 << /Hidden (yes) >>"; + // PNG Sub, one row. + let mut rows = vec![1u8]; + let mut before = 0u8; + for b in members { + rows.push(b.wrapping_sub(before)); + before = *b; + } + let coded = pdfcraft_filters::encode_flate(&rows); + let mut out = b"%PDF-1.5\n".to_vec(); + let mut offs = [0usize; 7]; + let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { + offs[n] = out.len(); + out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); + let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms 6 0 R /Length {} >>\nstream\n", coded.len()).into_bytes(); + stm.extend_from_slice(&coded); + stm.extend_from_slice(b"\nendstream"); + obj(&mut out, 4, &stm); + let data = [2u8, 0, 4, 0]; + let mut xs = format!("<< /Type /XRef /Size 7 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes(); + xs.extend_from_slice(&data); + xs.extend_from_slice(b"\nendstream"); + obj(&mut out, 5, &xs); + obj(&mut out, 6, format!("<< /Predictor 11 /Columns {} >>", members.len()).as_bytes()); + let table = out.len(); + out.extend_from_slice(b"xref\n0 7\n0000000000 65535 f \n"); + for (n, off) in offs.iter().enumerate().skip(1) { + let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") }; + out.extend_from_slice(line.as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size 7 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + let hidden = doc.get(ObjRef::new(3, 0)); + assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden")).and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"yes".to_vec())); +} + +/// Each entry of an `/Encrypt` dictionary, and of a crypt filter in it, reads the same by reference. +/// Read as absent, `/StmF` or `/CFM` was `/Identity`, so a whole file's streams stayed ciphertext. +#[test] +fn encrypt_dictionary_entries_read_the_same_by_reference() { + let string = |s: &str| Object::String(crate::PdfString::text(s)); + let mut filter = Dict::new(); + filter.set(b"CFM".to_vec(), Object::name("AESV2")); + filter.set(b"Length".to_vec(), Object::Int(5)); + let cf = |filter: Dict| { + let mut cf = Dict::new(); + cf.set(b"StdCF".to_vec(), Object::Dict(filter)); + cf.set(b"Other".to_vec(), Object::Dict(Dict::new())); + Object::Dict(cf) + }; + let mut d = Dict::new(); + let entries = [ + ("Filter", Object::name("Standard")), + ("V", Object::Int(4)), + ("R", Object::Int(4)), + ("Length", Object::Int(128)), + ("P", Object::Int(-44)), + ("O", string("owner")), + ("U", string("user")), + ("EncryptMetadata", Object::Bool(false)), + ("CF", cf(filter.clone())), + ("StmF", Object::name("StdCF")), + ("StrF", Object::name("Other")), + ("EFF", Object::name("StdCF")), + ]; + for (key, value) in &entries { + d.set(key.as_bytes().to_vec(), value.clone()); + } + let mut doc = Document::new_empty(); + let direct = crate::security::encrypt_dict(&doc, &d); + assert_eq!((direct.stm_f.as_slice(), direct.str_f.as_slice(), direct.ef_f.as_slice()), (&b"StdCF"[..], &b"Other"[..], &b"StdCF"[..])); + assert_eq!(direct.length_bits, 40, "the filter's own length, in bytes"); + assert!(!direct.encrypt_metadata); + assert_eq!(direct.crypt_filters.iter().filter(|(_, m)| *m == pdfcraft_crypt::Method::Aes128).count(), 1); + for (key, value) in &entries { + let mut one = d.clone(); + let r = doc.add(value.clone()); + one.set(key.as_bytes().to_vec(), Object::Ref(r)); + assert_eq!(crate::security::encrypt_dict(&doc, &one), direct, "/{key}"); + } + for key in ["CFM", "Length"] { + let mut by_reference = filter.clone(); + let r = doc.add(filter.get(key.as_bytes()).unwrap().clone()); + by_reference.set(key.as_bytes().to_vec(), Object::Ref(r)); + let mut one = d.clone(); + one.set(b"CF".to_vec(), cf(by_reference)); + assert_eq!(crate::security::encrypt_dict(&doc, &one), direct, "/{key} in the filter"); + } +} + /// /DecodeParms given by reference, whole, as array entries or as the numbers inside, are read /// once made direct: here a Flate predictor, which the stream alone doesn't see. Only the entries the filters use are /// resolved, and nothing is for a chain longer than a decoder takes. @@ -173,6 +315,217 @@ fn fax_images_decode_with_their_parameters() { assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name"); } +/// Images that share a `/JBIG2Globals` stream of the file decode it once a document: it is kept +/// for the next, in every clone. A globals stream edited since is read afresh (the kept one is the +/// file's), and objects added in memory aren't kept. +#[test] +fn shared_jbig2_globals_are_read_once() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped)))); + let image = |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.add(Object::Stream(Stream::from_raw(d, coded.clone()))) + }; + let (a, b) = (image(&mut doc), image(&mut doc)); + let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + }; + assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits); + assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "an object added in memory isn't kept"); + + // The same objects read from a file, the catalog holding on to the images (a full save may + // number them anew). + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(vec![Object::Ref(a), Object::Ref(b)]))).unwrap(); + let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(file)).unwrap(); + let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone(); + let (a, b) = (images[0].as_ref().unwrap(), images[1].as_ref().unwrap()); + let parms = stream(&doc, a).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().clone(); + let globals = parms.reference(b"JBIG2Globals").unwrap(); + let clone = doc.clone(); + assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits); + assert_eq!(doc.jbig2_globals.lock().unwrap().len(), 1, "kept"); + let kept = doc.jbig2_globals.lock().unwrap()[&globals.num].clone(); + assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone's other image"); + let shared = clone.jbig2_globals.lock().unwrap()[&globals.num].clone(); + assert!(Arc::ptr_eq(&kept, &shared), "the clone uses the one kept, not a second decode"); + doc.set(globals, Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec()))); + assert!(doc.decoded_image(&stream(&doc, b)).is_err(), "the edited globals are read afresh"); + assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone keeps the file's"); +} + +/// A JBIG2 image's size is read as given directly, as every caller reads it first, and an image +/// with neither is an error, not an image of no rows. +#[test] +fn a_jbig2_image_needs_its_size_given_directly() { + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF, 0xFF], 8, 2).unwrap(); + let mut doc = Document::new_empty(); + let size = doc.add(Object::Int(2)); + let image = |width: Object, height: Object| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), width); + d.set(b"Height".to_vec(), height); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + doc.decoded_image(&Stream::from_raw(d, coded.clone())) + }; + assert!(image(Object::Int(8), Object::Int(2)).is_ok()); + assert!(image(Object::Ref(size), Object::Int(2)).is_err(), "/Width by reference"); + assert!(image(Object::Int(8), Object::Ref(size)).is_err(), "/Height by reference"); +} + +/// A document that kept globals of 64 MiB keeps no more, and the images past that still read: +/// three streams of 22 MiB, shared by three images, are two kept and one decoded each time. +#[test] +fn kept_jbig2_globals_are_bounded_in_total() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let length = 22 * 1024 * 1024; + let mut skipped = vec![0, 0, 0, 0, 0x3E, 0, 0]; + skipped.extend_from_slice(&u32::try_from(4 + length).unwrap().to_be_bytes()); + skipped.extend_from_slice(&[0, 0, 0, 1]); + skipped.resize(skipped.len() + length, 0); + let mut doc = Document::new_empty(); + let mut images = Vec::new(); + for _ in 0..3 { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped)))); + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + images.push(Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, coded.clone()))))); + } + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(images))).unwrap(); + let doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone(); + for image in &images { + let Object::Stream(s) = &*doc.get(image.as_ref().unwrap()) else { panic!("not a stream") }; + assert_eq!(doc.decoded_image(s).unwrap(), bits, "every image reads"); + } + let kept = doc.jbig2_globals.lock().unwrap(); + assert_eq!(kept.len(), 2, "two fit in 64 MiB, a third doesn't"); + assert!(kept.values().all(|g| g.capacity() == g.len()), "what is kept holds no more than it counts"); +} + +/// Globals whose `/DecodeParms` are given by reference aren't kept: that's another object, so +/// editing it changes what they decode to while the stream itself, the key kept by, stays the same. +#[test] +fn jbig2_globals_with_parameters_by_reference_are_read_afresh() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 15, early_change: 1 }; + let mut doc = Document::new_empty(); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(15)); + let parms = doc.add(Object::Dict(parms)); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &skipped).unwrap(); + let globals = doc.add(Object::Stream(Stream::from_raw(d, predicted))); + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut image_parms = Dict::new(); + image_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(image_parms)); + let image = doc.add(Object::Stream(Stream::from_raw(d, coded))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap(); + let mut doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap(); + let stream = |doc: &Document| match &*doc.get(image) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + }; + assert_eq!(doc.decoded_image(&stream(&doc)).unwrap(), bits); + assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "not kept"); + let globals = stream(&doc).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().reference(b"JBIG2Globals").unwrap(); + let Object::Stream(g) = &*doc.get(globals) else { panic!("not a stream") }; + let parms = g.dict.reference(b"DecodeParms").unwrap(); + doc.set(parms, Object::Dict(Dict::new())); + assert!(doc.decoded_image(&stream(&doc)).is_err(), "without the predictor they read otherwise"); +} + +/// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of +/// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference +/// too): globals that can't be read are an error, and ones at another filter's place aren't its +/// own, nor are those of a lone dictionary when other filters come first. Its page has to be its +/// /Width × /Height, and fit the bound. +#[test] +fn jbig2_images_decode_with_their_globals() { + // 10 × 3: the first five samples of each row white, the rest black. + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + let mut doc = Document::new_empty(); + // An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips, Flate-encoded; and data + // that isn't JBIG2. + let globals = |doc: &mut Document, data: &[u8]| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(data)))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + Object::Dict(parms) + }; + let (good, bad) = (globals(&mut doc, &[0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]), globals(&mut doc, b"not JBIG2")); + let indirect = Object::Ref(doc.add(bad.clone())); + let image = |filters: &[&str], parms: Option, data: &[u8], (width, height): (i64, i64), max: usize| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(width)); + d.set(b"Height".to_vec(), Object::Int(height)); + d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect())); + if let Some(p) = parms { + d.set(b"DecodeParms".to_vec(), p); + } + doc.decoded_image_within(&Stream::from_raw(d, data.to_vec()), max) + }; + let jbig2 = ["JBIG2Decode"]; + let full = (10, 3); + assert_eq!(image(&jbig2, None, &coded, full, MAX_DECODED).unwrap(), bits); + assert_eq!(image(&jbig2, Some(good.clone()), &coded, full, MAX_DECODED).unwrap(), bits, "globals it skips"); + for parms in [bad.clone(), Object::Array(vec![bad.clone()]), indirect.clone(), Object::Array(vec![indirect])] { + let e = image(&jbig2, Some(parms.clone()), &coded, full, MAX_DECODED).unwrap_err(); + assert!(e.to_string().contains("JBIG2"), "{parms:?}: {e}"); + } + let behind = ["ASCIIHexDecode", "JBIG2Decode"]; + assert_eq!(image(&behind, None, &hex, full, MAX_DECODED).unwrap(), bits, "behind ASCIIHexDecode"); + assert!(image(&behind, Some(Object::Array(vec![Object::Null, bad.clone()])), &hex, full, MAX_DECODED).is_err(), "its own globals"); + assert_eq!(image(&behind, Some(Object::Array(vec![bad.clone(), Object::Null])), &hex, full, MAX_DECODED).unwrap(), bits, "another's"); + assert_eq!(image(&behind, Some(bad.clone()), &hex, full, MAX_DECODED).unwrap(), bits, "a lone dictionary, the first filter's"); + let after_crypt = ["Crypt", "JBIG2Decode"]; + assert!(image(&after_crypt, Some(Object::Array(vec![Object::Null, bad.clone()])), &coded, full, MAX_DECODED).is_err(), "after /Crypt"); + assert_eq!(image(&after_crypt, Some(Object::Array(vec![bad, Object::Null])), &coded, full, MAX_DECODED).unwrap(), bits, "/Crypt's"); + + for size in [(11, 3), (9, 3), (10, 2), (10, 4)] { + assert!(image(&jbig2, None, &coded, size, MAX_DECODED).is_err(), "a page other than {size:?}"); + } + assert_eq!(image(&jbig2, None, &coded, full, 6).unwrap(), bits, "the page within the bound"); + assert!(image(&jbig2, None, &coded, full, 5).is_err(), "the page past the bound"); +} + #[test] fn a_missing_file_id_is_derived_from_the_encryption_seed() { let mut doc = Document::new_empty(); @@ -898,7 +1251,8 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { // Data of its own (decrypted streams, strings) counts: past the budget the cache is // cleared after the next read, including a failed one. let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); - reader.document.cache.lock().unwrap().insert(3, Arc::new(owned)); + let size = held_bytes(&owned, &reader.document.data, 0); + reader.document.cache.lock().unwrap().insert(3, Arc::new(owned), size); assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); } @@ -981,13 +1335,14 @@ fn editor_cache_counts_deeply_nested_strings() { source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); let doc = Document::open(bytes).unwrap(); - assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let held = held_bytes(&nested, &doc.data, 0); + assert!((1 << 20..(1 << 20) + 4096).contains(&held), "the string and a little tree: {held}"); let root = doc.get(doc.root().unwrap()); for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { let Object::Ref(reference) = reference else { panic!("indirect object") }; drop(doc.get(*reference)); let cache = doc.cache.lock().unwrap(); - let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0))); let strings = cache.values().filter(|o| o.as_array().is_some()).count(); assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); } @@ -1029,8 +1384,8 @@ fn editor_cache_stays_bounded() { #[test] fn editor_cache_byte_bound_fires() { - // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each - // newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass. + // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget, which is + // checked after every insertion. let mut source = Document::new_empty(); let objects: Vec<_> = (0..40) .map(|i| { @@ -1490,3 +1845,76 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { }); rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); } + +#[test] +fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() { + // The earlier revision: an empty document read back from a file whose catalog and pages + // sit in an object stream. + let earlier = Document::open(Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap())).unwrap(); + // The saved revision adds an object the catalog links to, so a full save numbers its objects + // anew; an undo then returns to the earlier graph. + let mut later = earlier.clone(); + let extra = later.add(Object::Int(7)); + let root = later.root().unwrap(); + later.update_dict(root, |c| c.set(b"Extra".to_vec(), Object::Ref(extra))).unwrap(); + let saved = crate::write_full(&later, &crate::SaveOptions::default()).unwrap(); + let mut base = Document::open(Arc::new(saved)).unwrap(); + assert!( + base.object_numbers().iter().any(|&n| matches!(base.xref_entry(n), Some(XrefEntry::InStream { .. }))), + "the saved file holds objects in a stream" + ); + base.overlay_changes_from(&earlier).unwrap(); + let file = crate::write_incremental(&base, &crate::SaveOptions::default()).unwrap(); + let reread = Document::open(Arc::new(file)).unwrap(); + let catalog = reread.get(reread.root().unwrap()).as_dict().unwrap().clone(); + assert!(catalog.get(b"Extra").is_none(), "the restored catalog"); + let pages = reread.resolve(catalog.get(b"Pages").unwrap()); + assert_eq!(pages.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Pages"[..]), "the pages survive: {pages:?}"); +} + +/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has +/// to measure the cache again on every read (#5873). +#[test] +fn the_object_cache_keeps_its_total_as_objects_come_and_go() { + let data = Arc::new(Vec::new()); + let one = Arc::new(Object::String(crate::PdfString::literal(vec![b'x'; 100]))); + let two = Arc::new(Object::Array(vec![Object::Int(1); 10])); + let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0)); + let mut cache = super::state::ObjectCache::default(); + cache.insert(1, one.clone(), a); + cache.insert(2, two, b); + assert_eq!(cache.owned(), a + b); + cache.insert(2, one, a); + assert_eq!(cache.owned(), a + a, "a replaced object no longer counts"); + cache.retain(|n| *n == 1); + assert_eq!((cache.len(), cache.owned()), (1, a)); + cache.clear(); + assert_eq!((cache.len(), cache.owned()), (0, 0)); +} + +/// A dictionary of numbers owns no string or stream bytes, but parsed it is tens of thousands +/// of values: InDesign's per-page `/PieceInfo`. Counting only strings and streams, the editing +/// cache kept a thousand such pages and a 35 MB book held 1.5 GB (#5873). What the parsed tree +/// holds counts now, so the byte budget bounds them. +#[test] +fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() { + let entries: String = (0..2000).map(|i| format!("/{i}[{i} 141 3 116.22 -375.993 1.0]")).collect(); + let dict = format!("<< /PieceInfo << /InDesign << /Map << {entries} >> >> >> >>"); + let count = 300; + let refs: String = (0..count).map(|i| format!("{} 0 R ", i + 2)).collect(); + let mut bodies = vec![format!("<< /Type /Catalog /Extension [{refs}] >>")]; + bodies.extend((0..count).map(|_| dict.clone())); + let bodies: Vec<&str> = bodies.iter().map(String::as_str).collect(); + let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap(); + let one = doc.get(crate::ObjRef::new(2, 0)); + let held = held_bytes(&one, &doc.data, 0); + assert!(held > 2000 * std::mem::size_of::(), "the parsed tree counts: {held} bytes"); + for i in 0..count { + drop(doc.get(crate::ObjRef::new(i as u32 + 2, 0))); + } + let cache = doc.cache.lock().unwrap(); + assert!(cache.owned() <= Document::EDITOR_CACHE_BYTES, "{} bytes held", cache.owned()); + assert!(cache.len() < count, "the byte budget evicted some, below the count cap: {} kept", cache.len()); + let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0))); + assert_eq!(cache.owned(), sum, "the kept total is the cache's own"); +} diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index f3be66b9a1..8e051e4578 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -22,10 +22,10 @@ mod parser; mod security; mod writer; -pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; +pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; -pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; +pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 17ee7844cf..5d437faa82 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -213,6 +213,12 @@ pub struct Stream { /// Upper bound for a single decoded stream (decompression-bomb defence). pub const MAX_DECODED: usize = 1 << 30; +/// The most an image's `/JBIG2Globals` stream may decode to: the symbol and pattern dictionaries an +/// encoder shares between pages, themselves coded, so far less than any stream may decode to +/// ([`MAX_DECODED`]). They are decoded before the image's JBIG2 page is read, so the page's size +/// can't bound them: they have a limit of their own. +pub const MAX_JBIG2_GLOBALS: usize = 32 << 20; + impl Stream { /// A new stream from already-encoded bytes (`/Length` is set at write time). pub fn from_raw(dict: Dict, raw: Vec) -> Self { @@ -300,6 +306,14 @@ impl Stream { self.decode_chain(max, decode) } + /// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a + /// document's images ([`pdfcraft_filters::decode_tolerant_within`]). + pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result, CosError> { + let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string()); + let chain = self.readable_filters().map_err(filter)?; + pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter) + } + /// The stream decoded through its filters by `decode`, or copied when it has none, within /// `max` and [`MAX_DECODED`]. fn decode_chain( diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 1e9b45ad61..709659f537 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -341,7 +341,11 @@ impl<'a> Lexer<'a> { let s = std::str::from_utf8(tok).map_err(|_| self.err("bad number"))?; if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. - let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); + // Only a doubled sign needs a cleaned copy: a page of InDesign `/PieceInfo` is + // thousands of plain reals, and building a copy and a substring search for each + // was a large part of reading one (#5873). + let t = s.trim_start_matches('+'); + let cleaned = if t.as_bytes().windows(2).any(|w| w == b"--") { std::borrow::Cow::Owned(t.replacen("--", "-", 1)) } else { t.into() }; let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); return Ok(Object::Real(v.unwrap_or(0.0))); } @@ -707,4 +711,13 @@ mod tests { let mut lx = Lexer::new(two.as_bytes(), 0); assert!(lx.object().is_ok() && lx.object().is_ok()); } + + /// A plain real is read without a cleaned copy of its token; the forms that need one read + /// as they did (#5873). + #[test] + fn reals_read_the_same_with_and_without_a_doubled_sign() { + for (token, want) in [("1.5", 1.5), ("-375.993", -375.993), ("+2.25", 2.25), ("--5.5", -5.5), ("1.2.3", 1.2), ("-.5", -0.5), ("0.0", 0.0)] { + assert_eq!(Lexer::new(token.as_bytes(), 0).object().unwrap(), Object::Real(want), "{token}"); + } + } } diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 22c85c7f68..61c4f92a4c 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -7,10 +7,69 @@ //! false. Streams naming a crypt filter (`/Crypt` in `/Filter`) use that filter; embedded files //! use `/EFF`. -use pdfcraft_crypt::{EncryptDict, Method, SecurityHandler, StreamKind}; +use std::cell::Cell; +use pdfcraft_crypt::{EncryptDict, Method, SecurityHandler, StreamKind}; +use pdfcraft_filters::MAX_CHAIN; + +use crate::document::Held; use crate::{Dict, Document, Object, PdfString, Stream}; +/// How many references one read or write of an object may follow, with all that it loads on the +/// way. How an object is treated can be given by reference, and what that loads can do the same: a +/// file could make one read a tree of them, each object read again once the cache has let it go. +/// An object takes a few of these, and two dozen if every part of it is given by reference; past +/// this they read as absent. The parameters of an object stream, read together, count as one. +const MAX_FOLLOWED: u32 = 32; + +std::thread_local! { + /// How many [`FollowScope`]s this thread is inside. + static SCOPES: Cell = const { Cell::new(0) }; + /// The references followed in the outermost of them. + static FOLLOWED: Cell = const { Cell::new(0) }; + /// The references refused in the outermost of them, past [`MAX_FOLLOWED`]. + static REFUSED: Cell = const { Cell::new(0) }; +} + +/// One read or write of an object, with everything it loads on the way: what [`MAX_FOLLOWED`] counts. +pub(crate) struct FollowScope; + +impl FollowScope { + pub(crate) fn enter() -> Self { + if SCOPES.replace(SCOPES.get().saturating_add(1)) == 0 { + FOLLOWED.set(0); + REFUSED.set(0); + } + FollowScope + } +} + +impl Drop for FollowScope { + fn drop(&mut self) { + SCOPES.set(SCOPES.get().saturating_sub(1)); + } +} + +/// What `f` gives, counted against [`MAX_FOLLOWED`], or `spent` past it. Called inside a +/// [`FollowScope`], which is what starts the count again. +pub(crate) fn following(spent: T, f: impl FnOnce() -> T) -> T { + if FOLLOWED.get() >= MAX_FOLLOWED { + REFUSED.set(REFUSED.get().saturating_add(1)); + return spent; + } + FOLLOWED.set(FOLLOWED.get() + 1); + f() +} + +/// `o`, or the object it refers to, for what decides how a stream or dictionary is treated. Past +/// [`MAX_FOLLOWED`] a reference reads as absent. +fn followed<'o>(doc: &Document, o: &'o Object) -> Held<'o> { + match o { + Object::Ref(_) => following(Held::Direct(&Object::Null), || doc.held(o)), + o => Held::Direct(o), + } +} + fn bytes_of(doc: &Document, d: &Dict, key: &[u8]) -> Vec { d.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_string().map(|s| s.bytes.clone())).unwrap_or_default() } @@ -18,12 +77,12 @@ fn bytes_of(doc: &Document, d: &Dict, key: &[u8]) -> Vec { /// Read the handler parameters from an `/Encrypt` dictionary. pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { let int = |k: &[u8], default: i64| d.get(k).map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(default); - let name = |k: &[u8]| d.get(k).and_then(|v| v.as_name()).map(|n| n.to_vec()).unwrap_or_else(|| b"Identity".to_vec()); + let name = |d: &Dict, k: &[u8]| d.get(k).and_then(|v| doc.resolve(v).as_name().map(<[u8]>::to_vec)); let mut crypt_filters = Vec::new(); if let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) { for (k, v) in cf.iter() { let Some(f) = doc.resolve(v).as_dict().cloned() else { continue }; - let method = match f.name(b"CFM") { + let method = match name(&f, b"CFM").as_deref() { Some(b"V2") => Method::Rc4, Some(b"AESV2") => Method::Aes128, Some(b"AESV3") => Method::Aes256, @@ -40,7 +99,8 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { .get(b"CF") .map(|c| doc.resolve(c)) .and_then(|c| c.as_dict().and_then(|c| c.get(b"StdCF").map(|f| doc.resolve(f)))) - .and_then(|f| f.as_dict().and_then(|f| f.int(b"Length"))) + .and_then(|f| f.as_dict().and_then(|f| f.get(b"Length").map(|n| doc.resolve(n)))) + .and_then(|n| n.as_int()) .filter(|n| *n > 0); let length_bits = match (v, cf_len) { (5.., _) => 256, @@ -50,7 +110,7 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { _ => int(b"Length", 40), }; EncryptDict { - filter: d.name(b"Filter").map(|n| n.to_vec()).unwrap_or_default(), + filter: name(d, b"Filter").unwrap_or_default(), v, r: int(b"R", 0), length_bits, @@ -60,28 +120,46 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { ue: bytes_of(doc, d, b"UE"), perms: bytes_of(doc, d, b"Perms"), p: int(b"P", -1) as i32, - encrypt_metadata: !matches!(d.get(b"EncryptMetadata"), Some(Object::Bool(false))), + encrypt_metadata: !matches!(d.get(b"EncryptMetadata").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(false))), crypt_filters, - stm_f: name(b"StmF"), - str_f: name(b"StrF"), - ef_f: d.get(b"EFF").and_then(|v| v.as_name()).map(|n| n.to_vec()).unwrap_or_default(), + stm_f: name(d, b"StmF").unwrap_or_else(|| b"Identity".to_vec()), + str_f: name(d, b"StrF").unwrap_or_else(|| b"Identity".to_vec()), + ef_f: name(d, b"EFF").unwrap_or_default(), } } -/// The crypt filter a stream names in its `/Filter` chain (`/Crypt` + `/DecodeParms /Name`). -fn named_crypt_filter(dict: &Dict) -> Option> { - let names: Vec<&[u8]> = match dict.get(b"Filter") { - Some(Object::Name(n)) => vec![n], - Some(Object::Array(a)) => a.iter().filter_map(Object::as_name).collect(), +/// Whether `f`, an entry of a `/Filter` chain or its only filter, is `/Crypt`. +fn is_crypt(doc: &Document, f: &Object) -> bool { + followed(doc, f).as_name() == Some(b"Crypt") +} + +/// The crypt filter a stream names in its `/Filter` chain (`/Crypt` + `/DecodeParms /Name`), each +/// given directly or by reference, in an array or not. A chain longer than any decoder takes names +/// none: nothing can read that stream anyway, and every stream of a file can share one long array. +/// A `/Name` that reads as absent means `/Identity`, unless a reference on the way to it was refused +/// (see [`MAX_FOLLOWED`]): that says nothing about the stream, so it names none, and the document's +/// own filter is used. +fn named_crypt_filter(doc: &Document, dict: &Dict) -> Option> { + let refused = REFUSED.get(); + let filter = followed(doc, dict.get(b"Filter")?); + let i = match &*filter { + Object::Array(a) if a.len() <= MAX_CHAIN + 1 => a.iter().position(|f| is_crypt(doc, f))?, + f if is_crypt(doc, f) => 0, _ => return None, }; - let i = names.iter().position(|n| *n == b"Crypt")?; - let parms = match dict.get(b"DecodeParms") { - Some(Object::Dict(d)) if i == 0 => Some(d.clone()), - Some(Object::Array(a)) => a.get(i).and_then(|p| p.as_dict().cloned()), + let parms = dict.get(b"DecodeParms").map(|p| followed(doc, p)); + let entry = match parms.as_deref() { + Some(Object::Array(a)) => a.get(i).map(|p| followed(doc, p)), + Some(p) if i == 0 => Some(Held::Direct(p)), _ => None, }; - Some(parms.and_then(|p| p.name(b"Name").map(|n| n.to_vec())).unwrap_or_else(|| b"Identity".to_vec())) + let name = entry.as_deref().and_then(Object::as_dict).and_then(|p| p.get(b"Name")).map(|n| followed(doc, n)); + match name.as_deref().and_then(Object::as_name) { + Some(name) => Some(name.to_vec()), + // /Identity here would read ciphertext as data, and write the stream in the clear. + None if REFUSED.get() > refused => None, + None => Some(b"Identity".to_vec()), + } } /// A signature dictionary (field signature, document timestamp or usage-rights signature), @@ -91,42 +169,67 @@ fn named_crypt_filter(dict: &Dict) -> Option> { /// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary /// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment /// text) or with another `/Type` is never exempt, whatever else it carries. -fn is_signature_dict(d: &Dict) -> bool { - if d.get(b"Subtype").is_some() { +/// With `doc` its entries are read through references; a dictionary nested in another object +/// has none (see [`transform`]). +fn is_signature_dict(doc: Option<&Document>, d: &Dict) -> bool { + // All this decides is what happens to such a /Contents. + if !matches!(d.get(b"Contents"), Some(Object::String(_))) { return false; } - match d.get(b"Type") { - Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")), - None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))), + // A null entry, or one that refers to nothing, is an absent one (ISO 32000-2 §7.3.7, §7.3.10). + let entry = |key: &[u8]| { + let value = d.get(key)?; + let held = doc.map_or(Held::Direct(value), |doc| followed(doc, value)); + (!matches!(*held, Object::Null)).then_some(held) + }; + if entry(b"Subtype").is_some() { + return false; + } + match entry(b"Type") { + Some(t) => matches!(t.as_name(), Some(b"Sig" | b"DocTimeStamp")), + None => matches!(entry(b"ByteRange").as_deref(), Some(Object::Array(_))), } } -/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters. -fn drop_crypt_filter(dict: &mut Dict) { - let i = match dict.get(b"Filter") { - Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")), - Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0), - _ => None, +/// Longer than any filter's name. A name can be of any length, and a chain every stream shares can +/// hold a long one. +const MAX_FILTER_NAME: usize = 127; + +/// The dictionary of `source` without the `/Crypt` entries of its filter chain and their decode +/// parameters. The two come out direct when given by reference, which every stream of a file can +/// share, so of what was shared only small values are copied: filter names, and the parameters +/// decoders read ([`Document::with_direct_parms`]). The chain is short, as [`named_crypt_filter`] +/// found a filter in it. +fn drop_crypt_filter(doc: &Document, source: &Stream) -> Dict { + let entry = |f: &Object| match f { + f if is_crypt(doc, f) => Object::name("Crypt"), + Object::Name(n) if n.len() <= MAX_FILTER_NAME => f.clone(), + Object::Ref(_) => f.clone(), + _ => Object::Null, }; - let Some(i) = i else { return }; - let mut filters = match dict.get(b"Filter") { - Some(Object::Array(a)) => a.clone(), - _ => Vec::new(), + let filter = source.dict.get(b"Filter").map(|f| followed(doc, f)); + let chain: Vec = match filter.as_deref() { + Some(Object::Array(a)) => a.iter().map(entry).collect(), + Some(f) => vec![entry(f)], + None => Vec::new(), }; - if i < filters.len() { - filters.remove(i); - } + let crypt: Vec = chain.iter().map(|f| f.as_name() == Some(b"Crypt")).collect(); + let mut chained = Stream::from_raw(source.dict.clone(), Vec::new()); + chained.dict.set(b"Filter".to_vec(), Object::Array(chain.clone())); // A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it). - match dict.get(b"DecodeParms").cloned() { - Some(Object::Array(mut parms)) if i < parms.len() => { - parms.remove(i); - dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); - } - Some(Object::Dict(_)) if i == 0 => { + let parms = match doc.with_direct_parms(&chained).dict.get(b"DecodeParms") { + Some(Object::Array(p)) => Some(Object::Array(p.iter().zip(&crypt).filter(|(_, c)| !**c).map(|(p, _)| p.clone()).collect())), + Some(Object::Dict(_)) if crypt.first() == Some(&true) => None, + _ => source.dict.get(b"DecodeParms").cloned(), + }; + let mut dict = source.dict.clone(); + match parms { + Some(p) => dict.set(b"DecodeParms".to_vec(), p), + None => { dict.remove(b"DecodeParms"); } - _ => {} } + let filters: Vec = chain.into_iter().zip(&crypt).filter(|(_, c)| !**c).map(|(f, _)| f).collect(); if filters.is_empty() { dict.remove(b"Filter"); if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) { @@ -135,6 +238,7 @@ fn drop_crypt_filter(dict: &mut Dict) { } else { dict.set(b"Filter".to_vec(), Object::Array(filters)); } + dict } /// What [`transform`] does to an object. @@ -152,51 +256,81 @@ pub(crate) enum Pass { /// Decrypt or encrypt every string and stream in an indirect object, except a signature /// dictionary's `/Contents` (see [`is_signature_dict`]). -pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { +/// +/// `o` is as `doc` holds it: what a stream's dictionary gives by reference is read from `doc`, so +/// its references have to be the document's, not a save's renumbered ones. +pub(crate) fn transform(doc: &Document, h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { + let _scope = FollowScope::enter(); let decrypt = pass == Pass::Decrypt; let strings = |s: &PdfString| { let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) }; PdfString { bytes, hex: s.hex || !decrypt } }; - walk(o, &strings, h, num, generation, pass) + // References are followed here only, for the object itself, never for what is nested in it. + // `walk` goes deep, and a load from down there adds that depth again for each object loaded, + // which overflowed the stack on a 10 KB file. A stream can't be nested, and a dictionary that + // is reads as it says directly. + match o { + Object::Stream(s) => stream(doc, s, &strings, h, num, generation, pass), + Object::Dict(d) if is_signature_dict(Some(doc), d) => signature_dict(d, &strings), + o => walk(o, &strings), + } } -fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object { - let decrypt = pass == Pass::Decrypt; +/// `d`, a signature dictionary, with the strings of everything but its `/Contents` transformed. +fn signature_dict(d: &Dict, strings: &dyn Fn(&PdfString) -> PdfString) -> Object { + Object::Dict( + d.iter() + .map(|(k, v)| match (k.as_slice(), v) { + (b"Contents", Object::String(_)) => (k.clone(), v.clone()), + _ => (k.clone(), walk(v, strings)), + }) + .collect(), + ) +} + +fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString) -> Object { match o { Object::String(s) => Object::String(strings(s)), - Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()), - Object::Dict(d) if is_signature_dict(d) => Object::Dict( - d.iter() - .map(|(k, v)| match (k.as_slice(), v) { - (b"Contents", Object::String(_)) => (k.clone(), v.clone()), - _ => (k.clone(), walk(v, strings, h, num, generation, pass)), - }) - .collect(), - ), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()), - Object::Stream(s) => { - let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect(); - let ty = s.dict.name(b"Type"); - // Cross-reference streams are never encrypted (§7.5.8), but only reading may trust the - // label: a stream a producer calls /XRef stays plaintext if writing believes it, and the - // writer builds its own cross-reference streams outside this transform. - if (decrypt && ty == Some(b"XRef")) || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { - return Object::Stream(Stream { dict, raw: s.raw.clone() }); - } - let mut named = named_crypt_filter(&s.dict); - if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) { - drop_crypt_filter(&mut dict); - named = None; - } - let kind = match (&named, ty) { - (Some(n), _) => StreamKind::Named(n), - (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, - _ => StreamKind::Normal, - }; - let raw = if decrypt { h.decrypt_stream(num, generation, &s.raw, kind) } else { h.encrypt_stream(num, generation, &s.raw, kind) }; - Object::Stream(Stream { dict, raw: raw.into() }) - } + Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings)).collect()), + Object::Dict(d) if is_signature_dict(None, d) => signature_dict(d, strings), + Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings))).collect()), other => other.clone(), } } + +fn stream( + doc: &Document, + s: &Stream, + strings: &dyn Fn(&PdfString) -> PdfString, + h: &SecurityHandler, + num: u32, + generation: u16, + pass: Pass, +) -> Object { + let decrypt = pass == Pass::Decrypt; + let walked = |d: &Dict| -> Dict { d.iter().map(|(k, v)| (k.clone(), walk(v, strings))).collect() }; + let ty = s.dict.get(b"Type").map(|t| followed(doc, t)); + let ty = ty.as_deref().and_then(Object::as_name); + // Cross-reference streams are never encrypted (§7.5.8), but only reading may trust the + // label: a stream a producer calls /XRef stays plaintext if writing believes it, and the + // writer builds its own cross-reference streams outside this transform. + if (decrypt && ty == Some(b"XRef")) || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { + return Object::Stream(Stream { dict: walked(&s.dict), raw: s.raw.clone() }); + } + let mut named = named_crypt_filter(doc, &s.dict); + // Before the walk, which then encrypts what is in the dictionary this leaves. + let foreign = pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)); + let dropped = foreign.then(|| drop_crypt_filter(doc, s)); + if dropped.is_some() { + named = None; + } + let dict = walked(dropped.as_ref().unwrap_or(&s.dict)); + let kind = match (&named, ty) { + (Some(n), _) => StreamKind::Named(n), + (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, + _ => StreamKind::Normal, + }; + let raw = if decrypt { h.decrypt_stream(num, generation, &s.raw, kind) } else { h.encrypt_stream(num, generation, &s.raw, kind) }; + Object::Stream(Stream { dict, raw: raw.into() }) +} diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 627f716495..dcfcb97997 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -133,11 +133,12 @@ fn write_stream(s: &Stream, out: &mut Vec) { /// The object as written: encrypted with its (output) number when the document is encrypted, /// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document). +/// `o` has the document's numbers in its references ([`crate::security::transform`]). fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> { match doc.output_security() { - (Some(h), encrypt_num) if Some(source_num) != encrypt_num => { + (Some(h), encrypt_num) if Some(source_num) != encrypt_num && !doc.stored_as_is(source_num) => { let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt }; - Cow::Owned(crate::security::transform(h, o, num, generation, pass)) + Cow::Owned(crate::security::transform(doc, h, o, num, generation, pass)) } _ => Cow::Borrowed(o), } @@ -245,7 +246,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, if let Some(p) = prev { trailer.set(b"Prev".to_vec(), Object::Int(p as i64)); } - let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32); + let size = doc.next_num().max(trailer.int(b"Size").map_or(0, |n| n.clamp(0, i64::from(u32::MAX)) as u32)); if as_stream { let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect(); write_xref_stream(&mut out, base, &mut trailer, rows, size); @@ -287,6 +288,10 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro queue.push_back(r); } } + // A direct /Encrypt can refer to objects, /CF or /StmF, say. + if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { + visit_refs(encrypt, &mut queue); + } // Unknown trailer entries are document data. Objects they alone reference must be written // too, or a full rewrite would keep the key and drop its value. for (key, value) in trailer_in.iter() { @@ -315,7 +320,10 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro // The /Encrypt dictionary and everything it references (e.g. indirect /CF crypt-filter // dictionaries) are needed before anything can be decrypted: never inside object streams. let mut encryption_objects: std::collections::HashSet = std::collections::HashSet::new(); - let mut pending: Vec = trailer_in.reference(b"Encrypt").into_iter().collect(); + let mut pending: Vec = Vec::new(); + if let Some(encrypt) = trailer_in.get(b"Encrypt") { + collect_refs(encrypt, &mut |r| pending.push(r)); + } while let Some(r) = pending.pop() { if encryption_objects.insert(r) { collect_refs(&reader.get(r), &mut |x| pending.push(x)); @@ -336,9 +344,10 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro if in_stream { packed.push((num, *r)); } else { - let o = renumber(&*reread(&mut reader, *r, *is_stream)?, &map); + let o = reread(&mut reader, *r, *is_stream)?; + let o = renumber(&prepared(&reader.document, r.num, num, 0, &o), &map); rows.insert(num, Row::InFile(out.len() as u64, 0)); - write_indirect(num, 0, &prepared(&reader.document, r.num, num, 0, &o), &mut out); + write_indirect(num, 0, &o, &mut out); } } // Object streams take the numbers after the last object. Their contents are not encrypted @@ -368,11 +377,11 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro trailer.set(key.to_vec(), Object::Ref(ObjRef::new(*r, 0))); } } - if let Some(Object::Dict(d)) = trailer_in.get(b"Encrypt") { - trailer.set(b"Encrypt".to_vec(), Object::Dict(d.clone())); + if let Some(encrypt @ Object::Dict(_)) = trailer_in.get(b"Encrypt") { + trailer.set(b"Encrypt".to_vec(), renumber(encrypt, &map)); } if let Some(id) = trailer_in.get(b"ID") { - trailer.set(b"ID".to_vec(), id.clone()); + trailer.set(b"ID".to_vec(), direct_id(&reader.document, id)); } for (key, value) in trailer_in.iter() { if !trailer_key_rewritten(key) { @@ -442,6 +451,22 @@ impl ObjectStreamBatch { } } +/// `/ID` for the trailer, direct, as it has to be with `/Encrypt`: nothing else in the file holds +/// what it refers to now. Given by reference it is two entries at most, however many references a +/// file gave, so that one large string isn't copied for each. An entry that isn't a string stays in +/// its place as an empty one, which is how the key read it. +fn direct_id(doc: &Document, id: &Object) -> Object { + if matches!(id, Object::Array(a) if !a.iter().any(|s| matches!(s, Object::Ref(_)))) { + return id.clone(); + } + let entry = |s: &Object| match &*doc.resolve(s) { + s @ Object::String(_) => s.clone(), + _ => Object::String(crate::PdfString { bytes: Vec::new(), hex: true }), + }; + let held = doc.resolve(id); + Object::Array(held.as_array().into_iter().flatten().take(2).map(entry).collect()) +} + /// Trailer keys a full rewrite generates itself. Everything else is copied, with references /// renumbered, so a save does not drop metadata the reader accepted. fn trailer_key_rewritten(key: &[u8]) -> bool { @@ -486,7 +511,7 @@ pub(crate) fn unlinked_additions(doc: &Document) -> HashSet { pending.into_keys().collect() } -fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) { +pub(crate) fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) { match o { Object::Ref(r) => f(*r), Object::Array(a) => a.iter().for_each(|x| collect_refs(x, f)), @@ -663,6 +688,15 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) { #[cfg(test)] mod tests { + /// `/ID` by reference comes out direct, each entry where it was: the key is made from the first. + #[test] + fn a_file_identifier_keeps_its_entries_in_their_places() { + let doc = Document::new_empty(); + let string = |bytes: &[u8]| Object::String(crate::PdfString { bytes: bytes.to_vec(), hex: true }); + let nowhere = Object::Ref(ObjRef::new(9_999, 0)); + assert_eq!(super::direct_id(&doc, &Object::Array(vec![nowhere, string(b"ab")])), Object::Array(vec![string(b""), string(b"ab")])); + } + use proptest::prelude::*; use super::*; diff --git a/crates/cos/tests/it/encryption.rs b/crates/cos/tests/it/encryption.rs index 2ee33afc12..b19d618f5c 100644 --- a/crates/cos/tests/it/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -494,3 +494,826 @@ fn a_stream_labelled_xref_is_encrypted_when_saved() { let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); assert!(!bytes.windows(SECRET.len()).any(|w| w == SECRET.as_bytes()), "the labelled stream was written in the clear"); } + +/// The content stream's reference. +fn contents_of(doc: &Document) -> ObjRef { + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap(); + page.reference(b"Contents").unwrap() +} + +/// The content stream's data as loaded, whatever its filters say. +fn raw_content(doc: &Document) -> String { + match &*doc.get(contents_of(doc)) { + Object::Stream(s) => String::from_utf8_lossy(&s.raw).trim().to_string(), + _ => panic!("no content stream"), + } +} + +/// Gives the content stream the `/Filter` and `/DecodeParms` of `selector`. +fn select(doc: &mut Document, (filter, parms): (Object, Object)) { + let contents = contents_of(doc); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Filter".to_vec(), filter); + s.dict.set(b"DecodeParms".to_vec(), parms); + doc.set(contents, Object::Stream(s)); +} + +/// `/Crypt` naming `name`, in the `form`th spelling: all direct, then each part by reference. +/// `None` past the last. +fn selector(doc: &mut Document, form: usize, name: &str) -> Option<(Object, Object)> { + let crypt = Object::name("Crypt"); + let parms = |n: Object| { + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Name".to_vec(), n); + Object::Dict(d) + }; + let named = parms(Object::name(name)); + Some(match form { + 0 => (crypt, named), + 1 => (crypt, Object::Ref(doc.add(named))), + 2 => (Object::Array(vec![crypt]), Object::Array(vec![Object::Ref(doc.add(named))])), + 3 => (Object::Array(vec![crypt]), Object::Ref(doc.add(Object::Array(vec![named])))), + 4 => (crypt, parms(Object::Ref(doc.add(Object::name(name))))), + 5 => (Object::Ref(doc.add(crypt)), named), + 6 => (Object::Ref(doc.add(Object::Array(vec![crypt]))), Object::Array(vec![named])), + 7 => (Object::Array(vec![Object::Ref(doc.add(crypt))]), Object::Array(vec![named])), + // Behind a filter given by reference: places in the two arrays pair up all the same. + 8 => (Object::Array(vec![Object::Ref(doc.add(Object::name("ASCIIHexDecode"))), crypt]), Object::Array(vec![Object::Null, named])), + // The two entries in different forms, which is how a lone filter is usually spelled. + 9 => (Object::Array(vec![crypt]), named), + 10 => (crypt, Object::Array(vec![named])), + // The filter twice, which names the first's. + 11 => (Object::Array(vec![crypt.clone(), crypt]), Object::Array(vec![named.clone(), named])), + // A reference to a reference. + 12 => { + let name = doc.add(Object::name(name)); + let hop = doc.add(Object::Ref(name)); + (crypt, parms(Object::Ref(hop))) + } + _ => return None, + }) +} + +fn holds(bytes: &[u8], text: &str) -> bool { + String::from_utf8_lossy(bytes).contains(text) +} + +/// An object `/Encrypt` names is written as stored only while it is as stored. A file whose +/// `/Encrypt` names its `/Info` reads that dictionary as stored; once the title is edited, both +/// saves encrypt it rather than writing the new title in the clear. +#[test] +fn an_edited_object_that_encrypt_names_is_encrypted() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let info = doc.trailer().reference(b"Info").unwrap(); + let mut dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap(); + dict.set(b"Junk".to_vec(), Object::Ref(info)); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let info = doc.trailer().reference(b"Info").unwrap(); + doc.update_dict(info, |d| d.set(b"Title".to_vec(), PdfString::text("Merger plan"))).unwrap(); + for (save, saved) in [("incremental", write_incremental(&doc, &SaveOptions::default())), ("full", write_full(&doc, &SaveOptions::default()))] { + assert!(!holds(&saved.unwrap(), "Merger plan"), "{save}: the edited title was written in the clear"); + } +} + +/// A stream can select its crypt filter by reference at every step: the chain, an entry of it, the +/// parameters, an entry of those, the name (ISO 32000-2 §7.3.10, §7.4.10). Read as absent, the +/// filter was `/Identity`, so such a stream was saved in the clear and, in a file from elsewhere, +/// its ciphertext read as data. A full save renumbers: what it follows has the document's numbers. +#[test] +fn a_crypt_filter_selected_by_reference_is_used() { + for alg in [Algorithm::Aes128, Algorithm::Aes256] { + for form in 0.. { + let mut doc = Document::open(Arc::new(protect(alg, "", "owner", -1))).unwrap(); + let Some(selected) = selector(&mut doc, form, "StdCF") else { break }; + select(&mut doc, selected); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(!holds(&saved, "Secret page"), "{alg:?} form {form}: saved encrypted"); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(raw_content(&back), SECRET, "{alg:?} form {form}: read back"); + let full = write_full(&back, &SaveOptions::default()).unwrap(); + assert!(!holds(&full, "Secret page"), "{alg:?} form {form}: renumbered, encrypted"); + assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "{alg:?} form {form}: renumbered"); + } + } +} + +/// A reference to a stream that takes `follows` references to read cold, this one included: its +/// nine filters are all read, the last leading to the next stream. +fn spends(doc: &mut Document, follows: usize) -> Object { + let left = follows - 1; + let mut filters: Vec = (0..left.min(8)).map(|i| Object::Ref(doc.add(Object::Int(i as i64)))).collect(); + if left > 8 { + filters.push(spends(doc, left - 8)); + } + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(filters)); + Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(dict, Vec::new())))) +} + +/// Gives the content stream a `/Type` that takes `follows` references to read, ahead of its filter. +fn spend_first(doc: &mut Document, follows: usize) { + let spent = spends(doc, follows); + let contents = contents_of(doc); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Type".to_vec(), spent); + doc.set(contents, Object::Stream(s)); +} + +/// Past what one read may follow a reference reads as absent, and a crypt filter with no `/Name` is +/// `/Identity`. Together those would read this stream's ciphertext as data, so what can't be told +/// is the document's own filter, as a `/Filter` that can't be read is. +#[test] +fn a_crypt_filter_past_what_a_read_may_follow_is_the_documents_own() { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let selected = selector(&mut doc, 1, "StdCF").unwrap(); + select(&mut doc, selected); + spend_first(&mut doc, 40); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(!holds(&saved, "Secret page"), "saved encrypted"); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(raw_content(&back), SECRET); + let full = write_full(&back, &SaveOptions::default()).unwrap(); + assert!(!holds(&full, "Secret page"), "renumbered, encrypted"); + assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "renumbered"); +} + +/// A read that takes all it may follow has refused nothing: a crypt filter with no `/Name` after it +/// is still `/Identity`, and the stream is in the clear. +#[test] +fn a_crypt_filter_with_no_name_after_all_a_read_may_follow_is_identity() { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + select(&mut doc, (Object::name("Crypt"), Object::Dict(pdfcraft_cos::Dict::new()))); + spend_first(&mut doc, 32); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(holds(&saved, "Secret page"), "written as the stream asks"); + assert_eq!(raw_content(&Document::open(Arc::new(saved)).unwrap()), SECRET); +} + +/// `/Identity` selected by reference keeps a stream in the clear in a document that encrypts the +/// rest, in every spelling, on both saves. +#[test] +fn an_identity_crypt_filter_selected_by_reference_leaves_a_stream_alone() { + for form in 0.. { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let Some(selected) = selector(&mut doc, form, "Identity") else { break }; + select(&mut doc, selected); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(holds(&saved, "Secret page"), "form {form}: written as the stream asks"); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(raw_content(&back), SECRET, "form {form}"); + let full = write_full(&back, &SaveOptions::default()).unwrap(); + assert!(holds(&full, "Secret page"), "form {form}: renumbered"); + assert_eq!(raw_content(&Document::open(Arc::new(full)).unwrap()), SECRET, "form {form}: renumbered"); + } +} + +/// New protection drops a crypt filter it doesn't define, whichever way the stream selected it. The +/// chain comes out direct. Parameters in the stream's own dictionary stay, encrypted with it; of an +/// array in an object of its own, which every stream can share, only what decoders read is copied. +#[test] +fn protecting_drops_a_crypt_filter_selected_by_reference() { + let protected = |doc: &mut Document| { + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + write_full(doc, &SaveOptions::default()).unwrap() + }; + for form in 0.. { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let Some(selected) = selector(&mut doc, form, "MyCF") else { break }; + select(&mut doc, selected); + let bytes = protected(&mut doc); + assert!(!holds(&bytes, "Secret page"), "form {form}: encrypted"); + assert!(!holds(&bytes, "MyCF"), "form {form}: no selector left in the stream"); + let back = Document::open(Arc::new(bytes.clone())).unwrap(); + assert_eq!(raw_content(&back), SECRET, "form {form}"); + let contents = back.get(contents_of(&back)); + let filter = contents.as_dict().unwrap().get(b"Filter"); + match form { + 8 => assert!( + matches!(filter, Some(Object::Array(a)) if matches!(a.as_slice(), [Object::Ref(_)])), + "form 8: the other filter stays: {filter:?}" + ), + _ => assert_eq!(filter, None, "form {form}"), + } + assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "form {form}"); + } + for shared in [false, true] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut named = pdfcraft_cos::Dict::new(); + named.set(b"Name".to_vec(), Object::name("MyCF")); + let mut other = pdfcraft_cos::Dict::new(); + other.set(b"Note".to_vec(), Object::String(PdfString::text("Private note"))); + other.set(b"Predictor".to_vec(), Object::Int(1)); + let parms = Object::Array(vec![Object::Dict(named), Object::Dict(other)]); + let parms = if shared { Object::Ref(doc.add(parms)) } else { parms }; + select(&mut doc, (Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")]), parms)); + let bytes = protected(&mut doc); + assert!(!holds(&bytes, "Private note"), "shared: {shared}"); + let back = Document::open(Arc::new(bytes)).unwrap(); + let contents = back.get(contents_of(&back)); + let kept = contents.as_dict().unwrap().get(b"DecodeParms").unwrap().as_array().unwrap(); + assert_eq!(kept.len(), 1, "one filter left, one entry"); + let kept = kept[0].as_dict().unwrap(); + assert_eq!(kept.get(b"Predictor"), Some(&Object::Int(1)), "shared: {shared}"); + assert_eq!(kept.get(b"Note").is_some(), !shared, "shared: {shared}"); + } +} + +/// Every stream of a file can share one large object as its `/DecodeParms` or `/Filter`, which +/// costs nothing until new protection drops a crypt filter from each. Copied whole, a 1 MB file +/// came out at 1 GB. +#[test] +fn protecting_copies_no_large_object_into_every_stream() { + let pad = Object::Array(vec![Object::Int(1); 100_000]); + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut large = pdfcraft_cos::Dict::new(); + large.set(b"Pad".to_vec(), pad.clone()); + let parms = doc.add(Object::Array(vec![Object::Null, Object::Dict(large)])); + let filter = doc.add(Object::Array(vec![Object::name("Crypt"), Object::Name(vec![b'A'; 100_000]), pad])); + let mut streams = Vec::new(); + for _ in 0..200 { + let mut by_parms = pdfcraft_cos::Dict::new(); + by_parms.set(b"Filter".to_vec(), Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")])); + by_parms.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + streams.push(Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(by_parms, b"41>".to_vec()))))); + let mut by_filter = pdfcraft_cos::Dict::new(); + by_filter.set(b"Filter".to_vec(), Object::Ref(filter)); + streams.push(Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(by_filter, b"41>".to_vec()))))); + } + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Array(streams))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(bytes.len() < 1 << 20, "{} bytes", bytes.len()); +} + +/// A file can name one large string thousands of times in `/ID`, which holds two. An encrypted file +/// keeps its `/ID`, so each copy would be in the file a save writes. +#[test] +fn a_file_identifier_of_many_references_is_two_strings() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let large = doc.add(Object::String(PdfString { bytes: vec![0xAB; 100_000], hex: true })); + doc.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![Object::Ref(large); 500])); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(bytes.len() < 1 << 20, "{} bytes", bytes.len()); +} + +/// What a file can do with those references: point a stream's selector at the stream itself, or at +/// nothing. They read as absent, so a stream with `/Crypt` is `/Identity` (ISO 32000-2 §7.4.10), +/// and one that names no filter follows the document's. Loading ends either way. +#[test] +fn a_crypt_filter_reference_to_itself_or_nothing_reads_as_absent() { + let named = |n: Object| { + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Name".to_vec(), n); + Object::Dict(d) + }; + for (what, encrypted) in [ + ("/Filter to itself", true), + ("/Filter nowhere", true), + ("/DecodeParms to itself", false), + ("/DecodeParms nowhere", false), + ("/Name to itself", false), + ("/Name nowhere", false), + ] { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let me = Object::Ref(contents_of(&doc)); + let nowhere = Object::Ref(ObjRef::new(9_999, 0)); + let crypt = Object::name("Crypt"); + select( + &mut doc, + match what { + "/Filter to itself" => (me, Object::Null), + "/Filter nowhere" => (nowhere, Object::Null), + "/DecodeParms to itself" => (crypt, me), + "/DecodeParms nowhere" => (crypt, nowhere), + "/Name to itself" => (crypt, named(me)), + _ => (crypt, named(nowhere)), + }, + ); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(!holds(&saved, "Secret page"), encrypted, "{what}"); + assert_eq!(raw_content(&Document::open(Arc::new(saved)).unwrap()), SECRET, "{what}"); + } +} + +/// A lone parameter dictionary belongs to the first filter, so `/Crypt` behind another filter has +/// none of its own and is `/Identity` (ISO 32000-2 §7.4.1), whatever that dictionary says. +#[test] +fn a_lone_parameter_dictionary_is_the_first_filters() { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let mut parms = pdfcraft_cos::Dict::new(); + parms.set(b"Name".to_vec(), Object::name("StdCF")); + let first = Object::Array(vec![Object::name("Crypt"), Object::name("ASCIIHexDecode")]); + let second = Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("Crypt")]); + select(&mut doc, (first, Object::Dict(parms.clone()))); + assert!(!holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "first: it names StdCF"); + select(&mut doc, (second, Object::Dict(parms))); + assert!(holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "second: it names nothing"); +} + +/// A lone parameter dictionary is the first filter's, so dropping a `/Crypt` behind that filter +/// leaves it, and dropping one in front of it takes it away. +#[test] +fn protecting_keeps_the_parameters_of_the_filter_before_a_crypt_filter() { + let protected = |filters: Vec| { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut parms = pdfcraft_cos::Dict::new(); + parms.set(b"Name".to_vec(), Object::name("MyCF")); + select(&mut doc, (Object::Array(filters), Object::Dict(parms))); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let back = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + back.get(contents_of(&back)).as_dict().unwrap().contains(b"DecodeParms") + }; + let (hex, crypt) = (Object::name("ASCIIHexDecode"), Object::name("Crypt")); + assert!(protected(vec![hex.clone(), crypt.clone()]), "the first filter's, which stays"); + assert!(!protected(vec![crypt, hex]), "/Crypt's, which goes"); +} + +/// Dropping `/Crypt` takes its entry out of `/DecodeParms`, and an array too short to have one is +/// left as it is, given directly or by reference. +#[test] +fn protecting_leaves_a_parameter_array_too_short_for_the_crypt_filter() { + for by_reference in [false, true] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let short = Object::Array(vec![Object::Null]); + let parms = if by_reference { Object::Ref(doc.add(short)) } else { short }; + select(&mut doc, (Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("Crypt")]), parms)); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!holds(&bytes, "Secret page"), "by reference: {by_reference}"); + let back = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(raw_content(&back), SECRET, "by reference: {by_reference}"); + let contents = back.get(contents_of(&back)); + let filter = contents.as_dict().unwrap().get(b"Filter").unwrap().as_array().unwrap(); + assert_eq!(filter.len(), 1, "only /Crypt goes"); + assert_eq!(filter[0].as_name(), Some(b"ASCIIHexDecode".as_slice()), "the other filter stays as it was"); + } +} + +/// A chain of up to [`pdfcraft_filters::MAX_CHAIN`] filters and `/Crypt` is read; a longer one +/// names no crypt filter, so its entries aren't followed for every stream sharing it. +#[test] +fn a_chain_longer_than_any_decoder_takes_names_no_crypt_filter() { + let chain = |others: usize| { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let mut filters = vec![Object::name("ASCIIHexDecode"); others]; + filters.push(Object::name("Crypt")); + let array = doc.add(Object::Array(filters)); + select(&mut doc, (Object::Ref(array), Object::Null)); + holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page") + }; + assert!(chain(pdfcraft_filters::MAX_CHAIN), "the longest chain read: /Identity, in the clear"); + assert!(!chain(pdfcraft_filters::MAX_CHAIN + 1), "one more: the document's filter"); +} + +/// A stream's `/Type` by reference picks its handling like one given directly: metadata left +/// readable under `/EncryptMetadata false`, an embedded file under `/EFF`. +#[test] +fn a_stream_type_given_by_reference_picks_its_crypt_filter() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let meta = catalog.reference(b"Metadata").unwrap(); + let ty = doc.add(Object::name("Metadata")); + let Object::Stream(mut s) = doc.get(meta).as_ref().clone() else { panic!("no metadata stream") }; + s.dict.set(b"Type".to_vec(), Object::Ref(ty)); + doc.set(meta, Object::Stream(s)); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "o", + permissions: -1, + encrypt_metadata: false, + seed: [5; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(holds(&bytes, "meta"), "search engines can read XMP"); + + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let encrypt = doc.trailer().reference(b"Encrypt").unwrap(); + doc.update_dict(encrypt, |e| e.set(b"EFF".to_vec(), Object::name("Identity"))).unwrap(); + let mut doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let contents = contents_of(&doc); + let ty = doc.add(Object::name("EmbeddedFile")); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Type".to_vec(), Object::Ref(ty)); + doc.set(contents, Object::Stream(s)); + assert!(holds(&write_incremental(&doc, &SaveOptions::default()).unwrap(), "Secret page"), "/EFF /Identity leaves it alone"); +} + +/// What makes a dictionary a signature's can be given by reference, and what is null or refers to +/// nothing is absent: `/Contents` stays as written in each of these. +#[test] +fn a_signature_dictionary_is_known_in_every_form() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let ty = doc.add(Object::name("Sig")); + let range = Object::Array([0, 10, 20, 30].into_iter().map(Object::Int).collect()); + let by_reference = doc.add(range.clone()); + let nowhere = Object::Ref(ObjRef::new(9_999, 0)); + let forms: [(Vec<(&str, Object)>, &str); 5] = [ + (vec![("Type", Object::Ref(ty))], "DEAD"), + (vec![("ByteRange", Object::Ref(by_reference))], "BEEF"), + (vec![("Type", Object::Null), ("ByteRange", range.clone())], "CAFE"), + (vec![("Type", nowhere.clone()), ("ByteRange", range.clone())], "F00D"), + (vec![("Subtype", nowhere), ("ByteRange", range)], "D00D"), + ]; + let mut signatures = Vec::new(); + for (entries, contents) in forms { + let mut sig = pdfcraft_cos::Dict::new(); + for (key, value) in entries { + sig.set(key.as_bytes().to_vec(), value); + } + let bytes = (0..contents.len()).step_by(2).map(|i| u8::from_str_radix(&contents[i..i + 2], 16).unwrap()).collect(); + sig.set(b"Contents".to_vec(), Object::String(PdfString { bytes, hex: true })); + signatures.push(Object::Ref(doc.add(Object::Dict(sig)))); + } + // Nested in another object, it follows nothing, but known directly it is one all the same. + let mut nested = pdfcraft_cos::Dict::new(); + nested.set(b"Type".to_vec(), Object::name("Sig")); + nested.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0xAB, 0xBA], hex: true })); + let mut field = pdfcraft_cos::Dict::new(); + field.set(b"V".to_vec(), Object::Dict(nested)); + signatures.push(Object::Ref(doc.add(Object::Dict(field)))); + // Neither of what makes one: a string /Contents alone is anybody's, so it's encrypted. + let mut other = pdfcraft_cos::Dict::new(); + other.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0xAB, 0xCD], hex: true })); + signatures.push(Object::Ref(doc.add(Object::Dict(other)))); + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Array(signatures))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for contents in ["DEAD", "BEEF", "CAFE", "F00D", "D00D", "ABBA"] { + assert!(holds(&bytes, &format!("<{contents}>")), "{contents}: written as is"); + } + assert!(!holds(&bytes, ""), "without a /ByteRange it is no signature"); +} + +/// What a test needs to write an update of a classic file protected with AES-128. +struct Classic { + bytes: Vec, + root: ObjRef, + pages: ObjRef, + info: ObjRef, + encrypt: ObjRef, + id: Vec, + dict: pdfcraft_cos::Dict, +} + +fn classic_file() -> Classic { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + let encrypt = doc.trailer().reference(b"Encrypt").unwrap(); + let id = doc.trailer().get(b"ID").unwrap().as_array().unwrap()[0].as_string().unwrap().bytes.clone(); + let dict = doc.get(encrypt).as_dict().cloned().unwrap(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + Classic { root: doc.root().unwrap(), pages, info: doc.trailer().reference(b"Info").unwrap(), bytes, encrypt, id, dict } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02X}")).collect() +} + +/// `bytes` with an update appended: `objects`, and a trailer of `trailer` entries, in which `@n` +/// stands for the offset of object `n`. An object with nothing in it gets an entry of the +/// cross-reference table that points at nothing. +fn updated(bytes: &[u8], objects: &[(u32, Vec)], trailer: &str) -> Vec { + let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + let prev: usize = String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap(); + let mut out = bytes.to_vec(); + let mut table = String::from("xref\n"); + let mut trailer = trailer.to_string(); + for (num, body) in objects { + if body.is_empty() { + table.push_str(&format!("{num} 1\n0000000010 00000 n \n")); + continue; + } + trailer = trailer.replace(&format!("@{num}"), &out.len().to_string()); + table.push_str(&format!("{num} 1\n{:010} 00000 n \n", out.len())); + out.extend_from_slice(format!("{num} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(table.as_bytes()); + out.extend_from_slice(format!("trailer\n<< {trailer} /Size 2000 /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +/// The `/Encrypt` dictionary of `file`, with `/O`, `/StmF` and `/StrF` as given. +fn encrypt_with(file: &Classic, o: &str, filter: &str) -> Vec { + let string = |key: &[u8]| hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes); + format!( + "<< /Filter /Standard /V 4 /R 4 /Length 128 /P {} /O {o} /U <{}> /CF << /StdCF << /CFM /AESV2 /Length 16 /AuthEvent /DocOpen >> >> /StmF /{filter} /StrF /{filter} >>", + file.dict.int(b"P").unwrap(), + string(b"U") + ) + .into_bytes() +} + +/// An object stream can have its parameters by reference, and a stream's crypt filter can be +/// selected by a member of it: legal together, though each is rare. What reads the stream first +/// goes through the object stream, and members must come out of it as they do read directly. They +/// didn't, while a read made for a selector wasn't allowed to follow its own: the object stream was +/// kept undecoded, its members lost for good, and a full save wrote nulls without saying so. +#[test] +fn an_object_stream_reached_from_a_selector_keeps_its_members() { + let file = classic_file(); + let parms = "[<< /Name /Identity >> null]"; + let header = format!("902 0 903 {} ", parms.len() + 1); + let members = format!("{header}{parms} << /Keep (yes) >>"); + let (mut rows, mut before) = (vec![1u8], 0u8); + for b in members.bytes() { + rows.push(b.wrapping_sub(before)); + before = b; + } + let coded = pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &rows).raw; + let mut object_stream = + format!("<< /Type /ObjStm /N 2 /First {} /Filter /FlateDecode /DecodeParms 911 0 R /Length {} >>\nstream\n", header.len(), coded.len()) + .into_bytes(); + object_stream.extend_from_slice(&coded); + object_stream.extend_from_slice(b"\nendstream"); + let data = [2u8, 0x03, 0x8E, 0, 2, 0x03, 0x8E, 1]; + let mut xref = format!("<< /Type /XRef /Size 2000 /W [1 2 1] /Index [902 2] /Length {} >>\nstream\n", data.len()).into_bytes(); + xref.extend_from_slice(&data); + xref.extend_from_slice(b"\nendstream"); + let string = |key: &[u8]| format!("<{}>", hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes)); + let objects = [ + (file.root.num, format!("<< /Type /Catalog /Pages {} 0 R /A 900 0 R /B 903 0 R >>", file.pages.num).into_bytes()), + (file.encrypt.num, encrypt_with(&file, &string(b"O"), "Identity")), + (900, b"<< /Filter [/Crypt /ASCIIHexDecode] /DecodeParms 902 0 R /Length 1 >>\nstream\n>\nendstream".to_vec()), + (910, object_stream), + (911, format!("<< /Predictor 11 /Columns {} >>", members.len()).into_bytes()), + (912, xref), + ]; + let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>] /XRefStm @912", file.root.num, file.encrypt.num, id = hex(&file.id)); + let bytes = updated(&file.bytes, &objects, &trailer); + let keep = + |doc: &Document, r: &Object| doc.resolve(r).as_dict().and_then(|d| d.get(b"Keep")).and_then(Object::as_string).map(|s| s.bytes.clone()); + let member = Object::Ref(ObjRef::new(903, 0)); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + assert_eq!(keep(&doc, &member).as_deref(), Some(b"yes".as_slice()), "read first"); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + let _ = doc.get(ObjRef::new(900, 0)); + assert_eq!(keep(&doc, &member).as_deref(), Some(b"yes".as_slice()), "read after the stream selecting from it"); + let saved = write_full(&Document::open(Arc::new(bytes)).unwrap(), &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + assert_eq!(keep(&back, catalog.as_dict().unwrap().get(b"B").unwrap()).as_deref(), Some(b"yes".as_slice()), "after a full save"); +} + +/// `walk` goes as deep as an object nests, so following a reference from down there adds that depth +/// again for each object it loads: this file, under 10 KB, overflowed a 2 MiB stack and took the +/// process with it. Only the object itself follows references, and it does so in very little stack. +#[test] +fn nested_dictionaries_load_nothing_on_a_small_stack() { + let file = classic_file(); + let first = 960u32; + let objects: Vec<(u32, Vec)> = (first..first + 40) + .map(|num| { + let open = "[".repeat(120); + let close = "]".repeat(120); + (num, format!("{open}<< /Contents () /Type {} 0 R >>{close}", num + 1).into_bytes()) + }) + .collect(); + let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.encrypt.num, id = hex(&file.id)); + let bytes = updated(&file.bytes, &objects, &trailer); + let reading = std::thread::Builder::new().stack_size(1 << 20).spawn(move || { + let doc = Document::open(Arc::new(bytes)).unwrap(); + doc.get(ObjRef::new(first, 0)).as_array().is_some() + }); + assert_eq!(reading.unwrap().join().ok(), Some(true)); +} + +/// The object itself can follow references, a ladder of them as long as loading may nest, and that +/// fits a small stack too. +#[test] +fn a_ladder_of_signature_checks_fits_a_small_stack() { + let file = classic_file(); + let first = 960u32; + let objects: Vec<(u32, Vec)> = + (first..first + 40).map(|num| (num, format!("<< /Contents () /Type {} 0 R >>", num + 1).into_bytes())).collect(); + let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.encrypt.num, id = hex(&file.id)); + let bytes = updated(&file.bytes, &objects, &trailer); + let reading = std::thread::Builder::new().stack_size(1 << 20).spawn(move || { + let doc = Document::open(Arc::new(bytes)).unwrap(); + doc.get(ObjRef::new(first, 0)).as_dict().is_some() + }); + assert_eq!(reading.unwrap().join().ok(), Some(true)); +} + +/// The strings of `/Encrypt` are never encrypted (ISO 32000-2 §7.6.2), and can be objects of their +/// own. Read as ordinary strings they were decrypted, and a full save encrypted them again under +/// another number, so the file it wrote no longer opened with its own password. +#[test] +fn encrypt_strings_given_by_reference_stay_as_stored() { + let file = classic_file(); + let string = |key: &[u8]| hex(&file.dict.get(key).unwrap().as_string().unwrap().bytes); + let replaced = format!( + "<< /Filter /Standard /V 4 /R 4 /Length 128 /P {} /O 900 0 R /U 901 0 R /CF << /StdCF << /CFM /AESV2 /Length 16 /AuthEvent /DocOpen >> >> /StmF /StdCF /StrF /StdCF >>", + file.dict.int(b"P").unwrap() + ); + let objects = [ + (file.encrypt.num, replaced.into_bytes()), + (900, format!("<{}>", string(b"O")).into_bytes()), + (901, format!("<{}>", string(b"U")).into_bytes()), + ]; + let trailer = + format!("/Root {} 0 R /Info {} 0 R /Encrypt {} 0 R /ID [<{id}> <{id}>]", file.root.num, file.info.num, file.encrypt.num, id = hex(&file.id)); + let source = Document::open(Arc::new(updated(&file.bytes, &objects, &trailer))).unwrap(); + assert_eq!(content(&source), SECRET, "the file reads"); + assert_eq!(title(&source).as_deref(), Some("Quarterly numbers")); + for object_streams in [false, true] { + let saved = write_full(&source, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(content(&back), SECRET, "object streams: {object_streams}"); + assert_eq!(title(&back).as_deref(), Some("Quarterly numbers"), "object streams: {object_streams}"); + } +} + +/// A save keeps what a file is protected with: its `/Encrypt`, which holds the handler and the +/// permissions, its `/ID`, and so its file key, under every algorithm and by either kind of save. +#[test] +fn a_save_keeps_the_handler_the_key_and_the_permissions() { + for alg in ALL { + let doc = Document::open_with_password(Arc::new(protect(alg, "user", "owner", -1 ^ 0b1_0000)), Some("user")).unwrap(); + let held = |d: &Document| { + let encrypt = d.resolve(d.trailer().get(b"Encrypt").unwrap()).as_dict().unwrap().clone(); + (encrypt, d.trailer().get(b"ID").cloned(), d.security().unwrap().file_key().to_vec(), format!("{:?}", d.permissions())) + }; + let before = held(&doc); + let mut edited = doc.clone(); + let _ = edited.add(Object::Int(1)); + for (save, saved) in [ + ("incremental", write_incremental(&edited, &SaveOptions::default()).unwrap()), + ("full", write_full(&doc, &SaveOptions::default()).unwrap()), + ] { + let back = Document::open_with_password(Arc::new(saved), Some("user")).unwrap(); + assert!(held(&back) == before, "{alg:?}: a {save} save"); + assert_eq!(content(&back), SECRET, "{alg:?}: a {save} save"); + } + } +} + +/// `/ID` has to be direct in an encrypted file, but one that isn't has its key made from the same +/// bytes. A full save writes it direct, and an incremental one leaves it as it was. +#[test] +fn a_file_identifier_given_by_reference_is_read() { + let file = classic_file(); + let id = hex(&file.id); + let reference = |entries: &str, objects: &[(u32, Vec)]| { + let trailer = format!("/Root {} 0 R /Encrypt {} 0 R /ID {entries}", file.root.num, file.encrypt.num); + updated(&file.bytes, objects, &trailer) + }; + let whole = reference("900 0 R", &[(900, format!("[<{id}> <{id}>]").into_bytes())]); + let entries = reference("[900 0 R 901 0 R]", &[(900, format!("<{id}>").into_bytes()), (901, format!("<{id}>").into_bytes())]); + for file in [whole, entries] { + let source = Document::open(Arc::new(file)).unwrap(); + assert_eq!(content(&source), SECRET); + let back = Document::open(Arc::new(write_full(&source, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(content(&back), SECRET); + assert!(matches!(back.trailer().get(b"ID"), Some(Object::Array(_))), "written direct"); + let mut edited = source.clone(); + let _ = edited.add(Object::Int(1)); + let back = Document::open(Arc::new(write_incremental(&edited, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(content(&back), SECRET, "an incremental save"); + assert_eq!(back.trailer().get(b"ID"), edited.trailer().get(b"ID"), "and leaves it as it was"); + } +} + +/// A direct `/Encrypt` can refer to objects. A full save dropped those it found only there, and kept +/// its references as they were under the old numbers, so nothing in the file read afterwards. +#[test] +fn a_direct_encrypt_dictionary_keeps_what_it_refers_to() { + for object_streams in [false, true] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let mut dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap(); + let filter = doc.add(Object::name("StdCF")); + let filters = doc.add(dict.get(b"CF").unwrap().clone()); + dict.set(b"StmF".to_vec(), Object::Ref(filter)); + dict.set(b"CF".to_vec(), Object::Ref(filters)); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + let options = SaveOptions { object_streams, ..SaveOptions::default() }; + let saved = write_full(&doc, &options).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(content(&back), SECRET, "object streams: {object_streams}"); + let again = write_full(&back, &options).unwrap(); + assert_eq!(content(&Document::open(Arc::new(again)).unwrap()), SECRET, "object streams: {object_streams}, saved again"); + } +} + +/// How a stream is treated can be given by reference, and what that loads can be a stream too. A +/// file can make a ladder of those, where each rung reads the rung below, then two trees wider than +/// the object cache, which push that rung out of it, then the rung below again. Each rung would +/// take twice what the one below does, if one read could follow any number of references. +#[test] +fn selectors_that_lead_to_selectors_load_at_once() { + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let stream_of = |doc: &mut Document, filters: Vec| { + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(filters)); + doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(dict, Vec::new()))) + }; + let tree = |doc: &mut Document| { + let mut level: Vec = (0..6_561).map(|i| doc.add(Object::Int(i))).collect(); + while level.len() > 1 { + let chunks: Vec> = level.chunks(9).map(|c| c.iter().map(|r| Object::Ref(*r)).collect()).collect(); + level = chunks.into_iter().map(|c| stream_of(doc, c)).collect(); + } + Object::Ref(level[0]) + }; + let (first, second) = (tree(&mut doc), tree(&mut doc)); + let mut top = doc.add(Object::Int(0)); + // Twelve rungs of two, and the trees' five levels, stay inside the 32 that loading may nest. + for _ in 0..12 { + let (a, b) = (stream_of(&mut doc, vec![Object::Ref(top)]), stream_of(&mut doc, vec![Object::Ref(top)])); + top = stream_of(&mut doc, vec![Object::Ref(a), first.clone(), second.clone(), Object::Ref(b)]); + } + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let (done, finished) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let doc = Document::open(Arc::new(saved)).unwrap(); + let _ = done.send(doc.get(top).as_dict().is_some()); + }); + // With the budget it takes milliseconds, and without it longer than anyone would wait, so a + // margin this wide can't fail on a busy machine. + assert_eq!(finished.recv_timeout(std::time::Duration::from_secs(120)), Ok(true), "loading must not fan out"); +} diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index 1b98664255..eb03523189 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -228,6 +228,16 @@ fn incremental_save_keeps_a_trailer_only_edit() { assert_qpdf_clean(&saved); } +#[test] +fn incremental_save_ignores_a_negative_trailer_size() { + let mut doc = Document::open(Arc::new(classic_table())).unwrap(); + doc.trailer_mut().set(b"Size".to_vec(), Object::Int(-1)); + let next = doc.add(Object::Int(5)).num + 1; + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(back.trailer().int(b"Size"), Some(i64::from(next))); +} + /// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is /// not written: the file never held it, and writing it would keep what was removed. Additions the /// update still refers to, from the trailer or a changed object and through other additions, are. diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index b41caa2b88..51eca668d0 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -15,8 +15,6 @@ pdfcraft-model = { workspace = true } pdfcraft-fonts = { workspace = true } # For `image::bmp_with_pixel_offset`, shared with XFA pictures. pdfcraft-xfa = { workspace = true } -# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images. -hayro-jbig2.workspace = true png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } diff --git a/crates/create/README.md b/crates/create/README.md index d359eed9ae..f72f70aa26 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -27,11 +27,14 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. -JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included, -as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more -pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the -page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image -exported opaque, as JPX and CCITT masks are. +JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind +other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image +whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded), +or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a +limit of their own), is skipped, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are. +Past what one export may decode, which its images share, filters, decoders and pixels alike (`ImageBudget`; a JPEG when it has to be +decoded, as a mask, to apply one, or to undo a CMYK file's inversion, and not when it is written as it is), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that +is more, since they stay in memory until it ends. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. @@ -43,4 +46,4 @@ CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::D `ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine` says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or, -at `/K 0`, may come out wrong. JPEG 2000 and JBIG2 images are still reported, not exported. +at `/K 0`, may come out wrong. JPEG 2000 images are still reported, not exported. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index b12bd50ec2..d5103071e6 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -6,7 +6,8 @@ use std::collections::HashSet; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_DECODED, ObjRef, Object, Stream}; +use pdfcraft_filters::FilterError; /// One image, ready to write. #[derive(Clone, Debug, PartialEq)] @@ -31,12 +32,29 @@ pub struct ImageExport { /// Images larger than this many pixels are skipped (memory). const MAX_PIXELS: u64 = 100_000_000; +/// What one export may hold of the files it makes: they are all in memory until it ends, and a file +/// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason. +const MAX_EXPORT_BYTES: usize = 2 << 30; + +/// What an export of a file of `file_bytes` may hold: twice the file if that is more than +/// [`MAX_EXPORT_BYTES`], so that a file of large images is exported whole. +pub(crate) fn export_bytes(file_bytes: usize) -> usize { + MAX_EXPORT_BYTES.max(file_bytes.saturating_mul(2)) +} + /// The images of `pages` (0-based; each image once, on the first page using it), skipping /// those with fewer than `min_side` pixels on their shorter side. pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport { + extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), export_bytes(doc.bytes().len())) +} + +/// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is +/// left out, with the reason. +pub(crate) fn extract_images_within(doc: &Document, pages: &[usize], min_side: u32, budget: &mut ImageBudget, max_bytes: usize) -> ImageExport { let all = pdfcraft_model::pages(doc); let mut out = ImageExport::default(); let mut seen = HashSet::new(); + let mut held = 0usize; for &p in pages { let Some(page) = all.get(p) else { continue }; let mut refs = Vec::new(); @@ -53,8 +71,15 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx if w <= 0 || h <= 0 || (w.min(h) as u64) < min_side as u64 { continue; } - match image(doc, s) { - Ok((extension, data)) => out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }), + if held >= max_bytes { + out.skipped.push((p, r, "more images than one export can hold".into())); + continue; + } + match image(doc, s, budget) { + Ok((extension, data)) => { + held = held.saturating_add(data.len()); + out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }); + } Err(why) => out.skipped.push((p, r, why)), } } @@ -64,8 +89,13 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx /// One image XObject as a file: JPEG as is, else PNG ("Save image as"). pub fn image_file(doc: &Document, image: ObjRef) -> Result<(&'static str, Vec), String> { + image_file_within(doc, image, &mut ImageBudget::new(doc)) +} + +/// [`image_file`] within `budget`, for a caller making a file of many images. +pub fn image_file_within(doc: &Document, image: ObjRef, budget: &mut ImageBudget) -> Result<(&'static str, Vec), String> { match &*doc.get(image) { - Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s), + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s, budget), _ => Err("not an image".into()), } } @@ -100,125 +130,24 @@ fn filters(s: &Stream) -> Vec> { } } -/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given -/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor -/// given so would otherwise be lost), or, for a JBIG2 image -/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples -/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7). -fn decoded(doc: &Document, s: &Stream) -> Result, String> { - match filters(s).as_slice() { - [f] if f == b"JBIG2Decode" => jbig2(doc, s), - _ => doc.decoded_image(s).map_err(|e| e.to_string()), - } -} - -/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2). -fn decode_parms(doc: &Document, s: &Stream) -> Dict { - let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() }; - let p = match &*p { - Object::Array(a) => match a.first() { - Some(first) => doc.resolve(first), - None => return Dict::default(), - }, - _ => p, - }; - p.as_dict().cloned().unwrap_or_default() -} - -/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder -/// shares between pages, themselves coded, so far less than any stream may decode to -/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image. -const MAX_GLOBALS: usize = 32 << 20; - -/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller -/// checked against the export's size limit, and within that limit itself: the decoder decodes the -/// whole page, rows past the image's height too, so a small `/Height` over a far taller page -/// would cost more than the limit allows. Rows past the image's height are dropped, and too few -/// rows are refused by the caller. Globals that can't be read refuse the image, rather than -/// decoding it without them. They are decoded before the page's size is known, so the page's -/// limit can't bound them; they have [`MAX_GLOBALS`] of their own. -fn jbig2(doc: &Document, s: &Stream) -> Result, String> { - let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() { - Some(Object::Stream(g)) => { - Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?) - } - _ => None, - }; - let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?; - if s.dict.int(b"Width") != Some(i64::from(image.width())) { - return Err("the JBIG2 image's width isn't the image's width".into()); - } - if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS { - return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height())); - } - let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0); - let mut rows = Rows::new(image.width() as usize, height); - image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?; - Ok(rows.out) -} - -/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns` -/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so -/// the output is bounded by the size the caller gave. -struct Rows { - out: Vec, - row: Vec, - x: usize, - columns: usize, - rows: usize, - done: usize, -} - -impl Rows { - fn new(columns: usize, rows: usize) -> Self { - Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 } - } - - /// `count` pixels, as 1 samples when `one`. - fn push(&mut self, one: bool, count: usize) { - let end = self.x.saturating_add(count); - if one { - 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; - } - - 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; - } -} - -/// 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(); - } +/// The samples of image `s`, decoded ([`ImageBudget::decode`]): through its filters, with a +/// `/DecodeParms` dictionary given by reference, or as an array's entry, or with numbers inside by +/// reference, read too (a predictor given so would otherwise be lost); a CCITT fax or JBIG2 image +/// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`. +/// A JBIG2 page has to be the image's size, which the caller checked against the export's limit, +/// before the page is decoded. +fn decoded(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result, String> { + budget.decode(doc, s, MAX_DECODED).map_err(|e| e.to_string()) } /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft /// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as /// alpha, JPEG images included, so cut-out images keep their transparent background. -fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> { +fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'static str, Vec), String> { let f = filters(s); let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1; match f.last().map(Vec::as_slice) { Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), - Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()), Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()), _ => {} } @@ -233,14 +162,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> }; let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). - let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; + let refused = budget.refused(); + let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) }; + // Without its mask the image would come out opaque, showing what the mask hides. + if budget.refused() > refused { + return Err(FilterError::Spent.to_string()); + } let key = if mask { None } else { colour_key(doc, s) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. + let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).filter(|v| v.is_finite()); let inverted = (jpeg || mask) && s.dict .get(b"Decode") .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + // Both ends have to be finite numbers, as for lossless images, or the default mapping stands. + .and_then(|d| { + let a = d.as_array()?; + Some(endpoint(a.first())? > endpoint(a.get(1))?) + }) .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as @@ -251,13 +190,16 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> } let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? }; let (samples, bpc) = if jpeg { + if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64), &s.raw) { + return Err(FilterError::Spent.to_string()); + } // Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an // indexed one (not valid), is written as is, without its transparency. let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() }; space = decoded; (samples, 8) } else { - let data = decoded(doc, s)?; + let data = decoded(doc, s, budget)?; let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, @@ -348,6 +290,32 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> { (w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h)) } +#[cfg(test)] +thread_local! { + /// JPEGs decoded whole, for the tests that keep one from being decoded for nothing. + pub(crate) static JPEGS_DECODED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary +/// says: an image of one pixel can hold a header of 500 megapixels. +fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> { + use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions}; + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); + dec.decode_headers().ok()?; + dec.dimensions() +} + +/// JPEG `data`, decoded, if its header says it is `w` × `h`. +fn jpeg_of_size(data: &[u8], w: usize, h: usize) -> Option { + if jpeg_size(data)? != (w, h) { + return None; + } + #[cfg(test)] + JPEGS_DECODED.with(|n| n.set(n.get() + 1)); + image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok() +} + /// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB, /// with the space they are in. fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec, Space)> { @@ -356,10 +324,7 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)), _ => {} } - let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?; - if img.width() as usize != w || img.height() as usize != h { - return None; - } + let img = jpeg_of_size(data, w, h)?; Some(match space { Space::Gray => (img.into_luma8().into_raw(), Space::Gray), _ => (img.into_rgb8().into_raw(), Space::Rgb), @@ -560,32 +525,35 @@ fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] { } /// The soft mask (`/SMask`) as 8-bit alpha, scaled to the image's size when it has another. -fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option> { +fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let r = s.dict.get(b"SMask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; - gray_plane(doc, m, w, h) + gray_plane(doc, m, w, h, budget) } /// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with /// `/Decode [1 0]`) hide the image. -fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option> { +fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let r = s.dict.get(b"Mask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; - Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect()) + Some(gray_plane(doc, m, w, h, budget)?.into_iter().map(|v| 255 - v).collect()) } /// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to /// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks). -fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> { +fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?; let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true))); let f = filters(m); let mut px = match f.last().map(Vec::as_slice) { Some(b"DCTDecode" | b"DCT") if f.len() == 1 => { - let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8(); - (img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())? + // Past the allowance the image goes too: `image` sees this one refused. + if !budget.pay_jpeg((mw as u64).saturating_mul(mh as u64), &m.raw) { + return None; + } + jpeg_of_size(&m.raw, mw, mh)?.into_luma8().into_raw() } Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None, _ => { @@ -599,7 +567,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; - unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() + unpack(&decoded(doc, m, budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { @@ -612,7 +580,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> return Some(px); } let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?; - Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw()) + Some(scaled(&img, w as u32, h as u32).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 mask of other proportions than its image can make larger than any +/// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process. +/// Such a mask 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 mask of its image's shape as it was. +pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage { + use image::imageops::{FilterType, resize}; + let (mw, mh) = (u64::from(img.width()), u64::from(img.height())); + let largest = (mw * mh).max(u64::from(w) * u64::from(h)); + if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) { + resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle) + } else { + resize(img, w, h, FilterType::Triangle) + } } fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result, String> { diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 36b52d4211..6346e85120 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -6,7 +6,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize} mod extract; mod upright_image; -pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; +pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within}; use pdfcraft_fonts::{literal, win_ansi, wrap}; #[derive(Debug, thiserror::Error, PartialEq)] @@ -23,29 +23,48 @@ pub const LETTER: (f64, f64) = (612.0, 792.0); /// The largest page side PDF allows (ISO 32000-2 Annex C: 14 400 units). const MAX_SIDE: f64 = 14_400.0; -fn add_page(doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option>) -> Result { - // `Document::new_empty` always has a page tree. - let pages = doc - .root() - .and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))) - .ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?; - let mut page = Dict::new(); - page.set(b"Type".to_vec(), Object::name("Page")); - page.set(b"Parent".to_vec(), Object::Ref(pages)); - page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)])); - page.set(b"Resources".to_vec(), Object::Dict(resources)); - if let Some(c) = content { - let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c))); - page.set(b"Contents".to_vec(), Object::Ref(s)); +/// The page tree of a document being made, with the pages added to it so far: its `/Kids` are +/// written once, by [`Pages::finish`]. Adding each page to the tree in the document would copy every +/// page before it, which a text of a million form feeds makes quadratic. +struct Pages { + tree: ObjRef, + kids: Vec, +} + +impl Pages { + /// The page tree of `doc`, with the pages it has. + fn of(doc: &Document) -> Result { + // `Document::new_empty` always has a page tree. + let tree = doc + .root() + .and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))) + .ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?; + let kids = doc.get(tree).as_dict().and_then(|d| d.get(b"Kids")).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + Ok(Self { tree, kids }) + } + + fn add(&mut self, doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option>) { + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(self.tree)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)])); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + if let Some(c) = content { + let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c))); + page.set(b"Contents".to_vec(), Object::Ref(s)); + } + self.kids.push(Object::Ref(doc.add(page))); + } + + /// Writes the pages added into the tree. + fn finish(self, doc: &mut Document) -> Result<(), CreateError> { + let count = i64::try_from(self.kids.len()).map_err(|_| CreateError::Invalid("too many pages".into()))?; + doc.update_dict(self.tree, |d| { + d.set(b"Count".to_vec(), Object::Int(count)); + d.set(b"Kids".to_vec(), Object::Array(self.kids)); + }) + .map_err(|e| CreateError::Invalid(e.to_string())) } - let r = doc.add(page); - let _ = doc.update_dict(pages, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - 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)); - }); - Ok(r) } fn set_title(doc: &mut Document, title: &str) { @@ -65,9 +84,11 @@ pub fn blank(width: f64, height: f64, pages: usize) -> Result)], resolution: Ima return Err(CreateError::Invalid("no images".into())); } let mut doc = Document::new_empty(); + let mut tree = Pages::of(&doc)?; let mut profiles = Vec::new(); for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::, _>>()?.into_iter().flatten() { let dpi = match resolution { @@ -692,8 +714,9 @@ pub fn from_images_with_resolution(images: &[(String, Vec)], resolution: Ima let mut res = Dict::new(); res.set(b"XObject".to_vec(), Object::Dict(xobj)); let content = format!("q {w:.3} 0 0 {h:.3} 0 0 cm /Im0 Do Q\n").into_bytes(); - add_page(&mut doc, w, h, res, Some(content))?; + tree.add(&mut doc, w, h, res, Some(content)); } + tree.finish(&mut doc)?; if let Some((name, _)) = images.first() { set_title(&mut doc, name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s)); } @@ -787,6 +810,7 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); let fr = doc.add(font); + let mut tree = Pages::of(&doc)?; let mut page_lines: Vec> = vec![Vec::new()]; for l in lines.iter().take(lines.len().saturating_sub(1)) { if l == "\u{c}" || page_lines.last().is_some_and(|p| p.len() >= per_page) { @@ -810,8 +834,9 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result fonts.set(b"F1".to_vec(), Object::Ref(fr)); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); - add_page(&mut doc, w, h, res, Some(c))?; + tree.add(&mut doc, w, h, res, Some(c)); } + tree.finish(&mut doc)?; set_title(&mut doc, title); Ok(doc) } diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 03c93d4a51..9e620c19c3 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -124,6 +124,27 @@ fn text_is_wrapped_and_paginated() { assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); } +#[test] +fn text_of_many_form_feeds_makes_its_pages_in_linear_time() { + // Each form feed starts a page: adding each to the page tree copied all the pages before it, + // which made forty thousand of them take over a minute in a debug build, against about two + // seconds when the tree is written once. + let text = "x\u{c}".repeat(40_000); + let start = std::time::Instant::now(); + let doc = from_text("many", &text, LETTER, 11.0).unwrap(); + let took = start.elapsed(); + let tree = doc.get(doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap()); + let tree = tree.as_dict().unwrap(); + assert_eq!(tree.get(b"Kids").and_then(|k| k.as_array()).map(|a| a.len()), Some(40_001)); + assert_eq!(tree.int(b"Count"), Some(40_001)); + assert!(took < std::time::Duration::from_secs(30), "{took:?}"); + // Blank documents and pages made from images keep their pages, in order. + let doc = reopen(&blank(200.0, 300.0, 3).unwrap()); + assert_eq!(pages(&doc).len(), 3); + let doc = reopen(&from_images(&[("a.png".into(), png_bytes(false)), ("b.png".into(), png_bytes(true))]).unwrap()); + assert_eq!(pages(&doc).len(), 2); +} + #[test] fn text_keeps_leading_spaces_and_tabs() { let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap()); @@ -2054,10 +2075,10 @@ fn symbol_coded_jbig2_images_export_as_their_picture() { /// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as /// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in -/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the -/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image, -/// or larger than an exported image may be (however small the image's `/Height`), or data that -/// isn't JBIG2, skips the image. +/// an array, so globals that can't be read skip the image rather than being ignored. A JBIG2 page +/// of another size than the image (a taller one too: PdfCraft's renderer draws the whole page into +/// the image), refused before it is decoded however large, or data that isn't JBIG2, skips the +/// image. #[test] fn jbig2_images_export_as_their_picture() { let (w, h) = (203i64, 41i64); @@ -2118,33 +2139,24 @@ fn jbig2_images_export_as_their_picture() { for in_array in [false, true] { assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}"); } - assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height"); - for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] { + for (width, height, data) in + [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h - 5, &jbig2[..]), (w, h, &b"not JBIG2"[..])] + { assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len()); } // The same stream with its page information (the first segment's data, after its 11-byte - // header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the - // export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page - // (it takes pages of up to about 2^28 pixels), so it is refused first. + // header: width, then height) claiming a 20,000 × 10,000 page, behind an image of 20,000 × 4: + // the decoder would decode the whole page, so it is refused first. let mut large = jbig2.clone(); large[11..15].copy_from_slice(&20_000u32.to_be_bytes()); large[15..19].copy_from_slice(&10_000u32.to_be_bytes()); - assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size"); + assert!(skipped(&page(20_000, 4, &large, None, false)), "a page far taller than the image"); // Refused the same ways as a stencil /Mask, it is left out: the image exports opaque. for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] { let rgba = exported_rgba(&page(width, height, mask, None, true)).1; assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask"); assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out"); } - // A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused. - for (rows, refused) in [(5_000u32, false), (5_001, true)] { - large[15..19].copy_from_slice(&rows.to_be_bytes()); - let doc = page(20_000, 4, &large, None, false); - assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}"); - if !refused { - assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4); - } - } // Damaged data never panics. for end in [0, 1, 10, 20, 30, jbig2.len() / 2] { let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0); @@ -2155,6 +2167,38 @@ fn jbig2_images_export_as_their_picture() { assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha); } +/// A JBIG2 image behind another filter (ASCIIHexDecode) exports as its picture, as a fax image does, +/// with the `/JBIG2Globals` at its own place in a `/DecodeParms` array: globals that can't be read +/// there skip the image, and ones at the other filter's place aren't the image's. +#[test] +fn jbig2_images_behind_another_filter_export_as_their_picture() { + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let hex: Vec = jbig2.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); + // With unreadable globals at place `at` of its /DecodeParms. + let page = |at: Option| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")])); + if let Some(at) = at { + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec()))))); + let mut entries = vec![Object::Null, Object::Null]; + entries[at] = Object::Dict(parms); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + } + Stream::from_raw(d, hex.clone()) + }); + doc + }; + assert_eq!(exported_rgba(&page(None)), ("png", picture.clone())); + assert_eq!(exported_rgba(&page(Some(0))).1, picture, "globals at ASCIIHexDecode's place"); + let out = extract_images(&page(Some(1)), &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "unreadable globals at its own place"); +} + /// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have /// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to /// one byte more skip the image, with the limit in the reason, though they take a few kilobytes @@ -2162,7 +2206,8 @@ fn jbig2_images_export_as_their_picture() { /// indirect `/DecodeParms`, in an array or not. #[test] fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { - const LIMIT: usize = 32 << 20; + const LIMIT: usize = pdfcraft_cos::MAX_JBIG2_GLOBALS; + assert_eq!(LIMIT, 32 << 20); let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); @@ -2206,6 +2251,372 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { let out = extract_images(&page(&over, form), &[0], 0); assert!(out.images.is_empty(), "globals over the limit, form {form}"); let why = &out.skipped.first().expect("the image is skipped").2; - assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); + assert!(why.contains("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); } } + +/// One allowance for all the images of an export: an image past it is left out, saying so. +#[test] +fn jbig2_images_past_the_exports_allowance_are_left_out_with_the_reason() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + 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")); + let image = Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()); + 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 pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec(); + let mut doc = Document::open(std::sync::Arc::new(pdf)).unwrap(); + let (a, b) = (doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image))); + let mut xobjects = Dict::new(); + xobjects.set(b"A".to_vec(), Object::Ref(a)); + xobjects.set(b"B".to_vec(), Object::Ref(b)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let page = pdfcraft_model::pages(&doc).swap_remove(0).obj; + doc.update_dict(page, |p| p.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + + let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(cost), usize::MAX); + assert_eq!(export.images.len(), 1); + assert_eq!(export.skipped.len(), 1); + assert!(export.skipped[0].2.contains("more image decoding than"), "{:?}", export.skipped); + // What an export holds is bounded too: the files are all in memory until it ends. + let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(u64::MAX), 1); + assert_eq!(export.images.len(), 1, "the first goes past it"); + assert_eq!(export.skipped.len(), 1); + assert!(export.skipped[0].2.contains("than one export can hold"), "{:?}", export.skipped); + // A file of many images shares one allowance too, image by image. + let mut budget = ImageBudget::with_work(cost); + assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok()); + assert!(crate::extract::image_file_within(&doc, b, &mut budget).is_err()); +} + +/// An image that isn't JBIG2 shares the allowance too: a file of filter bombs would hold an export +/// as long as one of JBIG2. What isn't decoded here, a JPEG, is still exported. +#[test] +fn flate_images_past_the_exports_allowance_are_left_out_with_the_reason() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let image = |filter: &str, raw: Vec| { + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(2)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name(filter)); + Stream::from_raw(dict, raw) + }; + let flate = image("FlateDecode", pdfcraft_filters::encode_flate(&[0x80; 4])); + let mut cost = ImageBudget::with_work(u64::MAX); + cost.decode(&Document::new_empty(), &flate, 1 << 20).unwrap(); + let cost = u64::MAX - cost.work_left(); + + let mut doc = Document::new_empty(); + let (a, b) = (doc.add(Object::Stream(flate.clone())), doc.add(Object::Stream(flate))); + let mut budget = ImageBudget::with_work(cost); + assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok()); + let refused = crate::extract::image_file_within(&doc, b, &mut budget).unwrap_err(); + assert!(refused.contains("more image decoding than"), "{refused}"); + assert_eq!(budget.refused(), 1); + + let jpeg = image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])); + let mut bytes = Vec::new(); + jpeg.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Jpeg).unwrap(); + let jpeg = doc.add(Object::Stream(image("DCTDecode", bytes))); + assert!(crate::extract::image_file_within(&doc, jpeg, &mut budget).is_ok(), "nothing charged"); + assert_eq!(budget.refused(), 1); +} + +/// A JPEG is written as it is, at no cost, unless something means decoding it, here a mask: that +/// pays for its samples, and past the allowance the image goes, with the reason, as it would come out without +/// its transparency otherwise. +#[test] +fn a_jpeg_that_has_to_be_decoded_shares_the_allowance() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(4, 4, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + let image_dict = |extra: Option<(&[u8], Object)>| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(4)); + d.set(b"Height".to_vec(), Object::Int(4)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + if let Some((key, value)) = extra { + d.set(key.to_vec(), value); + } + d + }; + let mut doc = Document::new_empty(); + let mut mask = Dict::new(); + mask.set(b"Subtype".to_vec(), Object::name("Image")); + mask.set(b"Width".to_vec(), Object::Int(4)); + mask.set(b"Height".to_vec(), Object::Int(4)); + mask.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![0x80; 16]))); + let doc_jpeg_len = jpeg.len(); + let masked = doc.add(Object::Stream(Stream::from_raw(image_dict(Some((b"SMask", Object::Ref(mask)))), jpeg.clone()))); + let plain = doc.add(Object::Stream(Stream::from_raw(image_dict(None), jpeg))); + + let mut budget = ImageBudget::with_work(0); + assert!(crate::extract::image_file_within(&doc, plain, &mut budget).is_ok(), "written as it is"); + assert_eq!(budget.refused(), 0); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(crate::extract::image_file_within(&doc, masked, &mut budget).is_ok()); + let cost = u64::MAX - budget.work_left(); + let read = 4 * doc_jpeg_len as u64; + assert_eq!(cost, 16 + 16 + 2 * 4 * 4 * 3 + read, "the mask's bytes and pixels, the JPEG's samples twice and its reading"); + let mut budget = ImageBudget::with_work(cost - 1); + let refused = crate::extract::image_file_within(&doc, masked, &mut budget).unwrap_err(); + assert!(refused.contains("more image decoding than"), "{refused}"); + assert_eq!(budget.refused(), 1); +} + +/// A JPEG that is itself a mask pays for its samples, each time an image reads it: images can +/// share one, and only the images are ever seen once. +#[test] +fn a_jpeg_mask_pays_for_its_samples() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut jpeg = Vec::new(); + image::GrayImage::from_pixel(4, 4, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + let mut mask = Dict::new(); + mask.set(b"Subtype".to_vec(), Object::name("Image")); + mask.set(b"Width".to_vec(), Object::Int(4)); + mask.set(b"Height".to_vec(), Object::Int(4)); + mask.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + mask.set(b"Filter".to_vec(), Object::name("DCTDecode")); + let mut doc = Document::new_empty(); + let read = 4 * jpeg.len() as u64; + let mask = doc.add(Object::Stream(Stream::from_raw(mask, jpeg))); + let mut image = Dict::new(); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + image.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80]))); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_ok()); + assert_eq!(u64::MAX - budget.work_left(), 2 * 4 * 4 + read + 2, "its samples twice, its reading, and the image's byte and pixel"); + // Just short of what the mask costs: it isn't decoded, and takes the image with it. + let mut budget = ImageBudget::with_work(2 * 4 * 4 + read - 1); + let refused = crate::extract::image_file_within(&doc, image, &mut budget).unwrap_err(); + assert!(refused.contains("more image decoding than"), "{refused}"); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); +} + +/// What decoding a JPEG takes goes by its header, so that is what has to agree with the +/// dictionary before it is decoded: one image of a pixel can hold a page of 500 megapixels. The +/// file comes out as it is either way, so what shows is whether it was decoded. +#[test] +fn a_jpeg_larger_than_its_dictionary_says_isnt_decoded() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(4, 4, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + // A colour key means decoding it, to find the pixels it hides. + let keyed = |side: i64| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(side)); + d.set(b"Height".to_vec(), Object::Int(side)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0); 6])); + Stream::from_raw(d, jpeg.clone()) + }; + let decodes = |doc: &Document, image: pdfcraft_cos::ObjRef| { + crate::extract::JPEGS_DECODED.with(|n| n.set(0)); + assert!(crate::extract::image_file_within(doc, image, &mut ImageBudget::with_work(u64::MAX)).is_ok()); + crate::extract::JPEGS_DECODED.with(std::cell::Cell::get) + }; + let mut doc = Document::new_empty(); + let (honest, lying) = (doc.add(Object::Stream(keyed(4))), doc.add(Object::Stream(keyed(1)))); + assert_eq!(decodes(&doc, honest), 1); + assert_eq!(decodes(&doc, lying), 0); + // The same as a mask: read as none, without being decoded. + let mut mask = keyed(1); + mask.dict.remove(b"Mask"); + let mask = doc.add(Object::Stream(mask)); + let mut image = Dict::new(); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + image.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80]))); + assert_eq!(decodes(&doc, image), 0); + // Reading the header to find that out is paid for: a mask can be mostly tables, shared by + // every image of a file, and with nothing left it isn't read. + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_ok()); + assert_eq!(u64::MAX - budget.work_left(), 2 + 4 * jpeg.len() as u64 + 2, "its one declared sample twice, its bytes, the image's byte and pixel"); + let mut budget = ImageBudget::with_work(0); + assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_err()); + assert_eq!(budget.refused(), 1, "the image goes, with its mask unread"); +} + +/// A mask past the allowance can't be left out as an unreadable one is, or the image comes out +/// opaque, showing what the mask hides: the image goes, with the reason. +#[test] +fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut mask = Dict::new(); + mask.set(b"Subtype".to_vec(), Object::name("Image")); + mask.set(b"Width".to_vec(), Object::Int(16)); + mask.set(b"Height".to_vec(), Object::Int(2)); + mask.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + mask.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut doc = Document::new_empty(); + let mask = doc.add(Object::Stream(Stream::from_raw(mask, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()))); + // A JPEG, read for nothing until its mask is, so only the mask can be what stops it. + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(1, 1, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + let mut image = Dict::new(); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + image.set(b"Filter".to_vec(), Object::name("DCTDecode")); + image.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(Stream::from_raw(image, jpeg))); + assert!(crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(u64::MAX)).is_ok()); + let refused = crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(0)).unwrap_err(); + assert!(refused.contains("more image decoding than"), "{refused}"); +} + +/// A large honest file keeps its images: what an export may hold grows with the file past the fixed +/// part, and a file too long to double doesn't wrap round to nothing. +#[test] +fn an_export_may_hold_twice_a_large_file() { + let fixed = crate::extract::export_bytes(0); + assert_eq!(fixed, 2 << 30); + assert_eq!(crate::extract::export_bytes(fixed / 2), fixed); + assert_eq!(crate::extract::export_bytes(fixed / 2 + 1), fixed + 2); + assert_eq!(crate::extract::export_bytes(usize::MAX), usize::MAX); +} + +/// `/Decode` says which way round a stencil mask is, and its numbers can be given by reference. Read +/// as absent, they gave the wrong answer, so the file came out a negative of what the page draws. +#[test] +fn a_stencil_masks_decode_array_can_hold_references() { + use pdfcraft_cos::Document; + let export = |decode: &str| { + 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 100 100] /Resources << /XObject << /Im 4 0 R >> >> >> endobj\n\ + 4 0 obj << /Subtype /Image /ImageMask true /Width 8 /Height 1 /BitsPerComponent 1 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\nF0>\nendstream endobj\n\ + 5 0 obj 1 endobj\n6 0 obj 0 endobj\n7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ); + let doc = Document::open(std::sync::Arc::new(pdf.into_bytes())).unwrap(); + let mut export = crate::extract::extract_images(&doc, &[0], 1); + assert_eq!(export.images.len(), 1, "{decode}: {:?}", export.skipped); + export.images.remove(0).data + }; + let (inverted, identity) = (export("[1 0]"), export("[0 1]")); + assert_ne!(inverted, identity, "the two ways round differ"); + for decode in ["[5 0 R 0]", "[1 6 0 R]", "[5 0 R 6 0 R]"] { + assert_eq!(export(decode), inverted, "{decode}"); + } + for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] { + assert_eq!(export(decode), identity, "{decode}"); + } + // A reference that only leads back to itself holds no number, and an end that is missing or isn't + // a number leaves the default mapping, whichever end it is. + for decode in ["[7 0 R 0]", "[1 7 0 R]", "[8 0 R 0]", "[1 8 0 R]", "[7 0 R 8 0 R]", "[1]", "[1 /Name]", "[/Name 0]"] { + assert_eq!(export(decode), identity, "{decode}"); + } + // Equal ends aren't the two ways round either, and a number too long to hold reads as infinite. + assert_eq!(export("[1 1]"), identity); + assert_eq!(export(&format!("[{} 0]", "9".repeat(400))), identity); +} + +/// A mask of other proportions than its image is scaled to it, however far apart they are: the +/// first two shapes asked for 160 GB on the way, in a file of a kilobyte, and that aborted the +/// process. The third would ask for 10 GB if it went across first, which is why the way round +/// depends on the shapes. The value comes through, and masks of ordinary proportions, of the +/// image's size and larger than it, are scaled as they always were. +#[test] +fn a_mask_of_other_proportions_than_its_image_is_scaled_without_the_memory_of_both() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + let gray = |w: i64, h: i64, data: &[u8]| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w)); + d.set(b"Height".to_vec(), Object::Int(h)); + 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")); + Stream::from_raw(d, pdfcraft_filters::encode_flate(data)) + }; + let shapes = [ + (100_000usize, 1usize, 1usize, 100_000usize), + (100_000, 64, 64, 100_000), + (1, 100_000, 100_000, 1), + (64, 1, 1, 64), + (3, 2, 6, 4), + (3, 2, 3, 2), + (6, 4, 3, 2), + ]; + for (mask_w, mask_h, w, h) in shapes { + let mut doc = Document::new_empty(); + let mask = doc.add(Object::Stream(gray(mask_w as i64, mask_h as i64, &vec![0x80; mask_w * mask_h]))); + let mut image = gray(w as i64, h as i64, &vec![0x40; w * h]); + image.dict.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(image)); + let (name, png) = crate::image_file(&doc, image).unwrap_or_else(|e| panic!("{mask_w} × {mask_h} on {w} × {h}: {e}")); + assert_eq!(name, "png"); + let mut reader = ::png::Decoder::new(std::io::Cursor::new(&png)).read_info().unwrap(); + let mut rgba = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut rgba).unwrap(); + assert_eq!((info.width as usize, info.height as usize, info.color_type), (w, h, ::png::ColorType::Rgba)); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[3].abs_diff(0x80) <= 1), "{mask_w} × {mask_h} on {w} × {h}"); + } +} + +/// The long way round is for proportions far apart: up to a quarter over the larger image the +/// buffer is no danger, and such a mask scales as it always did, to the byte. Noise, as a flat mask comes +/// out the same either way. +#[test] +fn proportions_up_to_the_limit_scale_as_they_did() { + let noise = |w: u32, h: u32| { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let px = (0..w * h) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + image::GrayImage::from_raw(w, h, px).unwrap() + }; + // Each exactly a quarter over the larger: the source's size, then the target's. + for (mw, mh, w, h) in [(25, 16, 10, 20), (20, 5, 16, 25)] { + let mask = noise(mw, mh); + let direct = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Triangle); + assert_eq!(crate::extract::scaled(&mask, w, h), direct, "{mw} × {mh} to {w} × {h}"); + } + // A step past it goes the long way, which is how to tell. + let mask = noise(25, 15); + let direct = image::imageops::resize(&mask, 10, 20, image::imageops::FilterType::Triangle); + assert_ne!(crate::extract::scaled(&mask, 10, 20), direct); + // A mask of its image's size comes through as it is. + let mask = noise(20, 10); + assert_eq!(crate::extract::scaled(&mask, 20, 10), mask); +} diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 89d9387989..2cd33a275d 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -24,3 +24,5 @@ unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } +pdfcraft-render = { workspace = true } +proptest = { workspace = true } diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index ff570d5483..0d0213904b 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -7,9 +7,25 @@ use super::place::{ }; use super::{Document, EditError, Object, added, backdrop, helvetica_width}; +/// Commit one complete mark operation only after every selected page succeeds. The snapshot +/// shares original bytes, cross-references, caches and unchanged objects; only the overlay table +/// and document metadata are copied. Dropping a failed stage also drops removed marks, changed +/// page/resources/backdrop dictionaries and new objects, including the allocation cursor. +fn apply_mark_transaction(doc: &mut Document, apply: impl FnOnce(&mut Document) -> Result<(), EditError>) -> Result<(), EditError> { + let mut staged = doc.clone(); + apply(&mut staged)?; + *doc = staged; + Ok(()) +} + /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on -/// those pages are removed first (Acrobat's Replace Existing). +/// those pages are removed first (Acrobat's Replace Existing). The whole call is atomic: +/// on error, the document (including earlier selected pages) is unchanged. pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_header_footer_in_place(staged, pages, hf, replace, cx)) +} + +fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; if hf.text.iter().all(|t| t.trim().is_empty()) { @@ -121,8 +137,13 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, Ok(()) } -/// Add a text watermark to `pages`. +/// Add a text or picture watermark to `pages`. All selected pages, including mark replacement, +/// form one transaction; on error, the document is unchanged. pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_watermark_in_place(staged, pages, wm, replace)) +} + +fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; let text = wm.text.trim(); @@ -201,8 +222,13 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac /// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a /// white backdrop a page paints itself is replaced first, so the background shows (see -/// [`backdrop`]); a picture backdrop (a scan) still hides it. +/// [`backdrop`]); a picture backdrop (a scan) still hides it. All selected pages, including +/// replacement and backdrop edits, form one transaction; on error, the document is unchanged. pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_background_in_place(staged, pages, bg, replace)) +} + +fn add_background_in_place(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; if replace { diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index 7d745c0c10..376cb5a165 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -98,8 +98,13 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE } b"sc" | b"scn" => st.fill = colour(st.space, op), b"gs" => { - if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) { - apply_gstate(doc, &g, &mut st); + let mut held = None; + let gstate = match op.name(0).and_then(|n| gstates.get(n)) { + Some(g) => crate::text::dict_in_place(doc, g, &mut held), + None => None, + }; + if let Some(g) = gstate { + apply_gstate(doc, g, &mut st); } } b"re" => match op.nums::<4>() { diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index f66e7f24ee..72656fa1bb 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -4,13 +4,28 @@ //! or delete. Only the stream that draws it is rewritten, as a new object. use kurbo::{BezPath, Point, Shape}; -use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; use crate::text::content_streams; +/// The identity of a soft mask (`/SMask`) in the PDF graphics state. +#[derive(Clone, Debug)] +pub(crate) struct MaskIdentity { + // Share the owning ExtGState instead of copying an arbitrarily large inline mask on q/Q. + // Its complete SMask value retains S, TR, BC and every other mask parameter, not just G. + definition: Arc, + ctm: Matrix, +} + +impl PartialEq for MaskIdentity { + fn eq(&self, other: &Self) -> bool { + self.ctm == other.ctm && self.definition.as_dict().and_then(|d| d.get(b"SMask")) == other.definition.as_dict().and_then(|d| d.get(b"SMask")) + } +} + /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] pub struct PageImage { @@ -33,7 +48,7 @@ pub struct PageImage { pub height: u32, /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the /// operators of the page's streams joined ([`Pieces`]). - stream: usize, + pub stream: usize, op: usize, /// CTM before `Do`, excluding a Form's own Matrix. draw_matrix: [f64; 6], @@ -41,6 +56,11 @@ pub struct PageImage { } impl PageImage { + /// Whether an active clip was written by PdfCraft's image crop tool. + pub fn has_editor_crop(&self) -> bool { + self.clips.iter().any(|clip| clip.app_crop) + } + /// Whether a user-space point belongs to the placed artwork and every active clip. pub fn contains(&self, point: [f64; 2]) -> bool { if !point.iter().all(|v| v.is_finite()) { @@ -72,6 +92,7 @@ impl PageImage { struct ClipPath { path: Arc, even_odd: bool, + app_crop: bool, } /// Image draws one page's inventory lists at most: a form drawn many times from other forms @@ -115,17 +136,39 @@ struct ImageState { placement: Placement, stack: Vec, path: BezPath, + subpath_start: Option, pending_clip: Option, + app_crop: bool, } impl ImageState { fn new(matrix: Matrix, clip: [f64; 4], clips: Vec) -> Self { - Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), pending_clip: None } + Self { + placement: Placement { matrix, clip, clips }, + stack: Vec::new(), + path: BezPath::new(), + subpath_start: None, + pending_clip: None, + app_crop: false, + } } - fn point(&self, x: f64, y: f64) -> Point { + fn current_point(&self) -> Option { + match self.path.elements().last() { + Some(kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p)) => Some(*p), + Some(kurbo::PathEl::QuadTo(_, p2) | kurbo::PathEl::CurveTo(_, _, p2)) => Some(*p2), + Some(kurbo::PathEl::ClosePath) => self.subpath_start, + None => None, + } + } + + fn has_current_point(&self) -> bool { + self.current_point().is_some() + } + + fn point(&self, x: f64, y: f64) -> Option { let (x, y) = self.placement.matrix.apply(x, y); - Point::new(x, y) + if x.is_finite() && y.is_finite() { Some(Point::new(x, y)) } else { None } } fn apply(&mut self, op: &Op) -> Result<(), EditError> { @@ -146,12 +189,18 @@ impl ImageState { } b"cm" => { if let Some(m) = op.nums::<6>() { - self.placement.matrix = Matrix(m).then(&self.placement.matrix); + let next_mat = Matrix(m).then(&self.placement.matrix); + if next_mat.0.iter().all(|v| v.is_finite()) { + self.placement.matrix = next_mat; + } } } b"m" => { - if let Some([x, y]) = op.nums::<2>() { - self.path.move_to(self.point(x, y)); + if let Some([x, y]) = op.nums::<2>() + && let Some(p) = self.point(x, y) + { + self.subpath_start = Some(p); + self.path.move_to(p); } } b"l" => { @@ -159,46 +208,57 @@ impl ImageState { // friends panic on an empty path by contract, so the segment is skipped, as the // renderer, MuPDF and pdftotext do (#3581). if let Some([x, y]) = op.nums::<2>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let Some(p) = self.point(x, y) { - self.path.line_to(self.point(x, y)); + self.path.line_to(p); } } b"c" => { if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let (Some(p1), Some(p2), Some(p3)) = (self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)) { - self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)); + self.path.curve_to(p1, p2, p3); } } b"v" => { if let Some([x1, y1, x2, y2]) = op.nums::<4>() - && let Some(current) = self.path.elements().last().and_then(|e| e.end_point()) + && self.has_current_point() + && let (Some(p1), Some(p2)) = (self.point(x1, y1), self.point(x2, y2)) + && let Some(current) = self.current_point() { - self.path.curve_to(current, self.point(x1, y1), self.point(x2, y2)); + self.path.curve_to(current, p1, p2); } } b"y" => { if let Some([x1, y1, x2, y2]) = op.nums::<4>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let (Some(p1), Some(end)) = (self.point(x1, y1), self.point(x2, y2)) { - let end = self.point(x2, y2); - self.path.curve_to(self.point(x1, y1), end, end); + self.path.curve_to(p1, end, end); } } - // ClosePath with no subpath panics by the same contract (#3581). - b"h" if self.path.elements().last().is_some() => { - self.path.close_path(); - } - b"re" => { - if let Some([x, y, w, h]) = op.nums::<4>() { - self.path.move_to(self.point(x, y)); - self.path.line_to(self.point(x + w, y)); - self.path.line_to(self.point(x + w, y + h)); - self.path.line_to(self.point(x, y + h)); + b"h" => { + // ClosePath with no subpath panics by the same contract (#3581). + if self.has_current_point() { self.path.close_path(); } } + b"re" => { + if let Some([x, y, w, h]) = op.nums::<4>() + && let (Some(p0), Some(p1), Some(p2), Some(p3)) = + (self.point(x, y), self.point(x + w, y), self.point(x + w, y + h), self.point(x, y + h)) + { + self.subpath_start = Some(p0); + self.path.move_to(p0); + self.path.line_to(p1); + self.path.line_to(p2); + self.path.line_to(p3); + self.path.close_path(); + } + } + b"MP" if op.name(0) == Some(b"PdfCraftImageCrop") => self.app_crop = true, b"W" => self.pending_clip = Some(false), b"W*" => self.pending_clip = Some(true), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { @@ -207,9 +267,11 @@ impl ImageState { // follows the same rule, and its bounds use cubic extrema, not controls. let bounds = self.path.bounding_box(); self.placement.clip = intersection(self.placement.clip, [bounds.x0, bounds.y0, bounds.x1, bounds.y1]).unwrap_or([0.0; 4]); - self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd }); + self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd, app_crop: self.app_crop }); } self.path = BezPath::new(); + self.subpath_start = None; + self.app_crop = false; } _ => {} } @@ -264,6 +326,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr } else { continue; }; + let rect = matrix.bbox(bounds); if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) || bounds[2] <= bounds[0] @@ -316,34 +379,34 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi forms: &mut Forms, out: &mut Vec, ) -> Result<(), EditError> { - let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let mut held = None; + let xo = crate::text::resource_dict(doc, resources, b"XObject", &mut held); for (i, op) in ops.iter().enumerate() { st.apply(op)?; let ctm = st.placement.matrix; if op.is("Do") { let Some(name) = op.name(0) else { continue }; + let drawn = xo.and_then(|xo| xo.get(name)); if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) { if let Some(r) = form.obj { forms.path.push(r); } let bounds = - xo.get(name).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.get(b"BBox")).map(|b| doc.resolve(b))).and_then( - |b| { - let a = b.as_array()?; - let [x0, y0, x1, y1] = a.as_slice() else { return None }; - Some(form.ctm.bbox([x0.as_f64()?, y0.as_f64()?, x1.as_f64()?, y1.as_f64()?])) - }, - ); + drawn.map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.get(b"BBox")).map(|b| doc.resolve(b))).and_then(|b| { + let a = b.as_array()?; + let [x0, y0, x1, y1] = a.as_slice() else { return None }; + Some(form.ctm.bbox([x0.as_f64()?, y0.as_f64()?, x1.as_f64()?, y1.as_f64()?])) + }); let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4])); let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone()); let piece = stream(op); - walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?; + walk(doc, &parse(&form.data).ops, form.resources(resources), &mut inner, &|_| piece, forms, out)?; if form.obj.is_some() { forms.path.pop(); } continue; } - let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue }; + let Some(r) = drawn.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"Image") { @@ -382,6 +445,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi Ok(out) } +/// The four stacking order arrangements. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArrangeOrder { + BringToFront, + SendToBack, + BringForward, + SendBackward, +} + /// What to do with an image. #[derive(Clone, Debug, PartialEq)] pub enum ImageChange { @@ -394,6 +466,10 @@ pub enum ImageChange { /// Draw this image XObject instead, in the same place. Replace(ObjRef), Delete, + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, } /// The user-space transform that maps box `from` onto box `to` (move and resize). @@ -473,6 +549,1081 @@ fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { Some(outer.then(&t).then(&outer.invert()?)) } +pub(crate) fn is_entire_page_wrapped(ops: &[Op]) -> bool { + if ops.len() < 2 || !ops.first().is_some_and(|o| o.is("q")) || !ops.last().is_some_and(|o| o.is("Q")) { + return false; + } + let mut depth: usize = 0; + for (i, op) in ops.iter().enumerate() { + if op.is("q") { + depth = depth.saturating_add(1); + } else if op.is("Q") { + if depth == 1 && i < ops.len().saturating_sub(1) { + return false; + } + depth = depth.saturating_sub(1); + } + } + depth == 0 +} + +/// The end (exclusive) of the sequence opened at `i`: just past the `close` that matches it, +/// counting the nested `opens`, or `end` when it never closes. +fn sequence_end(ops: &[Op], i: usize, end: usize, opens: &[&str], close: &str) -> usize { + let mut depth: usize = 1; + for (j, op) in ops.iter().enumerate().take(end).skip(i.saturating_add(1)) { + if opens.iter().any(|o| op.is(o)) { + depth = depth.saturating_add(1); + } else if op.is(close) { + depth = depth.saturating_sub(1); + if depth == 0 { + return j.saturating_add(1); + } + } + } + end +} + +/// The stacking units of `ops[scan_start..scan_end]` that Arrange moves past one another, in +/// paint order: a `q … Q`, `BT … ET` or marked-content sequence up to its matching close (or the +/// end of the scan), a single `Do`, `sh` or inline image, or a run of other operators that ends +/// at a path-painting operator or before the next unit. Units that paint nothing are left out. +pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec> { + let mut units = Vec::new(); + let mut i = scan_start; + while i < scan_end { + let Some(op) = ops.get(i) else { break }; + let j = if op.is("BDC") || op.is("BMC") { + // A marked-content sequence is one unit up to its matching EMC (#3145), so Arrange moves + // it whole and never inserts into another one: its content belongs to its tag (ISO 32000-2 + // § 14.6). An unbalanced sequence runs to the end of the scan. + sequence_end(ops, i, scan_end, &["BDC", "BMC"], "EMC") + } else if op.is("q") { + sequence_end(ops, i, scan_end, &["q"], "Q") + } else if op.is("BT") { + sequence_end(ops, i, scan_end, &[], "ET") + } else if op.is("BI") || op.is("Do") || op.is("sh") { + // The parser emits a whole inline image (`BI` … `ID` … `EI`) as one `BI` op, so it is a + // unit of its own (#3146). + i.saturating_add(1) + } else { + let mut j = i; + let mut found_end = false; + while j < scan_end { + let Some(next) = ops.get(j) else { break }; + if matches!(next.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + j = j.saturating_add(1); + found_end = true; + break; + } else if j > i + && matches!( + next.op.as_slice(), + b"q" | b"Q" | b"BT" | b"BI" | b"Do" | b"sh" | b"BDC" | b"BMC" | b"EMC" | b"Tj" | b"TJ" | b"'" | b"\"" + ) + { + found_end = true; + break; + } + j = j.saturating_add(1); + } + if found_end { j } else { scan_end.max(i.saturating_add(1)) } + }; + units.push(i..j); + i = j; + } + units.retain(|u| { + ops.get(u.clone()).is_some_and(|slice| { + slice.iter().any(|op| { + matches!( + op.op.as_slice(), + b"Do" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" + ) + }) + }) + }); + units +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ExtractedScopeState { + pub clip_ops: Vec, + pub state_ops: Vec, + pub do_op: Op, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct EffectiveGState { + pub(crate) ca: Option, + pub(crate) ca_stroke: Option, + pub(crate) bm: Option>, + pub(crate) smask: Option, + // Printing parameters cannot generally be reset by a content-stream operator. Retain their + // exact values so Arrange refuses a destination whose state it cannot reconstruct. + print_params: std::collections::BTreeMap, std::rc::Rc>, + rendering_intent: Option>, +} + +impl EffectiveGState { + pub(crate) fn visually_differs(&self, other: &EffectiveGState) -> bool { + (self.ca.unwrap_or(1.0) - other.ca.unwrap_or(1.0)).abs() > 1e-4 + || (self.ca_stroke.unwrap_or(1.0) - other.ca_stroke.unwrap_or(1.0)).abs() > 1e-4 + || self.bm.as_deref().unwrap_or(b"Normal") != other.bm.as_deref().unwrap_or(b"Normal") + || self.smask != other.smask + || self.print_params != other.print_params + } + + #[cfg(test)] + pub(crate) fn merge_dict(&mut self, dict: &Dict, doc: &Document, ctm: Matrix) { + self.merge_shared(&Arc::new(Object::Dict(dict.clone())), doc, ctm); + } + + fn merge_shared(&mut self, definition: &Arc, doc: &Document, ctm: Matrix) { + let Some(dict) = definition.as_dict() else { return }; + for key in [b"TR".as_slice(), b"TR2", b"OP", b"op", b"OPM", b"BG", b"BG2", b"UCR", b"UCR2", b"HT"] { + if let Some(value) = dict.get(key) { + self.print_params.insert(key.to_vec(), std::rc::Rc::new(value.clone())); + } + } + if let Some(intent) = dict.get(b"RI").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)) { + self.rendering_intent = Some(intent); + } + if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca = Some(val); + } + if let Some(val) = dict.get(b"CA").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca_stroke = Some(val); + } + if let Some(bm_obj) = dict.get(b"BM").map(|v| doc.resolve(v)) { + if let Some(name) = bm_obj.as_name() { + self.bm = Some(name.to_vec()); + } else if let Some(arr) = bm_obj.as_array() + && let Some(first_name) = arr.first().map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(|n| n.to_vec())) + { + self.bm = Some(first_name); + } + } + if let Some(raw_smask) = dict.get(b"SMask") { + let disabled = raw_smask.as_name() == Some(b"None") || raw_smask.as_ref().is_some_and(|r| doc.get(r).as_name() == Some(b"None")); + self.smask = (!disabled).then(|| MaskIdentity { definition: Arc::clone(definition), ctm }); + } + } +} + +const MAX_ACTIVE_GS_OPS: usize = 128; + +type ActiveGsOp = (Vec, Op, Matrix); +type ActiveGsOps = std::rc::Rc>; + +#[derive(Clone, Default)] +struct ExtendedGStateTracker { + stack: Vec<(EffectiveGState, ActiveGsOps)>, + current_state: EffectiveGState, + active_gs_ops: ActiveGsOps, + definitions: std::collections::HashMap, Arc>, +} + +impl ExtendedGStateTracker { + fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document, ctm: Matrix) -> Result<(), EditError> { + match op.op.as_slice() { + b"ri" => self.current_state.rendering_intent = op.name(0).map(<[u8]>::to_vec), + b"q" => { + if self.stack.len() >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push((self.current_state.clone(), std::rc::Rc::clone(&self.active_gs_ops))); + } + b"Q" => { + if let Some((prev_state, prev_ops)) = self.stack.pop() { + self.current_state = prev_state; + self.active_gs_ops = prev_ops; + } + } + b"gs" => { + let valid_gs = op.name(0).and_then(|name| { + let definition = self.definitions.get(name).cloned().or_else(|| { + let definition = doc.resolve(ext_gstates.get(name)?); + definition.as_dict()?; + self.definitions.insert(name.to_vec(), Arc::clone(&definition)); + Some(definition) + })?; + Some((name, definition)) + }); + if let Some((name, definition)) = valid_gs { + self.current_state.merge_shared(&definition, doc, ctm); + let mut next = (*self.active_gs_ops).clone(); + if let Some(pos) = next.iter().position(|(k, _, _)| k.as_slice() == name) { + next.remove(pos); + } + if next.len() < MAX_ACTIVE_GS_OPS { + next.push((name.to_vec(), op.clone(), ctm)); + } + self.active_gs_ops = std::rc::Rc::new(next); + } + } + _ => {} + } + Ok(()) + } +} + +/// The drawing-state operators besides colour that a moved unit restores, in the order it writes them. +const DRAWING_PARAMS: [&str; 14] = ["w", "J", "j", "M", "d", "ri", "i", "Tc", "Tw", "Tz", "TL", "Tr", "Ts", "Tf"]; + +#[derive(Clone, Default, Debug, PartialEq)] +struct DrawingState { + non_stroking_color: Option, + stroking_color: Option, + /// The last operator of each of [`DRAWING_PARAMS`]. + params: [Option>; DRAWING_PARAMS.len()], +} + +// Save/restore shares operand storage, including when only the current colour changes under q. +type SharedColorOps = std::rc::Rc>>; + +/// Keep the explicit or implicit colour-space selector before `sc`/`scn` (or `SC`/`SCN`). +fn set_color(slot: &mut Option, op: &Op, space: &str) { + match slot { + Some(ops) if ops.first().is_some_and(|o| o.is(space) || matches!(o.op.as_slice(), b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K")) => { + let ops = std::rc::Rc::make_mut(ops); + ops.truncate(1); + ops.push(std::rc::Rc::new(op.clone())); + } + _ => *slot = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + } +} + +#[derive(Clone, Default, Debug)] +struct DrawingStateTracker { + stack: Vec, + current: DrawingState, +} + +impl DrawingStateTracker { + fn step(&mut self, op: &Op) -> Result<(), EditError> { + match op.op.as_slice() { + b"q" => { + if self.stack.len() >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push(self.current.clone()); + } + b"Q" => { + if let Some(prev) = self.stack.pop() { + self.current = prev; + } + } + b"g" | b"rg" | b"k" | b"cs" => self.current.non_stroking_color = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + b"sc" | b"scn" => set_color(&mut self.current.non_stroking_color, op, "cs"), + b"G" | b"RG" | b"K" | b"CS" => self.current.stroking_color = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + b"SC" | b"SCN" => set_color(&mut self.current.stroking_color, op, "CS"), + _ => { + if let Some(slot) = DRAWING_PARAMS.iter().position(|p| op.is(p)).and_then(|k| self.current.params.get_mut(k)) { + *slot = Some(std::rc::Rc::new(op.clone())); + } + } + } + Ok(()) + } +} + +/// Initial line style, rendering intent and text-state parameters. +fn initial_param(name: &str) -> Option { + use pdfcraft_content::num; + let operands = match name { + "w" => vec![num(1.0)], + "J" | "j" => vec![num(0.0)], + "M" => vec![num(10.0)], + "d" => vec![Object::Array(vec![]), num(0.0)], + "ri" => vec![Object::name("RelativeColorimetric")], + "Tc" | "Tw" | "TL" | "Tr" | "Ts" => vec![num(0.0)], + "Tz" => vec![num(100.0)], + _ => return None, + }; + Some(Op::new(name, operands)) +} + +/// The drawing state the image had at `source` that differs at `dest`, unless the moved unit's own +/// state operators (`extracted_state_ops`) set it. +fn compute_inherited_drawing_ops(source: &DrawingState, dest: &DrawingState, extracted_state_ops: &[Op]) -> Vec { + let sets = |names: &[&str]| extracted_state_ops.iter().any(|o| names.iter().any(|n| o.is(n))); + let mut ops = Vec::new(); + for (own, source, dest, black) in [ + (sets(&["g", "rg", "k", "cs"]), &source.non_stroking_color, &dest.non_stroking_color, "g"), + (sets(&["G", "RG", "K", "CS"]), &source.stroking_color, &dest.stroking_color, "G"), + ] { + match (own, source, dest) { + (true, _, _) | (false, None, None) => {} + (false, Some(color), _) => ops.extend(color.iter().map(|op| (**op).clone())), + (false, None, Some(_)) => ops.push(Op::new(black, vec![pdfcraft_content::num(0.0)])), + } + } + let mut text_ops = Vec::new(); + for ((name, source), dest) in DRAWING_PARAMS.iter().zip(&source.params).zip(&dest.params) { + let output = if matches!(*name, "Tc" | "Tw" | "Tz" | "TL" | "Tr" | "Ts" | "Tf") { &mut text_ops } else { &mut ops }; + match (sets(&[name]), source, dest) { + (true, _, _) | (false, None, None) => {} + (false, Some(op), _) => output.push((**op).clone()), + (false, None, Some(_)) => output.extend(initial_param(name)), + } + } + if !text_ops.is_empty() { + // Forms inherit text state, even though each BT resets its text matrices. + ops.push(Op::new("BT", vec![])); + ops.extend(text_ops); + ops.push(Op::new("ET", vec![])); + } + ops +} + +/// The operators that set `clip` again, in user space. [`ImageState`] builds clips only from +/// finite points with `m`, `l`, `c` and `h`, so `None` (a quadratic segment) does not happen. +fn clip_path_to_ops(clip: &ClipPath) -> Option> { + let mut ops = Vec::new(); + if clip.app_crop { + ops.push(crop_marker()); + } + for el in clip.path.elements() { + let (name, points) = match el { + kurbo::PathEl::MoveTo(p) => ("m", vec![*p]), + kurbo::PathEl::LineTo(p) => ("l", vec![*p]), + kurbo::PathEl::CurveTo(p1, p2, p3) => ("c", vec![*p1, *p2, *p3]), + kurbo::PathEl::ClosePath => ("h", Vec::new()), + kurbo::PathEl::QuadTo(..) => return None, + }; + ops.push(Op::new(name, points.iter().flat_map(|p| [pdfcraft_content::num(p.x), pdfcraft_content::num(p.y)]).collect())); + } + if !ops.is_empty() { + ops.push(Op::new(if clip.even_odd { "W*" } else { "W" }, vec![])); + ops.push(Op::new("n", vec![])); + } + Some(ops) +} + +pub(crate) fn extract_scope_state(ops: &[Op], start: usize, target: usize, initial_ctm: Matrix) -> Result, EditError> { + if target >= ops.len() || start > target { + return Ok(None); + } + let target_op = ops.get(target).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + if start == target { + return Ok(Some(ExtractedScopeState { clip_ops: Vec::new(), state_ops: Vec::new(), do_op: target_op })); + } + let mut clip_ops = Vec::new(); + let mut state_ops = Vec::new(); + let mut pending_path: Vec = Vec::new(); + let mut pending_clip = false; + let mut active_path = false; + let mut depth: usize = 0; + let mut matrix = initial_ctm; + let mut matrix_at_clip = None; + + let scan_start = if ops.get(start).is_some_and(|o| o.is("q")) { start.saturating_add(1) } else { start }; + + for i in scan_start..target { + let Some(op) = ops.get(i) else { + return Ok(None); + }; + match op.op.as_slice() { + b"q" => { + if depth >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + depth = depth.saturating_add(1); + } + b"Q" => { + if depth == 0 { + return Ok(None); + } + depth = depth.saturating_sub(1); + } + _ if depth > 0 => {} + b"cm" => { + if active_path { + return Ok(None); + } + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => { + active_path = true; + pending_path.push(op.clone()); + } + b"W" | b"W*" => { + if !active_path { + return Ok(None); + } + pending_clip = true; + pending_path.push(op.clone()); + } + b"n" => { + if pending_clip { + if matrix_at_clip.is_none() { + matrix_at_clip = Some(matrix); + } else if matrix_at_clip != Some(matrix) { + return Ok(None); + } + clip_ops.extend(std::mem::take(&mut pending_path)); + clip_ops.push(op.clone()); + pending_clip = false; + active_path = false; + } else { + pending_path.clear(); + active_path = false; + } + } + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if pending_clip { + return Ok(None); + } + pending_path.clear(); + active_path = false; + } + b"gs" | b"CS" | b"cs" | b"SC" | b"sc" | b"SCN" | b"scn" | b"G" | b"g" | b"RG" | b"rg" | b"K" | b"k" | b"ri" | b"i" => { + state_ops.push(op.clone()); + } + b"BT" => { + return Ok(None); + } + _ => {} + } + } + + if depth != 0 || active_path || pending_clip { + return Ok(None); + } + + if !clip_ops.is_empty() && matrix_at_clip != Some(Matrix::IDENTITY) { + return Ok(None); + } + + Ok(Some(ExtractedScopeState { clip_ops, state_ops, do_op: target_op })) +} + +/// The unit `order` puts the image's unit `target_idx` next to, and whether after it. An image that +/// shares its unit with other paint at the limit moves within that unit, past or behind the paint. +fn target_destination_unit<'a>( + units: &'a [std::ops::Range], + target_idx: usize, + target_unit: &'a std::ops::Range, + order: ArrangeOrder, + has_other_paint: bool, +) -> Result<(&'a std::ops::Range, bool), EditError> { + let forward = matches!(order, ArrangeOrder::BringToFront | ArrangeOrder::BringForward); + let refused = || EditError::Invalid(if forward { "the image is already in front" } else { "the image is already at the back" }.into()); + let at_limit = if forward { target_idx.saturating_add(1) >= units.len() } else { target_idx == 0 }; + if at_limit { + return if has_other_paint { Ok((target_unit, forward)) } else { Err(refused()) }; + } + let dest = match order { + ArrangeOrder::BringToFront => units.last(), + ArrangeOrder::SendToBack => units.first(), + ArrangeOrder::BringForward => units.get(target_idx.saturating_add(1)), + ArrangeOrder::SendBackward => target_idx.checked_sub(1).and_then(|i| units.get(i)), + }; + dest.map(|u| (u, forward)).ok_or_else(refused) +} + +/// A moved unit must not borrow a graphics or marked-content boundary from its neighbours. +fn balanced_arrange_span(ops: &[Op], range: std::ops::Range) -> bool { + let Some(slice) = ops.get(range) else { return false }; + let (mut graphics, mut marked) = (0usize, 0usize); + for op in slice { + let depth = match op.op.as_slice() { + b"q" => { + graphics = graphics.saturating_add(1); + continue; + } + b"BDC" | b"BMC" => { + marked = marked.saturating_add(1); + continue; + } + b"Q" => &mut graphics, + b"EMC" => &mut marked, + _ => continue, + }; + let Some(next) = depth.checked_sub(1) else { return false }; + *depth = next; + } + graphics == 0 && marked == 0 +} + +/// What Arrange takes out: the byte span, the op where the image's own scope state starts, and the +/// ops `start..end` removed. That is the image's own `q … Q` scope inside its unit, else the `Do` +/// alone when the unit paints something else too, else the whole unit. +fn determine_arrange_spans( + ops: &[Op], + target: usize, + target_unit: &std::ops::Range, + has_other_paint: bool, + marked_unit: bool, +) -> Result<(std::ops::Range, usize, usize, usize), EditError> { + let scope = image_scopes(ops, target, "arranged") + .ok() + .and_then(|s| s.isolated) + .filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end); + let unit_last = target_unit.end.saturating_sub(1); + let (first, scope_start, last) = match scope { + Some((start, end, _)) if has_other_paint || !marked_unit => (start, start, end), + _ if has_other_paint => (target, target, target), + // A marked-content unit holding only this image moves whole, with its BDC…EMC. + // The whole sequence is removed; the image's state is rebuilt from the ops inside it. + _ if marked_unit => (target_unit.start, target_unit.start.saturating_add(1).min(target), unit_last), + _ => (target_unit.start, target_unit.start, unit_last), + }; + if !balanced_arrange_span(ops, first..last.saturating_add(1)) { + return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into())); + } + let missing = || EditError::Invalid("the image content unit could not be located".into()); + let start = ops.get(first).ok_or_else(missing)?.span.start; + let end = ops.get(last).ok_or_else(missing)?.span.end; + Ok((start..end, scope_start, first, last.saturating_add(1))) +} + +fn scan_leaking_ops(ops: &[Op], rem_start: usize, rem_end: usize) -> (Vec, bool) { + let mut q_depth = 0usize; + let mut leaking_ops = Vec::new(); + let mut pending_clip_path: Vec = Vec::new(); + let mut pending_clip = false; + let mut has_leaking_clip = false; + for op in ops.get(rem_start..rem_end).unwrap_or_default() { + match op.op.as_slice() { + b"q" => q_depth = q_depth.saturating_add(1), + b"Q" => q_depth = q_depth.saturating_sub(1), + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" if q_depth == 0 => { + pending_clip_path.push(op.clone()); + } + b"W" | b"W*" if q_depth == 0 => { + if !pending_clip_path.is_empty() { + pending_clip = true; + pending_clip_path.push(op.clone()); + } + } + b"n" if q_depth == 0 => { + if pending_clip { + pending_clip_path.push(op.clone()); + leaking_ops.extend(std::mem::take(&mut pending_clip_path)); + has_leaking_clip = true; + pending_clip = false; + } else { + pending_clip_path.clear(); + } + } + b"rg" | b"RG" | b"g" | b"G" | b"k" | b"K" | b"cs" | b"CS" | b"sc" | b"scn" | b"SC" | b"SCN" | b"w" | b"J" | b"j" | b"M" | b"d" + | b"ri" | b"i" | b"gs" | b"Tf" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Tr" | b"Ts" + if q_depth == 0 => + { + leaking_ops.push(op.clone()); + } + b"cm" if q_depth == 0 => { + leaking_ops.push(op.clone()); + } + _ => { + if q_depth == 0 && !op.is("BDC") && !op.is("BMC") && !op.is("EMC") && !pending_clip { + pending_clip_path.clear(); + } + } + } + } + (leaking_ops, has_leaking_clip) +} + +fn build_relocated_unit_ops( + marked_open: Option<&Op>, + source_gs_ops: &[(Op, Matrix)], + inherited_drawing_ops: &[Op], + extracted: ExtractedScopeState, + dest_ctm: Matrix, + draw_matrix: [f64; 6], + has_closing_emc: bool, +) -> Result, EditError> { + let mut unit_ops = Vec::new(); + if let Some(open) = marked_open { + unit_ops.push(open.clone()); + } + unit_ops.push(Op::new("q", vec![])); + let mut current_ctm = dest_ctm; + + for (gs_op, target_ctm) in source_gs_ops { + if *target_ctm != current_ctm { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + let delta = target_ctm.then(¤t_inv); + if !delta.0.iter().all(|v| v.is_finite()) { + return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into())); + } + if delta != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec())); + current_ctm = *target_ctm; + } + } + unit_ops.push(gs_op.clone()); + } + + unit_ops.extend(inherited_drawing_ops.iter().cloned()); + + let non_gs_ops = extracted.state_ops.into_iter().filter(|o| !o.is("gs")); + unit_ops.extend(non_gs_ops); + + if !extracted.clip_ops.is_empty() { + if current_ctm != Matrix::IDENTITY { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + unit_ops.push(Op::new("cm", current_inv.0.map(pdfcraft_content::num).to_vec())); + } + unit_ops.extend(extracted.clip_ops); + if Matrix(draw_matrix) != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", draw_matrix.map(pdfcraft_content::num).to_vec())); + } + } else if Matrix(draw_matrix) != current_ctm { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + let delta = Matrix(draw_matrix).then(¤t_inv); + if !delta.0.iter().all(|v| v.is_finite()) { + return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into())); + } + if delta != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec())); + } + } + unit_ops.push(extracted.do_op); + unit_ops.push(Op::new("Q", vec![])); + if has_closing_emc { + unit_ops.push(Op::new("EMC", vec![])); + } + Ok(unit_ops) +} + +/// Change the stacking order of `img`, drawn on page `p` by its content streams `all`. +fn arrange_image( + doc: &mut Document, + p: &pdfcraft_model::Page, + all: &[(Object, Vec)], + img: &PageImage, + order: ArrangeOrder, +) -> Result<(), EditError> { + let pieces = joined(all); + let ops = pieces.parse().ops; + let target = img.op; + let target_op = ops.get(target).filter(|op| op.is("Do")).ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + + let is_wrapped = is_entire_page_wrapped(&ops); + let (scan_start, scan_end) = if is_wrapped { (1, ops.len().saturating_sub(1)) } else { (0, ops.len()) }; + let units = content_units(&ops, scan_start, scan_end); + let Some((target_idx, target_unit)) = units.iter().enumerate().find(|(_, u)| u.contains(&target)) else { + return Err(EditError::Invalid("the image content unit could not be located".into())); + }; + let has_other_paint = ops.iter().enumerate().any(|(i, op)| { + i >= target_unit.start + && i < target_unit.end + && i != target + && matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) + }); + if units.len() <= 1 && !has_other_paint { + return Err(EditError::Invalid("the page has only one content unit to arrange".into())); + } + + let (dest_unit, insert_after) = target_destination_unit(&units, target_idx, target_unit, order, has_other_paint)?; + if !balanced_arrange_span(&ops, target_unit.clone()) || !balanced_arrange_span(&ops, dest_unit.clone()) { + return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into())); + } + + // A marked-content sequence that holds other content cannot be split without generating + // a new structure element and MCID, so we refuse to rearrange it to avoid leaving the image untagged. + // If the sequence holds only this image, the whole marked unit is moved intact. + let marked_unit = ops.get(target_unit.start).is_some_and(|o| o.is("BDC") || o.is("BMC")); + let marked_depth = ops.iter().take(target).fold(0usize, |depth, op| { + if op.is("BDC") || op.is("BMC") { + depth.saturating_add(1) + } else if op.is("EMC") { + depth.saturating_sub(1) + } else { + depth + } + }); + if marked_depth > usize::from(marked_unit) { + return Err(EditError::Invalid("cannot rearrange an image out of a nested marked-content sequence".into())); + } + if marked_unit && has_other_paint { + return Err(EditError::Invalid("cannot rearrange an image that shares a marked-content sequence with other content".into())); + } + + let (mut target_span, scope_start, rem_start, rem_end) = determine_arrange_spans(&ops, target, target_unit, has_other_paint, marked_unit)?; + + let missing = || EditError::Invalid("the destination content unit could not be located".into()); + let (dest_pos, scan_target) = if insert_after { + (dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)).ok_or_else(missing)?.span.end, dest_unit.end) + } else { + (ops.get(dest_unit.start).ok_or_else(missing)?.span.start, dest_unit.start) + }; + + // An added item owns its entire stream: editing or deleting it replaces that stream. + // Insert ordinary artwork next to the complete owned unit in a separate stream instead. + let destination_piece = if insert_after { + dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)).map(|op| pieces.piece_of(op.span.end.saturating_sub(1))) + } else { + ops.get(dest_unit.start).map(|op| pieces.piece_of(op.span.start)) + }; + let owned_destination = destination_piece + .filter(|piece| all.get(*piece).is_some_and(|(obj, _)| doc.resolve(obj).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some()))); + if let Some(piece) = owned_destination { + let whole_unit = ops.iter().enumerate().all(|(index, op)| { + let (first, last) = pieces.pieces_of(op); + if dest_unit.contains(&index) { first == piece && last == piece } else { piece < first || piece > last } + }); + if !whole_unit || img.stream == piece { + return Err(EditError::Invalid("cannot arrange an image inside an added item's content stream".into())); + } + } + + let (leaking_ops, has_leaking_clip) = scan_leaking_ops(&ops, rem_start, rem_end); + + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut tracker = ExtendedGStateTracker::default(); + let mut drawing_tracker = DrawingStateTracker::default(); + let mut source_effective_state = EffectiveGState::default(); + let mut source_gs_ops = Vec::new(); + let mut source_drawing_state = DrawingState::default(); + let mut dest_effective_state = EffectiveGState::default(); + let mut dest_drawing_state = DrawingState::default(); + let mut dest_ctm = Matrix::IDENTITY; + let mut scope_start_ctm = Matrix::IDENTITY; + let mut outer_clips = Vec::new(); + let mut dest_clips = Vec::new(); + let mut ctm_before_removal = Matrix::IDENTITY; + let mut ctm_after_removal = Matrix::IDENTITY; + + let max_scan = scan_target.max(target.saturating_add(1)).max(scope_start.saturating_add(1)).max(rem_end); + for (i, op) in ops.iter().enumerate() { + if i == rem_start { + ctm_before_removal = state.placement.matrix; + } + if i == scope_start { + scope_start_ctm = state.placement.matrix; + outer_clips = state.placement.clips.clone(); + } + if i == target { + source_effective_state = tracker.current_state.clone(); + source_gs_ops = tracker.active_gs_ops.iter().map(|(_, op, ctm)| (op.clone(), *ctm)).collect(); + source_drawing_state = drawing_tracker.current.clone(); + } + if i == scan_target { + dest_effective_state = tracker.current_state.clone(); + dest_drawing_state = drawing_tracker.current.clone(); + dest_ctm = state.placement.matrix; + dest_clips = state.placement.clips.clone(); + } + if i >= max_scan { + break; + } + state.apply(op)?; + tracker.step(op, &ext_gstates, doc, state.placement.matrix)?; + drawing_tracker.step(op)?; + if op.is("gs") + && op.name(0).and_then(|name| tracker.definitions.get(name)).and_then(|gs| gs.as_dict()).is_some_and(|gs| gs.get(b"Font").is_some()) + && let Some(font) = DRAWING_PARAMS.iter().position(|name| *name == "Tf").and_then(|index| drawing_tracker.current.params.get_mut(index)) + { + // The replayed gs selects its font; do not overwrite it with an older direct Tf. + *font = None; + } + if op.is("gs") + && let Some(intent) = &tracker.current_state.rendering_intent + { + drawing_tracker.step(&Op::new("ri", vec![Object::Name(intent.clone())]))?; + } + if i == rem_end.saturating_sub(1) { + ctm_after_removal = state.placement.matrix; + } + } + if scan_target >= ops.len() { + dest_effective_state = tracker.current_state.clone(); + dest_drawing_state = drawing_tracker.current.clone(); + dest_ctm = state.placement.matrix; + dest_clips = state.placement.clips.clone(); + } + if dest_ctm.invert().is_none() { + return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into())); + } + + let owned_crop = ops.get(scope_start..target).unwrap_or_default().iter().any(|op| op.is("MP") && op.name(0) == Some(b"PdfCraftImageCrop")); + let extracted_scope = if owned_crop { None } else { extract_scope_state(&ops, scope_start, target, scope_start_ctm)? }; + let mut extracted = match extracted_scope { + Some(ext) => ext, + None if scope_start != target => { + if !marked_unit || has_other_paint { + target_span = target_op.span.clone(); + } + extract_scope_state(&ops, target, target, Matrix(img.draw_matrix))? + .ok_or_else(|| EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()))? + } + None => { + return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into())); + } + }; + + // The clips the moved unit draws itself: all of the image's when they are rebuilt from + // `img.clips`, else those its own scope set after the clips around it. + let all_clips = extracted.clip_ops.is_empty(); + let reemitted = if all_clips { + img.clips.as_slice() + } else if img.clips.starts_with(&outer_clips) { + img.clips.get(outer_clips.len()..).unwrap_or_default() + } else { + return Err(EditError::Invalid("unsupported graphics state: the image clipping path cannot be cleanly preserved".into())); + }; + // At the destination it is clipped by the clips there and the ones it draws. Moving past a + // clip the image isn't under (or out from under one it is) would change what part shows. + if !dest_clips.iter().all(|c| img.clips.contains(c)) || !img.clips.iter().all(|c| dest_clips.contains(c) || reemitted.contains(c)) { + return Err(EditError::Invalid( + "unsupported graphics state: moving the image past a clipping path would change what part of it shows".into(), + )); + } + + if all_clips { + for clip in &img.clips { + let ops = clip_path_to_ops(clip) + .ok_or_else(|| EditError::Invalid("unsupported graphics state: the image clipping path cannot be cleanly preserved".into()))?; + extracted.clip_ops.extend(ops); + } + } + + let has_inherited_clip = !img.clips.is_empty() && extracted.clip_ops.is_empty(); + if has_inherited_clip { + return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into())); + } + + let mut reconstructed_state = dest_effective_state.clone(); + for (op, ctm) in &source_gs_ops { + if let Some(name) = op.name(0) + && let Some(definition) = tracker.definitions.get(name) + { + reconstructed_state.merge_shared(definition, doc, *ctm); + } + } + + if reconstructed_state.visually_differs(&source_effective_state) { + return Err(EditError::Invalid( + "unsupported graphics state: moving the image would alter opacity, blend mode, soft mask, or printing parameters".into(), + )); + } + + let marked_open = if marked_unit && !has_other_paint { ops.get(target_unit.start) } else { None }; + let has_closing_emc = marked_open.is_some() && target_unit.end.checked_sub(1).and_then(|i| ops.get(i)).is_some_and(|o| o.is("EMC")); + let inherited_drawing_ops = compute_inherited_drawing_ops(&source_drawing_state, &dest_drawing_state, &extracted.state_ops); + let unit_ops = + build_relocated_unit_ops(marked_open, &source_gs_ops, &inherited_drawing_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?; + + let mut serialized = Vec::new(); + serialized.push(b'\n'); + serialized.extend_from_slice(&serialize_ops(&unit_ops)); + serialized.push(b'\n'); + + let leaking = if let Some(inv) = ctm_before_removal.invert() { ctm_after_removal.then(&inv) } else { Matrix::IDENTITY }; + if leaking != Matrix::IDENTITY && has_leaking_clip { + return Err(EditError::Invalid("unsupported graphics state: moving the image would alter clipping path transform".into())); + } + let mut leak_ops = leaking_ops; + if leaking != Matrix::IDENTITY && leak_ops.iter().all(|o| !o.is("cm")) { + leak_ops.insert(0, Op::new("cm", leaking.0.map(pdfcraft_content::num).to_vec())); + } + let removal_replacement = if !leak_ops.is_empty() { + let mut s = Vec::new(); + s.push(b'\n'); + s.extend_from_slice(&serialize_ops(&leak_ops)); + s.push(b'\n'); + s + } else { + Vec::new() + }; + + if let Some(piece) = owned_destination { + let changed = pieces.splice([(target_span, removal_replacement)]); + let contents = crate::text::rewritten_contents(doc, all, changed); + let mut list = match contents { + Object::Array(list) => list, + one => vec![one], + }; + let insertion = piece.saturating_add(usize::from(insert_after)); + if insertion > list.len() { + return Err(EditError::Invalid("the destination content stream is missing".into())); + } + let relocated = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &serialized))); + list.insert(insertion, Object::Ref(relocated)); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + return Ok(()); + } + + let mut edits = Vec::new(); + if dest_pos >= target_span.end { + edits.push((target_span, removal_replacement)); + edits.push((dest_pos..dest_pos, serialized)); + } else if dest_pos <= target_span.start { + edits.push((dest_pos..dest_pos, serialized)); + edits.push((target_span, removal_replacement)); + } else { + return Err(EditError::Invalid("cannot arrange image into overlapping content range".into())); + } + + let changed = pieces.splice(edits); + let contents = crate::text::rewritten_contents(doc, all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + +/// The saved graphics-state scopes (`q … Q`) around the `Do` at `target` that paint nothing else, +/// each as `(q, Q, CTM before the q)`. +struct ImageScopes { + /// The innermost one inside which every clip on the image is set. + isolated: Option<(usize, usize, Matrix)>, + /// The outermost one. It holds all of the image's private clips, but not a clip set outside it. + outermost: Option<(usize, usize, Matrix)>, + /// The CTM at the `Do`, and whether a clip applies there. + placement: Matrix, + clipped: bool, +} + +impl ImageScopes { + /// The saved graphics-state scope containing just this image and its crop, or the `Do` alone + /// when nothing clips it. Applying a transform before that scope moves the actual clipping path + /// as well as the pixels. A shared clip must never be moved along with unrelated artwork or text. + /// + /// Under a clip set outside every scope that holds only this image (a report or table drawing + /// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active + /// clip. The outermost scope holding only the image is used then: it contains all of the + /// image's private clips, which move with it, while the outer clip is shared and stays. + fn transform_scope(&self, target: usize) -> Option<(usize, usize, Matrix)> { + if !self.clipped { + return Some((target, target, self.placement)); + } + self.isolated.or(self.outermost) + } +} + +/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. +fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result { + struct Scope { + start: usize, + matrix: Matrix, + inherited_clip: bool, + inherited_path: bool, + selected: bool, + other_paint: bool, + } + let mut stack: Vec = Vec::new(); + let mut matrix = Matrix::IDENTITY; + let mut clipped = false; + let mut path = false; + let mut pending_clip = false; + let mut text = false; + let mut at_image = None; + let mut isolated = None; + let mut outermost = None; + for (index, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"q" => { + if stack.len() >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); + } + b"Q" => { + if let Some(scope) = stack.pop() { + if scope.selected && !scope.other_paint && !scope.inherited_path { + let found = (scope.start, index, scope.matrix); + if !scope.inherited_clip { + isolated.get_or_insert(found); + } + // Scopes close inner first, so the last one recorded is the outermost. + outermost = Some(found); + } + matrix = scope.matrix; + clipped = scope.inherited_clip; + path = scope.inherited_path; + pending_clip = false; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"BT" => text = true, + b"ET" => text = false, + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path = true, + b"W" | b"W*" => pending_clip = true, + _ => {} + } + if index == target { + if text { + return Err(EditError::Invalid(format!("an image inside a text object cannot be {done}"))); + } + at_image = Some((matrix, clipped)); + for scope in &mut stack { + scope.selected = true; + } + } else if matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) { + for scope in &mut stack { + scope.other_paint = true; + } + } + if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { + clipped |= pending_clip; + pending_clip = false; + path = false; + } + } + let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + Ok(ImageScopes { isolated, outermost, placement, clipped }) +} + +/// A marked-content point identifies only the clipping path written by this editor. +/// It does not open a marked-content sequence or change the document's structure tags. +fn crop_marker() -> Op { + Op::new("MP", vec![Object::Name(b"PdfCraftImageCrop".to_vec())]) +} + +fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec> { + let mut ranges = Vec::new(); + for (index, op) in ops.iter().enumerate().take(target).skip(start) { + if !op.is("MP") || op.name(0) != Some(b"PdfCraftImageCrop") { + continue; + } + let path_start = index.saturating_add(1); + let mut end = path_start; + while end < target && ops.get(end).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { + end = end.saturating_add(1); + } + if end > path_start && ops.get(end).is_some_and(|o| o.is("W") || o.is("W*")) && ops.get(end.saturating_add(1)).is_some_and(|o| o.is("n")) { + ranges.push(index..end.saturating_add(2)); + } + } + ranges +} + +/// Remove only crops written by PdfCraft, preserving the document author's clipping paths. +fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Object, Vec)], img: &PageImage) -> Result<(), EditError> { + let pieces = joined(all); + let ops = pieces.parse().ops; + let target = img.op; + let scope = image_scopes(&ops, target, "cropped")?; + let Some((start, _, _)) = scope.transform_scope(target) else { + return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); + }; + let crops = app_crop_ranges(&ops, start, target); + if crops.is_empty() { + return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); + } + let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index)))); + let contents = crate::text::rewritten_contents(doc, all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// Change image `index` (from [`page_images`]) on `page`. pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -490,8 +1641,14 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag ImageChange::Transform(t) => { return transform_image(doc, &p, &all, &img, *t); } + ImageChange::Arrange(order) => { + return arrange_image(doc, &p, &all, &img, *order); + } + ImageChange::RemoveCrop => { + return remove_image_crop(doc, &p, &all, &img); + } ImageChange::Crop(keep) => { - return crop_image(doc, &p, &all, &img, *keep); + return crop_image_impl(doc, &p, &all, &img, *keep); } ImageChange::Replace(r) => { if img.is_form { @@ -534,92 +1691,10 @@ pub(crate) fn selectable_images( .collect()) } -/// Find the saved graphics-state scope containing just this image and its crop. Applying -/// the transform before that scope moves the actual clipping path as well as the pixels. -/// A shared clip must never be moved along with unrelated artwork or text. -/// -/// Under a clip set outside every scope that holds only this image (a report or table drawing -/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active -/// clip. The outermost scope holding only the image is used then: it contains all of the -/// image's private clips, which move with it, while the outer clip is shared and stays. -fn image_transform_scope(ops: &[Op], target: usize) -> Result, EditError> { - struct Scope { - start: usize, - matrix: Matrix, - inherited_clip: bool, - inherited_path: bool, - selected: bool, - other_paint: bool, - } - let mut stack: Vec = Vec::new(); - let mut matrix = Matrix::IDENTITY; - let mut clipped = false; - let mut path = false; - let mut pending_clip = false; - let mut text = false; - let mut at_image = None; - let mut isolated = None; - let mut outermost = None; - for (index, op) in ops.iter().enumerate() { - match op.op.as_slice() { - b"q" => { - if stack.len() >= 256 { - return Err(EditError::Invalid("the image graphics state is too deeply nested to transform".into())); - } - stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); - } - b"Q" => { - if let Some(scope) = stack.pop() { - if scope.selected && !scope.other_paint && !scope.inherited_path { - if !scope.inherited_clip { - // Prefer the innermost scope that contains every active clipping path. - isolated.get_or_insert((scope.start, index, scope.matrix)); - } - // Scopes close inner first, so the last one recorded is the outermost. - outermost = Some((scope.start, index, scope.matrix)); - } - matrix = scope.matrix; - clipped = scope.inherited_clip; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - matrix = Matrix(m).then(&matrix); - } - } - b"BT" => text = true, - b"ET" => text = false, - b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true, - b"W" | b"W*" => pending_clip = true, - _ => {} - } - if index == target { - if text { - return Err(EditError::Invalid("an image inside a text object cannot be transformed".into())); - } - at_image = Some((matrix, clipped)); - for scope in &mut stack { - scope.selected = true; - } - } else if matches!( - op.op.as_slice(), - b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" - ) { - for scope in &mut stack { - scope.other_paint = true; - } - } - if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { - clipped |= pending_clip; - pending_clip = false; - path = false; - } - } - let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; - if !clipped { - return Ok(Some((target, target, placement))); - } - Ok(isolated.or(outermost)) +/// Whether an XObject drawn at or after operator `from` of the page's joined operators shows +/// inside `rect` (user space). +pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: usize) -> Result { + Ok(page_images(doc, page)?.iter().any(|i| i.op >= from && i.visible_rect.and_then(|v| intersection(v, rect)).is_some())) } fn transform_image( @@ -640,12 +1715,14 @@ fn transform_image( } // A private path crop moves with its image regardless of intervening state operators. // Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint. - let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let (start, end, placement) = + image_scopes(&ops, target, "transformed")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix))); let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; let local = placement.then(&Matrix(transform)).then(&inverse); if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image transform is too large".into())); } + // Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added): // change that `cm` in place. The CTM inside is `local · cm · outer` either way. if let Some((at, cm)) = enclosing_transform(&ops, start, end) { @@ -686,13 +1763,16 @@ fn transform_image( /// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in /// that scope's coordinates as the box's four corners, so a turned image crops to an upright /// box too and a later transform of the scope moves the crop with the pixels. -fn crop_image( +fn crop_image_impl( doc: &mut Document, page: &pdfcraft_model::Page, streams: &[(Object, Vec)], image: &PageImage, keep: [f64; 4], ) -> Result<(), EditError> { + if image.is_form { + return Err(EditError::Invalid("cropping grouped Form artwork is not supported".into())); + } if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into())); } @@ -700,36 +1780,53 @@ fn crop_image( if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 { return Err(EditError::Invalid("the crop box has no area".into())); } + if intersection(image.rect, [x0, y0, x1, y1]).is_none() { + return Err(EditError::Invalid("the crop box does not intersect the image".into())); + } + let pieces = joined(streams); let ops = pieces.parse().ops; let target = image.op; if !ops.get(target).is_some_and(|op| op.is("Do")) { return Err(EditError::Invalid("the image operation could not be located".into())); } - let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let (start, end, placement) = + image_scopes(&ops, target, "cropped")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix))); let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y)); if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) { return Err(EditError::Invalid("the crop box is too large for the image's placement".into())); } - let clip: Vec = corners - .iter() - .enumerate() - .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) - .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) - .collect(); - // The image's own earlier crop (a clipping path right after the scope's `q`) gives way to - // the new one, so a crop can grow again as well as shrink. - let old_clip: Option> = (start != end) - .then(|| { - let mut k = start + 1; - while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { - k += 1; - } - (k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n"))) - .then_some(start + 1..k + 2) - }) - .flatten(); + let is_axis_aligned = ((corners[0].1 - corners[1].1).abs() < 1e-4 + && (corners[1].0 - corners[2].0).abs() < 1e-4 + && (corners[2].1 - corners[3].1).abs() < 1e-4 + && (corners[3].0 - corners[0].0).abs() < 1e-4) + || ((corners[0].0 - corners[1].0).abs() < 1e-4 + && (corners[1].1 - corners[2].1).abs() < 1e-4 + && (corners[2].0 - corners[3].0).abs() < 1e-4 + && (corners[3].1 - corners[0].1).abs() < 1e-4); + let mut clip: Vec = if is_axis_aligned { + let min_x = corners.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min); + let max_x = corners.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max); + let min_y = corners.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min); + let max_y = corners.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max); + vec![ + Op::new("re", vec![Object::Real(min_x), Object::Real(min_y), Object::Real(max_x - min_x), Object::Real(max_y - min_y)]), + Op::new("W", vec![]), + Op::new("n", vec![]), + ] + } else { + corners + .iter() + .enumerate() + .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) + .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) + .collect() + }; + clip.insert(0, crop_marker()); + // Replace only our identified crop; an author's path must remain intact. + let old_crops = app_crop_ranges(&ops, start, target); + let insert_at = start.saturating_add(1); let changed = crate::text::splice(&pieces, &ops, |index| { if start == end && index == target { // No scope of its own yet: one around the drawing, with the crop inside. @@ -738,10 +1835,10 @@ fn crop_image( out.extend(ops.get(index).cloned()); out.push(Op::new("Q", vec![])); (out, false) - } else if start != end && index == start + 1 { + } else if start != end && index == insert_at { // Inside the scope's `q`, before whatever it draws (in place of its old crop). - (clip.clone(), old_clip.is_none()) - } else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) { + (clip.clone(), !old_crops.iter().any(|r| r.contains(&index))) + } else if old_crops.iter().any(|r| r.contains(&index)) { (Vec::new(), false) } else { (Vec::new(), true) @@ -787,6 +1884,42 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } // Images alone in their own `q re W n cm Do Q` take the clip with them. let mut clips = std::collections::BTreeMap::new(); + let mut crop_starts = std::collections::BTreeMap::new(); + let mut crop_ends = std::collections::BTreeSet::new(); + let mut folds = std::collections::BTreeMap::new(); + for image in images.iter().filter(|image| image.has_editor_crop()) { + let Some(&(_, _, offset)) = plans.get(&image.op) else { continue }; + let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved")?.transform_scope(image.op) else { + return Err(EditError::Invalid("the image crop cannot be moved independently".into())); + }; + let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?; + let local = placement.then(&Matrix::translate(offset[0], offset[1])).then(&inverse); + if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + let leading_transform = || { + let at = start.checked_add(1)?; + if start >= end || !ops.get(start)?.is("q") || !ops.get(at)?.is("cm") { + return None; + } + ops.get(at)?.nums::<6>().map(|m| (at, Matrix(m))) + }; + // An enclosing cm is before the captured placement; an inside-scope cm is after it. + // Their multiplication orders differ when that cm scales or shears a translation. + let folded = enclosing_transform(&ops, start, end) + .map(|(at, cm)| (at, local.then(&cm))) + .or_else(|| leading_transform().map(|(at, cm)| (at, cm.then(&local)))); + if let Some((at, folded)) = folded { + if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + folds.insert(at, folded); + } else { + crop_starts.insert(start, local); + crop_ends.insert(end); + } + plans.remove(&image.op); + } for index in 0..ops.len() { let Some(&(_, placement, offset)) = plans.get(&index) else { continue }; let Some((at, cm)) = exclusive_clip(&ops, index) else { continue }; @@ -807,7 +1940,6 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize }; } // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another. - let mut folds = std::collections::BTreeMap::new(); for index in 0..ops.len() { let Some(&(movement, _, _)) = plans.get(&index) else { continue }; let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue }; @@ -822,6 +1954,14 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize return Err(EditError::Invalid("a selected image operation is missing".into())); } let data = crate::text::splice(&pieces, &ops, |index| { + if let Some(matrix) = crop_starts.remove(&index) { + return (vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())], true); + } + if crop_ends.remove(&index) { + let mut out: Vec<_> = ops.get(index).cloned().into_iter().collect(); + out.push(Op::new("Q", vec![])); + return (out, false); + } if let Some((c, keep)) = clips.remove(&index) { return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep); } @@ -841,3 +1981,44 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } + +#[cfg(test)] +mod allocation_tests { + use super::*; + + #[test] + fn saved_drawing_states_share_large_operand_storage() { + let mut tracker = DrawingStateTracker::default(); + let parsed = parse(b"[1 2 3 4 5 6 7 8] 0 d /Pattern cs 1 2 3 4 5 6 /P scn q q q"); + for op in &parsed.ops { + tracker.step(op).unwrap(); + } + for state in &tracker.stack { + let dash = state.params[4].as_ref().unwrap(); + let current_dash = tracker.current.params[4].as_ref().unwrap(); + assert!(std::ptr::eq(dash.operands.as_ptr(), current_dash.operands.as_ptr()), "q must share the dash operands instead of cloning them"); + let color = state.non_stroking_color.as_ref().unwrap().last().unwrap(); + let current_color = tracker.current.non_stroking_color.as_ref().unwrap().last().unwrap(); + assert!(std::ptr::eq(color.operands.as_ptr(), current_color.operands.as_ptr()), "q must share scn operands instead of cloning them"); + } + let saved = tracker.current.clone(); + for op in &parse(b"[9 10] 0 d 4 5 6 /Other scn Q").ops { + tracker.step(op).unwrap(); + } + assert_eq!(tracker.current, saved, "changing shared operands must preserve the saved state"); + } + + #[test] + fn review_soft_mask_identity_includes_establishment_matrix() { + let doc = Document::new_empty(); + let mask = Dict::from_iter([(b"S".to_vec(), Object::name("Alpha")), (b"G".to_vec(), Object::Ref(ObjRef::new(8, 0)))]); + let definition = Dict::from_iter([(b"SMask".to_vec(), Object::Dict(mask))]); + let states = Dict::from_iter([(b"A".to_vec(), Object::Dict(definition))]); + let mut tracker = ExtendedGStateTracker::default(); + let op = Op::new("gs", vec![Object::name("A")]); + tracker.step(&op, &states, &doc, Matrix::IDENTITY).unwrap(); + let before = tracker.current_state.clone(); + tracker.step(&op, &states, &doc, Matrix::translate(50.0, 0.0)).unwrap(); + assert!(before.visually_differs(&tracker.current_state), "the same mask established under a different CTM is a different effective mask"); + } +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 193f7d8424..b95c4296f9 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -1,6 +1,11 @@ //! pdfcraft-edit — page content editing (L4). Today: page marks, Acrobat's Header & footer, //! Watermark, Background and Bates numbering (execution plan M7.6). //! +//! **Atomic page-mark operations.** Adding or replacing headers/footers, watermarks and backgrounds +//! stages the whole call on a document snapshot and commits only after every selected page succeeds. +//! An error preserves existing marks, content, resources, pending edits and allocated objects. The +//! snapshot shares original PDF bytes and unchanged objects; it copies the overlay table and metadata. +//! //! **How marks are stored.** Each mark is one content stream appended to (or, for content behind //! the page, inserted after the backgrounds at the start of) the page's `/Contents`, drawn in //! display space (after `/Rotate`) and wrapped in a marked-content artifact (§14.8.2.2) so @@ -35,6 +40,7 @@ pub type Rgb = [f64; 3]; pub mod backdrop; pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops}; +pub use text::strip_subset_tag; mod background; pub use background::page_background; pub mod objects; @@ -43,7 +49,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed mod flatten; pub use flatten::{flatten, flatten_fill_sign}; pub mod images; -pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees}; +pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees}; pub mod bidi; pub mod indic; mod linebreak; @@ -71,7 +77,7 @@ use marks::{matrix_number, n}; pub(crate) use place::undrawable; #[cfg(test)] use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role}; -use place::{check, check_on_top, contents, page_list, place_tagged}; +use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{stamp, undrawable_message}; #[cfg(test)] diff --git a/crates/edit/src/mark_transaction_tests.rs b/crates/edit/src/mark_transaction_tests.rs new file mode 100644 index 0000000000..2623da88ba --- /dev/null +++ b/crates/edit/src/mark_transaction_tests.rs @@ -0,0 +1,211 @@ +//! The public mark APIs either commit the complete operation or leave the document untouched. +use super::{cx, fixture, reopen, streams}; +use crate::place::MARK_PLACEMENT_FAILURE; +use crate::*; +use pdfcraft_cos::{ObjRef, SaveOptions, write_incremental}; + +const SELECTED: &[usize] = &[0, 1, 2]; + +struct PlacementFailure; + +impl PlacementFailure { + fn after_two_pages(kind: MarkKind) -> Self { + MARK_PLACEMENT_FAILURE.with(|failure| { + assert!(failure.get().is_none()); + failure.set(Some((kind, 2, 0))); + }); + Self + } + + fn completed(&self) -> usize { + MARK_PLACEMENT_FAILURE.with(|failure| failure.get().unwrap().2) + } +} + +impl Drop for PlacementFailure { + fn drop(&mut self) { + MARK_PLACEMENT_FAILURE.with(|failure| failure.set(None)); + } +} + +/// Four pages share an original stream and resources, both inherited and directly referenced. +/// All have all three existing marks, including a picture background with a replaced white fill. +fn marked_document() -> (Document, MarkSource) { + let mut doc = fixture(); + let parent = ObjRef::new(2, 0); + let extra = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(parent)), + (b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0))), + ]))); + doc.update_dict(parent, |d| { + d.set(b"Kids".to_vec(), Object::Array([3, 4, 5].map(|n| Object::Ref(ObjRef::new(n, 0))).into_iter().chain([Object::Ref(extra)]).collect())); + d.set(b"Count".to_vec(), Object::Int(4)); + }) + .unwrap(); + doc.set(ObjRef::new(7, 0), Object::Stream(Stream::from_raw(Dict::new(), b"1 g 0 0 600 800 re f 0 g BT /F1 9 Tf (Original page) Tj ET".to_vec()))); + let picture = |doc: &mut Document, gray| { + let image = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Image")), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::name("DeviceGray")), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ]), + vec![gray], + ))); + MarkSource { xobject: image, size: (1.0, 1.0), image: true } + }; + let old_picture = picture(&mut doc, 170); + let replacement_picture = picture(&mut doc, 80); + let all = [0, 1, 2, 3]; + let hf = HeaderFooter { + text: ["Old header".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Times, + ..Default::default() + }; + add_header_footer(&mut doc, &all, &hf, false, &cx()).unwrap(); + add_watermark(&mut doc, &all, &Watermark { text: "Old watermark".into(), opacity: 0.45, ..Default::default() }, false).unwrap(); + add_background(&mut doc, &all, &Background { source: Some(old_picture), opacity: 0.6, replace_backdrop: true, ..Default::default() }, false) + .unwrap(); + let pages = page_list(&doc); + let shared = ObjRef::new(6, 0); + let resources = (*doc.resolve(pages[0].dict.get(b"Resources").unwrap())).clone(); + doc.set(shared, resources); + for (i, page) in pages.iter().enumerate() { + doc.update_dict(page.obj, |d| { + if i % 2 == 0 { + d.set(b"Resources".to_vec(), Object::Ref(shared)); + } else { + d.remove(b"Resources"); + } + }) + .unwrap(); + } + let doc = reopen(&doc); + assert!(!doc.is_modified()); + let pages = page_list(&doc); + assert_eq!(pages.len(), 4); + assert!(doc.get(pages[1].obj).as_dict().unwrap().get(b"Resources").is_none(), "a selected page inherits its resources"); + assert_eq!(pages[0].dict.get(b"Resources").and_then(Object::as_ref), Some(shared)); + assert_eq!(pages[2].dict.get(b"Resources").and_then(Object::as_ref), Some(shared)); + for i in 0..4 { + for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + assert_eq!(mark_streams(&doc, i, kind).len(), 1, "page {i} has existing {kind:?}"); + } + } + (doc, replacement_picture) +} + +fn mark_streams(doc: &Document, page: usize, kind: MarkKind) -> Vec { + streams(doc, page).into_iter().filter(|s| s.contains(&format!("/PCMark /{}", kind.tag()))).collect() +} + +fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, replace: bool) -> Result<(), EditError> { + match kind { + MarkKind::HeaderFooter => add_header_footer( + doc, + SELECTED, + &HeaderFooter { + text: ["New header".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Courier, + shrink: true, + ..Default::default() + }, + replace, + &cx(), + ), + MarkKind::Watermark => add_watermark(doc, SELECTED, &Watermark { source: Some(source), opacity: 0.25, ..Default::default() }, replace), + MarkKind::Background => { + add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace) + } + } +} + +fn unchanged_after_later_page_failure(kind: MarkKind) { + // Replacement and addition share the same operation boundary; exercise replacement first. + for (replace, pending_edit) in [(true, false), (true, true), (false, false), (false, true)] { + let (mut doc, source) = marked_document(); + if pending_edit { + let pending = doc.add(Object::String(pdfcraft_cos::PdfString::literal(b"unsaved caller edit".to_vec()))); + doc.update_dict(page_list(&doc)[0].obj, |d| d.set(b"CallerEdit".to_vec(), Object::Ref(pending))).unwrap(); + } + let modified = doc.is_modified(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let objects = doc.object_numbers().len(); + let mut allocation_probe = doc.clone(); + let next_object = allocation_probe.add(Object::Null); + let failure = PlacementFailure::after_two_pages(kind); + let result = apply_replacement(&mut doc, kind, source, replace); + assert_eq!(result, Err(EditError::Invalid("injected mark placement failure".into()))); + assert_eq!(failure.completed(), 2, "the error followed two real page placements"); + assert_eq!(doc.object_numbers().len(), objects, "{kind:?}, replace={replace}: no allocated objects remain"); + assert_eq!(doc.is_modified(), modified, "{kind:?}, replace={replace}: the dirty state is preserved"); + assert!(write_incremental(&doc, &SaveOptions::default()).unwrap() == before, "{kind:?}, replace={replace}: every byte stays unchanged"); + assert_eq!(doc.add(Object::Null), next_object, "the allocation cursor is unchanged too"); + } +} + +#[test] +fn header_footer_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::HeaderFooter); +} + +#[test] +fn watermark_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::Watermark); +} + +#[test] +fn background_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::Background); +} + +#[test] +fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources() { + for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + let (mut doc, source) = marked_document(); + let before = doc.clone(); + let expected = match kind { + MarkKind::HeaderFooter => "(New header)".to_string(), + MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num), + MarkKind::Background => "0.1 0.3 0.7 rg".to_string(), + }; + apply_replacement(&mut doc, kind, source, true).unwrap(); + let doc = reopen(&doc); + assert_eq!(streams(&doc, 3), streams(&before, 3), "the unselected page keeps its content"); + let unselected = page_list(&before)[3].obj; + assert_eq!(doc.get(unselected), before.get(unselected), "the unselected page keeps its resources and entries"); + for reference in [ObjRef::new(6, 0), ObjRef::new(7, 0)] { + assert_eq!( + format!("{:?}", doc.get(reference)), + format!("{:?}", before.get(reference)), + "shared resources and original content stay unchanged" + ); + } + for page in SELECTED { + let marks = mark_streams(&doc, *page, kind); + assert_eq!(marks.len(), 1, "one replacement on selected page {page}"); + assert!(marks[0].contains(&expected), "the new mark is present: {}", marks[0]); + let old_page = &page_list(&before)[*page]; + let new_page = &page_list(&doc)[*page]; + let old_resources = before.resolve(old_page.dict.get(b"Resources").unwrap()); + let new_resources = doc.resolve(new_page.dict.get(b"Resources").unwrap()); + for category in [&b"Font"[..], &b"ExtGState"[..], &b"XObject"[..]] { + let old = before.resolve(old_resources.as_dict().unwrap().get(category).unwrap()); + let new = doc.resolve(new_resources.as_dict().unwrap().get(category).unwrap()); + for (name, value) in old.as_dict().unwrap().iter() { + assert_eq!(new.as_dict().unwrap().get(name), Some(value), "old resources remain usable on page {page}"); + assert_eq!(doc.resolve(value), before.resolve(value), "referenced resource contents are unchanged on page {page}"); + } + } + for other in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + if other != kind { + assert_eq!(mark_streams(&doc, *page, other), mark_streams(&before, *page, other), "unrelated {other:?} on page {page}"); + } + } + } + } +} diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index d936955184..344c76250f 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -286,7 +286,12 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C let digits = parts.get(1).and_then(|d| d.parse::().ok()).unwrap_or(6).clamp(1, 15); format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&"")) } - _ => format!("<<{token}>>"), + _ => { + // Not a token: emit one `<` and retry, so a later token is still found. + out.push('<'); + rest = &rest[start + 1..]; + continue; + } }; out.push_str(&value); rest = &after[end + 2..]; @@ -302,3 +307,16 @@ pub(crate) fn bates_start(template: &str) -> Option { let t = &t[..t.find(">>")?]; t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1)) } + +#[cfg(test)] +mod expand_tests { + use super::*; + + #[test] + fn expand_token_after_extra_angle_brackets() { + let cx = Context::default(); + assert_eq!(expand("<<<1>>>", 3, 9, 1, &cx), "<3>"); + assert_eq!(expand("<< <<1>>", 3, 9, 1, &cx), "<< 3"); + assert_eq!(expand("<> <<1>>", 3, 9, 1, &cx), "<> 3"); + } +} diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 2f2c24c862..27ed7b3793 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -294,7 +294,32 @@ pub(crate) fn isolation(doc: &Document, list: &[Object]) -> Isolation { /// Put a mark's content on a page: behind (with the behind-marks at the start) or on top /// (appended, after wrapping the original content in q/Q). pub(crate) fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { - place_tagged(doc, page, kind.tag(), content, behind).map(drop) + place_tagged(doc, page, kind.tag(), content, behind)?; + #[cfg(test)] + fail_after_mark_placement(kind)?; + Ok(()) +} + +#[cfg(test)] +thread_local! { + /// Force an error after N completed placements of one kind, on this test thread only. + pub(crate) static MARK_PLACEMENT_FAILURE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn fail_after_mark_placement(kind: MarkKind) -> Result<(), EditError> { + MARK_PLACEMENT_FAILURE.with(|failure| { + if let Some((target, after, completed)) = failure.get() + && target == kind + { + let completed = completed.saturating_add(1); + failure.set(Some((target, after, completed))); + if completed == after { + return Err(EditError::Invalid("injected mark placement failure".into())); + } + } + Ok(()) + }) } /// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the @@ -346,6 +371,76 @@ pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: Ok(mark_ref) } +/// Draw `ops`, written for the space whose CTM is `ctm`, on top of everything on `page`, tagged +/// `Edited`. A rewritten invisible paragraph goes here when [`revealed_under_paint`]: an OCR +/// layer written before the scan image it lies under would keep the revealed text hidden (#3973). +pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: [f64; 6], ops: &[pdfcraft_content::Op]) -> Result<(), EditError> { + if ops.is_empty() { + return Ok(()); + } + use pdfcraft_content::Op; + let mut all = vec![Op::new("q", vec![]), Op::new("cm", ctm.iter().map(|v| pdfcraft_content::num(*v)).collect())]; + all.extend_from_slice(ops); + all.push(Op::new("Q", vec![])); + place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) +} + +/// Where a rewritten paragraph can be drawn, in user space ([`revealed_under_paint`]). +pub(crate) struct Reach { + /// The paragraph's old box, where its cover goes. + pub rect: [f64; 4], + /// The move asked for (`BlockStyle::offset`). + pub offset: [f64; 2], + /// The widest line the rewrite may draw, how many lines it has, and how far apart they are. + pub width: f64, + pub rows: usize, + pub pitch: f64, + pub upright: bool, +} + +impl Reach { + /// The old box and the moved one, grown to `width` either way (alignment may grow a line left + /// or right), down to `rows` lines and up one `pitch` above its top (a larger size on the first line, however many lines the old box had); turned text grows by the lines' diagonal every way. + /// Unbounded when a number is not finite. + pub(crate) fn bounds(&self) -> [f64; 4] { + const UNBOUNDED: [f64; 4] = [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY]; + let ([dx, dy], r) = (self.offset, self.rect); + if !r.iter().chain(&self.offset).chain([&self.width, &self.pitch]).all(|v| v.is_finite()) { + return UNBOUNDED; + } + let moved = [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]; + let rows = f64::from(u32::try_from(self.rows).unwrap_or(u32::MAX)); + let (width, height) = (self.width.abs(), self.pitch.abs() * rows); + let grown = if self.upright { + [moved[0].min(moved[2] - width), moved[1].min(moved[3] - height), moved[2].max(moved[0] + width), moved[3] + self.pitch.abs()] + } else { + let d = width.hypot(height); + [moved[0] - d, moved[1] - d, moved[2] + d, moved[3] + d] + }; + let b = [r[0].min(grown[0]), r[1].min(grown[1]), r[2].max(grown[2]), r[3].max(grown[3])]; + if b.iter().all(|v| v.is_finite()) { b } else { UNBOUNDED } + } +} + +/// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after +/// operator `at` of the page's joined `ops` paints over `reach` (user space, [`Reach::bounds`]), as the scan image +/// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline +/// image or a shading after it counts anywhere, and so does any XObject when the page's images +/// cannot be listed. +pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, reach: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { + let later = ops.get(at..).unwrap_or_default(); + if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) { + return invisible; + } + later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, reach, at).unwrap_or(true) +} + +/// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, +/// none otherwise. The page was checked to take them ([`check_on_top`]) before the edit began. +pub(crate) fn lift_on_top(over: bool, paragraph: &mut Vec) -> Vec { + if over { std::mem::take(paragraph) } else { Vec::new() } +} + fn too_deep_on_top() -> EditError { EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into()) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d45e983a2e..cbea5c21d2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4,6 +4,9 @@ use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; use super::*; +#[path = "mark_transaction_tests.rs"] +mod mark_transactions; + /// Three pages: an upright page with content that leaves the graphics state changed (an /// unbalanced `cm`), a page rotated 90° and one with inherited, shared resources. fn fixture() -> Document { @@ -3516,11 +3519,24 @@ fn image_page() -> Document { image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET") } +/// Arrange, Crop and Remove crop as the engine asks for them, through [`images::change_image`]. +fn arrange(doc: &mut Document, page: usize, index: usize, order: images::ArrangeOrder) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::Arrange(order)) +} + +fn crop(doc: &mut Document, page: usize, index: usize, keep: [f64; 4]) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::Crop(keep)) +} + +fn remove_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::RemoveCrop) +} + fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), @@ -3540,6 +3556,57 @@ fn image_page_with(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +fn stencil_page_with(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >>\nstream\n0000>\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn form_page_with(content: &str) -> Document { + let form_content = "0 0 100 100 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 6 0 R >> >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{form_content}\nendstream", form_content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + /// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used /// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract, /// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581). @@ -3728,6 +3795,880 @@ fn page_images_move_turn_replace_and_delete() { assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err()); } +#[test] +fn image_arrange_stacking_order_and_remove_crop() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let initial_images = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial_images.len(), 2); + assert!(close(initial_images[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(initial_images[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Boundary case: image 0 is already at the back; SendToBack and SendBackward report an error. + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err()); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err()); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 2); + assert!(close(imgs[0].rect, initial_images[0].rect)); + + // Bring forward image 0: moves it past the middle text, but it is still behind image 1. + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringForward).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + // Middle text should now precede image 0's Do in the content stream! + let text_pos = str_content.find("Middle Text").unwrap(); + let first_do = str_content.find("/Im0 Do").unwrap(); + assert!(text_pos < first_do, "text should now precede image 0"); + + // Bring to front on image 0: moves it past image 1 to the front! + let mut doc = doc; + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let imgs_after_front = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs_after_front.len(), 2); + // In drawing order, the image that was at [100, 100, 200, 200] is now drawn LAST (index 1)! + assert!(close(imgs_after_front[0].rect, [200.0, 200.0, 350.0, 350.0])); + assert!(close(imgs_after_front[1].rect, [100.0, 100.0, 200.0, 200.0])); + + // Send to back on the front image (index 1) restores it to the back. + let mut doc = doc; + arrange(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap(); + let imgs_after_back = images::page_images(&doc, 0).unwrap(); + assert!(close(imgs_after_back[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(imgs_after_back[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Test remove crop + let cropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(cropped_content); + crop(&mut doc, 0, 0, [100.0, 100.0, 150.0, 150.0]).unwrap(); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_ne!(img_before.visible_rect, Some(img_before.rect)); + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_after = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_after.visible_rect, Some(img_after.rect)); + // Removing crop again when there is no crop is refused. + let mut doc = doc; + assert!(remove_crop(&mut doc, 0, 0).is_err()); + + // Test crop + let uncropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(uncropped_content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_before.visible_rect, Some(img_before.rect)); + crop(&mut doc, 0, 0, [120.0, 110.0, 250.0, 180.0]).unwrap(); + let doc = reopen(&doc); + let img_cropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_cropped.visible_rect.unwrap(), [120.0, 110.0, 250.0, 180.0])); + // And remove_crop works on it: + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored.visible_rect, Some(img_restored.rect)); + + // Test cropping an image that was moved (has an outer transform) + let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q"); + let move_transform = [1.0, 0.0, 0.0, 1.0, 50.0, 40.0]; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(move_transform)).unwrap(); + let doc = reopen(&doc); + let img_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_moved.rect, [150.0, 140.0, 350.0, 240.0]); + let mut doc = doc; + crop(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap(); + let doc = reopen(&doc); + let img_crop_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert!(img_crop_moved.visible_rect.is_some(), "cropped image must not be deleted"); + assert!(close(img_crop_moved.visible_rect.unwrap(), [170.0, 150.0, 300.0, 220.0])); + // Adjust crop (re-crop) + let mut doc = doc; + crop(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap(); + let doc = reopen(&doc); + let img_recropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_recropped.visible_rect.unwrap(), [180.0, 160.0, 280.0, 210.0])); + // Remove crop from moved image + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect)); +} + +#[test] +fn crop_rotated_image_preserves_angle_and_in_place_geometry() { + let content = "q 173.20508 100 -50 86.60254 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + let m_before = img_before.matrix; + assert!((m_before[1] - 100.0).abs() < 1e-3); + assert!((m_before[2] - (-50.0)).abs() < 1e-3); + + crop(&mut doc, 0, 0, [140.0, 170.0, 180.0, 210.0]).unwrap(); + let doc = reopen(&doc); + let img_cropped = &images::page_images(&doc, 0).unwrap()[0]; + + let m_after = img_cropped.matrix; + assert!((m_after[0] - m_before[0]).abs() < 1e-3); + assert!((m_after[1] - m_before[1]).abs() < 1e-3); + assert!((m_after[2] - m_before[2]).abs() < 1e-3); + assert!((m_after[3] - m_before[3]).abs() < 1e-3); + assert!((m_after[4] - m_before[4]).abs() < 1e-3); + assert!((m_after[5] - m_before[5]).abs() < 1e-3); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!( + (str_content.contains("re") || (str_content.contains("m\n") && str_content.contains("l\n"))) && str_content.contains("W\nn"), + "{str_content}" + ); + assert!(str_content.contains("/Im0 Do"), "{str_content}"); + + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored.matrix, m_before); +} + +#[test] +fn image_arrange_preserves_shared_group_drawings() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (End Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2, "both images initially present in shared group"); + + // Bring image 0 to front: it must not delete image 1 that shares the same q...Q group! + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must be preserved after BringToFront"); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("End Text"), "text in page must be preserved"); +} + +/// The marked-content sequence that `mcid` opens (`/MCID n >> BDC` … its `EMC`), as content text. +fn marked_sequence(content: &str, mcid: u32) -> &str { + let tag = format!("/MCID {mcid}"); + let start = content.find(&tag).unwrap_or_else(|| panic!("no {tag} in {content}")); + let end = content[start..].find("EMC").map(|e| start + e).unwrap_or_else(|| panic!("{tag} has no EMC in {content}")); + &content[start..end] +} + +/// #3145: Arrange treated marked-content boundaries as if they weren't there, so Bring to Front put +/// the image inside the text's `BDC`…`EMC` (tagged as the text) and left its own sequence empty. +#[test] +fn image_arrange_keeps_marked_content_sequences_whole() { + let content = "/P <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(marked_sequence(&s, 0).contains("/Im0 Do"), "the image stays in its own sequence: {s}"); + assert!(!marked_sequence(&s, 1).contains("/Im0 Do"), "the text's sequence holds no image: {s}"); + assert!(marked_sequence(&s, 1).contains("(Text)"), "{s}"); + assert!(s.find("/MCID 1").unwrap() < s.find("/MCID 0").unwrap(), "the image's sequence now comes after the text's: {s}"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); +} + +/// A `BMC` sequence (here an artifact) moves as a whole too. +#[test] +fn image_arrange_moves_a_bmc_sequence_as_a_whole() { + let content = "/Artifact BMC q 100 0 0 100 50 50 cm /Im0 Do Q EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let s = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let (art, emc, body, img) = (s.find("/Artifact BMC").unwrap(), s.rfind("EMC").unwrap(), s.find("(Body)").unwrap(), s.find("/Im0 Do").unwrap()); + assert!(body < art && art < img && img < emc, "text first, then the whole artifact sequence: {s}"); +} + +/// An image sharing a marked-content sequence with other content must not end up untagged. +/// Either the arrange call is refused with Err, or every image paint remains inside a marked-content sequence. +#[test] +fn arrange_image_in_shared_tagged_block_refuses_or_keeps_tagged() { + let content = + "/Figure <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Caption) Tj ET EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + match res { + Err(_) => { + // Refusing is the expected safe behavior when moving out of a shared tagged unit. + } + Ok(()) => { + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let ops = pdfcraft_content::parse(&bytes).ops; + let mut mc_depth = 0usize; + for op in ops { + if op.is("BDC") || op.is("BMC") { + mc_depth += 1; + } else if op.is("EMC") { + mc_depth = mc_depth.saturating_sub(1); + } else if op.is("Do") { + assert!(mc_depth > 0, "every image paint must be inside a marked-content sequence: {}", String::from_utf8_lossy(&bytes)); + } + } + } + } +} + +/// An image that shares its marked-content sequence with other content cannot be rearranged +/// without leaving the image untagged or duplicating MCIDs, so it is refused. +#[test] +fn image_arrange_splits_a_shared_marked_sequence_into_own_tag() { + let content = + "/Figure <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Caption) Tj ET EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err()); +} + +/// Send backward also refuses to rearrange an image when it shares its sequence with other content. +#[test] +fn image_arrange_send_backward_splits_shared_marked_sequence() { + let content = + "BT /F1 12 Tf 72 700 Td (Header) Tj ET /Table <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Cell) Tj ET EMC"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err()); +} + +#[test] +fn arrange_preserves_color_leaking_out_of_marked_content() { + let content = "/Figure <> BDC 1 0 0 rg q 100 0 0 100 0 0 cm /Im0 Do Q EMC BT /F1 12 Tf 72 700 Td (Text) Tj ET"; + let mut doc = image_page_with(content); + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + if res.is_ok() { + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + let text_pos = s.find("(Text) Tj").unwrap(); + assert!(s[..text_pos].contains("1 0 0 rg") || s[..text_pos].contains("1 0 0 RG")); + } +} + +#[test] +fn arrange_shared_tagged_does_not_duplicate_mcid() { + let content = "/Figure <> BDC q 100 0 0 100 0 0 cm /Im0 Do Q 0 0 m 10 10 l S EMC"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err()); +} + +#[test] +fn image_crop_preserves_outer_cm_for_subsequent_drawings() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Crop image 0 + crop(&mut doc, 0, 0, [60.0, 60.0, 120.0, 120.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must remain"); + // Image 0 is cropped: + assert!(close(after[0].visible_rect.unwrap(), [60.0, 60.0, 120.0, 120.0])); + // Crucial: Image 1 must still have its rect at [50, 50, 150, 150], proving outer cm was not destroyed! + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "subsequent image must retain global cm"); +} + +#[test] +fn image_arrange_preserves_position_under_active_ctm() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Move first image to front: must not apply destination CTM twice! + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: check order of operators in the content stream + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let first_do = str_content.find("/Im0 Do").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(first_do < last_do, "Position: multiple image Do calls exist and order was updated"); + + // 2. Appearance: both images' positions and sizes are preserved + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: first image position preserved"); + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: second image position preserved"); + + // 3. Preservation: outer CTM is preserved + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: initial CTM preserved"); +} + +#[test] +fn image_arrange_preserves_shared_group_clipping_bounds() { + let content = "q 120 120 40 40 re W n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + assert_eq!(initial[1].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + + // Bring image 0 to front: moves it past the external text while preserving clipping bounds + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: moved image Do now follows External Text in AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let moved_do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < moved_do_pos, "Position: moved image Do follows External Text"); + + // 2. Appearance: both images retain exact clipping bounds and geometry + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 0 retains clip"); + assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 1 retains clip"); + + // 3. Preservation: External Text and original group drawing preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); + assert!(str_content.contains("120 120 40 40 re W n"), "Preservation: clipping path operators preserved"); +} + +#[test] +fn image_arrange_preserves_extended_graphics_state_opacity() { + let content = "q /GS0 gs 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do moved past External Text + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: /GS0 gs is retained and directly precedes /Im0 Do + let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + assert!(text_pos < gs_pos, "Appearance: /GS0 gs is in the new moved unit"); + assert!(gs_pos < do_pos, "Appearance: /GS0 gs directly precedes /Im0 Do"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "image preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: text preserved"); +} + +#[test] +fn image_arrange_preserves_exact_non_rectangular_clipping_path() { + let content = "q 120 120 m 160 120 l 140 160 l h W* n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: moved image follows External Text in AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < last_do, "Position: moved image follows External Text"); + + // 2. Appearance: polygon geometry and even-odd rule are preserved + assert!(str_content.contains("W*"), "Appearance: even-odd W* fill rule preserved"); + // The original group still clips the image left behind, so the moved copy must carry its own. + let flat = str_content.split_whitespace().collect::>().join(" "); + assert_eq!(flat.matches("120 120 m 160 120 l 140 160 l h W* n").count(), 2, "Appearance: exact polygon clip preserved in both units"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + for image in &after { + assert!(image.contains([140.0, 130.0]), "Appearance: inside the triangle shows"); + assert!(!image.contains([125.0, 155.0]), "Appearance: inside its box but outside the triangle stays clipped"); + } + + // 3. Preservation: External Text and remaining original group content preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); +} + +#[test] +fn image_remove_crop_after_arrange_keeps_position_under_outer_transform() { + // Arrange writes `cm inverse`, the clip, then the absolute placement; Remove Crop used to drop + // only the first, which drew the image under the outer translation twice. + let content = "1 0 0 1 50 50 cm q 100 0 0 100 0 0 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + crop(&mut doc, 0, 0, [50.0, 50.0, 100.0, 100.0]).unwrap(); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let mut doc = reopen(&doc); + let moved = images::page_images(&doc, 0).unwrap(); + assert_eq!(moved[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(moved[0].visible_rect, Some([50.0, 50.0, 100.0, 100.0])); + + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "the image stays where it was"); + assert_eq!(after[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0]), "the crop is gone"); +} + +#[test] +fn image_arrange_refuses_to_move_past_a_clip_it_is_not_under() { + // The text after the image is drawn under a top-level clip; moving the image after it would + // crop the image to that clip. + let content = "q 100 0 0 100 50 50 cm /Im0 Do Q 0 0 60 60 re W n BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let before = page_content_bytes(&doc, 0); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0])); + + for order in [images::ArrangeOrder::BringToFront, images::ArrangeOrder::BringForward] { + let err = arrange(&mut doc, 0, 0, order).unwrap_err(); + assert!(err.to_string().contains("clipping path"), "{err}"); + } + assert_eq!(page_content_bytes(&doc, 0), before, "nothing changes"); +} + +#[test] +fn image_arrange_send_to_back_keeps_a_shared_clip() { + // The image after the text is clipped by a top-level clip set before both; behind the text it + // is no longer under that clip, so the moved unit has to draw it itself. + let content = "BT /F1 12 Tf 72 700 Td (External Text) Tj ET 0 0 60 60 re W n q 100 0 0 100 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.rfind("/Im0 Do").unwrap() < str_content.find("External Text").unwrap(), "the image moved behind the text"); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].visible_rect, Some([50.0, 50.0, 60.0, 60.0]), "still cropped to the clip"); +} + +#[test] +fn unwrapped_image_moves_successfully() { + let content = "100 0 0 100 50 50 cm /Im0 Do BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes Middle Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("Middle Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes Middle Text"); + + // Move unwrapped image to front (past Middle Text) + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows Middle Text in the content stream AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("Middle Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do actually moved after Middle Text"); + + // 2. Appearance: image rect and visibility are unchanged + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: image coordinates preserved"); + assert_eq!(after[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0])); + + // 3. Preservation: Middle Text and CTM remain intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"), "Preservation: text operation preserved"); + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: original cm preserved"); +} + +#[test] +fn test_arrange_unwrapped_image_extraction() { + let content = b"100 0 0 100 100 100 cm /Im0 Do /Im1 Do"; + let ops = pdfcraft_content::parse(content).ops; + let target = ops.iter().position(|op| op.is("Do") && op.name(0) == Some(b"Im0")).expect("target /Im0 not found"); + + // Scope start for unwrapped image matches target because content_units isolates Do + let scope_start = target; + + // Accumulate effective CTM prior to the image operation + let mut initial_ctm = pdfcraft_content::Matrix::IDENTITY; + for op in &ops[..target] { + if op.is("cm") + && let Some(m) = op.nums::<6>() + { + initial_ctm = pdfcraft_content::Matrix(m).then(&initial_ctm); + } + } + assert_eq!(initial_ctm, pdfcraft_content::Matrix([100.0, 0.0, 0.0, 100.0, 100.0, 100.0])); + + let extracted = images::extract_scope_state(&ops, scope_start, target, initial_ctm) + .expect("extraction must not return Err") + .expect("extraction must return Some rather than None"); + + assert_eq!(extracted.do_op.op.as_slice(), b"Do"); + assert_eq!(extracted.do_op.name(0), Some(b"Im0".as_slice())); +} + +#[test] +fn arrange_unwrapped_image_in_mixed_scope_succeeds() { + // Content unit contains text and an unwrapped image; scope extraction of unit fails on BT, + // so it safely falls back to extracting target Do directly. + let content = "q BT /F1 12 Tf 72 700 Td (Preceding Text) Tj ET 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 500 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "moved image follows External Text"); + + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0]); +} + +#[test] +fn image_arrange_with_transformed_clipping_path_succeeds() { + // Nested graphics state: outer scope establishes a scaled clipping path (cm + path + W n), + // inner scope draws the image. + let content = "q 0.5 0 0 0.5 0 0 cm 100 100 m 300 100 l 300 300 l 100 300 l h W n q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + assert!(before[0].visible_rect.is_some()); + let rect_before = before[0].rect; + let clip_before = before[0].visible_rect; + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, rect_before); + assert_eq!(after[0].visible_rect, clip_before); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "image moved after text: {str_content}"); +} + +#[test] +fn inherited_source_opacity_is_preserved() { + // Outer q sets /GS0 gs; inner q draws /Im0 Do; outer Q restores state before External Text + let content = "q /GS0 gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes External Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("External Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes External Text"); + + // Move image to front (past External Text, outside its original outer q...Q scope) + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: effective appearance state (/GS0 gs) is injected and precedes /Im0 Do in the new unit + let after_gs = str_content.rfind("/GS0 gs").unwrap(); + assert!(after_text < after_gs, "Appearance: /GS0 gs injected into the new unit at destination"); + assert!(after_gs < after_do, "Appearance: /GS0 gs directly precedes /Im0 Do in the moved unit"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text and original outer scope structure are preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_opacity_pollution_is_prevented() { + // /Im0 Do is fully opaque at the start; destination has active top-level /GS_translucent gs + let content = "100 0 0 100 50 50 cm /Im0 Do /GS_translucent gs BT /F1 12 Tf 72 700 Td (Translucent Text) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move opaque image 0 to front into active transparency scope must safely bail out with Err + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently corrupting opacity"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: the document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +#[test] +fn sequential_gs_operators_merging_properties_are_preserved() { + // Sequential gs calls: /GSalpha sets ca=0.6, /GSblend sets BM=/Multiply (without touching ca). + let content = "q /GSalpha gs /GSblend gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: Both merged state operators (/GSalpha and /GSblend) are preserved and precede /Im0 Do + let after_alpha = str_content.rfind("/GSalpha gs").unwrap(); + let after_blend = str_content.rfind("/GSblend gs").unwrap(); + assert!(after_text < after_alpha, "Appearance: /GSalpha gs injected into moved unit"); + assert!(after_text < after_blend, "Appearance: /GSblend gs injected into moved unit"); + assert!(after_alpha < after_do, "Appearance: /GSalpha gs precedes /Im0 Do"); + assert!(after_blend < after_do, "Appearance: /GSblend gs precedes /Im0 Do"); + + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_differing_blend_mode_pollution_is_prevented() { + // /Im0 Do has default Normal blend mode; destination has active top-level /GSblend gs (BM = /Multiply) + let content = "100 0 0 100 50 50 cm /Im0 Do /GSblend gs BT /F1 12 Tf 72 700 Td (Blended Section) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move Normal-blend image to front into active Multiply-blend scope must safely bail out with Err + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently altering blend mode"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +fn nested_soft_mask_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /GroupA 9 0 R /GroupB 10 0 R >> /ExtGState << /GSA 7 0 R /GSB 8 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 9 0 R >> >>".into(), + "<< /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 10 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 100 50 re f\nendstream".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 50 100 re f\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn nested_soft_masks_replay_in_correct_order_and_effective_mask_is_rendered() { + let content = "q /GSA gs q /GSB gs 100 0 0 100 0 0 cm /Im0 Do Q Q BT /F1 12 Tf 10 90 Td (External Text) Tj ET"; + let mut doc = nested_soft_mask_page(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + let content_bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&content_bytes); + + // 1. Assert emitted stream order: outer /GSA gs must precede inner /GSB gs, which precedes /Im0 Do + let after_text = str_content.find("External Text").unwrap(); + let after_a = str_content.rfind("/GSA gs").unwrap(); + let after_b = str_content.rfind("/GSB gs").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_a, "Position: /GSA gs relocated past external text"); + assert!(after_a < after_b, "Order: outer /GSA gs must be replayed before inner /GSB gs"); + assert!(after_b < after_do, "Order: inner /GSB gs must immediately precede /Im0 Do"); + + // 2. The image survives saving and reopening. + assert_eq!(images::page_images(&reopen(&doc), 0).unwrap().len(), 1); + + // 3. Rendered-pixel assertion: render output and verify Mask B (left 50x100) is effective, not Mask A (bottom 100x50) + let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes), pdfcraft_render::RenderConfig::default()); + let page = + renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let px = |x: u32, y: u32| { + let idx = ((y * page.width + x) * 4) as usize; + page.rgba[idx..idx + 4].to_vec() + }; + // Left half (inside Mask B): image is rendered black + assert_eq!(px(25, 25), vec![0, 0, 0, 255], "point (25, 25) is inside Mask B: image rendered"); + assert_eq!(px(25, 75), vec![0, 0, 0, 255], "point (25, 75) is inside Mask B: image rendered"); + // Right half (outside Mask B): masked out to white page background + assert_eq!(px(75, 25), vec![255, 255, 255, 255], "point (75, 25) is outside Mask B: masked out to white"); + assert_eq!(px(75, 75), vec![255, 255, 255, 255], "point (75, 75) is outside Mask B: masked out to white"); +} + +#[test] +fn destination_differing_mask_pollution_is_prevented() { + // /Im0 Do has no mask; destination has active top-level /GSA gs (Mask A) + let content = "100 0 0 100 0 0 cm /Im0 Do /GSA gs BT /F1 12 Tf 10 90 Td (Masked Section) Tj ET"; + let mut doc = nested_soft_mask_page(content); + let initial_bytes = page_content_bytes(&doc, 0); + + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than corrupting mask state"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +fn soft_mask_with_alpha_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /GroupA 9 0 R >> /ExtGState << /GSA 7 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /ExtGState /ca 0.25 /SMask << /Type /Mask /S /Alpha /G 9 0 R >> >>".into(), + "<< /Type /ExtGState /SMask /None >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 100 50 re f\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn non_identity_cm_before_gs_preserves_mask_coordinates_and_rendering() { + let content = "q 2 0 0 2 10 10 cm /GSA gs 50 0 0 50 0 0 cm /Im0 Do Q BT /F1 12 Tf 10 180 Td (External Text) Tj ET"; + let mut doc = soft_mask_with_alpha_page(content); + + // Initial state: image bounds are [10, 10, 110, 110] + let initial_images = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial_images.len(), 1); + assert!(close(initial_images[0].rect, [10.0, 10.0, 110.0, 110.0])); + + // Render baseline before reordering + let pdf_bytes_before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes_before), pdfcraft_render::RenderConfig::default()); + let page_before = renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let px_before = |x: u32, y: u32| { + let idx = ((y * page_before.width + x) * 4) as usize; + page_before.rgba[idx..idx + 4].to_vec() + }; + + // Arrange image: BringToFront + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + let content_bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&content_bytes); + + // 1. Assert AST reordering succeeds and interleaves cm before gs + let text_pos = str_content.find("External Text").unwrap(); + let cm_mask_pos = str_content.rfind("2 0 0 2 10 10 cm").unwrap(); + let gs_pos = str_content.rfind("/GSA gs").unwrap(); + let cm_img_pos = str_content.rfind("50 0 0 50 0 0 cm").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + + assert!(text_pos < cm_mask_pos, "Image relocated after external text"); + assert!(cm_mask_pos < gs_pos, "CTM for soft mask established before /GSA gs"); + assert!(gs_pos < cm_img_pos, "/GSA gs executed before image cm"); + assert!(cm_img_pos < do_pos, "image cm executed before /Im0 Do"); + + // 2. Assert image bounds remain unchanged after saving and reopening + let reopened_doc = reopen(&doc); + let reopened_images = images::page_images(&reopened_doc, 0).unwrap(); + assert_eq!(reopened_images.len(), 1); + assert!(close(reopened_images[0].rect, [10.0, 10.0, 110.0, 110.0]), "Image bounds preserved: {:?}", reopened_images[0].rect); + + // 3. Render reopened document and assert non-overlapping pixels remain unchanged + let pdf_bytes_after = write_incremental(&reopened_doc, &SaveOptions::default()).unwrap(); + let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes_after), pdfcraft_render::RenderConfig::default()); + let page_after = renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let px_after = |x: u32, y: u32| { + let idx = ((y * page_after.width + x) * 4) as usize; + page_after.rgba[idx..idx + 4].to_vec() + }; + + // A point inside the masked image (e.g. at x=50, inside the image and bottom half of mask) + // Non-overlapping with text (text is at y=180). + // The pixel renders at [191, 191, 191, 255] (25% black over 100% white). + // In raster coordinates, raster y for PDF y=50 in a 200-high page is y=150. + let rendered_pixel_before = px_before(50, 150); + let rendered_pixel_after = px_after(50, 150); + assert_eq!(rendered_pixel_before, vec![191, 191, 191, 255], "baseline pixel rendered with 25% opacity mask"); + assert_eq!(rendered_pixel_after, rendered_pixel_before, "rendered non-overlapping pixels remain unchanged"); + + // Outside the image: pure white background + assert_eq!(px_after(5, 5), vec![255, 255, 255, 255]); + assert_eq!(px_after(5, 5), px_before(5, 5)); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents @@ -4179,6 +5120,56 @@ fn the_graphics_state_carries_over_between_content_streams() { assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}"); } +fn two_stream_image_page(first: &str, second: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> /XObject << /Im0 7 0 R /Im1 7 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{first}\nendstream", first.len()), + format!("<< /Length {} >>\nstream\n{second}\nendstream", second.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\0\0\0\0\0\0\0\0\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn arrange_and_crop_image_in_second_content_stream_succeeds() { + let first = "q 100 0 0 100 50 50 cm /Im0 Do Q"; + let second = "q 100 0 0 100 200 200 cm /Im1 Do Q"; + let mut doc = two_stream_image_page(first, second); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 2); + assert_eq!(before[0].name, "Im0"); + assert_eq!(before[1].name, "Im1"); + + // Arrange second image (located in the second content stream) + arrange(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap(); + let after_arrange = images::page_images(&reopen(&doc), 0).unwrap(); + assert_eq!(after_arrange.len(), 2); + + // Crop image in second stream + crop(&mut doc, 0, 0, [210.0, 210.0, 290.0, 290.0]).unwrap(); + let after_crop = images::page_images(&reopen(&doc), 0).unwrap(); + assert_eq!(after_crop.len(), 2); + assert_eq!(after_crop[0].name, "Im1"); + assert!(close(after_crop[0].visible_rect.unwrap(), [210.0, 210.0, 290.0, 290.0])); + assert_eq!(after_crop[1].name, "Im0"); + assert_eq!(after_crop[1].visible_rect, Some(after_crop[1].rect)); +} + /// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש, /// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`. fn hebrew_page(content: &str) -> Document { @@ -4584,6 +5575,41 @@ fn reading_follows_form_xobjects() { assert!(own[0].is_form && own[0].object == Some(pdfcraft_cos::ObjRef::new(6, 0)), "{own:?}"); } +/// A page's `/XObject` and a form's `/Resources` are read wherever they are stored, and a form with +/// none of its own, or with something else there, uses the ones it was drawn with (PDF 1.1 files). +/// Each of these pages shows one line of text and one picture through one form. +#[test] +fn reading_follows_forms_however_their_resources_are_stored() { + let body = "BT /F1 12 Tf 72 700 Td (In the form) Tj ET q 20 0 0 10 5 5 cm /Im Do Q"; + let (font, direct) = ("/Font << /F1 5 0 R >>", "/XObject << /Fm 6 0 R /Im 7 0 R >>"); + for (page, form) in [ + (format!("<< {font} {direct} >>"), ""), + (format!("<< {font} /XObject 9 0 R >>"), ""), + (format!("<< /Font 10 0 R {direct} >>"), ""), + (format!("<< {font} {direct} >>"), "/Resources 99 0 R"), + (format!("<< {font} {direct} >>"), "/Resources 12"), + ("<< /XObject << /Fm 6 0 R >> >>".to_string(), "/Resources 8 0 R"), + ("<< /XObject 9 0 R >>".to_string(), "/Resources << /Font << /F1 5 0 R >> /XObject 9 0 R >>"), + ] { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources {page} /Contents 4 0 R >>"), + &stream("", "/Fm Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>", + &stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 600 800] {form}"), body), + &stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"), + "<< /Font << /F1 5 0 R >> /XObject 9 0 R >>", + "<< /Fm 6 0 R /Im 7 0 R >>", + "<< /F1 5 0 R >>", + ]); + let blocks = text::reading_blocks(&doc, 0).unwrap(); + let texts: Vec<(&str, &str)> = blocks.iter().map(|b| (b.text.as_str(), b.base_font.as_str())).collect(); + assert_eq!(texts, [("In the form", "Courier")], "{page} {form}"); + assert_eq!(images::reading_images(&doc, 0).unwrap().len(), 1, "{page} {form}"); + } +} + /// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but /// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging. #[test] @@ -5187,6 +6213,126 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } +/// Invisible text moves on top only when something painted after it covers it: an XObject drawn +/// over its box (an image, or a form holding one), or any inline image or shading. An image +/// clipped away entirely, or drawn elsewhere, does not move it. +#[test] +fn revealed_text_moves_on_top_only_when_painted_over() { + let on_top_of = |doc: Document| { + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at) + }; + let on_top = |content: &str| on_top_of(image_page_with(content)); + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it"); + assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere"); + assert!(!on_top(&format!("{scan} BT /F1 12 Tf 3 Tr 110 150 Td (Over) Tj ET")), "the scan is drawn first"); + assert!(!on_top(&format!("BT /F1 12 Tf 0 Tr 110 150 Td (Shown) Tj ET {scan}")), "visible text stays where it is"); + assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); + let under = "BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET"; + let elsewhere = "q 50 0 0 50 400 600 cm /Im0 Do Q"; + assert!(!on_top(&format!("{under} q 0 0 10 10 re W n {scan} Q")), "the scan is clipped away entirely"); + assert!(on_top(&format!("{under} {elsewhere} {scan}")), "two images, the second over it"); + assert!(on_top(&format!("{under} {scan} {elsewhere}")), "two images, the first over it"); + assert!(!on_top(&format!("{under} {elsewhere} {elsewhere}")), "two images, neither over it"); + let in_form = |content: &str| { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> >> >>"; + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im0 6 0 R >> >>", "/Im0 Do"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + page.into(), + stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + image, + form, + ]; + on_top_of(build(&objs.iter().map(String::as_str).collect::>())) + }; + assert!(in_form(&format!("{under} q 200 0 0 100 100 100 cm /Fm0 Do Q")), "a form holding the scan, over it"); + assert!(!in_form(&format!("{under} q 50 0 0 50 400 600 cm /Fm0 Do Q")), "a form holding an image elsewhere"); +} + +/// Revealed OCR text is checked where the rewrite lands, not only where it was: an image drawn +/// after it over just the moved or grown text still lifts the text on top, on both rewrite paths. +#[test] +fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() }; + let sized = text::BlockStyle { size: Some(24.0), ..Default::default() }; + let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() }; + let range = text::RangeStyle { + range: 0..2, + family: None, + resource: None, + size: Some(120.0), + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let enlarged = text::BlockStyle { ranges: vec![range], ..Default::default() }; + let above = "q 200 0 0 60 100 200 cm /Im0 Do Q"; + let cases = [ + ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved), + ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved), + ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there, this line now runs well past the scan's left edge", &text::BlockStyle::default()), + ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()), + ("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()), + ]; + let low = "q 200 0 0 30 100 300 cm /Im0 Do Q"; + let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([ + ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low), + ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low), + ("BT /F1 12 Tf 3 Tr 110 150 Td (Hi there) Tj ET", "Hi there", &enlarged, above), + ]); + for (content, edit, style, scan) in cases { + let mut doc = image_page_with(&format!("{content} {scan}")); + let before = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + assert!(!revealed_under_paint(&doc, 0, true, before.rect, &ops, at), "{edit:?}: the old box alone is clear of the scan"); + text::rewrite_block(&mut doc, 0, 0, Some(edit), style).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let scan_at = ops.iter().position(|o| o.is("Do")).unwrap(); + let shows = ["Tj", "TJ", "'", "\""]; + assert!(ops.iter().skip(scan_at).any(|o| shows.iter().any(|s| o.is(s))), "{edit:?}: the rewrite is drawn over the scan"); + } +} + +/// [`crate::Reach`] grows the old box to the move, the width either way and the lines below. +#[test] +fn a_rewrites_reach_covers_its_move_width_and_lines() { + let reach = |offset, width, rows, upright| crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset, width, rows, pitch: 10.0, upright }.bounds(); + assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 110.0]); + assert_eq!(reach([5.0, -50.0], 20.0, 1, true), [10.0, 40.0, 35.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 110.0]); + assert_eq!(reach([0.0, 0.0], 30.0, 4, false), [-40.0, 40.0, 80.0, 150.0]); + assert_eq!(reach([f64::NAN, 0.0], 20.0, 1, true)[0], f64::NEG_INFINITY); + let larger = crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset: [0.0, 0.0], width: 20.0, rows: 1, pitch: 24.0, upright: true }; + assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 124.0], "a larger size grows the line up as well as down"); +} + +/// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep +/// for that is refused before either rewrite path changes anything. +#[test] +fn an_ocr_rewrite_refused_on_top_leaves_the_document_as_it_was() { + let content = format!("{} BT /F1 20 Tf 3 Tr 42 44 Td (John) Tj ET /Im Do {}", "/X BMC ".repeat(10_001), "EMC ".repeat(10_001)); + let mut doc = text_page(&content); + assert!(text::text_blocks(&doc, 0).unwrap()[0].invisible); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + for edit in ["Jane", "Jane\nDoe"] { + let error = text::rewrite_block(&mut doc, 0, 0, Some(edit), &text::BlockStyle::default()).unwrap_err(); + assert!(error.to_string().contains("nested too deeply"), "{edit:?}: {error}"); + assert!(write_full(&doc, &SaveOptions::default()).unwrap() == before, "{edit:?}: a refused rewrite changes nothing"); + } +} + #[test] fn group_move_budget_refusals_keep_the_original_page_selectable() { let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995))); @@ -7495,6 +8641,155 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ); } +#[cfg(test)] +mod proptests { + use super::*; + use proptest::prelude::*; + + proptest! { + + #[test] + fn prop_is_entire_page_wrapped_arbitrary_bytes(bytes in proptest::collection::vec(any::(), 0..2048)) { + let pieces = pdfcraft_content::Pieces::join(&[&bytes]); + let ops = pieces.parse().ops; + let _ = images::is_entire_page_wrapped(&ops); + } + + #[test] + fn prop_content_units_arbitrary_bytes( + bytes in proptest::collection::vec(any::(), 0..2048), + start in 0usize..50, + end in 0usize..50, + ) { + let pieces = pdfcraft_content::Pieces::join(&[&bytes]); + let ops = pieces.parse().ops; + let scan_start = start.min(ops.len()); + let scan_end = end.min(ops.len()).max(scan_start); + let units = images::content_units(&ops, scan_start, scan_end); + for u in units { + prop_assert!(u.start <= u.end); + prop_assert!(u.end <= ops.len()); + } + } + + #[test] + fn prop_arrange_image_arbitrary_malformed( + index in 0usize..10, + order_idx in 0u8..4, + bytes in proptest::collection::vec(any::(), 0..1024), + ) { + let order = match order_idx { + 0 => images::ArrangeOrder::BringToFront, + 1 => images::ArrangeOrder::SendToBack, + 2 => images::ArrangeOrder::BringForward, + _ => images::ArrangeOrder::SendBackward, + }; + let mut doc = image_page_with(&String::from_utf8_lossy(&bytes)); + let _ = arrange(&mut doc, 0, index, order); + } + + #[test] + fn prop_crop_image_malformed_inputs( + x1 in -500.0f64..500.0, + y1 in -500.0f64..500.0, + x2 in -500.0f64..500.0, + y2 in -500.0f64..500.0, + bytes in proptest::collection::vec(any::(), 0..1024), + ) { + let mut doc = image_page_with(&String::from_utf8_lossy(&bytes)); + let _ = crop(&mut doc, 0, 0, [x1, y1, x2, y2]); + let _ = remove_crop(&mut doc, 0, 0); + } + } +} + +/// One 600×800 page: Im0 (4×2 gray image), Fm0 (200×100 Form), ExtGState GSone (ca 1). +fn regression_page(content: &str) -> Document { + let form = "0 g 0 0 200 100 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /Fm0 7 0 R >> /ExtGState << /GSone << /Type /ExtGState /ca 1 >> >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Length {} >>\nstream\n{form}\nendstream", form.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn regression_rects(doc: &Document) -> Vec<[i64; 4]> { + let mut v: Vec<[i64; 4]> = images::page_images(doc, 0).unwrap().iter().map(|i| i.rect.map(|x| x.round() as i64)).collect(); + v.sort(); + v +} + +/// A `cm` inside a BDC…EMC unit applies to later content; arranging the unit must not shift anything. +#[test] +fn arrange_keeps_cm_leaking_out_of_marked_content() { + let mut doc = regression_page("/Figure <> BDC 1 0 0 1 50 50 cm q 100 0 0 100 0 0 cm /Im0 Do Q EMC q 10 0 0 10 200 200 cm /Im0 Do Q"); + let before = regression_rects(&doc); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(before, regression_rects(&reopen(&doc)), "other content moved"); +} + +/// Cropping a Form XObject to 100×100 keeps a 100×100 visible area (or is refused) — never 1×1. +#[test] +fn crop_form_xobject_keeps_requested_area() { + let mut doc = regression_page("q 1 0 0 1 100 100 cm /Fm0 Do Q"); + if crop(&mut doc, 0, 0, [100.0, 100.0, 200.0, 200.0]).is_err() { + return; // refusing to crop vector art is also acceptable + } + let after = images::page_images(&reopen(&doc), 0).unwrap()[0].clone(); + let v = after.visible_rect.unwrap_or(after.rect); + assert!((v[2] - v[0] - 100.0).abs() < 1.0 && (v[3] - v[1] - 100.0).abs() < 1.0, "expected 100x100 visible, got {v:?}"); +} + +/// A gs naming a missing ExtGState must not be read as 50% opacity and block the reorder. +#[test] +fn arrange_tolerates_missing_extgstate() { + let mut doc = regression_page("q /GSmissing gs 100 0 0 100 50 50 cm /Im0 Do Q /GSone gs q 10 0 0 10 200 200 cm /Im0 Do Q"); + let r = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(r.is_ok(), "{r:?}"); +} + +/// Bring to front on the image already in front must not report a change it didn't make. +#[test] +fn noop_arrange_is_not_ok_without_change() { + let mut doc = regression_page("q 10 0 0 10 200 200 cm /Im0 Do Q q 100 0 0 100 50 50 cm /Im0 Do Q"); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let r = arrange(&mut doc, 0, 1, images::ArrangeOrder::BringToFront); + let after = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(r.is_err() || before != after, "Ok with no change"); +} + +/// Cropping a turned image keeps the requested upright box in user space, rather than expanding +/// to the bounding box of the turned image coordinates. +#[test] +fn crop_turned_image_preserves_requested_area() { + let mut doc = regression_page("q 70.71 70.71 -70.71 70.71 300 100 cm /Im0 Do Q"); + let req = [270.0, 150.0, 330.0, 200.0]; + crop(&mut doc, 0, 0, req).expect("crop succeeds"); + let after = &images::page_images(&reopen(&doc), 0).unwrap()[0]; + let v = after.visible_rect.expect("visible rect exists"); + assert!((v[0] - req[0]).abs() < 1.0, "x0: expected {}, got {}", req[0], v[0]); + assert!((v[1] - req[1]).abs() < 1.0, "y0: expected {}, got {}", req[1], v[1]); + assert!((v[2] - req[2]).abs() < 1.0, "x1: expected {}, got {}", req[2], v[2]); + assert!((v[3] - req[3]).abs() < 1.0, "y1: expected {}, got {}", req[3], v[3]); +} + /// Text set larger than the widest page PDF allows (14 400 pt) panicked when its paragraph was given /// a width: the width is clamped between the font size and 14 400 pt, and `f64::clamp` panics when /// its minimum is the larger. The width is now capped at 14 400 pt whatever the font size: from the @@ -7758,6 +9053,122 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() { assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content"); } +#[test] +fn arrange_preserves_clip_leaked_out_of_marked_unit() { + let content = "/Figure <> BDC 0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q EMC q 10 0 0 10 400 400 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert!(initial[0].visible_rect.is_some()); + assert!(initial[1].visible_rect.is_none(), "second image is fully clipped initially"); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2); + let img1 = after.iter().find(|img| (img.rect[0] - 400.0).abs() < 1e-3).expect("second image exists"); + assert!(img1.visible_rect.is_none(), "second image must stay clipped after BringToFront"); + + // Conflicting leaking transform with leaking clip must be refused + let content_cm_clip = + "/Figure <> BDC 2 0 0 2 0 0 cm 0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q EMC q 10 0 0 10 400 400 cm /Im0 Do Q"; + let mut doc3 = image_page_with(content_cm_clip); + let res3 = arrange(&mut doc3, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res3.is_err(), "moving marked unit with both leaking cm and leaking clip must be refused"); +} + +#[test] +fn arrange_preserves_leaked_color_and_text_state() { + let content = "/Figure <> BDC /DeviceRGB cs 1 0 0 sc /F1 14 Tf 2 Tc q 10 0 0 10 50 50 cm /Im0 Do Q EMC 0 0 10 10 re f q 10 0 0 10 200 200 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("1 0 0 sc"), "sc must be preserved in content stream: {s}"); + assert!(s.contains("/F1 14 Tf"), "Tf must be preserved in content stream: {s}"); + assert!(s.contains("2 Tc"), "Tc must be preserved in content stream: {s}"); + + // Pixel test: 0 0 10 10 re f still fills red [255, 0, 0] after arrange + let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes), pdfcraft_render::RenderConfig::default()); + let page = + renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let idx = (((page.height - 5) * page.width + 5) * 4) as usize; + assert_eq!(page.rgba[idx..idx + 3], [255, 0, 0], "point (5, 5 from bottom) inside 10x10 rect fills red"); +} + +#[test] +fn arrange_preserves_inherited_drawing_state_for_stencil_and_form() { + // 1. Stencil mask test: + // Page has: q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET + // Non-overlapping stencil and text. + let content = "q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"; + let mut doc = stencil_page_with(content); + + // Render before move: + let pdf_before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_before), pdfcraft_render::RenderConfig::default()); + let page_before = renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let stencil_pixel_idx = (((page_before.height - 75) * page_before.width + 75) * 4) as usize; + assert_eq!(page_before.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil starts red"); + + // Bring to front + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + // Save and reopen + let doc = reopen(&doc); + + // Verify content stream has inherited color + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("1 0 0 rg"), "inherited 1 0 0 rg must be replayed in moved unit: {s}"); + + // Render after reopen and verify stencil remains red: + let pdf_after = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_after), pdfcraft_render::RenderConfig::default()); + let page_after = renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert_eq!(page_after.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil stays red after BringToFront and reopen"); + + // 2. Form XObject test: + // Page has: q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET + let form_content = "q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"; + let mut form_doc = form_page_with(form_content); + + let form_pdf_before = write_incremental(&form_doc, &SaveOptions::default()).unwrap(); + let mut form_renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_before), pdfcraft_render::RenderConfig::default()); + let form_page_before = form_renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let form_pixel_idx = (((form_page_before.height - 75) * form_page_before.width + 75) * 4) as usize; + assert_eq!(form_page_before.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form starts green"); + + arrange(&mut form_doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let form_doc = reopen(&form_doc); + + let form_pdf_after = write_incremental(&form_doc, &SaveOptions::default()).unwrap(); + let mut form_renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_after), pdfcraft_render::RenderConfig::default()); + let form_page_after = form_renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert_eq!(form_page_after.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form stays green after BringToFront and reopen"); +} /// Existing-row alignment is explicitly refused without mutating the paragraph. #[test] fn one_row_of_a_paragraph_takes_its_own_alignment_is_refused_without_changing_the_document() { @@ -8500,6 +9911,247 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); } +#[test] +fn image_exclusive_scope_resets_path_on_scope_exit() { + // An earlier scope has a path operation closed by Q (with 'h' closepath). + // The subsequent image in its own scope must cleanly extract and allow crop removal. + let content = "q 0 0 m 10 10 l h Q q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + crop(&mut doc, 0, 0, [100.0, 100.0, 250.0, 180.0]).unwrap(); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + remove_crop(&mut doc, 0, 0).unwrap(); +} + +#[test] +fn content_units_does_not_swallow_subsequent_units_on_standalone_gs() { + // /GS0 gs followed by Q and subsequent images must isolate each Do unit properly + let content = "q /GS0 gs Q q 200 0 0 100 100 100 cm /Im0 Do Q q 10 0 0 10 300 300 cm /Im0 Do Q"; + let pieces = pdfcraft_content::Pieces::join(&[content.as_bytes()]); + let ops = pieces.parse().ops; + let units = images::content_units(&ops, 0, ops.len()); + assert_eq!(units.len(), 2); +} + +/// A 1 × 1 grey inline image whose one sample is `data`. +fn inline_image(data: char) -> String { + format!("BI /W 1 /H 1 /CS /G /BPC 8 ID {data} EI") +} + +/// What page 0 paints, in order: `BI x` for an inline image with sample `x`, `Do x,y` for an +/// XObject placed by a `cm` translating to x,y, and `Tj` for text. A `cm` lasts until its `Q`, so an +/// XObject moved out of its `q`/`Q` wrapper shows as a bare `Do `. +fn painted(doc: &Document) -> Vec { + let mut at = vec![String::new()]; + pdfcraft_content::parse(&page_content_bytes(doc, 0)) + .ops + .iter() + .filter_map(|op| match op.op.as_slice() { + b"q" => { + at.push(at.last().unwrap().clone()); + None + } + b"Q" => { + at.pop(); + None + } + b"cm" => { + let m = op.nums::<6>().unwrap(); + *at.last_mut().unwrap() = format!("{},{}", m[4], m[5]); + None + } + b"Do" => Some(format!("Do {}", at.last().unwrap())), + b"BI" => Some(format!("BI {}", String::from_utf8_lossy(&op.inline.as_ref().unwrap().1))), + b"Tj" => Some("Tj".into()), + _ => None, + }) + .collect() +} + +/// The index in [`images::page_images`] of the image whose box starts at `x`,`y`. +fn image_at(doc: &Document, x: f64, y: f64) -> usize { + images::page_images(doc, 0).unwrap().iter().position(|i| (i.rect[0] - x).abs() < 1e-6 && (i.rect[1] - y).abs() < 1e-6).unwrap() +} + +#[test] +fn content_units_keep_each_top_level_inline_image_to_itself() { + // The parser emits a whole inline image as one `BI` op; its unit must not run on to the end + // (#3146). Inline images at the start, side by side and at the end are one unit each. + let content = format!( + "{} q 200 0 0 100 100 100 cm /Im0 Do Q {} {} q 10 0 0 10 300 300 cm /Im0 Do Q {}", + inline_image('a'), + inline_image('b'), + inline_image('c'), + inline_image('d') + ); + let ops = pdfcraft_content::parse(content.as_bytes()).ops; + let units = images::content_units(&ops, 0, ops.len()); + assert_eq!(units, [0..1, 1..5, 5..6, 6..7, 7..11, 11..12]); +} + +#[test] +fn content_units_keep_a_nested_inline_image_inside_its_enclosing_unit() { + // A `BI` inside `q`/`Q` or a marked-content sequence stays part of that sequence's unit. + let content = format!("q {} Q /Span << /MCID 0 >> BDC {} EMC {}", inline_image('a'), inline_image('b'), inline_image('c')); + let ops = pdfcraft_content::parse(content.as_bytes()).ops; + let units = images::content_units(&ops, 0, ops.len()); + assert_eq!(units, [0..3, 3..6, 6..7]); +} + +#[test] +fn send_backward_after_a_top_level_inline_image_moves_one_step() { + // Before #3146 the inline image's unit swallowed everything after it, so SendBackward on a later + // image acted like SendToBack: it must step behind the text only. + let content = format!( + "{} q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q", + inline_image('x') + ); + let mut doc = image_page_with(&content); + let front = image_at(&doc, 200.0, 200.0); + arrange(&mut doc, 0, front, images::ArrangeOrder::SendBackward).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted(&doc), ["BI x", "Do 100,100", "Do 200,200", "Tj"]); +} + +#[test] +fn arrange_steps_an_image_past_one_inline_image_at_a_time() { + // Before #3146 BringForward on B, inside the first inline image's unit, put it in front of + // everything. One step forward is past the next inline image only, and one step back again + // is behind that inline image only. + let content = format!( + "q 100 0 0 100 100 100 cm /Im0 Do Q {} q 150 0 0 150 200 200 cm /Im0 Do Q {} BT /F1 12 Tf 72 700 Td (Text) Tj ET q 50 0 0 50 300 300 cm /Im0 Do Q", + inline_image('a'), + inline_image('b') + ); + let mut doc = image_page_with(&content); + let b = image_at(&doc, 200.0, 200.0); + arrange(&mut doc, 0, b, images::ArrangeOrder::BringForward).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(painted(&doc), ["Do 100,100", "BI a", "BI b", "Do 200,200", "Tj", "Do 300,300"]); + let b = image_at(&doc, 200.0, 200.0); + arrange(&mut doc, 0, b, images::ArrangeOrder::SendBackward).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted(&doc), ["Do 100,100", "BI a", "Do 200,200", "BI b", "Tj", "Do 300,300"]); +} + +#[test] +fn arrange_around_inline_images_at_the_ends_of_the_stream() { + // Before #3146 BringToFront on the last image after an inline image "succeeded" without moving + // it. It is already in front, so it is refused and nothing changes. + let content = + format!("{} q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q", inline_image('a')); + let mut doc = image_page_with(&content); + let before = page_content_bytes(&doc, 0); + let front = image_at(&doc, 200.0, 200.0); + assert!(arrange(&mut doc, 0, front, images::ArrangeOrder::BringToFront).is_err()); + assert!(arrange(&mut doc, 0, front, images::ArrangeOrder::BringForward).is_err()); + assert_eq!(page_content_bytes(&doc, 0), before); + // Send to back goes behind the inline image that starts the stream. + arrange(&mut doc, 0, front, images::ArrangeOrder::SendToBack).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted(&doc), ["Do 200,200", "BI a", "Do 100,100", "Tj"]); + + // Bring to front goes past the inline image that ends the stream. + let content = format!("q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Text) Tj ET {}", inline_image('z')); + let mut doc = image_page_with(&content); + let back = image_at(&doc, 100.0, 100.0); + arrange(&mut doc, 0, back, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted(&doc), ["Tj", "BI z", "Do 100,100"]); +} + +#[test] +fn different_inline_soft_masks_have_distinct_identities() { + let doc = Document::new_empty(); + let mut dict1 = Dict::default(); + let mut mask1 = Dict::default(); + mask1.set(b"Type".to_vec(), Object::Name(b"Mask1".to_vec())); + dict1.set(b"SMask".to_vec(), Object::Dict(mask1)); + + let mut dict2 = Dict::default(); + let mut mask2 = Dict::default(); + mask2.set(b"Type".to_vec(), Object::Name(b"Mask2".to_vec())); + dict2.set(b"SMask".to_vec(), Object::Dict(mask2)); + + let mut state1 = images::EffectiveGState::default(); + state1.merge_dict(&dict1, &doc, pdfcraft_content::Matrix::IDENTITY); + + let mut state2 = images::EffectiveGState::default(); + state2.merge_dict(&dict2, &doc, pdfcraft_content::Matrix::IDENTITY); + + assert!(state1.visually_differs(&state2), "two different inline soft masks must visually differ"); +} + +#[test] +fn is_entire_page_wrapped_with_nested_scopes() { + let wrapped = "q 1 0 0 1 0 0 cm q 0 0 10 10 re f Q 200 0 0 100 100 100 cm /Im0 Do Q"; + let pieces = pdfcraft_content::Pieces::join(&[wrapped.as_bytes()]); + let ops = pieces.parse().ops; + assert!(images::is_entire_page_wrapped(&ops), "page wrapped in outer q...Q with inner nested q...Q is wrapped"); + + let unwrapped = "q 1 0 0 1 0 0 cm Q q 200 0 0 100 100 100 cm /Im0 Do Q"; + let pieces2 = pdfcraft_content::Pieces::join(&[unwrapped.as_bytes()]); + let ops2 = pieces2.parse().ops; + assert!(!images::is_entire_page_wrapped(&ops2), "page with two separate top-level q...Q scopes is not wrapped"); +} + +#[test] +fn hostile_page_with_many_gs_and_nested_q_does_not_hang_or_oom() { + use std::fmt::Write as _; + let mut stream = String::new(); + for i in 0..10_000 { + let _ = write!(stream, "/G{i} gs "); + } + for _ in 0..500 { + stream.push_str("q "); + } + stream.push_str("100 0 0 100 0 0 cm /Im0 Do "); + for _ in 0..500 { + stream.push_str("Q "); + } + let doc = image_page_with(&stream); + let start = std::time::Instant::now(); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 1); + assert!(start.elapsed().as_secs() < 5, "page_images on hostile 10k gs must finish in under 5s, took {:?}", start.elapsed()); +} + +#[test] +fn crop_image_with_leading_cm_in_scope_places_clip_before_internal_cm() { + let content = "q 10 0 0 10 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + crop(&mut doc, 0, 0, [50.0, 50.0, 55.0, 55.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert!(after[0].visible_rect.is_some()); + let (v0, v1, v2, v3) = + (after[0].visible_rect.unwrap()[0], after[0].visible_rect.unwrap()[1], after[0].visible_rect.unwrap()[2], after[0].visible_rect.unwrap()[3]); + assert!((v0 - 50.0).abs() < 1e-3 && (v2 - 55.0).abs() < 1e-3, "visible rect must match crop coordinates: {:?}", after[0].visible_rect); + assert!((v1 - 50.0).abs() < 1e-3 && (v3 - 55.0).abs() < 1e-3, "visible rect must match crop coordinates: {:?}", after[0].visible_rect); +} + +#[test] +fn remove_crop_preserves_cm_before_extgstate() { + let content = "q 2 0 0 2 0 0 cm /GS0 gs 0 0 100 100 re W n 10 0 0 10 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + crop(&mut doc, 0, 0, [100.0, 100.0, 110.0, 110.0]).unwrap(); + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("W n"), "the author clipping path must remain: {s}"); + assert!(!s.contains("PdfCraftImageCrop"), "the editor crop must be removed: {s}"); + // Verify that the initial cm appears before /GS0 gs + let cm_pos = s.find("2 0 0 2 0 0 cm").expect("cm must be preserved"); + let gs_pos = s.find("/GS0 gs").expect("gs must be preserved"); + assert!(cm_pos < gs_pos, "cm must precede /GS0 gs so soft mask space is preserved: {s}"); +} + /// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2 /// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the /// text after `Q` is found, and stays, where it is drawn. @@ -8576,3 +10228,363 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning() assert_eq!(page_content_bytes(&doc, 0), before); assert_eq!(page_content_bytes(&reopen(&doc), 0), before); } + +#[test] +fn image_arrange_refuses_crossed_scope_boundaries_without_changes() { + for content in ["/P <> BDC q 10 0 0 10 0 0 cm /Im0 Do EMC Q /Im0 Do", "q /P <> BDC /Im0 Do Q EMC /Im0 Do"] { + let mut doc = image_page_with(content); + let before = page_content_bytes(&doc, 0); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err(), "{content}"); + assert_eq!(page_content_bytes(&doc, 0), before); + } +} + +#[test] +fn removing_a_crop_preserves_author_clips_and_works_under_outer_clip() { + for content in ["0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q", "q 50 50 8 8 re W n 10 0 0 10 50 50 cm /Im0 Do Q"] { + let mut doc = image_page_with(content); + let original = images::page_images(&doc, 0).unwrap()[0].clone(); + crop(&mut doc, 0, 0, [52.0, 52.0, 55.0, 55.0]).unwrap(); + let mut doc = reopen(&doc); + remove_crop(&mut doc, 0, 0).unwrap(); + let mut doc = reopen(&doc); + let restored = images::page_images(&doc, 0).unwrap()[0].clone(); + assert_eq!(restored.rect, original.rect); + assert_eq!(restored.visible_rect, original.visible_rect); + assert_eq!(restored.contains([59.0, 59.0]), original.contains([59.0, 59.0])); + assert!(remove_crop(&mut doc, 0, 0).is_err()); + } +} + +#[test] +fn remove_crop_refuses_an_unmarked_author_clip_without_changes() { + let mut doc = image_page_with("q 50 50 8 8 re W n 10 0 0 10 50 50 cm /Im0 Do Q"); + let before = page_content_bytes(&doc, 0); + assert!(remove_crop(&mut doc, 0, 0).is_err()); + assert_eq!(page_content_bytes(&doc, 0), before); +} + +#[test] +fn arrange_refuses_unrestorable_extended_print_state() { + let mut unexpectedly_moved = Vec::new(); + for (key, value) in [ + ("TR", Object::name("Identity")), + ("TR2", Object::name("Default")), + ("OP", Object::Bool(true)), + ("op", Object::Bool(true)), + ("OPM", Object::Int(1)), + ("BG", Object::name("Default")), + ("BG2", Object::name("Default")), + ("UCR", Object::name("Default")), + ("UCR2", Object::name("Default")), + ("HT", Object::name("Default")), + ] { + let mut doc = image_page_with("q 100 0 0 100 50 50 cm /Im0 Do Q /Print gs BT /F1 12 Tf 20 700 Td (Text) Tj ET"); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + resources.set( + b"ExtGState".to_vec(), + Object::Dict(Dict::from_iter([(b"Print".to_vec(), Object::Dict(Dict::from_iter([(key.as_bytes().to_vec(), value)])))])), + ); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let before = page_content_bytes(&doc, 0); + match arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront) { + Ok(()) => unexpectedly_moved.push(key), + Err(error) => { + assert!(error.to_string().contains("graphics state"), "{key}: {error}"); + assert_eq!(page_content_bytes(&doc, 0), before, "{key} refusal changes nothing"); + } + } + } + assert!(unexpectedly_moved.is_empty(), "unrestorable destination state was accepted: {unexpectedly_moved:?}"); +} + +#[test] +fn arrange_keeps_implicit_device_spaces_before_sc_and_scn() { + for (select, color) in [("0 g", "0.5 sc"), ("0 0 0 rg", "1 0 0 sc"), ("0 0 0 1 k", "0 1 1 0 scn")] { + for stroke in [false, true] { + let (select, color) = if stroke { (select.to_uppercase(), color.to_uppercase()) } else { (select.into(), color.into()) }; + for content in [ + format!("q {select} {color} q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"), + format!("q {select} q {color} 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"), + ] { + let mut doc = stencil_page_with(&content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let ops = pdfcraft_content::parse(&bytes).ops; + let do_index = ops.iter().rposition(|op| op.is("Do")).unwrap(); + let start = ops[..do_index].iter().rposition(|op| op.is("q")).unwrap(); + let relocated = &ops[start..do_index]; + let selector = select.split_whitespace().last().unwrap(); + let color_op = color.split_whitespace().last().unwrap(); + let space_index = relocated.iter().position(|op| op.is(selector)); + let color_index = relocated.iter().position(|op| op.is(color_op)); + assert!( + space_index.is_some() && space_index < color_index, + "{select} must select its device space before {color}: {}", + String::from_utf8_lossy(&bytes) + ); + } + } + } +} + +#[test] +fn arrange_restores_initial_rendering_intent_from_ri_and_gs() { + for selection in ["/Saturation ri", "/Print gs"] { + let mut doc = image_page_with(&format!("q 100 0 0 100 50 50 cm /Im0 Do Q {selection} BT /F1 12 Tf 20 700 Td (Text) Tj ET")); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + resources.set( + b"ExtGState".to_vec(), + Object::Dict(Dict::from_iter([(b"Print".to_vec(), Object::Dict(Dict::from_iter([(b"RI".to_vec(), Object::name("Saturation"))])))])), + ); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + assert!( + String::from_utf8_lossy(&bytes).contains("/RelativeColorimetric ri"), + "default rendering intent must be restored after {selection}: {}", + String::from_utf8_lossy(&bytes) + ); + } +} + +#[test] +fn editor_crop_moves_with_repeated_group_translation() { + let mut doc = image_page(); + let caption_rect = text::text_lines(&doc, 0).unwrap()[0].rect; + crop(&mut doc, 0, 0, [150.0, 120.0, 250.0, 170.0]).unwrap(); + let start = images::page_images(&doc, 0).unwrap()[0].clone(); + let mut count = 0; + for iteration in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + if iteration == 0 { + count = ops.len(); + } + assert_eq!(ops.len(), count, "repeated moves fold the same transform"); + } + let shifted = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0]; + let doc = reopen(&doc); + let after = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(after.rect, shifted(start.rect))); + assert!( + close(after.visible_rect.unwrap(), shifted(start.visible_rect.unwrap())), + "the editor crop moves with its pixels: {:?}", + after.visible_rect + ); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].rect, caption_rect); +} + +#[test] +fn arranging_original_image_keeps_it_independent_of_added_items() { + for update in [false, true] { + let mut doc = image_page(); + let caption_rect = text::text_lines(&doc, 0).unwrap()[0].rect; + add_content( + &mut doc, + 0, + &Content::Text(AddedText { rect: [20.0, 250.0, 250.0, 290.0], text: "Added caption".into(), ..AddedText::default() }), + ) + .unwrap(); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(list_added(&doc).len(), 1, "the added item stays editable"); + if update { + update_content( + &mut doc, + 0, + 0, + &Content::Text(AddedText { rect: [20.0, 250.0, 250.0, 290.0], text: "Updated caption".into(), ..AddedText::default() }), + ) + .unwrap(); + } else { + delete_content(&mut doc, 0, 0).unwrap(); + } + let doc = reopen(&doc); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "editing/deleting an added item must preserve arranged original artwork"); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].rect, [100.0, 100.0, 300.0, 200.0]); + let text = text::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect::>(); + assert!(text.iter().any(|line| line == "Caption"), "the original caption survives"); + assert_eq!(text.iter().any(|line| line == "Updated caption"), update); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().find(|line| line.text == "Caption").unwrap().rect, caption_rect); + } +} + +#[test] +fn arranging_nested_marked_image_refuses_without_losing_tags() { + for marked in ["/Figure <> BDC", "/Figure BMC"] { + let mut doc = image_page_with(&format!( + "q {marked} 100 0 0 100 50 50 cm /Im0 Do EMC BT /F1 12 Tf 20 700 Td (Text) Tj ET Q BT /F1 12 Tf 20 720 Td (Outside) Tj ET" + )); + let before = page_content_bytes(&doc, 0); + let error = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap_err(); + assert!(error.to_string().contains("marked-content"), "{error}"); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +#[test] +fn editor_crop_group_move_keeps_an_outer_author_clip_fixed() { + let mut doc = image_page_with("0 0 220 300 re W n q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 10 250 Td (Caption) Tj ET"); + crop(&mut doc, 0, 0, [150.0, 120.0, 210.0, 170.0]).unwrap(); + let mut operators = 0; + for i in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + let count = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops.len(); + if i == 0 { + operators = count; + } + assert_eq!(count, operators, "moves under an inherited clip must fold their transform"); + } + let doc = reopen(&doc); + let image = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(image.rect, [120.0, 60.0, 320.0, 160.0])); + assert!( + close(image.visible_rect.unwrap(), [170.0, 80.0, 220.0, 130.0]), + "the editor crop moves but its author's outer clip stays fixed: {:?}", + image.visible_rect + ); + let bytes = page_content_bytes(&doc, 0); + assert!(String::from_utf8_lossy(&bytes).starts_with("0 0 220 300 re W n")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption"); +} + +fn review_state_raster(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default()); + renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }).rgba.to_vec() +} + +#[test] +fn review_arrange_form_keeps_inherited_text_spacing() { + for (spacing, later_spacing) in [(0, false), (12, false), (0, true)] { + let initial = if later_spacing { String::new() } else { format!("BT {spacing} Tc ET") }; + let later = if later_spacing { "BT 12 Tc ET" } else { "" }; + let content = format!("q {initial} q 1 0 0 1 20 20 cm /Fm0 Do Q Q {later} q 0 0 1 rg 200 200 5 5 re f Q"); + let mut doc = form_page_with(&content); + let form = pdfcraft_cos::ObjRef::new(6, 0); + let dict = doc.get(form).as_dict().unwrap().clone(); + doc.set(form, Object::Stream(pdfcraft_cos::Stream::from_raw(dict, b"BT /F1 20 Tf 0 15 Td (ABC) Tj ET".to_vec()))); + let doc_before = reopen(&doc); + let before = review_state_raster(&doc_before); + assert!(before.as_chunks::<4>().0.iter().any(|p| p[0] == 0 && p[1] == 0 && p[2] == 0), "the Form actually paints text"); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let saved = reopen(&doc); + assert_eq!(images::page_images(&saved, 0).unwrap()[0].rect, images::page_images(&doc_before, 0).unwrap()[0].rect); + assert_eq!(review_state_raster(&saved), before, "inherited {spacing} Tc must survive arranging the Form and reopening"); + } +} + +fn review_shared_mask_page(states: &str, luminosity_b: bool, mask_paint: &str) -> Document { + let content = format!("q {states} q 100 0 0 100 20 20 cm /Im0 Do Q Q q 0 0 1 rg 200 200 5 5 re f Q"); + let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let subtype = if luminosity_b { "Luminosity" } else { "Alpha" }; + let mask_b = format!("<< /Type /ExtGState /SMask << /S /{subtype} /G 8 0 R >> >>"); + let group = format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] /Group << /S /Transparency /I true /CS /DeviceGray >> /Length {} >>\nstream\n{mask_paint}\nendstream", + mask_paint.len() + ); + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> /ExtGState << /A 6 0 R /B 7 0 R >> >> >>", + &stream, + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\0\0\0\nendstream", + "<< /Type /ExtGState /SMask << /S /Alpha /G 8 0 R >> >>", + &mask_b, + &group, + ]) +} + +#[test] +fn review_repeated_gs_keeps_alpha_vs_luminosity_mask_chronology() { + for states in ["/A gs", "/A gs /B gs", "/A gs /B gs /A gs"] { + let mut doc = review_shared_mask_page(states, true, "0.25 g 0 0 300 300 re f"); + let before = review_state_raster(&reopen(&doc)); + assert!(before.as_chunks::<4>().0.iter().any(|p| p[0] == p[1] && p[1] == p[2] && p[0] < 230), "the masked image is visible"); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(review_state_raster(&reopen(&doc)), before, "mask application order must survive: {states}"); + } +} + +#[test] +fn review_repeated_gs_keeps_last_mask_establishment_ctm() { + for states in [ + "1 0 0 1 50 0 cm /A gs 1 0 0 1 -50 0 cm", + "/A gs 1 0 0 1 50 0 cm /B gs 1 0 0 1 -50 0 cm", + "/A gs 1 0 0 1 50 0 cm /B gs 1 0 0 1 -50 0 cm /A gs", + ] { + let mut doc = review_shared_mask_page(states, false, "0.25 g 0 0 60 300 re f"); + let before = review_state_raster(&reopen(&doc)); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(review_state_raster(&reopen(&doc)), before, "mask CTM must survive: {states}"); + } +} + +#[test] +fn review_soft_masks_sharing_a_group_keep_distinct_configurations() { + let doc = Document::new_empty(); + let mask = |subtype: &str| { + Dict::from_iter([( + b"SMask".to_vec(), + Object::Dict(Dict::from_iter([(b"S".to_vec(), Object::name(subtype)), (b"G".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))])), + )]) + }; + let mut alpha = images::EffectiveGState::default(); + let mut luminosity = images::EffectiveGState::default(); + alpha.merge_dict(&mask("Alpha"), &doc, pdfcraft_content::Matrix::IDENTITY); + luminosity.merge_dict(&mask("Luminosity"), &doc, pdfcraft_content::Matrix::IDENTITY); + assert!(alpha.visually_differs(&luminosity), "a shared group does not make Alpha and Luminosity the same mask"); + for (key, value) in [ + (b"BC".to_vec(), Object::Array(vec![Object::Real(0.5)])), + ( + b"TR".to_vec(), + Object::Dict(Dict::from_iter([ + (b"FunctionType".to_vec(), Object::Int(2)), + (b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])), + (b"N".to_vec(), Object::Int(2)), + ])), + ), + ] { + let mut changed = mask("Luminosity"); + let Object::Dict(definition) = changed.get_mut(b"SMask").unwrap() else { panic!("mask dictionary") }; + definition.set(key, value); + let mut state = images::EffectiveGState::default(); + state.merge_dict(&changed, &doc, pdfcraft_content::Matrix::IDENTITY); + assert!(state.visually_differs(&luminosity), "mask backdrop and transfer function belong to its definition"); + } +} + +#[test] +fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { + for scale in [1, 2] { + let content = format!("{scale} 0 0 {scale} 0 0 cm q 40 0 0 40 10 10 cm /Im0 Do Q q 0 0 1 rg 75 75 5 5 re f Q"); + let mut cropped = image_page_with(&content); + let keep = if scale == 2 { [30.0, 30.0, 70.0, 70.0] } else { [15.0, 15.0, 35.0, 35.0] }; + crop(&mut cropped, 0, 0, keep).unwrap(); + arrange(&mut cropped, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + for offset in [[10.0, 0.0], [0.0, 10.0], [-10.0, 3.0]] { + let mut grouped = reopen(&cropped); + let mut direct = reopen(&cropped); + let before = images::page_images(&grouped, 0).unwrap()[0].clone(); + images::translate_images(&mut grouped, 0, &[0], offset).unwrap(); + images::change_image(&mut direct, 0, 0, &images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]])).unwrap(); + let moved = |r: [f64; 4]| [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]; + let saved = reopen(&grouped); + let image = &images::page_images(&saved, 0).unwrap()[0]; + assert!(close(image.rect, moved(before.rect)), "scale {scale} offset {offset:?}: {:?}", image.rect); + assert!(close(image.visible_rect.unwrap(), moved(before.visible_rect.unwrap())), "the crop follows the complete displacement"); + assert_eq!( + review_state_raster(&saved), + review_state_raster(&reopen(&direct)), + "group and direct transforms must agree, preserving other artwork" + ); + } + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 98824c7694..0fb00324b6 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -24,6 +24,7 @@ use crate::EditError; mod regions; #[path = "text_session.rs"] mod session; +pub use fallback::strip_subset_tag; use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block}; pub use session::TextSession; @@ -47,13 +48,13 @@ pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_l pub(crate) use fallback::SOURCE_SPACE_CALLS; use fallback::{ MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes, - source_family, source_program, source_show, source_space, source_width, strip_subset_tag, type3_faces, type3_glyph, write_type3_glyphs, + source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs, }; #[cfg(test)] use fallback::{add_type3_font, is_type3_fallback}; use interp::{Origin, Paint, union}; use lines::page_dict; -pub(crate) use lines::{FormBudget, content_streams, content_streams_within, form_call, rewritten_contents, splice}; +pub(crate) use lines::{FormBudget, content_streams, content_streams_within, dict_in_place, form_call, resource_dict, rewritten_contents, splice}; pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; pub use rewrite::{LineEdit, replace_line, rewrite_block}; use rewrite::{coincident_ops, rewrite_block_from}; diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 9ddf4db4b1..48fe956f4f 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -31,7 +31,8 @@ pub struct TextBlock { /// Byte ranges in `editor_text` and their source line indexes. pub spans: Vec<(std::ops::Range, usize)>, /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose - /// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]). + /// pixels show the words. A rewrite draws the new text visibly, on top of the page when + /// the scan is drawn after it (see [`BlockStyle::cover`]). pub invisible: bool, } diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index b8f45a221f..78f9014a20 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -163,7 +163,7 @@ pub(crate) fn same_base_font(a: &str, b: &str) -> bool { } /// `ABCDEF+Arial` → `Arial`. A name without a subset tag is returned unchanged. -pub(crate) fn strip_subset_tag(name: &str) -> &str { +pub fn strip_subset_tag(name: &str) -> &str { let bytes = name.as_bytes(); let is_tag = bytes.get(6) == Some(&b'+') && bytes.get(..6).is_some_and(|tag| tag.iter().all(u8::is_ascii_uppercase)); if is_tag { name.get(7..).unwrap_or(name) } else { name } diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 2b55e51ef8..5114a76263 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -18,13 +18,33 @@ pub(super) struct TextState { /// graphics state the text was painted with that [`TextState::ops`] doesn't restate. pub(super) stroke: Vec, pub(super) line_width: Option, - ext_gstate: Vec>, + ext_gstate: Vec, /// The dash pattern and line cap in force (see [`Ts`]): compared, not restated. pub(super) dash: Dash, pub(super) line_cap: i64, } impl TextState { + /// The state a page's content starts in (ISO 32000-2 §8.4.1, §9.3.1): what is in force on + /// top of the page, after its content wrapped in `q`/`Q`. + pub(crate) fn initial() -> Self { + TextState { + font: None, + char_spacing: 0.0, + word_spacing: 0.0, + scale: 1.0, + leading: 0.0, + rise: 0.0, + fill: Vec::new(), + render_mode: 0, + stroke: Vec::new(), + line_width: None, + ext_gstate: Vec::new(), + dash: (Vec::new(), 0.0), + line_cap: 0, + } + } + pub(super) fn of(ts: &Ts) -> Self { TextState { font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)), @@ -54,13 +74,17 @@ impl TextState { /// /// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force: /// run these inside a `q`/`Q` that starts from `current`, one per state drawn. - pub(crate) fn paint_ops(&self, current: &TextState) -> Vec { + /// + /// `ctm` is the CTM where they run. A soft mask is placed by the CTM its `gs` ran at + /// (ISO 32000-2 §11.6.5.2), so one selected at another CTM is selected there and the CTM + /// set back. + pub(crate) fn paint_ops(&self, current: &TextState, ctm: [f64; 6]) -> Vec { let n = pdfcraft_content::num; let mut v = Vec::new(); // The selections both share stay in force; the ones after them are selected again in order. let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count(); - for name in self.ext_gstate.iter().skip(shared) { - v.push(Op::new("gs", vec![Object::Name(name.clone())])); + for sel in self.ext_gstate.iter().skip(shared) { + sel.push_ops(&mut v, ctm); } // A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it. let reselected = !v.is_empty(); @@ -222,7 +246,7 @@ pub(super) struct Ts { /// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0). pub(super) line_width: Option, /// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]). - pub(super) ext_gstate: Vec>, + pub(super) ext_gstate: Vec, /// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`). pub(super) dash: Dash, /// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt). @@ -255,12 +279,38 @@ const MAX_GS: usize = 32; /// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute /// value, so a later selection of the same dictionary overrides the earlier one completely: the /// earlier entry goes, which keeps replaying the list equivalent to replaying the stream. -fn select_gs(list: &mut Vec>, name: &[u8]) { - list.retain(|n| n != name); +fn select_gs(list: &mut Vec, name: &[u8], anchor: Option<[f64; 6]>) { + list.retain(|s| s.name != name); if list.len() >= MAX_GS { list.remove(0); } - list.push(name.to_vec()); + list.push(GsSelection { name: name.to_vec(), anchor }); +} + +/// One `gs` selection: the ExtGState's resource name, and the CTM it ran at when the dictionary +/// has a soft mask, which that CTM places (ISO 32000-2 §11.6.5.2). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GsSelection { + name: Vec, + anchor: Option<[f64; 6]>, +} + +impl GsSelection { + /// Select this ExtGState from where the CTM is `ctm`: at its anchor, then back to `ctm`. Both + /// moves need an invertible matrix; without one it is selected where it runs. + fn push_ops(&self, v: &mut Vec, ctm: [f64; 6]) { + let gs = Op::new("gs", vec![Object::Name(self.name.clone())]); + let moves = self.anchor.filter(|a| *a != ctm).and_then(|a| { + let (anchor, here) = (Matrix(a), Matrix(ctm)); + Some((anchor.then(&here.invert()?), here.then(&anchor.invert()?))) + }); + let Some((to, back)) = moves else { + v.push(gs); + return; + }; + let cm = |m: Matrix| Op::new("cm", m.0.iter().map(|x| pdfcraft_content::num(*x)).collect()); + v.extend([cm(to), gs, cm(back)]); + } } /// The text-showing operators of one stream with their text, font, box and baseline. @@ -773,8 +823,9 @@ pub(super) fn interpret( // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. logical_text: Option<&Dict>, ) -> Result, EditError> { - let fonts_res = resources.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let states = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let (mut held_fonts, mut held_states, none) = (None, None, Dict::new()); + let fonts_res = super::resource_dict(doc, resources, b"Font", &mut held_fonts).unwrap_or(&none); + let states = super::resource_dict(doc, resources, b"ExtGState", &mut held_states).unwrap_or(&none); let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); @@ -878,11 +929,16 @@ pub(super) fn interpret( b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), b"gs" => { + let mut held = None; + let params = match op.name(0).and_then(|name| states.get(name)) { + Some(p) => super::dict_in_place(doc, p, &mut held), + None => None, + }; if let Some(name) = op.name(0) { - select_gs(&mut ts.ext_gstate, name); + let masked = params.and_then(|p| p.get(b"SMask")).is_some_and(|m| doc.resolve(m).as_dict().is_some()); + select_gs(&mut ts.ext_gstate, name, masked.then_some(ts.ctm.0)); } - let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p)); - if let Some(params) = params.as_deref().and_then(Object::as_dict) { + if let Some(params) = params { ts.gs.apply(doc, params); // `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5). let lw = params.get(b"LW").and_then(|w| doc.resolve(w).as_f64()).filter(|w| w.is_finite()); diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index f811f34ea6..85209a5472 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use super::fallback::{same_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; @@ -452,10 +454,41 @@ impl FormBudget { pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, - pub(crate) resources: Dict, + /// The form, and its `/Resources` when they are an object of their own, which is what + /// [`FormCall::resources`] reads. + form: Arc, + own: Option>, pub(crate) ctm: Matrix, } +impl FormCall { + /// The resources its content is read with: its own, or if it has none, `drawn_with`, the ones + /// it was drawn with (PDF 1.1 files). Read in place, as a page can draw a form thousands of times: + /// a copy for each took time in the square of them. + pub(crate) fn resources<'a>(&'a self, drawn_with: &'a Dict) -> &'a Dict { + let direct = || match &*self.form { + Object::Stream(s) => s.dict.get(b"Resources").and_then(Object::as_dict), + _ => None, + }; + self.own.as_deref().and_then(Object::as_dict).or_else(direct).unwrap_or(drawn_with) + } +} + +/// `o` as a dictionary, read in place for the same reason as [`FormCall::resources`]. `held` keeps it +/// when it is an object of its own. Nothing else is one, and [`Document::resolve`] would copy it. +pub(crate) fn dict_in_place<'a>(doc: &Document, o: &'a Object, held: &'a mut Option>) -> Option<&'a Dict> { + match o { + Object::Dict(dict) => Some(dict), + Object::Ref(_) => held.insert(doc.resolve(o)).as_dict(), + _ => None, + } +} + +/// The dictionary at `key` of `resources`, `/XObject` or `/Font`, say, as each form reads these. +pub(crate) fn resource_dict<'a>(doc: &Document, resources: &'a Dict, key: &[u8], held: &'a mut Option>) -> Option<&'a Dict> { + dict_in_place(doc, resources.get(key)?, held) +} + /// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names /// and forms already being read (`path`), and for forms nested too deep or past the page's /// budget, which `budget` records as truncated; each form entered counts one visit. @@ -472,13 +505,15 @@ pub(crate) fn form_call( if limited && budget.truncated { return None; } - let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; - let o = xobjects.get(name)?; - let obj = o.as_ref(); + let mut held = None; + let o = resource_dict(doc, resources, b"XObject", &mut held)?.get(name)?; + // Only a reference can name a stream, so nothing else needs reading, or copying. + let obj = Some(o.as_ref()?); if obj.is_some_and(|r| path.contains(&r)) { return None; } - let Object::Stream(s) = &*doc.resolve(o) else { return None }; + let form = doc.resolve(o); + let Object::Stream(s) = &*form else { return None }; if s.dict.name(b"Subtype") != Some(b"Form") { return None; } @@ -492,8 +527,7 @@ pub(crate) fn form_call( let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); <[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) }); - // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). - let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let own = s.dict.get(b"Resources").filter(|r| matches!(r, Object::Ref(_))).map(|r| doc.resolve(r)); let data = match budget.decode.decode(s) { Ok(data) => data, Err(_) if budget.decode.remaining() == 0 => { @@ -503,7 +537,7 @@ pub(crate) fn form_call( Err(_) => Vec::new(), }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) + Some(FormCall { obj, data, form, own, ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -522,8 +556,9 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec usize) -> Result<(), EditError> { let mut cache = HashMap::new(); let mut invoked = Vec::new(); - let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); - let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(&properties))?; + let (mut held, none) = (None, Dict::new()); + let properties = resource_dict(w.doc, resources, b"Properties", &mut held).unwrap_or(&none); + let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(properties))?; let mut last = None; let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); @@ -556,7 +591,7 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, writer: &LineWriter<'_>, wrapped: &[(String, let mut ops = vec![Op::new("q", vec![])]; ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); // Moved: a translation inside that state. + let mut ctm = Matrix(plan.emit.ctm); if let Some((ax, ay)) = plan.movement { ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + ctm = Matrix([1.0, 0.0, 0.0, 1.0, ax, ay]).then(&ctm); } // Painted as the paragraph was, including what its text object set before the first show. - ops.extend(plan.state.paint_ops(plan.after)); + ops.extend(plan.state.paint_ops(plan.after, ctm.0)); ops.push(Op::new("BT", vec![])); let mut state = plan.state.clone(); state.word_spacing = 0.0; @@ -816,6 +818,11 @@ pub(crate) fn rewrite_block_from( let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; members.iter().try_for_each(|line| line.ensure_editable())?; let p = page_dict(doc, page)?; + // Revealed OCR text may go on top of the page (see `revealed_under_paint`): a page that can't + // take that is refused here, before either rewrite path changes anything. + if b.invisible { + crate::check_on_top(doc, &p)?; + } let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; @@ -930,9 +937,28 @@ pub(crate) fn rewrite_block_from( char_spacing: o_state.char_spacing, lead, }; - let plan = - BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let at = if split { start } else { emit.bt_op }; + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let widest = wrapped.iter().map(|(l, _, _)| advance.of(l)).fold(width, f64::max); + let reach = crate::Reach { rect: b.rect, offset: [dx, dy], width: widest, rows: wrapped.len(), pitch: lead * k, upright }; + let over = crate::revealed_under_paint(doc, page, b.invisible, reach.bounds(), &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; + let plan = BlockPlan { + block: &b, + lines: &lines, + style, + emit, + after: drawn_from, + movement, + state: o_state, + tm, + size, + underline: style.underline == Some(true), + }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let on_top = crate::lift_on_top(over, &mut block_ops); let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { @@ -940,7 +966,6 @@ pub(crate) fn rewrite_block_from( block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !dropped.contains(&i)) @@ -957,12 +982,14 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; + crate::draw_on_top(doc, &p, emit.ctm, &on_top)?; Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a /// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text. -/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it. +/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one +/// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]). pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { if !block.invisible { return Ok(Vec::new()); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 885a0a7158..7a9204a8c0 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,3 +1,4 @@ +use super::super::interp::TextState; use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, @@ -401,6 +402,16 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) } +/// How far below a row of `size` the next one is written: the source's leading unless a size +/// or line spacing is chosen. +fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 { + if style.line_spacing.is_none() && style.size.is_none() { + source_leading + } else { + style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(size) + } +} + type WrappedStyledRows = (StyledRows, RowRanges, Vec, Vec, Vec); fn wrap_styled_rows( @@ -976,6 +987,7 @@ pub(crate) fn rewrite_styled_block( let o = &lead.origin; // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; + let at = if split { start } else { o.bt_op }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; @@ -989,6 +1001,7 @@ pub(crate) fn rewrite_styled_block( .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + let run_ctm = Matrix([1.0, 0.0, 0.0, 1.0, tx, ty]).then(&Matrix(o.ctm)).0; // A new row can land one leading above the next paragraph and look like the same // paragraph. When every row is in this rewrite, a paragraph marker keeps them in one // scope so the neighbour stays out (#2517). A row left in the source stream cannot share @@ -1007,6 +1020,19 @@ pub(crate) fn rewrite_styled_block( }) .rfind(|v| *v > 0.0) .unwrap_or(first.size * 1.5); + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let largest = + block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max); + // At least one line of the largest glyphs, so a larger range reaches up as far as it draws. + const LINE_EM: f64 = 1.2; + let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * LINE_EM); + let widest = measured.iter().copied().fold(width, f64::max); + // This path lays rows out along user-space baselines, so the paragraph is upright here. + let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; + let over = crate::revealed_under_paint(doc, page, block.invisible, reach.bounds(), &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; let mut baseline = first.origin.baseline; for (row_index, row) in rows.iter().enumerate() { let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); @@ -1073,7 +1099,7 @@ pub(crate) fn rewrite_styled_block( // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); - block_ops.extend(state.paint_ops(after)); + block_ops.extend(state.paint_ops(drawn_from, run_ctm)); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); @@ -1164,21 +1190,17 @@ pub(crate) fn rewrite_styled_block( x += segment_width; pos = end; } - baseline -= if style.line_spacing.is_none() && style.size.is_none() { - source_leading - } else { - style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) - }; + baseline -= row_step(style, anchor.size, source_leading); } push_whole_paragraph_marker(&mut block_ops, marked, "EMC"); block_ops.push(Op::new("Q", vec![])); + let on_top = crate::lift_on_top(over, &mut block_ops); block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; // A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is // safe. The close is not part of `block_ops`: that vector can end inside a text object // when the rewrite splits one, and a kept row's operators come later in the stream. @@ -1190,5 +1212,6 @@ pub(crate) fn rewrite_styled_block( d.set(b"Contents".to_vec(), contents); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + crate::draw_on_top(doc, &p, o.ctm, &on_top)?; Ok(LineEdit { substituted }) } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6dae6adf0a..f5b924c921 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -19,6 +19,7 @@ pdfcraft-annot = { workspace = true } pdfcraft-forms = { workspace = true } pdfcraft-xfa = { workspace = true } pdfcraft-edit = { workspace = true } +pdfcraft-content = { workspace = true } pdfcraft-audit = { workspace = true } pdfcraft-redact = { workspace = true } pdfcraft-print = { workspace = true } @@ -58,3 +59,5 @@ getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] pdfcraft-testkit = { workspace = true } +# JBIG2 fixtures (`encode_jbig2_generic`). +pdfcraft-filters = { workspace = true } diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index f7d11d47ed..3e942f6a2f 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -146,6 +146,11 @@ pub struct FileResult { pub log: Vec, } +/// What an action's log says of the images a Reduce File Size step left, if there are any. +fn images_refused_note(images: usize) -> Option { + (images > 0).then(|| format!("Reduce file size: {images} image(s) stayed as they were, past what one run may decode")) +} + fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> Result, EditError> { let pages = s.get(id).map_or(0, |d| d.info.pages.len()); let all: Vec = (0..pages).collect(); @@ -156,9 +161,10 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } Step::ReduceFileSize => { - let (bytes, _) = s.optimized_bytes(id, &crate::optimize::Settings::default(), &[])?; + let (bytes, report) = s.optimized_bytes(id, &crate::optimize::Settings::default(), &[])?; let before = s.get(id).map_or(0, |d| d.bytes.len()); log.push(format!("Reduce file size: {} → {} bytes", before, bytes.len())); + log.extend(images_refused_note(report.optimize.images_refused)); let name = s.get(id).map(|d| d.name.clone()).unwrap_or_default(); s.close(id); let new = s.open(name, None, bytes, None).map_err(|e| EditError::Reopen(e.to_string()))?; @@ -222,3 +228,16 @@ pub fn run_on(action: &Action, name: &str, bytes: Arc>, mut progress: im let bytes = if d.is_signed() { s.save_bytes(id) } else { s.save_full_bytes(id) }.map_err(|e| e.to_string())?; Ok(FileResult { name: name.to_string(), bytes, log }) } + +#[cfg(test)] +mod tests { + use super::images_refused_note; + + /// A log line only for images that stayed, saying how many. + #[test] + fn the_log_says_when_images_stayed_as_they_were() { + assert_eq!(images_refused_note(0), None); + assert!(images_refused_note(1).is_some_and(|note| note.contains("1 image(s)"))); + assert!(images_refused_note(3).is_some_and(|note| note.contains("3 image(s)"))); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6bfe9fa826..0647726452 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -163,6 +163,8 @@ const HELP: Option<&str> = Some("Help"); /// Every command, in menu order. pub const COMMANDS: &[CommandSpec] = &[ + c("view.select_tool", "Select", None, None, Document, "mouse-pointer-2"), + c("view.hand_tool", "Hand", None, None, Document, "hand"), c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"), // Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item. @@ -259,6 +261,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.underline", "Underline text", None, None, Annotate, "underline"), c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"), c("comment.squiggly", "Squiggly underline text", None, None, Annotate, "spline"), + c("comment.freehand_highlight", "Freehand-Highlight", None, None, Annotate, "highlighter"), c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"), c("comment.line", "Draw a line", None, None, Annotate, "minus"), c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"), diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index eee799ed2f..c8262a7241 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -82,6 +82,7 @@ pub fn comment_type_name(subtype: &str) -> &str { /// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { match a.intent.as_deref() { + Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", Some("FreeTextTypeWriter") => "Typewriter", diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index dca1e24af5..9be1954b09 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -140,6 +140,8 @@ pub enum OfficeFormat { Docx, Pptx, Html, + /// CSS-free HTML that preserves text and inline images but drops colours and layout styling. + HtmlSimple, Rtf, Markdown, } @@ -150,6 +152,7 @@ impl OfficeFormat { OfficeFormat::Docx => "docx", OfficeFormat::Pptx => "pptx", OfficeFormat::Html => "html", + OfficeFormat::HtmlSimple => "html", OfficeFormat::Rtf => "rtf", OfficeFormat::Markdown => "md", } @@ -173,14 +176,15 @@ pub struct OfficeExport { /// The exported file. pub bytes: Vec, /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them - /// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops - /// reading there to stay bounded on hostile files, so the text and images of those forms, or + /// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`] or past what one export + /// may decode: export stops reading there to stay bounded on hostile files, so the text and images of those forms, or /// those pictures, are missing from `bytes`. pub truncated_pages: Vec, } /// An image object prepared for Office export: its file extension and encoded bytes, or why it is -/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded). +/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`], or what the export may decode; +/// `Err(false)`: it can't be encoded). type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>; impl crate::Document { @@ -194,6 +198,18 @@ impl crate::Document { /// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without /// `images` no picture is prepared. pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (Vec, Vec) { + let Some(editor) = self.editor.as_ref() else { return (Vec::new(), Vec::new()) }; + self.export_pages_within(images, image_budget, &mut pdfcraft_cos::ImageBudget::new(&editor.cos)) + } + + /// [`Self::export_pages_checked`], its pictures decoded within `image_work`, which is shared + /// by the whole export: a picture past it is left out like one past `image_budget`. + pub(crate) fn export_pages_within( + &self, + images: bool, + image_budget: usize, + image_work: &mut pdfcraft_cos::ImageBudget, + ) -> (Vec, Vec) { let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; let mut truncated = Vec::new(); // Each image object is encoded once and shared by every place that draws it. @@ -244,7 +260,9 @@ impl crate::Document { .filter_map(|im| { let object = im.object?; let prepared = encoded.entry(object).or_insert_with(|| { - let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?; + let refused = image_work.refused(); + let (ext, bytes) = + pdfcraft_create::image_file_within(cos, object, &mut *image_work).map_err(|_| image_work.refused() > refused)?; image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?; Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes))) }); @@ -286,6 +304,7 @@ impl crate::Document { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), + OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(), }; diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index b377903ee4..53b890f27c 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -18,9 +18,8 @@ impl Document { /// [`Document::page_images`]: a raster image, or Form artwork that holds one. pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option { let editor = self.editor.as_ref()?; - let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]); self.page_images(page).iter().rposition(|i| { - inside(&i.rect) + i.contains(at) && match (i.is_form, i.object) { (false, Some(_)) => true, (true, Some(form)) => picture_in_form(&editor.cos, form).is_some(), diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d28429de6f..91b33ad017 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize}; +use super::{Arc, ArrangeOrder, EditError, Hidden, SignatureInfo, TrustStore, optimize}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -20,6 +20,12 @@ pub enum ImageEdit { bytes: Arc>, }, Delete, + /// Affine transform [a, b, c, d, e, f] applied to the image's matrix. + Transform([f64; 6]), + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, } /// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 8f43689aa9..ea43733401 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -6,7 +6,7 @@ use crate::{ AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; mod labels; @@ -119,6 +119,15 @@ pub enum Edit { bold: Option, italic: Option, }, + /// Write `bookmarks` as children of `parent` from child `index` on, or with `replace` in + /// place of the whole outline (`parent` must then be `[]`). A bookmark whose page this + /// document lacks is kept without a destination (see [`pdfcraft_organize::import_outline`]). + ImportBookmarks { + parent: Vec, + index: usize, + bookmarks: Vec, + replace: bool, + }, /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first /// "Untitled" bookmark (New Bookmarks from Structure). BookmarksFromStructure, @@ -157,6 +166,17 @@ pub enum Edit { file: MarkFile, author: String, }, + /// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named + /// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an + /// image) fitted inside `rect`, keeping its shape and centred, or a labelled frame without one. + AddModel3d { + page: usize, + rect: [f64; 4], + model: Arc>, + poster: Option, + activation: pdfcraft_annot::Activation3d, + name: String, + }, /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. DeleteAnnotation { page: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index c3339453af..7a51f9078d 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -38,6 +38,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Set bookmark destination", "Colour", "Style", + "Import bookmarks", "New bookmarks from structure", "New bookmarks from text styles", "Number pages", @@ -68,6 +69,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Add inserted text", "Add file attachment", "Add stamp", + "Add 3D model", "Delete comment", "Edit comment", "Reply", @@ -111,10 +113,17 @@ pub const EDIT_LABELS: &[&str] = &[ "Move object", "Move objects", "Move image", + "Transform image", "Rotate image", + "Crop image", "Flip image", "Replace image", "Delete image", + "Bring to front", + "Bring forward", + "Send backward", + "Send to back", + "Remove crop", "Add header & footer", "Update header & footer", "Add watermark", @@ -186,6 +195,7 @@ impl Edit { // The bookmark Style and Colour menus' own names, already said in every language. Edit::SetBookmarkColor { .. } => Label::of("Colour"), Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::ImportBookmarks { .. } => Label::of("Import bookmarks"), Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"), Edit::NumberPages { .. } => Label::of("Number pages"), @@ -198,6 +208,7 @@ impl Edit { Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::AddModel3d { .. } => Label::of("Add 3D model"), Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), Edit::ReplyToAnnotation { .. } => Label::of("Reply"), @@ -254,11 +265,19 @@ impl Edit { Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), Edit::EditPageImage { change, .. } => Label::of(match change { ImageEdit::Move(_) => "Move image", + ImageEdit::Transform(_) => "Transform image", ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", ImageEdit::Crop(_) => "Crop image", ImageEdit::Flip { .. } => "Flip image", ImageEdit::Replace { .. } => "Replace image", ImageEdit::Delete => "Delete image", + ImageEdit::Arrange(ord) => match ord { + pdfcraft_edit::ArrangeOrder::BringToFront => "Bring to front", + pdfcraft_edit::ArrangeOrder::BringForward => "Bring forward", + pdfcraft_edit::ArrangeOrder::SendBackward => "Send backward", + pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back", + }, + ImageEdit::RemoveCrop => "Remove crop", }), Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), @@ -312,6 +331,7 @@ fn add_annotation(s: &Shape) -> &'static str { Shape::Oval { .. } => "Add oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", Shape::Line { .. } => "Add line", + Shape::FreehandHighlight { .. } => "Add freehand highlight", Shape::Ink { .. } => "Add drawing", Shape::TextBox { .. } => "Add text box", Shape::Typewriter { .. } => "Add text", diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c6b1a7438b..8ef6795f96 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,9 +1,10 @@ use super::{ AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, - image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, + image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, }; use crate::SourceProblem; use crate::sources::{comment_source, import_pdf_comments}; +use pdfcraft_content::Matrix; /// Whether the opening password allows an edit (§7.6.4.2, Table 22). pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { @@ -26,6 +27,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::SetBookmarkPage { .. } | Edit::SetBookmarkColor { .. } | Edit::SetBookmarkStyle { .. } + | Edit::ImportBookmarks { .. } | Edit::BookmarksFromStructure | Edit::NumberPages { .. } => { if p.assemble() { @@ -48,6 +50,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } + | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } | Edit::SetAnnotationContents { .. } | Edit::ReplyToAnnotation { .. } @@ -250,6 +253,169 @@ impl EditCtx { } } +fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> { + match change { + ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into()))); + } + ImageEdit::Transform(t) => { + let [a, b, c, d, _, _] = *t; + let determinant = a * d - b * c; + if !t.iter().all(|v| v.is_finite()) || !determinant.is_finite() || determinant.abs() < 1e-9 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite and nonsingular".into()))); + } + } + ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into()))); + } + _ => {} + } + let img = pdfcraft_edit::page_images(doc, page)? + .into_iter() + .nth(index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into()))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order), + ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop, + }; + let page_model = pdfcraft_model::pages(doc) + .into_iter() + .nth(page) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?; + let target_stream_obj = match page_model.dict.get(b"Contents") { + None => None, + Some(c) => match &*doc.resolve(c) { + pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()), + _ if img.stream == 0 => c.as_ref(), + _ => None, + }, + }; + + let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect(); + // Ownership belongs to the drawing stream, not the XObject: original and added images may + // show the same resource without sharing an editable item. + let shows_image = |a: &pdfcraft_edit::Added| img.object.is_some_and(|t| matches!(&a.content, AddedContent::Image(ai) if ai.image == t)); + let matched_added = added_items.iter().find(|(_, a)| target_stream_obj == Some(a.obj) && (img.object.is_none() || shows_image(a))); + + let mut ai_to_update = None; + if let Some((added_idx, added_item)) = matched_added + && let AddedContent::Image(ai) = added_item.content.clone() + { + let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6) + || img.visible_rect.is_some_and(|v| { + (v[0] - img.rect[0]).abs() > 1e-4 + || (v[1] - img.rect[1]).abs() > 1e-4 + || (v[2] - img.rect[2]).abs() > 1e-4 + || (v[3] - img.rect[3]).abs() > 1e-4 + }); + let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _]) + if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5))); + let should_promote = has_active_crop + || non_axis_transform + || matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete); + + if should_promote { + doc.update_dict(added_item.obj, |d| { + d.remove(b"PCAdded"); + d.remove(b"PCMark"); + }) + .map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?; + } else { + ai_to_update = Some((*added_idx, ai)); + } + } + + if let Some((added_idx, mut ai)) = ai_to_update { + let view = Matrix(page_model.view_matrix(doc)); + let to_display = view.invert().ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())))?; + + match change { + ImageEdit::Move(to) => { + ai.rect = to_display.bbox(*to); + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Transform(_) | ImageEdit::Flip { .. } => { + let pdfcraft_edit::ImageChange::Transform(t) = c else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into()))); + }; + let t_display = view.then(&Matrix(t)).then(&to_display); + let [ta, tb, tc, td, _, _] = t_display.0; + let is_parallel = tb.abs() < 1e-5 && tc.abs() < 1e-5; + let is_perpendicular = ta.abs() < 1e-5 && td.abs() < 1e-5; + + let (quarters_ccw, disp_flip_h, disp_flip_v) = if is_parallel { + (0, ta < 0.0, td < 0.0) + } else if is_perpendicular { + match (tb > 0.0, tc > 0.0) { + (true, false) => (1, false, false), + (false, true) => (3, false, false), + (true, true) => (1, false, true), + (false, false) => (1, true, false), + } + } else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "non-axis-aligned transforms are not supported for added images".into(), + ))); + }; + let (toggle_h, toggle_v) = if ai.rotation % 2 == 1 { (disp_flip_v, disp_flip_h) } else { (disp_flip_h, disp_flip_v) }; + ai.flip_h ^= toggle_h; + ai.flip_v ^= toggle_v; + ai.rotation = (ai.rotation + quarters_ccw) % 4; + ai.rect = t_display.bbox(ai.rect); + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Rotate(q) => { + let k = ((4 - q.rem_euclid(4)) % 4) as u8; + ai.rotation = (ai.rotation + k) % 4; + let r = ai.rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if k % 2 == 1 { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0]; + } + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Replace { .. } => { + let pdfcraft_edit::ImageChange::Replace(new_image) = c else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into()))); + }; + ai.image = new_image; + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => { + pdfcraft_edit::change_image(doc, page, index, &c)?; + } + } + } else { + pdfcraft_edit::change_image(doc, page, index, &c)?; + } + Ok(()) +} + /// Perform an edit on a working copy (the caller discards it on error). pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { match edit { @@ -290,6 +456,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::ImportBookmarks { parent, index, bookmarks, replace } => { + pdfcraft_organize::import_outline(doc, parent, *index, bookmarks, *replace)?; + } Edit::BookmarksFromStructure => { let entries: Vec<_> = pdfcraft_a11y::headings(doc) .into_iter() @@ -345,6 +514,16 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; } + Edit::AddModel3d { page, rect, model, poster, activation, name } => { + // A file that is not a model is refused before its poster is brought in. + pdfcraft_annot::check_model(model)?; + let poster = match poster { + Some(file) => Some(model_poster(doc, *rect, file)?), + None => None, + }; + let new = pdfcraft_annot::NewModel3d { page: *page, rect: *rect, model, activation: *activation, poster, contents: name }; + pdfcraft_annot::add_3d_model(doc, &new, &cx.meta())?; + } Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, Edit::ReplyToAnnotation { page, index, text, author } => { @@ -525,40 +704,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddOcrText { page, words } => { pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::RotateBy(degrees) => { - let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; - pdfcraft_edit::ImageChange::Transform(t) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } + Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?, Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..8869e5d1a7 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -54,6 +54,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } + | Edit::AddModel3d { .. } | Edit::DeleteAnnotation { .. } | Edit::SetAnnotationContents { .. } | Edit::ReplyToAnnotation { .. } diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs index c2f4f63714..f75967097c 100644 --- a/crates/engine/src/layers.rs +++ b/crates/engine/src/layers.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use pdfcraft_render::{Layer, LayerOp, RenderPool}; -use crate::{Document, render_threads}; +use crate::{Document, display_config, render_threads}; /// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy /// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its @@ -31,7 +31,7 @@ pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: pub(crate) fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); doc.config.layers = Arc::new(overrides); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); } /// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7cd2f96a96..d6a04c47a8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -35,6 +35,7 @@ mod history; pub mod js; mod layers; pub mod links; +pub mod model3d; pub mod ocr; mod open; pub mod optimizer; @@ -54,6 +55,7 @@ mod stamp_preview; mod test_hooks; pub mod text_fonts; mod text_preview; +pub mod text_script; pub mod text_style_bookmarks; pub mod word_count; pub mod xfa; @@ -64,12 +66,16 @@ use std::sync::Arc; pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; +pub use pdfcraft_edit::strip_subset_tag; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use stamp_preview::{CommentLayer, StampPreview}; pub use text_preview::{TextEditPreview, UnmarkedPages}; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; +pub use pdfcraft_organize::{ + BoxSpec, FieldConflicts, MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, + split_ranges, unmapped as outline_unmapped, view_arity as outline_view_arity, +}; pub use pdfcraft_organize::LabelStyle; pub use pdfcraft_organize::Standard; @@ -106,9 +112,10 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr 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, + Activation3d, 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_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat}; pub use pdfcraft_forms::detect; pub use pdfcraft_optimize as optimize; pub use pdfcraft_preflight as pdfa; @@ -137,8 +144,8 @@ pub use history::Label; use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; pub use page_filter::{PageOrientation, PageParity, filter_pages}; -pub use pictures::MarkFile; -use pictures::{embed_image, fit_picture, mark_source}; +pub use pictures::{MarkFile, image_dimensions}; +use pictures::{embed_image, fit_picture, mark_source, model_poster}; use protection::unrestricted; pub use protection::{Changes, Printing, Protection, SecuritySummary}; pub use session::Session; @@ -154,9 +161,19 @@ use xfa_form::{ mod clock; mod doc_state; +pub use pdfcraft_edit::ArrangeOrder; + use clock::{local_utc_offset, parse_ymd}; pub use doc_state::{DocId, ImageEdit, OptimizeReport, RecoverySnapshot, SplitPart, guard, merge_data_files}; use doc_state::{LOW_RESOLUTION_PRINT_DPI, render_threads, renderer_password, signatures_of, writable}; +/// The configuration of the pool that draws the viewer: the document's own, plus installed CJK +/// fonts for fonts a PDF does not embed (`pdfcraft_render::RenderConfig::system_fonts`). Exports, +/// printing, OCR and automation use the document configuration itself, so nothing drawn with an +/// installed font is saved or printed (AGENTS.md §1.2, §1.4). +fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::RenderConfig { + config.clone().on_screen() +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/model3d.rs b/crates/engine/src/model3d.rs new file mode 100644 index 0000000000..b95a332a22 --- /dev/null +++ b/crates/engine/src/model3d.rs @@ -0,0 +1,19 @@ +//! The 3D models on a page (ISO 32000-2 §13.6), read so a renderer can draw them. + +pub use pdfcraft_annot::{MAX_MODEL_BYTES, Model3d, Model3dKind}; + +use crate::Document; + +impl Document { + /// The `/Annots` positions of the 3D annotations on 0-based `page`, in order. + pub fn model_3d_annotations(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_annot::model_3d_annotations(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// The decoded model of the 3D annotation at `/Annots` position `index` on 0-based `page`. + pub fn model_3d(&self, page: usize, index: usize) -> Result { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_annot::model_3d(&editor.cos, page, index).map_err(|e| e.to_string()) + } +} diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 58bc8327e2..32db07abee 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -7,8 +7,8 @@ use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; use crate::history::{Editor, Keys, TextBlocksCache}; use crate::{ - AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, guard, info_fields, js, note_warnings, - recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, + AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, info_fields, js, + note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, }; /// Immutable session state needed while a document is prepared away from its owning thread. @@ -304,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..d8b8835dc7 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,81 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) } } + +/// A 3D model's poster: the picture in `file` brought into `doc` and fitted inside `rect` +/// (user space), keeping its shape, and centred. It is drawn in user space and not counter-rotated +/// (unlike a stamp), so on a page with `/Rotate` it turns with the page, as the model does. +pub(crate) fn model_poster(doc: &mut pdfcraft_cos::Document, rect: [f64; 4], file: &MarkFile) -> Result { + let src = mark_source(doc, file)?; + let (sw, sh) = src.size; + if !(sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0) { + return Err(EditError::Source(format!("{}: the poster has no area", file.name))); + } + Ok(pdfcraft_annot::PosterPicture { xobject: src.xobject, image: src.image, size: (sw, sh), at: fit_picture(rect, sw, sh) }) +} + +/// 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/session.rs b/crates/engine/src/session.rs index 1051903bae..9a4cb04dac 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -12,10 +12,10 @@ 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; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index c78b10ab94..be7fd5aca5 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -2,12 +2,21 @@ 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, 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, + 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> { @@ -163,6 +172,11 @@ impl Session { // 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())); @@ -375,7 +389,7 @@ impl Session { 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.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.bytes = bytes; Ok(()) } @@ -399,6 +413,7 @@ impl Session { | 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| { @@ -432,7 +447,7 @@ impl Session { 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.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.bytes = bytes; doc.fonts_stale = true; Ok(()) @@ -459,7 +474,7 @@ impl Session { /// 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(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); } /// Rebuild working bytes, inspection and renderer from the current edit state. @@ -516,7 +531,7 @@ impl Session { 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.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.bytes = bytes; Ok(()) } diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 34d75e5c82..553ddd6aa3 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -71,12 +71,12 @@ impl Session { /// 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 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> { + /// 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)) + Ok((bytes, report.merged, report.optimize.images_refused)) } /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index d4156a2eb1..1e6918f6a6 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -1,6 +1,6 @@ use super::{ - Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard, - render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, + 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 { @@ -170,7 +170,7 @@ impl Session { { 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()); + 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. @@ -249,7 +249,7 @@ impl Session { 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()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); } } diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 055437f562..5ef135d6c8 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -1,6 +1,6 @@ use super::{ Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort, - apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices, + apply_layer_state, comment_summary, display_config, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices, }; impl Session { @@ -168,6 +168,14 @@ impl Session { Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) } + /// The bookmark at `path` with everything under it, or the whole outline for `[]`, as a tree + /// another document can take ([`crate::Edit::ImportBookmarks`]). `None` when no document has + /// `id`, or its structure could not be read for editing. + pub fn outline_tree(&self, id: DocId, path: &[usize]) -> Option> { + let editor = self.get(id)?.editor.as_ref()?; + Some(pdfcraft_organize::outline_tree(&editor.cos, path)) + } + /// 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() }; @@ -246,7 +254,7 @@ impl Session { return false; } doc.config.hide_comments = hide; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.generation += 1; true } @@ -259,7 +267,7 @@ impl Session { return false; } doc.config.reading_colors = colors; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.generation += 1; true } diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index ae8842ddfb..074adfc82d 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -55,7 +55,10 @@ impl Document { if w <= 0 || h <= 0 || w > i64::from(MAX_SIDE) || h > i64::from(MAX_SIDE) || w.saturating_mul(h) > MAX_PIXELS as i64 { return Err(SignatureImageError::PlacedSize.to_string()); } - let (_, bytes) = pdfcraft_create::image_file(&editor.cos, picture)?; + // Decoded within an allowance sized for the small picture a signature is, not the + // document's: a stream that inflates past it is refused before it is held whole. + let (_, bytes) = + pdfcraft_create::image_file_within(&editor.cos, picture, &mut pdfcraft_cos::ImageBudget::with_work(SIGNATURE_IMAGE_WORK))?; let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?; let opacity = 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; @@ -90,6 +93,9 @@ pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Opti /// Saved signatures live in settings, so both source and normalized PNG are bounded. pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20; const MAX_PIXELS: u64 = 4 << 20; +/// Steps of decoding the signature's picture may take: every stage of its filters, for a +/// picture of at most [`MAX_PIXELS`] samples, fits in it many times over. +const SIGNATURE_IMAGE_WORK: u64 = 1 << 28; const MAX_SIDE: u32 = 4096; /// Larger sources (scans, phone photos) are decoded within these bounds, then scaled down. const MAX_SOURCE_SIDE: u32 = 8192; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 833fa16b9f..6c54131d02 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"); @@ -516,6 +549,85 @@ fn pdf_page_stamps_fit_inside_an_explicit_box() { assert!(content.contains("/Pic Do") && !content.contains("BI"), "{content}"); } +/// #1297: a 3D model on a page keeps the whole box as its rectangle and draws a PDF poster as a +/// form XObject fitted inside it, keeping its shape and centred. +#[test] +fn a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let blank = session.create_blank(595.0, 842.0, 1).unwrap(); + let id = session.open_new("model.pdf", blank).unwrap(); + let mut prc = b"PRC".to_vec(); + prc.extend_from_slice(&[1; 64]); + let add = |model: Vec| Edit::AddModel3d { + page: 0, + rect: [56.0, 400.0, 539.0, 700.0], + model: Arc::new(model), + poster: Some(MarkFile { name: "poster.pdf".into(), bytes: Arc::new(half_red_page()), page: 0 }), + activation: Activation3d::default(), + name: "Bracket".into(), + }; + assert!(session.apply(id, add(b"%PDF-1.7 not a model".to_vec())).is_err()); + session.apply(id, add(prc)).unwrap(); + + let doc = pdfcraft_cos::Document::open(session.save_bytes(id).unwrap()).unwrap(); + let page = &pdfcraft_model::pages(&doc)[0]; + let annots = doc.resolve(page.dict.get(b"Annots").unwrap()); + let [only] = annots.as_array().unwrap().as_slice() else { panic!("the refused model added nothing: {annots:?}") }; + let annot = doc.get(only.as_ref().unwrap()); + let annot = annot.as_dict().unwrap(); + assert_eq!(annot.name(b"Subtype"), Some(&b"3D"[..])); + let rect: Vec = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(pdfcraft_cos::Object::as_f64).collect(); + assert_eq!(rect, [56.0, 400.0, 539.0, 700.0], "the whole box, not the fitted picture"); + assert_eq!(doc.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().name(b"A"), Some(&b"XA"[..])); + let ap = doc.resolve(annot.get(b"AP").unwrap()); + let pdfcraft_cos::Object::Stream(normal) = doc.get(ap.as_dict().unwrap().reference(b"N").unwrap()).as_ref().clone() else { + panic!("an appearance stream") + }; + let pic = doc.resolve(normal.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let pic = doc.resolve(pic.get(b"XObject").unwrap()).as_dict().unwrap().reference(b"Pic").unwrap(); + let pdfcraft_cos::Object::Stream(picture) = doc.get(pic).as_ref().clone() else { panic!("the picture is a stream") }; + assert_eq!(picture.dict.name(b"Subtype"), Some(&b"Form"[..]), "the page is kept as a form XObject"); + // The 80 × 40 page in the 483 × 300 box: 483 × 241.5 from y 429.25. + let content = String::from_utf8(normal.decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains(" 56 429.25 cm /Pic Do"), "{content}"); +} + +/// A PDF poster whose page has no area is refused rather than drawn as nothing. +#[test] +fn a_3d_poster_page_without_area_is_refused() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let blank = session.create_blank(595.0, 842.0, 1).unwrap(); + let id = session.open_new("model.pdf", blank).unwrap(); + let flat = String::from_utf8(half_red_page()).unwrap().replace("[0 0 80 40]", "[0 0 80 0 ]"); + let add = Edit::AddModel3d { + page: 0, + rect: [56.0, 400.0, 539.0, 700.0], + model: Arc::new(b"PRC rest".to_vec()), + poster: Some(MarkFile { name: "flat.pdf".into(), bytes: Arc::new(flat.into_bytes()), page: 0 }), + activation: Activation3d::default(), + name: "Bracket".into(), + }; + assert!(matches!(session.apply(id, add), Err(EditError::Source(_)))); + assert!(session.get(id).unwrap().info.annotations.is_empty(), "nothing was added"); +} + +/// Adding a 3D model is a comment change: refused without the comments permission, and only the +/// comments are rebuilt after it. +#[test] +fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() { + let edit = Edit::AddModel3d { + page: 0, + rect: [0.0, 0.0, 100.0, 100.0], + model: Arc::new(b"PRC rest".to_vec()), + poster: None, + activation: Activation3d::default(), + name: String::new(), + }; + let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments"))); + assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments); +} + fn page_texts(s: &Session, id: DocId) -> Vec { let doc = s.get(id).unwrap(); let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; @@ -581,6 +693,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 +1046,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"]); @@ -3495,13 +3682,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; @@ -6084,24 +6331,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 @@ -8027,6 +8277,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 @@ -8079,6 +8336,130 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() { assert!(d.page_image_copy_file(0, 1).is_err()); } +/// 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); @@ -8998,6 +9379,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] @@ -9044,6 +9480,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 @@ -9335,3 +10239,244 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() 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); + } +} + +/// Optimize has JBIG2 images share a symbol dictionary only where redaction can store each again +/// without it. Of four pages of the same scan, the two in DeviceGray and the image mask share one; +/// the one in a DeviceN of one colourant, which redaction doesn't store again, is coded alone. +/// Redacting the first page then stores the second and the mask again without the dictionary, +/// which leaves the file. +#[test] +fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + let (w, h) = (600usize, 480usize); + // Lines of 26 letters, each 32 rows of a run from a stem down its left side, of 2 to 24 pixels, + // the same to the pixel wherever it is, picked and spaced at random. + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as usize + }; + let letters: Vec> = (0..26).map(|_| (0..16).map(|_| 1 + next() % 12).collect()).collect(); + let stride = w.div_ceil(8); + let mut bits = vec![0xFFu8; stride * h]; + for top in (8..h - 40).step_by(44) { + let mut x = 8 + next() % 8; + while x + 32 < w { + let letter = &letters[next() % 26]; + for (row, run) in letter.iter().enumerate() { + for (px, py) in (x..x + 2 * run).flat_map(|px| [(px, top + 2 * row), (px, top + 2 * row + 1)]) { + bits[py * stride + px / 8] &= !(0x80 >> (px % 8)); + } + } + x += 2 * letter.iter().max().unwrap_or(&1) + 4 + next() % 5; + } + } + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let device_n = Object::Array(vec![Object::name("DeviceN"), Object::Array(vec![Object::name("Black")]), Object::name("DeviceGray"), { + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Dict(tint) + }]); + let mut images: Vec = Vec::new(); + let grey = || ("ColorSpace", Object::name("DeviceGray")); + for (k, (key, value)) in [grey(), grey(), ("ColorSpace", device_n), ("ImageMask", Object::Bool(true))].into_iter().enumerate() { + 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(1)); + d.set(key.as_bytes().to_vec(), value); + let image = doc.add(Object::Stream(Stream::flate(d, &bits))); + images.push(image); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 144 0 0 115.2 36 600 cm /Im Do Q".to_vec()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + 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"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + if k == 0 { + let mut mark = Dict::new(); + mark.set(b"Type".to_vec(), Object::name("Annot")); + mark.set(b"Subtype".to_vec(), Object::name("Redact")); + mark.set(b"Rect".to_vec(), Object::Array(vec![40.into(), 610.into(), 100.into(), 650.into()])); + let mark = doc.add(Object::Dict(mark)); + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(mark)])); + } + let page = doc.add(Object::Dict(page)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(page)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + } + let mono = pdfcraft_optimize::MonoSettings { + compression: pdfcraft_optimize::MonoCompression::Jbig2, + downsample: false, + ..pdfcraft_optimize::MonoSettings::REDUCE + }; + pdfcraft_optimize::optimize(&mut doc, &pdfcraft_optimize::Settings { mono, ..Default::default() }).unwrap(); + let globals = |doc: &Document, image: ObjRef| match &*doc.get(image) { + Object::Stream(s) => s.dict.get(b"DecodeParms").and_then(Object::as_dict).and_then(|d| d.reference(b"JBIG2Globals")), + _ => None, + }; + let shared = globals(&doc, images[0]); + assert!(shared.is_some() && globals(&doc, images[1]) == shared && globals(&doc, images[3]) == shared, "DeviceGray and the mask share"); + assert_eq!(globals(&doc, images[2]), None, "the DeviceN page is coded alone"); + pdfcraft_redact::apply(&mut doc, None).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let named = saved.scan_objects().filter(|(_, o)| matches!(&**o, Object::Stream(s) if saved.jbig2_globals_ref(s).is_some())).count(); + assert_eq!(named, 0, "no image names the dictionary, which left with them"); +} + +#[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"); +} + +/// A whole tree goes in as one undoable step, and a refused tree changes nothing. +#[test] +fn imported_bookmarks_undo_in_one_step() { + let (mut s, id) = session_with(2); + let node = |title: &str, page, children| pdfcraft_organize::OutlineNode { + title: title.into(), + page, + view: None, + color: None, + italic: false, + bold: false, + open: true, + children, + }; + let bookmarks = vec![node("Intro", Some(0), vec![node("Detail", Some(5), vec![])]), node("End", Some(1), vec![])]; + s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: bookmarks.clone(), replace: false }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Intro→1[Detail→0]", "End→2"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Import bookmarks")); + assert_eq!(s.outline_tree(id, &[]).unwrap().unwrap().nodes[0].title, "Intro"); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().info.outline.is_empty()); + s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: bookmarks.clone(), replace: false }).unwrap(); + s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: vec![node("New", Some(1), vec![])], replace: true }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["New→2"]); + s.undo(id).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Intro→1[Detail→0]", "End→2"]); + s.undo(id).unwrap(); + let err = s.apply(id, Edit::ImportBookmarks { parent: vec![4], index: 0, bookmarks, replace: false }).unwrap_err(); + assert!(err.to_string().contains("no bookmark"), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); +} 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_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/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 8009ef1bbf..53ca309326 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -207,3 +207,192 @@ fn visible_stretched_text_rewrites_unchanged() { let i = ((y * out.width + x) * 4) as usize; assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]); } + +/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools +/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image. +fn ocr_under_scan(text: &str) -> std::sync::Arc> { + under_scan(&format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET")) +} + +/// A page whose `text` content is followed by the opaque 200 × 100 scan image. +fn under_scan(text: &str) -> std::sync::Arc> { + under_scan_with(text, |_| pdfcraft_cos::Dict::new()) +} + +/// As [`under_scan`], with the page's ExtGState resources made by `ext_gstates`. +fn under_scan_with(text: &str, ext_gstates: impl FnOnce(&mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict) -> std::sync::Arc> { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; + let (w, h) = (200u32, 100u32); + let mut rgb = Vec::new(); + for y in 0..h { + for x in 0..w { + let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y); + rgb.extend(if ink { INK } else { PAPER }); + } + } + let mut d = Document::new_empty(); + let pages = d.add(Object::Dict(Dict::new())); + let content = format!("{text} q 200 0 0 100 0 0 cm /Im Do Q"); + let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + let mut image = Dict::new(); + image.set(b"Type".to_vec(), Object::name("XObject")); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(i64::from(w))); + image.set(b"Height".to_vec(), Object::Int(i64::from(h))); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let image = d.add(Object::Stream(Stream::flate(image, &rgb))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(font)); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + res.set(b"ExtGState".to_vec(), Object::Dict(ext_gstates(&mut d))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + let page = d.add(Object::Dict(p)); + d.update_dict(pages, |p| { + p.set(b"Type".to_vec(), Object::name("Pages")); + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut root = Dict::new(); + root.set(b"Type".to_vec(), Object::name("Catalog")); + root.set(b"Pages".to_vec(), Object::Ref(pages)); + let root = d.add(Object::Dict(root)); + d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap()) +} + +/// The scanned word's box shows the cover and new ink, not the scan. +fn assert_edit_shows(s: &Session, id: pdfcraft_engine::DocId, label: &str) { + let px = word_pixels(s, id); + let paper = px.iter().filter(|p| near(**p, PAPER)).count(); + let ink = px.iter().filter(|p| near(**p, INK)).count(); + assert!(paper * 10 > px.len() * 4, "{label}: the cover is drawn over the scan: {paper} of {} paper", px.len()); + assert!(ink * 100 > px.len() * 3, "{label}: the new text shows: {ink} of {} ink", px.len()); +} + +fn texts(s: &Session, id: pdfcraft_engine::DocId) -> Vec { + s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.replace('\n', " ")).collect() +} + +fn edit_block(s: &mut Session, id: pdfcraft_engine::DocId, block: usize, text: &str) { + s.apply(id, Edit::EditTextBlock { page: 0, block, text: text.into(), style: Default::default() }).unwrap(); +} + +/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image, +/// not in the OCR text's place where the image would hide it (#3973). One line takes the +/// single-style rewrite, a typed line break the styled one. Undo restores the hidden layer, and +/// the edit survives redo, a save and reopen, and being edited again. +#[test] +fn an_ocr_layer_under_the_scan_image_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap(); + let blocks = s.get(id).unwrap().text_blocks(0); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].invisible, "the OCR layer is invisible text"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box"); + + edit_block(&mut s, id, 0, edit); + assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}"); + assert_edit_shows(&s, id, edit); + + s.undo(id).unwrap(); + assert_eq!(texts(&s, id), ["John"], "{edit:?}: undo"); + assert!(s.get(id).unwrap().text_blocks(0)[0].invisible, "{edit:?}: undo hides the layer again"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "{edit:?}: undo shows the scan again"); + s.redo(id).unwrap(); + assert_edit_shows(&s, id, &format!("{edit:?} redo")); + + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let id = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(texts(&reopened, id), [edit.replace('\n', " ")], "{edit:?}: reopened"); + assert_edit_shows(&reopened, id, &format!("{edit:?} reopened")); + + edit_block(&mut reopened, id, 0, "Ann"); + assert_eq!(texts(&reopened, id), ["Ann"], "{edit:?}: edited again"); + assert_edit_shows(&reopened, id, &format!("{edit:?} edited again")); + } +} + +/// Visible text earlier in the same text object makes the rewrite split it; the OCR phrase still +/// goes on top of the scan, and the visible text stays. +#[test] +fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let page = under_scan("BT /F1 20 Tf 10 80 Td (Head) Tj 3 Tr 32 -36 Td (John) Tj ET"); + let id = s.open("split.pdf", None, page, None).unwrap(); + let blocks = s.get(id).unwrap().text_blocks(0); + let john = blocks.iter().position(|b| b.text == "John").unwrap(); + assert!(blocks[john].invisible && blocks.iter().any(|b| b.text == "Head" && !b.invisible), "{blocks:?}"); + edit_block(&mut s, id, john, edit); + let mut after = texts(&s, id); + after.sort(); + let mut want = vec!["Head".to_string(), edit.replace('\n', " ")]; + want.sort(); + assert_eq!(after, want, "{edit:?}"); + assert_edit_shows(&s, id, &format!("{edit:?} split")); + } +} + +/// `/SM`: a luminosity soft mask that is white over 30..110 × 30..70 in the space it is selected +/// in, black elsewhere. +fn soft_mask_state(d: &mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Object, Stream}; + let mut group = Dict::new(); + group.set(b"S".to_vec(), Object::name("Transparency")); + group.set(b"CS".to_vec(), Object::name("DeviceRGB")); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + form.set(b"Group".to_vec(), Object::Dict(group)); + let mut mask = Dict::new(); + mask.set(b"Type".to_vec(), Object::name("Mask")); + mask.set(b"S".to_vec(), Object::name("Luminosity")); + mask.set(b"G".to_vec(), Object::Ref(d.add(Object::Stream(Stream::flate(form, b"1 g 30 30 80 40 re f"))))); + let mut state = Dict::new(); + state.set(b"Type".to_vec(), Object::name("ExtGState")); + state.set(b"SMask".to_vec(), Object::Dict(mask)); + let mut states = Dict::new(); + states.set(b"SM".to_vec(), Object::Dict(state)); + states +} + +/// The soft mask is selected before a translation, so it is placed by the CTM before it. The +/// paragraph lifted over the scan selects it again after its own `cm`: there it must be placed +/// as it was, not 100 pt to the right where it would hide the new text, so the edit renders +/// exactly as the same edit on the page without the mask. Both rewrite paths. +#[test] +fn a_soft_mask_selected_before_a_cm_stays_put_when_the_edit_is_lifted() { + for edit in ["Jane", "Jane\nDoe"] { + let mut plain = Session::new(); + let page = under_scan("q 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q"); + let plain_id = plain.open("plain.pdf", None, page, None).unwrap(); + edit_block(&mut plain, plain_id, 0, edit); + let mut s = Session::new(); + let page = under_scan_with("q /SM gs 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q", soft_mask_state); + let id = s.open("masked.pdf", None, page, None).unwrap(); + assert_eq!(texts(&s, id), ["John"]); + edit_block(&mut s, id, 0, edit); + assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}"); + assert_edit_shows(&s, id, &format!("{edit:?} masked")); + let ink = |s: &Session, id| word_pixels(s, id).iter().filter(|p| near(**p, INK)).count(); + assert_eq!(ink(&s, id), ink(&plain, plain_id), "{edit:?}: the mask does not hide the new text"); + } +} diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 7aeea595a8..3bc6709e69 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -38,11 +38,23 @@ pub(crate) fn base64(data: &[u8]) -> String { /// 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) + "\n\n\n\n{}\n{}\n\n\n", + esc(title), + if simple { + "" + } else { + "" + } ); for it in items(pages) { match it { @@ -59,13 +71,13 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi } // 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) { + if !simple && 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}\"")); + 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")), @@ -84,7 +96,7 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi 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}\"")); + 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"); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index db53ee59c8..2b82919bbf 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -27,9 +27,9 @@ pub(crate) use as_docx::hex; #[cfg(test)] use as_docx::{MAX_MEDIA, docx_table}; pub(crate) use as_html::esc; -pub use as_html::html; #[cfg(test)] use as_html::{allowed, base64}; +pub use as_html::{html, html_simple}; pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 27cd8264e0..f630d98d5d 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -85,6 +85,24 @@ fn html_has_headings_paragraphs_and_images_in_order() { 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 ["", "", "", " 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 -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 +decode_ccitt_within(data, ¶ms, max_rows, work: &mut u64) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS; its rows paid for from work before they are decoded +decode_jbig2_within(data, globals, columns, rows, max, work: &mut u64) -> Result, FilterError> // JBIG2 as JBIG2Decode gives it; a page of columns × rows, within max and MAX_JBIG2_PIXELS; reading and decoding it spend from work, in the decoder's steps +encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns and rows <= MAX_JBIG2_COLUMNS (65,535) and MAX_JBIG2_PIXELS (2^28) samples, what the decoder takes +encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; the same limits +encode_jbig2_shared(&[(data, columns, rows)]) -> Result, FilterError> // images symbol coded sharing one dictionary: the /JBIG2Globals data, and each image's stream or None; the same limits ``` - `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 @@ -46,8 +51,8 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row | 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 here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `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 | +| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt_within` 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::ImageBudget::decode` 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_within` 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`, 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::ImageBudget::decode` 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 65,535 samples on a side and 2^28 in all, where `hayro-jbig2` stops; no rows or columns, or too large → 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, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made. `encode_jbig2_shared`: the marks found in more than one image of a group (at most 16,383, those in the most images first, within the same limits on distinct marks and their pixels as one image's) go once in a symbol dictionary segment associated with no page, number 0, which is the `/JBIG2Globals` data (7.2.6, D.3); each image's stream numbers its segments from 1 (a segment refers only to lower numbers, 7.2.5) and its text regions refer to that dictionary first, then to one of its own for the marks only it repeats, so the shared symbols' IDs come first (Table 31's SBSYMS); the rest is coded as `encode_jbig2`'s symbol coding codes it, the smaller of its two plans. An image that places none of the shared marks gets no stream, to be stored alone; whether the rest save more than the globals take is the caller's to weigh | ## Tests @@ -57,12 +62,13 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row - `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/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. +- `jbig2/decode.rs`: `decode_jbig2_within` 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, 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; pages in one font share one dictionary, segment 0 of no page, retained, which each page's text regions refer to first, taking less together than alone, a page's own repeated marks in a dictionary referred to after it, a page sharing nothing left alone, images with fewer than two marks in common sharing nothing, and past 16,383 shared marks those in the most images kept, with a property test over groups of scattered pages; 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 decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2), DCT encoding, CCITT Group 3 encoding 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 index e87a82fe16..2a038568ee 100644 --- a/crates/filters/src/ccitt.rs +++ b/crates/filters/src/ccitt.rs @@ -50,7 +50,7 @@ pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result, Filter } } -/// The widest row [`decode_ccitt`] decodes: its row buffer stays within 128 KiB. +/// 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 @@ -74,22 +74,8 @@ impl Default for CcittParams { } } -/// 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`]). -pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result, FilterError> { +/// 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)) @@ -123,6 +109,39 @@ pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result 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. @@ -132,9 +151,10 @@ fn starts_with_end_of_line(data: &[u8]) -> bool { } /// 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. -struct Rows { - out: Vec, +/// 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, @@ -144,12 +164,12 @@ struct Rows { } impl Rows { - fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { + 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. - fn push(&mut self, white: bool, count: usize) { + 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) { @@ -160,6 +180,16 @@ impl Rows { } 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 { @@ -170,12 +200,7 @@ impl hayro_ccitt::Decoder for Rows { self.push(white, (chunks as usize).saturating_mul(8)); } fn next_line(&mut self) { - if self.done < self.rows { - self.out.extend_from_slice(&self.row); - self.done += 1; - } - self.row.fill(0); - self.x = 0; + self.end_row(); } } @@ -183,6 +208,42 @@ impl hayro_ccitt::Decoder for Rows { 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); 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 index 343fbbd27d..3d3c7b81a0 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -1,17 +1,23 @@ //! 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). +//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]), which a group +//! of images may share through `/JBIG2Globals`. 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; +/// The widest row, and the most rows, [`encode_jbig2_generic`] encodes: what `hayro-jbig2`, the +/// decoder behind `JBIG2Decode` here, takes on a side. JBIG2 allows 32-bit sizes, but past this +/// nothing here could read the stream, and the optimizer, which doesn't read back what it stores, +/// replaced such an image with one that draws nothing. +pub const MAX_JBIG2_COLUMNS: u32 = 65_535; /// 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 @@ -51,9 +57,7 @@ const PAD: usize = 4; /// 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)); + let samples = rows_to_encode(data, columns, rows).ok(); Ok(match samples.and_then(|s| symbols::encode(s, columns, rows)) { Some(coded) if coded.len() < generic.len() => coded, _ => generic, @@ -69,18 +73,13 @@ pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result, Fil /// 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`]). +/// Errors: no rows or columns, more of either than [`MAX_JBIG2_COLUMNS`], or more samples than +/// [`MAX_JBIG2_PIXELS`], what the decoder takes ([`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 samples = rows_to_encode(data, columns, rows)?; 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; @@ -98,6 +97,48 @@ pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result Result<&[u8], FilterError> { + let too_large = columns > MAX_JBIG2_COLUMNS || rows > MAX_JBIG2_COLUMNS || u64::from(columns) * u64::from(rows) > MAX_JBIG2_PIXELS; + if columns == 0 || rows == 0 || too_large { + return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples"))); + } + let stride = (columns as usize).div_ceil(8); + 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()) }) +} + +/// A group of images coded by [`encode_jbig2_shared`]: the data of the `/JBIG2Globals` stream +/// they share, and each image's own stream, in the order given, or `None` for one that shares +/// nothing with the others. +#[derive(Debug)] +pub struct SharedJbig2 { + pub globals: Vec, + pub images: Vec>>, +} + +/// Images, each `rows` rows of `columns` 1-bit PDF samples, as lossless JBIG2 sharing one symbol +/// dictionary: the marks found in more than one of them are stored once, in a symbol dictionary +/// segment not associated with a page, which is the `/JBIG2Globals` stream's data, and each image's +/// stream, in the embedded organisation, places them from there, with its other marks coded as +/// [`encode_jbig2`]'s symbol coding codes them (see [`symbols`]). With the globals, each decodes to +/// its own bits. An image that holds none of those marks, or that symbol coding doesn't take, has +/// no stream here; it is stored alone. `None` when the images share fewer than two marks. +/// +/// What each image's stream saves is left for the caller to weigh against what the globals take, +/// which the images that use them share. +/// +/// Errors: as [`encode_jbig2_generic`], for the first image it would refuse. +pub fn encode_jbig2_shared(images: &[(&[u8], u32, u32)]) -> Result, FilterError> { + let images = + images.iter().map(|(data, columns, rows)| Ok((rows_to_encode(data, *columns, *rows)?, *columns, *rows))).collect::, _>>()?; + Ok(symbols::encode_shared(&images).map(|(globals, images)| SharedJbig2 { globals, images })) +} + /// 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 { @@ -138,12 +179,18 @@ fn at_pixels(out: &mut Vec) { } } -/// 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 +/// A segment (7.2) of page 1: its header (number, type, the segments it refers to, the page, 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]) { + segment_of(out, 1, number, kind, refers_to, retained, data); +} + +/// [`segment`], of page `page`: 0 for a segment associated with no page (7.2.6), such as the +/// global ones `/JBIG2Globals` holds, which a page's segments may refer to. +fn segment_of(out: &mut Vec, page: u8, 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. @@ -151,8 +198,8 @@ fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[(u8, bool)], r 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.push(page); + // A segment's data never comes near 4 GiB here: an image is at most 2^28 samples. out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); out.extend_from_slice(data); } @@ -209,6 +256,11 @@ mod tests { /// 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> { + decode_with(data, None) + } + + /// [`decode`], with `/JBIG2Globals`. + pub(super) fn decode_with(data: &[u8], globals: Option<&[u8]>) -> Vec> { struct Rows(Vec>, Vec, usize); impl hayro_jbig2::Decoder for Rows { fn push_pixel(&mut self, black: bool) { @@ -223,7 +275,7 @@ mod tests { self.0.push(row); } } - let image = hayro_jbig2::Image::new_embedded(data, None).unwrap(); + let image = hayro_jbig2::Image::new_embedded(data, globals).unwrap(); let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize); image.decode(&mut rows).unwrap(); rows.0 @@ -340,9 +392,56 @@ mod tests { 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 { .. }))); + assert!(matches!(encode_jbig2_generic(&[], 1, 2), Err(FilterError::Corrupt { .. }))); + } + + /// The encoder stops where the decoder does, on each side and in total: past that it would + /// write a stream nothing here can read. 16,384 squared is exactly the bound on samples. + /// `encode_jbig2_shared` refuses what `encode_jbig2_generic` refuses: an image taller than + /// the decoder takes is `Unsupported`, one at the bound isn't refused. (Its one tall mark is + /// too large to be a symbol, so nothing is shared: `Ok(None)`.) + #[test] + fn shared_coding_takes_only_what_the_decoder_takes() { + let past = vec![0x7F; MAX_JBIG2_COLUMNS as usize + 1]; + let tall = (&past[..], 1, MAX_JBIG2_COLUMNS + 1); + assert!(matches!(encode_jbig2_shared(&[tall, tall]), Err(FilterError::Unsupported(_))), "too tall"); + let at = (&past[..MAX_JBIG2_COLUMNS as usize], 1, MAX_JBIG2_COLUMNS); + assert!(matches!(encode_jbig2_shared(&[at, at]), Ok(None)), "at the bound"); + } + + #[test] + fn it_encodes_only_what_the_decoder_takes() { + let unsupported = |columns, rows| matches!(encode_jbig2_generic(&[], columns, rows), Err(FilterError::Unsupported(_))); + assert!(unsupported(MAX_JBIG2_COLUMNS + 1, 1), "too wide"); + assert!(unsupported(1, MAX_JBIG2_COLUMNS + 1), "too tall"); + assert!(unsupported(16_385, 16_384), "too many samples, each side within the bound"); + assert!(unsupported(u32::MAX, u32::MAX), "the product doesn't overflow"); + // At the bounds what it writes reads back, through the decoder that has to read it. + let wide = vec![0x0F; (MAX_JBIG2_COLUMNS as usize).div_ceil(8) * 4]; + assert_eq!(decode(&encode_jbig2_generic(&wide, MAX_JBIG2_COLUMNS, 4).unwrap()), unpack(&wide, MAX_JBIG2_COLUMNS, 4)); + let tall = vec![0x7F; MAX_JBIG2_COLUMNS as usize]; + assert_eq!(decode(&encode_jbig2_generic(&tall, 1, MAX_JBIG2_COLUMNS).unwrap()), unpack(&tall, 1, MAX_JBIG2_COLUMNS)); + struct Rows(u32, u64); + impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.1 += u64::from(black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.1 += u64::from(black) * u64::from(chunks) * 8; + } + fn next_line(&mut self) { + self.0 += 1; + } + } + // One black pixel, so a page that came back blank wouldn't pass. + let mut largest = vec![0xFF; 2_048 * 16_384]; + largest[0] = 0x7F; + let largest = encode_jbig2_generic(&largest, 16_384, 16_384).unwrap(); + let mut rows = Rows(0, 0); + hayro_jbig2::Image::new_embedded(&largest, None).unwrap().decode(&mut rows).unwrap(); + assert_eq!(rows.0, 16_384); + assert_eq!(rows.1, 1); } proptest::proptest! { diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs new file mode 100644 index 0000000000..ddb70270d8 --- /dev/null +++ b/crates/filters/src/jbig2/decode.rs @@ -0,0 +1,488 @@ +//! 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 this many (20,000 × 10,000 decodes). It is also the most +/// [`encode_jbig2_generic`](crate::encode_jbig2_generic) encodes, so what it stores reads back. +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); + } + + /// A region's location is unsigned (ISO/IEC 14492 5.4.4), so one past `i32::MAX` is off the + /// page. The decoder took it for a signed one: a region at 0xFFFFFFFC came back onto the page + /// four pixels left of it, and a refinement anywhere past `i32::MAX` was refused, and the page + /// with it. Each stream is a page of 16 × 12 covered by a generic region, then a region of 8 × 6 + /// of one kind past `i32::MAX`, coded by the standard's procedures, and decodes to that page + /// alone; moved onto the page, the region draws. Vendored hayro-jbig2 patch 14. + #[test] + fn a_region_located_past_i32_max_is_off_the_page() { + const PAGE: &str = "0000000030000100000013000000100000000c00000000000000004200000000000126000100000035000000100000000c0000000000000000040003fffdff02fefefed906aa2cb6ee4ccb4bc975d02669ca7a9c14c20c638f5a5e1bffac"; + let cases = [ + // At 0xFFFFFFFC, 1. + ("generic", "00000002260001000000220000000800000006fffffffc00000001000003fffdff02fefefe17c876ae6f77ffac"), + // At 1, 0xFFFFFFFE, with its symbol dictionary. + ( + "text", + "000000020000010000001e000003fffdff02fefefe00000001000000014fdda14149cec477ed5fffac00000003062002010000001b000000080000000600000001fffffffe00001000000001a943ffac", + ), + // At 0xFFFFFFFC, 1, with its pattern dictionary. + ( + "halftone", + "000000021000010000000a000101000000011fffac00000003162002010000002e0000000800000006fffffffc0000000100000000000800000006000000000000000001000000d09301774485ffac", + ), + // At 0xFFFFFFFC, 1, reading the page there (7.4.7.4): 0s. + ("refinement of the page", "000000022a00010000001e0000000800000006fffffffc000000010400ffffffff0bd98f188428ffac"), + // Of an intermediate region at the same place (7.4.7.5), 1, 0xFFFFFFFE. + ( + "refinement of a region", + "0000000224000100000023000000080000000600000001fffffffe000003fffdff02fefefe3d81322da16b7fffac000000032a2002010000001e000000080000000600000001fffffffe0000ffffffffe29946b82229ffac", + ), + // Of an intermediate refinement of an intermediate region, all at 0xFFFFFFFC, 1. + ( + "refinement of a refinement", + "00000002240001000000230000000800000006fffffffc00000001000003fffdff02fefefecdfd921ec8c47fffac00000003282002010000001e0000000800000006fffffffc000000010000ffffffff903cdb447a47ffac000000042a2003010000001e0000000800000006fffffffc000000010000ffffffffdcd6078cab86ffac", + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + let decode = |segments: &str| decode_jbig2(&[hex(PAGE), hex(segments)].concat(), None, 16, 12, usize::MAX).unwrap(); + let page = decode(""); + assert!(page.iter().any(|&byte| byte != 0xFF), "the page has ink"); + for (kind, segments) in cases { + assert_eq!(decode(segments), page, "{kind}"); + // Moved onto the page, to 4, 1 or 1, 2, the same regions draw: they decode, and are + // left out for where they are. + let (off, on) = [("fffffffc00000001", "0000000400000001"), ("00000001fffffffe", "0000000100000002")] + .into_iter() + .find(|(off, _)| segments.contains(off)) + .unwrap(); + assert_ne!(decode(&segments.replace(off, on)), page, "{kind} on the page"); + } + + // A refinement at 2, 0 of an intermediate region at 0x80000000, 0. A refinement of a + // region refines it where it is, "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35), and the two + // should be at one place (7.4.7.5). Where they aren't, it decodes as it does where they are. + const ELSEWHERE: &str = "000000022400010000002300000008000000068000000000000000000003fffdff02fefefe1bca3149568cdfffac000000032a2002010000001e000000080000000600000002000000000000ffffffff784a909cc761ffac"; + let refined = decode(ELSEWHERE); + assert_eq!(refined, decode(&ELSEWHERE.replace("8000000000000000", "0000000200000000")), "the region at 2, 0 too"); + assert_ne!(refined, page, "drawn"); + } + + /// A refinement of the page refines "the current contents of the page buffer, restricted to the + /// area of the page buffer specified by this segment's region segment information field" + /// (ISO/IEC 14492 7.4.7.4), at an offset of 0 (Table 35), and reads 0 outside that area + /// (6.3.5.2). The decoder read the page around the area too, and refused a refinement wider or + /// taller than the page. A refinement of another region refines that region, not the page. And + /// a reference 32 or more left of a refinement (GRREFERENCEDX below −31, as a symbol refined + /// with an RDX below −31 has, Table 18) is read from where each row starts in it: each row's + /// first context read 0 for the reference column it starts at, and from 33 left for the one + /// before it too. Each stream is coded by the standard's procedures and decodes to its + /// picture, `#` for black. Vendored hayro-jbig2 patch 16. + #[test] + fn a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is() { + let cases: [(&str, &str, &[&str]); 5] = [ + ( + "a 48 × 6 page a generic region has inked, refined at 33: ink on both sides of the area, template 0", + "000000003000010000001300000030000000060000000000000000420000000000012600010000004100000030000000060000000000000000040003fffdff02fefefe331433558a5c5056cf412003ec9a38164df39bd4463b9331502c4fb1c267ab77b665aa5962ffac000000022a0001000000210000000c0000000600000021000000000400ffffffff9a6c35bbeee221f687ffac", + &[ + ".#..##.######..#.#..#####.##..#........##.##..##", + ".#..####.####.#...###.##.##.#.#.....#.#.####.###", + "##.#......#..#....#..#.###.#..#.#####.###.######", + "......#....###.#.##..#.##........#.#.#...#####..", + "#.##..###.#.#.##.....#...#.#..#.##.#..#...#...#.", + ".###...#..#..#..##..#.#..#####.#.##.......###.##", + ], + ), + ( + "a 24 × 8 inked page refined at 5, 1, template 1", + "000000003000010000001300000018000000080000000000000000420000000000012600010000003500000018000000080000000000000000040003fffdff02fefefeb1e133cb8bcbb8510e81d219bb880dfb40a311dc5c298819efffac000000022a00010000001b0000000a0000000500000005000000010401e55c0078dcd30fffac", + &[ + "#.##.##...##....##.##..#", + "#.##.##...#.####..##..#.", + "##..##.##...#...###..#.#", + ".....#.#.##.#...###....#", + "..#.#...#.#...#.#...##.#", + "....######...#.####....#", + "..######..##.#.#..##.#..", + "...#..##.####.#..##...#.", + ], + ), + ( + "a 16 × 5 inked page refined by a region 4 wider than it, template 0", + "000000003000010000001300000010000000050000000000000000420000000000012600010000002600000010000000050000000000000000040003fffdff02fefefe22e5e253a429cbf2c701ffac000000022a000100000023000000140000000500000000000000000400ffffffff3afa516532bbe3d481926fffac", + &[".#.#.#.#..#.#.#.", "##.####..###...#", "..####.#..####.#", ".###.#.....#..#.", "#.###..#########"], + ), + ( + "an intermediate region at 4, 1 on an inked 20 × 8 page, refined there (OR): the region, not the page, template 0", + "000000003000010000001300000014000000080000000000000000420000000000012600010000003000000014000000080000000000000000040003fffdff02fefefeb91e434e1726ca89ed06c2bffb0fe5fa7fccc80fffac00000002240001000000230000000a000000050000000400000001000003fffdff02fefefece7a9826ba81bfffac000000032a2002010000001e0000000a0000000500000004000000010000ffffffffd046ee6f6190ffac", + &[ + "#.#.#....#.#...#....", + "##..#.#.#.##.....##.", + "##.#.#.#.#.###.##...", + "###.##.####..###.#.#", + "...#.###..###.#.#..#", + "#...#######.#...####", + "..##.##...#..##..#.#", + "#...##...#.##....#..", + ], + ), + ( + "a symbol refined in a symbol dictionary from a 40 × 6 one, RDX −33: 33 left, template 1", + "0000000030000100000013000000070000000600000000000000004200000000000100000100000036000003fffdff02fefefe00000001000000014f913296209e2187f37bddb8589c8beb7d70109de7124ebe47d04914ef7e655c3c4cffac00000002002001010000001f100203fffdff02fefefe00000001000000014fe451a27bddf49f7ef7e7ffac00000003062002010000001b0000000700000006000000000000000004001000000001a943ffac", + &[".####.#", ".##..##", "......#", "...#..#", "#.#.###", "....##."], + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + for (form, stream, picture) in cases { + let (w, h) = (picture[0].len(), picture.len()); + // As the filter gives it: black as 0, each row padded to a byte with 0s. + let mut rows = vec![0u8; w.div_ceil(8) * h]; + for (y, row) in picture.iter().enumerate() { + for (x, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'.') { + rows[y * w.div_ceil(8) + x / 8] |= 0x80 >> (x % 8); + } + } + assert_eq!(decode_jbig2(&hex(stream), None, w as u32, h as u32, usize::MAX).unwrap(), rows, "{form}"); + } + } + + /// 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); + } + + /// A refinement whose reference is offset right of it (GRREFERENCEDX above 0, as a refined text + /// instance has from a wider bitmap, ISO/IEC 14492 Table 12, and a refined symbol from an RDX + /// above 0, Table 18) reads the reference from its first column. Left of the reference the + /// decoder emptied what it had read of it, and at its first column didn't read it again, so its + /// first 32 columns read as 0: such a refinement decoded to other pixels than were coded, or + /// failed. Each stream is a refinement coded by the standard's procedures on a page of its own + /// size, and decodes to its picture, `#` for black. Vendored hayro-jbig2 patch 15. + #[test] + fn a_refinement_reads_a_reference_right_of_it_from_its_first_column() { + let cases: [(&str, &str, &[&str]); 4] = [ + ( + "a text instance of an 8 × 6 symbol refined 6 wider: 3 right, template 0", + "00000000300001000000130000000e000000060000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdd3dba48bdddde35ffac00000002062001010000002c0000000e000000060000000000000000040012ffffffff00000001a96b4a6b78b7fc094fadde1b3e881fffac", + &[".#.#.#....#...", "##.###....##.#", ".###...###..#.", "#.......#...#.", "#.####.#...#..", "#.##...###.#.."], + ), + ( + "refined 2 wider and 2 taller, RDX and RDY 1: 2 right, 2 down, template 1", + "00000000300001000000130000000a000000080000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdd9c52d3ea827a43ffac0000000206200101000000280000000a00000008000000000000000004801200000001a96c00cdac6dd5f6f848ce872411cfffac", + &["####.##...", "####..####", "####..##..", "...##...#.", "#......#..", "..#.....#.", "##....####", ".#........"], + ), + ( + "a symbol refined in a symbol dictionary, RDX 5, RDY −1, template 0 with its AT pixels moved", + "00000000300001000000130000000c000000060000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda13efc4029efc95fffac000000020020010100000027000203fffdff02fefefefeff010100000001000000014fc98c352dd84a906f120ffb49793fffac00000003062002010000001b0000000c00000006000000000000000004001000000001a943ffac", + &["#.#.##.#.###", "..#.#.....##", "##..#...#...", "..##....##..", "##.#.##.####", ".#..#####..#"], + ), + ( + "a text instance of a 40 × 5 symbol refined 2 wider, RDX 1: 2 right, across a word, template 0", + "00000000300001000000130000002a0000000500000000000000004200000000000100000100000031000003fffdff02fefefe00000001000000015240f590d953ae3f4069dede672090d4d61b377906d3f41aafa4f77107ffac00000002062001010000003c0000002a000000050000000000000000040012ffffffff00000001a96c0b9386d4b140486bdc3673dbd031490e468c7f7813d19068ab04a79cbfffac", + &[ + "##.#.#..##.##.#####.#....#.#.#..#..######.", + ".#.#..###....#..##.#..#.####.#..#.###...##", + ".#.#......#....#####....#...###...#####...", + "#...##.##.......#...###.###...##..##.###.#", + "..#..###..###..#...#.......##.#........##.", + ], + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + for (form, stream, picture) in cases { + let (w, h) = (picture[0].len(), picture.len()); + // As the filter gives it: black as 0, each row padded to a byte with 0s. + let mut rows = vec![0u8; w.div_ceil(8) * h]; + for (y, row) in picture.iter().enumerate() { + for (x, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'.') { + rows[y * w.div_ceil(8) + x / 8] |= 0x80 >> (x % 8); + } + } + assert_eq!(decode_jbig2(&hex(stream), None, w as u32, h as u32, usize::MAX).unwrap(), rows, "{form}"); + } + } + + /// 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); + } + } + + /// Pixels past a bitmap's edge are 0 (ISO/IEC 14492 6.3.5.2). A page whose default pixel is 1, + /// and an intermediate text or halftone region whose default pixel is 1, kept 1s past their + /// width in their last word of each row, and a refinement, which reads its reference a word at + /// a time, read them as pixels: of rows not a multiple of 32 wide, it decoded to other pixels + /// than were coded. Each stream is a refinement coded by the standard's procedures from a + /// reference read as 0 past its edge, and decodes to the picture given, `#` for black. + /// Vendored hayro-jbig2 patch 13. + #[test] + fn a_refinement_reads_nothing_past_its_references_width() { + // A page of 7 × 6, then of 33 × 6, whose default pixel is 1, refined whole. + const PAGE_7: &str = "000000003000010000001300000007000000060000000000000000460000000000012a00010000001e000000070000000600000000000000000400ffffffffe42d7e889ebcffac"; + const PAGE_33: &str = "000000003000010000001300000021000000060000000000000000460000000000012a000100000034000000210000000600000000000000000400ffffffff314a8d598037eb3f915ab3d526ccae915ad6a6a7b814111b124ae60fffac"; + // An intermediate text region of 31 × 6 whose default pixel is 1, then one of 7 × 6 on a + // page of 40, each refined into the page. + const TEXT_31: &str = "00000000300001000000130000001f0000000600000000000000004200000000000100000100000030000003fffdff02fefefe00000001000000014f96dceb76c7f1ff585971969cd9f88e538261a6f713be68d77e8e1fffac00000002042001010000001b0000001f00000006000000000000000000029000000001a943ffac000000032a2002010000002f0000001f0000000600000000000000000400ffffffff9ac311688adbc8f570a2f96aef93d332e3b1f4b27b704bffac"; + const TEXT_7: &str = "000000003000010000001300000028000000060000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fe1f79f4a95c5b77fffac00000002042001010000001b0000000700000006000000000000000000029000000001a943ffac000000032a2002010000001e000000070000000600000000000000000400ffffffffa71dae12bd7fffac"; + // An intermediate halftone region of 33 × 6 whose default pixel is 1, refined into the page. + const HALFTONE_33: &str = "000000003000010000001300000021000000060000000000000000420000000000011000010000000a000101000000011fffac00000002142001010000004100000021000000060000000000000000009000000021000000060000000000000000010000004ea6618f551d48ff24c2c5c98124d449f2adea6fca76e4297fffac000000032a20020100000032000000210000000600000000000000000400ffffffffe87db809393a3251e565e7a749e6470ab37f7aef186ceaf5167bffac"; + let cases: [(&str, &str, [&str; 6]); 5] = [ + ("page 7", PAGE_7, ["###.#.#", ".......", "#.####.", "######.", "#.#...#", ".#...##"]), + ( + "page 33", + PAGE_33, + [ + ".#..##........###..####....#.##.#", + ".#.###.###.##.##..#..#...#.....##", + ".....##..#....#.##.######.###..#.", + "##...#..##.###..###.##..#.#.#.#.#", + ".##.#........##.#.#.....###......", + "...#.#####.#..#.###......###..###", + ], + ), + ( + "text 31", + TEXT_31, + [ + "....##....###......#.##.#.#....", + ".#.....##.......##..###.#...#..", + ".#.#.#.####...#####.####...#.#.", + "......#...#...#.##...##...#..#.", + ".#..##.###.#.###..####...#...#.", + "#...#...#..#.#....#.##.#.....#.", + ], + ), + ( + "text 7 on a page of 40", + TEXT_7, + [ + "......#.................................", + ".....##.................................", + "##...##.................................", + "..#.....................................", + "##......................................", + "..#.....................................", + ], + ), + ( + "halftone 33", + HALFTONE_33, + [ + "##...#.###.#.###.....#.####..####", + "##.##.#...#.##.##.##.#...#.#.###.", + "###..#.#....##.###...#..##.##..##", + "##..#...###...##..#.####.###.#...", + "####.###.###.#..#..#.#.####...###", + "#.#..#........#....#.#.##.##..#.#", + ], + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + for (form, stream, picture) in cases { + let w = picture[0].len(); + // As the filter gives it: black as 0, each row padded to a byte with 0s. + let mut rows = vec![0u8; w.div_ceil(8) * picture.len()]; + for (y, row) in picture.iter().enumerate() { + for (x, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'.') { + rows[y * w.div_ceil(8) + x / 8] |= 0x80 >> (x % 8); + } + } + assert_eq!(decode_jbig2(&hex(stream), None, w as u32, 6, usize::MAX).unwrap(), rows, "{form}"); + } + } +} diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs index e9fed24441..6e9a8bf952 100644 --- a/crates/filters/src/jbig2/symbols.rs +++ b/crates/filters/src/jbig2/symbols.rs @@ -11,7 +11,7 @@ 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 super::{at_pixels, code_generic, generic_region, page_information, region_information, segment, segment_of}; use crate::mq::{Context, MqEncoder}; /// The longest side of a mark stored as a symbol. A larger one is left to the generic region. @@ -27,7 +27,9 @@ 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. +/// refuses a dictionary whose new and exported symbols pass 65,535 together, and a plan exports +/// every symbol it codes, so it refuses 32,768 or more; 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 @@ -35,14 +37,14 @@ const MAX_CODED_SYMBOLS: usize = (1 << 15) - 1; /// 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 symbols a group's shared dictionary holds ([`encode_shared`]): half of what a page may +/// place, so each page keeps room for its own. +const MAX_SHARED_SYMBOLS: usize = MAX_CODED_SYMBOLS / 2; -/// 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; +// Segment numbers stay at most 256, so a referred-to one takes a byte (7.2.5): the shared +// dictionary, the page, its own 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) + 4 <= 256); /// 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. @@ -99,20 +101,111 @@ struct Instance { height: u32, } +/// A group of images coded by [`encode_shared`]: the globals' data, and each image's stream. +type SharedCoding = (Vec, Vec>>); + +/// The symbols of a group's shared dictionary, as one image's [`plan`] places them: the ID each of +/// the image's symbols has there, if it is there, and how many symbols that dictionary exports. +struct Shared { + ids: Vec>, + count: 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) + let page = page(samples, columns)?; + [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None, None)).min_by_key(Vec::len) +} + +/// Images, each as [`encode`] takes one, symbol coded sharing one dictionary: the globals' data, a +/// symbol dictionary segment associated with no page (number 0) holding the distinct marks found +/// in more than one image, and each image's stream placing them from there, the smaller of +/// [`encode`]'s two plans for its other marks. The group's marks are held within the limits one +/// image's are ([`MAX_SYMBOLS`], [`MAX_SYMBOL_PIXELS`]): past them, later marks aren't shared. Past +/// [`MAX_SHARED_SYMBOLS`] shared marks, those in the most images are kept. +/// +/// An image too large or too busy to look at, or that places none of the shared marks, gets `None`; +/// `None` overall when fewer than two marks are shared. +pub(super) fn encode_shared(images: &[(&[u8], u32, u32)]) -> Option { + // The group's distinct marks, each with how many images hold it (as its uses), looked at an + // image at a time. + let mut table: Vec = Vec::new(); + let mut by_hash: HashMap> = HashMap::new(); + let mut held = 0usize; + for (samples, columns, _) in images { + let Some(page) = page(samples, *columns) else { continue }; + // An image's symbols are distinct, so each counts an image once. + for symbol in page.symbols { + match find(&table, &by_hash, &symbol) { + Some(t) => { + if let Some(s) = table.get_mut(t as usize) { + s.uses = s.uses.saturating_add(1); + } + } + None => { + let Some(more) = held.checked_add(symbol.pixels.len()).filter(|h| *h <= MAX_SYMBOL_PIXELS && table.len() < MAX_SYMBOLS) else { + continue; + }; + held = more; + let t = u32::try_from(table.len()).ok()?; + by_hash.entry(fingerprint(&symbol)).or_default().push(t); + table.push(Symbol { uses: 1, ..symbol }); + } + } + } + } + let uses = |t: &u32| table.get(*t as usize).map_or(0, |s| s.uses); + let mut shared: Vec = (0..u32::try_from(table.len()).ok()?).filter(|t| uses(t) >= 2).collect(); + if shared.len() < 2 { + return None; + } + shared.sort_by_key(|t| std::cmp::Reverse(uses(t))); + shared.truncate(MAX_SHARED_SYMBOLS); + // The dictionary's order, as `plan` keeps it. + shared.sort_by_key(|t| table.get(*t as usize).map(|s| (s.height, s.width))); + let mut global = vec![None; table.len()]; + for (k, t) in shared.iter().enumerate() { + if let Some(slot) = global.get_mut(*t as usize) { + *slot = u32::try_from(k).ok(); + } + } + let symbols: Vec<&Symbol> = shared.iter().filter_map(|t| table.get(*t as usize)).collect(); + let mut globals = Vec::new(); + segment_of(&mut globals, 0, 0, 0, &[], true, &symbol_dictionary(&symbols)?); + let count = u32::try_from(symbols.len()).ok()?; + let streams = images + .iter() + .map(|(samples, columns, rows)| { + let page = page(samples, *columns)?; + let ids = page.symbols.iter().map(|s| find(&table, &by_hash, s).and_then(|t| global.get(t as usize).copied().flatten())).collect(); + let shared = Shared { ids, count }; + [1, 2].into_iter().filter_map(|copies| plan(&page, *columns, *rows, copies, None, Some(&shared))).min_by_key(Vec::len) + }) + .collect(); + Some((globals, streams)) +} + +/// What sorts a symbol's bitmap ([`symbols`] and [`encode_shared`] look exact copies up by it). +fn fingerprint(s: &Symbol) -> u64 { + let mut hasher = DefaultHasher::new(); + (s.width, s.height, &s.pixels).hash(&mut hasher); + hasher.finish() +} + +/// The index in `table` of the symbol with `s`'s bitmap, looked up by its [`fingerprint`]. +fn find(table: &[Symbol], by_hash: &HashMap>, s: &Symbol) -> Option { + by_hash + .get(&fingerprint(s))? + .iter() + .copied() + .find(|t| table.get(*t as usize).is_some_and(|t| (t.width, t.height) == (s.width, s.height) && t.pixels == s.pixels)) } /// 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; - } +fn page(samples: &[u8], columns: u32) -> Option { let width = usize::try_from(columns).ok()?; let stride = width.div_ceil(8); if stride == 0 { @@ -272,23 +365,18 @@ fn symbols(page: &mut Page) -> Option<()> { 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 symbol = Symbol { width, height, pixels: pixels(mark, &page.runs, &page.order), uses: 0 }; 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 { + let id = match find(symbols, &by_hash, &symbol) { Some(id) => id, None => { - kept = kept.checked_add(pixels.len())?; + kept = kept.checked_add(symbol.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); + by_hash.entry(fingerprint(&symbol)).or_default().push(id); + symbols.push(symbol); id } }; @@ -305,18 +393,28 @@ fn symbols(page: &mut Page) -> Option<()> { /// 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> { +/// +/// With `shared`, the symbols a group's dictionary holds are placed from there, whatever their uses +/// here, and the page's own dictionary holds only the others (none, when it has none), as +/// [`encode_shared`] writes them; `None` too for a page that places none of the shared ones. +fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option, shared: Option<&Shared>) -> Option> { let symbol = |s: u32| page.symbols.get(s as usize); + let global = |s: u32| shared.and_then(|g| g.ids.get(s as usize).copied().flatten()); + let globals = shared.map_or(0, |g| g.count); // 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 { + let mut kept: Vec = + (0..page.symbols.len() as u32).filter(|s| global(*s).is_none() && symbol(*s).is_some_and(|s| s.uses >= copies)).collect(); + let count = usize::try_from(globals).ok()?.checked_add(kept.len())?; + if !(2..=MAX_CODED_SYMBOLS).contains(&count) { return None; } kept.sort_by_key(|s| symbol(*s).map(|s| (s.height, s.width))); - let mut id = vec![None; page.symbols.len()]; + // The shared symbols' IDs come first, as their dictionary is referred to first (Table 31's + // SBSYMS). + let mut id: Vec> = (0..page.symbols.len() as u32).map(global).collect(); for (k, s) in kept.iter().enumerate() { if let Some(slot) = id.get_mut(*s as usize) { - *slot = u32::try_from(k).ok(); + *slot = u32::try_from(k).ok().and_then(|k| k.checked_add(globals)); } } let mut instances = Vec::new(); @@ -329,9 +427,12 @@ fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option left.push(*m), } } + if shared.is_some() && !instances.iter().any(|i| i.id < globals) { + return None; + } 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()?; + let dictionary = if kept.is_empty() { None } else { Some(symbol_dictionary(&kept)?) }; + let count = u32::try_from(count).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(); @@ -344,12 +445,21 @@ fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option = shared.map(|_| 0).into_iter().collect(); + if let Some(dictionary) = &dictionary { + segment(&mut out, number, 0, &[], true, dictionary); + dictionaries.push(u8::try_from(number).ok()?); + number += 1; + } 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); + // The dictionaries stay retained for the next region, until the last (7.2.4). + let refers_to: Vec<(u8, bool)> = dictionaries.iter().map(|n| (*n, k + 1 < regions.len())).collect(); + segment(&mut out, number, 7, &refers_to, false, text); number += 1; } if !left.is_empty() { @@ -543,8 +653,9 @@ fn encode_id(mq: &mut MqEncoder, contexts: &mut [Context], length: u32, id: u32) #[cfg(test)] mod tests { use super::*; - use crate::jbig2::tests::{decode, samples, unpack}; - use crate::jbig2::{encode_jbig2, encode_jbig2_generic}; + use crate::FilterError; + use crate::jbig2::tests::{decode, decode_with, samples, unpack}; + use crate::jbig2::{encode_jbig2, encode_jbig2_generic, encode_jbig2_shared}; type Bitmap = Vec>; @@ -665,11 +776,11 @@ mod tests { 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 page = page(&data, columns).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)) { + if let Some(coded) = plan(&page, columns, rows, copies, Some(log_strips), None) { assert_eq!(decode(&coded), unpack(&data, columns, rows), "{copies} copies, strips of {}", 1 << log_strips); plans += 1; } @@ -735,10 +846,10 @@ mod tests { row[stride - 1] &= 0xFF << (8 * stride - columns); } let (c, r) = (columns as u32, 120); - let page = page(&data, c, r).unwrap(); + let page = page(&data, c).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) { + if let Some(coded) = plan(&page, c, r, copies, None, None) { assert_eq!(decode(&coded), unpack(&data, c, r), "{columns} columns"); } } @@ -771,7 +882,7 @@ mod tests { row[290] = 1; } let data = samples(&picture); - let page = page(&data, 300, 300).unwrap(); + let page = page(&data, 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, @@ -792,18 +903,14 @@ mod tests { 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. + /// Past 2^20 black runs the image is left to generic coding; at the cap it is still looked at. #[test] - fn busy_or_tall_images_are_left_to_generic_coding() { + fn busy_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()); + assert!(page(&row.repeat(2), columns as u32).is_some(), "2^20 runs"); + assert!(page(&row.repeat(3), columns as u32).is_none(), "1.5 × 2^20 runs"); } /// A plan codes at most 32,767 symbols, which an independent decoder reads back; with more, @@ -828,18 +935,18 @@ mod tests { (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)); + let at_cap = page(&data, columns).unwrap(); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None, 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()); + let past = page(&data, columns).unwrap(); + assert!(plan(&past, columns, rows, 1, None, None).is_none() && plan(&past, columns, rows, 2, None, 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)); + let past = page(&data, columns).unwrap(); + assert!(plan(&past, columns, rows, 1, None, None).is_none()); + assert_eq!(decode(&plan(&past, columns, rows, 2, None, 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]; @@ -857,10 +964,10 @@ mod tests { 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()); + let (data, columns, _) = codes(MAX_SYMBOLS, 0); + assert_eq!(page(&data, columns).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); + let (data, columns, _) = codes(MAX_SYMBOLS + 1, 0); + assert!(page(&data, columns).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| { @@ -876,11 +983,11 @@ mod tests { (samples(&picture), columns as u32, rows as u32) }; let (data, columns, rows) = squares(256); - let at_cap = page(&data, columns, rows).unwrap(); + let at_cap = page(&data, columns).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()); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None, None).unwrap()), unpack(&data, columns, rows)); + let (data, columns, _) = squares(257); + assert!(page(&data, columns).is_none()); } /// More than 10,000 marks are placed by several text regions, each of at most 10,000 (more @@ -993,7 +1100,199 @@ mod tests { } } + /// Each segment's page association (7.2.6): the byte after the numbers it refers to. + fn associations(data: &[u8]) -> Vec { + let (mut pages, mut at) = (Vec::new(), 0); + while at < data.len() { + let refers = usize::from(data[at + 5] >> 5); + pages.push(data[at + 6 + refers]); + let length_at = at + 6 + refers + 1; + at = length_at + 4 + u32::from_be_bytes(data[length_at..length_at + 4].try_into().unwrap()) as usize; + } + pages + } + + /// `pictures`, `columns` × `rows` each, coded sharing a dictionary; each image's stream, which + /// has to be there, decodes with the globals to its picture. + fn shared_round_trip(pictures: &[Bitmap]) -> (Vec, Vec>) { + let (columns, rows) = (pictures[0][0].len() as u32, pictures[0].len() as u32); + let data: Vec> = pictures.iter().map(|p| samples(p)).collect(); + let images: Vec<(&[u8], u32, u32)> = data.iter().map(|d| (&d[..], columns, rows)).collect(); + let shared = encode_jbig2_shared(&images).unwrap().unwrap(); + let (globals, coded) = (shared.globals, shared.images); + let coded: Vec> = coded.into_iter().map(Option::unwrap).collect(); + for (d, c) in data.iter().zip(&coded) { + assert_eq!(decode_with(c, Some(&globals)), unpack(d, columns, rows)); + } + (globals, coded) + } + + /// `font` with a stem 20 pixels tall down each glyph's left side: a font sharing no glyph with + /// any [`glyphs`] gives, whose glyphs are at most 18 pixels tall. + fn stemmed(font: &[Bitmap]) -> Vec { + font.iter() + .map(|g| { + (0..g.len().max(20)).map(|y| std::iter::once(1).chain(g.get(y).cloned().unwrap_or_else(|| vec![0; g[0].len()])).collect()).collect() + }) + .collect() + } + + /// Mark `k` of a set of distinct ones: a stroke 33 pixels long with a pixel above every other + /// column for each 1 bit of `k`, below 2^17. + fn comb(k: usize) -> Bitmap { + let mut g = vec![vec![0u8; 33]; 2]; + g[1].fill(1); + for bit in 0..17 { + g[0][2 * bit] = u8::from(k >> bit & 1 == 1); + } + g + } + + /// The combs `ks`, 120 a row, the rows four pixels apart. + fn combs(ks: impl ExactSizeIterator) -> Bitmap { + let mut page = vec![vec![0u8; 120 * 36]; ks.len().div_ceil(120) * 4]; + for (i, k) in ks.enumerate() { + draw(&mut page, &comb(k), (i % 120 * 36) as i64, (i / 120 * 4) as i64); + } + page + } + + /// The symbols a symbol dictionary segment exports (SDNUMEXSYMS, 7.4.2.1.4), in a stream + /// that is that segment alone: after its header, flags and adaptive pixels. + fn exported(globals: &[u8]) -> usize { + u32::from_be_bytes(globals[21..25].try_into().unwrap()) as usize + } + + /// Pages in one font share its letters: one symbol dictionary, segment 0, associated with no + /// page and retained (7.2.4, 7.2.6), which each page's text region refers to first, its own + /// segments numbered after it (7.2.5). Together they take less than they take alone. + #[test] + fn pages_in_one_font_share_one_dictionary() { + let font = glyphs(40, 7); + let pictures: Vec = (0..4).map(|seed| text(1200, 300, &font, 20 + seed)).collect(); + let (globals, coded) = shared_round_trip(&pictures); + assert_eq!(segments(&globals), [(0, 0, true, vec![])]); + assert_eq!(associations(&globals), [0]); + let (mut shared, mut alone) = (globals.len(), 0); + for (picture, c) in pictures.iter().zip(&coded) { + let segments = segments(c); + assert_eq!(segments.first().map(|s| s.0), Some(1), "the page's own segments follow"); + assert!(segments.iter().filter(|s| s.1 == 7).all(|s| s.3.first() == Some(&(0, false))), "{segments:?}"); + assert!(associations(c).iter().all(|page| *page == 1)); + shared += c.len(); + alone += encode_jbig2(&samples(picture), 1200, 300).unwrap().len(); + } + assert!(shared < alone, "shared {shared} bytes, alone {alone}"); + } + + /// Two pages alike place every mark from the shared dictionary and have none of their own; a + /// page with letters of its own as well keeps them in its own dictionary, referred to after the + /// shared one (its symbols' IDs follow), and the page with nothing shared has no stream. + #[test] + fn what_a_page_has_alone_stays_its_own() { + let (font, other) = (glyphs(30, 7), stemmed(&glyphs(20, 99))); + let same = text(900, 240, &font, 3); + let mut more = text(900, 240, &font, 4); + more.extend(text(900, 120, &other, 5)); + let (globals, coded) = shared_round_trip(&[same.clone(), same.clone()]); + assert!(!globals.is_empty()); + assert!(coded.iter().all(|c| segment_types(c) == [48, 7]), "{:?}", coded.iter().map(|c| segment_types(c)).collect::>()); + let with_more = |p: &Bitmap| p.iter().cloned().chain(std::iter::repeat_n(vec![0u8; 900], 120)).collect::(); + let (_, coded) = shared_round_trip(&[with_more(&same), more]); + assert_eq!(segment_types(&coded[1])[..3], [48, 0, 7]); + assert_eq!(segments(&coded[1])[2].3, [(0, false), (2, false)]); + let alone = text(900, 360, &other, 6); + let data: Vec> = [&same, &same, &alone].iter().map(|p| samples(p)).collect(); + let images: Vec<(&[u8], u32, u32)> = vec![(&data[0], 900, 240), (&data[1], 900, 240), (&data[2], 900, 360)]; + let (_, coded) = encode_shared(&images).unwrap(); + assert!(coded[0].is_some() && coded[1].is_some() && coded[2].is_none()); + } + + /// Images that share fewer than two marks share nothing; two are enough. + #[test] + fn images_with_nothing_in_common_share_nothing() { + let font = glyphs(30, 7); + let (a, b) = (samples(&text(600, 200, &font, 1)), samples(&text(600, 200, &stemmed(&font), 1))); + assert!(encode_shared(&[(&a, 600, 200), (&b, 600, 200)]).is_none()); + assert!(encode_shared(&[(&a, 600, 200)]).is_none(), "one image"); + assert!(encode_shared(&[]).is_none()); + // The two commonest letters of the first page, below the last line of the second. + let mut two = text(600, 200, &stemmed(&font), 1); + draw(&mut two, &font[0], 10, 180); + draw(&mut two, &font[1], 100, 180); + let two = samples(&two); + let (globals, coded) = encode_shared(&[(&a, 600, 200), (&two, 600, 200)]).unwrap(); + assert_eq!(exported(&globals), 2); + assert_eq!(decode_with(coded[1].as_ref().unwrap(), Some(&globals)), unpack(&two, 600, 200)); + } + + /// The images of a group are checked as `encode_jbig2_generic` checks one, and an image it + /// would refuse refuses the group. + #[test] + fn a_group_with_an_image_the_encoder_refuses_is_refused() { + let page = samples(&text(600, 200, &glyphs(30, 7), 1)); + assert!(matches!(encode_jbig2_shared(&[(&page, 600, 200), (&page[..10], 600, 200)]), Err(FilterError::Corrupt { .. }))); + assert!(matches!(encode_jbig2_shared(&[(&page, 600, 200), (&page, 0, 200)]), Err(FilterError::Unsupported(_)))); + assert!(encode_jbig2_shared(&[(&page, 600, 200), (&page, 600, 200)]).unwrap().is_some()); + } + + /// A group holds its distinct marks within what one image's are held to: past 65,536 a mark + /// first seen later isn't held, so isn't shared, while those held count every image they're + /// in. Ten marks of the first image are in two images after it, with 464 first seen there. + #[test] + fn past_the_marks_a_group_holds_later_ones_arent_shared() { + let (a, b) = (samples(&combs(0..40_000)), samples(&combs(40_000..65_536))); + let late = combs((0..10).chain(65_536..66_000).collect::>().into_iter()); + let (rows, late) = (late.len() as u32, samples(&late)); + let (columns, a_rows, b_rows) = (120 * 36, 40_000u32.div_ceil(120) * 4, 25_536u32.div_ceil(120) * 4); + let images = [(&a[..], columns, a_rows), (&b[..], columns, b_rows), (&late[..], columns, rows), (&late[..], columns, rows)]; + let (globals, coded) = encode_shared(&images).unwrap(); + assert_eq!(exported(&globals), 10); + for c in &coded[2..] { + assert_eq!(decode_with(c.as_ref().unwrap(), Some(&globals)), unpack(&late, columns, rows)); + } + } + + /// Past what one dictionary shares, it keeps the marks the most images hold: 20,000 shared, + /// the first 10,000 in all three images and the rest in two. The third image then places all + /// its marks from it, and the other two code the rest themselves (in a dictionary of their own + /// or a generic region, whichever is smaller). + #[test] + fn past_the_shared_limit_the_most_widely_held_marks_are_shared() { + let (both, first) = (combs(0..20_000), combs(0..10_000)); + let rows = both.len(); + let first: Bitmap = first.iter().cloned().chain(std::iter::repeat_n(vec![0u8; 120 * 36], rows - first.len())).collect(); + let (globals, coded) = shared_round_trip(&[both.clone(), both, first]); + assert_eq!(exported(&globals), MAX_SHARED_SYMBOLS); + assert_eq!(segment_types(&coded[2]), [48, 7]); + assert!(segment_types(&coded[0]).iter().any(|t| *t == 0 || *t == 39), "{:?}", segment_types(&coded[0])); + } + proptest::proptest! { + /// Glyphs scattered over pages of one font: whatever is shared, every page with a stream + /// decodes with the globals to its picture. + #[test] + fn shared_groups_round_trip( + columns in 1usize..200, + rows in 1usize..100, + shapes in 2usize..8, + n in 0usize..60, + pages in 2usize..5, + seed in proptest::num::u64::ANY, + ) { + let font = glyphs(shapes, seed); + let data: Vec> = (0..pages).map(|p| samples(&scatter(columns, rows, &font, seed ^ p as u64, n))).collect(); + let (c, r) = (columns as u32, rows as u32); + let images: Vec<(&[u8], u32, u32)> = data.iter().map(|d| (&d[..], c, r)).collect(); + if let Some((globals, coded)) = encode_shared(&images) { + for (d, coded) in data.iter().zip(coded) { + if let Some(coded) = coded { + proptest::prop_assert_eq!(decode_with(&coded, Some(&globals)), unpack(d, c, r)); + } + } + } + } + /// 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] @@ -1024,6 +1323,7 @@ mod tests { 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); + let _ = encode_shared(&[(&data, columns, rows), (&data, columns, rows.saturating_sub(1))]); } } } diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 7eac1a83e1..0b50b0efe0 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -6,8 +6,29 @@ //! 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. 1-bit samples can be encoded as CCITT Group 4 -//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]). +//! 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`]), a group of images sharing their marks through `/JBIG2Globals` +//! ([`encode_jbig2_shared`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -26,8 +47,8 @@ mod runlength; use std::borrow::Cow; -pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4}; -pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic}; +pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt_within, encode_g4}; +pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, SharedJbig2, decode_jbig2_within, encode_jbig2, encode_jbig2_generic, encode_jbig2_shared}; /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -89,7 +110,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) } } @@ -121,33 +143,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 @@ -155,47 +197,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 } @@ -214,15 +307,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) } @@ -234,12 +327,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) @@ -426,6 +519,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, @@ -444,4 +540,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/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/forms/README.md b/crates/forms/README.md index ba9dc18f48..0c8f5ad9b9 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -50,7 +50,10 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table keeps it: `/V` is stored as typed, the appearance draws `?` for those characters, the widget is marked with the first one (`pdfcraft_fonts::UNDRAWN_MARK`, cleared once all of it can be drawn) and the form sets `/NeedAppearances`, so other viewers draw the field from its value with fonts - that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`). + that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`: + the bytes `pdfaid:part` in XMP metadata each filter stage of which decodes within 8 MiB, the cap + preflight's Standards panel reads it with; preflight reads the identifier by its namespace, so a + packet that gives that namespace another prefix declares PDF/A there and not here). `undrawn_fields` lists the marked fields; the engine reports each edit that draws one, and flattening refuses to make that `?` permanent. - Displaying a form that leaves its appearances to the viewer (`/NeedAppearances`, or a text or diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index 64569fa3ca..02c1eec5bc 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -212,13 +212,24 @@ impl FieldAppearanceCache { } } -/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). +/// The most each filter stage of the XMP metadata stream may decode for its PDF/A declaration to +/// be read, the cap preflight's Standards panel reads it with: past it, metadata declares nothing +/// there either. +pub(crate) const MAX_XMP_BYTES: usize = 8 << 20; + +/// Whether the document's XMP metadata declares PDF/A conformance (the bytes `pdfaid:part`). +/// Metadata a filter stage of which decodes past [`MAX_XMP_BYTES`] doesn't. 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")), + // Not a `DecodeBudget`, which counts every stage against one allowance: preflight bounds + // each stage, and the two must agree on metadata stored through a chain of filters. + // `/DecodeParms` is read only when direct, as preflight and organize read metadata too: + // PDF/A asks for it unfiltered, so only a file that breaks that reaches parameters here, + // and the readers go to `with_direct_parms` together or they disagree. + Object::Stream(s) => s.decoded_within(MAX_XMP_BYTES).is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), _ => false, } } diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 2d05600ae2..14659c4f52 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -234,6 +234,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), + // An absent key would inherit the parent's limit; zero is "no limit" (`read.rs`). + None if parent_limit => d.set(b"MaxLen".to_vec(), Object::Int(0)), None => { d.remove(b"MaxLen"); } @@ -583,10 +586,15 @@ fn apply_late_widget_props( } /// Name field `old` `new` in each field's Calculate script that reads it -/// ([`crate::af::rename_in_calculation`]). +/// ([`crate::af::rename_in_calculation`]). Script streams share one budget, as a reading of the +/// form's scripts does, however many fields name the same stream; past it, a script is left as +/// it is, like one that won't decode. A Calculate action several fields share by reference is +/// read and rewritten once, in place, so they all read the new name. 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()); + let mut scripts = crate::script_budget(); + let mut shared = std::collections::HashSet::new(); 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. @@ -595,12 +603,16 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st _ => None, }; let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; + let by_ref = aa.get(b"C").and_then(Object::as_ref); + if by_ref.is_some_and(|r| !shared.insert(r)) { + 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(Object::Stream(s)) => match scripts.decode(s).ok().and_then(|b| String::from_utf8(b).ok()) { Some(js) => js, None => continue, }, @@ -608,6 +620,10 @@ fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &st }; let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; action.set(b"JS".to_vec(), PdfString::text(&js)); + if let Some(r) = by_ref { + doc.update_dict(r, |d| *d = action)?; + continue; + } aa.set(b"C".to_vec(), Object::Dict(action)); doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; } diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 386671f433..94ad7adb37 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -6,7 +6,7 @@ //! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan). use pdfcraft_content::{Matrix, Pieces}; -use pdfcraft_cos::{Document, Object}; +use pdfcraft_cos::{DecodeBudget, Document, Object}; pub mod raster; @@ -49,6 +49,60 @@ pub struct Candidate { /// points along its sides (#2942: a cell drawn with a midpoint on each vertical side). const MAX_RECT_POINTS: usize = 16; +/// Graphics states saved (`q`) and kept at once while detecting; deeper saves are counted, not kept. +const MAX_SAVES: usize = 256; + +/// The graphics states a page saves (`q`) as detection follows its matrix: at most [`MAX_SAVES`] +/// kept, deeper ones counted so each `Q` still closes its own. Of those not kept: the matrix when +/// the first was made, which closing the last restores; the shallowest a `cm` changed the matrix +/// in; and whether a `Q` has closed that one, so the matrix isn't known until the last closes. +struct Saves { + kept: Vec, + lost: usize, + lost_base: Matrix, + moved_at: usize, + unknown: bool, +} + +impl Default for Saves { + fn default() -> Self { + Saves { kept: Vec::new(), lost: 0, lost_base: Matrix::IDENTITY, moved_at: usize::MAX, unknown: false } + } +} + +impl Saves { + fn save(&mut self, ctm: Matrix) { + if self.kept.len() < MAX_SAVES && self.lost == 0 { + self.kept.push(ctm); + } else { + if self.lost == 0 { + self.lost_base = ctm; + } + self.lost = self.lost.saturating_add(1); + } + } + + fn restore(&mut self, ctm: &mut Matrix) { + if self.lost == 0 { + *ctm = self.kept.pop().unwrap_or(*ctm); + return; + } + self.lost -= 1; + if self.lost == 0 { + (*ctm, self.moved_at, self.unknown) = (self.lost_base, usize::MAX, false); + } else if self.lost < self.moved_at { + self.unknown = true; + } + } + + /// A `cm` changed the matrix at the current depth. + fn moved(&mut self) { + if self.lost > 0 { + self.moved_at = self.moved_at.min(self.lost); + } + } +} + /// The rectangles and horizontal segments a page draws (stroked or filled), in user space. #[derive(Default)] struct DetectionPath { @@ -125,7 +179,15 @@ impl DetectionPath { } } +/// The shapes page `page`'s content draws, its streams decoded within one page's allowance +/// ([`DecodeBudget::page`]), however often `/Contents` lists the same stream. pub fn page_shapes(doc: &Document, page: usize) -> Shapes { + shapes_within(doc, page, &mut DecodeBudget::page()) +} + +/// [`page_shapes`], decoding against `budget`: a stream `/Contents` lists again is charged again, +/// and one past what is left reads as empty. +pub(crate) fn shapes_within(doc: &Document, page: usize, budget: &mut DecodeBudget) -> Shapes { let mut out = Shapes::default(); let Some(p) = pdfcraft_model::pages(doc).into_iter().nth(page) else { return out }; let list: Vec = match p.dict.get(b"Contents").map(|c| doc.resolve(c)) { @@ -139,24 +201,36 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let mut pieces = Vec::new(); for o in list { if let Object::Stream(s) = &*doc.resolve(&o) { - pieces.push(s.decoded().unwrap_or_default()); + pieces.push(budget.decode(s).unwrap_or_default()); } } let mut ctm = Matrix::IDENTITY; - let mut stack = Vec::new(); + let mut saves = Saves::default(); let mut path_boxes: Vec<[f64; 4]> = Vec::new(); let mut subpaths: Vec = Vec::new(); let mut path = DetectionPath::default(); let mut cur: Option<[f64; 2]> = None; - for op in Pieces::join(&pieces).parse().ops { + let joined = Pieces::join(&pieces); + drop(pieces); + // An operator at a time: dense content (`n n n …`) parsed whole holds about 80 bytes for each + // byte it decoded. + for op in pdfcraft_content::ops(joined.data()) { match op.op.as_slice() { - b"q" => stack.push(ctm), - b"Q" => ctm = stack.pop().unwrap_or(ctm), + b"q" => saves.save(ctm), + b"Q" => saves.restore(&mut ctm), b"cm" => { if let Some(m) = op.nums::<6>() { ctm = Matrix(m).then(&ctm); + saves.moved(); } } + // Painted where the matrix isn't known: dropped, as `n` drops a path. + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" if saves.unknown => { + path_boxes.clear(); + subpaths.clear(); + path = DetectionPath::default(); + cur = None; + } b"re" => { path.finish(&mut subpaths); if let Some([x, y, w, h]) = op.nums::<4>() { diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index e689c0e813..34775ee75e 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -192,11 +192,17 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Decode out } -fn values_of(doc: &Document, o: Option<&Object>) -> Vec { +/// Text streams of field values (`/V`, `/DV`) one reading of the form may decode in all, however +/// many fields name the same stream. +pub(crate) const MAX_VALUE_BYTES: usize = 64 << 20; + +/// A field's value or default value (`/V`, `/DV`). A text stream is decoded against `budget`, and +/// reads as no value once it is spent, as one that won't decode does. +fn values_of(doc: &Document, o: Option<&Object>, budget: &mut DecodeBudget) -> 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(), + Object::Stream(s) => budget.decode(s).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(), } } @@ -345,7 +351,12 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { 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() }; + let mut seen = Seen { + fields: std::collections::HashSet::new(), + widgets: std::collections::HashSet::new(), + scripts: script_budget(), + values: DecodeBudget::new(MAX_VALUE_BYTES, 0), + }; 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() { @@ -416,13 +427,14 @@ fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet, /// A widget belongs to the first field that lists it, however often it is listed. widgets: std::collections::HashSet, scripts: DecodeBudget, + values: DecodeBudget, } /// Read the field at `r` and the fields under it into `out`, with what they inherit from @@ -549,8 +561,8 @@ fn walk( name: inh.name.clone(), obj: r, kind, - value: values_of(doc, inh.v.as_ref()), - default: values_of(doc, inh.dv.as_ref()), + value: values_of(doc, inh.v.as_ref(), &mut seen.values), + default: values_of(doc, inh.dv.as_ref(), &mut seen.values), flags: ff, max_len: inh.max_len, options, diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index cca4cb4881..3948e06e83 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3575,6 +3575,149 @@ fn repeated_script_references_share_one_decode_budget() { assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); } +/// An unfiltered stream of `body`, as an object of [`document`]. +fn raw_stream(body: &str) -> String { + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()) +} + +/// 300 fields whose value is one 1 MiB text stream: a reading of the form decodes their values +/// within one budget, not 300 MiB. +#[test] +fn repeated_value_streams_share_one_decode_budget() { + let fields_list: String = (0..300).map(|i| format!("{} 0 R ", i + 3)).collect(); + let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"x".repeat(1 << 20))]; + objs.extend((0..300).map(|i| format!("<< /FT /Tx /T (f{i}) /V 2 0 R /DV 2 0 R >>"))); + let all = fields(&document(&objs)); + assert_eq!(all.len(), 300); + let read: usize = all.iter().flat_map(|f| f.value.iter().chain(&f.default)).map(String::len).sum(); + assert!(read > 0 && read <= crate::read::MAX_VALUE_BYTES, "{read} bytes of values"); + assert_eq!(all[0].value, ["x".repeat(1 << 20)], "the first field still reads its value"); +} + +/// Clear form leaves a field as it is when its default is a stream the value budget didn't reach: +/// 70 fields typed in, whose default is one 1 MiB text stream, keep a value, the first ones +/// their default and the rest what was typed, none of them emptied. +#[test] +fn clear_form_keeps_a_field_whose_default_was_not_read() { + let n = 70; + let fields_list: String = (0..n).map(|i| format!("{} 0 R ", i + 3)).collect(); + let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"d".repeat(1 << 20))]; + objs.extend((0..n).map(|i| format!("<< /FT /Tx /T (f{i}) /V (typed) /DV 2 0 R >>"))); + let mut doc = document(&objs); + reset(&mut doc, None).unwrap(); + let values: Vec = (0..n) + .map(|i| { + let field = doc.get(ObjRef::new(i as u32 + 3, 0)); + field.as_dict().and_then(|d| d.get(b"V")).and_then(|v| v.as_string().map(PdfString::to_text)).unwrap_or_default() + }) + .collect(); + assert!(values.iter().all(|v| !v.is_empty()), "no field is emptied"); + assert_eq!(values[0], "d".repeat(1 << 20), "the first field goes back to its default"); + assert_eq!(values[n - 1], "typed", "the last field, whose default wasn't read, keeps its value"); + // A value the reading didn't decode, with no default, is cleared: it isn't known to be empty. + let mut objs = vec![format!("<< /Type /Catalog /AcroForm << /Fields [{fields_list}] >> >>"), raw_stream(&"v".repeat(1 << 20))]; + objs.extend((0..n).map(|i| format!("<< /FT /Tx /T (f{i}) /V 2 0 R >>"))); + let mut doc = document(&objs); + reset(&mut doc, None).unwrap(); + let last = doc.get(ObjRef::new(n as u32 + 2, 0)); + assert!(last.as_dict().and_then(|d| d.get(b"V")).is_none_or(|v| !matches!(v, Object::Ref(_))), "the last field's unread value is cleared"); +} + +/// Renaming a field that 300 calculations read through one shared Calculate action, whose script +/// is a 256 KiB stream, decodes and rewrites that action once, in place: every calculation reads +/// the new name, and nothing is decoded or written 300 times. +#[test] +fn a_rename_rewrites_a_shared_calculation_once() { + let n = 300; + let refs = |from: usize| -> String { (from..from + n).map(|i| format!("{i} 0 R ")).collect() }; + let js = format!("AFSimple_Calculate(\"SUM\", new Array (\"a\"));{}", " ".repeat(256 << 10)); + let mut objs = 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(), + format!("<< /Type /Page /Parent 2 0 R /Annots [5 0 R {}] >>", refs(8)), + format!("<< /Fields [5 0 R {}] /DA (/Helv 0 Tf 0 g) >>", refs(8)), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /Rect [50 700 250 720] /P 3 0 R >>".into(), + "<< /S /JavaScript /JS 7 0 R >>".into(), + raw_stream(&js), + ]; + objs.extend((0..n).map(|i| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (c{i}) /Rect [50 10 250 30] /P 3 0 R /AA << /C 6 0 R >> >>"))); + let mut doc = document(&objs); + set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); + let action = ObjRef::new(6, 0); + let renamed = (8..8 + n).all(|i| { + let field = doc.get(ObjRef::new(i as u32, 0)); + field.as_dict().and_then(|d| d.get(b"AA")).and_then(|aa| aa.as_dict()?.get(b"C")?.as_ref()) == Some(action) + }); + assert!(renamed, "every calculation still names the one shared action"); + let js = doc.get(action).as_dict().and_then(|d| d.get(b"JS")?.as_string().map(PdfString::to_text)).unwrap_or_default(); + assert!(js.contains("\"qty\"") && !js.contains("\"a\""), "the shared action reads the new name"); + assert!(js.len() <= MAX_SCRIPT_BYTES, "{} bytes written once", js.len()); +} + +/// A page whose `/Contents` lists one 1 MiB stream ten times is read within the budget given: +/// pieces past it read as empty, so their rectangles aren't found. +#[test] +fn page_shapes_decode_a_repeated_content_stream_within_the_budget() { + let piece = format!("%{}\n10 10 20 20 re S\n", "x".repeat(1 << 20)); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Contents [{}] >>", "4 0 R ".repeat(10)), + raw_stream(&piece), + ]); + assert_eq!(crate::detect::page_shapes(&doc, 0).boxes.len(), 10, "one page's allowance reads them all"); + let mut budget = DecodeBudget::new(4 << 20, 0); + let boxes = crate::detect::shapes_within(&doc, 0, &mut budget).boxes.len(); + assert!((1..=4).contains(&boxes), "{boxes} rectangles read within 4 MiB"); + assert_eq!(budget.remaining(), 0, "the budget is spent"); +} + +/// Field detection keeps at most 256 saved graphics states, and each `Q` still restores its own +/// `q` past that: a scale set 300 saves deep is gone once they are all restored. +#[test] +fn page_shapes_restore_saves_past_the_cap() { + let content = format!("{} 2 0 0 2 0 0 cm {} 10 10 20 20 re S", "q ".repeat(300), "Q ".repeat(300)); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(), + raw_stream(&content), + ]); + assert_eq!(crate::detect::page_shapes(&doc, 0).boxes, [[10.0, 10.0, 30.0, 30.0]]); + // A scale set past the cap and closed by its own `Q` leaves a matrix that can't be known until + // the last lost save closes: what is drawn in between isn't reported at the scaled place. + let content = format!("{} q 2 0 0 2 0 0 cm Q 10 10 20 20 re S {} 40 40 10 10 re S", "q ".repeat(257), "Q ".repeat(257)); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(), + raw_stream(&content), + ]); + assert_eq!(crate::detect::page_shapes(&doc, 0).boxes, [[40.0, 40.0, 50.0, 50.0]]); +} + +/// The PDF/A declaration is read from metadata each filter stage of which decodes within +/// [`crate::appearances::MAX_XMP_BYTES`], as preflight decodes it: a field drawn with characters its +/// font lacks decodes no more than that a stage. +#[test] +fn a_pdfa_declaration_is_read_from_metadata_within_its_cap() { + let declared = |xmp: String| declares_pdfa(&document(&["<< /Type /Catalog /Metadata 2 0 R >>".into(), raw_stream(&xmp)])); + let pdfa = |padding: usize| format!("2{}", " ".repeat(padding)); + assert!(declared(pdfa(1 << 20))); + assert!(!declared(pdfa(crate::appearances::MAX_XMP_BYTES)), "metadata past the cap declares nothing"); + assert!(!declared("pdfaid".into()), "metadata without pdfaid:part declares nothing"); + // 3.5 MiB stored as hex, then Flate: its stages decode 7 and 3.5 MiB, each within the cap. + let xmp = pdfa(7 << 19); + let hex: Vec = xmp.bytes().flat_map(|b| [b"0123456789ABCDEF"[usize::from(b >> 4)], b"0123456789ABCDEF"[usize::from(b & 15)]]).collect(); + let mut stream = pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), &hex); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let mut doc = Document::new_empty(); + let metadata = doc.add(Object::Stream(stream)); + let catalog = doc.add(Object::Dict([(b"Metadata".to_vec(), Object::Ref(metadata))].into_iter().collect())); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert!(declares_pdfa(&doc), "metadata through a chain of filters declares PDF/A, as preflight decodes it"); +} + /// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share, /// is read once: the first field to list it owns it. #[test] @@ -5344,6 +5487,29 @@ fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() { assert_eq!(selection_indices(&doc, &list), Some(vec![2])); } +#[test] +fn clearing_a_kids_length_limit_does_not_bring_back_its_parents() { + let mut doc = fixture(); + let city = field(&fields(&doc), "address.city").clone(); + let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); + set_props(&mut doc, "address.city", &FieldProps { max_len: Some(Some(8)), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").max_len, Some(8)); + set_props(&mut doc, "address.city", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert_eq!(f.max_len, None, "no limit, not the parent's 3"); + let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"MaxLen")).and_then(Object::as_int); + assert_eq!(own, Some(0), "the kid holds an explicit unlimited of its own"); + set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap(); + assert_eq!(field(&fields(&reopen(&doc)), "address.city").value, ["Absalom"], "not cut to 3 characters"); + // Without a parent limit, clearing still removes the key. + set_props(&mut doc, "name", &FieldProps { max_len: Some(Some(4)), ..FieldProps::default() }).unwrap(); + set_props(&mut doc, "name", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap(); + let name = field(&fields(&doc), "name").clone(); + assert!(doc.get(name.obj).as_dict().unwrap().get(b"MaxLen").is_none()); +} + #[test] fn clearing_a_kids_default_does_not_bring_back_its_parents() { let mut doc = fixture(); diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index e8615567b6..cf191c2240 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -470,7 +470,9 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc /// 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) { + // The form's own flag first: once it is set, the metadata needn't be decoded again for each + // field drawn after. + if need_appearances(doc) || declares_pdfa(doc) { return Ok(()); } let Some(root) = doc.root() else { return Ok(()) }; @@ -625,6 +627,22 @@ pub fn clear_password_values(doc: &mut Document) -> Result { Ok(targets.len()) } +/// Whether `field`'s `key` (`/V` or `/DV`, its own or inherited) is a stream with data, which +/// [`fields`] reads as empty once its value budget is spent. +fn unread_stream(doc: &Document, field: ObjRef, key: &[u8]) -> bool { + let mut node = Some(field); + for _ in 0..64 { + let Some(r) = node else { break }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { break }; + if let Some(value) = d.get(key) { + return matches!(&*doc.resolve(value), Object::Stream(s) if !s.raw.is_empty()); + } + node = d.reference(b"Parent"); + } + false +} + /// 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); @@ -638,7 +656,11 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result String { if !n.is_finite() { return if n.is_nan() { @@ -35,8 +39,12 @@ pub(super) fn num_text(n: f64) -> String { 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('.'); + // 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() } } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 2d3877efe3..b61649b729 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1026,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] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 9f96b17218..9acb076908 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -28,10 +28,13 @@ 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 by these colour and grey - settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000, - JBIG2 and CCITT (black-and-white images, CCITT among them, are below). 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. Images over 80 million pixels are left + alone by the image steps, here and below, whatever their settings (memory); Clean Up may still + Flate an unfiltered or LZW one. 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. Samples are read with `/DecodeParms` given by @@ -40,7 +43,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **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 after any of those (decoded by `Document::decoded_image_within`, no further + 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 @@ -59,40 +62,69 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going 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 and the painted part of an image - mask (a 0, or a 1 with an inverting decode array), full ink in a separation (a 1, or a 0), and - entry 0 of an indexed image, whatever colour its palette gives it (the palette isn't read yet). - 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); 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 text page, Group 4 at 300 ppi is half the size. + 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; + 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. With Retain, a resampled image - keeps its kind of compression (CCITT, 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. + 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` of its own. With JBIG2 asked for, the images stored as JBIG2, up to 16 at a + time, then share one symbol dictionary in a new `/JBIG2Globals` stream, holding the marks found + in more than one of them: those that come out smaller that way, when together they save more + than it takes. Only images that redaction can store again without it share one: image masks + and DeviceGray images named directly, of at most 64 Mi pixels (redaction's limit, + `pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS`); others are coded alone. + Redacting one of them stores the others of its group again without it, so the group keeps that + to 15 images, and each image's decode reads the dictionary of 16 at most. A + JBIG2 image 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. + image (only images drawn on a page are visited), Group 4 for rows wider than 32,767 + samples, and JBIG2 past 65,535 samples on a side, where the decoder stops (its 2^28 in all + is past the 80 million pixels above). - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when - smaller. No lossy symbol substitution is used. Export reads embedded images and bounded - shared globals. + smaller, a dictionary shared by up to 16 images when that is smaller still. 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 9338061978..41f893c71b 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1,9 +1,10 @@ //! 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, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; @@ -300,6 +301,19 @@ fn device_rgb(doc: &Document, r: ObjRef) -> bool { && 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)) { @@ -449,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) }; } @@ -465,7 +479,7 @@ fn count_decoded(_n: u64) { /// 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) -> Option> { +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; @@ -476,11 +490,14 @@ 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 = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?; + let v = budget.decode_filters(doc, s, allowance(bytes, h)?).ok()?; Some(v.get(..bytes)?.to_vec()) } } @@ -618,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)); @@ -684,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))) { @@ -718,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). @@ -731,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)?)); } @@ -754,7 +802,7 @@ 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 a.first().and_then(Object::as_name) { + 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)), @@ -771,7 +819,7 @@ type Pixel = fn(&[u8]) -> Option; /// 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 last ([`Document::decoded_image_within`], no further +/// 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 @@ -785,7 +833,7 @@ type Pixel = fn(&[u8]) -> Option; /// - 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) -> Option<(Vec, u32, u32, Option)> { +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()?); @@ -802,9 +850,11 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve 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)?, }; - let mut data = doc.decoded_image_within(s, max).ok()?; + count_decoded(u64::from(w) * u64::from(h)); + let mut data = budget.decode(doc, s, max).ok()?; if data.len() < len { return None; } @@ -827,7 +877,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve 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!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + 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 => {} @@ -872,7 +922,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve 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 = doc.with_direct_parms(s).decoded_within(allowance(row_len.checked_mul(hu)?, h)?).ok()?; + 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))) } @@ -908,8 +958,12 @@ fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, whit Some(out) } -/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom -/// colour spaces stay at their original resolution rather than guessing which entry is dark. +/// 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() { @@ -923,12 +977,62 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option { 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` @@ -1000,33 +1104,56 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// `/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`, -/// 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). -/// - **Stored** as CCITT Group 4 with `CcittG4`, 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 stays. Group 4 alone is only decoded to be resampled; Group 3 is stored -/// again like any other. With `Retain`, a resampled image keeps its kind of compression -/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the -/// grey or colour settings. +/// 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`; with `Jbig2`, [`share`] +/// may then have a group of them share one. /// /// 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) -> Option<(Stream, bool)> { +/// +/// Stored as JBIG2 with `Jbig2`, it comes with the 1-bit samples it was coded from, for [`share`] +/// to code again with the run's other JBIG2 images. +fn mono( + doc: &Document, + s: &Stream, + ppi: f64, + defaults: bool, + settings: &MonoSettings, + budget: &mut ImageBudget, +) -> Option<(Stream, bool, Option>)> { 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(); - // Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind - // ASCII85Decode, say, can be smaller stored again as Group 4 alone. - let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1; - if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !downsample { + 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)?; + 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, @@ -1038,8 +1165,8 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { (w, h) }; - let polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; - let averaged = if (nw, nh) != (w, h) && polarity.is_some() { + 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 @@ -1055,7 +1182,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet Some(out) => (out, nw, nh), None => (data, w, h), }; - if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !resampled { + if only_to_resample && !resampled { return None; } let mut dict = packed.unwrap_or_else(|| d.clone()); @@ -1078,7 +1205,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { None }; - let jbig = if settings.compression == MonoCompression::Jbig2 { + 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")); @@ -1087,25 +1214,129 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { None }; - let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data)); + 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)) + let shareable = settings.compression == MonoCompression::Jbig2 && best.1.dict.name(b"Filter") == Some(b"JBIG2Decode"); + (best.0 < crate::stored_len(s)?).then(|| (best.1, resampled, shareable.then_some(data))) } +/// The most images that share one symbol dictionary ([`share`]). Redaction stores again every +/// image that shares a redacted one's dictionary, and each image's decode reads all of it, so a +/// group keeps both to that many; most of what sharing saves comes with the first few images. +const SHARED_IMAGES: usize = 16; + +/// The most bytes of 1-bit samples a group holds: a group is coded before it would pass them +/// ([`hold_in`]). An image that shares has at most [`SHARED_PIXELS`], and as its rows are whole +/// bytes, at most that many bytes, which is this: one always fits a group with nothing in it. +const SHARED_BYTES: usize = 64 << 20; + +/// What the globals cost as an object of their own besides their dictionary and data: `N 0 obj`, +/// `stream`, `endstream` and `endobj`, a cross-reference entry, and an encrypted file's IV and +/// padding. A group is shared only when it saves more than that as well. +const NEW_OBJECT: usize = 96; + +/// Adds `image` to `group`, coding the group (`share`, which empties it) first when the image's +/// samples would take it past `cap` bytes, and after when it has [`SHARED_IMAGES`]. +fn hold_in(group: &mut Vec, image: Held, cap: usize, mut share: impl FnMut(&mut Vec)) { + if group.iter().map(|h| h.bits.len()).fold(image.bits.len(), usize::saturating_add) > cap { + share(group); + } + group.push(image); + if group.len() == SHARED_IMAGES { + share(group); + } +} + +/// The most pixels an image sharing a dictionary has: redaction's own limit on the images it +/// stores again (see [`shares`]). +const SHARED_PIXELS: u64 = pdfcraft_cos::MAX_REDACTED_IMAGE_PIXELS; + +/// Whether an image stored as JBIG2 (its dictionary `d`) may share a dictionary: only one that +/// redaction can store again without it, an image mask or a DeviceGray image, named directly, of +/// at most [`SHARED_PIXELS`]. Redacting one image of a group stores the others so, as the +/// dictionary can hold the shapes of marks under the area, and refuses the redaction for one it +/// can't; an image of another colour space, though redaction may take it, is coded alone. +fn shares(d: &Dict) -> bool { + let side = |key: &[u8]| d.int(key).and_then(|v| u64::try_from(v).ok()); + let within = side(b"Width").zip(side(b"Height")).is_some_and(|(w, h)| w.saturating_mul(h) <= SHARED_PIXELS); + within && (matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) || d.name(b"ColorSpace") == Some(b"DeviceGray")) +} + +/// An image the run stored as JBIG2, held to be stored again sharing a dictionary ([`share`]): its +/// 1-bit samples, `width` × `height`. +struct Held { + image: ObjRef, + bits: Vec, + width: u32, + height: u32, +} + +/// Stores the images of `group`, each JBIG2 already, again sharing one symbol dictionary in a +/// `/JBIG2Globals` stream ([`pdfcraft_filters::encode_jbig2_shared`]): those that come out smaller +/// that way, if together they save more than the globals take. The others stay as they are, and +/// so do all of them when that saves nothing. `report` counts the bytes as they end up, the globals +/// included. +fn share(doc: &mut Document, group: &mut Vec, report: &mut Report) { + let held = std::mem::take(group); + if held.len() < 2 { + return; + } + let images: Vec<(&[u8], u32, u32)> = held.iter().map(|h| (&h.bits[..], h.width, h.height)).collect(); + let Ok(Some(shared)) = pdfcraft_filters::encode_jbig2_shared(&images) else { return }; + let with_globals = |s: &Stream, coded: Vec, globals: ObjRef| { + let mut dict = s.dict.clone(); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }; + // Weighed naming the globals with the longest reference there is: the real one is no longer. + let longest = ObjRef { num: u32::MAX, generation: u16::MAX }; + let (mut chosen, mut saved) = (Vec::new(), 0usize); + for (h, coded) in held.iter().zip(shared.images) { + let Some(coded) = coded else { continue }; + let object = doc.get(h.image); + let Object::Stream(alone) = &*object else { continue }; + let shorter = crate::stored_len(alone).zip(crate::stored_len(&with_globals(alone, coded.clone(), longest))).filter(|(a, b)| b < a); + if let Some((before, after)) = shorter { + saved = saved.saturating_add(before - after); + chosen.push((h.image, coded)); + } + } + let globals = Stream::from_raw(Dict::new(), shared.globals); + if chosen.is_empty() || crate::stored_len(&globals).is_none_or(|takes| saved <= takes.saturating_add(NEW_OBJECT)) { + return; + } + report.image_bytes_after = report.image_bytes_after.saturating_add(globals.raw.len()); + let globals = doc.add(Object::Stream(globals)); + for (image, coded) in chosen { + let object = doc.get(image); + let Object::Stream(alone) = &*object else { continue }; + report.image_bytes_after = report.image_bytes_after.saturating_sub(alone.raw.len()).saturating_add(coded.len()); + let new = with_globals(alone, coded, globals); + doc.set(image, Object::Stream(new)); + } +} + +/// 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, mut defaults, read) = walk_pages(doc, pages); - // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used - // where the walk didn't look may be drawn so. + // 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 rgb: HashSet = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect(); - defaults.extend(used_elsewhere(doc, &rgb, &read)); + 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() @@ -1118,6 +1349,13 @@ pub(crate) fn run( refs.sort_by_key(|(r, _)| r.num); let total = refs.len(); let mut jbig2 = false; + let mut group: Vec = Vec::new(); + // Holds a JBIG2 image for `share`, which codes the group once it is full. + let mut hold = |doc: &mut Document, image: ObjRef, size: (Option, Option), bits: Option>, report: &mut Report| { + let side = |v: Option| v.and_then(|v| u32::try_from(v).ok()); + let (Some(bits), Some(width), Some(height)) = (bits, side(size.0), side(size.1)) else { return }; + hold_in(&mut group, Held { image, bits, width, height }, SHARED_BYTES, |group| share(doc, group, report)); + }; for (done, (r, ppi)) in refs.into_iter().enumerate() { if !progress(Stage::Images { done, total }) { return Err(OptimizeError::Cancelled); @@ -1125,14 +1363,15 @@ pub(crate) fn run( let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; report.images += 1; + 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) { + 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 { + if let Some((new, resampled, bits)) = mono { report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); if resampled { @@ -1141,11 +1380,14 @@ pub(crate) fn run( report.images_recompressed += 1; } jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + let size = (new.dict.int(b"Width"), new.dict.int(b"Height")); + let bits = bits.filter(|_| shares(&new.dict)); doc.set(r, Object::Stream(new)); + hold(doc, r, size, bits, report); 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)); + 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 { @@ -1156,10 +1398,10 @@ pub(crate) fn run( }), }; 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, _)| { + 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) { + if let Some((new, resampled, bits)) = 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(); @@ -1168,10 +1410,17 @@ pub(crate) fn run( } else { report.images_recompressed += 1; } + let size = (new.dict.int(b"Width"), new.dict.int(b"Height")); + let bits = bits.filter(|_| shares(&new.dict)); doc.set(r, Object::Stream(new)); + hold(doc, r, size, bits, report); continue; } - let Some((new, smask)) = processed else { 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(); @@ -1185,6 +1434,7 @@ pub(crate) fn run( doc.set(mr, Object::Stream(m)); } } + share(doc, &mut group, report); if !progress(Stage::Images { done: total, total }) { return Err(OptimizeError::Cancelled); } @@ -1212,6 +1462,58 @@ fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { mod dimension_tests { use super::*; + /// A group is coded before an image's samples would take it past its limit, and once it has + /// [`SHARED_IMAGES`]: with room for 10 bytes, images of 4 go two to a group, never three, and + /// with room for 8, two of 4 fill one exactly; one of exactly the limit joins a group with + /// nothing in it. + #[test] + fn a_group_is_coded_before_its_samples_would_pass_the_limit() { + let held = |bytes: usize| Held { image: ObjRef { num: 1, generation: 0 }, bits: vec![0; bytes], width: 8, height: 8 }; + let mut coded: Vec> = Vec::new(); + let mut group = Vec::new(); + let mut code = |g: &mut Vec| coded.push(std::mem::take(g).iter().map(|h| h.bits.len()).collect()); + for _ in 0..5 { + hold_in(&mut group, held(4), 10, &mut code); + } + hold_in(&mut group, held(10), 10, &mut code); + assert_eq!(coded, [vec![4, 4], vec![4, 4], vec![4]]); + assert_eq!(group.iter().map(|h| h.bits.len()).collect::>(), [10]); + // Exactly the limit is within it: with room for 8, two images of 4 stay together. + let (mut count, mut group) = (0, Vec::new()); + let mut code = |g: &mut Vec| count += std::mem::take(g).len(); + hold_in(&mut group, held(4), 8, &mut code); + hold_in(&mut group, held(4), 8, &mut code); + assert_eq!((count, group.len()), (0, 2)); + let (mut count, mut group) = (0, Vec::new()); + for _ in 0..SHARED_IMAGES { + hold_in(&mut group, held(1), usize::MAX, |g| count += std::mem::take(g).len()); + } + assert_eq!((count, group.len()), (SHARED_IMAGES, 0), "coded on the 16th"); + } + + /// Only what redaction can store again without the dictionary shares one: an image mask, or + /// DeviceGray named directly, of at most 2^26 pixels. + #[test] + fn only_what_redaction_can_store_again_shares_a_dictionary() { + let dict = |entries: Vec<(&str, Object)>| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v); + } + d + }; + let sized = |w: i64, h: i64, cs: Object| dict(vec![("Width", Object::Int(w)), ("Height", Object::Int(h)), ("ColorSpace", cs)]); + assert!(shares(&sized(8192, 8192, Object::name("DeviceGray")))); + assert!(!shares(&sized(8192, 8193, Object::name("DeviceGray"))), "a row past 2^26 pixels"); + assert!(shares(&dict(vec![("Width", Object::Int(8)), ("Height", Object::Int(8)), ("ImageMask", Object::Bool(true))]))); + let device_n = + Object::Array(vec![Object::name("DeviceN"), Object::Array(vec![Object::name("Black")]), Object::name("DeviceGray"), Object::Null]); + assert!(!shares(&sized(8, 8, device_n))); + assert!(!shares(&sized(8, 8, Object::name("CalGray")))); + assert!(!shares(&sized(8, 8, Object::Ref(ObjRef { num: 3, generation: 0 }))), "a colour space by reference"); + assert!(!shares(&dict(vec![("ColorSpace", Object::name("DeviceGray"))])), "no size"); + } + #[test] fn pixel_reader_rejects_hostile_dimensions_before_decoding() { for (width, height, channels) in [(-1, 1, 1), (0, 1, 1), (i64::from(u32::MAX) + 101, 1, 1), (80_000_001, 1, 1), (2, 2, usize::MAX)] { @@ -1219,13 +1521,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. @@ -1240,9 +1850,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); } } @@ -1307,8 +1977,9 @@ mod downsample_tests { 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, 1 in a separation, each the other way round - /// with an inverting decode array; 0 for an indexed image, whatever its decode array. + /// 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(); @@ -1332,6 +2003,7 @@ mod downsample_tests { (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), @@ -1340,6 +2012,54 @@ mod downsample_tests { } } + /// 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"); diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 76e30da7ac..e70aa5e71d 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -10,10 +10,11 @@ //! 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 and CCITT fax, +//! 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, halftones and dithers aside, and are stored as CCITT Group 4 or lossless JBIG2, or 1-bit +//! 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. @@ -35,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; @@ -278,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, @@ -328,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); } @@ -427,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; } @@ -462,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")) { @@ -512,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, @@ -596,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 2e3ea2d42f..1387cae528 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -9,6 +9,12 @@ use pdfcraft_optimize::{ 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); @@ -910,7 +916,7 @@ fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() { (Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)), "{i:?}" ); - assert_eq!(doc.decoded_image(&s).unwrap(), text, "{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:?}"); } @@ -1066,7 +1072,7 @@ fn retain_keeps_a_group_3_halftone_as_it_is() { 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!(doc.decoded_image(&stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes"); + 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[..]); @@ -1564,7 +1570,7 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { } 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), kept.decoded_image(&stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}"); + 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"); @@ -1578,7 +1584,7 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { 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!(doc.decoded_image(&s).unwrap(), text.clone(), "{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. @@ -1588,12 +1594,12 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { (size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")), ((1200, 480), Some(&Object::name("DeviceGray")), Some(&inverting)) ); - assert_eq!(doc.decoded_image(&s).unwrap(), text.iter().map(|b| !b).collect::>(), "the same picture, ties black"); + 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!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept"); + 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"); @@ -1681,7 +1687,7 @@ fn a_fax_image_behind_a_decompression_bomb_is_left_alone() { 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!(doc.decoded_image(&stream(&doc, img)).unwrap(), bilevel(w, h, false)); + 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); @@ -1775,7 +1781,8 @@ fn monochrome_images_keep_what_they_should() { 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_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller"); + 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(); @@ -1784,7 +1791,7 @@ fn monochrome_images_keep_what_they_should() { 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!(doc.decoded_image(&s).unwrap(), ragged, "Retain preserves every sample"); + 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, @@ -1852,7 +1859,12 @@ fn group_3_fax_images_become_group_4() { 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() }; - assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture"); + 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(); @@ -1885,7 +1897,7 @@ fn group_3_fax_images_become_group_4() { 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!(doc.decoded_image(&s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture"); + 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"); } } @@ -1939,8 +1951,17 @@ fn black_and_white_images_become_jbig2() { 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); + // The same text three times: the grey image and the mask share one symbol dictionary, and the + // indexed image, which only those share, is coded alone. + let globals_of = |s: &Stream| s.dict.get(b"DecodeParms").and_then(Object::as_dict).map(|d| (d.len(), d.reference(b"JBIG2Globals"))); + let is_indexed = |s: &Stream| s.dict.get(b"ColorSpace").and_then(Object::as_array).is_some(); + let shared: Vec<_> = images.iter().filter(|s| !is_indexed(s)).map(globals_of).collect(); + assert!(shared.len() == 2 && shared[0].is_some_and(|(n, g)| n == 1 && g.is_some()) && shared[0] == shared[1], "{shared:?}"); for s in &images { - assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + if is_indexed(s) { + assert!(!s.dict.contains(b"DecodeParms")); + } } assert!(images.iter().any(|s| s.dict.get(b"Decode") == Some(&decode)), "the decode array stays"); let indexed_kept = @@ -1987,6 +2008,11 @@ fn a_catalog_version_given_by_reference_is_read() { /// `#` 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 { + rendered_text_from(w, h, 0x2545_F491_4F6C_DD1D) +} + +/// [`rendered_text`], its letters picked from `seed`, which isn't 0. +fn rendered_text_from(w: u32, h: u32, mut seed: u64) -> Vec { const LETTERS: [&[&str]; 5] = [ &["##", "##", "##", "##", "##", "##", "##", "##", "##", "##"], &["......", ".####.", "##..##", "##..##", "##..##", "##..##", ".####."], @@ -1996,7 +2022,6 @@ fn rendered_text(w: u32, h: u32) -> Vec { ]; 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; @@ -2041,9 +2066,315 @@ fn rendered_text_becomes_jbig2_symbols() { 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!(!s.dict.contains(b"DecodeParms"), "alone, it shares no dictionary"); assert!(render(&doc) == before, "the page looks the same"); } +/// Page `index` of `doc`, saved and opened again, rendered at scale 1. +fn render_page(doc: &Document, index: usize) -> 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: index, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba.to_vec() +} + +/// Text in a font of 26 letters, about 10 pt at 300 dpi: each letter 32 rows of a run from a stem +/// down its left side, of 2 to 24 pixels, every copy the same to the pixel; lines 44 rows apart, +/// the letters picked from `seed`, which isn't 0. +fn font_text(w: u32, h: u32, mut seed: u64) -> Vec { + let mut font_seed = 0x2545_F491_4F6C_DD1Du64; + let next = |seed: &mut u64| { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + }; + let letters: Vec> = (0..26).map(|_| (0..16).map(|_| 1 + (next(&mut font_seed) % 12) as usize).collect()).collect(); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for top in (8..(h as usize).saturating_sub(40)).step_by(44) { + let mut x = 8; + while x + 32 < w as usize { + let letter = &letters[(next(&mut seed) % 26) as usize]; + for (row, run) in letter.iter().enumerate() { + for y in [top + 2 * row, top + 2 * row + 1] { + for px in x..x + 2 * run { + data[y * stride + px / 8] &= !(0x80 >> (px % 8)); + } + } + } + x += 2 * letter.iter().max().unwrap() + 4 + if next(&mut seed) % 6 == 0 { 14 } else { 0 }; + } + } + data +} + +/// The `/JBIG2Globals` an image names in its `/DecodeParms`. +fn globals_of(doc: &Document, image: ObjRef) -> Option { + stream(doc, image).dict.get(b"DecodeParms")?.as_dict()?.reference(b"JBIG2Globals") +} + +/// Pages of text in one font, each letter the same to the pixel on every page, stored as JBIG2 +/// share one symbol dictionary (`/JBIG2Globals`): together they take less than each page coded +/// alone, and every page looks the same, the last one, whose crosses no other page has, placing +/// them from a dictionary of its own beside the shared one. The report counts the bytes they +/// take, the globals once. +#[test] +fn jbig2_pages_share_one_symbol_dictionary() { + let mut doc = Document::new_empty(); + let (w, h) = (600u32, 480u32); + let stride = (w as usize).div_ceil(8); + let pages: Vec> = (1..=4u64) + .map(|k| { + let mut text = font_text(w, h, k.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + if k == 4 { + // Crosses down the right margin, three pixels a stroke. + for top in (40..440).step_by(40) { + for (dx, dy) in (0..9).map(|i| (i, 4)).chain((0..9).map(|i| (4, i))) { + let (x, y) = (580 + dx, top + dy); + text[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + text + }) + .collect(); + let images: Vec = pages + .iter() + .map(|text| { + let im = mono_image(&mut doc, w, h, text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + im + }) + .collect(); + let before: Vec> = (0..pages.len()).map(|p| render_page(&doc, p)).collect(); + let alone: usize = pages.iter().map(|text| pdfcraft_filters::encode_jbig2(text, w, h).unwrap().len()).sum(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let report = optimize(&mut doc, &settings).unwrap(); + let globals = globals_of(&doc, images[0]).expect("the first page names the globals"); + assert!(images.iter().all(|im| globals_of(&doc, *im) == Some(globals)), "every page names the same globals"); + let shared = stream(&doc, globals).raw.len() + images.iter().map(|im| stream(&doc, *im).raw.len()).sum::(); + assert!(shared * 4 < alone * 3, "shared {shared} bytes, alone {alone}"); + assert_eq!(report.image_bytes_after, shared); + for (p, before) in before.iter().enumerate() { + assert!(render_page(&doc, p) == *before, "page {p} looks the same"); + let s = stream(&doc, images[p]); + assert_eq!(decoded_image(&doc, &s).unwrap(), pages[p], "page {p} decodes to its samples"); + } + // Optimized again, each page would come out larger alone than it is beside the globals: the + // sharing stays. + let streams: Vec = images.iter().map(|im| stream(&doc, *im)).collect(); + let again = optimize(&mut doc, &settings).unwrap(); + assert_eq!((again.images_recompressed, again.images_resampled), (0, 0)); + assert!(images.iter().zip(&streams).all(|(im, s)| stream(&doc, *im) == *s)); +} + +/// Images share a dictionary only where that saves more than it takes: two pages of five small +/// letters save less than their globals would take, and stay as they are coded alone. +#[test] +fn a_dictionary_that_saves_less_than_it_takes_isnt_shared() { + let mut doc = Document::new_empty(); + let (w, h) = (600u32, 480u32); + let images: Vec = (1..=2u64) + .map(|k| { + let text = rendered_text_from(w, h, k.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + im + }) + .collect(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let report = optimize(&mut doc, &settings).unwrap(); + assert_eq!(report.images_recompressed, 2); + for im in &images { + let s = stream(&doc, *im); + assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + } + // Optimized again, they code to what they are, no smaller: they stay. + let streams: Vec = images.iter().map(|im| stream(&doc, *im)).collect(); + assert_eq!(optimize(&mut doc, &settings).unwrap().images_recompressed, 0); + assert!(images.iter().zip(&streams).all(|(im, s)| stream(&doc, *im) == *s)); +} + +/// Only JBIG2 asked for shares: under Retain, JBIG2 pages drawn at 600 ppi are averaged and stored +/// as JBIG2 again, each alone, as Retain changes nothing but their resolution. +#[test] +fn retained_jbig2_shares_nothing() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 960u32); + let images: Vec = (1..=4u64) + .map(|k| { + let text = font_text(w, h, k.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let mut d = Dict::new(); + for (key, value) in [("Type", Object::name("XObject")), ("Subtype", Object::name("Image")), ("ColorSpace", Object::name("DeviceGray"))] { + d.set(key.as_bytes().to_vec(), value); + } + for (key, value) in [("Width", i64::from(w)), ("Height", i64::from(h)), ("BitsPerComponent", 1)] { + d.set(key.as_bytes().to_vec(), Object::Int(value)); + } + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let im = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_jbig2(&text, w, h).unwrap()))); + // 1200 samples over 144 pt: 600 ppi. + page(&mut doc, &[("Im", im)], "q 144 0 0 115.2 6 300 cm /Im Do Q"); + im + }) + .collect(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }, ..Settings::default() }; + assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 4); + for im in images { + let s = stream(&doc, im); + assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + } +} + +/// Sixteen images at most share one dictionary, and two are enough: of eighteen, the first +/// sixteen share one and the other two another. +#[test] +fn sixteen_images_at_most_share_a_dictionary() { + let mut doc = Document::new_empty(); + let (w, h) = (600u32, 480u32); + let texts: Vec> = (1..=18u64).map(|k| font_text(w, h, k.wrapping_mul(0x9E37_79B9_7F4A_7C15))).collect(); + let images: Vec = texts + .iter() + .map(|text| { + let im = mono_image(&mut doc, w, h, text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + im + }) + .collect(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + let globals: Vec> = images.iter().map(|im| globals_of(&doc, *im)).collect(); + assert!(globals.iter().all(Option::is_some), "{globals:?}"); + assert!(globals[..16].iter().all(|g| *g == globals[0]) && globals[16..].iter().all(|g| *g == globals[16]), "{globals:?}"); + assert_ne!(globals[0], globals[16]); + for (k, (im, text)) in images.iter().zip(&texts).enumerate() { + assert!(decoded_image(&doc, &stream(&doc, *im)).unwrap() == *text, "image {k} decodes to its samples"); + } +} + +/// 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] @@ -3277,6 +3608,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() }; @@ -3393,10 +3760,12 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); } -/// Unknown palette polarity is never guessed. Both palette orders and Decode directions -/// retain the original thin rule and exact samples through a full save and render. +/// 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_keep_their_samples_for_both_palette_orders_and_decodes() { +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]; @@ -3408,8 +3777,9 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { 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 = gray.decoded_image(&gray_stream).unwrap(); + 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] { @@ -3439,14 +3809,276 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { 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(w.into()), Some(h.into()))); - assert_eq!(doc.decoded_image(&s).unwrap(), data); + 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_eq!(render(&saved), before, "palette white-first={white_first}, inverted={inverted}"); + assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}"); } } } +/// An image past what the JBIG2 decoder takes on a side stays as it is: JBIG2 can state the size, +/// but PdfCraft's decoder refuses such a stream, so stored as JBIG2 it drew nothing here. At 65,535 +/// on a side, the same bars do become JBIG2, so the fixture does reach that choice, and the coding +/// stored (symbols, here) reads back through that decoder. +#[test] +fn an_image_too_large_for_the_jbig2_decoder_stays_as_it_is() { + for (w, h, stored) in + [(65_535u32, 16u32, &b"JBIG2Decode"[..]), (16, 65_535, b"JBIG2Decode"), (65_536, 16, b"FlateDecode"), (16, 65_536, b"FlateDecode")] + { + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &bars(w, h), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", image)], &format!("q {w} 0 0 {h} 0 0 cm /Im Do Q")); + let before = stream(&doc, image).raw.clone(); + let settings = + Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, downsample: false, ..MonoSettings::REDUCE }, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + let s = stream(&doc, image); + assert_eq!(s.dict.name(b"Filter"), Some(stored), "{w} × {h}"); + if stored == b"FlateDecode" { + assert_eq!(s.raw, before, "{w} × {h}"); + } else { + // The decoder gives padding bits as 0; the bars have them 1. + let mut bits = bars(w, h); + if w % 8 != 0 { + for row in bits.chunks_mut((w as usize).div_ceil(8)) { + if let Some(last) = row.last_mut() { + *last &= 0xFFu8 << (8 - w % 8); + } + } + } + assert!(decoded_image(&doc, &s).unwrap() == bits, "{w} × {h} reads back"); + } + } +} + +/// The colour and grey steps leave an image over 80 million pixels as it is, whatever their settings +/// (memory): 10,000 × 8,000 grey, the limit itself, drawn at 600 ppi, is resampled, and a row more +/// stays as it is. +#[test] +fn grey_images_are_resampled_up_to_the_pixel_limit() { + for (h, resampled) in [(8_000u32, true), (8_001, false)] { + let mut doc = Document::new_empty(); + let grey = image(&mut doc, 10_000, h, 1, false, None); + let before = stream(&doc, grey).raw.clone(); + let (pw, ph) = (10_000.0 * 72.0 / 600.0, f64::from(h) * 72.0 / 600.0); + page(&mut doc, &[("G", grey)], &format!("q {pw} 0 0 {ph} 0 0 cm /G Do Q")); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, usize::from(resampled), "10,000 × {h}: {report:?}"); + let s = stream(&doc, grey); + if resampled { + assert_eq!(s.dict.int(b"Width"), Some(2_500), "10,000 × {h}"); + } else { + assert!(s.dict.int(b"Width") == Some(10_000) && s.raw == before, "10,000 × {h} stays as it is"); + } + } +} + +/// Dark bars of irregular lengths across the long side of a `w` × `h` 1-bit image, which Flate +/// shrinks less than JBIG2. +fn bars(w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut state = 0x1234_5678u32; + let mut next = move || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state as usize + }; + let long = w.max(h) as usize; + let mut at = 10; + while at + 60 < long { + let len = 5 + next() % 50; + for along in at..at + len { + for across in 0..12 { + let (x, y) = if w > h { (along, across) } else { (across, along) }; + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + at += len + 5 + next() % 50; + } + data +} + +/// Past what the JBIG2 decoder takes on either side, an image packed from more bits, or one +/// averaged to a size still past it, isn't stored as JBIG2 either: it becomes 1-bit Flate. Within +/// the bound the same pictures do become JBIG2, so the fixtures reach that choice. +#[test] +fn what_is_too_large_for_jbig2_is_stored_another_way() { + let settings = |downsample| Settings { + mono: MonoSettings { compression: MonoCompression::Jbig2, downsample, ..MonoSettings::REDUCE }, + ..Settings::default() + }; + let (jbig2, flate) = (&b"JBIG2Decode"[..], &b"FlateDecode"[..]); + // Eight-bit grey of only black and white, packed to one bit first, wide and tall. + for (w, h, stored) in [(65_535u32, 16u32, jbig2), (65_536, 16, flate), (16, 65_535, jbig2), (16, 65_536, flate)] { + let (bits, stride) = (bars(w, h), (w as usize).div_ceil(8)); + let grey: Vec = (0..h as usize) + .flat_map(|y| (0..w as usize).map(move |x| (y, x))) + .map(|(y, x)| if bits[y * stride + x / 8] & (0x80 >> (x % 8)) == 0 { 0 } else { 255 }) + .collect(); + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &grey, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", image)], &format!("q {w} 0 0 {h} 0 0 cm /Im Do Q")); + optimize(&mut doc, &settings(false)).unwrap(); + let s = stream(&doc, image); + assert_eq!(s.dict.name(b"Filter"), Some(stored), "{w} × {h}, packed"); + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(1), "{w} × {h}, packed"); + } + // One bit, drawn at 600 ppi and averaged to 300: half the size, so 140,000 is still past it. + for (w, h, stored) in [(70_000u32, 16u32, jbig2), (140_000, 16, flate), (16, 70_000, jbig2), (16, 140_000, flate)] { + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &bars(w, h), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let (pw, ph) = (f64::from(w) * 72.0 / 600.0, f64::from(h) * 72.0 / 600.0); + page(&mut doc, &[("Im", image)], &format!("q {pw} 0 0 {ph} 0 0 cm /Im Do Q")); + optimize(&mut doc, &settings(true)).unwrap(); + let s = stream(&doc, image); + assert_eq!(s.dict.name(b"Filter"), Some(stored), "{w} × {h}, averaged"); + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(1), "{w} × {h}, averaged"); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(i64::from(w / 2)), Some(i64::from(h / 2))), "{w} × {h}, averaged"); + } +} + +/// 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() { @@ -3472,8 +4104,107 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin 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!(doc.decoded_image(&s).unwrap(), data, "screened samples survive, not a solid patch"); + 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/lib.rs b/crates/organize/src/lib.rs index b890965d0c..3b3d56cd83 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -20,6 +20,7 @@ mod dedupe; mod import; mod labels; mod outline; +mod outline_io; mod pdfx; mod prune; mod transition; @@ -40,6 +41,7 @@ pub use outline::{ Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_at, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, set_bookmark_color, set_bookmark_open, set_bookmark_page, set_bookmark_style, top_level_bookmarks, }; +pub use outline_io::{MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, import_outline, outline_tree, unmapped, view_arity}; pub use transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition}; pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view}; diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index ff7d3d8da7..13c02eb662 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -19,7 +19,7 @@ use crate::{OrganizeError, walk}; /// Deepest level a listing enters (the top level is 0). Bookmarks deeper than this are not /// listed; edits still reach them by path. -const LIST_DEPTH: usize = 32; +pub(crate) const LIST_DEPTH: usize = 32; /// Deepest name-tree node read when looking up named destinations. const NAME_TREE_DEPTH: usize = 32; @@ -72,6 +72,14 @@ pub enum OutlineError { BadColor, #[error("this document has no tagged headings to make bookmarks from")] NoEntries, + #[error("there are no bookmarks to add")] + NothingToImport, + #[error("the bookmark at {0:?} has a view that is not a known destination type with its values")] + BadView(Vec), + #[error("bookmarks may nest at most {0} levels deep")] + TooDeep(usize), + #[error("at most {0} bookmarks can be added at once")] + TooManyBookmarks(usize), #[error("{0}")] Organize(#[from] OrganizeError), #[error("{0}")] @@ -449,7 +457,7 @@ impl From for OrganizeError { /// Make `kids` the children of `parent`, in order: rewires `/Parent`, `/Prev`, `/Next`, /// `/First` and `/Last`. -fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> { +pub(crate) fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> { for (i, k) in kids.iter().enumerate() { put(doc, *k, b"Parent", Some(Object::Ref(parent)))?; put(doc, *k, b"Prev", i.checked_sub(1).map(|p| Object::Ref(kids[p])))?; @@ -473,7 +481,7 @@ struct Counting { /// Recompute every `/Count` (§12.3.3: open items count their visible descendants, closed items /// the negative of what opening them would show; the root counts all visible items). -fn recount(doc: &mut Document) -> Result<()> { +pub(crate) fn recount(doc: &mut Document) -> Result<()> { let Some(root) = outline_root(doc) else { return Ok(()) }; let mut seen = HashSet::from([root]); let kids = children_of(doc, root, &mut seen); @@ -714,7 +722,7 @@ pub fn move_bookmark(doc: &mut Document, from: &[usize], to_parent: &[usize], in /// Open `parent` so a bookmark just added or moved into it shows (the root is always open). /// Only the sign of `/Count` matters here: [`recount`] sets its value. -fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> { +pub(crate) fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> { if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { put(doc, parent, b"Count", Some(Object::Int(1)))?; } diff --git a/crates/organize/src/outline_io.rs b/crates/organize/src/outline_io.rs new file mode 100644 index 0000000000..1fc3afc4f3 --- /dev/null +++ b/crates/organize/src/outline_io.rs @@ -0,0 +1,261 @@ +//! Moving a whole outline (or one bookmark's subtree) out of a document and into another +//! (ISO 32000-2 §12.3.3 outlines, §12.3.2.2 explicit destinations). +//! +//! [`outline_tree`] reads bookmarks as a portable tree of [`OutlineNode`]s: titles, nesting, open +//! state, colour, style and destinations as page index plus view in the file's own user space, +//! so writing a tree back with [`import_outline`] gives the same structure. A node whose page the +//! target lacks keeps its place in the tree without a destination, and [`unmapped`] names it, so +//! nothing is dropped silently. Not carried: actions other than a GoTo, named destinations (they +//! travel resolved to their page), and structure links (`/SE`). + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; + +use crate::outline::{LIST_DEPTH, children, container, recount, relink, show_children}; +use crate::{OrganizeError, OutlineError, walk}; + +/// Most bookmarks one import writes. +pub const MAX_IMPORT_BOOKMARKS: usize = 100_000; + +/// The destination types (Table 149) and how many numbers follow each. +const VIEWS: [(&[u8], usize); 8] = [(b"XYZ", 3), (b"Fit", 0), (b"FitH", 1), (b"FitV", 1), (b"FitR", 4), (b"FitB", 0), (b"FitBH", 1), (b"FitBV", 1)]; + +/// One bookmark and the bookmarks under it, independent of any document. +#[derive(Clone, Debug, PartialEq)] +pub struct OutlineNode { + pub title: String, + /// The 0-based page it goes to; `None` for a bookmark that goes nowhere in its document. + pub page: Option, + /// Where on the page: a destination type from Table 149 and exactly as many values as that + /// type takes, in PDF user space (`None` keeps the reader's value). `None` goes to the page at + /// the reader's position and zoom. + pub view: Option<(Vec, Vec>)>, + /// The title's colour (`/C`), each component from 0 to 1; `None` is the default black. + pub color: Option<[f32; 3]>, + pub italic: bool, + pub bold: bool, + /// Shown expanded, when it has children. + pub open: bool, + pub children: Vec, +} + +/// The tree [`outline_tree`] read. +#[derive(Clone, Debug, PartialEq)] +pub struct OutlineTree { + pub nodes: Vec, + /// Bookmarks nested deeper than the listing reaches (32 levels) were left out. + pub truncated: bool, +} + +/// The bookmark at `path` with everything under it, or the whole outline for `[]`. +pub fn outline_tree(doc: &Document, path: &[usize]) -> Result { + // A whole outline's top level sits at depth 0; a subtree's root is the bookmark at `path`. + let base = path.len().max(1); + let mut truncated = false; + // The open nodes, outermost first, each with its depth. + let mut open: Vec<(usize, OutlineNode)> = Vec::new(); + let mut roots = Vec::new(); + for b in crate::bookmarks(doc).into_iter().filter(|b| b.path.starts_with(path) && !b.path.is_empty()) { + let depth = b.path.len().saturating_sub(base); + if b.path.len() > LIST_DEPTH && has_children(doc, b.obj) { + truncated = true; + } + while open.last().is_some_and(|(d, _)| *d >= depth) { + close(&mut open, &mut roots); + } + let view = b.view.and_then(|(kind, values)| portable_view(kind, values)); + let node = + OutlineNode { title: b.title, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold, open: b.open, children: Vec::new() }; + open.push((depth, node)); + } + while !open.is_empty() { + close(&mut open, &mut roots); + } + if !path.is_empty() && roots.is_empty() { + return Err(OutlineError::NoSuchBookmark(path.to_vec())); + } + Ok(OutlineTree { nodes: roots, truncated }) +} + +fn close(open: &mut Vec<(usize, OutlineNode)>, roots: &mut Vec) { + if let Some((_, node)) = open.pop() { + match open.last_mut() { + Some((_, parent)) => parent.children.push(node), + None => roots.push(node), + } + } +} + +fn has_children(doc: &Document, r: ObjRef) -> bool { + doc.get(r).as_dict().is_some_and(|d| d.reference(b"First").is_some()) +} + +/// A destination's view as [`import_outline`] takes it: a known type with exactly its number of +/// values (extra ones dropped, missing ones `None`). An unknown type, or a FitR missing one of +/// its four values (the rectangle can't be kept), gives `None`. +fn portable_view(kind: Vec, mut values: Vec>) -> Option<(Vec, Vec>)> { + let (_, arity) = VIEWS.iter().find(|(k, _)| *k == kind.as_slice())?; + values.resize(*arity, None); + if kind.as_slice() == b"FitR" && values.iter().any(Option::is_none) { + return None; + } + Some((kind, values)) +} + +/// The nodes of `nodes` whose page `page_count` pages don't have, as paths into `nodes` +/// (0-based child indices), in tree order. [`import_outline`] keeps these without a destination. +pub fn unmapped(nodes: &[OutlineNode], page_count: usize) -> Vec> { + let mut out = Vec::new(); + visit(nodes, |path, node| { + if node.page.is_some_and(|p| p >= page_count) { + out.push(path.to_vec()); + } + }); + out +} + +/// How many values a view of destination type `kind` takes (Table 149), or `None` for a type +/// that is not one of them. +pub fn view_arity(kind: &[u8]) -> Option { + VIEWS.iter().find(|(k, _)| *k == kind).map(|(_, arity)| *arity) +} + +/// Call `f` with each node's path and the node, parents first. Iterative. +fn visit<'a>(nodes: &'a [OutlineNode], mut f: impl FnMut(&[usize], &'a OutlineNode)) { + // One entry per open level: its nodes and how many of them have been visited. + let mut stack: Vec<(&'a [OutlineNode], usize)> = vec![(nodes, 0)]; + // The visited node's path, kept in step with the stack rather than rebuilt per node. + let mut path: Vec = Vec::new(); + while let Some(level) = stack.last_mut() { + let list = level.0; + let index = level.1; + let Some(node) = list.get(index) else { + stack.pop(); + continue; + }; + level.1 = index.saturating_add(1); + path.truncate(stack.len().saturating_sub(1)); + path.push(index); + f(&path, node); + if !node.children.is_empty() { + stack.push((&node.children, 0)); + } + } +} + +/// Check every node before anything is written: depth (counting the `base` levels above the +/// tree), count, colour and view. +fn check(nodes: &[OutlineNode], base: usize) -> Result<(), OutlineError> { + let mut problem = None; + let mut count = 0usize; + visit(nodes, |path, node| { + count = count.saturating_add(1); + if problem.is_some() { + return; + } + if base.saturating_add(path.len()) > LIST_DEPTH.saturating_add(1) { + problem = Some(OutlineError::TooDeep(LIST_DEPTH.saturating_add(1))); + } else if node.color.is_some_and(|c| !c.iter().all(|v| (0.0..=1.0).contains(v))) { + problem = Some(OutlineError::BadColor); + } else if node.view.as_ref().is_some_and(|v| !view_is_valid(v)) { + problem = Some(OutlineError::BadView(path.to_vec())); + } + }); + if count > MAX_IMPORT_BOOKMARKS { + return Err(OutlineError::TooManyBookmarks(MAX_IMPORT_BOOKMARKS)); + } + problem.map_or(Ok(()), Err) +} + +/// A known type with its number of values, each finite; `/FitR` needs all four. +fn view_is_valid((kind, values): &(Vec, Vec>)) -> bool { + VIEWS.iter().any(|(k, arity)| *k == kind.as_slice() && *arity == values.len()) + && values.iter().flatten().all(|v| v.is_finite()) + && (kind.as_slice() != b"FitR" || values.iter().all(Option::is_some)) +} + +/// Write `nodes` as children of the bookmark at `parent_path` (`[]` = top level), starting at +/// child `index` (past the end appends), or, with `replace`, in place of the outline's +/// top-level bookmarks (then `parent_path` must be `[]`). Nodes whose page this document lacks +/// are written without a destination (see [`unmapped`]). Everything is checked before anything +/// is written. Returns the path of the first node written. +pub fn import_outline( + doc: &mut Document, + parent_path: &[usize], + index: usize, + nodes: &[OutlineNode], + replace: bool, +) -> Result, OutlineError> { + if nodes.is_empty() { + return Err(OutlineError::NothingToImport); + } + if replace && !parent_path.is_empty() { + return Err(OrganizeError::Invalid("replace applies to the whole outline: leave the parent out".into()).into()); + } + check(nodes, parent_path.len())?; + let pages: Vec = walk(doc)?.into_iter().map(|(r, _)| r).collect(); + let parent = container(doc, parent_path)?; + let top = write_nodes(doc, &pages, nodes)?; + let mut kids = if replace { Vec::new() } else { children(doc, parent) }; + let at = index.min(kids.len()); + kids.splice(at..at, top); + relink(doc, parent, &kids)?; + show_children(doc, parent, parent_path)?; + recount(doc)?; + let mut path = parent_path.to_vec(); + path.push(at); + Ok(path) +} + +/// Add one item per node, linked under each other, and return the top-level items in order. +fn write_nodes(doc: &mut Document, pages: &[ObjRef], nodes: &[OutlineNode]) -> Result, OutlineError> { + // Each written item with its node, then its children's items once they are written. + let mut written: Vec<(ObjRef, Vec)> = Vec::new(); + let mut top = Vec::new(); + // (the nodes of one level, the next to write, the index in `written` of their parent) + let mut stack: Vec<(&[OutlineNode], usize, Option)> = vec![(nodes, 0, None)]; + while let Some(level) = stack.last_mut() { + let (list, parent) = (level.0, level.2); + let Some(node) = list.get(level.1) else { + stack.pop(); + continue; + }; + level.1 = level.1.saturating_add(1); + let item = doc.add(Object::Dict(item_dict(node, pages))); + match parent.and_then(|p| written.get_mut(p)) { + Some((_, kids)) => kids.push(item), + None => top.push(item), + } + written.push((item, Vec::new())); + if !node.children.is_empty() { + stack.push((&node.children, 0, Some(written.len().saturating_sub(1)))); + } + } + for (item, kids) in &written { + relink(doc, *item, kids)?; + } + Ok(top) +} + +/// The item's own entries; the links and `/Count`'s value come from [`relink`] and [`recount`] +/// (only the sign of the `/Count` written here matters: it keeps the open state). +fn item_dict(node: &OutlineNode, pages: &[ObjRef]) -> Dict { + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(PdfString::text(&node.title))); + if let Some(page) = node.page.and_then(|p| pages.get(p)) { + let (kind, values) = node.view.clone().unwrap_or_else(|| (b"XYZ".to_vec(), vec![None; 3])); + let mut dest = vec![Object::Ref(*page), Object::Name(kind)]; + dest.extend(values.into_iter().map(|v| v.map_or(Object::Null, Object::Real))); + d.set(b"Dest".to_vec(), Object::Array(dest)); + } + if let Some(c) = node.color.filter(|c| *c != [0.0; 3]) { + d.set(b"C".to_vec(), Object::Array(c.iter().map(|v| Object::Real(f64::from(*v))).collect())); + } + let flags = i64::from(node.italic) | i64::from(node.bold) << 1; + if flags != 0 { + d.set(b"F".to_vec(), Object::Int(flags)); + } + if !node.children.is_empty() { + d.set(b"Count".to_vec(), Object::Int(if node.open { 1 } else { -1 })); + } + d +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 33dc262fce..d5ff2116ff 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3813,3 +3813,175 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() { ctrl_names.sort(); assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]); } + +/// Intro (closed, red bold, at a point) > [Part (italic, FitH), Figure (FitR)], then Results. +fn outline_source() -> Document { + let mut d = doc_a(); + crate::add_bookmark_at(&mut d, &[], 0, "Intro", 0, Some(20.0), Some(380.0)).unwrap(); + crate::add_bookmark(&mut d, &[0], 0, "Part", 1).unwrap(); + crate::add_bookmark(&mut d, &[0], 1, "Figure", 2).unwrap(); + crate::add_bookmark(&mut d, &[], 1, "Results", 2).unwrap(); + crate::set_bookmark_color(&mut d, &[0], Some([1.0, 0.0, 0.0])).unwrap(); + crate::set_bookmark_style(&mut d, &[0], Some(true), None).unwrap(); + crate::set_bookmark_style(&mut d, &[0, 0], None, Some(true)).unwrap(); + let pages = crate::walk(&d).unwrap(); + let (part, figure) = (crate::bookmarks(&d)[1].obj, crate::bookmarks(&d)[2].obj); + let dest = |p: usize, rest: Vec| Object::Array([vec![Object::Ref(pages[p].0)], rest].concat()); + d.update_dict(part, |x| x.set(b"Dest".to_vec(), dest(1, vec![Object::name("FitH"), Object::Real(300.0)]))).unwrap(); + let fit_r = vec![Object::name("FitR"), Object::Int(10), Object::Int(20), Object::Int(110), Object::Int(220)]; + d.update_dict(figure, |x| x.set(b"Dest".to_vec(), dest(2, fit_r))).unwrap(); + crate::set_bookmark_open(&mut d, &[0], false).unwrap(); + d +} + +fn node(title: &str, page: Option, children: Vec) -> crate::OutlineNode { + crate::OutlineNode { title: title.into(), page, view: None, color: None, italic: false, bold: false, open: true, children } +} + +/// The `/Prev`, `/Last` and `/Parent` links agree with `/First`–`/Next` at every level. +fn assert_back_links(doc: &Document) { + let root = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap(); + let mut stack = vec![root]; + while let Some(parent) = stack.pop() { + let kids = crate::outline::children(doc, parent); + let d = doc.get(parent).as_dict().cloned().unwrap(); + assert_eq!(d.reference(b"Last"), kids.last().copied()); + for (i, k) in kids.iter().enumerate() { + let kd = doc.get(*k).as_dict().cloned().unwrap(); + assert_eq!(kd.reference(b"Prev"), i.checked_sub(1).map(|p| kids[p])); + assert_eq!(kd.reference(b"Parent"), Some(parent)); + } + stack.extend(kids); + } +} + +#[test] +fn an_exported_outline_imports_into_another_document_unchanged() { + let source = outline_source(); + let tree = crate::outline_tree(&source, &[]).unwrap(); + assert!(!tree.truncated); + let intro = &tree.nodes[0]; + assert_eq!((intro.title.as_str(), intro.page, intro.open, intro.bold, intro.color), ("Intro", Some(0), false, true, Some([1.0, 0.0, 0.0]))); + assert_eq!(intro.view, Some((b"XYZ".to_vec(), vec![Some(20.0), Some(380.0), None]))); + assert_eq!(intro.children[0].view, Some((b"FitH".to_vec(), vec![Some(300.0)]))); + assert!(intro.children[0].italic); + assert_eq!(intro.children[1].view, Some((b"FitR".to_vec(), vec![Some(10.0), Some(20.0), Some(110.0), Some(220.0)]))); + + let mut target = doc_a(); + assert_eq!(crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(), vec![0]); + assert_back_links(&target); + let back = full_roundtrip(&target); + assert_eq!(crate::outline_tree(&back, &[]).unwrap(), tree); + // The closed parent stays closed: its count is negative and the root counts only what shows. + let first = crate::bookmarks(&back)[0].obj; + let root = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap(); + assert_eq!((count_of(&back, first), count_of(&back, root)), (Some(-2), Some(2))); +} + +#[test] +fn a_subtree_exports_alone_and_a_missing_path_is_refused() { + let source = outline_source(); + let tree = crate::outline_tree(&source, &[0]).unwrap(); + assert_eq!(tree.nodes.len(), 1); + assert_eq!(tree.nodes[0].title, "Intro"); + assert_eq!(tree.nodes[0].children.iter().map(|n| n.title.as_str()).collect::>(), ["Part", "Figure"]); + let leaf = crate::outline_tree(&source, &[0, 1]).unwrap(); + assert_eq!((leaf.nodes.len(), leaf.nodes[0].title.as_str()), (1, "Figure")); + assert_eq!(crate::outline_tree(&source, &[5]), Err(crate::OutlineError::NoSuchBookmark(vec![5]))); + assert_eq!(crate::outline_tree(&doc_a(), &[]).unwrap().nodes, []); +} + +#[test] +fn a_page_the_target_lacks_keeps_its_bookmark_without_a_destination() { + let tree = crate::outline_tree(&outline_source(), &[]).unwrap(); + // doc_b has two pages: Figure and Results go to the third. + assert_eq!(crate::unmapped(&tree.nodes, 2), [vec![0, 1], vec![1]]); + let mut target = doc_b(); + crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); + let listed = crate::bookmarks(&target); + assert_eq!(titles(&listed), ["Intro[Part,Figure]", "Results"]); + assert_eq!(listed.iter().map(|b| b.page).collect::>(), [Some(0), Some(1), None, None]); + assert!(target.get(listed[2].obj).as_dict().unwrap().get(b"Dest").is_none()); +} + +#[test] +fn a_fit_r_missing_a_value_copies_with_its_page_and_no_view() { + let mut src = outline_source(); + let pages = crate::walk(&src).unwrap(); + let figure = crate::bookmarks(&src)[2].obj; + let short = vec![Object::Ref(pages[2].0), Object::name("FitR"), Object::Int(10), Object::Int(20), Object::Int(110)]; + src.update_dict(figure, |x| x.set(b"Dest".to_vec(), Object::Array(short))).unwrap(); + let tree = crate::outline_tree(&src, &[]).unwrap(); + let copied = &tree.nodes[0].children[1]; + assert_eq!((copied.page, copied.view.clone()), (Some(2), None)); + let mut target = doc_a(); + crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); + assert_eq!(titles(&crate::bookmarks(&target)), ["Intro[Part,Figure]", "Results"]); +} + +#[test] +fn an_import_appends_inserts_or_replaces() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 0, "Old A", 0).unwrap(); + crate::add_bookmark(&mut d, &[], 1, "Old B", 1).unwrap(); + let new = [node("New", Some(2), vec![node("Child", Some(1), vec![])])]; + assert_eq!(crate::import_outline(&mut d, &[], 1, &new, false).unwrap(), vec![1]); + assert_eq!(titles(&crate::bookmarks(&d)), ["Old A", "New[Child]", "Old B"]); + assert_eq!(crate::import_outline(&mut d, &[2], 9, &new, false).unwrap(), vec![2, 0]); + assert_eq!(titles(&crate::bookmarks(&d)), ["Old A", "New[Child]", "Old B[New[Child]]"]); + assert_back_links(&d); + assert_eq!(crate::import_outline(&mut d, &[], 5, &new, true).unwrap(), vec![0]); + assert_eq!(titles(&crate::bookmarks(&d)), ["New[Child]"]); + assert_back_links(&d); + let refused = crate::import_outline(&mut d, &[0], 0, &new, true); + assert!(matches!(refused, Err(crate::OutlineError::Organize(crate::OrganizeError::Invalid(_))))); + assert_eq!(crate::import_outline(&mut d, &[], 0, &[], false), Err(crate::OutlineError::NothingToImport)); + assert_eq!(crate::import_outline(&mut d, &[7], 0, &new, false), Err(crate::OutlineError::NoSuchBookmark(vec![7]))); +} + +#[test] +fn a_bad_node_anywhere_refuses_the_whole_import() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 0, "Kept", 0).unwrap(); + let before = crate::bookmarks(&d); + let with = |f: &dyn Fn(&mut crate::OutlineNode)| { + let mut child = node("Child", Some(0), vec![]); + f(&mut child); + vec![node("Fine", Some(0), vec![]), node("Parent", Some(0), vec![child])] + }; + let bad_views: [(&[u8], Vec>); 4] = + [(b"XYZ", vec![Some(1.0)]), (b"Zoom", vec![]), (b"FitH", vec![Some(f64::NAN)]), (b"FitR", vec![Some(0.0), None, Some(1.0), Some(1.0)])]; + for (kind, values) in bad_views { + let nodes = with(&|n| n.view = Some((kind.to_vec(), values.clone()))); + assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadView(vec![1, 0])), "{kind:?}"); + } + let nodes = with(&|n| n.color = Some([0.5, 1.5, 0.0])); + assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadColor)); + let nodes = with(&|n| n.color = Some([f32::NAN, 0.0, 0.0])); + assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadColor)); + let mut deep = node("Leaf", None, vec![]); + for _ in 0..33 { + deep = node("Level", None, vec![deep]); + } + assert_eq!(crate::import_outline(&mut d, &[], 0, &[deep], false), Err(crate::OutlineError::TooDeep(33))); + // 33 levels fit at the top, but not under a bookmark: the deepest would be past the listing. + let mut chain = node("Leaf", None, vec![]); + for _ in 0..32 { + chain = node("Level", None, vec![chain]); + } + assert_eq!(crate::import_outline(&mut d, &[0], 0, std::slice::from_ref(&chain), false), Err(crate::OutlineError::TooDeep(33))); + let many = vec![node("Item", None, vec![]); crate::MAX_IMPORT_BOOKMARKS + 1]; + assert_eq!(crate::import_outline(&mut d, &[], 0, &many, false), Err(crate::OutlineError::TooManyBookmarks(crate::MAX_IMPORT_BOOKMARKS))); + assert_eq!(crate::bookmarks(&d), before); +} + +#[test] +fn an_outline_deeper_than_the_listing_is_exported_as_truncated() { + let tree = crate::outline_tree(&open(deep_outline(40)), &[]).unwrap(); + assert!(tree.truncated); + assert!(!crate::outline_tree(&open(deep_outline(33)), &[]).unwrap().truncated); + // What was exported imports back: 33 levels is the deepest an import takes. + let mut target = doc_a(); + crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); + assert_eq!(crate::bookmarks(&target).len(), 33); +} diff --git a/crates/prc-render/Cargo.toml b/crates/prc-render/Cargo.toml new file mode 100644 index 0000000000..11ccd3346f --- /dev/null +++ b/crates/prc-render/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pdfcraft-prc-render" +description = "Headless CPU renderer for PRC 3D models: camera, depth-buffered triangle fill, headlight shading" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +pdfcraft-prc.workspace = true +thiserror.workspace = true + +[dev-dependencies] +flate2.workspace = true diff --git a/crates/prc-render/README.md b/crates/prc-render/README.md new file mode 100644 index 0000000000..000d673179 --- /dev/null +++ b/crates/prc-render/README.md @@ -0,0 +1,29 @@ +# pdfcraft-prc-render + +A headless CPU renderer for PRC 3D models read by `pdfcraft-prc`: placed meshes in world space, a fitted camera, a +depth-buffered triangle fill and headlight shading, into a straight-RGBA image. + +- **Layer:** L3. Depends on `pdfcraft-prc` only. +- **Licence:** MIT OR Apache-2.0. +- `#![forbid(unsafe_code)]`. Never panics on input: image size, triangle count and every index are checked, and + damage returns `RenderError`. + +## API + +```rust +assemble(&PrcFile) -> Result, RenderError> // every placement's mesh moved into the world and coloured +place(&TriangleMesh, &Matrix, colour, triangle_colours) -> WorldMesh +Camera::fit(&Bounds, direction, up, perspective, aspect) / Camera::fit_meshes(&[WorldMesh], ...) +render(&[WorldMesh], &Camera, &RenderOptions) -> Result +``` + +- With no product tree, each tessellated mesh is drawn where it lies, in the default colour. +- A triangle's own colour wins over its mesh's, which wins over `RenderOptions::colour`. +- Opaque triangles are drawn first, then translucent ones blended over them; alpha 0 is not drawn. +- `Lighting::Headlight` (default) shades by the angle to the view direction; `Lighting::Unlit` draws flat colour. +- Perspective triangles are clipped at a near plane 1/10,000 of the eye-to-target distance from the eye; orthographic views are not clipped. +- `MAX_RENDER_PIXELS` (2^24, about 16 MP) bounds the image: at most 64 MB of RGBA plus 128 MB of depth. + `MAX_ASSEMBLED_TRIANGLES` and `MAX_ASSEMBLED_VERTICES` (2^24 each) bound one `assemble` call. `MAX_RENDER_WORK` (2^31) bounds + the pixel centres one `render` tests, summed over each triangle's clipped bounding box; past it the render returns + `RenderError::TooMuchWork`. That is still seconds of CPU at the top, so callers on a UI thread should render small or off-thread. +- Not yet: shadows, ambient occlusion, specular light, scene lights, textures, wires and markups. diff --git a/crates/prc-render/src/assemble.rs b/crates/prc-render/src/assemble.rs new file mode 100644 index 0000000000..c9264d620e --- /dev/null +++ b/crates/prc-render/src/assemble.rs @@ -0,0 +1,164 @@ +//! A parsed PRC file's tessellations placed in the world, ready for [`crate::render`]. + +use crate::RenderError; +use crate::raster::channel; +use pdfcraft_prc::{Matrix, PrcFile, Rgba, Tessellation, TriangleMesh}; + +/// The most triangles [`assemble`] places, over every placement: a tessellation many +/// occurrences draw is copied once per occurrence. +pub const MAX_ASSEMBLED_TRIANGLES: usize = 1 << 24; + +/// The most positions, normals and texture coordinates [`assemble`] copies, over every placement. +pub const MAX_ASSEMBLED_VERTICES: usize = 1 << 24; + +/// A triangle mesh in world coordinates with the colours it draws in. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct WorldMesh { + /// The triangles, already placed. + pub mesh: TriangleMesh, + /// The mesh's straight-RGBA colour; `None` takes [`crate::RenderOptions::colour`], opaque. + pub colour: Option<[u8; 4]>, + /// Per triangle, its own colour when its face is styled; `None` (the whole list or an entry) + /// takes [`Self::colour`]. + pub triangle_colours: Option>>, +} + +impl WorldMesh { + /// `mesh`, uncoloured. + #[must_use] + pub fn new(mesh: TriangleMesh) -> Self { + WorldMesh { mesh, colour: None, triangle_colours: None } + } + + /// The colour triangle `t` draws in, before the render options' default. + pub(crate) fn colour_of(&self, t: usize) -> Option<[u8; 4]> { + self.triangle_colours.as_ref().and_then(|c| c.get(t).copied().flatten()).or(self.colour) + } +} + +/// `mesh` moved into the world by `matrix`, drawn in `colour` and, per triangle, `triangle_colours`. +#[must_use] +pub fn place(mesh: &TriangleMesh, matrix: &Matrix, colour: Option<[u8; 4]>, triangle_colours: Option>>) -> WorldMesh { + WorldMesh { mesh: mesh.transformed(matrix), colour, triangle_colours } +} + +/// Every triangle mesh `prc` draws, in world coordinates and coloured as its product tree says +/// ([`PrcFile::placements`]). A file with no tree draws each file structure's meshes where they +/// are, uncoloured. Wires, markups and compressed tessellations are not drawn. +/// +/// # Errors +/// [`RenderError::Model`] when a tessellation section or the tree cannot be read, or the tree +/// places a tessellation its file structure does not have; [`RenderError::TooManyTriangles`] past +/// [`MAX_ASSEMBLED_TRIANGLES`] and [`RenderError::TooManyVertices`] past [`MAX_ASSEMBLED_VERTICES`], +/// counting both what is decoded and what is placed. +pub fn assemble(prc: &PrcFile) -> Result, RenderError> { + let tessellations = decode(prc.file_structures.iter().map(|fs| fs.tessellations()), Budget::default())?; + let mut budget = Budget::default(); + let mut out = Vec::new(); + if !prc.stores_tree() { + for mesh in tessellations.iter().flatten().filter_map(as_mesh) { + budget.spend(mesh)?; + out.push(WorldMesh::new(mesh.clone())); + } + return Ok(out); + } + let items = prc.placements()?.into_iter().map(|p| Item { + file_structure: p.file_structure, + tessellation: p.tessellation, + matrix: p.matrix, + colour: p.colour, + triangle_colours: Box::new(move |mesh: &TriangleMesh| p.triangle_colours(mesh)), + }); + place_items(&tessellations, items, &mut budget, &mut out)?; + Ok(out) +} + +/// Each file structure's tessellations, decoded one structure at a time and charged to `budget` +/// as they are, so decoding stops at the structure that passes a ceiling. +pub(crate) fn decode( + structures: impl IntoIterator, pdfcraft_prc::PrcError>>, + mut budget: Budget, +) -> Result>, RenderError> { + let mut out = Vec::new(); + for tessellations in structures { + let tessellations = tessellations?; + for mesh in tessellations.iter().filter_map(as_mesh) { + budget.spend(mesh)?; + } + out.push(tessellations); + } + Ok(out) +} + +/// One placement as [`place_items`] reads it: a tessellation by file structure and index, where +/// it goes and how it is coloured. +pub(crate) struct Item<'a> { + pub(crate) file_structure: usize, + pub(crate) tessellation: usize, + pub(crate) matrix: Matrix, + pub(crate) colour: Option, + pub(crate) triangle_colours: Box>, +} + +/// Per triangle of a mesh, the colour its face's style resolves to. +type FaceColours<'a> = dyn Fn(&TriangleMesh) -> Option>> + 'a; + +/// Each item's mesh moved into the world and coloured, appended to `out`. Items naming a wire, +/// markup or compressed tessellation are skipped; an item naming no tessellation at all is +/// [`PrcError::Malformed`](pdfcraft_prc::PrcError::Malformed). +pub(crate) fn place_items<'a>( + tessellations: &[Vec], + items: impl IntoIterator>, + budget: &mut Budget, + out: &mut Vec, +) -> Result<(), RenderError> { + for p in items { + let Some(tessellation) = tessellations.get(p.file_structure).and_then(|t| t.get(p.tessellation)) else { + let what = format!("tessellation {} of file structure {}, which has none by that index", p.tessellation, p.file_structure); + return Err(pdfcraft_prc::PrcError::Malformed(what).into()); + }; + let Some(mesh) = as_mesh(tessellation) else { + continue; + }; + budget.spend(mesh)?; + let triangle_colours = (p.triangle_colours)(mesh).map(|c| c.into_iter().map(|c| c.map(bytes)).collect()); + out.push(place(mesh, &p.matrix, p.colour.map(bytes), triangle_colours)); + } + Ok(()) +} + +/// Triangles and vertex entries (positions, normals, texture coordinates) placed so far. +#[derive(Debug, Default)] +pub(crate) struct Budget { + pub(crate) triangles: usize, + pub(crate) vertices: usize, +} + +impl Budget { + fn spend(&mut self, mesh: &TriangleMesh) -> Result<(), RenderError> { + let triangles = + self.triangles.checked_add(mesh.triangles.len()).filter(|&n| n <= MAX_ASSEMBLED_TRIANGLES).ok_or(RenderError::TooManyTriangles)?; + let vertices = [mesh.positions.len(), mesh.normals.len(), mesh.uvs.len()] + .into_iter() + .try_fold(self.vertices, usize::checked_add) + .filter(|&n| n <= MAX_ASSEMBLED_VERTICES) + .ok_or(RenderError::TooManyVertices)?; + (self.triangles, self.vertices) = (triangles, vertices); + Ok(()) + } +} + +fn as_mesh(t: &Tessellation) -> Option<&TriangleMesh> { + match t { + Tessellation::Mesh(m) => Some(m), + _ => None, + } +} + +/// A 0–1 colour as straight-RGBA bytes, each channel clamped. +fn bytes(c: Rgba) -> [u8; 4] { + [c.r, c.g, c.b, c.a].map(|v| channel(v.clamp(0.0, 1.0) * 255.0)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc-render/src/assemble/tests.rs b/crates/prc-render/src/assemble/tests.rs new file mode 100644 index 0000000000..5018bdbe8c --- /dev/null +++ b/crates/prc-render/src/assemble/tests.rs @@ -0,0 +1,127 @@ +#![allow(clippy::unwrap_used)] + +use super::*; + +fn triangle() -> TriangleMesh { + let mut m = TriangleMesh::default(); + m.positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + m.triangles = vec![[0, 1, 2]]; + m +} + +#[test] +fn a_placement_moves_its_mesh_into_the_world() { + let mut shift = Matrix::IDENTITY; + shift.0[0][3] = 5.0; + shift.0[2][3] = -2.0; + let placed = place(&triangle(), &shift, Some([1, 2, 3, 255]), None); + assert_eq!(placed.mesh.positions, [[5.0, 0.0, -2.0], [6.0, 0.0, -2.0], [5.0, 1.0, -2.0]]); + assert_eq!(placed.colour, Some([1, 2, 3, 255])); + // A mirror keeps the triangle facing the same way round. + let mut mirror = Matrix::IDENTITY; + mirror.0[0][0] = -1.0; + assert_eq!(place(&triangle(), &mirror, None, None).mesh.triangles, [[0, 2, 1]]); +} + +#[test] +fn a_triangle_colour_wins_over_the_mesh_colour() { + let mut m = place(&triangle(), &Matrix::IDENTITY, Some([9, 9, 9, 255]), Some(vec![Some([1, 1, 1, 255]), None])); + m.mesh.triangles.push([0, 2, 1]); + m.mesh.triangles.push([1, 2, 0]); + assert_eq!(m.colour_of(0), Some([1, 1, 1, 255])); + assert_eq!(m.colour_of(1), Some([9, 9, 9, 255]), "an unstyled face takes the mesh's colour"); + assert_eq!(m.colour_of(2), Some([9, 9, 9, 255]), "past the list too"); + assert_eq!(WorldMesh::new(triangle()).colour_of(0), None); +} + +#[test] +fn colours_are_clamped_into_bytes() { + let [r, g, b, a] = [0.0, 1.0, 0.5, 2.0]; + let mut c = Rgba::default(); + (c.r, c.g, c.b, c.a) = (r, g, b, a); + assert_eq!(bytes(c), [0, 255, 128, 255]); + c.r = f64::NAN; + c.g = -3.0; + assert_eq!(bytes(c)[..2], [0, 0]); +} + +#[test] +fn the_triangle_budget_is_shared_and_refuses_past_the_ceiling() { + let mut b = Budget { triangles: MAX_ASSEMBLED_TRIANGLES - 1, vertices: 0 }; + assert!(b.spend(&triangle()).is_ok()); + assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles)); + let mut b = Budget { triangles: usize::MAX, vertices: 0 }; + assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles)); +} + +#[test] +fn the_vertex_budget_counts_positions_normals_and_uvs() { + let mut heavy = triangle(); + heavy.normals = vec![[0.0, 0.0, 1.0]; 2]; + heavy.uvs = vec![[0.0; 2]; 4]; + let mut b = Budget::default(); + b.spend(&heavy).unwrap(); + assert_eq!((b.triangles, b.vertices), (1, 9)); + // One triangle over many vertices: the triangle ceiling alone would let it through. + let mut b = Budget { triangles: 0, vertices: MAX_ASSEMBLED_VERTICES - 8 }; + assert_eq!(b.spend(&heavy), Err(RenderError::TooManyVertices)); + assert_eq!((b.triangles, b.vertices), (0, MAX_ASSEMBLED_VERTICES - 8), "a refused mesh spends nothing"); + let mut b = Budget { triangles: 0, vertices: usize::MAX }; + assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyVertices)); +} + +fn item<'a>(fs: usize, tess: usize, x: f64, colour: Option) -> Item<'a> { + let mut matrix = Matrix::IDENTITY; + matrix.0[0][3] = x; + Item { file_structure: fs, tessellation: tess, matrix, colour, triangle_colours: Box::new(|_| None) } +} + +#[test] +fn each_placement_draws_its_tessellation_where_and_in_the_colour_the_tree_says() { + let tessellations = vec![vec![Tessellation::Mesh(triangle())]]; + let mut red = Rgba::default(); + red.r = 1.0; + red.a = 1.0; + // Two occurrences of one tessellation at different places, one coloured. + let items = [item(0, 0, 3.0, Some(red)), item(0, 0, -4.0, None)]; + let (mut budget, mut out) = (Budget::default(), Vec::new()); + place_items(&tessellations, items, &mut budget, &mut out).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].mesh.positions, [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]]); + assert_eq!(out[0].colour, Some([255, 0, 0, 255])); + assert_eq!(out[1].mesh.positions, [[-4.0, 0.0, 0.0], [-3.0, 0.0, 0.0], [-4.0, 1.0, 0.0]]); + assert_eq!(out[1].colour, None); + assert_eq!(budget.triangles, 2, "each occurrence spends its triangles"); +} + +#[test] +fn a_placement_naming_a_missing_tessellation_is_malformed() { + let tessellations = vec![vec![Tessellation::Mesh(triangle())]]; + for (fs, tess) in [(1, 0), (0, 7)] { + let (mut budget, mut out) = (Budget::default(), Vec::new()); + let r = place_items(&tessellations, [item(fs, tess, 0.0, None)], &mut budget, &mut out); + assert!(matches!(r, Err(RenderError::Model(pdfcraft_prc::PrcError::Malformed(_)))), "{fs}/{tess}: {r:?}"); + } +} + +#[test] +fn a_placement_past_the_triangle_budget_is_an_error() { + let tessellations = vec![vec![Tessellation::Mesh(triangle())]]; + let (mut budget, mut out) = (Budget { triangles: MAX_ASSEMBLED_TRIANGLES, vertices: 0 }, Vec::new()); + let r = place_items(&tessellations, [item(0, 0, 0.0, None)], &mut budget, &mut out); + assert!(matches!(r, Err(RenderError::TooManyTriangles))); + assert!(out.is_empty()); +} + +#[test] +fn decoding_stops_at_the_structure_that_passes_the_ceiling() { + let pulled = std::cell::Cell::new(0); + let structures = std::iter::repeat_with(|| { + pulled.set(pulled.get() + 1); + Ok(vec![Tessellation::Mesh(triangle())]) + }) + .take(3); + let nearly_full = Budget { triangles: MAX_ASSEMBLED_TRIANGLES - 1, vertices: 0 }; + assert_eq!(decode(structures, nearly_full).err(), Some(RenderError::TooManyTriangles)); + assert_eq!(pulled.get(), 2, "the third structure is never decoded"); +} diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs new file mode 100644 index 0000000000..9cfd49efbe --- /dev/null +++ b/crates/prc-render/src/lib.rs @@ -0,0 +1,18 @@ +//! Headless CPU rendering of PRC 3D models (ISO 14739-1) to an RGBA image. +//! +//! Layer L3: depends only on `pdfcraft-prc`. [`assemble`] turns a parsed [`pdfcraft_prc::PrcFile`] +//! into world-space meshes; [`render`] draws them from a [`Camera`] with one depth-tested sample per +//! pixel centre and a light at the camera. Single-threaded and GUI-free, so it runs unchanged on wasm32. +//! The image size is capped by [`MAX_RENDER_PIXELS`], the drawing work by [`MAX_RENDER_WORK`] and the +//! assembled model by [`MAX_ASSEMBLED_TRIANGLES`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an +//! error, never a panic. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod assemble; +mod raster; +mod render; +mod vec3; + +pub use assemble::{MAX_ASSEMBLED_TRIANGLES, MAX_ASSEMBLED_VERTICES, WorldMesh, assemble, place}; +pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, MAX_RENDER_WORK, Projection, RenderError, RenderOptions, render}; diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs new file mode 100644 index 0000000000..4427df5973 --- /dev/null +++ b/crates/prc-render/src/raster.rs @@ -0,0 +1,321 @@ +//! Projection, near-plane clipping and the depth-tested triangle fill behind [`crate::render`]. + +use crate::render::{Camera, Projection, RenderError}; +use crate::vec3::{length, sub}; + +/// One view-space corner: x, y, z (z along the view direction), then its red, green and blue shade. +pub(crate) type ViewCorner = [f64; 6]; + +/// One screen-space corner: pixel x, pixel y and depth key, and its shade. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Corner { + pub(crate) at: [f64; 3], + pub(crate) shade: [f64; 3], +} + +/// The perspective near plane, as a fraction of the eye-to-target distance. +pub(crate) const NEAR_FRACTION: f64 = 1e-4; + +pub(crate) enum View { + /// `focal` = 1 / tan(fov_y / 2); `near` = the clip distance. + Perspective { + focal: f64, + aspect: f64, + near: f64, + }, + Orthographic { + half_height: f64, + aspect: f64, + }, +} + +impl View { + /// The projection for `camera` at width/height `aspect`; refuses a field of view outside + /// (0, 180) degrees or a non-positive orthographic height. + pub(crate) fn new(camera: &Camera, aspect: f64) -> Result { + match camera.projection { + Projection::Perspective { fov_y } => { + if !(fov_y > 0.0 && fov_y < 180.0) { + return Err(RenderError::Camera("the field of view is not between 0 and 180 degrees")); + } + let focal = 1.0 / (fov_y.to_radians() / 2.0).tan(); + // A field of view too small to measure leaves no finite focal length. + if !focal.is_finite() { + return Err(RenderError::Camera("the field of view is too small")); + } + let distance = length(sub(camera.target, camera.eye)); + Ok(View::Perspective { focal, aspect, near: (distance * NEAR_FRACTION).max(f64::MIN_POSITIVE) }) + } + Projection::Orthographic { height } => { + if !(height.is_finite() && height > 0.0) { + return Err(RenderError::Camera("the orthographic height is not positive")); + } + Ok(View::Orthographic { half_height: height / 2.0, aspect }) + } + } + } + + /// The part of `tri` in front of the near plane (Sutherland–Hodgman against one plane): + /// 0, 3 or 4 corners, the shade interpolated along each cut edge. + pub(crate) fn clip(&self, tri: [ViewCorner; 3]) -> Vec { + let View::Perspective { near, .. } = *self else { + return tri.to_vec(); + }; + let mut out = Vec::with_capacity(4); + for (k, &p) in tri.iter().enumerate() { + let q = tri.get((k + 1) % 3).copied().unwrap_or(p); + let z = |v: ViewCorner| v.get(2).copied().unwrap_or(0.0); + let (p_in, q_in) = (z(p) >= near, z(q) >= near); + if p_in { + out.push(p); + } + if p_in != q_in { + // Cut from the same end whichever way round the edge is met, so two triangles + // sharing it get the same cut point. + let (s, e) = if first(&p, &q) { (p, q) } else { (q, p) }; + let t = (near - z(s)) / (z(e) - z(s)); + let mut cut = s; + for (c, d) in cut.iter_mut().zip(e) { + *c += t * (d - *c); + } + out.push(cut); + } + } + out + } + + /// The screen corner of view corner `v`: pixel x, pixel y and a depth key that is linear + /// in screen space and larger for nearer points. + pub(crate) fn project(&self, [x, y, z, r, g, b]: ViewCorner, width: u32, height: u32) -> Corner { + let (nx, ny, key) = match *self { + // Divided before multiplied, so an on-screen point's intermediate products stay finite. + View::Perspective { focal, aspect, .. } => (x / z * focal / aspect, y / z * focal, 1.0 / z), + View::Orthographic { half_height, aspect } => (x / half_height / aspect, y / half_height, -z), + }; + Corner { at: [(nx + 1.0) / 2.0 * f64::from(width), (1.0 - ny) / 2.0 * f64::from(height), key], shade: [r, g, b] } + } +} + +/// Whether view corner `a`'s position comes before `b`'s, ordering x, then y, then z. +fn first(a: &ViewCorner, b: &ViewCorner) -> bool { + a.iter().zip(b).take(3).map(|(p, q)| p.total_cmp(q)).find(|o| o.is_ne()).is_none_or(std::cmp::Ordering::is_lt) +} + +/// The image being drawn: `rgba` holds `width * height` straight-RGBA pixels and `depth` their +/// depth keys, row by row from the top. +pub(crate) struct Target<'b> { + /// Depth keys are 1/z (a perspective view), so the shade is interpolated perspective-correct. + pub(crate) perspective: bool, + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) rgba: &'b mut [u8], + pub(crate) depth: &'b mut [f64], + /// Pixel centres the fills may still test. + pub(crate) work: u64, +} + +impl Target<'_> { + /// Fill the screen triangle of `corners` with straight-RGBA `colour`, depth-tested at pixel + /// centres, the shade interpolated across it. A pixel centre on an edge two triangles share + /// belongs to one of them only. An opaque pixel replaces the pixel and its depth; a + /// translucent one is blended over it, leaving the depth. The clipped bounding box's pixel + /// centres are charged to [`Self::work`] first: [`RenderError::TooMuchWork`], with nothing + /// drawn, when too few are left. + pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) -> Result<(), RenderError> { + let [a, b, c] = corners; + let area = edge(a.at, b.at, c.at); + let edges = [(b.at, c.at), (c.at, a.at), (a.at, b.at)]; + if !area.is_finite() || area.abs() < 1e-12 { + return Ok(()); + } + let Some((x0, x1, y0, y1)) = self.bounds(&corners) else { + return Ok(()); + }; + let span = |lo: u32, hi: u32| u64::from(hi.saturating_sub(lo)).saturating_add(1); + self.work = self.work.checked_sub(span(x0, x1).saturating_mul(span(y0, y1))).ok_or(RenderError::TooMuchWork)?; + for y in y0..=y1 { + for x in x0..=x1 { + let p = [f64::from(x) + 0.5, f64::from(y) + 0.5, 0.0]; + let w = edges.map(|(from, to)| edge(from, to, p) / area); + if w.iter().zip(edges).any(|(&k, (from, to))| k < 0.0 || (k == 0.0 && !owns(from, to, area))) { + continue; + } + let mix = |f: &dyn Fn(&Corner) -> f64| w.iter().zip(&corners).map(|(k, c)| k * f(c)).sum::(); + let key = mix(&|c| at(c.at, 2)); + let Some(i) = pixel_index(self.width, x, y) else { + continue; + }; + if self.depth.get(i).is_none_or(|&d| key <= d) { + continue; + } + let shade = if a.shade == b.shade && b.shade == c.shade { + a.shade + } else if self.perspective && key > 0.0 { + // Screen weights times 1/z, renormalised, are the surface's own weights. + [0, 1, 2].map(|j| mix(&|c| at(c.shade, j) * at(c.at, 2)) / key) + } else { + [0, 1, 2].map(|j| mix(&|c| at(c.shade, j))) + }; + self.blend(i, key, shaded(colour, shade)); + } + } + Ok(()) + } + + /// The pixel rows and columns `corners` may cover, clipped to the image; `None` when off it. + fn bounds(&self, corners: &[Corner; 3]) -> Option<(u32, u32, u32, u32)> { + let lo = |i: usize| corners.iter().map(|c| at(c.at, i)).fold(f64::INFINITY, f64::min).floor().max(0.0); + let hi = |i: usize, limit: u32| corners.iter().map(|c| at(c.at, i)).fold(f64::NEG_INFINITY, f64::max).ceil().min(f64::from(limit) - 1.0); + let (x0, x1) = (lo(0), hi(0, self.width)); + let (y0, y1) = (lo(1), hi(1, self.height)); + // Each bound is finite and within 0..limit here, so the casts are exact. + (x0 <= x1 && y0 <= y1).then_some((x0 as u32, x1 as u32, y0 as u32, y1 as u32)) + } + + /// Write straight-RGBA `rgba` at pixel `i`: replacing it and its depth when opaque, blended + /// over it otherwise. + fn blend(&mut self, i: usize, key: f64, rgba: [u8; 4]) { + let opaque = rgba[3] == 255; + if opaque && let Some(d) = self.depth.get_mut(i) { + *d = key; + } + let Some(px) = i.checked_mul(4).and_then(|s| self.rgba.get_mut(s..s.checked_add(4)?)) else { + return; + }; + if opaque { + px.copy_from_slice(&rgba); + } else if let [pr, pg, pb, pa] = px { + let [sr, sg, sb, sa] = rgba; + let src = f64::from(sa) / 255.0; + let dst = f64::from(*pa) / 255.0 * (1.0 - src); + let out = src + dst; + if out <= 0.0 { + return; + } + for (p, s) in [pr, pg, pb].into_iter().zip([sr, sg, sb]) { + *p = channel((f64::from(s) * src + f64::from(*p) * dst) / out); + } + *pa = channel(out * 255.0); + } + } +} + +/// Pixel `x, y`'s index in an image `width` wide; `None` on overflow. +pub(crate) fn pixel_index(width: u32, x: u32, y: u32) -> Option { + usize::try_from(y).ok()?.checked_mul(usize::try_from(width).ok()?)?.checked_add(usize::try_from(x).ok()?) +} + +/// `v` rounded and clamped to a colour channel; NaN is 0. +pub(crate) fn channel(v: f64) -> u8 { + // Clamped to 0..=255 first, so the cast is exact (NaN casts to 0). + v.round().clamp(0.0, 255.0) as u8 +} + +/// `colour`'s RGB scaled channel by channel by `shade`, saturating at 255; alpha kept. +fn shaded([r, g, b, a]: [u8; 4], [sr, sg, sb]: [f64; 3]) -> [u8; 4] { + let [r, g, b] = [(r, sr), (g, sg), (b, sb)].map(|(v, s)| channel(f64::from(v) * s)); + [r, g, b, a] +} + +/// Twice the signed area of (a, b, p) in the xy plane. +pub(crate) fn edge(a: [f64; 3], b: [f64; 3], p: [f64; 3]) -> f64 { + // Two triangles sharing an edge meet it in opposite orders; evaluating it in one canonical + // order makes their values exact negatives, so a pixel centre on the edge cannot round out + // of both and leave a crack. + if (at(a, 0), at(a, 1)) > (at(b, 0), at(b, 1)) { + return -edge(b, a, p); + } + (at(b, 0) - at(a, 0)) * (at(p, 1) - at(a, 1)) - (at(b, 1) - at(a, 1)) * (at(p, 0) - at(a, 0)) +} + +/// Whether a pixel centre exactly on the edge `from`-`to` of a triangle of signed doubled area +/// `area` is the triangle's: the side the triangle grows towards, +x first, then +y. Two +/// triangles sharing the edge lie on opposite sides of it, so exactly one owns it. +fn owns(from: [f64; 3], to: [f64; 3], area: f64) -> bool { + let s = area.signum(); + let (gx, gy) = (-(at(to, 1) - at(from, 1)) * s, (at(to, 0) - at(from, 0)) * s); + gx > 0.0 || (gx == 0.0 && gy > 0.0) +} + +/// Component `i` of `v`, 0.0 out of range. +pub(crate) fn at(v: [f64; 3], i: usize) -> f64 { + v.get(i).copied().unwrap_or(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_edge_seen_from_either_triangle_is_exactly_negated() { + // So no pixel centre on a shared edge is rejected by both. + let vals = [0.1, 0.7, 1.3, 2.9, 3.3, 5.5]; + for (a, b, p) in [([0.1, 0.1], [0.7, 0.7], [0.1, 2.9]), ([5.5, 0.7], [1.3, 3.3], [2.9, 0.1])] + .into_iter() + .chain(vals.iter().map(|&v| ([v, 0.1], [0.7, v], [v, v]))) + { + let [a, b, p] = [a, b, p].map(|[x, y]| [x, y, 0.0]); + assert_eq!(edge(a, b, p), -edge(b, a, p), "{a:?} {b:?} {p:?}"); + } + } + + #[test] + fn pixel_indices_and_channels_never_overflow() { + assert_eq!(pixel_index(10, 3, 2), Some(23)); + assert_eq!(channel(f64::NAN), 0); + assert_eq!(channel(1e300), 255); + assert_eq!(channel(-4.0), 0); + assert_eq!(channel(127.5), 128); + } + + #[test] + fn a_shared_edge_is_cut_at_the_same_point_from_either_triangle() { + // Cutting this edge from P towards Q and from Q towards P rounds differently. + let view = View::Perspective { focal: 1.0, aspect: 1.0, near: 0.5 }; + let (p, q) = ([0.1, 0.1, 0.2, 1.0, 1.0, 1.0], [0.7, 0.2, 1.6, 1.0, 1.0, 1.0]); + let one = view.clip([p, q, [0.0, 3.0, 2.0, 1.0, 1.0, 1.0]]); + let other = view.clip([q, p, [3.0, 0.0, 2.0, 1.0, 1.0, 1.0]]); + let shared = one.iter().filter(|a| other.iter().any(|b| a[..3] == b[..3])).count(); + assert_eq!(shared, 2, "Q and the cut on PQ: {one:?} {other:?}"); + } + + fn corner(x: f64, y: f64, key: f64, shade: f64) -> Corner { + Corner { at: [x, y, key], shade: [shade; 3] } + } + + #[test] + fn a_pixel_on_an_edge_two_triangles_share_is_blended_once() { + let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]); + let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: u64::MAX }; + let [a, b, c, d] = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)].map(|(x, y)| corner(x, y, 1.0, 1.0)); + t.fill([a, b, c], [255, 0, 0, 128]).unwrap(); + t.fill([a, c, d], [255, 0, 0, 128]).unwrap(); + // The diagonal runs through four pixel centres; each must look like the rest. + for px in rgba.chunks(4) { + assert_eq!(px, [255, 0, 0, 128]); + } + } + + #[test] + fn a_perspective_shade_is_interpolated_on_the_surface_not_the_screen() { + let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]); + let mut t = Target { perspective: true, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: u64::MAX }; + // The x = 4 corner is three times as far away as the others, and the only lit one. + t.fill([corner(0.0, 0.0, 1.0, 0.0), corner(4.0, 0.0, 1.0 / 3.0, 1.0), corner(0.0, 4.0, 1.0, 0.0)], [240, 240, 240, 255]).unwrap(); + // At screen (1.5, 0.5) the weights are 0.5, 0.375, 0.125: on the surface the far corner + // counts 0.125 / 0.75 = 1/6, where on the screen it would count 0.375. + assert_eq!(rgba.get(4..8), Some(&[40, 40, 40, 255][..])); + } + + #[test] + fn fills_stop_when_the_work_allowance_is_spent() { + let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]); + // Each fill's box is the whole 4x4 image: 16 pixel centres. + let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: 40 }; + let tri = [corner(0.0, 0.0, 1.0, 1.0), corner(4.0, 0.0, 1.0, 1.0), corner(0.0, 4.0, 1.0, 1.0)]; + assert_eq!(t.fill(tri, [255, 0, 0, 255]), Ok(())); + assert_eq!(t.fill(tri, [255, 0, 0, 255]), Ok(())); + assert_eq!(t.fill(tri, [255, 0, 0, 255]), Err(RenderError::TooMuchWork)); + assert_eq!(t.work, 8); + } +} diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs new file mode 100644 index 0000000000..bcaf8acced --- /dev/null +++ b/crates/prc-render/src/render.rs @@ -0,0 +1,388 @@ +//! The camera, the render options and [`render`]: world meshes to an RGBA image. +//! +//! One sample per pixel centre, no antialiasing. Faces are lit on both sides because a +//! producer's winding is not trusted to face outward. + +use crate::WorldMesh; +use crate::raster::{Corner, Target, View, ViewCorner, at}; +use crate::vec3::{cross, dot, length, normalize, scale, span, sub, unit_cross}; +use pdfcraft_prc::TriangleMesh; + +/// The largest image [`render`] draws, in pixels (width × height). +pub const MAX_RENDER_PIXELS: u64 = 16 * 1024 * 1024; + +/// The most pixel centres one [`render`] tests, summed over each drawn triangle's bounding box +/// clipped to the image: 128 full layers of the largest image. +pub const MAX_RENDER_WORK: u64 = 1 << 31; + +/// [`Camera::fit`]'s framed extent over the model's: 5% clear on each side. +const FIT_MARGIN: f64 = 1.1; + +/// How the camera projects the scene onto the image. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Projection { + /// A pinhole camera with this vertical field of view, in degrees (exclusive range 0–180). + Perspective { + /// Vertical field of view, degrees. + fov_y: f64, + }, + /// A parallel projection showing this much of the scene vertically, in model units. + Orthographic { + /// Visible height, model units. + height: f64, + }, +} + +/// Where the camera is and where it looks, in world coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Camera { + /// The camera position. + pub eye: [f64; 3], + /// The point at the image centre. + pub target: [f64; 3], + /// The direction that appears upward; must not be parallel to the view direction. + pub up: [f64; 3], + /// The projection. + pub projection: Projection, +} + +/// An axis-aligned box around every point a set of meshes draws. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Bounds { + /// The smallest x, y and z. + pub min: [f64; 3], + /// The largest x, y and z. + pub max: [f64; 3], +} + +impl Bounds { + /// The box around every finite vertex the meshes' drawn triangles use, or `None` when there + /// is none. + #[must_use] + pub fn of(meshes: &[WorldMesh]) -> Option { + let mut out: Option = None; + for p in vertices(meshes) { + let b = out.get_or_insert(Bounds { min: p, max: p }); + for ((lo, hi), c) in b.min.iter_mut().zip(b.max.iter_mut()).zip(p) { + *lo = lo.min(c); + *hi = hi.max(c); + } + } + out + } + + /// The box's centre, finite for any finite box. + #[must_use] + pub fn centre(&self) -> [f64; 3] { + [0, 1, 2].map(|i| at(self.min, i) / 2.0 + at(self.max, i) / 2.0) + } + + /// Half the box's diagonal: the radius of a sphere holding it. + #[must_use] + pub fn radius(&self) -> f64 { + length(sub(self.max, self.min)) / 2.0 + } +} + +/// Every corner of the triangles [`render`] draws: those not fully transparent whose three +/// corners exist and are finite. +fn vertices(meshes: &[WorldMesh]) -> impl Iterator + Clone + '_ { + meshes + .iter() + .flat_map(|m| m.mesh.triangles.iter().enumerate().filter(|&(t, _)| !invisible(m, t)).filter_map(|(_, tri)| corners(&m.mesh, tri)).flatten()) +} + +/// Whether triangle `t` of `m` is fully transparent, so [`render`] does not draw it. +fn invisible(m: &WorldMesh, t: usize) -> bool { + m.colour_of(t).is_some_and(|c| c[3] == 0) +} + +impl Camera { + /// A camera looking along `direction` that fits `bounds` in an image of the given aspect + /// ratio (width / height), with a 30° perspective or an orthographic projection: the box's + /// eight corners framed from this direction, about 5% clear on each side of the tighter axis. + /// + /// # Errors + /// [`RenderError::Camera`] when `direction` is zero or parallel to `up`, or `aspect` is not positive. + pub fn fit(bounds: &Bounds, direction: [f64; 3], up: [f64; 3], perspective: bool, aspect: f64) -> Result { + let corners = (0..8).map(|k| [0, 1, 2].map(|i| if k >> i & 1 == 0 { at(bounds.min, i) } else { at(bounds.max, i) })); + Camera::frame(bounds, corners, direction, up, (perspective, aspect)) + } + + /// As [`Camera::fit`], framing every vertex the meshes' drawn triangles use rather than their box, + /// so an oblique view is not padded by empty box corners. + /// + /// # Errors + /// As [`Camera::fit`], and when the meshes have no finite vertex. + pub fn fit_meshes(meshes: &[WorldMesh], direction: [f64; 3], up: [f64; 3], perspective: bool, aspect: f64) -> Result { + let bounds = Bounds::of(meshes).ok_or(RenderError::Camera("the model has no vertex"))?; + Camera::frame(&bounds, vertices(meshes), direction, up, (perspective, aspect)) + } + + /// Frames `points` (which `bounds` holds) from `direction`, aimed at the middle of their projection. + fn frame( + bounds: &Bounds, + points: impl Iterator + Clone, + direction: [f64; 3], + up: [f64; 3], + (perspective, aspect): (bool, f64), + ) -> Result { + if !(aspect.is_finite() && aspect > 0.0) { + return Err(RenderError::Camera("the aspect ratio is not positive")); + } + let dir = normalize(direction).ok_or(RenderError::Camera("the view direction is zero"))?; + let centre = bounds.centre(); + let radius = match bounds.radius() { + r if r.is_finite() && r > 0.0 => r, + _ => 1.0, + }; + let [right, upward, forward] = axes(dir, up)?; + let view = |p: [f64; 3]| { + let r = sub(p, centre); + [dot(r, right), dot(r, upward), dot(r, forward)] + }; + let ((x0, x1), (y0, y1)) = points + .clone() + .map(view) + .fold(((f64::INFINITY, f64::NEG_INFINITY), (f64::INFINITY, f64::NEG_INFINITY)), |((a0, a1), (b0, b1)), [x, y, _]| { + ((a0.min(x), a1.max(x)), (b0.min(y), b1.max(y))) + }); + if !(x0 <= x1 && y0 <= y1) { + return Err(RenderError::Camera("the model has no vertex")); + } + let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let target = [0, 1, 2].map(|i| at(centre, i) + mx * at(right, i) + my * at(upward, i)); + let floor = radius * 1e-3; + let (projection, distance) = if perspective { + let fov_y: f64 = 30.0; + let ty = (fov_y.to_radians() / 2.0).tan() / FIT_MARGIN; + let tx = ty * aspect; + // A point at lateral (x, y) from the target and depth f past it is in view when + // |x| <= (d + f)·tx and |y| <= (d + f)·ty. + let d = points.map(view).fold(floor, |d, [x, y, f]| { + let need = ((x - mx).abs() / tx).max((y - my).abs() / ty); + d.max(need - f).max(floor - f) + }); + (Projection::Perspective { fov_y }, d) + } else { + let height = ((y1 - y0).max((x1 - x0) / aspect) * FIT_MARGIN).max(floor); + (Projection::Orthographic { height }, 2.0 * radius) + }; + let camera = Camera { eye: sub(target, scale(dir, distance)), target, up, projection }; + camera.basis()?; + Ok(camera) + } + + /// Right, up and forward unit vectors. + pub(crate) fn basis(&self) -> Result<[[f64; 3]; 3], RenderError> { + let forward = normalize(sub(self.target, self.eye)).ok_or(RenderError::Camera("the eye and the target coincide"))?; + axes(forward, self.up) + } +} + +/// Right, up and `forward` (a unit vector) for a camera whose up direction is `up`. +fn axes(forward: [f64; 3], up: [f64; 3]) -> Result<[[f64; 3]; 3], RenderError> { + let right = normalize(cross(forward, up)).ok_or(RenderError::Camera("the up direction is zero or parallel to the view direction"))?; + Ok([right, cross(right, forward), forward]) +} + +/// How surfaces are lit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum Lighting { + /// One light at the camera: a surface's colour times `0.3 + 0.7·|n·v|`, where `v` points from + /// the surface to the eye (orthographic: against the view direction). + #[default] + Headlight, + /// No shading: every surface in its own colour. + Unlit, +} + +/// What [`render`] draws. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RenderOptions { + /// Image width, pixels. + pub width: u32, + /// Image height, pixels. + pub height: u32, + /// Background colour, straight RGBA. + pub background: [u8; 4], + /// The colour of a surface with none of its own. + pub colour: [u8; 3], + /// How surfaces are lit. + pub lighting: Lighting, +} + +impl Default for RenderOptions { + /// 1024 × 768, opaque white background, light grey surface, the headlight. + fn default() -> Self { + RenderOptions { width: 1024, height: 768, background: [255, 255, 255, 255], colour: [190, 192, 200], lighting: Lighting::Headlight } + } +} + +/// A rendered image: `rgba` holds `width * height` straight-alpha pixels, row by row from the top. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Image { + /// Width, pixels. + pub width: u32, + /// Height, pixels. + pub height: u32, + /// The pixels. + pub rgba: Vec, +} + +/// Why [`render`], [`Camera::fit`] or [`crate::assemble`] refused. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum RenderError { + /// The image is empty or larger than [`MAX_RENDER_PIXELS`]. + #[error("a {width}x{height} image is empty or larger than the render ceiling")] + Size { + /// Requested width. + width: u32, + /// Requested height. + height: u32, + }, + /// The camera cannot form a view. + #[error("invalid camera: {0}")] + Camera(&'static str), + /// The model could not be read. + #[error("unreadable model: {0}")] + Model(#[from] pdfcraft_prc::PrcError), + /// The placed model has more than [`crate::MAX_ASSEMBLED_TRIANGLES`] triangles. + #[error("the placed model has more triangles than the assembly ceiling")] + TooManyTriangles, + /// The placed model has more than [`crate::MAX_ASSEMBLED_VERTICES`] positions, normals and + /// texture coordinates. + #[error("the placed model has more vertices than the assembly ceiling")] + TooManyVertices, + /// Drawing would test more than [`MAX_RENDER_WORK`] pixel centres. + #[error("the render would test more pixels than the work ceiling")] + TooMuchWork, +} + +/// Draw `meshes` as seen by `camera`. +/// +/// Each triangle is filled in its own colour ([`WorldMesh::triangle_colours`]), else its mesh's +/// ([`WorldMesh::colour`]), else [`RenderOptions::colour`], and lit per [`RenderOptions::lighting`]; +/// nearer surfaces hide farther ones. Opaque triangles are drawn first; translucent ones are then +/// blended over them, hidden by nearer opaque surfaces but not by each other, in mesh order; fully +/// transparent ones are not drawn. Triangles with a non-finite or out-of-range vertex are skipped. +/// A perspective camera clips at a near plane 1/10,000 of the way from the eye to the target, so +/// what lies behind the eye, or in front of it but nearer than that, is not drawn; an orthographic +/// camera does not clip. +/// +/// # Errors +/// [`RenderError::Size`] for an empty or over-ceiling image; [`RenderError::Camera`] for a camera +/// that cannot form a view (eye on the target, `up` parallel to the view, a field of view outside +/// 0–180° or too small for a finite focal length, a non-positive orthographic height); +/// [`RenderError::TooMuchWork`] past [`MAX_RENDER_WORK`]. +pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> Result { + let (width, height) = (options.width, options.height); + let size = RenderError::Size { width, height }; + let pixels = u64::from(width) * u64::from(height); + if pixels == 0 || pixels > MAX_RENDER_PIXELS { + return Err(size); + } + let pixels = usize::try_from(pixels).map_err(|_| size)?; + let view = View::new(camera, f64::from(width) / f64::from(height))?; + let perspective = matches!(view, View::Perspective { .. }); + let scene = Scene { eye: camera.eye, basis: camera.basis()?, view, lighting: options.lighting }; + let mut rgba = options.background.repeat(pixels); + let mut depth = vec![f64::NEG_INFINITY; pixels]; + let mut target = Target { perspective, width, height, rgba: &mut rgba, depth: &mut depth, work: MAX_RENDER_WORK }; + let [r, g, b] = options.colour; + for opaque in [true, false] { + for m in meshes { + for t in 0..m.mesh.triangles.len() { + let colour = m.colour_of(t).unwrap_or([r, g, b, 255]); + if (colour[3] == 255) == opaque && colour[3] != 0 { + scene.draw(&mut target, &m.mesh, t, colour)?; + } + } + } + } + Ok(Image { width, height, rgba }) +} + +/// The camera a frame is drawn from, with its basis and projection. +struct Scene { + eye: [f64; 3], + /// Right, up and forward. + basis: [[f64; 3]; 3], + view: View, + lighting: Lighting, +} + +impl Scene { + /// The shade at world point `p` with normal `n`. + fn shade(&self, p: [f64; 3], n: [f64; 3]) -> [f64; 3] { + if self.lighting == Lighting::Unlit { + return [1.0; 3]; + } + let towards = match self.view { + View::Perspective { .. } => normalize(sub(self.eye, p)), + View::Orthographic { .. } => Some(scale(at3(&self.basis, 2), -1.0)), + }; + [0.3 + 0.7 * towards.map_or(1.0, |v| dot(n, v).abs().min(1.0)); 3] + } + + /// Draw triangle `t` of `mesh` into `target` in straight-RGBA `colour`; refused once the + /// target's work allowance is spent. + fn draw(&self, target: &mut Target, mesh: &TriangleMesh, t: usize, colour: [u8; 4]) -> Result<(), RenderError> { + let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else { + return Ok(()); + }; + let Some(face) = unit_cross(span(at3(&world, 0), at3(&world, 1)), span(at3(&world, 0), at3(&world, 2))) else { + return Ok(()); + }; + let stored = stored_normals(mesh, t); + let normal = |k: usize| stored.and_then(|n| n.get(k).copied().flatten()).unwrap_or(face); + // Without stored normals the triangle is flat: one shade, lit along the view ray to its first corner. + let flat = stored.is_none().then(|| self.shade(at3(&world, 0), normal(0))); + let [right, up, forward] = self.basis; + let view_corners = [0, 1, 2].map(|k| -> ViewCorner { + let p = at3(&world, k); + let [r, g, b] = flat.unwrap_or_else(|| self.shade(p, normal(k))); + let d = sub(p, self.eye); + [dot(d, right), dot(d, up), dot(d, forward), r, g, b] + }); + let screen: Vec = self.view.clip(view_corners).into_iter().map(|v| self.view.project(v, target.width, target.height)).collect(); + if let Some((&a, rest)) = screen.split_first() { + for pair in rest.windows(2) { + if let [b, c] = pair { + target.fill([a, *b, *c], colour)?; + } + } + } + Ok(()) + } +} + +/// Triangle `t`'s stored corner normals, unit length; a corner whose normal is zero or not finite +/// is `None`, and so is the whole when the mesh stores none. +fn stored_normals(mesh: &TriangleMesh, t: usize) -> Option<[Option<[f64; 3]>; 3]> { + let slots = mesh.triangle_normals.get(t)?; + Some(slots.map(|i| mesh.normals.get(usize::try_from(i).ok()?).copied().and_then(normalize))) +} + +/// The three corners of a triangle, or `None` when one is missing or not finite. +fn corners(mesh: &TriangleMesh, tri: &[u32; 3]) -> Option<[[f64; 3]; 3]> { + let mut out = [[0.0; 3]; 3]; + for (slot, &i) in out.iter_mut().zip(tri) { + let p = *mesh.positions.get(usize::try_from(i).ok()?)?; + if !p.iter().all(|c| c.is_finite()) { + return None; + } + *slot = p; + } + Some(out) +} + +/// Corner `i` of a triangle, the origin when out of range. +fn at3(v: &[[f64; 3]; 3], i: usize) -> [f64; 3] { + v.get(i).copied().unwrap_or([0.0; 3]) +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs new file mode 100644 index 0000000000..586292ef80 --- /dev/null +++ b/crates/prc-render/src/render/tests.rs @@ -0,0 +1,347 @@ +#![allow(clippy::unwrap_used, clippy::panic, clippy::unreachable)] + +use super::*; + +fn mesh(positions: Vec<[f64; 3]>, triangles: Vec<[u32; 3]>) -> TriangleMesh { + let mut m = TriangleMesh::default(); + m.positions = positions; + m.triangles = triangles; + m +} + +fn quad_mesh(z: f64, half: f64, tilt: f64) -> TriangleMesh { + mesh(vec![[-half, -half, z - tilt], [half, -half, z + tilt], [half, half, z + tilt], [-half, half, z - tilt]], vec![[0, 1, 2], [0, 2, 3]]) +} + +fn quad(z: f64, half: f64, tilt: f64) -> WorldMesh { + WorldMesh::new(quad_mesh(z, half, tilt)) +} + +fn painted(m: WorldMesh, colour: Option<[u8; 4]>) -> WorldMesh { + WorldMesh { colour, ..m } +} + +fn ortho(height: f64) -> Camera { + Camera { eye: [0.0, 0.0, 10.0], target: [0.0, 0.0, 0.0], up: [0.0, 1.0, 0.0], projection: Projection::Orthographic { height } } +} + +fn small() -> RenderOptions { + RenderOptions { width: 40, height: 40, ..RenderOptions::default() } +} + +fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] { + let i = (y * image.width + x) as usize * 4; + image.rgba[i..i + 4].try_into().unwrap() +} + +#[test] +fn a_facing_square_fills_the_middle_and_leaves_the_corners() { + let image = render(&[quad(0.0, 1.0, 0.0)], &ortho(4.0), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); + assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]); + // Half the view height is covered: pixels 10..30. + assert_eq!(pixel(&image, 10, 20)[0], 190); + assert_eq!(pixel(&image, 9, 20)[0], 255); +} + +#[test] +fn stored_normals_shade_smoothly_across_a_flat_square() { + let mut m = quad_mesh(0.0, 1.0, 0.0); + let s = 3f64.sqrt() / 2.0; + // The left corners lean 60° away from the viewer; the right face it. + m.normals = vec![[s, 0.0, 0.5], [0.0, 0.0, 2.0]]; + m.triangle_normals = vec![[0, 1, 1], [0, 1, 0]]; + let image = render(&[WorldMesh::new(m.clone())], &ortho(4.0), &small()).unwrap(); + let red = |x| pixel(&image, x, 20)[0]; + assert!(red(11) < red(20) && red(20) < red(29), "{} {} {}", red(11), red(20), red(29)); + // Shade 0.3 + 0.7 cos θ at the edges: 0.65 left, 1.0 right. + assert!((i32::from(red(10)) - 128).abs() <= 4, "{}", red(10)); + assert!(red(29) >= 186); + m.normals.clear(); + m.triangle_normals.clear(); + let flat = render(&[WorldMesh::new(m)], &ortho(4.0), &small()).unwrap(); + assert_eq!(pixel(&flat, 11, 20), pixel(&flat, 29, 20)); +} + +#[test] +fn the_nearer_surface_hides_the_farther_whatever_the_order() { + let far = quad(-1.0, 2.0, 1.0); + let near = quad(1.0, 0.5, 0.0); + for meshes in [[far.clone(), near.clone()], [near, far]] { + let image = render(&meshes, &ortho(8.0), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); + assert!(pixel(&image, 20, 12)[0] < 190, "the tilted far square is darker"); + } +} + +const RED: Option<[u8; 4]> = Some([255, 0, 0, 255]); +const GLASS: Option<[u8; 4]> = Some([0, 0, 255, 102]); + +/// Glass (40% blue) in front of red blends whichever is listed first, glass behind red is hidden, +/// a clear mesh draws nothing, and a mesh with no colour takes the options' colour. +#[test] +fn meshes_draw_in_their_own_colours_and_glass_blends() { + let unlit = RenderOptions { lighting: Lighting::Unlit, ..small() }; + let at_middle = |meshes: &[(f64, Option<[u8; 4]>)]| { + let meshes: Vec = meshes.iter().map(|&(z, c)| painted(quad(z, 1.0, 0.0), c)).collect(); + pixel(&render(&meshes, &ortho(4.0), &unlit).unwrap(), 20, 20) + }; + assert_eq!(at_middle(&[(1.0, GLASS), (-1.0, RED)]), [153, 0, 102, 255]); + assert_eq!(at_middle(&[(-1.0, RED), (1.0, GLASS)]), [153, 0, 102, 255]); + assert_eq!(at_middle(&[(-1.0, GLASS), (1.0, RED)]), [255, 0, 0, 255]); + assert_eq!(at_middle(&[(1.0, Some([0, 0, 255, 0])), (-1.0, RED)]), [255, 0, 0, 255]); + assert_eq!(at_middle(&[(1.0, None)]), [190, 192, 200, 255]); + assert_eq!(at_middle(&[(2.0, GLASS), (1.0, GLASS), (-1.0, RED)]), [92, 0, 163, 255], "glass does not hide glass"); + let clear = RenderOptions { background: [0, 0, 0, 0], ..unlit }; + let image = render(&[painted(quad(1.0, 1.0, 0.0), GLASS)], &ortho(4.0), &clear).unwrap(); + assert_eq!(pixel(&image, 20, 20), [0, 0, 255, 102], "glass over nothing"); +} + +#[test] +fn a_triangle_colour_overrides_its_mesh_colour() { + let mut m = painted(quad(0.0, 1.0, 0.0), RED); + // The lower-right triangle is green; the upper-left keeps the mesh's red. + m.triangle_colours = Some(vec![Some([0, 255, 0, 255]), None]); + let unlit = RenderOptions { lighting: Lighting::Unlit, ..small() }; + let image = render(&[m], &ortho(4.0), &unlit).unwrap(); + assert_eq!(pixel(&image, 25, 25), [0, 255, 0, 255]); + assert_eq!(pixel(&image, 14, 14), [255, 0, 0, 255]); +} + +#[test] +fn each_edge_bounds_a_triangle() { + let tri = WorldMesh::new(mesh(vec![[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2]])); + let image = render(&[tri], &ortho(4.0), &small()).unwrap(); + assert_ne!(pixel(&image, 20, 25)[0], 255); + // Just past each edge: below the base, left and right of the apex. + for (x, y) in [(20, 31), (13, 15), (27, 15)] { + assert_eq!(pixel(&image, x, y)[0], 255, "({x},{y})"); + } +} + +#[test] +fn a_face_turned_away_is_still_drawn() { + let mut back = quad_mesh(0.0, 1.0, 0.0); + for t in &mut back.triangles { + t.swap(1, 2); + } + let image = render(&[WorldMesh::new(back)], &ortho(4.0), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); +} + +#[test] +fn perspective_clips_what_is_behind_the_camera() { + let camera = Camera { projection: Projection::Perspective { fov_y: 60.0 }, ..ortho(1.0) }; + let image = render(&[quad(20.0, 1.0, 0.0)], &camera, &small()).unwrap(); + assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255])); + // A floor running from behind the camera to beyond the target. + let floor = WorldMesh::new(mesh(vec![[-5.0, -1.0, 20.0], [5.0, -1.0, 20.0], [0.0, -1.0, -20.0]], vec![[0, 1, 2]])); + let image = render(&[floor], &camera, &small()).unwrap(); + assert_ne!(pixel(&image, 20, 38), [255, 255, 255, 255]); + assert_eq!(pixel(&image, 20, 2), [255, 255, 255, 255]); +} + +#[test] +fn a_fitted_camera_frames_the_model() { + // Depth matters in perspective: the nearer square looks larger. + let meshes = [quad(3.0, 3.0, 0.0), quad(-3.0, 3.0, 0.0)]; + let bounds = Bounds::of(&meshes).unwrap(); + for perspective in [true, false] { + let camera = Camera::fit(&bounds, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], perspective, 2.0).unwrap(); + let image = render(&meshes, &camera, &RenderOptions { width: 80, height: 40, ..RenderOptions::default() }).unwrap(); + assert_ne!(pixel(&image, 40, 20)[0], 255); + // Nothing drawn touches the image border. + for x in 0..80 { + assert_eq!(pixel(&image, x, 0)[0], 255); + assert_eq!(pixel(&image, x, 39)[0], 255); + } + } +} + +#[test] +fn a_mesh_fit_ignores_empty_box_corners() { + // A thin strip along the box's diagonal: seen across the diagonal, the box is as tall as it + // is wide, the strip is not. + let meshes = [WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [10.0, 10.0, 0.0], [10.0, 10.01, 0.0]], vec![[0, 1, 2]]))]; + let (dir, up) = ([0.0, 0.0, -1.0], [1.0, -1.0, 0.0]); + let height = |c: Camera| match c.projection { + Projection::Orthographic { height } => height, + Projection::Perspective { .. } => unreachable!(), + }; + let boxed = height(Camera::fit(&Bounds::of(&meshes).unwrap(), dir, up, false, 2.0).unwrap()); + let tight = height(Camera::fit_meshes(&meshes, dir, up, false, 2.0).unwrap()); + assert!((boxed / tight - 2.0).abs() < 0.01, "{boxed} vs {tight}"); + let near = Camera::fit_meshes(&meshes, dir, up, true, 2.0).unwrap(); + let far = Camera::fit(&Bounds::of(&meshes).unwrap(), dir, up, true, 2.0).unwrap(); + assert!(length(sub(near.eye, near.target)) < 0.6 * length(sub(far.eye, far.target))); + + // Lopsided in its box: the limiting vertex sits exactly on the margin, measured from the + // re-centred target. Narrow (width limits) and wide (height limits). + let tri = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 2.0, 1.0]], vec![[0, 1, 2]])); + for aspect in [0.5, 3.0] { + let c = Camera::fit_meshes(std::slice::from_ref(&tri), [0.0, 0.3, -1.0], [1.0, 1.0, 0.0], true, aspect).unwrap(); + let [right, upward, forward] = c.basis().unwrap(); + let ty = 15f64.to_radians().tan() / FIT_MARGIN; + let fill = tri + .mesh + .positions + .iter() + .map(|&p| { + let r = sub(p, c.eye); + (dot(r, right).abs() / (ty * aspect)).max(dot(r, upward).abs() / ty) / dot(r, forward) + }) + .fold(0.0, f64::max); + assert!((fill - 1.0).abs() < 1e-6, "aspect {aspect}: {fill}"); + } +} + +#[test] +fn a_fitted_camera_fills_the_tighter_axis() { + let drawn = |image: &Image, horizontal: bool, line: u32| { + let n = if horizontal { image.width } else { image.height }; + (0..n) + .filter(|&i| { + let (x, y) = if horizontal { (i, line) } else { (line, i) }; + pixel(image, x, y)[0] != 255 + }) + .count() + }; + // Height-limited: a 6 x 6 square in an 80 x 40 image. + let square = [quad(0.0, 3.0, 0.0)]; + // Width-limited and off-origin: a 10 x 1 strip in a 40 x 40 image. + let strip = [WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 1.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 3]]))]; + for perspective in [true, false] { + let fit = + |meshes: &[WorldMesh], aspect| Camera::fit(&Bounds::of(meshes).unwrap(), [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], perspective, aspect).unwrap(); + let wide = RenderOptions { width: 80, height: 40, ..RenderOptions::default() }; + let rows = drawn(&render(&square, &fit(&square, 2.0), &wide).unwrap(), false, 40); + assert!((34..=37).contains(&rows), "{perspective}: {rows} of 40 rows"); + let image = render(&strip, &fit(&strip, 1.0), &small()).unwrap(); + let cols = drawn(&image, true, 20); + assert!((34..=37).contains(&cols), "{perspective}: {cols} of 40 columns"); + // Centred: the margins differ by at most a pixel. + let left = (0..40).take_while(|&x| pixel(&image, x, 20)[0] == 255).count(); + assert!(left.abs_diff(40 - cols - left) <= 1, "{perspective}: left {left}"); + } +} + +#[test] +fn bad_sizes_and_cameras_are_refused() { + let m = [quad(0.0, 1.0, 0.0)]; + let size = |width, height| RenderOptions { width, height, ..RenderOptions::default() }; + assert!(matches!(render(&m, &ortho(1.0), &size(0, 10)), Err(RenderError::Size { .. }))); + assert!(matches!(render(&m, &ortho(1.0), &size(10_000, 10_000)), Err(RenderError::Size { .. }))); + let bad = [ + Camera { eye: [0.0; 3], target: [0.0; 3], ..ortho(1.0) }, + Camera { up: [0.0, 0.0, 1.0], ..ortho(1.0) }, + Camera { projection: Projection::Perspective { fov_y: 180.0 }, ..ortho(1.0) }, + Camera { projection: Projection::Perspective { fov_y: f64::NAN }, ..ortho(1.0) }, + Camera { projection: Projection::Perspective { fov_y: 1e-310 }, ..ortho(1.0) }, + ortho(0.0), + ortho(f64::INFINITY), + ]; + for camera in bad { + assert!(matches!(render(&m, &camera, &small()), Err(RenderError::Camera(_))), "{camera:?}"); + } + let b = Bounds::of(&m).unwrap(); + assert!(Camera::fit(&b, [0.0; 3], [0.0, 1.0, 0.0], true, 1.0).is_err()); + assert!(Camera::fit(&b, [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], true, 0.0).is_err()); + assert!(Camera::fit_meshes(&[], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], true, 1.0).is_err()); +} + +#[test] +fn non_finite_and_missing_vertices_are_skipped() { + let m = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 9], [0, 2, u32::MAX]])); + let image = render(std::slice::from_ref(&m), &ortho(4.0), &small()).unwrap(); + assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255])); + // No triangle can be drawn, so none frames the model. + assert_eq!(Bounds::of(&[m]), None); +} + +#[test] +fn unlit_draws_the_plain_colour_and_the_headlight_shades_by_angle() { + let lit = |lighting, tilt| { + let image = render(&[quad(0.0, 1.0, tilt)], &ortho(4.0), &RenderOptions { lighting, ..small() }).unwrap(); + pixel(&image, 20, 20) + }; + assert_eq!(lit(Lighting::Headlight, 0.0), [190, 192, 200, 255]); + // Tilted 45°: 0.3 + 0.7 cos 45° ≈ 0.795 of the colour. + assert_eq!(lit(Lighting::Headlight, 1.0), [151, 153, 159, 255]); + assert_eq!(lit(Lighting::Unlit, 1.0), [190, 192, 200, 255]); +} + +#[test] +fn a_huge_square_is_drawn() { + // Its edges' cross product (about 1e320) overflows unless they are scaled first. + let image = render(&[quad(0.0, 1e160, 0.0)], &ortho(4e160), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); + assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]); +} + +#[test] +fn a_camera_very_far_away_still_draws() { + let camera = Camera { eye: [0.0, 0.0, 1e200], target: [0.0; 3], up: [0.0, 1.0, 0.0], projection: Projection::Perspective { fov_y: 30.0 } }; + let image = render(&[quad(0.0, 1e199, 0.0)], &camera, &small()).unwrap(); + // The headlight meets the square almost head-on at the centre pixel. + let [r, _, _, a] = pixel(&image, 20, 20); + assert!((185..=190).contains(&r) && a == 255, "{:?}", pixel(&image, 20, 20)); +} + +#[test] +fn the_centre_of_a_box_near_the_largest_float_is_finite() { + let b = Bounds { min: [1e308; 3], max: [1.5e308; 3] }; + assert!(b.centre().iter().all(|c| c.is_finite()), "{:?}", b.centre()); +} + +#[test] +fn a_triangle_that_is_not_drawn_does_not_widen_the_box() { + // The second triangle has a non-finite corner, so its finite far corner is not drawn either. + let m = mesh(vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [f64::NAN, 0.0, 0.0], [1e6, 0.0, 0.0]], vec![[0, 1, 2], [3, 4, 0]]); + let b = Bounds::of(&[WorldMesh::new(m)]).unwrap(); + assert_eq!(b.max, [1.0, 1.0, 0.0]); +} + +#[test] +fn a_fully_transparent_triangle_does_not_widen_the_box_or_the_fit() { + // The far triangle is clear, so the camera frames the near one as if it were alone. + let m = mesh(vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1e6, 0.0, 0.0], [1e6, 1.0, 0.0]], vec![[0, 1, 2], [3, 4, 1]]); + let clear = WorldMesh { triangle_colours: Some(vec![None, Some([0, 0, 0, 0])]), ..WorldMesh::new(m.clone()) }; + assert_eq!(Bounds::of(std::slice::from_ref(&clear)).unwrap().max, [1.0, 1.0, 0.0]); + let alone = WorldMesh::new(mesh(m.positions, vec![[0, 1, 2]])); + let fit = |w: &WorldMesh| Camera::fit_meshes(std::slice::from_ref(w), [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], true, 1.0).unwrap(); + assert_eq!(fit(&clear), fit(&alone)); + // A whole mesh painted clear is not framed either. + assert_eq!( + Bounds::of(&[painted(WorldMesh::new(mesh(vec![[0.0; 3], [9.0, 0.0, 0.0], [0.0, 9.0, 0.0]], vec![[0, 1, 2]])), Some([1, 2, 3, 0]))]), + None + ); +} + +#[test] +fn a_small_model_far_from_the_origin_can_be_fitted() { + let (o, d) = (1e16, 4.0); + let m = mesh(vec![[o, o, o], [o + d, o, o], [o, o + d, o]], vec![[0, 1, 2]]); + let camera = Camera::fit_meshes(&[WorldMesh::new(m)], [1.0, 1.0, 1.0], [0.0, 1.0, 0.0], false, 1.0); + assert!(camera.is_ok(), "{camera:?}"); +} + +#[test] +fn a_triangle_whose_edges_overflow_is_drawn() { + // Its corners are finite but 2e308 apart, so the plain edge differences are infinite. + let m = mesh(vec![[-1e308, -1e308, 0.0], [1e308, -1e308, 0.0], [0.0, 1e308, 0.0]], vec![[0, 1, 2]]); + let image = render(&[WorldMesh::new(m)], &ortho(1e308), &small()).unwrap(); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]); +} + +#[test] +fn an_on_screen_triangle_is_drawn_when_projecting_it_would_overflow() { + let wide = RenderOptions { width: 30, height: 10, ..RenderOptions::default() }; + let drawn = |image: &Image| image.rgba.chunks(4).filter(|px| px != &[255, 255, 255, 255]).count(); + // A tiny field of view: x times the focal length (about 1.1e300) overflows, though x over z does not. + let camera = Camera { eye: [0.0; 3], target: [0.0, 0.0, 1e308], up: [0.0, 1.0, 0.0], projection: Projection::Perspective { fov_y: 1e-298 } }; + let far = mesh(vec![[1.5e8, -3e7, 1e308], [2.5e8, -3e7, 1e308], [2e8, 3e7, 1e308]], vec![[0, 1, 2]]); + assert!(drawn(&render(&[WorldMesh::new(far)], &camera, &wide).unwrap()) > 0); + // Half the orthographic height times the aspect ratio overflows. + let big = mesh(vec![[-5e307, -5e307, 0.0], [5e307, -5e307, 0.0], [0.0, 5e307, 0.0]], vec![[0, 1, 2]]); + assert_eq!(pixel(&render(&[WorldMesh::new(big)], &ortho(1.5e308), &wide).unwrap(), 15, 5), [190, 192, 200, 255]); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs new file mode 100644 index 0000000000..bdbed70aed --- /dev/null +++ b/crates/prc-render/src/vec3.rs @@ -0,0 +1,119 @@ +//! Three-component vector arithmetic for the camera and the rasterizer. + +/// A point or direction, `[x, y, z]`. +pub(crate) type Vec3 = [f64; 3]; + +/// `a - b`. +pub(crate) fn sub([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 { + [ax - bx, ay - by, az - bz] +} + +/// The direction from `from` to `to`: `to - from`, halved when the full difference overflows. +pub(crate) fn span(from: Vec3, to: Vec3) -> Vec3 { + let d = sub(to, from); + if d.iter().all(|c| c.is_finite()) { d } else { sub(scale(to, 0.5), scale(from, 0.5)) } +} + +/// `a` times `s`. +pub(crate) fn scale(a: Vec3, s: f64) -> Vec3 { + a.map(|c| c * s) +} + +/// The dot product `a · b`. +pub(crate) fn dot([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> f64 { + ax * bx + ay * by + az * bz +} + +/// The right-handed cross product `a × b`. +pub(crate) fn cross([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 { + [ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx] +} + +/// The Euclidean length of `a`, taken with `a` divided by its largest component so squaring +/// cannot overflow or underflow; infinite or NaN when a component is. +pub(crate) fn length(a: Vec3) -> f64 { + let big = largest(a); + if !(big.is_finite() && big > 0.0) { + return dot(a, a).sqrt(); + } + let a = a.map(|c| c / big); + big * dot(a, a).sqrt() +} + +/// The largest component of `a` by magnitude; NaN components are passed over. +fn largest(a: Vec3) -> f64 { + a.iter().fold(0.0_f64, |m, c| m.max(c.abs())) +} + +/// `a` scaled to unit length; `None` when its length is zero or not finite. `a` is first divided +/// by its largest component, so squaring cannot overflow or underflow. +pub(crate) fn normalize(a: Vec3) -> Option { + let big = largest(a); + if !(big.is_finite() && big > 0.0) { + return None; + } + let a = a.map(|c| c / big); + let l = dot(a, a).sqrt(); + (l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l)) +} + +/// The unit normal of the plane spanned by `a` and `b` (`a × b`, normalized); `None` when they are +/// parallel, zero or not finite. Each is normalized first, so neither huge edges nor edges of +/// very different sizes overflow or underflow the cross product. +pub(crate) fn unit_cross(a: Vec3, b: Vec3) -> Option { + normalize(cross(normalize(a)?, normalize(b)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cross_follows_the_right_hand_rule() { + assert_eq!(cross([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), [0.0, 0.0, 1.0]); + assert_eq!(cross([0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), [0.0, 0.0, -1.0]); + } + + #[test] + fn normalize_rejects_degenerate_vectors() { + assert_eq!(normalize([0.0, 4.0, 0.0]), Some([0.0, 1.0, 0.0])); + assert_eq!(normalize([0.0; 3]), None); + assert_eq!(normalize([f64::NAN, 0.0, 0.0]), None); + assert_eq!(normalize([f64::INFINITY, 0.0, 0.0]), None); + } + + #[test] + fn normalize_keeps_huge_and_tiny_vectors() { + assert_eq!(normalize([1e200, 0.0, 0.0]), Some([1.0, 0.0, 0.0])); + assert_eq!(normalize([0.0, -3e-320, 0.0]), Some([0.0, -1.0, 0.0])); + } + + #[test] + fn unit_cross_survives_edges_whose_product_overflows() { + assert_eq!(unit_cross([1e160, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0])); + assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e-170, 0.0]), Some([0.0, 0.0, 1.0])); + assert_eq!(unit_cross([2.0, 0.0, 0.0], [4.0, 0.0, 0.0]), None); + assert_eq!(unit_cross([f64::INFINITY, 0.0, 0.0], [0.0, 1.0, 0.0]), None); + } + + #[test] + fn unit_cross_survives_edges_of_very_different_sizes() { + assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0])); + } + + #[test] + fn span_keeps_the_direction_of_a_difference_that_overflows() { + assert_eq!(span([1.0, 2.0, 3.0], [4.0, 0.0, 3.0]), [3.0, -2.0, 0.0]); + assert_eq!(span([-1e308, 0.0, 0.0], [1e308, 1e308, 0.0]), [1e308, 5e307, 0.0]); + } + + #[test] + fn length_survives_huge_and_tiny_vectors() { + let near = |l: f64, want: f64| (l - want).abs() <= want * 1e-15; + assert!(near(length([3e200, 4e200, 0.0]), 5e200)); + assert!(near(length([0.0, 3e-200, 4e-200]), 5e-200)); + assert_eq!(length([0.0; 3]), 0.0); + assert!(length([f64::NAN, 1.0, 0.0]).is_nan()); + assert_eq!(length([f64::INFINITY, 1.0, 0.0]), f64::INFINITY); + } +} diff --git a/crates/prc-render/tests/model.rs b/crates/prc-render/tests/model.rs new file mode 100644 index 0000000000..80e84252c6 --- /dev/null +++ b/crates/prc-render/tests/model.rs @@ -0,0 +1,145 @@ +//! End to end: a synthetic PRC stream parsed, assembled and rendered to pixels. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use pdfcraft_prc::PrcFile; +use pdfcraft_prc_render::{Camera, Image, RenderOptions, assemble, render}; +use std::io::Write as _; + +/// An MSB-first bit sink: the inverse of the PRC bit reader, for the few values these tests write. +#[derive(Default)] +struct W { + bits: Vec, +} + +impl W { + fn put(&mut self, v: u64, n: u32) -> &mut Self { + for i in (0..n).rev() { + self.bits.push((v >> i) & 1 == 1); + } + self + } + + fn bit(&mut self, b: bool) -> &mut Self { + self.bits.push(b); + self + } + + /// An `UnsignedInteger`: bytes low first, each after a 1 bit, then a 0 bit. + fn uint(&mut self, mut v: u32) -> &mut Self { + while v != 0 { + self.bit(true).put(u64::from(v & 0xff), 8); + v >>= 8; + } + self.bit(false) + } + + fn uints(&mut self, v: &[u32]) -> &mut Self { + self.uint(u32::try_from(v.len()).unwrap()); + for &x in v { + self.uint(x); + } + self + } + + /// A `Double` from the few the code table stores whole: 0, ±0.5, ±1, ±2. + fn double(&mut self, v: f64) -> &mut Self { + let negative = v < 0.0; + match v.abs() { + 0.0 => self.put(0b01, 2), + 0.5 => self.put(0x777, 11).bit(negative), + 1.0 => self.put(0b0000, 4).bit(negative), + // The exponent row of [2, 4) with no mantissa bytes. + 2.0 => self.put(0x1b, 5).bit(negative).bit(false), + _ => panic!("{v} has no short code here"), + } + } + + fn bytes(&self) -> Vec { + self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect() + } +} + +/// A tessellation section of one `TESS_3D` (authoring version 8137) with explicit normals and +/// triangles of `(normal, point)` coordinate indices. +fn tess_section(points: &[[f64; 3]], normals: &[[f64; 3]], corners: &[(u32, u32)]) -> Vec { + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(1); + w.uint(172).bit(false).uint(u32::try_from(points.len() * 3).unwrap()); + points.iter().flatten().for_each(|&c| _ = w.double(c)); + // Faces present; no loop flag; normals not recalculated. + w.bit(true).bit(false).bit(false); + w.uint(u32::try_from(normals.len() * 3).unwrap()); + normals.iter().flatten().for_each(|&c| _ = w.double(c)); + w.uint(0).uints(&corners.iter().flat_map(|&(n, p)| [n, p]).collect::>()); + // One face of triangles with a normal per corner. + w.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0).uints(&[u32::try_from(corners.len() / 3).unwrap()]); + w.uint(0).bit(false); + w.uint(0).uint(0); + w.bytes() +} + +/// A one-file-structure PRC stream whose tessellation section is `tess`, with no product tree. +fn prc_file(tess: &[u8]) -> Vec { + const HEADER_LEN: usize = 107; + let zlib = |data: &[u8]| { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + }; + let u32s = |out: &mut Vec, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes())); + let mut globals = W::default(); + globals.uint(0); + let sections: [&[u8]; 5] = [&globals.bytes(), b"", tess, b"", b""]; + let mut body = b"PRC".to_vec(); + u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]); + let mut offs = vec![HEADER_LEN]; + for s in sections { + offs.push(HEADER_LEN + body.len()); + body.extend(zlib(s)); + } + let mf_start = HEADER_LEN + body.len(); + body.extend(zlib(b"")); + let mf_end = HEADER_LEN + body.len(); + let mut out = b"PRC".to_vec(); + u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]); + u32s(&mut out, &[5, 6, 7, 8, 0, 6]); + u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::>()); + u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]); + out.extend(body); + out +} + +fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] { + let i = (y * image.width + x) as usize * 4; + image.rgba[i..i + 4].try_into().unwrap() +} + +#[test] +fn a_synthetic_prc_renders_its_nearer_triangle_over_the_farther() { + // Listed first, a small triangle at z = 1 facing the viewer; behind it at z = -1, a larger + // one whose stored normal lies across the view, so the headlight shades it darkest. + let points = [[-1., -1., 1.], [1., -1., 1.], [0., 1., 1.], [-2., -2., -1.], [2., -2., -1.], [0., 2., -1.]]; + let normals = [[0., 0., 1.], [1., 0., 0.]]; + let corners = [(0, 0), (0, 3), (0, 6), (3, 9), (3, 12), (3, 15)]; + let prc = PrcFile::parse(&prc_file(&tess_section(&points, &normals, &corners))).unwrap(); + let meshes = assemble(&prc).unwrap(); + assert_eq!(meshes.len(), 1); + assert_eq!(meshes[0].mesh.triangles.len(), 2); + let camera = Camera::fit_meshes(&meshes, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], false, 1.0).unwrap(); + let image = render(&meshes, &camera, &RenderOptions { width: 40, height: 40, ..RenderOptions::default() }).unwrap(); + assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255], "background corner"); + assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255], "the nearer triangle, lit head on"); + // Below the near triangle only the far one: 0.3 of the surface colour. + assert_eq!(pixel(&image, 20, 34), [57, 58, 60, 255], "the farther triangle, lit edge on"); +} + +#[test] +fn damaged_streams_are_errors_not_panics() { + let points = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]]; + let bytes = prc_file(&tess_section(&points, &[[0., 0., 1.]], &[(0, 0), (0, 3), (0, 6)])); + for end in 0..bytes.len() { + if let Ok(prc) = PrcFile::parse(&bytes[..end]) { + let _ = assemble(&prc); + } + } +} diff --git a/crates/prc/Cargo.toml b/crates/prc/Cargo.toml new file mode 100644 index 0000000000..741c0ac5bd --- /dev/null +++ b/crates/prc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pdfcraft-prc" +description = "PRC 3D model reader (ISO 14739-1): container, bit-level primitives, schema" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +flate2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/prc/README.md b/crates/prc/README.md new file mode 100644 index 0000000000..40bdab335b --- /dev/null +++ b/crates/prc/README.md @@ -0,0 +1,43 @@ +# pdfcraft-prc + +A reader for PRC (ISO 14739-1), the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries (ISO 32000-2 §13.6.3). + +- **Layer:** L0, standalone. No dependency on any other PdfCraft crate. +- **Licence:** MIT OR Apache-2.0. Inflation through `flate2` with the pure-Rust `miniz_oxide` backend. +- `#![forbid(unsafe_code)]`. Never panics on input: inflated bytes, decoded elements, schema steps and walk depth are capped, and damage returns `PrcError`. + +## API + +```rust +PrcFile::parse(data) -> Result // header, file structures, model file +PrcFile::parse_with_limit(data, max_inflated) -> Result +FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry +FileStructure::schema() -> Result // the globals section's leading schema +FileStructure::tessellations() -> Result, PrcError> // meshes, wires, markups, compressed meshes +PrcFile::placements() / placements_with(StyleAlpha, EntityOverrides) -> Result, PrcError> +PrcFile::model_tree() -> Result, PrcError> // the assembly tree as a model-tree panel lists it +Placement::triangle_colours(&TriangleMesh) -> Option>> +TriangleMesh::transformed(&Matrix) -> TriangleMesh +Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader) +BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double +``` + +- `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit. +- `MAX_DECODED_ELEMENTS` (2^24) bounds the array elements one pass decodes (a tree walk, or one `tessellations` call), since a single bit can decode to a 4-8 byte element; `TooManyElements` reports it. +- Schema programs (how a newer producer describes fields it added) run under a nesting ceiling (64) and `MAX_SCHEMA_STEPS` (2^22), shared like `MAX_DECODED_ELEMENTS` by every program one pass runs. +- `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`). +- `Tessellation::Mesh` is a `TriangleMesh`: positions, triangles, one triangle range per face, the stored normals and + texture coordinates with each triangle's corner indices into them, and per-triangle line-attribute styles. + `Wire` is polylines; `Markup` is read past. +- `Tessellation::Compressed` is read to its end so later entities decode; its triangles are not rebuilt yet. +- A face may carry at most 8 texture-coordinate sets; more is `Malformed`. Optimised vertex colours are `Unsupported`. +- The product tree: each drawable tessellation placed by its occurrence path's locations and the item's coordinate + systems (a 4x4 `Matrix`), coloured by the style inheritance rules, with occurrences' entity references applied + (`EntityOverrides`: subtree, everywhere or ignored) and style transparency combined with material alpha by `StyleAlpha`. + A prototype's part is coloured by its own file structure's styles. Each `ModelNode` lists its name, where the name + came from, its visibility and the range of placements it drew. +- Occurrence and prototype cycles are `Malformed`; the walk is capped at depth 64, 2^20 visits and `MAX_PLACEMENTS`. +- A tree's markups (PMI), views, scene lights and clipping planes are read past, not modelled; so are textures. +- Not yet in the crate: rebuilding compressed meshes, textures. +- The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`. + A few reading choices also follow prc-rs; each is marked `// prc-rs:` in the source. diff --git a/crates/prc/src/acof.rs b/crates/prc/src/acof.rs new file mode 100644 index 0000000000..fc11a663a6 --- /dev/null +++ b/crates/prc/src/acof.rs @@ -0,0 +1,93 @@ +//! The prefix-code table behind the PRC `Double` [WD 11.17.2]. +//! +//! The table has 2077 rows: 29 frequent doubles and one row per IEEE binary64 exponent +//! (0..=2047). Each row is a code of 1 to 22 bits and the value it decodes to. +//! The rows come from prc-rs under its MIT licence (see [`table`]). + +use std::collections::HashMap; +use std::sync::OnceLock; + +mod table; + +/// Rows a complete table has. +const ROWS: usize = 2077; + +/// The longest code, in bits. +pub(crate) const MAX_CODE_BITS: u32 = 22; + +/// What a code decodes to. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Entry { + /// A frequent value, returned with the sign bit applied. + Double(f64), + /// The upper 32 bits of a binary64 (exponent set, mantissa zero); the mantissa follows + /// in the stream. + Exponent(u32), +} + +/// One row: code length, code, and what it decodes to. +type Row = (u32, u32, Entry); + +fn key(nbits: u32, code: u32) -> u32 { + (nbits << 24) | code +} + +/// The rows keyed by `(nbits, code)`, or `None` when the table is incomplete or not prefix +/// free (then no `Double` decodes, rather than some decoding wrongly). +fn build(rows: &[Row]) -> Option> { + let mut map = HashMap::with_capacity(rows.len()); + for &(nbits, code, entry) in rows { + if nbits == 0 || nbits > MAX_CODE_BITS || code >> nbits != 0 || map.insert(key(nbits, code), entry).is_some() { + return None; + } + } + let prefix_free = map.keys().all(|&k| { + let (n, c) = (k >> 24, k & 0xff_ffff); + (1..n).all(|m| !map.contains_key(&key(m, c >> (n - m)))) + }); + (map.len() == ROWS && prefix_free).then_some(map) +} + +fn table() -> Option<&'static HashMap> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| build(table::TABLE)).as_ref() +} + +/// The entry for an `nbits`-long code, if one exists. +pub(crate) fn lookup(nbits: u32, code: u32) -> Option { + table()?.get(&key(nbits, code)).copied() +} + +/// The `(nbits, code)` of IEEE exponent `e`'s row, the test writer's half of [`lookup`]. +#[cfg(test)] +pub(crate) fn exponent_code(e: u32) -> Option<(u32, u32)> { + table()?.iter().find(|(_, v)| matches!(v, Entry::Exponent(hi) if (hi >> 20) & 0x7ff == e)).map(|(&k, _)| (k >> 24, k & 0xff_ffff)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn an_incomplete_or_ambiguous_table_decodes_nothing() { + assert!(build(&[(2, 0b01, Entry::Double(0.0))]).is_none(), "one row is not the table"); + let rows: Vec = (0..ROWS as u32).map(|i| (22, i, Entry::Exponent(i))).collect(); + assert!(build(&rows).is_some(), "2077 distinct 22-bit codes are prefix free"); + let mut ambiguous = rows.clone(); + ambiguous[0] = (1, 0, Entry::Double(1.0)); // a prefix of every code starting 0 + assert!(build(&ambiguous).is_none()); + let mut too_long = rows; + too_long[0] = (2, 0b111, Entry::Double(1.0)); // a code wider than its length + assert!(build(&too_long).is_none()); + } + + #[test] + fn the_table_is_complete() { + let t = table().unwrap(); + assert_eq!(t.len(), ROWS); + assert_eq!(t.values().filter(|e| matches!(e, Entry::Double(_))).count(), 29); + assert!((0..2048).all(|e| exponent_code(e).is_some())); + assert_eq!(lookup(2, 0b01), Some(Entry::Double(0.0))); + } +} diff --git a/crates/prc/src/acof/table.rs b/crates/prc/src/acof/table.rs new file mode 100644 index 0000000000..28cff50a2e --- /dev/null +++ b/crates/prc/src/acof/table.rs @@ -0,0 +1,2113 @@ +//! The 2077 rows of the PRC `Double` prefix-code table [WD 11.17.2], as `(code length, code, entry)`. +//! +//! The rows are taken from prc-rs (, `src/double.rs` at commit +//! 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d), used under its MIT licence: +//! +//! Copyright (c) 2025,2026 Kristof Ralovich +//! +//! 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. + +use super::{Entry, Row}; + +/// A frequent value: the binary64 with upper and lower words `hi`, `lo`. +const fn d(hi: u32, lo: u32) -> Entry { + Entry::Double(f64::from_bits(((hi as u64) << 32) | lo as u64)) +} + +/// An exponent row: the upper word of a binary64 whose mantissa follows in the stream. +const fn e(hi: u32) -> Entry { + Entry::Exponent(hi) +} + +#[rustfmt::skip] +pub(super) const TABLE: &[Row] = &[ + (2, 0x1, d(0x00000000, 0x00000000)), + (22, 0xd1d32, e(0x00000000)), + (22, 0xd1d33, e(0x00100000)), + (22, 0xf78d8, e(0x00200000)), + (22, 0xf78d9, e(0x00300000)), + (22, 0xf78da, e(0x00400000)), + (22, 0xf78db, e(0x00500000)), + (22, 0xf78dc, e(0x00600000)), + (22, 0xf78dd, e(0x00700000)), + (22, 0xf78de, e(0x00800000)), + (22, 0xf78df, e(0x00900000)), + (22, 0xf78e0, e(0x00a00000)), + (22, 0xf78e1, e(0x00b00000)), + (22, 0xf78e2, e(0x00c00000)), + (22, 0xf78e3, e(0x00d00000)), + (22, 0xf78e4, e(0x00e00000)), + (22, 0xf78e5, e(0x00f00000)), + (22, 0xf78e6, e(0x01000000)), + (22, 0xf78e7, e(0x01100000)), + (22, 0xf78e8, e(0x01200000)), + (22, 0xf78e9, e(0x01300000)), + (22, 0xf78ea, e(0x01400000)), + (22, 0xf78eb, e(0x01500000)), + (22, 0xf78ec, e(0x01600000)), + (22, 0xf78ed, e(0x01700000)), + (22, 0xf78ee, e(0x01800000)), + (22, 0xf78ef, e(0x01900000)), + (22, 0xf78f0, e(0x01a00000)), + (22, 0xf78f1, e(0x01b00000)), + (22, 0xf78f2, e(0x01c00000)), + (22, 0xf78f3, e(0x01d00000)), + (22, 0xf78f4, e(0x01e00000)), + (22, 0xf78f5, e(0x01f00000)), + (22, 0xf78f6, e(0x02000000)), + (22, 0xf78f7, e(0x02100000)), + (22, 0xf78f8, e(0x02200000)), + (22, 0xf78f9, e(0x02300000)), + (22, 0xf78fa, e(0x02400000)), + (22, 0xf78fb, e(0x02500000)), + (22, 0xf78fc, e(0x02600000)), + (22, 0xf78fd, e(0x02700000)), + (22, 0xf78fe, e(0x02800000)), + (22, 0xf78ff, e(0x02900000)), + (22, 0x3a8300, e(0x02a00000)), + (22, 0x3a8301, e(0x02b00000)), + (22, 0x3a8302, e(0x02c00000)), + (22, 0x3a8303, e(0x02d00000)), + (22, 0x3a8304, e(0x02e00000)), + (22, 0x3a8305, e(0x02f00000)), + (22, 0x3a8306, e(0x03000000)), + (22, 0x3a8307, e(0x03100000)), + (22, 0x3a8308, e(0x03200000)), + (22, 0x3a8309, e(0x03300000)), + (22, 0x3a830a, e(0x03400000)), + (22, 0x3a830b, e(0x03500000)), + (22, 0x3a830c, e(0x03600000)), + (22, 0x3a830d, e(0x03700000)), + (22, 0x3a830e, e(0x03800000)), + (22, 0x3a830f, e(0x03900000)), + (22, 0x3a8310, e(0x03a00000)), + (22, 0x3a8311, e(0x03b00000)), + (22, 0x3a8312, e(0x03c00000)), + (22, 0x3a8313, e(0x03d00000)), + (22, 0x3a8314, e(0x03e00000)), + (22, 0x3a8315, e(0x03f00000)), + (22, 0x3a8316, e(0x04000000)), + (22, 0x3a8317, e(0x04100000)), + (22, 0x3a8318, e(0x04200000)), + (22, 0x3a8319, e(0x04300000)), + (22, 0x3a831a, e(0x04400000)), + (22, 0x3a831b, e(0x04500000)), + (22, 0x3a831c, e(0x04600000)), + (22, 0x3a831d, e(0x04700000)), + (22, 0x3a831e, e(0x04800000)), + (22, 0x3a831f, e(0x04900000)), + (22, 0x3a8320, e(0x04a00000)), + (22, 0x3a8321, e(0x04b00000)), + (22, 0x3a8322, e(0x04c00000)), + (22, 0x3a8323, e(0x04d00000)), + (22, 0x3a8324, e(0x04e00000)), + (22, 0x3a8325, e(0x04f00000)), + (22, 0x3a8326, e(0x05000000)), + (22, 0x3a8327, e(0x05100000)), + (22, 0x3a8328, e(0x05200000)), + (22, 0x3a8329, e(0x05300000)), + (22, 0x3a832a, e(0x05400000)), + (22, 0x3a832b, e(0x05500000)), + (22, 0x3a832c, e(0x05600000)), + (22, 0x3a832d, e(0x05700000)), + (22, 0x3a832e, e(0x05800000)), + (22, 0x3a832f, e(0x05900000)), + (22, 0x3a8330, e(0x05a00000)), + (22, 0x3a8331, e(0x05b00000)), + (22, 0x3a8332, e(0x05c00000)), + (22, 0x3a8333, e(0x05d00000)), + (22, 0x3a8334, e(0x05e00000)), + (22, 0x3a8335, e(0x05f00000)), + (22, 0x3a8336, e(0x06000000)), + (22, 0x3a8337, e(0x06100000)), + (22, 0x3a8338, e(0x06200000)), + (22, 0x3a8339, e(0x06300000)), + (22, 0x3a833a, e(0x06400000)), + (22, 0x3a833b, e(0x06500000)), + (22, 0x3a833c, e(0x06600000)), + (22, 0x3a833d, e(0x06700000)), + (22, 0x3a833e, e(0x06800000)), + (22, 0x3a833f, e(0x06900000)), + (22, 0x3a8340, e(0x06a00000)), + (22, 0x3a8341, e(0x06b00000)), + (22, 0x3a8342, e(0x06c00000)), + (22, 0x3a8343, e(0x06d00000)), + (22, 0x3a8344, e(0x06e00000)), + (22, 0x3a8345, e(0x06f00000)), + (22, 0x3a8346, e(0x07000000)), + (22, 0x3a8347, e(0x07100000)), + (22, 0x3a8348, e(0x07200000)), + (22, 0x3a8349, e(0x07300000)), + (22, 0x3a834a, e(0x07400000)), + (22, 0x3a834b, e(0x07500000)), + (22, 0x3a834c, e(0x07600000)), + (22, 0x3a834d, e(0x07700000)), + (22, 0x3a834e, e(0x07800000)), + (22, 0x3a834f, e(0x07900000)), + (22, 0x3a8350, e(0x07a00000)), + (22, 0x3a8351, e(0x07b00000)), + (22, 0x3a8352, e(0x07c00000)), + (22, 0x3a8353, e(0x07d00000)), + (22, 0x3a8354, e(0x07e00000)), + (22, 0x3a8355, e(0x07f00000)), + (22, 0x3a8356, e(0x08000000)), + (22, 0x3a8357, e(0x08100000)), + (22, 0x3a8358, e(0x08200000)), + (22, 0x3a8359, e(0x08300000)), + (22, 0x3a835a, e(0x08400000)), + (22, 0x3a835b, e(0x08500000)), + (22, 0x3a835c, e(0x08600000)), + (22, 0x3a835d, e(0x08700000)), + (22, 0x3a835e, e(0x08800000)), + (22, 0x3a835f, e(0x08900000)), + (22, 0x3a8360, e(0x08a00000)), + (22, 0x3a8361, e(0x08b00000)), + (22, 0x3a8362, e(0x08c00000)), + (22, 0x3a8363, e(0x08d00000)), + (22, 0x3a8364, e(0x08e00000)), + (22, 0x3a8365, e(0x08f00000)), + (22, 0x3a8366, e(0x09000000)), + (22, 0x3a8367, e(0x09100000)), + (22, 0x3a8368, e(0x09200000)), + (22, 0x3a8369, e(0x09300000)), + (22, 0x3a836a, e(0x09400000)), + (22, 0x3a836b, e(0x09500000)), + (22, 0x3a836c, e(0x09600000)), + (22, 0x3a836d, e(0x09700000)), + (22, 0x3a836e, e(0x09800000)), + (22, 0x3a836f, e(0x09900000)), + (22, 0x3a8370, e(0x09a00000)), + (22, 0x3a8371, e(0x09b00000)), + (22, 0x3a8372, e(0x09c00000)), + (22, 0x3a8373, e(0x09d00000)), + (22, 0x3a8374, e(0x09e00000)), + (22, 0x3a8375, e(0x09f00000)), + (22, 0x3a8376, e(0x0a000000)), + (22, 0x3a8377, e(0x0a100000)), + (22, 0x3a8378, e(0x0a200000)), + (22, 0x3a8379, e(0x0a300000)), + (22, 0x3a837a, e(0x0a400000)), + (22, 0x3a837b, e(0x0a500000)), + (22, 0x3a837c, e(0x0a600000)), + (22, 0x3a837d, e(0x0a700000)), + (22, 0x3a837e, e(0x0a800000)), + (22, 0x3a837f, e(0x0a900000)), + (22, 0x3a8380, e(0x0aa00000)), + (22, 0x3a8381, e(0x0ab00000)), + (22, 0x3a8382, e(0x0ac00000)), + (22, 0x3a8383, e(0x0ad00000)), + (22, 0x3a8384, e(0x0ae00000)), + (22, 0x3a8385, e(0x0af00000)), + (22, 0x3a8386, e(0x0b000000)), + (22, 0x3a8387, e(0x0b100000)), + (22, 0x3a8388, e(0x0b200000)), + (22, 0x3a8389, e(0x0b300000)), + (22, 0x3a838a, e(0x0b400000)), + (22, 0x3a838b, e(0x0b500000)), + (22, 0x3a838c, e(0x0b600000)), + (22, 0x3a838d, e(0x0b700000)), + (22, 0x3a838e, e(0x0b800000)), + (22, 0x3a838f, e(0x0b900000)), + (22, 0x3a8390, e(0x0ba00000)), + (22, 0x3a8391, e(0x0bb00000)), + (22, 0x3a8392, e(0x0bc00000)), + (22, 0x3a8393, e(0x0bd00000)), + (22, 0x3a8394, e(0x0be00000)), + (22, 0x3a8395, e(0x0bf00000)), + (22, 0x3a8396, e(0x0c000000)), + (22, 0x3a8397, e(0x0c100000)), + (22, 0x3a8398, e(0x0c200000)), + (22, 0x3a8399, e(0x0c300000)), + (22, 0x3a839a, e(0x0c400000)), + (22, 0x3a839b, e(0x0c500000)), + (22, 0x3a839c, e(0x0c600000)), + (22, 0x3a839d, e(0x0c700000)), + (22, 0x3a839e, e(0x0c800000)), + (22, 0x3a839f, e(0x0c900000)), + (22, 0x3a83a0, e(0x0ca00000)), + (22, 0x3a83a1, e(0x0cb00000)), + (22, 0x3a83a2, e(0x0cc00000)), + (22, 0x3a83a3, e(0x0cd00000)), + (22, 0x3a83a4, e(0x0ce00000)), + (22, 0x3a83a5, e(0x0cf00000)), + (22, 0x3a83a6, e(0x0d000000)), + (22, 0x3a83a7, e(0x0d100000)), + (22, 0x3a83a8, e(0x0d200000)), + (22, 0x3a83a9, e(0x0d300000)), + (22, 0x3a83aa, e(0x0d400000)), + (22, 0x3a83ab, e(0x0d500000)), + (22, 0x3a83ac, e(0x0d600000)), + (22, 0x3a83ad, e(0x0d700000)), + (22, 0x3a83ae, e(0x0d800000)), + (22, 0x3a83af, e(0x0d900000)), + (22, 0x3a83b0, e(0x0da00000)), + (22, 0x3a83b1, e(0x0db00000)), + (22, 0x3a83b2, e(0x0dc00000)), + (22, 0x3a83b3, e(0x0dd00000)), + (22, 0x3a83b4, e(0x0de00000)), + (22, 0x3a83b5, e(0x0df00000)), + (22, 0x3a83b6, e(0x0e000000)), + (22, 0x3a83b7, e(0x0e100000)), + (22, 0x3a83b8, e(0x0e200000)), + (22, 0x3a83b9, e(0x0e300000)), + (22, 0x3a83ba, e(0x0e400000)), + (22, 0x3a83bb, e(0x0e500000)), + (22, 0x3a83bc, e(0x0e600000)), + (22, 0x3a83bd, e(0x0e700000)), + (22, 0x3a83be, e(0x0e800000)), + (22, 0x3a83bf, e(0x0e900000)), + (22, 0x3a83c0, e(0x0ea00000)), + (22, 0x3a83c1, e(0x0eb00000)), + (22, 0x3a83c2, e(0x0ec00000)), + (22, 0x3a83c3, e(0x0ed00000)), + (22, 0x3a83c4, e(0x0ee00000)), + (22, 0x3a83c5, e(0x0ef00000)), + (22, 0x3a83c6, e(0x0f000000)), + (22, 0x3a83c7, e(0x0f100000)), + (22, 0x3a83c8, e(0x0f200000)), + (22, 0x3a83c9, e(0x0f300000)), + (22, 0x3a83ca, e(0x0f400000)), + (22, 0x3a83cb, e(0x0f500000)), + (22, 0x3a83cc, e(0x0f600000)), + (22, 0x3a83cd, e(0x0f700000)), + (22, 0x3a83ce, e(0x0f800000)), + (22, 0x3a83cf, e(0x0f900000)), + (22, 0x3a83d0, e(0x0fa00000)), + (22, 0x3a83d1, e(0x0fb00000)), + (22, 0x3a83d2, e(0x0fc00000)), + (22, 0x3a83d3, e(0x0fd00000)), + (22, 0x3a83d4, e(0x0fe00000)), + (22, 0x3a83d5, e(0x0ff00000)), + (22, 0x3a83d6, e(0x10000000)), + (22, 0x3a83d7, e(0x10100000)), + (22, 0x3a83d8, e(0x10200000)), + (22, 0x3a83d9, e(0x10300000)), + (22, 0x3a83da, e(0x10400000)), + (22, 0x3a83db, e(0x10500000)), + (22, 0x3a83dc, e(0x10600000)), + (22, 0x3a83dd, e(0x10700000)), + (22, 0x3a83de, e(0x10800000)), + (22, 0x3a83df, e(0x10900000)), + (22, 0x3a83e0, e(0x10a00000)), + (22, 0x3a83e1, e(0x10b00000)), + (22, 0x3a83e2, e(0x10c00000)), + (22, 0x3a83e3, e(0x10d00000)), + (22, 0x3a83e4, e(0x10e00000)), + (22, 0x3a83e5, e(0x10f00000)), + (22, 0x3a83e6, e(0x11000000)), + (22, 0x3a83e7, e(0x11100000)), + (22, 0x3a83e8, e(0x11200000)), + (22, 0x3a83e9, e(0x11300000)), + (22, 0x3a83ea, e(0x11400000)), + (22, 0x3a83eb, e(0x11500000)), + (22, 0x3a83ec, e(0x11600000)), + (22, 0x3a83ed, e(0x11700000)), + (22, 0x3a83ee, e(0x11800000)), + (22, 0x3a83ef, e(0x11900000)), + (22, 0x3a83f0, e(0x11a00000)), + (22, 0x3a83f1, e(0x11b00000)), + (22, 0x3a83f2, e(0x11c00000)), + (22, 0x3a83f3, e(0x11d00000)), + (22, 0x3a83f4, e(0x11e00000)), + (22, 0x3a83f5, e(0x11f00000)), + (22, 0x3a83f6, e(0x12000000)), + (22, 0x3a83f7, e(0x12100000)), + (22, 0x3a83f8, e(0x12200000)), + (22, 0x3a83f9, e(0x12300000)), + (22, 0x3a83fa, e(0x12400000)), + (22, 0x3a83fb, e(0x12500000)), + (22, 0x3a83fc, e(0x12600000)), + (22, 0x3a83fd, e(0x12700000)), + (22, 0x3a83fe, e(0x12800000)), + (22, 0x3a83ff, e(0x12900000)), + (22, 0x3a8400, e(0x12a00000)), + (22, 0x3a8401, e(0x12b00000)), + (22, 0x3a8402, e(0x12c00000)), + (22, 0x3a8403, e(0x12d00000)), + (22, 0x3a8404, e(0x12e00000)), + (22, 0x3a8405, e(0x12f00000)), + (22, 0x3a8406, e(0x13000000)), + (22, 0x3a8407, e(0x13100000)), + (22, 0x3a8408, e(0x13200000)), + (22, 0x3a8409, e(0x13300000)), + (22, 0x3a840a, e(0x13400000)), + (22, 0x3a840b, e(0x13500000)), + (22, 0x3a840c, e(0x13600000)), + (22, 0x3a840d, e(0x13700000)), + (22, 0x3a840e, e(0x13800000)), + (22, 0x3a840f, e(0x13900000)), + (22, 0x3a8410, e(0x13a00000)), + (22, 0x3a8411, e(0x13b00000)), + (22, 0x3a8412, e(0x13c00000)), + (22, 0x3a8413, e(0x13d00000)), + (22, 0x3a8414, e(0x13e00000)), + (22, 0x3a8415, e(0x13f00000)), + (22, 0x3a8416, e(0x14000000)), + (22, 0x3a8417, e(0x14100000)), + (22, 0x3a8418, e(0x14200000)), + (22, 0x3a8419, e(0x14300000)), + (22, 0x3a841a, e(0x14400000)), + (22, 0x3a841b, e(0x14500000)), + (22, 0x3a841c, e(0x14600000)), + (22, 0x3a841d, e(0x14700000)), + (22, 0x3a841e, e(0x14800000)), + (22, 0x3a841f, e(0x14900000)), + (22, 0x3a8420, e(0x14a00000)), + (22, 0x3a8421, e(0x14b00000)), + (22, 0x3a8422, e(0x14c00000)), + (22, 0x3a8423, e(0x14d00000)), + (22, 0x3a8424, e(0x14e00000)), + (22, 0x3a8425, e(0x14f00000)), + (22, 0x3a8426, e(0x15000000)), + (22, 0x3a8427, e(0x15100000)), + (22, 0x3a8428, e(0x15200000)), + (22, 0x3a8429, e(0x15300000)), + (22, 0x3a842a, e(0x15400000)), + (22, 0x3a842b, e(0x15500000)), + (22, 0x3a842c, e(0x15600000)), + (22, 0x3a842d, e(0x15700000)), + (22, 0x3a842e, e(0x15800000)), + (22, 0x3a842f, e(0x15900000)), + (22, 0x3a8430, e(0x15a00000)), + (22, 0x3a8431, e(0x15b00000)), + (22, 0x3a8432, e(0x15c00000)), + (22, 0x3a8433, e(0x15d00000)), + (22, 0x3a8434, e(0x15e00000)), + (22, 0x3a8435, e(0x15f00000)), + (22, 0x3a8436, e(0x16000000)), + (22, 0x3a8437, e(0x16100000)), + (22, 0x3a8438, e(0x16200000)), + (22, 0x3a8439, e(0x16300000)), + (22, 0x3a843a, e(0x16400000)), + (22, 0x3a843b, e(0x16500000)), + (22, 0x3a843c, e(0x16600000)), + (22, 0x3a843d, e(0x16700000)), + (22, 0x3a843e, e(0x16800000)), + (22, 0x3a843f, e(0x16900000)), + (22, 0x3a8440, e(0x16a00000)), + (22, 0x3a8441, e(0x16b00000)), + (22, 0x3a8442, e(0x16c00000)), + (22, 0x3a8443, e(0x16d00000)), + (22, 0x3a8444, e(0x16e00000)), + (22, 0x3a8445, e(0x16f00000)), + (22, 0x3a8446, e(0x17000000)), + (22, 0x3a8447, e(0x17100000)), + (22, 0x3a8448, e(0x17200000)), + (22, 0x3a8449, e(0x17300000)), + (22, 0x3a844a, e(0x17400000)), + (22, 0x3a844b, e(0x17500000)), + (22, 0x3a844c, e(0x17600000)), + (22, 0x3a844d, e(0x17700000)), + (22, 0x3a844e, e(0x17800000)), + (22, 0x3a844f, e(0x17900000)), + (22, 0x3a8450, e(0x17a00000)), + (22, 0x3a8451, e(0x17b00000)), + (22, 0x3a8452, e(0x17c00000)), + (22, 0x3a8453, e(0x17d00000)), + (22, 0x3a8454, e(0x17e00000)), + (22, 0x3a8455, e(0x17f00000)), + (22, 0x3a8456, e(0x18000000)), + (22, 0x3a8457, e(0x18100000)), + (22, 0x3a8458, e(0x18200000)), + (22, 0x3a8459, e(0x18300000)), + (22, 0x3a845a, e(0x18400000)), + (22, 0x3a845b, e(0x18500000)), + (22, 0x3a845c, e(0x18600000)), + (22, 0x3a845d, e(0x18700000)), + (22, 0x3a845e, e(0x18800000)), + (22, 0x3a845f, e(0x18900000)), + (22, 0x3a8460, e(0x18a00000)), + (22, 0x3a8461, e(0x18b00000)), + (22, 0x3a8462, e(0x18c00000)), + (22, 0x3a8463, e(0x18d00000)), + (22, 0x3a8464, e(0x18e00000)), + (22, 0x3a8465, e(0x18f00000)), + (22, 0x3a8466, e(0x19000000)), + (22, 0x3a8467, e(0x19100000)), + (22, 0x3a8468, e(0x19200000)), + (22, 0x3a8469, e(0x19300000)), + (22, 0x3a846a, e(0x19400000)), + (22, 0x3a846b, e(0x19500000)), + (22, 0x3a846c, e(0x19600000)), + (22, 0x3a846d, e(0x19700000)), + (22, 0x3a846e, e(0x19800000)), + (22, 0x3a846f, e(0x19900000)), + (22, 0x3a8470, e(0x19a00000)), + (22, 0x3a8471, e(0x19b00000)), + (22, 0x3a8472, e(0x19c00000)), + (22, 0x3a8473, e(0x19d00000)), + (22, 0x3a8474, e(0x19e00000)), + (22, 0x3a8475, e(0x19f00000)), + (22, 0x3a8476, e(0x1a000000)), + (22, 0x3a8477, e(0x1a100000)), + (22, 0x3a8478, e(0x1a200000)), + (22, 0x3a8479, e(0x1a300000)), + (22, 0x3a847a, e(0x1a400000)), + (22, 0x3a847b, e(0x1a500000)), + (22, 0x3a847c, e(0x1a600000)), + (22, 0x3a847d, e(0x1a700000)), + (22, 0x3a847e, e(0x1a800000)), + (22, 0x3a847f, e(0x1a900000)), + (22, 0x3a8480, e(0x1aa00000)), + (22, 0x3a8481, e(0x1ab00000)), + (22, 0x3a8482, e(0x1ac00000)), + (22, 0x3a8483, e(0x1ad00000)), + (22, 0x3a8484, e(0x1ae00000)), + (22, 0x3a8485, e(0x1af00000)), + (22, 0x3a8486, e(0x1b000000)), + (22, 0x3a8487, e(0x1b100000)), + (22, 0x3a8488, e(0x1b200000)), + (22, 0x3a8489, e(0x1b300000)), + (22, 0x3a848a, e(0x1b400000)), + (22, 0x3a848b, e(0x1b500000)), + (22, 0x3a848c, e(0x1b600000)), + (22, 0x3a848d, e(0x1b700000)), + (22, 0x3a848e, e(0x1b800000)), + (22, 0x3a848f, e(0x1b900000)), + (22, 0x3a8490, e(0x1ba00000)), + (22, 0x3a8491, e(0x1bb00000)), + (22, 0x3a8492, e(0x1bc00000)), + (22, 0x3a8493, e(0x1bd00000)), + (22, 0x3a8494, e(0x1be00000)), + (22, 0x3a8495, e(0x1bf00000)), + (22, 0x3a8496, e(0x1c000000)), + (22, 0x3a8497, e(0x1c100000)), + (22, 0x3a8498, e(0x1c200000)), + (22, 0x3a8499, e(0x1c300000)), + (22, 0x3a849a, e(0x1c400000)), + (22, 0x3a849b, e(0x1c500000)), + (22, 0x3a849c, e(0x1c600000)), + (22, 0x3a849d, e(0x1c700000)), + (22, 0x3a849e, e(0x1c800000)), + (22, 0x3a849f, e(0x1c900000)), + (22, 0x3a84a0, e(0x1ca00000)), + (22, 0x3a84a1, e(0x1cb00000)), + (22, 0x3a84a2, e(0x1cc00000)), + (22, 0x3a84a3, e(0x1cd00000)), + (22, 0x3a84a4, e(0x1ce00000)), + (22, 0x3a84a5, e(0x1cf00000)), + (22, 0x3a84a6, e(0x1d000000)), + (22, 0x3a84a7, e(0x1d100000)), + (22, 0x3a84a8, e(0x1d200000)), + (22, 0x3a84a9, e(0x1d300000)), + (22, 0x3a84aa, e(0x1d400000)), + (22, 0x3a84ab, e(0x1d500000)), + (22, 0x3a84ac, e(0x1d600000)), + (22, 0x3a84ad, e(0x1d700000)), + (22, 0x3a84ae, e(0x1d800000)), + (22, 0x3a84af, e(0x1d900000)), + (22, 0x3a84b0, e(0x1da00000)), + (22, 0x3a84b1, e(0x1db00000)), + (22, 0x3a84b2, e(0x1dc00000)), + (22, 0x3a84b3, e(0x1dd00000)), + (22, 0x3a84b4, e(0x1de00000)), + (22, 0x3a84b5, e(0x1df00000)), + (22, 0x3a84b6, e(0x1e000000)), + (22, 0x3a84b7, e(0x1e100000)), + (22, 0x3a84b8, e(0x1e200000)), + (22, 0x3a84b9, e(0x1e300000)), + (22, 0x3a84ba, e(0x1e400000)), + (22, 0x3a84bb, e(0x1e500000)), + (22, 0x3a84bc, e(0x1e600000)), + (22, 0x3a84bd, e(0x1e700000)), + (22, 0x3a84be, e(0x1e800000)), + (22, 0x3a84bf, e(0x1e900000)), + (22, 0x3a84c0, e(0x1ea00000)), + (22, 0x3a84c1, e(0x1eb00000)), + (22, 0x3a84c2, e(0x1ec00000)), + (22, 0x3a84c3, e(0x1ed00000)), + (22, 0x3a84c4, e(0x1ee00000)), + (22, 0x3a84c5, e(0x1ef00000)), + (22, 0x3a84c6, e(0x1f000000)), + (22, 0x3a84c7, e(0x1f100000)), + (22, 0x3a84c8, e(0x1f200000)), + (22, 0x3a84c9, e(0x1f300000)), + (22, 0x3a84ca, e(0x1f400000)), + (22, 0x3a84cb, e(0x1f500000)), + (22, 0x3a84cc, e(0x1f600000)), + (22, 0x3a84cd, e(0x1f700000)), + (22, 0x3a84ce, e(0x1f800000)), + (22, 0x3a84cf, e(0x1f900000)), + (22, 0x3a84d0, e(0x1fa00000)), + (22, 0x3a84d1, e(0x1fb00000)), + (22, 0x3a84d2, e(0x1fc00000)), + (22, 0x3a84d3, e(0x1fd00000)), + (22, 0x3a84d4, e(0x1fe00000)), + (22, 0x3a84d5, e(0x1ff00000)), + (22, 0x3a84d6, e(0x20000000)), + (22, 0x3a84d7, e(0x20100000)), + (22, 0x3a84d8, e(0x20200000)), + (22, 0x3a84d9, e(0x20300000)), + (22, 0x3a84da, e(0x20400000)), + (22, 0x3a84db, e(0x20500000)), + (22, 0x3a84dc, e(0x20600000)), + (22, 0x3a84dd, e(0x20700000)), + (22, 0x3a84de, e(0x20800000)), + (22, 0x3a84df, e(0x20900000)), + (22, 0x3a84e0, e(0x20a00000)), + (22, 0x3a84e1, e(0x20b00000)), + (22, 0x3a84e2, e(0x20c00000)), + (22, 0x3a84e3, e(0x20d00000)), + (22, 0x3a84e4, e(0x20e00000)), + (22, 0x3a84e5, e(0x20f00000)), + (22, 0x3a84e6, e(0x21000000)), + (22, 0x3a84e7, e(0x21100000)), + (22, 0x3a84e8, e(0x21200000)), + (22, 0x3a84e9, e(0x21300000)), + (22, 0x3a84ea, e(0x21400000)), + (22, 0x3a84eb, e(0x21500000)), + (22, 0x3a84ec, e(0x21600000)), + (22, 0x3a84ed, e(0x21700000)), + (22, 0x3a84ee, e(0x21800000)), + (22, 0x3a84ef, e(0x21900000)), + (22, 0x3a84f0, e(0x21a00000)), + (22, 0x3a84f1, e(0x21b00000)), + (22, 0x3a84f2, e(0x21c00000)), + (22, 0x3a84f3, e(0x21d00000)), + (22, 0x3a84f4, e(0x21e00000)), + (22, 0x3a84f5, e(0x21f00000)), + (22, 0x3a84f6, e(0x22000000)), + (22, 0x3a84f7, e(0x22100000)), + (22, 0x3a84f8, e(0x22200000)), + (22, 0x3a84f9, e(0x22300000)), + (22, 0x3a84fa, e(0x22400000)), + (22, 0x3a84fb, e(0x22500000)), + (22, 0x3a84fc, e(0x22600000)), + (22, 0x3a84fd, e(0x22700000)), + (22, 0x3a84fe, e(0x22800000)), + (22, 0x3a84ff, e(0x22900000)), + (22, 0x3a8500, e(0x22a00000)), + (22, 0x3a8501, e(0x22b00000)), + (22, 0x3a8502, e(0x22c00000)), + (22, 0x3a8503, e(0x22d00000)), + (22, 0x3a8504, e(0x22e00000)), + (22, 0x3a8505, e(0x22f00000)), + (22, 0x3a8506, e(0x23000000)), + (22, 0x3a8507, e(0x23100000)), + (22, 0x3a8508, e(0x23200000)), + (22, 0x3a8509, e(0x23300000)), + (22, 0x3a850a, e(0x23400000)), + (22, 0x3a850b, e(0x23500000)), + (22, 0x3a850c, e(0x23600000)), + (22, 0x3a850d, e(0x23700000)), + (22, 0x3a850e, e(0x23800000)), + (22, 0x3a850f, e(0x23900000)), + (22, 0x3a8510, e(0x23a00000)), + (22, 0x3a8511, e(0x23b00000)), + (22, 0x3a8512, e(0x23c00000)), + (22, 0x3a8513, e(0x23d00000)), + (22, 0x3a8514, e(0x23e00000)), + (22, 0x3a8515, e(0x23f00000)), + (22, 0x3a8516, e(0x24000000)), + (22, 0x3a8517, e(0x24100000)), + (22, 0x3a8518, e(0x24200000)), + (22, 0x3a8519, e(0x24300000)), + (22, 0x3a851a, e(0x24400000)), + (22, 0x3a851b, e(0x24500000)), + (22, 0x3a851c, e(0x24600000)), + (22, 0x3a851d, e(0x24700000)), + (22, 0x3a851e, e(0x24800000)), + (22, 0x3a851f, e(0x24900000)), + (22, 0x3a8520, e(0x24a00000)), + (22, 0x3a8521, e(0x24b00000)), + (22, 0x3a8522, e(0x24c00000)), + (22, 0x3a8523, e(0x24d00000)), + (22, 0x3a8524, e(0x24e00000)), + (22, 0x3a8525, e(0x24f00000)), + (22, 0x3a8526, e(0x25000000)), + (22, 0x3a8527, e(0x25100000)), + (22, 0x3a8528, e(0x25200000)), + (22, 0x3a8529, e(0x25300000)), + (22, 0x3a852a, e(0x25400000)), + (22, 0x3a852b, e(0x25500000)), + (22, 0x3a852c, e(0x25600000)), + (22, 0x3a852d, e(0x25700000)), + (22, 0x3a852e, e(0x25800000)), + (22, 0x3a852f, e(0x25900000)), + (22, 0x3a8530, e(0x25a00000)), + (22, 0x3a8531, e(0x25b00000)), + (22, 0x3a8532, e(0x25c00000)), + (22, 0x3a8533, e(0x25d00000)), + (22, 0x3a8534, e(0x25e00000)), + (22, 0x3a8535, e(0x25f00000)), + (22, 0x3a8536, e(0x26000000)), + (22, 0x3a8537, e(0x26100000)), + (22, 0x3a8538, e(0x26200000)), + (22, 0x3a8539, e(0x26300000)), + (22, 0x3a853a, e(0x26400000)), + (22, 0x3a853b, e(0x26500000)), + (22, 0x3a853c, e(0x26600000)), + (22, 0x3a853d, e(0x26700000)), + (22, 0x3a853e, e(0x26800000)), + (22, 0x3a853f, e(0x26900000)), + (22, 0x3a8540, e(0x26a00000)), + (22, 0x3a8541, e(0x26b00000)), + (22, 0x3a8542, e(0x26c00000)), + (22, 0x3a8543, e(0x26d00000)), + (22, 0x3a8544, e(0x26e00000)), + (22, 0x3a8545, e(0x26f00000)), + (22, 0x3a8546, e(0x27000000)), + (22, 0x3a8547, e(0x27100000)), + (22, 0x3a8548, e(0x27200000)), + (22, 0x3a8549, e(0x27300000)), + (22, 0x3a854a, e(0x27400000)), + (22, 0x3a854b, e(0x27500000)), + (22, 0x3a854c, e(0x27600000)), + (22, 0x3a854d, e(0x27700000)), + (22, 0x3a854e, e(0x27800000)), + (22, 0x3a854f, e(0x27900000)), + (22, 0x3a8550, e(0x27a00000)), + (22, 0x3a8551, e(0x27b00000)), + (22, 0x3a8552, e(0x27c00000)), + (22, 0x3a8553, e(0x27d00000)), + (22, 0x3a8554, e(0x27e00000)), + (22, 0x3a8555, e(0x27f00000)), + (22, 0x3a8556, e(0x28000000)), + (22, 0x3a8557, e(0x28100000)), + (22, 0x3a8558, e(0x28200000)), + (22, 0x3a8559, e(0x28300000)), + (22, 0x3a855a, e(0x28400000)), + (22, 0x3a855b, e(0x28500000)), + (22, 0x3a855c, e(0x28600000)), + (22, 0x3a855d, e(0x28700000)), + (22, 0x3a855e, e(0x28800000)), + (22, 0x3a855f, e(0x28900000)), + (22, 0x3a8560, e(0x28a00000)), + (22, 0x3a8561, e(0x28b00000)), + (22, 0x3a8562, e(0x28c00000)), + (22, 0x3a8563, e(0x28d00000)), + (22, 0x3a8564, e(0x28e00000)), + (22, 0x3a8565, e(0x28f00000)), + (22, 0x3a8566, e(0x29000000)), + (22, 0x3a8567, e(0x29100000)), + (22, 0x3a8568, e(0x29200000)), + (22, 0x3a8569, e(0x29300000)), + (22, 0x3a856a, e(0x29400000)), + (22, 0x3a856b, e(0x29500000)), + (22, 0x3a856c, e(0x29600000)), + (22, 0x3a856d, e(0x29700000)), + (22, 0x3a856e, e(0x29800000)), + (22, 0x3a856f, e(0x29900000)), + (22, 0x3a8570, e(0x29a00000)), + (22, 0x3a8571, e(0x29b00000)), + (22, 0x3a8572, e(0x29c00000)), + (22, 0x3a8573, e(0x29d00000)), + (22, 0x3a8574, e(0x29e00000)), + (22, 0x3a8575, e(0x29f00000)), + (22, 0x3a8576, e(0x2a000000)), + (22, 0x3a8577, e(0x2a100000)), + (22, 0x3a8578, e(0x2a200000)), + (22, 0x3a8579, e(0x2a300000)), + (22, 0x3a857a, e(0x2a400000)), + (22, 0x3a857b, e(0x2a500000)), + (22, 0x3a857c, e(0x2a600000)), + (22, 0x3a857d, e(0x2a700000)), + (22, 0x3a857e, e(0x2a800000)), + (22, 0x3a857f, e(0x2a900000)), + (22, 0x3a8580, e(0x2aa00000)), + (22, 0x3a8581, e(0x2ab00000)), + (22, 0x3a8582, e(0x2ac00000)), + (22, 0x3a8583, e(0x2ad00000)), + (22, 0x3a8584, e(0x2ae00000)), + (22, 0x3a8585, e(0x2af00000)), + (22, 0x3a8586, e(0x2b000000)), + (22, 0x3a8587, e(0x2b100000)), + (22, 0x3a8588, e(0x2b200000)), + (22, 0x3a8589, e(0x2b300000)), + (22, 0x3a858a, e(0x2b400000)), + (22, 0x3a858b, e(0x2b500000)), + (22, 0x3a858c, e(0x2b600000)), + (22, 0x3a858d, e(0x2b700000)), + (22, 0x3a858e, e(0x2b800000)), + (22, 0x3a858f, e(0x2b900000)), + (22, 0x3a8590, e(0x2ba00000)), + (22, 0x3a8591, e(0x2bb00000)), + (22, 0x3a8592, e(0x2bc00000)), + (22, 0x3a8593, e(0x2bd00000)), + (22, 0x3a8594, e(0x2be00000)), + (22, 0x3a8595, e(0x2bf00000)), + (22, 0x3a8596, e(0x2c000000)), + (22, 0x3a8597, e(0x2c100000)), + (22, 0x3a8598, e(0x2c200000)), + (22, 0x3a8599, e(0x2c300000)), + (22, 0x3a859a, e(0x2c400000)), + (22, 0x3a859b, e(0x2c500000)), + (22, 0x3a859c, e(0x2c600000)), + (22, 0x3a859d, e(0x2c700000)), + (22, 0x3a859e, e(0x2c800000)), + (22, 0x3a859f, e(0x2c900000)), + (22, 0x3a85a0, e(0x2ca00000)), + (22, 0x3a85a1, e(0x2cb00000)), + (22, 0x3a85a2, e(0x2cc00000)), + (22, 0x3a85a3, e(0x2cd00000)), + (22, 0x3a85a4, e(0x2ce00000)), + (22, 0x3a85a5, e(0x2cf00000)), + (22, 0x3a85a6, e(0x2d000000)), + (22, 0x3a85a7, e(0x2d100000)), + (22, 0x3a85a8, e(0x2d200000)), + (22, 0x3a85a9, e(0x2d300000)), + (22, 0x3a85aa, e(0x2d400000)), + (22, 0x3a85ab, e(0x2d500000)), + (22, 0x3a85ac, e(0x2d600000)), + (22, 0x3a85ad, e(0x2d700000)), + (22, 0x3a85ae, e(0x2d800000)), + (22, 0x3a85af, e(0x2d900000)), + (22, 0x3a85b0, e(0x2da00000)), + (22, 0x3a85b1, e(0x2db00000)), + (22, 0x3a85b2, e(0x2dc00000)), + (22, 0x3a85b3, e(0x2dd00000)), + (22, 0x3a85b4, e(0x2de00000)), + (22, 0x3a85b5, e(0x2df00000)), + (22, 0x3a85b6, e(0x2e000000)), + (22, 0x3a85b7, e(0x2e100000)), + (22, 0x3a85b8, e(0x2e200000)), + (22, 0x3a85b9, e(0x2e300000)), + (22, 0x3a85ba, e(0x2e400000)), + (22, 0x3a85bb, e(0x2e500000)), + (22, 0x3a85bc, e(0x2e600000)), + (22, 0x3a85bd, e(0x2e700000)), + (22, 0x3a85be, e(0x2e800000)), + (22, 0x3a85bf, e(0x2e900000)), + (22, 0x3a85c0, e(0x2ea00000)), + (22, 0x3a85c1, e(0x2eb00000)), + (22, 0x3a85c2, e(0x2ec00000)), + (22, 0x3a85c3, e(0x2ed00000)), + (22, 0x3a85c4, e(0x2ee00000)), + (22, 0x3a85c5, e(0x2ef00000)), + (22, 0x3a85c6, e(0x2f000000)), + (22, 0x3a85c7, e(0x2f100000)), + (22, 0x3a85c8, e(0x2f200000)), + (22, 0x3a85c9, e(0x2f300000)), + (22, 0x3a85ca, e(0x2f400000)), + (22, 0x3a85cb, e(0x2f500000)), + (22, 0x3a85cc, e(0x2f600000)), + (22, 0x3a85cd, e(0x2f700000)), + (22, 0x3a85ce, e(0x2f800000)), + (22, 0x3a85cf, e(0x2f900000)), + (22, 0x3a85d0, e(0x2fa00000)), + (22, 0x3a85d1, e(0x2fb00000)), + (22, 0x3a85d2, e(0x2fc00000)), + (22, 0x3a85d3, e(0x2fd00000)), + (22, 0x3a85d4, e(0x2fe00000)), + (22, 0x3a85d5, e(0x2ff00000)), + (22, 0x3a85d6, e(0x30000000)), + (22, 0x3a85d7, e(0x30100000)), + (22, 0x3a85d8, e(0x30200000)), + (22, 0x3a85d9, e(0x30300000)), + (22, 0x3a85da, e(0x30400000)), + (22, 0x3a85db, e(0x30500000)), + (22, 0x3a85dc, e(0x30600000)), + (22, 0x3a85dd, e(0x30700000)), + (22, 0x3a85de, e(0x30800000)), + (22, 0x3a85df, e(0x30900000)), + (22, 0x3a85e0, e(0x30a00000)), + (22, 0x3a85e1, e(0x30b00000)), + (22, 0x3a85e2, e(0x30c00000)), + (22, 0x3a85e3, e(0x30d00000)), + (22, 0x3a85e4, e(0x30e00000)), + (22, 0x3a85e5, e(0x30f00000)), + (22, 0x3a85e6, e(0x31000000)), + (22, 0x3a85e7, e(0x31100000)), + (22, 0x3a85e8, e(0x31200000)), + (22, 0x3a85e9, e(0x31300000)), + (22, 0x3a85ea, e(0x31400000)), + (22, 0x3a85eb, e(0x31500000)), + (22, 0x3a85ec, e(0x31600000)), + (22, 0x3a85ed, e(0x31700000)), + (22, 0x3a85ee, e(0x31800000)), + (22, 0x3a85ef, e(0x31900000)), + (22, 0x3a85f0, e(0x31a00000)), + (22, 0x3a85f1, e(0x31b00000)), + (22, 0x3a85f2, e(0x31c00000)), + (22, 0x3a85f3, e(0x31d00000)), + (22, 0x3a85f4, e(0x31e00000)), + (22, 0x3a85f5, e(0x31f00000)), + (22, 0x3a85f6, e(0x32000000)), + (22, 0x3a85f7, e(0x32100000)), + (22, 0x3a85f8, e(0x32200000)), + (22, 0x3a85f9, e(0x32300000)), + (22, 0x3a85fa, e(0x32400000)), + (22, 0x3a85fb, e(0x32500000)), + (22, 0x3a85fc, e(0x32600000)), + (22, 0x3a85fd, e(0x32700000)), + (22, 0x3a85fe, e(0x32800000)), + (22, 0x3a85ff, e(0x32900000)), + (22, 0x3a8600, e(0x32a00000)), + (22, 0x3a8601, e(0x32b00000)), + (22, 0x3a8602, e(0x32c00000)), + (22, 0x3a8603, e(0x32d00000)), + (22, 0x3a8604, e(0x32e00000)), + (22, 0x3a8605, e(0x32f00000)), + (22, 0x3a8606, e(0x33000000)), + (22, 0x3a8607, e(0x33100000)), + (22, 0x3a8608, e(0x33200000)), + (22, 0x3a8609, e(0x33300000)), + (22, 0x3a860a, e(0x33400000)), + (22, 0x3a860b, e(0x33500000)), + (22, 0x3a860c, e(0x33600000)), + (22, 0x3a860d, e(0x33700000)), + (22, 0x3a860e, e(0x33800000)), + (22, 0x3a860f, e(0x33900000)), + (22, 0x3a8610, e(0x33a00000)), + (22, 0x3a8611, e(0x33b00000)), + (22, 0x3a8612, e(0x33c00000)), + (22, 0x3a8613, e(0x33d00000)), + (22, 0x3a8614, e(0x33e00000)), + (22, 0x3a8615, e(0x33f00000)), + (22, 0x3a8616, e(0x34000000)), + (22, 0x3a8617, e(0x34100000)), + (22, 0x3a8618, e(0x34200000)), + (22, 0x3a8619, e(0x34300000)), + (22, 0x3a861a, e(0x34400000)), + (22, 0x3a861b, e(0x34500000)), + (22, 0x3a861c, e(0x34600000)), + (22, 0x3a861d, e(0x34700000)), + (22, 0x3a861e, e(0x34800000)), + (22, 0x3a861f, e(0x34900000)), + (22, 0x3a8620, e(0x34a00000)), + (22, 0x3a8621, e(0x34b00000)), + (22, 0x3a8622, e(0x34c00000)), + (22, 0x3a8623, e(0x34d00000)), + (22, 0x3a8624, e(0x34e00000)), + (22, 0x3a8625, e(0x34f00000)), + (22, 0x3a8626, e(0x35000000)), + (22, 0x3a8627, e(0x35100000)), + (22, 0x3a8628, e(0x35200000)), + (22, 0x3a8629, e(0x35300000)), + (18, 0xf787, e(0x35400000)), + (18, 0xd1d4, e(0x35500000)), + (19, 0x77f9f, e(0x35600000)), + (18, 0x3b8fb, e(0x35700000)), + (19, 0x1ef1a, e(0x35800000)), + (21, 0x1d4315, e(0x35900000)), + (16, 0x3de2, e(0x35a00000)), + (22, 0x3a862c, e(0x35b00000)), + (22, 0x3a862d, e(0x35c00000)), + (22, 0x3a862e, e(0x35d00000)), + (22, 0x3a862f, e(0x35e00000)), + (22, 0x3a8630, e(0x35f00000)), + (22, 0x3a8631, e(0x36000000)), + (22, 0x3a8632, e(0x36100000)), + (22, 0x3a8633, e(0x36200000)), + (22, 0x3a8634, e(0x36300000)), + (22, 0x3a8635, e(0x36400000)), + (22, 0x3a8636, e(0x36500000)), + (22, 0x3a8637, e(0x36600000)), + (22, 0x3a8638, e(0x36700000)), + (21, 0x1d431d, e(0x36800000)), + (22, 0x3a8639, e(0x36900000)), + (22, 0x3a863c, e(0x36a00000)), + (16, 0x3de0, e(0x36b00000)), + (18, 0x3a95e, e(0x36c00000)), + (21, 0x1d431f, e(0x36d00000)), + (22, 0x3a863d, e(0x36e00000)), + (22, 0x3a8640, e(0x36f00000)), + (20, 0x34749, e(0x37000000)), + (20, 0x3474d, e(0x37100000)), + (22, 0x3a8641, e(0x37200000)), + (22, 0x3a8642, e(0x37300000)), + (22, 0x3a8643, e(0x37400000)), + (22, 0x3a8644, e(0x37500000)), + (22, 0x3a8645, e(0x37600000)), + (22, 0x3a8646, e(0x37700000)), + (22, 0x3a8647, e(0x37800000)), + (22, 0x3a8648, e(0x37900000)), + (22, 0x3a8649, e(0x37a00000)), + (22, 0x3a864a, e(0x37b00000)), + (22, 0x3a864b, e(0x37c00000)), + (22, 0x3a864c, e(0x37d00000)), + (22, 0x3a864d, e(0x37e00000)), + (22, 0x3a864e, e(0x37f00000)), + (22, 0x3a864f, e(0x38000000)), + (22, 0x3a8650, e(0x38100000)), + (22, 0x3a8651, e(0x38200000)), + (22, 0x3a8652, e(0x38300000)), + (22, 0x3a8653, e(0x38400000)), + (22, 0x3a8654, e(0x38500000)), + (22, 0x3a8655, e(0x38600000)), + (22, 0x3a8656, e(0x38700000)), + (22, 0x3a8657, e(0x38800000)), + (22, 0x3a8658, e(0x38900000)), + (22, 0x3a8659, e(0x38a00000)), + (22, 0x3a865a, e(0x38b00000)), + (22, 0x3a865b, e(0x38c00000)), + (22, 0x3a865c, e(0x38d00000)), + (22, 0x3a865d, e(0x38e00000)), + (21, 0x1d432f, e(0x38f00000)), + (22, 0x3a8660, e(0x39000000)), + (22, 0x3a8661, e(0x39100000)), + (22, 0x3a8662, e(0x39200000)), + (21, 0x1d4332, e(0x39300000)), + (18, 0xd1d5, e(0x39400000)), + (18, 0x3bfda, e(0x39500000)), + (15, 0x7528, e(0x39600000)), + (15, 0x7529, e(0x39700000)), + (18, 0x3a95f, e(0x39800000)), + (17, 0x1d434, e(0x39900000)), + (19, 0x750cd, e(0x39a00000)), + (18, 0x3a867, e(0x39b00000)), + (19, 0x771fd, e(0x39c00000)), + (15, 0x7764, e(0x39d00000)), + (20, 0xee3f9, e(0x39e00000)), + (18, 0x3bfdb, e(0x39f00000)), + (16, 0xeff7, e(0x3a000000)), + (18, 0xd1d6, e(0x3a100000)), + (22, 0x3a8663, e(0x3a200000)), + (22, 0x3a8666, e(0x3a300000)), + (18, 0x3b8fa, e(0x3a400000)), + (17, 0x1d435, e(0x3a500000)), + (17, 0x1dfe4, e(0x3a600000)), + (19, 0x750d8, e(0x3a700000)), + (18, 0x3a95b, e(0x3a800000)), + (19, 0x77f9e, e(0x3a900000)), + (19, 0x750d9, e(0x3aa00000)), + (18, 0xd1d7, e(0x3ab00000)), + (18, 0x3b8ff, e(0x3ac00000)), + (17, 0x1dc7c, e(0x3ad00000)), + (19, 0x750da, e(0x3ae00000)), + (17, 0x7bc2, e(0x3af00000)), + (18, 0x3bfca, e(0x3b000000)), + (19, 0x1a3a5, e(0x3b100000)), + (17, 0x1d4ac, e(0x3b200000)), + (18, 0x3a86e, e(0x3b300000)), + (17, 0x1d438, e(0x3b400000)), + (18, 0x3bfcb, e(0x3b500000)), + (19, 0x1a3a7, e(0x3b600000)), + (17, 0x1d4ae, e(0x3b700000)), + (18, 0x3bfce, e(0x3b800000)), + (18, 0xf78c, e(0x3b900000)), + (17, 0x1dfec, e(0x3ba00000)), + (17, 0x1d439, e(0x3bb00000)), + (17, 0x68e8, e(0x3bc00000)), + (18, 0xf786, e(0x3bd00000)), + (15, 0x771e, e(0x3be00000)), + (17, 0x1dfe6, e(0x3bf00000)), + (15, 0x77f8, e(0x3c000000)), + (14, 0x3bb3, e(0x3c100000)), + (14, 0x3b8e, e(0x3c200000)), + (14, 0x3a82, e(0x3c300000)), + (14, 0x3b96, e(0x3c400000)), + (13, 0x68f, e(0x3c500000)), + (12, 0x3d4, e(0x3c600000)), + (13, 0x1dca, e(0x3c700000)), + (12, 0x346, e(0x3c800000)), + (12, 0xee7, e(0x3c900000)), + (12, 0xeea, e(0x3ca00000)), + (11, 0x1ed, e(0x3cb00000)), + (12, 0x3df, e(0x3cc00000)), + (11, 0x1a2, e(0x3cd00000)), + (11, 0x56f, e(0x3ce00000)), + (11, 0xb9, e(0x3cf00000)), + (13, 0x7b2, e(0x3d000000)), + (13, 0x1dd8, e(0x3d100000)), + (13, 0x15ba, e(0x3d200000)), + (12, 0xee6, e(0x3d300000)), + (13, 0x15b8, e(0x3d400000)), + (14, 0xf79, e(0x3d500000)), + (14, 0x3a81, e(0x3d600000)), + (14, 0xd1c, e(0x3d700000)), + (15, 0x7765, e(0x3d800000)), + (14, 0xf54, e(0x3d900000)), + (13, 0x15b9, e(0x3da00000)), + (13, 0x7ab, e(0x3db00000)), + (15, 0x7500, e(0x3dc00000)), + (15, 0x1eaa, e(0x3dd00000)), + (15, 0x7501, e(0x3de00000)), + (15, 0x1eab, e(0x3df00000)), + (14, 0x3b97, e(0x3e000000)), + (15, 0x752a, e(0x3e100000)), + (15, 0x77fa, e(0x3e200000)), + (10, 0xf4, d(0x3e35798e, 0xe2308c3a)), + (14, 0x3a93, e(0x3e300000)), + (11, 0x77c, d(0x3e45798e, 0xe2308c3a)), + (13, 0x1dc6, e(0x3e400000)), + (13, 0x7bd, e(0x3e500000)), + (13, 0x1dff, e(0x3e600000)), + (12, 0xefe, e(0x3e700000)), + (8, 0xaf, d(0x3e8ad7f2, 0x9abcaf4a)), + (12, 0xeed, e(0x3e800000)), + (11, 0xb8, e(0x3e900000)), + (12, 0x3d8, e(0x3ea00000)), + (11, 0x1eb, e(0x3eb00000)), + (9, 0x1d2, d(0x3ec0c6f7, 0xa0b5ed8e)), + (13, 0x1d4b, e(0x3ec00000)), + (13, 0x7b3, e(0x3ed00000)), + (10, 0x5d, e(0x3ee00000)), + (12, 0xeeb, e(0x3ef00000)), + (11, 0x1ee, e(0x3f000000)), + (10, 0x5f, e(0x3f100000)), + (10, 0x2b6, e(0x3f200000)), + (9, 0x1de, e(0x3f300000)), + (10, 0xd0, d(0x3f454c98, 0x5f06f694)), + (6, 0x9, d(0x3f4a36e2, 0xeb1c432d)), + (8, 0xe8, e(0x3f400000)), + (4, 0xf, d(0x3f50624d, 0xd2f1a9fc)), + (8, 0xae, e(0x3f500000)), + (5, 0x16, d(0x3f60624d, 0xd2f1a9fc)), + (7, 0x1b, e(0x3f600000)), + (7, 0x76, e(0x3f700000)), + (7, 0xa, e(0x3f800000)), + (6, 0x8, e(0x3f900000)), + (6, 0xe, e(0x3fa00000)), + (11, 0x751, d(0x3fbe69ad, 0x42c3c9ee)), + (6, 0x4, e(0x3fb00000)), + (6, 0xc, e(0x3fc00000)), + (5, 0x3, e(0x3fd00000)), + (11, 0x777, d(0x3fe00000, 0x00000000)), + (9, 0x1d6, d(0x3fefffff, 0xf8000002)), + (4, 0x8, e(0x3fe00000)), + (4, 0x0, d(0x3ff00000, 0x00000000)), + (5, 0x13, e(0x3ff00000)), + (5, 0x1b, e(0x40000000)), + (9, 0x15a, d(0x401921fb, 0x54442d18)), + (5, 0x17, e(0x40100000)), + (5, 0x12, e(0x40200000)), + (11, 0x774, d(0x4035ee14, 0x80000000)), + (5, 0x19, e(0x40300000)), + (9, 0x1d3, d(0x404ca5dc, 0x1a63c1f8)), + (5, 0x1a, e(0x40400000)), + (11, 0x77e, d(0x405bb32f, 0xe0000000)), + (10, 0x5e, d(0x405c332f, 0xe0000000)), + (5, 0x18, e(0x40500000)), + (9, 0x1d7, d(0x40668000, 0x00000000)), + (5, 0x1c, e(0x40600000)), + (9, 0x1d5, d(0x40768000, 0x00000000)), + (5, 0x14, e(0x40700000)), + (11, 0x77d, d(0x408f4000, 0x00000000)), + (5, 0x5, e(0x40800000)), + (10, 0xd2, d(0x409233ff, 0xffffffff)), + (8, 0x3c, d(0x40923400, 0x00000000)), + (11, 0x753, d(0x40923400, 0x00000001)), + (10, 0xd3, d(0x4092abff, 0xffffffff)), + (8, 0x35, d(0x4092ac00, 0x00000000)), + (11, 0x770, d(0x4092ac00, 0x00000001)), + (8, 0x16, e(0x40900000)), + (12, 0xee2, e(0x40a00000)), + (12, 0xee4, e(0x40b00000)), + (7, 0x1f, d(0x40c81c80, 0x00000000)), + (8, 0xac, e(0x40c00000)), + (13, 0x15bb, e(0x40d00000)), + (22, 0x3a8667, e(0x40e00000)), + (22, 0x3a86d8, e(0x40f00000)), + (22, 0x3a86d9, e(0x41000000)), + (22, 0x3a86da, e(0x41100000)), + (17, 0x1dc7e, e(0x41200000)), + (22, 0x3a86db, e(0x41300000)), + (22, 0x3a86dc, e(0x41400000)), + (22, 0x3a86dd, e(0x41500000)), + (22, 0x3a86de, e(0x41600000)), + (22, 0x3a86df, e(0x41700000)), + (22, 0x3a86f0, e(0x41800000)), + (22, 0x3a86f1, e(0x41900000)), + (22, 0x3a86f2, e(0x41a00000)), + (22, 0x3a86f3, e(0x41b00000)), + (6, 0x2a, d(0x41cdcd64, 0xff800000)), + (22, 0x3a86f4, e(0x41c00000)), + (22, 0x3a86f5, e(0x41d00000)), + (22, 0x3a86f6, e(0x41e00000)), + (22, 0x3a86f7, e(0x41f00000)), + (22, 0x3a86f8, e(0x42000000)), + (22, 0x3a86f9, e(0x42100000)), + (22, 0x3a86fa, e(0x42200000)), + (22, 0x3a86fb, e(0x42300000)), + (22, 0x3a86fc, e(0x42400000)), + (22, 0x3a86fd, e(0x42500000)), + (22, 0x3a86fe, e(0x42600000)), + (22, 0x3a86ff, e(0x42700000)), + (22, 0x3a8740, e(0x42800000)), + (22, 0x3a8741, e(0x42900000)), + (22, 0x3a8742, e(0x42a00000)), + (22, 0x3a8743, e(0x42b00000)), + (22, 0x3a8744, e(0x42c00000)), + (22, 0x3a8745, e(0x42d00000)), + (22, 0x3a8746, e(0x42e00000)), + (22, 0x3a8747, e(0x42f00000)), + (22, 0x3a8748, e(0x43000000)), + (22, 0x3a8749, e(0x43100000)), + (22, 0x3a874a, e(0x43200000)), + (22, 0x3a874b, e(0x43300000)), + (22, 0x3a874c, e(0x43400000)), + (22, 0x3a874d, e(0x43500000)), + (22, 0x3a874e, e(0x43600000)), + (22, 0x3a874f, e(0x43700000)), + (22, 0x3a8750, e(0x43800000)), + (22, 0x3a8751, e(0x43900000)), + (22, 0x3a8752, e(0x43a00000)), + (22, 0x3a8753, e(0x43b00000)), + (22, 0x3a8754, e(0x43c00000)), + (22, 0x3a8755, e(0x43d00000)), + (22, 0x3a8756, e(0x43e00000)), + (22, 0x3a8757, e(0x43f00000)), + (22, 0x3a8758, e(0x44000000)), + (15, 0x1a3b, e(0x44100000)), + (22, 0x3a8759, e(0x44200000)), + (22, 0x3a875a, e(0x44300000)), + (22, 0x3a875b, e(0x44400000)), + (22, 0x3a875c, e(0x44500000)), + (22, 0x3a875d, e(0x44600000)), + (22, 0x3a875e, e(0x44700000)), + (22, 0x3a875f, e(0x44800000)), + (22, 0x3a8760, e(0x44900000)), + (22, 0x3a8761, e(0x44a00000)), + (22, 0x3a8762, e(0x44b00000)), + (22, 0x3a8763, e(0x44c00000)), + (22, 0x3a8764, e(0x44d00000)), + (22, 0x3a8765, e(0x44e00000)), + (22, 0x3a8766, e(0x44f00000)), + (22, 0x3a8767, e(0x45000000)), + (22, 0x3a8768, e(0x45100000)), + (22, 0x3a8769, e(0x45200000)), + (22, 0x3a876a, e(0x45300000)), + (22, 0x3a876b, e(0x45400000)), + (22, 0x3a876c, e(0x45500000)), + (22, 0x3a876d, e(0x45600000)), + (22, 0x3a876e, e(0x45700000)), + (22, 0x3a876f, e(0x45800000)), + (22, 0x3a8770, e(0x45900000)), + (22, 0x3a8771, e(0x45a00000)), + (22, 0x3a8772, e(0x45b00000)), + (22, 0x3a8773, e(0x45c00000)), + (22, 0x3a8774, e(0x45d00000)), + (22, 0x3a8775, e(0x45e00000)), + (22, 0x3a8776, e(0x45f00000)), + (22, 0x3a8777, e(0x46000000)), + (22, 0x3a8778, e(0x46100000)), + (22, 0x3a8779, e(0x46200000)), + (22, 0x3a877a, e(0x46300000)), + (22, 0x3a877b, e(0x46400000)), + (22, 0x3a877c, e(0x46500000)), + (22, 0x3a877d, e(0x46600000)), + (22, 0x3a877e, e(0x46700000)), + (22, 0x3a877f, e(0x46800000)), + (22, 0x3a8780, e(0x46900000)), + (22, 0x3a8781, e(0x46a00000)), + (22, 0x3a8782, e(0x46b00000)), + (22, 0x3a8783, e(0x46c00000)), + (22, 0x3a8784, e(0x46d00000)), + (22, 0x3a8785, e(0x46e00000)), + (22, 0x3a8786, e(0x46f00000)), + (22, 0x3a8787, e(0x47000000)), + (22, 0x3a8788, e(0x47100000)), + (22, 0x3a8789, e(0x47200000)), + (22, 0x3a878a, e(0x47300000)), + (22, 0x3a878b, e(0x47400000)), + (22, 0x3a878c, e(0x47500000)), + (22, 0x3a878d, e(0x47600000)), + (22, 0x3a878e, e(0x47700000)), + (22, 0x3a878f, e(0x47800000)), + (22, 0x3a8790, e(0x47900000)), + (22, 0x3a8791, e(0x47a00000)), + (22, 0x3a8792, e(0x47b00000)), + (22, 0x3a8793, e(0x47c00000)), + (22, 0x3a8794, e(0x47d00000)), + (22, 0x3a8795, e(0x47e00000)), + (22, 0x3a8796, e(0x47f00000)), + (22, 0x3a8797, e(0x48000000)), + (22, 0x3a8798, e(0x48100000)), + (22, 0x3a8799, e(0x48200000)), + (22, 0x3a879a, e(0x48300000)), + (22, 0x3a879b, e(0x48400000)), + (22, 0x3a879c, e(0x48500000)), + (22, 0x3a879d, e(0x48600000)), + (22, 0x3a879e, e(0x48700000)), + (22, 0x3a879f, e(0x48800000)), + (22, 0x3a87a0, e(0x48900000)), + (22, 0x3a87a1, e(0x48a00000)), + (22, 0x3a87a2, e(0x48b00000)), + (22, 0x3a87a3, e(0x48c00000)), + (22, 0x3a87a4, e(0x48d00000)), + (22, 0x3a87a5, e(0x48e00000)), + (22, 0x3a87a6, e(0x48f00000)), + (22, 0x3a87a7, e(0x49000000)), + (22, 0x3a87a8, e(0x49100000)), + (22, 0x3a87a9, e(0x49200000)), + (22, 0x3a87aa, e(0x49300000)), + (22, 0x3a87ab, e(0x49400000)), + (22, 0x3a87ac, e(0x49500000)), + (22, 0x3a87ad, e(0x49600000)), + (22, 0x3a87ae, e(0x49700000)), + (22, 0x3a87af, e(0x49800000)), + (22, 0x3a87b0, e(0x49900000)), + (22, 0x3a87b1, e(0x49a00000)), + (22, 0x3a87b2, e(0x49b00000)), + (22, 0x3a87b3, e(0x49c00000)), + (22, 0x3a87b4, e(0x49d00000)), + (22, 0x3a87b5, e(0x49e00000)), + (22, 0x3a87b6, e(0x49f00000)), + (22, 0x3a87b7, e(0x4a000000)), + (22, 0x3a87b8, e(0x4a100000)), + (22, 0x3a87b9, e(0x4a200000)), + (22, 0x3a87ba, e(0x4a300000)), + (22, 0x3a87bb, e(0x4a400000)), + (22, 0x3a87bc, e(0x4a500000)), + (22, 0x3a87bd, e(0x4a600000)), + (22, 0x3a87be, e(0x4a700000)), + (22, 0x3a87bf, e(0x4a800000)), + (22, 0x3a87c0, e(0x4a900000)), + (22, 0x3a87c1, e(0x4aa00000)), + (22, 0x3a87c2, e(0x4ab00000)), + (22, 0x3a87c3, e(0x4ac00000)), + (22, 0x3a87c4, e(0x4ad00000)), + (22, 0x3a87c5, e(0x4ae00000)), + (22, 0x3a87c6, e(0x4af00000)), + (22, 0x3a87c7, e(0x4b000000)), + (22, 0x3a87c8, e(0x4b100000)), + (22, 0x3a87c9, e(0x4b200000)), + (22, 0x3a87ca, e(0x4b300000)), + (22, 0x3a87cb, e(0x4b400000)), + (22, 0x3a87cc, e(0x4b500000)), + (22, 0x3a87cd, e(0x4b600000)), + (22, 0x3a87ce, e(0x4b700000)), + (22, 0x3a87cf, e(0x4b800000)), + (22, 0x3a87d0, e(0x4b900000)), + (22, 0x3a87d1, e(0x4ba00000)), + (22, 0x3a87d2, e(0x4bb00000)), + (22, 0x3a87d3, e(0x4bc00000)), + (22, 0x3a87d4, e(0x4bd00000)), + (22, 0x3a87d5, e(0x4be00000)), + (22, 0x3a87d6, e(0x4bf00000)), + (22, 0x3a87d7, e(0x4c000000)), + (22, 0x3a87d8, e(0x4c100000)), + (22, 0x3a87d9, e(0x4c200000)), + (22, 0x3a87da, e(0x4c300000)), + (22, 0x3a87db, e(0x4c400000)), + (22, 0x3a87dc, e(0x4c500000)), + (22, 0x3a87dd, e(0x4c600000)), + (22, 0x3a87de, e(0x4c700000)), + (22, 0x3a87df, e(0x4c800000)), + (22, 0x3a87e0, e(0x4c900000)), + (22, 0x3a87e1, e(0x4ca00000)), + (22, 0x3a87e2, e(0x4cb00000)), + (22, 0x3a87e3, e(0x4cc00000)), + (22, 0x3a87e4, e(0x4cd00000)), + (22, 0x3a87e5, e(0x4ce00000)), + (22, 0x3a87e6, e(0x4cf00000)), + (22, 0x3a87e7, e(0x4d000000)), + (22, 0x3a87e8, e(0x4d100000)), + (22, 0x3a87e9, e(0x4d200000)), + (22, 0x3a87ea, e(0x4d300000)), + (22, 0x3a87eb, e(0x4d400000)), + (22, 0x3a87ec, e(0x4d500000)), + (22, 0x3a87ed, e(0x4d600000)), + (22, 0x3a87ee, e(0x4d700000)), + (22, 0x3a87ef, e(0x4d800000)), + (22, 0x3a87f0, e(0x4d900000)), + (22, 0x3a87f1, e(0x4da00000)), + (22, 0x3a87f2, e(0x4db00000)), + (22, 0x3a87f3, e(0x4dc00000)), + (22, 0x3a87f4, e(0x4dd00000)), + (22, 0x3a87f5, e(0x4de00000)), + (22, 0x3a87f6, e(0x4df00000)), + (22, 0x3a87f7, e(0x4e000000)), + (22, 0x3a87f8, e(0x4e100000)), + (22, 0x3a87f9, e(0x4e200000)), + (22, 0x3a87fa, e(0x4e300000)), + (22, 0x3a87fb, e(0x4e400000)), + (22, 0x3a87fc, e(0x4e500000)), + (22, 0x3a87fd, e(0x4e600000)), + (22, 0x3a87fe, e(0x4e700000)), + (22, 0x3a87ff, e(0x4e800000)), + (22, 0x3a9000, e(0x4e900000)), + (22, 0x3a9001, e(0x4ea00000)), + (22, 0x3a9002, e(0x4eb00000)), + (22, 0x3a9003, e(0x4ec00000)), + (22, 0x3a9004, e(0x4ed00000)), + (22, 0x3a9005, e(0x4ee00000)), + (22, 0x3a9006, e(0x4ef00000)), + (22, 0x3a9007, e(0x4f000000)), + (22, 0x3a9008, e(0x4f100000)), + (22, 0x3a9009, e(0x4f200000)), + (22, 0x3a900a, e(0x4f300000)), + (22, 0x3a900b, e(0x4f400000)), + (22, 0x3a900c, e(0x4f500000)), + (22, 0x3a900d, e(0x4f600000)), + (22, 0x3a900e, e(0x4f700000)), + (22, 0x3a900f, e(0x4f800000)), + (22, 0x3a9010, e(0x4f900000)), + (22, 0x3a9011, e(0x4fa00000)), + (22, 0x3a9012, e(0x4fb00000)), + (22, 0x3a9013, e(0x4fc00000)), + (22, 0x3a9014, e(0x4fd00000)), + (22, 0x3a9015, e(0x4fe00000)), + (22, 0x3a9016, e(0x4ff00000)), + (22, 0x3a9017, e(0x50000000)), + (22, 0x3a9018, e(0x50100000)), + (22, 0x3a9019, e(0x50200000)), + (22, 0x3a901a, e(0x50300000)), + (22, 0x3a901b, e(0x50400000)), + (22, 0x3a901c, e(0x50500000)), + (22, 0x3a901d, e(0x50600000)), + (22, 0x3a901e, e(0x50700000)), + (22, 0x3a901f, e(0x50800000)), + (22, 0x3a9020, e(0x50900000)), + (22, 0x3a9021, e(0x50a00000)), + (22, 0x3a9022, e(0x50b00000)), + (22, 0x3a9023, e(0x50c00000)), + (22, 0x3a9024, e(0x50d00000)), + (22, 0x3a9025, e(0x50e00000)), + (22, 0x3a9026, e(0x50f00000)), + (22, 0x3a9027, e(0x51000000)), + (22, 0x3a9028, e(0x51100000)), + (22, 0x3a9029, e(0x51200000)), + (22, 0x3a902a, e(0x51300000)), + (22, 0x3a902b, e(0x51400000)), + (22, 0x3a902c, e(0x51500000)), + (22, 0x3a902d, e(0x51600000)), + (22, 0x3a902e, e(0x51700000)), + (22, 0x3a902f, e(0x51800000)), + (22, 0x3a9030, e(0x51900000)), + (22, 0x3a9031, e(0x51a00000)), + (22, 0x3a9032, e(0x51b00000)), + (22, 0x3a9033, e(0x51c00000)), + (22, 0x3a9034, e(0x51d00000)), + (22, 0x3a9035, e(0x51e00000)), + (22, 0x3a9036, e(0x51f00000)), + (22, 0x3a9037, e(0x52000000)), + (22, 0x3a9038, e(0x52100000)), + (22, 0x3a9039, e(0x52200000)), + (22, 0x3a903a, e(0x52300000)), + (22, 0x3a903b, e(0x52400000)), + (22, 0x3a903c, e(0x52500000)), + (22, 0x3a903d, e(0x52600000)), + (22, 0x3a903e, e(0x52700000)), + (22, 0x3a903f, e(0x52800000)), + (22, 0x3a9040, e(0x52900000)), + (22, 0x3a9041, e(0x52a00000)), + (22, 0x3a9042, e(0x52b00000)), + (22, 0x3a9043, e(0x52c00000)), + (22, 0x3a9044, e(0x52d00000)), + (22, 0x3a9045, e(0x52e00000)), + (22, 0x3a9046, e(0x52f00000)), + (22, 0x3a9047, e(0x53000000)), + (22, 0x3a9048, e(0x53100000)), + (22, 0x3a9049, e(0x53200000)), + (22, 0x3a904a, e(0x53300000)), + (22, 0x3a904b, e(0x53400000)), + (22, 0x3a904c, e(0x53500000)), + (22, 0x3a904d, e(0x53600000)), + (22, 0x3a904e, e(0x53700000)), + (22, 0x3a904f, e(0x53800000)), + (22, 0x3a9050, e(0x53900000)), + (22, 0x3a9051, e(0x53a00000)), + (22, 0x3a9052, e(0x53b00000)), + (22, 0x3a9053, e(0x53c00000)), + (22, 0x3a9054, e(0x53d00000)), + (22, 0x3a9055, e(0x53e00000)), + (22, 0x3a9056, e(0x53f00000)), + (22, 0x3a9057, e(0x54000000)), + (22, 0x3a9058, e(0x54100000)), + (22, 0x3a9059, e(0x54200000)), + (22, 0x3a905a, e(0x54300000)), + (22, 0x3a905b, e(0x54400000)), + (22, 0x3a905c, e(0x54500000)), + (22, 0x3a905d, e(0x54600000)), + (22, 0x3a905e, e(0x54700000)), + (22, 0x3a905f, e(0x54800000)), + (22, 0x3a9060, e(0x54900000)), + (22, 0x3a9061, e(0x54a00000)), + (22, 0x3a9062, e(0x54b00000)), + (22, 0x3a9063, e(0x54c00000)), + (22, 0x3a9064, e(0x54d00000)), + (22, 0x3a9065, e(0x54e00000)), + (22, 0x3a9066, e(0x54f00000)), + (22, 0x3a9067, e(0x55000000)), + (22, 0x3a9068, e(0x55100000)), + (22, 0x3a9069, e(0x55200000)), + (22, 0x3a906a, e(0x55300000)), + (22, 0x3a906b, e(0x55400000)), + (22, 0x3a906c, e(0x55500000)), + (22, 0x3a906d, e(0x55600000)), + (22, 0x3a906e, e(0x55700000)), + (22, 0x3a906f, e(0x55800000)), + (22, 0x3a9070, e(0x55900000)), + (22, 0x3a9071, e(0x55a00000)), + (22, 0x3a9072, e(0x55b00000)), + (22, 0x3a9073, e(0x55c00000)), + (22, 0x3a9074, e(0x55d00000)), + (22, 0x3a9075, e(0x55e00000)), + (22, 0x3a9076, e(0x55f00000)), + (22, 0x3a9077, e(0x56000000)), + (22, 0x3a9078, e(0x56100000)), + (22, 0x3a9079, e(0x56200000)), + (22, 0x3a907a, e(0x56300000)), + (22, 0x3a907b, e(0x56400000)), + (22, 0x3a907c, e(0x56500000)), + (22, 0x3a907d, e(0x56600000)), + (22, 0x3a907e, e(0x56700000)), + (22, 0x3a907f, e(0x56800000)), + (22, 0x3a9080, e(0x56900000)), + (22, 0x3a9081, e(0x56a00000)), + (22, 0x3a9082, e(0x56b00000)), + (22, 0x3a9083, e(0x56c00000)), + (22, 0x3a9084, e(0x56d00000)), + (22, 0x3a9085, e(0x56e00000)), + (22, 0x3a9086, e(0x56f00000)), + (22, 0x3a9087, e(0x57000000)), + (22, 0x3a9088, e(0x57100000)), + (22, 0x3a9089, e(0x57200000)), + (22, 0x3a908a, e(0x57300000)), + (22, 0x3a908b, e(0x57400000)), + (22, 0x3a908c, e(0x57500000)), + (22, 0x3a908d, e(0x57600000)), + (22, 0x3a908e, e(0x57700000)), + (22, 0x3a908f, e(0x57800000)), + (22, 0x3a9090, e(0x57900000)), + (22, 0x3a9091, e(0x57a00000)), + (22, 0x3a9092, e(0x57b00000)), + (22, 0x3a9093, e(0x57c00000)), + (22, 0x3a9094, e(0x57d00000)), + (22, 0x3a9095, e(0x57e00000)), + (22, 0x3a9096, e(0x57f00000)), + (22, 0x3a9097, e(0x58000000)), + (22, 0x3a9098, e(0x58100000)), + (22, 0x3a9099, e(0x58200000)), + (22, 0x3a909a, e(0x58300000)), + (22, 0x3a909b, e(0x58400000)), + (22, 0x3a909c, e(0x58500000)), + (22, 0x3a909d, e(0x58600000)), + (22, 0x3a909e, e(0x58700000)), + (22, 0x3a909f, e(0x58800000)), + (22, 0x3a90a0, e(0x58900000)), + (22, 0x3a90a1, e(0x58a00000)), + (22, 0x3a90a2, e(0x58b00000)), + (22, 0x3a90a3, e(0x58c00000)), + (22, 0x3a90a4, e(0x58d00000)), + (22, 0x3a90a5, e(0x58e00000)), + (22, 0x3a90a6, e(0x58f00000)), + (22, 0x3a90a7, e(0x59000000)), + (22, 0x3a90a8, e(0x59100000)), + (22, 0x3a90a9, e(0x59200000)), + (22, 0x3a90aa, e(0x59300000)), + (22, 0x3a90ab, e(0x59400000)), + (22, 0x3a90ac, e(0x59500000)), + (22, 0x3a90ad, e(0x59600000)), + (22, 0x3a90ae, e(0x59700000)), + (22, 0x3a90af, e(0x59800000)), + (22, 0x3a90b0, e(0x59900000)), + (22, 0x3a90b1, e(0x59a00000)), + (22, 0x3a90b2, e(0x59b00000)), + (22, 0x3a90b3, e(0x59c00000)), + (22, 0x3a90b4, e(0x59d00000)), + (22, 0x3a90b5, e(0x59e00000)), + (22, 0x3a90b6, e(0x59f00000)), + (22, 0x3a90b7, e(0x5a000000)), + (22, 0x3a90b8, e(0x5a100000)), + (22, 0x3a90b9, e(0x5a200000)), + (22, 0x3a90ba, e(0x5a300000)), + (22, 0x3a90bb, e(0x5a400000)), + (22, 0x3a90bc, e(0x5a500000)), + (22, 0x3a90bd, e(0x5a600000)), + (22, 0x3a90be, e(0x5a700000)), + (22, 0x3a90bf, e(0x5a800000)), + (22, 0x3a90c0, e(0x5a900000)), + (22, 0x3a90c1, e(0x5aa00000)), + (22, 0x3a90c2, e(0x5ab00000)), + (22, 0x3a90c3, e(0x5ac00000)), + (22, 0x3a90c4, e(0x5ad00000)), + (22, 0x3a90c5, e(0x5ae00000)), + (22, 0x3a90c6, e(0x5af00000)), + (22, 0x3a90c7, e(0x5b000000)), + (22, 0x3a90c8, e(0x5b100000)), + (22, 0x3a90c9, e(0x5b200000)), + (22, 0x3a90ca, e(0x5b300000)), + (22, 0x3a90cb, e(0x5b400000)), + (22, 0x3a90cc, e(0x5b500000)), + (22, 0x3a90cd, e(0x5b600000)), + (22, 0x3a90ce, e(0x5b700000)), + (22, 0x3a90cf, e(0x5b800000)), + (22, 0x3a90d0, e(0x5b900000)), + (22, 0x3a90d1, e(0x5ba00000)), + (22, 0x3a90d2, e(0x5bb00000)), + (22, 0x3a90d3, e(0x5bc00000)), + (22, 0x3a90d4, e(0x5bd00000)), + (22, 0x3a90d5, e(0x5be00000)), + (22, 0x3a90d6, e(0x5bf00000)), + (22, 0x3a90d7, e(0x5c000000)), + (22, 0x3a90d8, e(0x5c100000)), + (22, 0x3a90d9, e(0x5c200000)), + (22, 0x3a90da, e(0x5c300000)), + (22, 0x3a90db, e(0x5c400000)), + (22, 0x3a90dc, e(0x5c500000)), + (22, 0x3a90dd, e(0x5c600000)), + (22, 0x3a90de, e(0x5c700000)), + (22, 0x3a90df, e(0x5c800000)), + (22, 0x3a90e0, e(0x5c900000)), + (22, 0x3a90e1, e(0x5ca00000)), + (22, 0x3a90e2, e(0x5cb00000)), + (22, 0x3a90e3, e(0x5cc00000)), + (22, 0x3a90e4, e(0x5cd00000)), + (22, 0x3a90e5, e(0x5ce00000)), + (22, 0x3a90e6, e(0x5cf00000)), + (22, 0x3a90e7, e(0x5d000000)), + (22, 0x3a90e8, e(0x5d100000)), + (22, 0x3a90e9, e(0x5d200000)), + (22, 0x3a90ea, e(0x5d300000)), + (22, 0x3a90eb, e(0x5d400000)), + (22, 0x3a90ec, e(0x5d500000)), + (22, 0x3a90ed, e(0x5d600000)), + (22, 0x3a90ee, e(0x5d700000)), + (22, 0x3a90ef, e(0x5d800000)), + (22, 0x3a90f0, e(0x5d900000)), + (22, 0x3a90f1, e(0x5da00000)), + (22, 0x3a90f2, e(0x5db00000)), + (22, 0x3a90f3, e(0x5dc00000)), + (22, 0x3a90f4, e(0x5dd00000)), + (22, 0x3a90f5, e(0x5de00000)), + (22, 0x3a90f6, e(0x5df00000)), + (22, 0x3a90f7, e(0x5e000000)), + (22, 0x3a90f8, e(0x5e100000)), + (22, 0x3a90f9, e(0x5e200000)), + (22, 0x3a90fa, e(0x5e300000)), + (22, 0x3a90fb, e(0x5e400000)), + (22, 0x3a90fc, e(0x5e500000)), + (22, 0x3a90fd, e(0x5e600000)), + (22, 0x3a90fe, e(0x5e700000)), + (22, 0x3a90ff, e(0x5e800000)), + (22, 0x3a9100, e(0x5e900000)), + (22, 0x3a9101, e(0x5ea00000)), + (22, 0x3a9102, e(0x5eb00000)), + (22, 0x3a9103, e(0x5ec00000)), + (22, 0x3a9104, e(0x5ed00000)), + (22, 0x3a9105, e(0x5ee00000)), + (22, 0x3a9106, e(0x5ef00000)), + (22, 0x3a9107, e(0x5f000000)), + (22, 0x3a9108, e(0x5f100000)), + (22, 0x3a9109, e(0x5f200000)), + (22, 0x3a910a, e(0x5f300000)), + (22, 0x3a910b, e(0x5f400000)), + (22, 0x3a910c, e(0x5f500000)), + (22, 0x3a910d, e(0x5f600000)), + (22, 0x3a910e, e(0x5f700000)), + (22, 0x3a910f, e(0x5f800000)), + (22, 0x3a9110, e(0x5f900000)), + (22, 0x3a9111, e(0x5fa00000)), + (22, 0x3a9112, e(0x5fb00000)), + (22, 0x3a9113, e(0x5fc00000)), + (22, 0x3a9114, e(0x5fd00000)), + (22, 0x3a9115, e(0x5fe00000)), + (22, 0x3a9116, e(0x5ff00000)), + (22, 0x3a9117, e(0x60000000)), + (22, 0x3a9118, e(0x60100000)), + (22, 0x3a9119, e(0x60200000)), + (22, 0x3a911a, e(0x60300000)), + (22, 0x3a911b, e(0x60400000)), + (22, 0x3a911c, e(0x60500000)), + (22, 0x3a911d, e(0x60600000)), + (22, 0x3a911e, e(0x60700000)), + (22, 0x3a911f, e(0x60800000)), + (22, 0x3a9120, e(0x60900000)), + (22, 0x3a9121, e(0x60a00000)), + (22, 0x3a9122, e(0x60b00000)), + (22, 0x3a9123, e(0x60c00000)), + (22, 0x3a9124, e(0x60d00000)), + (22, 0x3a9125, e(0x60e00000)), + (22, 0x3a9126, e(0x60f00000)), + (22, 0x3a9127, e(0x61000000)), + (22, 0x3a9128, e(0x61100000)), + (22, 0x3a9129, e(0x61200000)), + (22, 0x3a912a, e(0x61300000)), + (22, 0x3a912b, e(0x61400000)), + (22, 0x3a912c, e(0x61500000)), + (22, 0x3a912d, e(0x61600000)), + (22, 0x3a912e, e(0x61700000)), + (22, 0x3a912f, e(0x61800000)), + (22, 0x3a9130, e(0x61900000)), + (22, 0x3a9131, e(0x61a00000)), + (22, 0x3a9132, e(0x61b00000)), + (22, 0x3a9133, e(0x61c00000)), + (22, 0x3a9134, e(0x61d00000)), + (22, 0x3a9135, e(0x61e00000)), + (22, 0x3a9136, e(0x61f00000)), + (22, 0x3a9137, e(0x62000000)), + (22, 0x3a9138, e(0x62100000)), + (22, 0x3a9139, e(0x62200000)), + (22, 0x3a913a, e(0x62300000)), + (22, 0x3a913b, e(0x62400000)), + (22, 0x3a913c, e(0x62500000)), + (22, 0x3a913d, e(0x62600000)), + (22, 0x3a913e, e(0x62700000)), + (22, 0x3a913f, e(0x62800000)), + (22, 0x3a9140, e(0x62900000)), + (22, 0x3a9141, e(0x62a00000)), + (22, 0x3a9142, e(0x62b00000)), + (22, 0x3a9143, e(0x62c00000)), + (22, 0x3a9144, e(0x62d00000)), + (22, 0x3a9145, e(0x62e00000)), + (22, 0x3a9146, e(0x62f00000)), + (22, 0x3a9147, e(0x63000000)), + (22, 0x3a9148, e(0x63100000)), + (22, 0x3a9149, e(0x63200000)), + (22, 0x3a914a, e(0x63300000)), + (22, 0x3a914b, e(0x63400000)), + (22, 0x3a914c, e(0x63500000)), + (22, 0x3a914d, e(0x63600000)), + (22, 0x3a914e, e(0x63700000)), + (22, 0x3a914f, e(0x63800000)), + (22, 0x3a9150, e(0x63900000)), + (22, 0x3a9151, e(0x63a00000)), + (22, 0x3a9152, e(0x63b00000)), + (22, 0x3a9153, e(0x63c00000)), + (22, 0x3a9154, e(0x63d00000)), + (22, 0x3a9155, e(0x63e00000)), + (22, 0x3a9156, e(0x63f00000)), + (22, 0x3a9157, e(0x64000000)), + (22, 0x3a9158, e(0x64100000)), + (22, 0x3a9159, e(0x64200000)), + (22, 0x3a915a, e(0x64300000)), + (22, 0x3a915b, e(0x64400000)), + (22, 0x3a915c, e(0x64500000)), + (22, 0x3a915d, e(0x64600000)), + (22, 0x3a915e, e(0x64700000)), + (22, 0x3a915f, e(0x64800000)), + (22, 0x3a9160, e(0x64900000)), + (22, 0x3a9161, e(0x64a00000)), + (22, 0x3a9162, e(0x64b00000)), + (22, 0x3a9163, e(0x64c00000)), + (22, 0x3a9164, e(0x64d00000)), + (22, 0x3a9165, e(0x64e00000)), + (22, 0x3a9166, e(0x64f00000)), + (22, 0x3a9167, e(0x65000000)), + (22, 0x3a9168, e(0x65100000)), + (22, 0x3a9169, e(0x65200000)), + (22, 0x3a916a, e(0x65300000)), + (22, 0x3a916b, e(0x65400000)), + (22, 0x3a916c, e(0x65500000)), + (22, 0x3a916d, e(0x65600000)), + (22, 0x3a916e, e(0x65700000)), + (22, 0x3a916f, e(0x65800000)), + (22, 0x3a9170, e(0x65900000)), + (22, 0x3a9171, e(0x65a00000)), + (22, 0x3a9172, e(0x65b00000)), + (22, 0x3a9173, e(0x65c00000)), + (22, 0x3a9174, e(0x65d00000)), + (22, 0x3a9175, e(0x65e00000)), + (22, 0x3a9176, e(0x65f00000)), + (22, 0x3a9177, e(0x66000000)), + (22, 0x3a9178, e(0x66100000)), + (22, 0x3a9179, e(0x66200000)), + (22, 0x3a917a, e(0x66300000)), + (22, 0x3a917b, e(0x66400000)), + (22, 0x3a917c, e(0x66500000)), + (22, 0x3a917d, e(0x66600000)), + (22, 0x3a917e, e(0x66700000)), + (22, 0x3a917f, e(0x66800000)), + (22, 0x3a9180, e(0x66900000)), + (22, 0x3a9181, e(0x66a00000)), + (22, 0x3a9182, e(0x66b00000)), + (22, 0x3a9183, e(0x66c00000)), + (22, 0x3a9184, e(0x66d00000)), + (22, 0x3a9185, e(0x66e00000)), + (22, 0x3a9186, e(0x66f00000)), + (22, 0x3a9187, e(0x67000000)), + (22, 0x3a9188, e(0x67100000)), + (22, 0x3a9189, e(0x67200000)), + (22, 0x3a918a, e(0x67300000)), + (22, 0x3a918b, e(0x67400000)), + (22, 0x3a918c, e(0x67500000)), + (22, 0x3a918d, e(0x67600000)), + (22, 0x3a918e, e(0x67700000)), + (22, 0x3a918f, e(0x67800000)), + (22, 0x3a9190, e(0x67900000)), + (22, 0x3a9191, e(0x67a00000)), + (22, 0x3a9192, e(0x67b00000)), + (22, 0x3a9193, e(0x67c00000)), + (22, 0x3a9194, e(0x67d00000)), + (22, 0x3a9195, e(0x67e00000)), + (22, 0x3a9196, e(0x67f00000)), + (22, 0x3a9197, e(0x68000000)), + (22, 0x3a9198, e(0x68100000)), + (22, 0x3a9199, e(0x68200000)), + (22, 0x3a919a, e(0x68300000)), + (22, 0x3a919b, e(0x68400000)), + (22, 0x3a919c, e(0x68500000)), + (22, 0x3a919d, e(0x68600000)), + (22, 0x3a919e, e(0x68700000)), + (22, 0x3a919f, e(0x68800000)), + (22, 0x3a91a0, e(0x68900000)), + (22, 0x3a91a1, e(0x68a00000)), + (22, 0x3a91a2, e(0x68b00000)), + (22, 0x3a91a3, e(0x68c00000)), + (22, 0x3a91a4, e(0x68d00000)), + (22, 0x3a91a5, e(0x68e00000)), + (22, 0x3a91a6, e(0x68f00000)), + (22, 0x3a91a7, e(0x69000000)), + (22, 0x3a91a8, e(0x69100000)), + (22, 0x3a91a9, e(0x69200000)), + (22, 0x3a91aa, e(0x69300000)), + (22, 0x3a91ab, e(0x69400000)), + (22, 0x3a91ac, e(0x69500000)), + (22, 0x3a91ad, e(0x69600000)), + (22, 0x3a91ae, e(0x69700000)), + (22, 0x3a91af, e(0x69800000)), + (22, 0x3a91b0, e(0x69900000)), + (22, 0x3a91b1, e(0x69a00000)), + (22, 0x3a91b2, e(0x69b00000)), + (22, 0x3a91b3, e(0x69c00000)), + (22, 0x3a91b4, e(0x69d00000)), + (22, 0x3a91b5, e(0x69e00000)), + (22, 0x3a91b6, e(0x69f00000)), + (22, 0x3a91b7, e(0x6a000000)), + (22, 0x3a91b8, e(0x6a100000)), + (22, 0x3a91b9, e(0x6a200000)), + (22, 0x3a91ba, e(0x6a300000)), + (22, 0x3a91bb, e(0x6a400000)), + (22, 0x3a91bc, e(0x6a500000)), + (22, 0x3a91bd, e(0x6a600000)), + (22, 0x3a91be, e(0x6a700000)), + (22, 0x3a91bf, e(0x6a800000)), + (22, 0x3a91c0, e(0x6a900000)), + (22, 0x3a91c1, e(0x6aa00000)), + (22, 0x3a91c2, e(0x6ab00000)), + (22, 0x3a91c3, e(0x6ac00000)), + (22, 0x3a91c4, e(0x6ad00000)), + (22, 0x3a91c5, e(0x6ae00000)), + (22, 0x3a91c6, e(0x6af00000)), + (22, 0x3a91c7, e(0x6b000000)), + (22, 0x3a91c8, e(0x6b100000)), + (22, 0x3a91c9, e(0x6b200000)), + (22, 0x3a91ca, e(0x6b300000)), + (22, 0x3a91cb, e(0x6b400000)), + (22, 0x3a91cc, e(0x6b500000)), + (22, 0x3a91cd, e(0x6b600000)), + (22, 0x3a91ce, e(0x6b700000)), + (22, 0x3a91cf, e(0x6b800000)), + (22, 0x3a91d0, e(0x6b900000)), + (22, 0x3a91d1, e(0x6ba00000)), + (22, 0x3a91d2, e(0x6bb00000)), + (22, 0x3a91d3, e(0x6bc00000)), + (22, 0x3a91d4, e(0x6bd00000)), + (22, 0x3a91d5, e(0x6be00000)), + (22, 0x3a91d6, e(0x6bf00000)), + (22, 0x3a91d7, e(0x6c000000)), + (22, 0x3a91d8, e(0x6c100000)), + (22, 0x3a91d9, e(0x6c200000)), + (22, 0x3a91da, e(0x6c300000)), + (22, 0x3a91db, e(0x6c400000)), + (22, 0x3a91dc, e(0x6c500000)), + (22, 0x3a91dd, e(0x6c600000)), + (22, 0x3a91de, e(0x6c700000)), + (22, 0x3a91df, e(0x6c800000)), + (22, 0x3a91e0, e(0x6c900000)), + (22, 0x3a91e1, e(0x6ca00000)), + (22, 0x3a91e2, e(0x6cb00000)), + (22, 0x3a91e3, e(0x6cc00000)), + (22, 0x3a91e4, e(0x6cd00000)), + (22, 0x3a91e5, e(0x6ce00000)), + (22, 0x3a91e6, e(0x6cf00000)), + (22, 0x3a91e7, e(0x6d000000)), + (22, 0x3a91e8, e(0x6d100000)), + (22, 0x3a91e9, e(0x6d200000)), + (22, 0x3a91ea, e(0x6d300000)), + (22, 0x3a91eb, e(0x6d400000)), + (22, 0x3a91ec, e(0x6d500000)), + (22, 0x3a91ed, e(0x6d600000)), + (22, 0x3a91ee, e(0x6d700000)), + (22, 0x3a91ef, e(0x6d800000)), + (22, 0x3a91f0, e(0x6d900000)), + (22, 0x3a91f1, e(0x6da00000)), + (22, 0x3a91f2, e(0x6db00000)), + (22, 0x3a91f3, e(0x6dc00000)), + (22, 0x3a91f4, e(0x6dd00000)), + (22, 0x3a91f5, e(0x6de00000)), + (22, 0x3a91f6, e(0x6df00000)), + (22, 0x3a91f7, e(0x6e000000)), + (22, 0x3a91f8, e(0x6e100000)), + (22, 0x3a91f9, e(0x6e200000)), + (22, 0x3a91fa, e(0x6e300000)), + (22, 0x3a91fb, e(0x6e400000)), + (22, 0x3a91fc, e(0x6e500000)), + (22, 0x3a91fd, e(0x6e600000)), + (22, 0x3a91fe, e(0x6e700000)), + (22, 0x3a91ff, e(0x6e800000)), + (22, 0x3a9200, e(0x6e900000)), + (22, 0x3a9201, e(0x6ea00000)), + (22, 0x3a9202, e(0x6eb00000)), + (22, 0x3a9203, e(0x6ec00000)), + (22, 0x3a9204, e(0x6ed00000)), + (22, 0x3a9205, e(0x6ee00000)), + (22, 0x3a9206, e(0x6ef00000)), + (22, 0x3a9207, e(0x6f000000)), + (22, 0x3a9208, e(0x6f100000)), + (22, 0x3a9209, e(0x6f200000)), + (22, 0x3a920a, e(0x6f300000)), + (22, 0x3a920b, e(0x6f400000)), + (22, 0x3a920c, e(0x6f500000)), + (22, 0x3a920d, e(0x6f600000)), + (22, 0x3a920e, e(0x6f700000)), + (22, 0x3a920f, e(0x6f800000)), + (22, 0x3a9210, e(0x6f900000)), + (22, 0x3a9211, e(0x6fa00000)), + (22, 0x3a9212, e(0x6fb00000)), + (22, 0x3a9213, e(0x6fc00000)), + (22, 0x3a9214, e(0x6fd00000)), + (22, 0x3a9215, e(0x6fe00000)), + (22, 0x3a9216, e(0x6ff00000)), + (22, 0x3a9217, e(0x70000000)), + (22, 0x3a9218, e(0x70100000)), + (22, 0x3a9219, e(0x70200000)), + (22, 0x3a921a, e(0x70300000)), + (22, 0x3a921b, e(0x70400000)), + (22, 0x3a921c, e(0x70500000)), + (22, 0x3a921d, e(0x70600000)), + (22, 0x3a921e, e(0x70700000)), + (22, 0x3a921f, e(0x70800000)), + (22, 0x3a9220, e(0x70900000)), + (22, 0x3a9221, e(0x70a00000)), + (22, 0x3a9222, e(0x70b00000)), + (22, 0x3a9223, e(0x70c00000)), + (22, 0x3a9224, e(0x70d00000)), + (22, 0x3a9225, e(0x70e00000)), + (22, 0x3a9226, e(0x70f00000)), + (22, 0x3a9227, e(0x71000000)), + (22, 0x3a9228, e(0x71100000)), + (22, 0x3a9229, e(0x71200000)), + (22, 0x3a922a, e(0x71300000)), + (22, 0x3a922b, e(0x71400000)), + (22, 0x3a922c, e(0x71500000)), + (22, 0x3a922d, e(0x71600000)), + (22, 0x3a922e, e(0x71700000)), + (22, 0x3a922f, e(0x71800000)), + (22, 0x3a9230, e(0x71900000)), + (22, 0x3a9231, e(0x71a00000)), + (22, 0x3a9232, e(0x71b00000)), + (22, 0x3a9233, e(0x71c00000)), + (22, 0x3a9234, e(0x71d00000)), + (22, 0x3a9235, e(0x71e00000)), + (22, 0x3a9236, e(0x71f00000)), + (22, 0x3a9237, e(0x72000000)), + (22, 0x3a9238, e(0x72100000)), + (22, 0x3a9239, e(0x72200000)), + (22, 0x3a923a, e(0x72300000)), + (22, 0x3a923b, e(0x72400000)), + (22, 0x3a923c, e(0x72500000)), + (22, 0x3a923d, e(0x72600000)), + (22, 0x3a923e, e(0x72700000)), + (22, 0x3a923f, e(0x72800000)), + (22, 0x3a9240, e(0x72900000)), + (22, 0x3a9241, e(0x72a00000)), + (22, 0x3a9242, e(0x72b00000)), + (22, 0x3a9243, e(0x72c00000)), + (22, 0x3a9244, e(0x72d00000)), + (22, 0x3a9245, e(0x72e00000)), + (22, 0x3a9246, e(0x72f00000)), + (22, 0x3a9247, e(0x73000000)), + (22, 0x3a9248, e(0x73100000)), + (22, 0x3a9249, e(0x73200000)), + (22, 0x3a924a, e(0x73300000)), + (22, 0x3a924b, e(0x73400000)), + (22, 0x3a924c, e(0x73500000)), + (22, 0x3a924d, e(0x73600000)), + (22, 0x3a924e, e(0x73700000)), + (22, 0x3a924f, e(0x73800000)), + (22, 0x3a9250, e(0x73900000)), + (22, 0x3a9251, e(0x73a00000)), + (22, 0x3a9252, e(0x73b00000)), + (22, 0x3a9253, e(0x73c00000)), + (22, 0x3a9254, e(0x73d00000)), + (22, 0x3a9255, e(0x73e00000)), + (22, 0x3a9256, e(0x73f00000)), + (22, 0x3a9257, e(0x74000000)), + (22, 0x3a9258, e(0x74100000)), + (22, 0x3a9259, e(0x74200000)), + (22, 0x3a925a, e(0x74300000)), + (22, 0x3a925b, e(0x74400000)), + (22, 0x3a925c, e(0x74500000)), + (22, 0x3a925d, e(0x74600000)), + (22, 0x3a925e, e(0x74700000)), + (22, 0x3a925f, e(0x74800000)), + (22, 0x3a9260, e(0x74900000)), + (22, 0x3a9261, e(0x74a00000)), + (22, 0x3a9262, e(0x74b00000)), + (22, 0x3a9263, e(0x74c00000)), + (22, 0x3a9264, e(0x74d00000)), + (22, 0x3a9265, e(0x74e00000)), + (22, 0x3a9266, e(0x74f00000)), + (22, 0x3a9267, e(0x75000000)), + (22, 0x3a9268, e(0x75100000)), + (22, 0x3a9269, e(0x75200000)), + (22, 0x3a926a, e(0x75300000)), + (22, 0x3a926b, e(0x75400000)), + (22, 0x3a926c, e(0x75500000)), + (22, 0x3a926d, e(0x75600000)), + (22, 0x3a926e, e(0x75700000)), + (22, 0x3a926f, e(0x75800000)), + (22, 0x3a9270, e(0x75900000)), + (22, 0x3a9271, e(0x75a00000)), + (22, 0x3a9272, e(0x75b00000)), + (22, 0x3a9273, e(0x75c00000)), + (22, 0x3a9274, e(0x75d00000)), + (22, 0x3a9275, e(0x75e00000)), + (22, 0x3a9276, e(0x75f00000)), + (22, 0x3a9277, e(0x76000000)), + (22, 0x3a9278, e(0x76100000)), + (22, 0x3a9279, e(0x76200000)), + (22, 0x3a927a, e(0x76300000)), + (22, 0x3a927b, e(0x76400000)), + (22, 0x3a927c, e(0x76500000)), + (22, 0x3a927d, e(0x76600000)), + (22, 0x3a927e, e(0x76700000)), + (22, 0x3a927f, e(0x76800000)), + (22, 0x3a9280, e(0x76900000)), + (22, 0x3a9281, e(0x76a00000)), + (22, 0x3a9282, e(0x76b00000)), + (22, 0x3a9283, e(0x76c00000)), + (22, 0x3a9284, e(0x76d00000)), + (22, 0x3a9285, e(0x76e00000)), + (22, 0x3a9286, e(0x76f00000)), + (22, 0x3a9287, e(0x77000000)), + (22, 0x3a9288, e(0x77100000)), + (22, 0x3a9289, e(0x77200000)), + (22, 0x3a928a, e(0x77300000)), + (22, 0x3a928b, e(0x77400000)), + (22, 0x3a928c, e(0x77500000)), + (22, 0x3a928d, e(0x77600000)), + (22, 0x3a928e, e(0x77700000)), + (22, 0x3a928f, e(0x77800000)), + (22, 0x3a9290, e(0x77900000)), + (22, 0x3a9291, e(0x77a00000)), + (22, 0x3a9292, e(0x77b00000)), + (22, 0x3a9293, e(0x77c00000)), + (22, 0x3a9294, e(0x77d00000)), + (22, 0x3a9295, e(0x77e00000)), + (22, 0x3a9296, e(0x77f00000)), + (22, 0x3a9297, e(0x78000000)), + (22, 0x3a9298, e(0x78100000)), + (22, 0x3a9299, e(0x78200000)), + (22, 0x3a929a, e(0x78300000)), + (22, 0x3a929b, e(0x78400000)), + (22, 0x3a929c, e(0x78500000)), + (22, 0x3a929d, e(0x78600000)), + (22, 0x3a929e, e(0x78700000)), + (22, 0x3a929f, e(0x78800000)), + (22, 0x3a92a0, e(0x78900000)), + (22, 0x3a92a1, e(0x78a00000)), + (22, 0x3a92a2, e(0x78b00000)), + (22, 0x3a92a3, e(0x78c00000)), + (22, 0x3a92a4, e(0x78d00000)), + (22, 0x3a92a5, e(0x78e00000)), + (22, 0x3a92a6, e(0x78f00000)), + (22, 0x3a92a7, e(0x79000000)), + (22, 0x3a92a8, e(0x79100000)), + (22, 0x3a92a9, e(0x79200000)), + (22, 0x3a92aa, e(0x79300000)), + (22, 0x3a92ab, e(0x79400000)), + (22, 0x3a92ac, e(0x79500000)), + (22, 0x3a92ad, e(0x79600000)), + (22, 0x3a92ae, e(0x79700000)), + (22, 0x3a92af, e(0x79800000)), + (22, 0x3a92b0, e(0x79900000)), + (22, 0x3a92b1, e(0x79a00000)), + (22, 0x3a92b2, e(0x79b00000)), + (22, 0x3a92b3, e(0x79c00000)), + (22, 0x3a92b4, e(0x79d00000)), + (22, 0x3a92b5, e(0x79e00000)), + (22, 0x3a92b6, e(0x79f00000)), + (22, 0x3a92b7, e(0x7a000000)), + (22, 0x3a92b8, e(0x7a100000)), + (22, 0x3a92b9, e(0x7a200000)), + (22, 0x3a92ba, e(0x7a300000)), + (22, 0x3a92bb, e(0x7a400000)), + (22, 0x3a92bc, e(0x7a500000)), + (22, 0x3a92bd, e(0x7a600000)), + (22, 0x3a92be, e(0x7a700000)), + (22, 0x3a92bf, e(0x7a800000)), + (22, 0x3a92c0, e(0x7a900000)), + (22, 0x3a92c1, e(0x7aa00000)), + (22, 0x3a92c2, e(0x7ab00000)), + (22, 0x3a92c3, e(0x7ac00000)), + (22, 0x3a92c4, e(0x7ad00000)), + (22, 0x3a92c5, e(0x7ae00000)), + (22, 0x3a92c6, e(0x7af00000)), + (22, 0x3a92c7, e(0x7b000000)), + (22, 0x3a92c8, e(0x7b100000)), + (22, 0x3a92c9, e(0x7b200000)), + (22, 0x3a92ca, e(0x7b300000)), + (22, 0x3a92cb, e(0x7b400000)), + (22, 0x3a92cc, e(0x7b500000)), + (22, 0x3a92cd, e(0x7b600000)), + (22, 0x3a92ce, e(0x7b700000)), + (22, 0x3a92cf, e(0x7b800000)), + (22, 0x3a92d0, e(0x7b900000)), + (22, 0x3a92d1, e(0x7ba00000)), + (22, 0x3a92d2, e(0x7bb00000)), + (22, 0x3a92d3, e(0x7bc00000)), + (22, 0x3a92d4, e(0x7bd00000)), + (22, 0x3a92d5, e(0x7be00000)), + (22, 0x3a92d6, e(0x7bf00000)), + (22, 0x3a92d7, e(0x7c000000)), + (22, 0x3a92d8, e(0x7c100000)), + (22, 0x3a92d9, e(0x7c200000)), + (22, 0x3a92da, e(0x7c300000)), + (22, 0x3a92db, e(0x7c400000)), + (22, 0x3a92dc, e(0x7c500000)), + (22, 0x3a92dd, e(0x7c600000)), + (22, 0x3a92de, e(0x7c700000)), + (22, 0x3a92df, e(0x7c800000)), + (22, 0x3a92e0, e(0x7c900000)), + (22, 0x3a92e1, e(0x7ca00000)), + (22, 0x3a92e2, e(0x7cb00000)), + (22, 0x3a92e3, e(0x7cc00000)), + (22, 0x3a92e4, e(0x7cd00000)), + (22, 0x3a92e5, e(0x7ce00000)), + (22, 0x3a92e6, e(0x7cf00000)), + (22, 0x3a92e7, e(0x7d000000)), + (22, 0x3a92e8, e(0x7d100000)), + (22, 0x3a92e9, e(0x7d200000)), + (22, 0x3a92ea, e(0x7d300000)), + (22, 0x3a92eb, e(0x7d400000)), + (22, 0x3a92ec, e(0x7d500000)), + (22, 0x3a92ed, e(0x7d600000)), + (22, 0x3a92ee, e(0x7d700000)), + (22, 0x3a92ef, e(0x7d800000)), + (22, 0x3a92f0, e(0x7d900000)), + (22, 0x3a92f1, e(0x7da00000)), + (22, 0x3a92f2, e(0x7db00000)), + (22, 0x3a92f3, e(0x7dc00000)), + (22, 0x3a92f4, e(0x7dd00000)), + (22, 0x3a92f5, e(0x7de00000)), + (22, 0x3a92f6, e(0x7df00000)), + (22, 0x3a92f7, e(0x7e000000)), + (22, 0x3a92f8, e(0x7e100000)), + (22, 0x3a92f9, e(0x7e200000)), + (22, 0x3a92fa, e(0x7e300000)), + (22, 0x3a92fb, e(0x7e400000)), + (22, 0x3a92fc, e(0x7e500000)), + (22, 0x3a92fd, e(0x7e600000)), + (22, 0x3a92fe, e(0x7e700000)), + (22, 0x3a92ff, e(0x7e800000)), + (22, 0x3a95a0, e(0x7e900000)), + (22, 0x3a95a1, e(0x7ea00000)), + (22, 0x3a95a2, e(0x7eb00000)), + (22, 0x3a95a3, e(0x7ec00000)), + (22, 0x3a95a4, e(0x7ed00000)), + (22, 0x3a95a5, e(0x7ee00000)), + (22, 0x3a95a6, e(0x7ef00000)), + (22, 0x3a95a7, e(0x7f000000)), + (22, 0x3a95a8, e(0x7f100000)), + (22, 0x3a95a9, e(0x7f200000)), + (22, 0x3a95aa, e(0x7f300000)), + (22, 0x3a95ab, e(0x7f400000)), + (22, 0x3a95ac, e(0x7f500000)), + (22, 0x3a95ad, e(0x7f600000)), + (22, 0x3a95ae, e(0x7f700000)), + (22, 0x3a95af, e(0x7f800000)), + (22, 0x3b8fe0, e(0x7f900000)), + (22, 0x3b8fe1, e(0x7fa00000)), + (22, 0x3b8fe2, e(0x7fb00000)), + (22, 0x3b8fe3, e(0x7fc00000)), + (21, 0x68e90, e(0x7fd00000)), + (21, 0x68e91, e(0x7fe00000)), + (21, 0x68e98, e(0x7ff80000)), +]; diff --git a/crates/prc/src/arrays.rs b/crates/prc/src/arrays.rs new file mode 100644 index 0000000000..de13bde6eb --- /dev/null +++ b/crates/prc/src/arrays.rs @@ -0,0 +1,420 @@ +//! The array primitives of compressed tessellation [WD 11.6-11.9] and the Huffman payload +//! they may carry [WD 12.2]. +//! +//! The WD gives only the Huffman writer. The reader decodes what producers write: each +//! leaf's code length is read in the width the payload's 8-bit field states (the WD writes a +//! fixed 8 bits), stored codes carry a leading root bit, and the payload is read LSB-first +//! although the stream around it is MSB-first. + +use std::collections::HashMap; + +use crate::PrcError; +use crate::bits::BitReader; + +/// Longest Huffman code [WD 12.2]. +const MAX_CODE_BITS: u32 = 32; + +/// Widest Huffman symbol a caller asks for: the arrays hold 2- to 16-bit values. +const MAX_SYMBOL_BITS: u32 = 16; + +fn malformed(what: &str) -> PrcError { + PrcError::Malformed(what.to_owned()) +} + +/// An LSB-first cursor over a Huffman payload. +struct Lsb<'a> { + data: &'a [u8], + pos: usize, + end: usize, +} + +impl Lsb<'_> { + fn bit(&mut self) -> Result { + if self.pos >= self.end { + return Err(PrcError::Truncated("a Huffman payload")); + } + let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a Huffman payload"))?; + let b = u32::from((byte >> (self.pos % 8)) & 1); + self.pos += 1; + Ok(b) + } + + /// `n` ≤ 32 bits, the first read being the least significant. + fn bits(&mut self, n: u32) -> Result { + let mut v = 0u32; + for i in 0..n.min(32) { + v |= self.bit()? << i; + } + Ok(v) + } + + fn remaining(&self) -> usize { + self.end.saturating_sub(self.pos) + } +} + +/// The low `n` (1..=16) bits of `v` as a symbol, two's complement when `signed`. +fn symbol(v: u32, n: u32, signed: bool) -> i32 { + let v = i64::from(v); + let wide = if signed && n > 0 && v & (1 << (n - 1)) != 0 { v - (1i64 << n) } else { v }; + // n <= 16, so `wide` is within ±2^16. + i32::try_from(wide).unwrap_or(0) +} + +/// The Huffman container of a compressed Character/Short array [WD 11.6, 11.7, 12.2]: +/// symbols are `nbits` wide, sign-extended when `signed`. +fn huffman(r: &mut BitReader<'_>, nbits: u32, signed: bool) -> Result, PrcError> { + if nbits == 0 || nbits > MAX_SYMBOL_BITS { + return Err(malformed("Huffman symbol width")); + } + let words = r.unsigned_integer()? as usize; + if words == 0 { + return Ok(Vec::new()); + } + if words.saturating_mul(32) > r.remaining() { + return Err(PrcError::Truncated("a Huffman payload")); + } + // words * 32 bits remain, so words * 4 bytes cannot overflow. + let blob = (0..words * 4).map(|_| r.character()).collect::, _>>()?; + let last = r.unsigned_integer()?; + if last > 32 { + return Err(malformed("Huffman last-word bit count above 32")); + } + let end = (words - 1) * 32 + last as usize; + let mut b = Lsb { data: &blob, pos: 0, end }; + let codes = huffman_leaves(&mut b, nbits, signed)?; + if codes.is_empty() { + return Ok(Vec::new()); + } + let count = b.bits(32)? as usize; + // Every element is at least one bit. + if count > b.remaining() { + return Err(PrcError::Truncated("a Huffman element count")); + } + r.take_elements(count)?; + let mut out = Vec::with_capacity(count.min(1024)); + for _ in 0..count { + out.push(huffman_symbol(&mut b, &codes)?); + } + Ok(out) +} + +/// The leaf table: `(code length, stored code)` to symbol. +fn huffman_leaves(b: &mut Lsb<'_>, nbits: u32, signed: bool) -> Result, PrcError> { + let leaves = b.bits(nbits + 1)?; + if leaves == 0 { + return Ok(HashMap::new()); + } + // prc-rs: the code-length width is read from the payload, not fixed at 8 bits. + let len_width = b.bits(8)?; + if len_width == 0 || len_width > MAX_CODE_BITS { + return Err(malformed("Huffman code-length width")); + } + // Every leaf is at least nbits + 2 bits, so the payload bounds the table. + if (leaves as usize).saturating_mul((nbits + 2) as usize) > b.remaining() { + return Err(PrcError::Truncated("a Huffman payload")); + } + let mut codes = HashMap::new(); + for _ in 0..leaves { + let s = symbol(b.bits(nbits)?, nbits, signed); + let len = b.bits(len_width)?; + if len == 0 || len > MAX_CODE_BITS { + return Err(malformed("Huffman code length")); + } + let code = b.bits(len)?; + if codes.insert((len, code), s).is_some() { + return Err(malformed("duplicate Huffman code")); + } + } + // A leaf whose code begins another's would end that code early, leaving its unread bits + // in front of the next element. + let shadowed = codes.keys().any(|&(len, code)| (1..len).any(|l| codes.contains_key(&(l, code >> (len - l))))); + if shadowed { + return Err(malformed("a Huffman code that is a prefix of another")); + } + Ok(codes) +} + +/// One element: code bits, most significant first, until a leaf matches. +fn huffman_symbol(b: &mut Lsb<'_>, codes: &HashMap<(u32, u32), i32>) -> Result { + // prc-rs: the stored code's first bit read is its most significant (the root bit). + let (mut code, mut len) = (0u32, 0u32); + while len < MAX_CODE_BITS { + code = (code << 1) | b.bit()?; + len += 1; + if let Some(&s) = codes.get(&(len, code)) { + return Ok(s); + } + } + Err(malformed("a Huffman code matching no leaf")) +} + +/// A `CharacterArray(nbits)` [WD 11.6]. `compressed` is `None` when the flag is written, +/// else the caller's implicit value. Uncompressed elements are whole bytes, sign-extended +/// from 8 bits when `signed`. +pub(crate) fn character_array(r: &mut BitReader<'_>, nbits: u32, compressed: Option, signed: bool) -> Result, PrcError> { + let compressed = match compressed { + Some(c) => c, + None => r.bit()?, + }; + if compressed { + return huffman(r, nbits, signed); + } + let n = r.unsigned_integer()? as usize; + if n.saturating_mul(8) > r.remaining() { + return Err(PrcError::Truncated("a character array")); + } + r.take_elements(n)?; + (0..n) + .map(|_| { + let c = r.character()?; + Ok(if signed { i32::from(c.cast_signed()) } else { i32::from(c) }) + }) + .collect() +} + +/// A `ShortArray(nbits)` [WD 11.7]: uncompressed elements are a low then a high byte, read +/// unsigned. +pub(crate) fn short_array(r: &mut BitReader<'_>, nbits: u32) -> Result, PrcError> { + if r.bit()? { + return huffman(r, nbits, false); + } + let n = r.unsigned_integer()? as usize; + if n.saturating_mul(16) > r.remaining() { + return Err(PrcError::Truncated("a short array")); + } + r.take_elements(n)?; + (0..n) + .map(|_| { + let lo = r.character()?; + let hi = r.character()?; + Ok(i32::from(u16::from_le_bytes([lo, hi]))) + }) + .collect() +} + +/// A `CompressedIntegerArray` [WD 11.8]: a CharacterArray(6) of widths, then one +/// `IntegerWithVariableBitNumber` per width. +pub(crate) fn compressed_integer_array(r: &mut BitReader<'_>) -> Result, PrcError> { + let widths = character_array(r, 6, None, false)?; + widths + .into_iter() + .map(|w| { + let w = u32::try_from(w).map_err(|_| malformed("integer width"))?; + r.integer_vbn(w) + }) + .collect() +} + +/// A `CompressedIndiceArray` [WD 11.9], read as producers write it: the stored signed 6-bit +/// values are width differences summed into each element's width, and each element is a +/// delta from the previous (the WD's own reading desyncs from the third element). Indices +/// are never negative. +pub(crate) fn compressed_indice_array(r: &mut BitReader<'_>, compressed: Option) -> Result, PrcError> { + let diffs = character_array(r, 6, compressed, true)?; + let mut out = Vec::with_capacity(diffs.len()); + let (mut width, mut prev) = (0i64, 0i64); + for d in diffs { + // prc-rs: widths are running sums of the stored differences, values running deltas. + width = width.checked_add(i64::from(d)).ok_or_else(|| malformed("indice width"))?; + let w = u32::try_from(width).map_err(|_| malformed("indice width"))?; + let v = prev.checked_add(r.integer_vbn(w)?).ok_or_else(|| malformed("indice delta"))?; + out.push(u32::try_from(v).map_err(|_| malformed("an index outside 0..2^32"))?); + prev = v; + } + Ok(out) +} + +/// An `UncompressedBoolArray` of `n` raw bits (no length prefix). +pub(crate) fn bool_array(r: &mut BitReader<'_>, n: usize) -> Result, PrcError> { + if n > r.remaining() { + return Err(PrcError::Truncated("a boolean array")); + } + r.take_elements(n)?; + (0..n).map(|_| r.bit()).collect() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::testw::W; + + fn read(w: &W, f: impl FnOnce(&mut BitReader<'_>) -> Result) -> T { + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let v = f(&mut r).unwrap(); + assert_eq!(r.position(), w.len(), "bits consumed"); + v + } + + #[test] + fn huffman_decodes_the_wd_example_with_root_bits() { + // WD 12.2's input {1,3,5,7,7,9,5,3,3}; codes carry the root bit. + let leaves = [(3, 0b100, 3), (7, 0b101, 3), (5, 0b110, 3), (1, 0b1110, 4), (9, 0b1111, 4)]; + let values = [1, 3, 5, 7, 7, 9, 5, 3, 3]; + let mut w = W::default(); + w.bit(true).huffman(4, 3, &leaves, &values); + assert_eq!(read(&w, |r| character_array(r, 4, None, false)), values); + } + + #[test] + fn huffman_symbols_sign_extend_only_when_signed() { + let leaves = [(0b111110, 0b10, 2), (1, 0b11, 2)]; + let mut w = W::default(); + w.huffman(6, 2, &leaves, &[62, 1, 62]); + assert_eq!(read(&w, |r| huffman(r, 6, true)), [-2, 1, -2]); + assert_eq!(read(&w, |r| huffman(r, 6, false)), [62, 1, 62]); + } + + #[test] + fn huffman_rejects_codes_that_match_no_leaf_and_empty_payloads_are_empty() { + let mut bad = W::default(); + bad.huffman_raw(2, 2, &[(1, 0b10, 2)], &[(0b11, 2)]); + let bytes = bad.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Malformed(_) | PrcError::Truncated(_)))); + let mut empty = W::default(); + empty.uint(0); + assert!(read(&empty, |r| huffman(r, 2, false)).is_empty()); + } + + #[test] + fn a_huffman_element_count_past_the_payload_is_truncated_not_allocated() { + // One leaf, then an element count of 2^32 - 1 with no elements behind it. + let mut w = W::default(); + w.huffman_count(2, 2, &[(1, 0b10, 2)], u32::MAX); + let bytes = w.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Truncated("a Huffman element count")))); + } + + #[test] + fn a_huffman_code_that_begins_another_is_malformed() { + // `10` would end every `100` one bit early. + let mut w = W::default(); + w.huffman(2, 2, &[(1, 0b10, 2), (2, 0b100, 3)], &[2, 1]); + let bytes = w.bytes(); + let r = huffman(&mut BitReader::new(&bytes), 2, false); + assert!(malformed_by(r, "a Huffman code that is a prefix of another")); + } + + #[test] + fn huffman_widths_out_of_range_are_errors() { + // Well-formed payloads at each width, which only the width check refuses. + for nbits in [0, 17] { + let mut w = W::default(); + w.huffman(nbits, 2, &[(1, 0b10, 2)], &[1]); + let bytes = w.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), nbits, false), Err(PrcError::Malformed(_))), "{nbits}"); + } + } + + #[test] + fn uncompressed_arrays() { + let mut w = W::default(); + w.bit(false).uint(2).put(0xfe, 8).put(3, 8); + assert_eq!(read(&w, |r| character_array(r, 6, None, true)), [-2, 3]); + let mut s = W::default(); + s.bit(false).uint(1).put(0x34, 8).put(0x12, 8); + assert_eq!(read(&s, |r| short_array(r, 16)), [0x1234]); + } + + #[test] + fn integer_array_reads_one_width_per_value() { + let mut w = W::default(); + w.bit(false).uint(3).put(3, 8).put(0, 8).put(4, 8); + w.bit(true).put(3, 2); // -3 in 3 bits + w.bit(false).put(5, 3); // 5 in 4 bits + assert_eq!(read(&w, compressed_integer_array), [-3, 0, 5]); + } + + #[test] + fn indice_array_sums_widths_and_deltas() { + // Values 5, 6, 4: widths 4, 2, 3 stored as diffs 4, -2, +1. + let mut w = W::default(); + w.bit(false).uint(3).put(4, 8).put(0xfe, 8).put(1, 8); + w.bit(false).put(5, 3); // 5 + w.bit(false).put(1, 1); // +1 + w.bit(true).put(2, 2); // -2 + assert_eq!(read(&w, |r| compressed_indice_array(r, None)), [5, 6, 4]); + // The implicit-flag form reads no flag bit. + let mut i = W::default(); + i.uint(1).put(2, 8).bit(false).put(1, 1); + assert_eq!(read(&i, |r| compressed_indice_array(r, Some(false))), [1]); + // A negative running index is damage. + let mut n = W::default(); + n.bit(false).uint(1).put(2, 8).bit(true).put(1, 1); + let bytes = n.bytes(); + assert!(compressed_indice_array(&mut BitReader::new(&bytes), None).is_err()); + } + + #[test] + fn a_bool_array_longer_than_the_data_is_truncated() { + assert!(matches!(bool_array(&mut BitReader::new(&[0]), 9), Err(PrcError::Truncated(_)))); + assert_eq!(bool_array(&mut BitReader::new(&[0x80]), 2).unwrap(), [true, false]); + } + + #[test] + fn decoded_elements_are_charged_against_one_allowance() { + // One bit can decode to a 4-byte element, so the byte cap alone does not bound memory. + let leaves = [(0b111110, 0b10, 2), (1, 0b11, 2)]; + let mut w = W::default(); + w.huffman(6, 2, &leaves, &[62, 1, 62]); + let bytes = w.bytes(); + let mut r = BitReader::with_elements(&bytes, 2); + assert!(matches!(huffman(&mut r, 6, false), Err(PrcError::TooManyElements { .. }))); + let mut r = BitReader::with_elements(&bytes, 4); + assert_eq!(huffman(&mut r, 6, false).unwrap(), [62, 1, 62]); + assert_eq!(r.elements_left(), 1); + assert!(matches!(bool_array(&mut BitReader::with_elements(&[0], 1), 2), Err(PrcError::TooManyElements { .. }))); + } + + /// Uncompressed character and short arrays are charged against the allowance too, and a + /// character count past the data is refused by name before anything is read. + #[test] + fn uncompressed_arrays_are_charged_and_their_counts_checked() { + let mut w = W::default(); + w.uint(3).put(0, 8).put(1, 8).put(2, 8); + let chars = w.bytes(); + let read = |n| character_array(&mut BitReader::with_elements(&chars, n), 8, Some(false), false); + assert!(matches!(read(2), Err(PrcError::TooManyElements { .. }))); + assert_eq!(read(3).unwrap(), [0, 1, 2]); + let mut w = W::default(); + w.bit(false).uint(2).put(1, 8).put(0, 8).put(2, 8).put(0, 8); + let shorts = w.bytes(); + assert!(matches!(short_array(&mut BitReader::with_elements(&shorts, 1), 8), Err(PrcError::TooManyElements { .. }))); + assert_eq!(short_array(&mut BitReader::with_elements(&shorts, 2), 8).unwrap(), [1, 2]); + let mut w = W::default(); + w.uint(1000).put(7, 8); + let short_of_data = w.bytes(); + let res = character_array(&mut BitReader::new(&short_of_data), 8, Some(false), false); + assert!(matches!(res, Err(PrcError::Truncated("a character array"))), "{res:?}"); + } + + fn malformed_by(res: Result, PrcError>, what: &str) -> bool { + matches!(res, Err(PrcError::Malformed(m)) if m == what) + } + + #[test] + fn a_zero_code_length_width_or_length_is_refused_by_name() { + let mut w = W::default(); + w.huffman_raw(2, 0, &[(1, 0, 0)], &[]); + let bytes = w.bytes(); + assert!(malformed_by(huffman(&mut BitReader::new(&bytes), 2, false), "Huffman code-length width")); + let mut w = W::default(); + w.huffman_raw(2, 2, &[(1, 0, 0)], &[]); + let bytes = w.bytes(); + assert!(malformed_by(huffman(&mut BitReader::new(&bytes), 2, false), "Huffman code length")); + } + + #[test] + fn a_boolean_array_past_the_data_is_refused_before_it_is_read() { + let mut w = W::default(); + w.put(0b1011, 4); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let n = r.remaining() + 1; + assert_eq!(bool_array(&mut r, n), Err(PrcError::Truncated("a boolean array"))); + assert_eq!(r.position(), 0, "nothing read"); + } +} diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs new file mode 100644 index 0000000000..837d281f3b --- /dev/null +++ b/crates/prc/src/bits.rs @@ -0,0 +1,491 @@ +//! The bit-level reader for PRC compressed sections [WD 10.3, 11.1-11.17]. +//! +//! Values are consumed MSB-first from each inflated byte and straddle byte boundaries freely +//! [WD 10.3.1, 11.3]. Multi-byte integers are groups of eight bits, low group first. Two sign +//! conventions coexist: `Integer` is two's complement, the `*WithVariableBitNumber` types are +//! sign-magnitude. + +use crate::PrcError; +use crate::acof::{self, Entry}; + +/// The most array elements the readers of one PRC stream decode together (2^24): at most 8 +/// bytes each, so 128 MiB. +pub const MAX_DECODED_ELEMENTS: usize = 1 << 24; + +/// The most schema-program steps (tokens executed, loop iterations included) the readers of +/// one PRC stream run together. +pub const MAX_SCHEMA_STEPS: u64 = 1 << 22; + +/// What the readers of one stream may still spend, carried from each reader to the next. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Budget { + /// Array elements still to decode. + pub(crate) elements: usize, + /// Schema-program steps still to run. + pub(crate) steps: u64, +} + +impl Budget { + /// A whole stream's allowance. + pub(crate) const FULL: Self = Self { elements: MAX_DECODED_ELEMENTS, steps: MAX_SCHEMA_STEPS }; +} + +/// A cursor over one inflated section. +#[derive(Debug, Clone)] +pub struct BitReader<'a> { + data: &'a [u8], + pos: usize, + budget: Budget, +} + +impl<'a> BitReader<'a> { + /// A reader positioned at the first bit of `data`, allowed [`MAX_DECODED_ELEMENTS`] and + /// [`MAX_SCHEMA_STEPS`]. + pub fn new(data: &'a [u8]) -> Self { + Self::with_budget(data, Budget::FULL) + } + + /// A reader of `data` allowed `budget`: what an earlier reader of the same stream left. + pub(crate) fn with_budget(data: &'a [u8], budget: Budget) -> Self { + Self { data, pos: 0, budget } + } + + /// A reader of `data` allowed `elements` array elements and a full step allowance. + #[cfg(test)] + pub(crate) fn with_elements(data: &'a [u8], elements: usize) -> Self { + Self::with_budget(data, Budget { elements, ..Budget::FULL }) + } + + /// This reader, at its position, allowed `budget` instead: what readers of the same + /// stream left since it stopped. + pub(crate) fn with_remaining(mut self, budget: Budget) -> Self { + self.budget = budget; + self + } + + /// What this reader may still spend. + pub(crate) fn budget(&self) -> Budget { + self.budget + } + + /// Array elements this reader may still decode. + #[cfg(test)] + pub(crate) fn elements_left(&self) -> usize { + self.budget.elements + } + + /// Charge `n` decoded array elements against the allowance. + /// + /// # Errors + /// [`PrcError::TooManyElements`] when fewer than `n` are left. + pub(crate) fn take_elements(&mut self, n: usize) -> Result<(), PrcError> { + self.budget.elements = self.budget.elements.checked_sub(n).ok_or(PrcError::TooManyElements { limit: MAX_DECODED_ELEMENTS })?; + Ok(()) + } + + /// Charge one schema-program step; false when none is left. + pub(crate) fn take_step(&mut self) -> bool { + match self.budget.steps.checked_sub(1) { + Some(left) => { + self.budget.steps = left; + true + } + None => false, + } + } + + /// Bits consumed so far. + pub fn position(&self) -> usize { + self.pos + } + + /// Bits left before the end of the data. + pub fn remaining(&self) -> usize { + self.data.len().saturating_mul(8).saturating_sub(self.pos) + } + + /// Step over `n` bits of opaque data (`UserData` [WD 8.6]). + /// + /// # Errors + /// [`PrcError::Truncated`] when fewer than `n` bits remain; the cursor does not move. + pub fn skip_bits(&mut self, n: usize) -> Result<(), PrcError> { + if n > self.remaining() { + return Err(PrcError::Truncated("user data")); + } + self.pos = self.pos.saturating_add(n); + Ok(()) + } + + /// One bit; a PRC `Boolean` [WD 10.3.3]. + /// + /// # Errors + /// [`PrcError::Truncated`] at the end of the data. + pub fn bit(&mut self) -> Result { + let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a bit"))?; + let b = (byte >> (7 - (self.pos % 8))) & 1 == 1; + // `pos / 8` indexed the data, so `pos + 1` is at most `8 * len`. + self.pos = self.pos.saturating_add(1); + Ok(b) + } + + /// `n` bits (at most 32), MSB-first; `UnsignedIntegerWithVariableBitNumber` [WD 11.13]. + /// + /// # Errors + /// [`PrcError::Truncated`] at the end of the data; [`PrcError::Malformed`] for `n > 32`. + pub fn bits(&mut self, n: u32) -> Result { + if n > 32 { + return Err(PrcError::Malformed(format!("{n}-bit field"))); + } + if self.remaining() < n as usize { + return Err(PrcError::Truncated("a bit field")); + } + let mut v: u64 = 0; + for _ in 0..n { + v = (v << 1) | u64::from(self.bit()?); + } + // n <= 32, so v fits. + Ok(u32::try_from(v).unwrap_or(u32::MAX)) + } + + fn byte(&mut self) -> Result { + Ok(u8::try_from(self.bits(8)?).unwrap_or(u8::MAX)) + } + + /// A `Character`: one byte [WD 10.3.4]. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn character(&mut self) -> Result { + self.byte() + } + + /// An `UnsignedInteger` [WD 11.10]: while a 1 bit precedes it, one more eight-bit group, + /// low group first. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups. + pub fn unsigned_integer(&mut self) -> Result { + let mut v: u32 = 0; + let mut shift = 0; + while self.bit()? { + if shift >= 32 { + return Err(PrcError::Malformed("UnsignedInteger over 32 bits".into())); + } + v |= u32::from(self.byte()?) << shift; + shift += 8; + } + Ok(v) + } + + /// An `Integer` [WD 11.11]: a 0 bit is zero; otherwise eight-bit groups, low group first, + /// each followed by a continue bit, sign-extended from the last group's top bit. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups. + pub fn integer(&mut self) -> Result { + if !self.bit()? { + return Ok(0); + } + let mut v: u32 = 0; + let mut shift = 0; + loop { + if shift >= 32 { + return Err(PrcError::Malformed("Integer over 32 bits".into())); + } + let group = self.byte()?; + v |= u32::from(group) << shift; + shift += 8; + if !self.bit()? { + if group & 0x80 != 0 && shift < 32 { + v |= u32::MAX << shift; + } + return Ok(v.cast_signed()); + } + } + } + + /// An `IntegerWithVariableBitNumber(n)` [WD 11.12]: a negative flag (1 = negative; the WD + /// prose says the opposite, its sample code and real files agree on this), then the + /// magnitude in `n - 1` bits. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] for `n > 33`. + pub fn integer_vbn(&mut self, n: u32) -> Result { + let Some(magnitude_bits) = n.checked_sub(1) else { + return Ok(0); + }; + let negative = self.bit()?; + let m = i64::from(self.bits(magnitude_bits)?); + Ok(if negative { -m } else { m }) + } + + /// A `DoubleWithVariableBitNumber(tolerance, n)` [WD 11.14]: a sign-magnitude quantum + /// count times `tolerance`. + /// + /// # Errors + /// As [`Self::integer_vbn`]. + pub fn double_vbn(&mut self, tolerance: f64, n: u32) -> Result { + Ok(self.integer_vbn(n)? as f64 * tolerance) + } + + /// `NumberOfBitsThenUnsignedInteger` [WD 11.15]: a 5-bit width, then the value in that + /// many bits. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn nbits_then_unsigned(&mut self) -> Result { + let n = self.bits(5)?; + self.bits(n) + } + + /// A `String` [WD 10.3.7, 11.4]: `None` for the null string, else UTF-8 (invalid + /// sequences replaced). + /// + /// # Errors + /// [`PrcError::Truncated`] when the byte count runs past the data. + pub fn string(&mut self) -> Result, PrcError> { + if !self.bit()? { + return Ok(None); + } + let len = self.unsigned_integer()? as usize; + if len.saturating_mul(8) > self.remaining() { + return Err(PrcError::Truncated("a String")); + } + let bytes = (0..len).map(|_| self.byte()).collect::, _>>()?; + Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) + } + + /// `FloatAsBytes` [WD 11.5]: an IEEE binary32, least-significant byte first. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn float_as_bytes(&mut self) -> Result { + let b = [self.byte()?, self.byte()?, self.byte()?, self.byte()?]; + Ok(f32::from_le_bytes(b)) + } + + /// A `Double` [WD 11.17]: a prefix code from the Double table, then a sign bit and, for + /// an exponent code, a byte-wise mantissa with back-references. + /// + /// The two-bit zero code has no sign bit. Back-reference offsets 1 to 5 are all accepted, + /// distance 1 included (PDF Association pdf-issues #769). + /// + /// # Errors + /// [`PrcError::UnknownDoubleCode`], [`PrcError::Truncated`], or [`PrcError::Malformed`] + /// for a back-reference outside the value. + pub fn double(&mut self) -> Result { + // prc-rs: the decoding steps (code lookup, sign, exponent, byte-wise mantissa with + // back-references) follow prc-rs's. + let mut code = 0u32; + let mut entry = None; + for n in 1..=acof::MAX_CODE_BITS { + code = (code << 1) | u32::from(self.bit()?); + if let Some(e) = acof::lookup(n, code) { + if n == 2 && code == 0b01 { + return Ok(0.0); + } + entry = Some(e); + break; + } + } + let entry = entry.ok_or(PrcError::UnknownDoubleCode)?; + let negative = self.bit()?; + let upper = match entry { + Entry::Double(v) => return Ok(if negative { -v } else { v }), + Entry::Exponent(upper) => upper, + }; + let mut b = (u64::from(upper) << 32).to_le_bytes(); + if self.bit()? { + self.mantissa(&mut b)?; + } + let sign = if negative { 1u64 << 63 } else { 0 }; + Ok(f64::from_bits(u64::from_le_bytes(b) | sign)) + } + + /// Mantissa bits 51..0 into `b` (little-endian byte order): a nibble, then bytes 5 down + /// to 0, each a literal or a back-reference to a byte already set. + fn mantissa(&mut self, b: &mut [u8; 8]) -> Result<(), PrcError> { + let nibble = u8::try_from(self.bits(4)?).unwrap_or(0); + let [.., b6, _] = b; + *b6 |= nibble; + let at = |b: &[u8; 8], i: usize| b.get(i).copied().ok_or_else(|| PrcError::Malformed("Double back-reference past the value".into())); + for cbi in (0..=5usize).rev() { + let prev = at(b, cbi + 1)?; + let value = if self.bit()? { + self.byte()? + } else { + match self.bits(3)? { + // Fill this byte and every lower one with the previous byte. + 0 => { + b.iter_mut().take(cbi + 1).for_each(|x| *x = prev); + return Ok(()); + } + // As 0, but the lowest byte is a literal. + 6 => { + b.iter_mut().take(cbi + 1).skip(1).for_each(|x| *x = prev); + let low = self.byte()?; + let [b0, ..] = b; + *b0 = low; + return Ok(()); + } + off @ 1..=5 => at(b, cbi + off as usize)?, + _ => return Err(PrcError::Malformed("Double mantissa offset 7".into())), + } + }; + if let Some(x) = b.get_mut(cbi) { + *x = value; + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::testw::W; + + /// The code for IEEE exponent `e`. + fn exponent_code(e: u32) -> (u32, u32) { + acof::exponent_code(e).expect("the table has a row per exponent") + } + + fn read_double(w: &W) -> f64 { + BitReader::new(&w.bytes()).double().unwrap() + } + + #[test] + fn zero_is_the_two_bit_code_with_no_sign() { + let mut w = W::default(); + w.put(0b01, 2).put(0b1111_1111, 8); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.double().unwrap(), 0.0); + assert_eq!(r.position(), 2); + } + + #[test] + fn exponent_with_literal_mantissa() { + let v = -1234.5678f64; + let le = v.to_bits().to_le_bytes(); + let e = u32::try_from((v.to_bits() >> 52) & 0x7ff).unwrap(); + let (n, c) = exponent_code(e); + let mut w = W::default(); + w.put(u64::from(c), n).put(1, 1).put(1, 1).put(u64::from(le[6] & 0x0f), 4); + for byte in le[..6].iter().rev() { + w.put(1, 1).put(u64::from(*byte), 8); + } + assert_eq!(read_double(&w), v); + } + + #[test] + fn exponent_without_mantissa_is_a_power_of_two() { + let (n, c) = exponent_code(1023 + 3); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(0, 1); + assert_eq!(read_double(&w), 8.0); + } + + #[test] + fn fill_back_reference_and_literal_low_byte() { + // Mantissa bytes 6..0 = 0x05, 0xAA, 0xAA (offset 1), 0xAA (offset 2), fill 0xAA to byte 1, byte 0 = 0x11. + let (n, c) = exponent_code(1023); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4); + w.put(1, 1).put(0xAA, 8); + w.put(0, 1).put(1, 3); + w.put(0, 1).put(2, 3); + w.put(0, 1).put(6, 3).put(0x11, 8); + assert_eq!(read_double(&w), f64::from_le_bytes([0x11, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xF5, 0x3F])); + + // Byte 5 literal, then fill 4..0. + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4); + w.put(1, 1).put(0x77, 8).put(0, 1).put(0, 3); + assert_eq!(read_double(&w), f64::from_le_bytes([0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0xF5, 0x3F])); + } + + #[test] + fn a_back_reference_past_the_value_is_refused() { + let (n, c) = exponent_code(1023); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0, 4); + w.put(0, 1).put(5, 3).put(0, 16); // byte 5 + offset 5 = byte 10 + assert!(matches!(BitReader::new(&w.bytes()).double(), Err(PrcError::Malformed(_)))); + } + + #[test] + fn a_truncated_double_is_an_error_not_a_panic() { + // No code is 8 bits or shorter and a prefix of 0x17, so one byte ends inside a code. + assert!(matches!(BitReader::new(&[0x17]).double(), Err(PrcError::Truncated(_)))); + assert!(matches!(BitReader::new(&[]).double(), Err(PrcError::Truncated(_)))); + } + + #[test] + fn integers_and_strings() { + let mut w = W::default(); + // UnsignedInteger 300 = groups 0x2C, 0x01. + w.put(1, 1).put(0x2C, 8).put(1, 1).put(0x01, 8).put(0, 1); + // Integer -2 = group 0xFE, stop. + w.put(1, 1).put(0xFE, 8).put(0, 1); + // Integer 200 = 0xC8 then 0x00 (top bit clear), stop. + w.put(1, 1).put(0xC8, 8).put(1, 1).put(0x00, 8).put(0, 1); + // IntegerWithVariableBitNumber(4) = -5. + w.put(1, 1).put(5, 3); + // String "hé". + w.put(1, 1).put(1, 1).put(3, 8).put(0, 1).put(0x68, 8).put(0xC3, 8).put(0xA9, 8); + // Null string. + w.put(0, 1); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.unsigned_integer().unwrap(), 300); + assert_eq!(r.integer().unwrap(), -2); + assert_eq!(r.integer().unwrap(), 200); + assert_eq!(r.integer_vbn(4).unwrap(), -5); + assert_eq!(r.string().unwrap().as_deref(), Some("hé")); + assert_eq!(r.string().unwrap(), None); + } + + #[test] + fn the_writer_round_trips_through_the_reader() { + let mut w = W::default(); + w.uint(0).uint(u32::MAX).int(-129).int(i32::MIN).int(i32::MAX).string(Some("glass")).string(None); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.unsigned_integer().unwrap(), 0); + assert_eq!(r.unsigned_integer().unwrap(), u32::MAX); + assert_eq!(r.integer().unwrap(), -129); + assert_eq!(r.integer().unwrap(), i32::MIN); + assert_eq!(r.integer().unwrap(), i32::MAX); + assert_eq!(r.string().unwrap().as_deref(), Some("glass")); + assert_eq!(r.string().unwrap(), None); + } + + #[test] + fn oversized_groups_and_widths_are_malformed() { + // Five UnsignedInteger groups. + let mut w = W::default(); + (0..5).for_each(|_| { + w.put(1, 1).put(0xFF, 8); + }); + w.put(0, 1); + assert!(matches!(BitReader::new(&w.bytes()).unsigned_integer(), Err(PrcError::Malformed(_)))); + assert!(matches!(BitReader::new(&[0; 8]).bits(33), Err(PrcError::Malformed(_)))); + assert!(matches!(BitReader::new(&[0; 8]).integer_vbn(34), Err(PrcError::Malformed(_)))); + assert_eq!(BitReader::new(&[]).integer_vbn(0), Ok(0)); + } + + #[test] + fn truncation_is_an_error_not_a_panic() { + let mut r = BitReader::new(&[0xFF]); + assert!(r.unsigned_integer().is_err()); + let mut r = BitReader::new(&[0xC0, 0x40]); + assert!(matches!(r.string(), Err(PrcError::Truncated(_)))); + let mut r = BitReader::new(&[0xAB]); + assert!(matches!(r.skip_bits(9), Err(PrcError::Truncated(_)))); + assert_eq!(r.position(), 0, "a refused skip does not move the cursor"); + r.skip_bits(8).unwrap(); + assert_eq!(r.remaining(), 0); + } +} diff --git a/crates/prc/src/container.rs b/crates/prc/src/container.rs new file mode 100644 index 0000000000..3ff18508f1 --- /dev/null +++ b/crates/prc/src/container.rs @@ -0,0 +1,340 @@ +//! The PRC container: file header, file-structure descriptions, section extents and +//! inflation [WD 6.1-6.2, 10.2]. +//! +//! Section extents are not stored: each section runs to the next one's start offset, and the +//! last one to the model file's start. Inflation stops at the zlib stream's end, so padding +//! between sections is tolerated. + +use std::io::Read; + +use crate::PrcError; +use crate::bits::BitReader; +use crate::schema::Schema; + +/// The PRC version this reader implements. +pub const PRC_READER_VERSION: u32 = 8137; + +/// The most file structures [`PrcFile::parse`] accepts. +pub const MAX_FILE_STRUCTURES: usize = 65_536; + +/// Ceiling on the total inflated size of every section in one PRC stream, together with the +/// picture and uncompressed blocks it copies. +pub const MAX_INFLATED_BYTES: usize = 512 * 1024 * 1024; + +/// Sections per file structure [WD 6.1.2]: its header, then the five in [`SectionKind`] +/// order. +const SECTIONS: usize = 6; + +/// A 128-bit PRC identifier [WD 10.2.4]: four little-endian `u32`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct UniqueId(pub [u32; 4]); + +/// The compressed sections of one file structure, in file order [WD 6.1.2, 6.2.1]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SectionKind { + /// Starts with the file structure's schema, then its globals entity. + Globals, + /// The product-structure tree. + Tree, + /// Tessellated (mesh) data. + Tessellation, + /// Exact (B-rep) geometry. + Geometry, + /// Supplementary geometry. + ExtraGeometry, +} + +impl SectionKind { + /// Every kind, in file order. + pub const ALL: [SectionKind; 5] = + [SectionKind::Globals, SectionKind::Tree, SectionKind::Tessellation, SectionKind::Geometry, SectionKind::ExtraGeometry]; + + /// The section's name, as used in errors. + pub fn name(self) -> &'static str { + match self { + SectionKind::Globals => "globals", + SectionKind::Tree => "tree", + SectionKind::Tessellation => "tessellation", + SectionKind::Geometry => "geometry", + SectionKind::ExtraGeometry => "extra geometry", + } + } + + fn index(self) -> usize { + match self { + SectionKind::Globals => 0, + SectionKind::Tree => 1, + SectionKind::Tessellation => 2, + SectionKind::Geometry => 3, + SectionKind::ExtraGeometry => 4, + } + } +} + +/// The uncompressed file header [WD 6.1.1]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PrcHeader { + /// The oldest reader version the producer says can read the file. + pub min_version_for_read: u32, + /// The producer's PRC version. + pub authoring_version: u32, + /// The file's identifier. + pub file_id: UniqueId, + /// The producing application's identifier (all zero = unregistered). + pub application_id: UniqueId, +} + +/// One file structure: its uncompressed header fields and its five inflated sections +/// [WD 6.2]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct FileStructure { + /// The identifier; equals the file header's description of it. + pub id: UniqueId, + /// Per-structure reader version floor; may differ from the file's. + pub min_version_for_read: u32, + /// Per-structure producer version. + pub authoring_version: u32, + /// Picture payloads (JPEG, PNG, ...) carried uncompressed in the header, referenced by + /// index from picture entities [WD 6.2.2]. + pub pictures: Vec>, + sections: [Vec; 5], +} + +impl FileStructure { + /// The inflated bytes of one section. + pub fn section(&self, kind: SectionKind) -> &[u8] { + self.sections.get(kind.index()).map_or(&[], Vec::as_slice) + } + + /// The globals section's leading schema: the fields producers newer than this reader + /// added to each entity type [WD 6.3]. + /// + /// # Errors + /// As [`Schema::read`]. + pub fn schema(&self) -> Result { + Schema::read(&mut BitReader::new(self.section(SectionKind::Globals))) + } +} + +/// A parsed PRC stream: header, file structures and model-file section. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PrcFile { + /// The file header. + pub header: PrcHeader, + /// File structures in physical order (referenced before referencing). + pub file_structures: Vec, + /// The inflated model-file section: its schema, then the model file entity. + pub model_file: Vec, + /// The `UncompressedFiles` blocks after the file header [WD 6.1.1], which picture + /// entities may also reference. + pub uncompressed_files: Vec>, +} + +/// A little-endian byte cursor over the uncompressed parts of the stream. +struct Bytes<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Bytes<'a> { + fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], PrcError> { + let end = self.pos.checked_add(n).ok_or(PrcError::Truncated(what))?; + let s = self.data.get(self.pos..end).ok_or(PrcError::Truncated(what))?; + self.pos = end; + Ok(s) + } + + fn u32(&mut self, what: &'static str) -> Result { + let s = self.take(4, what)?; + let b: [u8; 4] = s.try_into().map_err(|_| PrcError::Truncated(what))?; + Ok(u32::from_le_bytes(b)) + } + + fn id(&mut self, what: &'static str) -> Result { + Ok(UniqueId([self.u32(what)?, self.u32(what)?, self.u32(what)?, self.u32(what)?])) + } + + fn magic(&mut self) -> Result<(), PrcError> { + match self.take(3, "magic") { + Ok(b"PRC") => Ok(()), + _ => Err(PrcError::NotPrc), + } + } + + /// `UncompressedBlock`s: a count, then size-prefixed blocks [WD 10.2.2-3], each charged to + /// `budget` before it is copied. + fn blocks(&mut self, what: &'static str, budget: &mut Budget) -> Result>, PrcError> { + let n = offset(self.u32(what)?); + // Each block needs at least its 4-byte size, so `n` is bounded by the data left. + if n > self.data.len().saturating_sub(self.pos) / 4 { + return Err(PrcError::Truncated(what)); + } + (0..n) + .map(|_| { + let len = offset(self.u32(what)?); + let block = self.take(len, what)?; + budget.charge(block.len())?; + Ok(block.to_vec()) + }) + .collect() + } +} + +/// A stored `u32` offset or count as a `usize` (saturating on a 16-bit target, where no data +/// that large could exist). +pub(crate) fn offset(v: u32) -> usize { + usize::try_from(v).unwrap_or(usize::MAX) +} + +/// One file structure description of the file header [WD 6.2]: its id and the offsets of +/// its sections. +fn file_structure_description(r: &mut Bytes) -> Result<(UniqueId, Vec), PrcError> { + let id = r.id("file structure description")?; + if r.u32("file structure description")? != 0 { + return Err(PrcError::Malformed("file structure reserved field is not 0".into())); + } + let count = offset(r.u32("file structure description")?); + if count < SECTIONS { + return Err(PrcError::Malformed(format!("{count} sections in a file structure"))); + } + let mut offs = Vec::with_capacity(SECTIONS); + // Offsets past the six this reader uses are read and dropped; each read is bounded by + // the data, so a huge `count` ends in `Truncated`. + for i in 0..count { + let o = r.u32("section offset")?; + if i < SECTIONS { + offs.push(offset(o)); + } + } + Ok((id, offs)) +} + +/// Where one file structure's parts lie: its section offsets and the end of its last section. +struct Extent<'a> { + offs: &'a [usize], + last: usize, +} + +impl Extent<'_> { + fn bound(&self, i: usize) -> usize { + self.offs.get(i).copied().unwrap_or(self.last) + } +} + +/// The file structure `id` lying at `extent`: its header and its inflated sections, charged +/// to `budget`. +fn file_structure(data: &[u8], id: UniqueId, extent: &Extent, budget: &mut Budget) -> Result { + if extent.offs.windows(2).any(|w| w.first() > w.get(1)) || extent.bound(SECTIONS - 1) > extent.last { + return Err(PrcError::Malformed("file structure section offsets are not ascending".into())); + } + let header = data.get(..extent.bound(1)).ok_or(PrcError::Truncated("file structure header"))?; + let mut h = Bytes { data: header, pos: extent.bound(0) }; + h.magic()?; + let min_version_for_read = h.u32("file structure header")?; + let authoring_version = h.u32("file structure header")?; + if h.id("file structure header")? != id { + return Err(PrcError::Malformed("file structure id differs from its description".into())); + } + let _application = h.id("file structure header")?; + // Headers may overlap, so each structure's copy of its pictures is charged. + let pictures = h.blocks("file structure pictures", budget)?; + let mut sections: [Vec; 5] = Default::default(); + for (i, (slot, kind)) in sections.iter_mut().zip(SectionKind::ALL).enumerate() { + let end = if i + 2 < SECTIONS { extent.bound(i + 2) } else { extent.last }; + *slot = budget.inflate(data, (extent.bound(i + 1), end), kind.name())?; + } + Ok(FileStructure { id, min_version_for_read, authoring_version, pictures, sections }) +} + +/// What is left of the inflation ceiling, and the ceiling itself for the error. +struct Budget { + left: usize, + limit: usize, +} + +impl Budget { + /// The zlib stream in `data[range]`, its size charged to the budget. + fn inflate(&mut self, data: &[u8], range: (usize, usize), section: &'static str) -> Result, PrcError> { + let (start, end) = range; + if start == end { + return Ok(Vec::new()); + } + let src = data.get(start..end).ok_or_else(|| PrcError::Malformed(format!("{section} section {start}..{end} is outside the data")))?; + let mut out = Vec::new(); + // One byte past the budget is enough to know it was exceeded. + let cap = u64::try_from(self.left).unwrap_or(u64::MAX).saturating_add(1); + flate2::read::ZlibDecoder::new(src).take(cap).read_to_end(&mut out).map_err(|e| PrcError::Inflate { section, reason: e.to_string() })?; + self.charge(out.len())?; + Ok(out) + } + + /// Spend `n` bytes of the ceiling. + fn charge(&mut self, n: usize) -> Result<(), PrcError> { + self.left = self.left.checked_sub(n).ok_or(PrcError::TooLarge { limit: self.limit })?; + Ok(()) + } +} + +impl PrcFile { + /// Parse a PRC stream (the decoded data of a `/3D` stream with `/Subtype /PRC`). + /// + /// Newer `min_version_for_read` values are not refused: the fields read here are + /// unchanged across published versions, and the caller sees the versions in + /// [`PrcHeader`]. + /// + /// # Errors + /// [`PrcError::NotPrc`] without the magic; [`PrcError::Truncated`] or + /// [`PrcError::Malformed`] for a damaged header or offset table; [`PrcError::Inflate`] + /// for a section that is not zlib; [`PrcError::TooLarge`] past [`MAX_INFLATED_BYTES`] + /// in total, inflated sections and copied blocks together. + /// + /// # Examples + /// ``` + /// use pdfcraft_prc::{PrcError, PrcFile}; + /// assert_eq!(PrcFile::parse(b"%PDF"), Err(PrcError::NotPrc)); + /// ``` + pub fn parse(data: &[u8]) -> Result { + Self::parse_with_limit(data, MAX_INFLATED_BYTES) + } + + /// [`Self::parse`] with a caller-chosen ceiling on the total inflated size, for callers + /// with less memory to spend than [`MAX_INFLATED_BYTES`]. + /// + /// # Errors + /// As [`Self::parse`]; [`PrcError::TooLarge`] carries `limit`. + pub fn parse_with_limit(data: &[u8], limit: usize) -> Result { + let mut r = Bytes { data, pos: 0 }; + r.magic()?; + let header = PrcHeader { + min_version_for_read: r.u32("file header")?, + authoring_version: r.u32("file header")?, + file_id: r.id("file header")?, + application_id: r.id("file header")?, + }; + let n_fs = offset(r.u32("file structure count")?); + if n_fs == 0 || n_fs > MAX_FILE_STRUCTURES { + return Err(PrcError::Malformed(format!("{n_fs} file structures"))); + } + let descriptions = (0..n_fs).map(|_| file_structure_description(&mut r)).collect::, _>>()?; + let mf_start = offset(r.u32("model file offsets")?); + let mf_end = offset(r.u32("model file offsets")?); + let mut budget = Budget { left: limit, limit }; + let uncompressed_files = r.blocks("uncompressed files", &mut budget)?; + if mf_end < mf_start { + return Err(PrcError::Malformed("model file ends before it starts".into())); + } + if mf_end > data.len() { + return Err(PrcError::Malformed(format!("model file ends at {mf_end}, past the {}-byte stream", data.len()))); + } + + // prc-rs: the last section runs to the model file's start. + let last = mf_start.min(data.len()); + let file_structures = + descriptions.iter().map(|(id, offs)| file_structure(data, *id, &Extent { offs, last }, &mut budget)).collect::, _>>()?; + let model_file = budget.inflate(data, (mf_start, mf_end), "model file")?; + Ok(PrcFile { header, file_structures, model_file, uncompressed_files }) + } +} diff --git a/crates/prc/src/entity.rs b/crates/prc/src/entity.rs new file mode 100644 index 0000000000..9e3c6c2a7b --- /dev/null +++ b/crates/prc/src/entity.rs @@ -0,0 +1,127 @@ +//! What every section reader shares: the cursor, the schema, the authoring version, and the +//! base fields every PRC entity starts with [WD 7.2.3, 7.4.3, 8.6]. + +use crate::PrcError; +use crate::bits::BitReader; +use crate::schema::Schema; +use crate::tree::Graphics; + +const ATTRIBUTE: u32 = 201; + +/// A [`PrcError::Malformed`] naming `what`. +pub(crate) fn malformed(what: String) -> PrcError { + PrcError::Malformed(what) +} + +/// A section reader. +pub(crate) struct Ctx<'a, 's> { + pub(crate) r: BitReader<'a>, + pub(crate) schema: &'s Schema, + /// The file structure's authoring version; gates version-added fields. + pub(crate) version: u32, + /// The current name: what a `same_name` entity reuses; every entity's own name replaces + /// it [WD 7.2.3.4]. Each section starts with none. + pub(crate) name: Option, + /// The PRC unique id of the last `ContentPRCRefBase` read [WD 7.2.3.2]. + pub(crate) uid: u32, + /// The current graphics: what a `same_graphics` entity reuses [WD 7.2.4]. + pub(crate) graphics: Graphics, +} + +impl<'a, 's> Ctx<'a, 's> { + pub(crate) fn new(r: BitReader<'a>, schema: &'s Schema, version: u32) -> Self { + Ctx { r, schema, version, name: None, uid: 0, graphics: Graphics::default() } + } + + /// Reads an entity type code; any value but `want` is malformed. + pub(crate) fn expect_type(&mut self, want: u32) -> Result<(), PrcError> { + let t = self.r.unsigned_integer()?; + if t != want { + return Err(malformed(format!("entity type {t} where {want} belongs"))); + } + Ok(()) + } + + /// A count that must fit in the remaining data at `min_bits` per item, which bounds any + /// allocation sized by it. + pub(crate) fn count(&mut self, min_bits: usize, what: &'static str) -> Result { + let n = self.r.unsigned_integer()? as usize; + if n.saturating_mul(min_bits) > self.r.remaining() { + return Err(PrcError::Truncated(what)); + } + self.r.take_elements(n)?; + Ok(n) + } + + /// A counted array of `UnsignedInteger`s. + pub(crate) fn uints(&mut self, what: &'static str) -> Result, PrcError> { + let n = self.count(1, what)?; + (0..n).map(|_| self.r.unsigned_integer()).collect() + } + + /// A counted array of `Double`s. + pub(crate) fn doubles(&mut self, what: &'static str) -> Result, PrcError> { + let n = self.count(2, what)?; + (0..n).map(|_| self.r.double()).collect() + } + + /// `AttributeEntry` [WD 7.4.3]: a predefined title code or a string. + fn attribute_entry(&mut self) -> Result<(), PrcError> { + if self.r.bit()? { + self.r.unsigned_integer()?; + } else { + self.r.string()?; + } + Ok(()) + } + + /// One attribute value [WD 7.4.3], read past. + fn attribute_value(&mut self) -> Result<(), PrcError> { + match self.r.unsigned_integer()? { + 0 => {} + 1 | 3 => { + self.r.integer()?; + } + 2 => { + self.r.double()?; + } + 4 => { + self.r.string()?; + } + // prc-rs: a 64-bit integer is its high part, then its low part. + 5 => { + self.r.integer()?; + self.r.unsigned_integer()?; + } + k => return Err(malformed(format!("attribute value kind {k}"))), + } + Ok(()) + } + + /// `ContentPRCBase` [WD 7.2.3]: attributes, then the name; returns the entity's name, + /// which is also the current name afterwards. + pub(crate) fn content_prc_base(&mut self) -> Result, PrcError> { + let n = self.count(1, "attributes")?; + for _ in 0..n { + self.expect_type(ATTRIBUTE)?; + self.attribute_entry()?; + let pairs = self.count(2, "attribute pairs")?; + for _ in 0..pairs { + self.attribute_entry()?; + self.attribute_value()?; + } + self.schema.skip_added_fields(ATTRIBUTE, &mut self.r)?; + } + // `same_name` TRUE reuses the current name [WD 7.2.3.4]. + if !self.r.bit()? { + self.name = self.r.string()?; + } + Ok(self.name.clone()) + } + + /// `UserData` [WD 8.6]: a bit count, then that many opaque bits. + pub(crate) fn user_data(&mut self) -> Result<(), PrcError> { + let n = self.r.unsigned_integer()? as usize; + self.r.skip_bits(n) + } +} diff --git a/crates/prc/src/error.rs b/crates/prc/src/error.rs new file mode 100644 index 0000000000..c1d927a224 --- /dev/null +++ b/crates/prc/src/error.rs @@ -0,0 +1,46 @@ +//! The crate's error type. + +/// Why a PRC stream could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PrcError { + /// The data does not start with the `PRC` magic [WD 6.1.1]. + #[error("not a PRC stream: missing the \"PRC\" magic")] + NotPrc, + /// A value ran past the end of its byte or bit stream. + #[error("PRC data truncated while reading {0}")] + Truncated(&'static str), + /// A structural field holds a value the format forbids. + #[error("malformed PRC: {0}")] + Malformed(String), + /// No `Double` code of up to 22 bits matched [WD 11.17]. + #[error("PRC Double: no code matches the next 22 bits")] + UnknownDoubleCode, + /// A section is not a valid zlib stream. + #[error("PRC {section} section does not inflate: {reason}")] + Inflate { + /// The section's name. + section: &'static str, + /// The decoder's message. + reason: String, + }, + /// The inflated sections and copied blocks together exceed the ceiling + /// ([`crate::MAX_INFLATED_BYTES`] or the caller's limit). + #[error("PRC sections inflate past the {limit}-byte ceiling")] + TooLarge { + /// The ceiling that was hit. + limit: usize, + }, + /// The arrays read from one stream hold more elements than [`crate::MAX_DECODED_ELEMENTS`]. + /// One bit can decode to a 4- or 8-byte element, so the inflation ceiling alone does not + /// bound the memory the arrays take. + #[error("PRC arrays decode past the {limit}-element ceiling")] + TooManyElements { + /// The ceiling that was hit. + limit: usize, + }, + /// A construct this reader does not decode; the part of the file holding it cannot be + /// read past. + #[error("PRC {0} is not supported")] + Unsupported(&'static str), +} diff --git a/crates/prc/src/geometry.rs b/crates/prc/src/geometry.rs new file mode 100644 index 0000000000..c8d7680e05 --- /dev/null +++ b/crates/prc/src/geometry.rs @@ -0,0 +1,276 @@ +//! Model-space vectors and matrices, and their application to meshes. + +use crate::TriangleMesh; + +/// A point or direction in model space. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(crate) struct Vec3(pub(crate) [f64; 3]); + +impl Vec3 { + /// The right-handed cross product `self × b`. + pub(crate) fn cross(self, b: Vec3) -> Vec3 { + let ([ax, ay, az], [bx, by, bz]) = (self.0, b.0); + Vec3([ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx]) + } +} + +/// A 4×4 matrix, `m.0[row][col]`, acting on column vectors. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Matrix(pub [[f64; 4]; 4]); + +impl Default for Matrix { + fn default() -> Self { + Matrix::IDENTITY + } +} + +impl Matrix { + /// The identity matrix. + pub const IDENTITY: Matrix = Matrix([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]); + + /// The entry at `row`, `col`; 0 outside the matrix. + #[must_use] + pub fn at(&self, row: usize, col: usize) -> f64 { + self.0.get(row).and_then(|r| r.get(col)).copied().unwrap_or(0.0) + } + + /// `self × b`: `b` applied first. + #[must_use] + pub fn multiply(&self, b: &Matrix) -> Matrix { + let mut m = [[0.0; 4]; 4]; + for (i, row) in m.iter_mut().enumerate() { + for (j, cell) in row.iter_mut().enumerate() { + *cell = (0..4).map(|k| self.at(i, k) * b.at(k, j)).sum(); + } + } + Matrix(m) + } + + /// `self × (p, 1)`, divided by the homogeneous coordinate when it is neither 0 nor 1. + #[must_use] + pub fn transform_point(&self, p: [f64; 3]) -> [f64; 3] { + let [x, y, z] = p; + let row = |i: usize| self.at(i, 0) * x + self.at(i, 1) * y + self.at(i, 2) * z + self.at(i, 3); + let w = row(3); + let s = if w == 0.0 || w == 1.0 { 1.0 } else { 1.0 / w }; + [row(0) * s, row(1) * s, row(2) * s] + } + + /// The determinant of the upper-left 3×3 block. + fn det3(&self) -> f64 { + let a = |i, j| self.at(i, j); + a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0)) + + a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0)) + } + + /// The determinant of the whole matrix, by expansion along the last row. + fn det4(&self) -> f64 { + (0..4) + .map(|j| { + let cols: Vec = (0..4).filter(|&c| c != j).collect(); + let a = |i: usize, k: usize| cols.get(k).map_or(0.0, |&c| self.at(i, c)); + let minor = a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0)) + + a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0)); + let sign = if (3 + j) % 2 == 0 { 1.0 } else { -1.0 }; + sign * self.at(3, j) * minor + }) + .sum() + } + + /// Whether mapping points through this matrix turns them inside out. Where the point is + /// divided by `w`, the Jacobian's sign is the sign of the 4×4 determinant; a last row of + /// zeros (no division) falls back to the 3×3 block. Each row is first divided by its + /// largest entry, which keeps both signs while the products stay in range. + fn mirrors(&self) -> bool { + let scaled = Matrix(self.0.map(|row| { + let big = row.iter().fold(0.0_f64, |m, c| m.max(c.abs())); + if big.is_finite() && big > 0.0 { row.map(|c| c / big) } else { row } + })); + let det = scaled.det4(); + if det == 0.0 { scaled.det3() < 0.0 } else { det < 0.0 } + } + + /// Cofactor `(i, j)` of the upper-left 3×3 block (`i`, `j` < 3). + fn cofactor(&self, i: usize, j: usize) -> f64 { + let (r0, r1) = ((i + 1) % 3, (i + 2) % 3); + let (c0, c1) = ((j + 1) % 3, (j + 2) % 3); + self.at(r0, c0) * self.at(r1, c1) - self.at(r0, c1) * self.at(r1, c0) + } +} + +impl TriangleMesh { + /// This mesh with every position mapped through `m` and every stored normal through its + /// inverse transpose (renormalised); a mirroring `m` (negative 4×4 determinant, which a + /// negative `w` can cause too) reverses each triangle so the outside stays + /// counter-clockwise. A projective `m` (a last row other than + /// `[0, 0, 0, w]`) bends the surface per point, so the stored normals are dropped. + /// + /// ``` + /// use pdfcraft_prc::{Matrix, TriangleMesh}; + /// let mut mirror = Matrix::IDENTITY; + /// mirror.0[0][0] = -1.0; + /// let mut mesh = TriangleMesh::default(); + /// mesh.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + /// mesh.triangles = vec![[0, 1, 2]]; + /// let placed = mesh.transformed(&mirror); + /// assert_eq!(placed.positions, [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + /// assert_eq!(placed.triangles, [[0, 2, 1]]); + /// ``` + #[must_use] + pub fn transformed(&self, m: &Matrix) -> Self { + let mut out = self.clone(); + for p in &mut out.positions { + *p = m.transform_point(*p); + } + // The cofactor matrix is det times the inverse transpose, so its sign is corrected + // and the result renormalised. + let mirrors = m.mirrors(); + let sign = if mirrors { -1.0 } else { 1.0 }; + if m.at(3, 0) != 0.0 || m.at(3, 1) != 0.0 || m.at(3, 2) != 0.0 { + out.normals.clear(); + out.triangle_normals.clear(); + } + for n in &mut out.normals { + let [x, y, z] = *n; + let v = [0, 1, 2].map(|i| sign * (m.cofactor(i, 0) * x + m.cofactor(i, 1) * y + m.cofactor(i, 2) * z)); + let len = v.iter().map(|c| c * c).sum::().sqrt(); + *n = if len > 0.0 && len.is_finite() { v.map(|c| c / len) } else { v }; + } + if mirrors { + for t in &mut out.triangles { + t.swap(1, 2); + } + for t in &mut out.triangle_normals { + t.swap(1, 2); + } + for t in out.triangle_uvs.iter_mut().flatten().flatten() { + t.swap(1, 2); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cross_follows_the_right_hand_rule() { + assert_eq!(Vec3([1.0, 0.0, 0.0]).cross(Vec3([0.0, 1.0, 0.0])), Vec3([0.0, 0.0, 1.0])); + assert_eq!(Vec3([0.0, 1.0, 0.0]).cross(Vec3([1.0, 0.0, 0.0])), Vec3([0.0, 0.0, -1.0])); + } + + #[test] + fn multiply_applies_the_right_operand_first() { + let mut t = Matrix::IDENTITY; + t.0[0][3] = 5.0; + let mut s = Matrix::IDENTITY; + s.0[0][0] = 2.0; + assert_eq!(t.multiply(&s).transform_point([1.0, 0.0, 0.0]), [7.0, 0.0, 0.0]); + assert_eq!(s.multiply(&t).transform_point([1.0, 0.0, 0.0]), [12.0, 0.0, 0.0]); + } + + #[test] + fn a_projective_point_is_divided_by_w() { + let mut m = Matrix::IDENTITY; + m.0[3][3] = 2.0; + assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [1.0, 2.0, 3.0]); + m.0[3][3] = 0.0; + assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [2.0, 4.0, 6.0]); + } + + #[test] + fn normals_take_the_inverse_transpose_and_mirror_with_their_triangle() { + // A shear in x by y turns the plane x = 0 into x = y; its x normal tilts to stay perpendicular. + let mut shear = Matrix::IDENTITY; + shear.0[0][1] = 1.0; + let mesh = TriangleMesh { + positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + triangles: vec![[0, 1, 2]], + normals: vec![[1.0, 0.0, 0.0]], + triangle_normals: vec![[0, 0, 0]], + ..TriangleMesh::default() + }; + let n = shear.transformed_normal(&mesh); + let s = std::f64::consts::FRAC_1_SQRT_2; + assert!((n[0] - s).abs() < 1e-12 && (n[1] + s).abs() < 1e-12 && n[2] == 0.0, "{n:?}"); + + let mut mirror = Matrix::IDENTITY; + mirror.0[2][2] = -1.0; + let placed = mesh.transformed(&mirror); + assert_eq!(placed.triangles, [[0, 2, 1]]); + assert_eq!(placed.triangle_normals, [[0, 0, 0]]); + assert_eq!(placed.normals, [[1.0, 0.0, 0.0]]); + } + + #[test] + fn a_projective_transform_drops_the_stored_normals() { + let mesh = TriangleMesh { + positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + triangles: vec![[0, 1, 2]], + normals: vec![[1.0, 0.0, 1.0]], + triangle_normals: vec![[0, 0, 0]], + ..TriangleMesh::default() + }; + let mut projective = Matrix::IDENTITY; + projective.0[3][0] = 1.0; + let placed = mesh.transformed(&projective); + assert!(placed.normals.is_empty() && placed.triangle_normals.is_empty()); + // A uniform w keeps them. + let mut uniform = Matrix::IDENTITY; + uniform.0[3][3] = 2.0; + assert_eq!(mesh.transformed(&uniform).triangle_normals, [[0, 0, 0]]); + } + + /// A negative `w` turns the mapped triangles inside out though the 3×3 block does not + /// mirror, so the winding is reversed by the sign of the whole determinant. + #[test] + fn a_negative_w_reverses_the_winding() { + let mesh = TriangleMesh { + positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + triangles: vec![[0, 1, 2]], + normals: vec![[1.0, 0.0, 0.0]], + triangle_normals: vec![[0, 0, 0]], + ..TriangleMesh::default() + }; + let mut negated = Matrix::IDENTITY; + negated.0[3][3] = -1.0; + let placed = mesh.transformed(&negated); + assert_eq!(placed.positions, [[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]]); + assert_eq!(placed.triangles, [[0, 2, 1]]); + assert_eq!(placed.normals, [[-1.0, 0.0, 0.0]]); + // A projective w that is negative over the mesh: w = z - 3. + let mut projective = Matrix::IDENTITY; + projective.0[3][2] = 1.0; + projective.0[3][3] = -3.0; + assert_eq!(mesh.transformed(&projective).triangles, [[0, 2, 1]]); + // A positive w, uniform or projective, keeps the winding. + let mut positive = Matrix::IDENTITY; + positive.0[3][2] = 1.0; + positive.0[3][3] = 3.0; + assert_eq!(mesh.transformed(&positive).triangles, [[0, 1, 2]]); + } + + #[test] + fn a_mirror_whose_determinant_underflows_still_reverses_the_winding() { + let mesh = TriangleMesh { + positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + triangles: vec![[0, 1, 2]], + ..TriangleMesh::default() + }; + let diagonal = |x: f64| { + let mut m = Matrix::IDENTITY; + (m.0[0][0], m.0[1][1], m.0[2][2]) = (x, 1e-200, 1e-200); + m + }; + assert_eq!(mesh.transformed(&diagonal(-1e200)).triangles, [[0, 2, 1]]); + assert_eq!(mesh.transformed(&diagonal(1e200)).triangles, [[0, 1, 2]]); + } + + impl Matrix { + fn transformed_normal(&self, mesh: &TriangleMesh) -> [f64; 3] { + mesh.transformed(self).normals.first().copied().unwrap_or_default() + } + } +} diff --git a/crates/prc/src/lib.rs b/crates/prc/src/lib.rs new file mode 100644 index 0000000000..d594e74065 --- /dev/null +++ b/crates/prc/src/lib.rs @@ -0,0 +1,37 @@ +//! A reader for PRC, the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries +//! (ISO 32000-2 §13.6.3, ISO 14739-1). +//! +//! Layer L0, standalone: this crate depends on no other PdfCraft crate. It reads the +//! uncompressed container, inflates each section, and decodes the bit-level primitives and +//! the schema a newer producer uses to describe fields this reader does not know; it decodes +//! tessellations and walks the product tree into placed, coloured meshes +//! ([`PrcFile::placements`], [`PrcFile::model_tree`]). +//! +//! Clause numbers in `[WD x.y]` refer to the PRC working draft, ISO/TC 171/SC 2 N570. +//! +//! Every read is bounded: inflation by [`MAX_INFLATED_BYTES`] (or a caller's limit), decoded +//! array elements by [`MAX_DECODED_ELEMENTS`] and schema-program steps by [`MAX_SCHEMA_STEPS`] +//! per reading pass, schema programs by a depth ceiling. Damaged data is an error, never a panic. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod acof; +mod arrays; +mod bits; +mod container; +mod entity; +mod error; +mod geometry; +mod schema; +mod tess; +#[cfg(test)] +mod testw; +mod tree; + +pub use bits::{BitReader, MAX_DECODED_ELEMENTS, MAX_SCHEMA_STEPS}; +pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId}; +pub use error::PrcError; +pub use geometry::Matrix; +pub use schema::Schema; +pub use tess::{Tessellation, TriangleMesh, TriangleStyle}; +pub use tree::{EntityOverrides, MAX_PLACEMENTS, ModelNode, NameSource, Placement, Rgba, StyleAlpha}; diff --git a/crates/prc/src/schema.rs b/crates/prc/src/schema.rs new file mode 100644 index 0000000000..2b505c14ea --- /dev/null +++ b/crates/prc/src/schema.rs @@ -0,0 +1,565 @@ +//! `FileStructureSchema` and its token interpreter [WD 6.3, 9]. +//! +//! A producer newer than the reader describes, per entity type, the fields it appended; the +//! reader runs that program after the fields it knows, to step over the rest [WD 9.1]. Token +//! values follow the WD 9.2 table as corrected by PDF Association pdf-issues #407 +//! (`Block_Start` = 19; 39 and 40 swallow one token). +//! +//! Choices the sources leave open, made here: +//! - `SimpleFor` reads its count as an `UnsignedInteger` [WD 9.3.17 pseudocode; pdf-issues +//! #575]: that read also decodes an `Integer`-written non-negative count, while a signed +//! read does not decode an unsigned one whose top group is 0x80 or higher. +//! - `Block_Version V` runs its block only when `V` is newer than +//! [`crate::PRC_READER_VERSION`]: an older block holds fields this reader already reads +//! natively [WD 9.3.20]. +//! - `Extent_1D` and `Extent_2D` read 2 and 4 `Double`s: `Interval` and `Domain` +//! [WD 8.2.2, 8.2.4]. +//! - Expression values are `i64`; a `Double` read as an operand truncates (NaN reads as 0). +//! - Variables are not block-scoped: a program that reuses an index after its block ends +//! reads the stale value instead of an error. +//! - Pointer tokens (12-14) and `Value_CurveIs3D` (28) need the exact-geometry reader and are +//! refused with [`PrcError::Unsupported`]. + +use std::collections::HashMap; + +use crate::PrcError; +use crate::bits::BitReader; + +/// Nesting depth of blocks, loops, expressions and `Father_Type` calls. +const MAX_DEPTH: u32 = 64; + +/// The per-entity-type programs at the start of a globals or model-file +/// bitstream [WD 6.3.1]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Schema { + programs: HashMap>, +} + +impl Schema { + /// Read a schema: `UnsignedInteger` count, then per entry the entity + /// type, a token count and the tokens [WD 6.3.2]. + /// + /// # Errors + /// [`PrcError::Truncated`] when a count runs past the data; + /// [`PrcError::Malformed`] for an entity type listed twice; [`PrcError::TooManyElements`] + /// past the reader's allowance, one element per entry and per token. + pub fn read(r: &mut BitReader<'_>) -> Result { + let n = r.unsigned_integer()? as usize; + // Every entry takes at least two bits. + if n > r.remaining() / 2 { + return Err(PrcError::Truncated("the schema")); + } + let mut programs = HashMap::new(); + for _ in 0..n { + // The entry itself costs one element, so empty programs are not free. + r.take_elements(1)?; + let entity_type = r.unsigned_integer()?; + let len = r.unsigned_integer()? as usize; + if len > r.remaining() { + return Err(PrcError::Truncated("a schema program")); + } + r.take_elements(len)?; + let tokens = (0..len).map(|_| r.unsigned_integer()).collect::, _>>()?; + if programs.insert(entity_type, tokens).is_some() { + return Err(PrcError::Malformed(format!("schema lists entity type {entity_type} twice"))); + } + } + Ok(Self { programs }) + } + + /// True when no entity type has added fields (the common case: the file + /// was written at the version this reader implements). + pub fn is_empty(&self) -> bool { + self.programs.is_empty() + } + + /// The entity types the schema describes, unordered. + pub fn entity_types(&self) -> impl Iterator + '_ { + self.programs.keys().copied() + } + + /// Consume the fields a newer producer appended to `entity_type`, if the + /// schema describes it; a no-op otherwise. Each step is charged to `r`, so every + /// program run on one stream shares [`crate::MAX_SCHEMA_STEPS`]. + /// + /// # Errors + /// [`PrcError::Unsupported`] for a token that needs exact geometry; + /// [`PrcError::Malformed`] for an ill-formed program, one past the depth ceiling, or + /// one that runs out of the stream's steps; any read error from `r`. + pub fn skip_added_fields(&self, entity_type: u32, r: &mut BitReader<'_>) -> Result<(), PrcError> { + let Some(program) = self.programs.get(&entity_type) else { + return Ok(()); + }; + let mut exec = Exec { schema: self, r, vars: HashMap::new(), loop_counter: 0 }; + exec.program(program, 0) + } +} + +struct Exec<'s, 'r, 'd> { + schema: &'s Schema, + r: &'r mut BitReader<'d>, + vars: HashMap, + loop_counter: i64, +} + +fn malformed(what: &str) -> PrcError { + PrcError::Malformed(format!("schema program: {what}")) +} + +/// The token at `*pos`, advancing past it. +fn next(prog: &[u32], pos: &mut usize) -> Result { + let t = prog.get(*pos).copied().ok_or_else(|| malformed("ends mid-statement"))?; + *pos += 1; + Ok(t) +} + +impl Exec<'_, '_, '_> { + fn tick(&mut self, depth: u32) -> Result<(), PrcError> { + if !self.r.take_step() { + return Err(malformed("step ceiling reached")); + } + if depth > MAX_DEPTH { + return Err(malformed("nesting ceiling reached")); + } + Ok(()) + } + + fn program(&mut self, prog: &[u32], depth: u32) -> Result<(), PrcError> { + let mut pos = 0; + while pos < prog.len() { + self.stmt(prog, &mut pos, true, depth)?; + } + Ok(()) + } + + /// One data read for tokens 0-5 and 7-11, returning its value as an + /// operand. + fn read(&mut self, token: u32) -> Result { + let doubles = match token { + 0 => return Ok(i64::from(self.r.bit()?)), + 1 => return Ok(self.r.double()? as i64), + 2 => return Ok(i64::from(self.r.character()?)), + 3 => return Ok(i64::from(self.r.unsigned_integer()?)), + 4 => return Ok(i64::from(self.r.integer()?)), + 5 => { + self.r.string()?; + return Ok(0); + } + 7 => 2, + 8 => 3, + 9 => 2, + 10 => 4, + 11 => 6, + _ => return Err(malformed("not a data token")), + }; + for _ in 0..doubles { + self.r.double()?; + } + Ok(0) + } + + /// Statements until `Block_End`, which is consumed. + fn block(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + loop { + if prog.get(*pos) == Some(&21) { + *pos += 1; + return Ok(()); + } + self.stmt(prog, pos, run, depth + 1)?; + } + } + + /// Run `body` (one statement at `*pos`) `count` times; `*pos` ends past it. + fn repeat(&mut self, prog: &[u32], pos: &mut usize, count: i64, run: bool, depth: u32) -> Result<(), PrcError> { + let start = *pos; + self.stmt(prog, pos, false, depth + 1)?; + if run { + let saved = self.loop_counter; + for i in 0..count.max(0) { + self.tick(depth)?; + self.loop_counter = i; + let mut p = start; + self.stmt(prog, &mut p, true, depth + 1)?; + } + self.loop_counter = saved; + } + Ok(()) + } + + /// One statement; with `run` false, only step over its tokens. + fn stmt(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + self.tick(depth)?; + match next(prog, pos)? { + t @ (0..=5 | 7..=11) => { + if run { + self.read(t)?; + } + } + 6 => self.parent_program(prog, pos, run, depth)?, + 12 => { + next(prog, pos)?; + if run { + return Err(PrcError::Unsupported("a schema pointer field")); + } + } + 13 | 14 => { + if run { + return Err(PrcError::Unsupported("a schema pointer field")); + } + } + 15 => { + let count = self.expr(prog, pos, run, depth + 1)?; + self.repeat(prog, pos, count, run, depth)?; + } + 16 => { + // prc-rs: reads SimpleFor's count as an Integer; this reader deliberately reads an + // UnsignedInteger (module doc). + let count = if run { i64::from(self.r.unsigned_integer()?) } else { 0 }; + self.repeat(prog, pos, count, run, depth)?; + } + 17 => self.conditional(prog, pos, run, depth)?, + 19 => self.block(prog, pos, run, depth)?, + 20 => { + let version = next(prog, pos)?; + let newer = version > crate::PRC_READER_VERSION; + self.block(prog, pos, run && newer, depth)?; + } + 22 => { + let v = next(prog, pos)?; + if run { + self.vars.insert(v, 0); + } + } + 23 | 24 => { + let v = next(prog, pos)?; + let value = self.expr(prog, pos, run, depth + 1)?; + if run { + self.vars.insert(v, value); + } + } + 39 | 40 => { + next(prog, pos)?; + } + t => return Err(malformed(&format!("token {t} where a statement belongs"))), + } + Ok(()) + } + + /// Token 6: run the named type's own program, the fields it inherits. + fn parent_program(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + let parent = next(prog, pos)?; + if run && let Some(p) = self.schema.programs.get(&parent) { + self.tick(depth + 1)?; + let mut q = 0; + while q < p.len() { + self.stmt(p, &mut q, true, depth + 1)?; + } + } + Ok(()) + } + + /// Token 17: `if` with an optional token-18 `else`. + fn conditional(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + let cond = self.expr(prog, pos, run, depth + 1)? != 0; + self.stmt(prog, pos, run && cond, depth + 1)?; + if prog.get(*pos) == Some(&18) { + *pos += 1; + self.stmt(prog, pos, run && !cond, depth + 1)?; + } + Ok(()) + } + + /// One expression; with `run` false its value is 0 and nothing is read. + fn expr(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result { + self.tick(depth)?; + let t = next(prog, pos)?; + match t { + 0..=4 => { + if run { + self.read(t) + } else { + Ok(0) + } + } + 25 => { + let v = next(prog, pos)?; + if !run { + return Ok(0); + } + self.vars.get(&v).copied().ok_or_else(|| malformed(&format!("variable {v} read before it is set"))) + } + 26 => Ok(i64::from(next(prog, pos)?)), + 27 => Ok(self.loop_counter), + 28 => { + if run { + Err(PrcError::Unsupported("Value_CurveIs3D")) + } else { + Ok(0) + } + } + 29..=38 => { + let a = self.expr(prog, pos, run, depth + 1)?; + let b = self.expr(prog, pos, run, depth + 1)?; + if !run { + return Ok(0); + } + let overflow = || malformed("a schema expression overflows"); + Ok(match t { + 29 => a.checked_mul(b).ok_or_else(overflow)?, + 30 => a.checked_div(b).ok_or_else(|| malformed("division by zero"))?, + 31 => a.checked_add(b).ok_or_else(overflow)?, + 32 => a.checked_sub(b).ok_or_else(overflow)?, + 33 => i64::from(a < b), + 34 => i64::from(a <= b), + 35 => i64::from(a > b), + 36 => i64::from(a >= b), + 37 => i64::from(a == b), + _ => i64::from(a != b), + }) + } + t => Err(malformed(&format!("token {t} where a value belongs"))), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::testw::W; + + fn schema(entries: &[(u32, &[u32])]) -> W { + let mut w = W::default(); + w.uint(entries.len() as u32); + for (t, toks) in entries { + w.uint(*t).uint(toks.len() as u32); + for k in *toks { + w.uint(*k); + } + } + w + } + + /// Each schema entry and its program's tokens are charged against the reader's element + /// allowance. + #[test] + fn program_tokens_are_charged_against_the_allowance() { + let bytes = schema(&[(172, &[1, 2, 3])]).bytes(); + assert!(matches!(Schema::read(&mut BitReader::with_elements(&bytes, 3)), Err(PrcError::TooManyElements { .. }))); + assert!(Schema::read(&mut BitReader::with_elements(&bytes, 4)).is_ok()); + let empty = schema(&[(172, &[]), (173, &[]), (174, &[])]).bytes(); + assert!(matches!(Schema::read(&mut BitReader::with_elements(&empty, 2)), Err(PrcError::TooManyElements { .. }))); + assert!(Schema::read(&mut BitReader::with_elements(&empty, 3)).is_ok()); + } + + fn run(entries: &[(u32, &[u32])], data: &W) -> (Result<(), PrcError>, usize) { + let mut w = schema(entries); + w.append(data); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let start = r.position(); + let res = s.skip_added_fields(172, &mut r); + (res, r.position() - start) + } + + #[test] + fn integer_string_and_boolean_tokens_consume_their_fields() { + let mut d = W::default(); + d.bit(true).uint(300).int(-7).string(Some("ab")).put(0x41, 8); + let want = d.len(); + d.uint(99); // not part of the added fields + let (res, used) = run(&[(172, &[0, 3, 4, 5, 2])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn data_tokens_consume_their_fields() { + let mut d = W::default(); + d.bit(true).double(1.5).uint(300).int(-7).string(Some("ab")).double(2.0).double(3.0); + let want = d.len(); + d.uint(99); // not part of the added fields + let (res, used) = run(&[(172, &[0, 1, 3, 4, 5, 7])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn vector_extent_and_box_tokens_read_their_doubles() { + // Vec3, Extent1D, Extent2D, BBox: 3 + 2 + 4 + 6 Doubles. + let mut d = W::default(); + for i in 0..15 { + d.double(f64::from(i) + 0.5); + } + let want = d.len(); + d.uint(99); + let (res, used) = run(&[(172, &[8, 9, 10, 11])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn arithmetic_and_comparison_operators() { + // For (5 - 3) { UInt }, then If (2 < 2) { Bool } Else { Char }. + let mut d = W::default(); + d.uint(7).uint(8).put(0x41, 8); + let want = d.len(); + d.uint(99); + let prog = [15, 32, 26, 5, 26, 3, 3, 17, 33, 26, 2, 26, 2, 0, 18, 2]; + let (res, used) = run(&[(172, &prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn an_absent_type_is_a_no_op() { + let mut w = schema(&[(300, &[3])]); + w.uint(5); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let at = r.position(); + s.skip_added_fields(172, &mut r).unwrap(); + assert_eq!(r.position(), at); + assert!(!s.is_empty()); + } + + #[test] + fn simple_for_reads_its_count_then_repeats_the_body() { + let mut d = W::default(); + d.int(3).uint(1).uint(2).uint(3); + let want = d.len(); + d.uint(77); + let (res, used) = run(&[(172, &[16, 3])], &d); + res.unwrap(); + assert_eq!(used, want); + + // A block body. + let mut d = W::default(); + d.int(2).bit(true).uint(9).bit(false).uint(8); + let want = d.len(); + let (res, used) = run(&[(172, &[16, 19, 0, 3, 21])], &d); + res.unwrap(); + assert_eq!(used, want); + + // A count whose top byte is 0x80 or higher, written either way. + for signed in [false, true] { + let mut d = W::default(); + if signed { + d.int(200); + } else { + d.uint(200); + } + (0..200).for_each(|_| { + d.bit(true); + }); + let want = d.len(); + d.uint(77); + let (res, used) = run(&[(172, &[16, 0])], &d); + res.unwrap(); + assert_eq!(used, want, "signed={signed}"); + } + } + + #[test] + fn if_else_and_variables_choose_the_branch_by_file_data() { + // DeclareAndSet v0 = ; if v0 > 1 { UInt } else { Character }. + let prog: &[u32] = &[24, 0, 3, 17, 35, 25, 0, 26, 1, 3, 18, 2]; + let mut d = W::default(); + d.uint(5).uint(4250); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + + let mut d = W::default(); + d.uint(0).put(0xAB, 8); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn for_counts_by_expression_and_exposes_the_counter() { + // For (2 * 2) { if counter == 2 { UInt } } => one UInt read. + let prog: &[u32] = &[15, 29, 26, 2, 26, 2, 17, 37, 27, 26, 2, 3]; + let mut d = W::default(); + d.uint(1234); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn an_expression_that_overflows_is_malformed() { + // For ((2^31 * 2^31) * 4) { UInt }: 2^64 would wrap to 0 and read nothing. + let prog: &[u32] = &[15, 29, 29, 26, 1 << 31, 26, 1 << 31, 26, 4, 3]; + let (res, _) = run(&[(172, prog)], &W::default()); + assert!(matches!(res, Err(PrcError::Malformed(m)) if m.contains("overflows"))); + } + + #[test] + fn version_blocks_run_only_when_newer_than_the_reader() { + let mut d = W::default(); + d.uint(42); + let want = d.len(); + let (res, used) = run(&[(172, &[20, 8137, 3, 21, 20, 9000, 3, 21])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn father_type_runs_the_parent_program_and_obsolete_tokens_swallow_one() { + let mut d = W::default(); + d.uint(1).uint(2); + let want = d.len(); + let (res, used) = run(&[(172, &[39, 3, 6, 50, 40, 8]), (50, &[3, 3])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn hostile_programs_are_refused_not_run_forever() { + let d = W::default(); + // For (1000000 * 1000000) { empty block }. + let prog: &[u32] = &[15, 29, 26, 1_000_000, 26, 1_000_000, 19, 21]; + assert!(matches!(run(&[(172, prog)], &d).0, Err(PrcError::Malformed(_)))); + // Father_Type recursing into itself. + assert!(matches!(run(&[(172, &[6, 172])], &d).0, Err(PrcError::Malformed(_)))); + // A pointer field. + assert!(matches!(run(&[(172, &[13])], &d).0, Err(PrcError::Unsupported(_)))); + // A stray Block_End, an unterminated block, a division by zero. + assert!(matches!(run(&[(172, &[21])], &d).0, Err(PrcError::Malformed(_)))); + assert!(matches!(run(&[(172, &[19, 0])], &d).0, Err(PrcError::Malformed(_)))); + assert!(matches!(run(&[(172, &[23, 0, 30, 26, 1, 26, 0])], &d).0, Err(PrcError::Malformed(_)))); + } + + /// Every program run on one reader draws on one step allowance. + #[test] + fn programs_on_one_stream_share_the_step_allowance() { + // For (2^19) { empty block }: about 2^20 steps, so the 2^22 allowance fits three runs. + let bytes = schema(&[(172, &[15, 26, 1 << 19, 19, 21])]).bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let runs = (0..5).take_while(|_| s.skip_added_fields(172, &mut r).is_ok()).count(); + assert_eq!(runs, 3); + assert!(matches!(s.skip_added_fields(172, &mut r), Err(PrcError::Malformed(m)) if m.contains("step ceiling"))); + } + + #[test] + fn a_duplicated_entity_type_is_malformed() { + let bytes = schema(&[(172, &[3]), (172, &[3])]).bytes(); + assert!(matches!(Schema::read(&mut BitReader::new(&bytes)), Err(PrcError::Malformed(_)))); + } + + #[test] + fn an_entry_count_past_the_data_is_refused_before_any_entry_is_read() { + let mut w = W::default(); + w.uint(1_000).uint(172).uint(0); + let bytes = w.bytes(); + assert_eq!(Schema::read(&mut BitReader::new(&bytes)).unwrap_err(), PrcError::Truncated("the schema")); + } +} diff --git a/crates/prc/src/tess.rs b/crates/prc/src/tess.rs new file mode 100644 index 0000000000..c9bffb30f4 --- /dev/null +++ b/crates/prc/src/tess.rs @@ -0,0 +1,440 @@ +//! The tessellation section: `FileStructureTessellation` (305) and its entities, `TESS_3D` +//! (172) with its `TESS_Face`s (174), `TESS_3D_Wire` (175), `TESS_Markup` (176) and +//! `TESS_3D_Compressed` (173) [WD 7.3.7, 7.8]. +//! +//! `TESS_3D_Compressed` is read field by field to its end [WD 7.8.9.7] so the entities after +//! it decode; its triangles are not rebuilt. +//! +//! The schema runs after the fields of each concrete type read here (201, 172, 173, 174, 175, +//! 176, 305), not for the abstract levels (`PRCBase`, `TESS`). + +mod triangulate; + +use crate::arrays; +use crate::bits::{BitReader, Budget}; +use crate::entity::{Ctx, malformed}; +use crate::{FileStructure, PrcError, SectionKind}; +use triangulate::{Corners, Face, triangle_elements}; + +const FILE_STRUCTURE_TESSELLATION: u32 = 305; +const TESS_3D: u32 = 172; +const TESS_3D_COMPRESSED: u32 = 173; +const TESS_FACE: u32 = 174; +const TESS_3D_WIRE: u32 = 175; +const TESS_MARKUP: u32 = 176; + +/// prc-rs: `origin` exists in a compressed tessellation from this version (pdf-issues #705). +const ORIGIN_FROM: u32 = 7031; +/// prc-rs: `has_loops` exists from this file-structure authoring version. +const HAS_LOOPS_FROM: u32 = 7039; +/// prc-rs: `must_recalculate_normals` exists from this version. +const RECALCULATE_FROM: u32 = 7047; + +/// A wire header's count bits; the top four are flags [WD 7.8.7.5]. +const WIRE_COUNT_MASK: u32 = 0x0FFF_FFFF; +const WIRE_IS_CLOSING: u32 = 0x1000_0000; +const WIRE_IS_CONTINUOUS: u32 = 0x2000_0000; + +/// Normal angles wider than this are not a `ShortArray` [WD 7.8.9.7]. +const MAX_ANGLE_BITS: u32 = 16; + +/// One entry of the tessellation array; a representation item's `index_tessellation` selects +/// one by position. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Tessellation { + /// A `TESS_3D` triangle mesh. + Mesh(TriangleMesh), + /// A `TESS_3D_Wire`: polylines. + Wire(Vec>), + /// A `TESS_Markup` (PMI drawing), read past but not decoded. + Markup, + /// A `TESS_3D_Compressed` mesh, read past but not rebuilt. + #[non_exhaustive] + Compressed { + /// The entity's triangle count. + triangles: usize, + }, +} + +/// A `TESS_3D` decoded to indexed triangles, in the entity's own frame (the representation +/// item's placement is not applied). +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct TriangleMesh { + /// The coordinate array as points. + pub positions: Vec<[f64; 3]>, + /// Counter-clockwise triangles seen from the outside, as indices into + /// [`Self::positions`] [WD 7.8.5.2]. + pub triangles: Vec<[u32; 3]>, + /// `triangles` split by `TESS_Face`: face `i` is `triangles[faces[i].clone()]`. + pub faces: Vec>, + /// The producer asks the reader to compute normals (none are stored). + pub normals_recalculated: bool, + /// The stored vertex normals [WD 7.8.5.1], as written (not renormalised); empty when the + /// mesh stores none or any of its normal indices is unusable. + pub normals: Vec<[f64; 3]>, + /// Per triangle, the index into [`Self::normals`] of each corner's normal, in the corner + /// order of [`Self::triangles`]; empty exactly when [`Self::normals`] is. + pub triangle_normals: Vec<[u32; 3]>, + /// Per triangle, the style its face's line attributes give it [WD 7.8.6]; empty when no + /// face carries any. + pub triangle_styles: Vec, + /// The stored texture coordinates as (u, v) pairs [WD 7.8.5.1]; empty when the mesh + /// stores none. + pub uvs: Vec<[f64; 2]>, + /// Per texture-coordinate set, per triangle, the index into [`Self::uvs`] of each + /// corner's coordinate, in the corner order of [`Self::triangles`]; each set is as long + /// as [`Self::triangles`], `None` for a triangle whose face stores no coordinate for the + /// set. Empty when [`Self::uvs`] is. + pub triangle_uvs: Vec>>, +} + +/// The graphics a face's `line_attributes` give one triangle [WD 7.8.6]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct TriangleStyle { + /// An index into the globals' style array plus one; 0 keeps the owner's graphics. + pub style: u32, + /// The face's behaviour bits [WD 5.4]. + pub behaviour: u16, +} + +impl FileStructure { + /// The tessellation section's entities, in the order a representation item's + /// `index_tessellation` counts them [WD 7.3.7]. + /// + /// # Errors + /// As [`crate::Schema::read`] for the globals' schema; [`PrcError::Truncated`] or + /// [`PrcError::Malformed`] for a damaged section; [`PrcError::Unsupported`] for + /// optimised vertex colours. + pub fn tessellations(&self) -> Result, PrcError> { + self.tessellations_within(Budget::FULL) + } + + /// As [`Self::tessellations`], the globals' schema and the section sharing `budget`. + pub(crate) fn tessellations_within(&self, budget: Budget) -> Result, PrcError> { + let mut globals = BitReader::with_budget(self.section(SectionKind::Globals), budget); + let schema = crate::Schema::read(&mut globals)?; + let r = BitReader::with_budget(self.section(SectionKind::Tessellation), globals.budget()); + Ctx::new(r, &schema, self.authoring_version).file_structure_tessellation() + } +} + +impl Ctx<'_, '_> { + /// `FileStructureTessellation` (305) [WD 7.3.7]. + pub(crate) fn file_structure_tessellation(&mut self) -> Result, PrcError> { + self.expect_type(FILE_STRUCTURE_TESSELLATION)?; + self.content_prc_base()?; + let n = self.count(1, "tessellations")?; + let mut out = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + out.push(match self.r.unsigned_integer()? { + TESS_3D => Tessellation::Mesh(self.tess_3d()?), + TESS_3D_WIRE => Tessellation::Wire(self.wire()?), + TESS_MARKUP => { + self.markup()?; + Tessellation::Markup + } + TESS_3D_COMPRESSED => Tessellation::Compressed { triangles: self.tess_3d_compressed()? }, + t => return Err(malformed(format!("entity type {t} in the tessellation array"))), + }); + } + self.schema.skip_added_fields(FILE_STRUCTURE_TESSELLATION, &mut self.r)?; + self.user_data()?; + Ok(out) + } + + /// `ContentBaseTessData` [WD 7.8.4]: `is_calculated`, then coordinates. + fn base_tess_data(&mut self) -> Result, PrcError> { + self.r.bit()?; + self.doubles("tessellation coordinates") + } + + /// `VertexColors` [WD 7.8.7.2] for `count` points, read past. + fn vertex_colors(&mut self, count: usize, in_wire: bool) -> Result<(), PrcError> { + let rgba = self.r.bit()?; + // prc-rs: `is_segment_color` exists only inside a wire. + let per_segment = in_wire && self.r.bit()?; + if self.r.bit()? { + return Err(PrcError::Unsupported("optimised vertex colours")); + } + let count = if per_segment { count / 2 } else { count }; + let bytes = if rgba { 4 } else { 3 }; + // The first colour is `8 * bytes` bits; every later one is at least its reuse bit. + if count > 0 && (count - 1).saturating_add(8 * bytes) > self.r.remaining() { + return Err(PrcError::Truncated("vertex colours")); + } + for i in 0..count { + if i == 0 || !self.r.bit()? { + for _ in 0..bytes { + self.r.character()?; + } + } + } + Ok(()) + } + + /// `TESS_3D` (172) after its type code [WD 7.8.5]. + fn tess_3d(&mut self) -> Result { + let coords = self.base_tess_data()?; + let _has_faces = self.r.bit()?; + if self.version >= HAS_LOOPS_FROM { + let _has_loops = self.r.bit()?; + } + let recalc = self.version >= RECALCULATE_FROM && self.r.bit()?; + if recalc { + let _flags = self.r.character()?; + let _crease_angle = self.r.double()?; + } + let normal_coords = self.doubles("normal coordinates")?; + let _wire_indices = self.uints("wire indices")?; + let indices = self.uints("triangulated indices")?; + let n_faces = self.count(1, "faces")?; + let mut faces = Vec::with_capacity(n_faces.min(1024)); + for _ in 0..n_faces { + self.expect_type(TESS_FACE)?; + faces.push(self.face()?); + } + let texture = self.doubles("texture coordinates")?; + self.schema.skip_added_fields(TESS_3D, &mut self.r)?; + + let mut mesh = TriangleMesh { positions: points(&coords)?, normals_recalculated: recalc, ..TriangleMesh::default() }; + let styled = faces.iter().any(|f| !f.styles.is_empty()); + let mut corners = Corners::default(); + // Every triangle carries a corner triple for each texture set any face uses. + let sets = faces.iter().map(Face::texture_sets).max().unwrap_or(0); + let per_triangle = triangle_elements(!recalc, sets, styled); + for f in &faces { + // Faces may reuse one index range, so each face's triangles, with what they + // carry, are charged before they are made. + self.r.take_elements(f.triangle_count()?.saturating_mul(per_triangle))?; + let start = mesh.triangles.len(); + let entities = f.triangulate(&indices, !recalc, mesh.positions.len(), &mut mesh.triangles, &mut corners)?; + mesh.faces.push(start..mesh.triangles.len()); + if styled { + f.styles_into(&entities, &mut mesh.triangle_styles); + } + } + (mesh.normals, mesh.triangle_normals) = stored_normals(&normal_coords, &corners.normals, mesh.triangles.len()); + (mesh.uvs, mesh.triangle_uvs) = stored_uvs(&texture, corners.textures); + Ok(mesh) + } + + /// `TESS_Face` (174) after its type code [WD 7.8.6]. + fn face(&mut self) -> Result { + let styles = self.uints("face line attributes")?; + let _start_of_wire = self.r.unsigned_integer()?; + let _sizes_wire = self.uints("face wire sizes")?; + let flags = self.r.unsigned_integer()?; + let start = self.r.unsigned_integer()? as usize; + let data = self.uints("triangulated data")?; + let textures = self.r.unsigned_integer()? as usize; + let mut face = Face { flags, start, data, textures, styles, behaviour: 0 }; + if self.r.bit()? { + let n = face.point_count()?; + self.vertex_colors(n, false)?; + } + if !face.styles.is_empty() { + let bits = self.r.unsigned_integer()?; + face.behaviour = u16::try_from(bits).map_err(|_| malformed(format!("face behaviour {bits} is wider than 16 bits")))?; + } + self.schema.skip_added_fields(TESS_FACE, &mut self.r)?; + Ok(face) + } + + /// `TESS_3D_Wire` (175) after its type code [WD 7.8.7]. + fn wire(&mut self) -> Result>, PrcError> { + let pts = points(&self.base_tess_data()?)?; + let words = self.uints("wire indices")?; + let mut wires: Vec> = Vec::new(); + let mut colours = 0usize; + if words.is_empty() { + colours = pts.len(); + if !pts.is_empty() { + wires.push(pts.clone()); + } + } + let mut it = words.iter().copied(); + while let Some(header) = it.next() { + let n = (header & WIRE_COUNT_MASK) as usize; + let mut wire = Vec::with_capacity(n.min(words.len())); + for _ in 0..n { + let idx = it.next().ok_or_else(|| malformed("wire runs past its indices".into()))?; + wire.push(point_at(&pts, idx)?); + } + colours = colours.saturating_add(n); + if header & WIRE_IS_CLOSING != 0 { + colours = colours.saturating_add(1); + if let Some(&first) = wire.first() { + wire.push(first); + } + } + match wires.last_mut() { + Some(prev) if header & WIRE_IS_CONTINUOUS != 0 => prev.extend(wire), + _ => wires.push(wire), + } + } + if self.r.bit()? { + self.vertex_colors(colours, true)?; + } + self.schema.skip_added_fields(TESS_3D_WIRE, &mut self.r)?; + Ok(wires) + } + + /// `TESS_Markup` (176) after its type code [WD 7.8.8], read past. + fn markup(&mut self) -> Result<(), PrcError> { + self.base_tess_data()?; + self.uints("markup codes")?; + let n = self.count(1, "markup strings")?; + for _ in 0..n { + self.r.string()?; + } + self.r.string()?; + self.r.character()?; + self.schema.skip_added_fields(TESS_MARKUP, &mut self.r) + } + + /// `TESS_3D_Compressed` (173) after its type code [WD 7.8.9.7], read past; returns its + /// triangle count. prc-rs: the field order and version gates follow its schema. + /// + /// Sizes the WD leaves unstated: the face count is the largest face index + 1, + /// `normal_is_reversed` is one bit per triangle, and the point-reference array's + /// compressed flag is implicit (more than three references). + fn tess_3d_compressed(&mut self) -> Result { + let r = &mut self.r; + r.bit()?; // is_calculated + r.bit()?; // has_faces + r.double()?; // tolerance + if self.version >= ORIGIN_FROM { + for _ in 0..3 { + r.float_as_bytes()?; + } + } + arrays::compressed_integer_array(r)?; // point_array + let edge_status = arrays::character_array(r, 2, None, false)?; + let face_of = arrays::compressed_indice_array(r, None)?; + let t = face_of.len(); + if edge_status.len() != t && edge_status.len() != t.saturating_mul(3) { + return Err(malformed(format!("{} edge statuses for {t} triangles", edge_status.len()))); + } + let faces = face_of.iter().max().map_or(0, |&m| (m as usize).saturating_add(1)); + let n = r.unsigned_integer()? as usize; + let references = arrays::bool_array(r, n)?.iter().filter(|&&b| b).count(); + let refs = arrays::compressed_indice_array(r, Some(references > 3))?; + if refs.len() != references { + return Err(malformed(format!("{} point references for {references} flagged points", refs.len()))); + } + self.compressed_normals(t, faces)?; + let r = &mut self.r; + if r.bit()? { + arrays::bool_array(r, faces)?; // is_point_color_on_face + arrays::character_array(r, 8, None, false)?; // point_color_array + } + if r.bit()? { + arrays::bool_array(r, faces)?; // is_multiple_line_attribute_on_face + } + arrays::short_array(r, 16)?; // line_attributes + if !r.bit()? { + self.compressed_texture_parameter()?; + if !self.r.bit()? { + arrays::bool_array(&mut self.r, faces)?; // face_has_texture + } + } + if self.r.bit()? { + arrays::character_array(&mut self.r, 8, None, false)?; // behaviours + } + self.schema.skip_added_fields(TESS_3D_COMPRESSED, &mut self.r)?; + Ok(t) + } + + /// A compressed tessellation's normals, from `must_recalculate_normals` on, read past. + fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result<(), PrcError> { + let r = &mut self.r; + if r.bit()? { + arrays::bool_array(r, triangles)?; // normal_is_reversed + r.double()?; // crease_angle + r.character()?; // normal_recalculation_flags + return Ok(()); + } + let angle_bits = u32::from(r.character()?); + if angle_bits > MAX_ANGLE_BITS { + return Err(malformed(format!("{angle_bits}-bit normal angles"))); + } + let n = r.unsigned_integer()? as usize; + arrays::bool_array(r, n)?; // is_binary_normal + arrays::short_array(r, angle_bits)?; // normal angles + arrays::bool_array(r, faces)?; // is_face_planar + Ok(()) + } + + /// `CompressedTextureParameter` [WD 7.8.9.8-7.8.9.9; pdf-issues #729, #749], read past. + fn compressed_texture_parameter(&mut self) -> Result<(), PrcError> { + let words = self.count(32, "texture data")?; + // count() bounded words * 32 by the remaining bits. + self.r.skip_bits(words.saturating_mul(32))?; + self.r.unsigned_integer()?; // last_integer_used_bit_number + let n = self.count(5, "texture references")?; + for _ in 0..n { + self.r.nbits_then_unsigned()?; + } + self.r.double()?; // tolerance + let n = self.count(32, "texture parameters")?; + for _ in 0..n { + self.r.float_as_bytes()?; + } + Ok(()) + } +} + +/// The normal array and each triangle's corner normals, or both empty when any slot fails to +/// name a normal: a mesh shades from its stored normals wholly or not at all. +fn stored_normals(coords: &[f64], slots: &[[u32; 3]], triangles: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { + // Like a slot naming no normal, a partial last normal drops the stored normals. + let Ok(normals) = points(coords) else { + return (Vec::new(), Vec::new()); + }; + let named = |k: u32| k.is_multiple_of(3) && (k as usize / 3) < normals.len(); + if normals.is_empty() || slots.len() != triangles || !slots.iter().flatten().all(|&k| named(k)) { + return (Vec::new(), Vec::new()); + } + let corners = slots.iter().map(|t| t.map(|k| k / 3)).collect(); + (normals, corners) +} + +/// Per texture-coordinate set, each triangle's corner pair indices. +type UvSets = Vec>>; + +/// The texture coordinates as (u, v) pairs, and per set the corner slots turned into pair +/// indices (a slot is a `Double` offset, so even); a triangle with a slot naming no pair is +/// `None`. +fn stored_uvs(coords: &[f64], sets: UvSets) -> (Vec<[f64; 2]>, UvSets) { + let uvs: Vec<[f64; 2]> = coords.as_chunks::<2>().0.to_vec(); + if uvs.is_empty() { + return (Vec::new(), Vec::new()); + } + let pair = |k: u32| (k.is_multiple_of(2) && (k as usize / 2) < uvs.len()).then_some(k / 2); + let sets = + sets.into_iter().map(|set| set.into_iter().map(|tri| tri.and_then(|[a, b, c]| Some([pair(a)?, pair(b)?, pair(c)?]))).collect()).collect(); + (uvs, sets) +} + +/// `coords` as points; an array that does not divide into whole points is malformed. +fn points(coords: &[f64]) -> Result, PrcError> { + let (whole, rest) = coords.as_chunks::<3>(); + if !rest.is_empty() { + return Err(malformed(format!("{} coordinates are not whole points", coords.len()))); + } + Ok(whole.to_vec()) +} + +/// The point a `Double`-offset index names [WD 7.8.5.2: multiples of 3]. +fn point_at(pts: &[[f64; 3]], idx: u32) -> Result<[f64; 3], PrcError> { + if !idx.is_multiple_of(3) { + return Err(malformed(format!("point index {idx} is not a multiple of 3"))); + } + pts.get(idx as usize / 3).copied().ok_or_else(|| malformed(format!("point index {idx} past the coordinates"))) +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/prc/src/tess/tests.rs b/crates/prc/src/tess/tests.rs new file mode 100644 index 0000000000..26219f2865 --- /dev/null +++ b/crates/prc/src/tess/tests.rs @@ -0,0 +1,738 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::triangulate::MAX_TEXTURE_SETS; +use super::*; +use crate::Schema; +use crate::testw::W; + +const SQUARE: [f64; 12] = [0., 0., 0., 1., 0., 0., 1., 1., 0., 0., 1., 0.]; +const ATTRIBUTE: u32 = 201; +const NORMAL_SINGLE: u32 = 0x4000_0000; + +/// A 305 section holding `body` (already-written entities, `n` of them). +fn section(n: u32, body: &W) -> W { + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(n).append(body).uint(0); + w +} + +fn uints(w: &mut W, v: &[u32]) { + w.uint(u32::try_from(v.len()).unwrap()); + for &x in v { + w.uint(x); + } +} + +fn square(w: &mut W, entity: u32) { + coordinates(w, entity, &SQUARE); +} + +fn coordinates(w: &mut W, entity: u32, coords: &[f64]) { + w.uint(entity).bit(false).uint(u32::try_from(coords.len()).unwrap()); + for &c in coords { + w.double(c); + } +} + +#[derive(Default)] +struct Mesh<'a> { + recalc: bool, + indices: &'a [u32], + faces: &'a [(u32, u32, &'a [u32])], + textures: u32, + uvs: &'a [f64], + /// The coordinates; [`SQUARE`] when `None`. + coords: Option<&'a [f64]>, +} + +/// A TESS_3D over [`SQUARE`] at authoring version `v`. +fn tess_3d(w: &mut W, v: u32, m: &Mesh<'_>) { + coordinates(w, 172, m.coords.unwrap_or(&SQUARE)); + w.bit(true); + if v >= 7039 { + w.bit(false); + } + if v >= 7047 { + w.bit(m.recalc); + if m.recalc { + w.put(0, 8).double(0.5); + } + } + if m.recalc { + w.uint(0); + } else { + // Two normals: slot 0 is +z, slot 3 is -z. + w.uint(6); + for c in [0., 0., 1., 0., 0., -1.] { + w.double(c); + } + } + w.uint(0); + uints(w, m.indices); + w.uint(u32::try_from(m.faces.len()).unwrap()); + for &(flags, start, data) in m.faces { + w.uint(174).uint(0).uint(0).uint(0).uint(flags).uint(start); + uints(w, data); + w.uint(m.textures).bit(false); + } + w.uint(u32::try_from(m.uvs.len()).unwrap()); + for &c in m.uvs { + w.double(c); + } +} + +fn decode(w: &W, schema: &Schema, version: u32) -> Result, PrcError> { + let bytes = w.bytes(); + Ctx::new(BitReader::new(&bytes), schema, version).file_structure_tessellation() +} + +fn mesh(t: &Tessellation) -> &TriangleMesh { + match t { + Tessellation::Mesh(m) => m, + other => panic!("not a mesh: {other:?}"), + } +} + +fn one(indices: &[u32], faces: &[(u32, u32, &[u32])], textures: u32) -> TriangleMesh { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices, faces, textures, ..Mesh::default() }); + mesh(&decode(§ion(1, &b), &Schema::default(), 8137).unwrap()[0]).clone() +} + +#[test] +fn triangle_and_fan_with_normals() { + // One triangle (n,p x3), then a fan of 4 vertices (n,p each). + let m = one(&[3, 0, 0, 3, 3, 6, 0, 0, 3, 3, 0, 6, 3, 9], &[(0x2 | 0x4, 0, &[1, 1, 4])], 0); + assert_eq!(m.positions.len(), 4); + assert_eq!(m.positions[2], [1., 1., 0.]); + assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [0, 2, 3]]); + assert_eq!(m.faces, vec![0..3]); + assert!(!m.normals_recalculated); + assert_eq!(m.normals, [[0., 0., 1.], [0., 0., -1.]]); + // Each corner keeps the normal stored beside its point. + assert_eq!(m.triangle_normals, [[1, 0, 1], [0, 1, 0], [0, 0, 1]]); +} + +#[test] +fn strip_normals_follow_the_alternating_winding() { + let m = one(&[3, 0, 0, 3, 3, 6, 0, 9], &[(0x8, 0, &[1, 4])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]); + assert_eq!(m.triangle_normals, [[1, 0, 1], [1, 0, 0]]); +} + +#[test] +fn a_normal_index_past_the_array_drops_every_normal_but_no_triangle() { + let m = one(&[0, 0, 6, 3, 0, 6], &[(0x2, 0, &[1])], 0); + assert_eq!(m.triangles, [[0, 1, 2]]); + assert!(m.normals.is_empty()); + assert!(m.triangle_normals.is_empty()); +} + +#[test] +fn recalculated_normals_drop_the_normal_slots_and_strips_alternate() { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { recalc: true, indices: &[0, 3, 6, 9], faces: &[(0x8, 0, &[1, 4])], ..Mesh::default() }); + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert!(m.normals_recalculated); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]); + assert!(m.normals.is_empty() && m.triangle_normals.is_empty()); +} + +#[test] +fn version_gates_the_loop_and_recalculation_flags() { + for v in [7038, 7039, 7046, 7047, 8137] { + let mut b = W::default(); + tess_3d(&mut b, v, &Mesh { indices: &[0, 0, 0, 3, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() }); + // A trailing wire: a misread flag desyncs it. + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), v).unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]], "version {v}"); + assert!(matches!(t[1], Tessellation::Wire(_)), "version {v}"); + } +} + +#[test] +fn one_normal_blocks_and_textures() { + // 0x20: n,p,p,p. 0x40 with NORMAL_Single: n then p per vertex. + // 0x2000 with one texture set: n,t,p,t,p,t,p. + let idx = [ + 0, 0, 3, 6, // 0x20 + 3, 0, 6, 9, // 0x40 single, 3 vertices + 0, 0, 9, 2, 0, 4, 3, // 0x2000 + ]; + let m = one(&idx, &[(0x20 | 0x40 | 0x2000, 0, &[1, 1, 3 | NORMAL_SINGLE, 1])], 1); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3], [3, 0, 1]]); + // A one-normal entity gives every corner its single normal. + assert_eq!(m.triangle_normals, [[0, 0, 0], [1, 1, 1], [0, 0, 0]]); +} + +#[test] +fn one_normal_fan_without_single_keeps_a_normal_per_vertex() { + let m = one(&[0, 0, 0, 3, 0, 6, 0, 9], &[(0x40, 0, &[1, 4])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); +} + +#[test] +fn textured_fan_and_strip_carry_texture_slots_per_vertex() { + // 0x400 then 0x800, two texture sets: n,t,t,p per vertex. + let mut idx = Vec::new(); + for p in [0, 3, 6, 0, 3, 6, 9] { + idx.extend([0, 0, 2, p]); + } + let m = one(&idx, &[(0x400 | 0x800, 0, &[1, 3, 1, 4])], 2); + assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [2, 1, 3]]); +} + +/// The unit square as two textured triangles (one set; per vertex a normal, a texture slot, +/// a point), its corners at the picture's corners. +fn textured_square() -> W { + let mut body = W::default(); + let indices = [0, 0, 0, 0, 2, 3, 0, 4, 6, 0, 0, 0, 0, 4, 6, 0, 6, 9]; + tess_3d( + &mut body, + 8137, + &Mesh { indices: &indices, faces: &[(0x200, 0, &[2])], textures: 1, uvs: &[0., 0., 1., 0., 1., 1., 0., 1.], ..Mesh::default() }, + ); + section(1, &body) +} + +#[test] +fn a_textured_mesh_keeps_each_corners_coordinates() { + let t = decode(&textured_square(), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); + assert_eq!(m.uvs, [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]); + assert_eq!(m.triangle_uvs, [vec![Some([0, 1, 2]), Some([0, 2, 3])]]); + // An odd slot names no pair: that triangle has no coordinates. + let mut b = W::default(); + tess_3d( + &mut b, + 8137, + &Mesh { indices: &[0, 1, 0, 0, 2, 3, 0, 4, 6], faces: &[(0x200, 0, &[1])], textures: 1, uvs: &[0.0; 6], ..Mesh::default() }, + ); + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert_eq!(m.triangles, [[0, 1, 2]]); + assert_eq!(m.triangle_uvs, [vec![None]]); +} + +#[test] +fn too_many_texture_sets_are_refused_before_anything_is_allocated() { + // An empty fan on a face claiming 2^32 - 1 texture sets: no slot bounds the count. + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: u32::MAX, ..Mesh::default() }); + assert!( + matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(m)) if m == format!("{} texture-coordinate sets on a face", u32::MAX)) + ); + let sets = u32::try_from(MAX_TEXTURE_SETS).unwrap(); + let mut ok = W::default(); + tess_3d(&mut ok, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: sets, ..Mesh::default() }); + assert!(decode(§ion(1, &ok), &Schema::default(), 8137).is_ok()); +} + +/// A tessellation section holding one TESS_3D: [`SQUARE`] as two faces of one triangle each. +pub(crate) fn two_face_square() -> W { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], faces: &[(0x2, 0, &[1]), (0x2, 6, &[1])], ..Mesh::default() }); + section(1, &b) +} + +#[test] +fn two_faces_partition_the_triangles() { + let m = one(&[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], &[(0x2, 0, &[1]), (0x2, 6, &[1])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); + assert_eq!(m.faces, [0..1, 1..2]); +} + +/// A wire over [`SQUARE`]: an open two-point wire continued by a closing two-point wire, +/// then optionally per-segment RGB colours. +fn wire(w: &mut W, colours: bool) { + square(w, 175); + uints(w, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]); + w.bit(colours); + if colours { + // 5 points (4 + the implicit closing one) -> 2 segment colours. + w.bit(false).bit(true).bit(false).put(0xff0000, 24); + w.bit(true); + } +} + +#[test] +fn wires_close_and_continue_and_colours_are_counted() { + for colours in [false, true] { + let mut b = W::default(); + wire(&mut b, colours); + // A second entity: a wrong colour count would desync it. + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + for e in &t { + let Tessellation::Wire(w) = e else { panic!("not a wire") }; + assert_eq!(w, &[vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [1., 1., 0.]]]); + } + } +} + +#[test] +fn a_closing_wire_colours_its_implicit_point() { + // Per-point RGB: 4 indexed points + 1 implicit = 5 colours. + let mut b = W::default(); + square(&mut b, 175); + uints(&mut b, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]); + b.bit(true).bit(false).bit(false).bit(false).put(1, 24); + for _ in 1..5 { + b.bit(true); + } + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + assert!(matches!(t[1], Tessellation::Wire(_))); + assert_eq!(t[0], t[1]); +} + +#[test] +fn a_wire_index_must_name_a_point() { + let mut b = W::default(); + square(&mut b, 175); + uints(&mut b, &[2, 0, 12]); + b.bit(false); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); +} + +#[test] +fn a_wire_without_indices_is_the_coordinate_polyline() { + let mut b = W::default(); + square(&mut b, 175); + b.uint(0).bit(true); + // 4 implicit points -> 4 RGB colours, all "same" after the first. + b.bit(false).bit(false).bit(false).put(1, 24); + for _ in 1..4 { + b.bit(true); + } + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + let Tessellation::Wire(w) = &t[0] else { panic!("not a wire") }; + assert_eq!(w.len(), 1); + assert_eq!(w[0].len(), 4); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +/// Reused colours cost one bit each, so a long run of them may end the section. +#[test] +fn reused_vertex_colours_may_be_the_last_data() { + let n = 40; + let mut b = W::default(); + b.uint(175).bit(false).uint(3 * n); + for i in 0..3 * n { + b.double(f64::from(i)); + } + // No indices: the polyline of all `n` points, one RGB colour then `n - 1` reuses. + b.uint(0).bit(true).bit(false).bit(false).bit(false).put(1, 24); + for _ in 1..n { + b.bit(true); + } + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + let Tessellation::Wire(w) = &t[0] else { panic!("not a wire") }; + assert_eq!(w[0].len(), n as usize); +} + +/// A square as one triangle plus one 4-vertex fan (two entities, three triangles), the face +/// carrying `attrs` and, when it has any, behaviour 0x30; then `colours` per-point colours +/// and a trailing wire. +fn styled_face(attrs: &[u32], colours: usize) -> Vec { + decode(&styled_face_section(attrs, colours, 0x30), &Schema::default(), 8137).unwrap() +} + +/// [`styled_face`]'s section, the face's behaviour `behaviour`. +fn styled_face_section(attrs: &[u32], colours: usize, behaviour: u32) -> W { + let mut b = W::default(); + square(&mut b, 172); + b.bit(true).bit(false).bit(false).uint(0).uint(0); + uints(&mut b, &[0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9]); + b.uint(1).uint(174); + uints(&mut b, attrs); + b.uint(0).uint(0).uint(0x2 | 0x4).uint(0); + uints(&mut b, &[1, 1, 4]); + b.uint(0).bit(colours > 0); + if colours > 0 { + b.bit(false).bit(false).put(0x10_2030, 24); + for _ in 1..colours { + b.bit(true); + } + } + if !attrs.is_empty() { + b.uint(behaviour); + } + b.uint(0); + wire(&mut b, false); + section(2, &b) +} + +#[test] +fn face_vertex_colours_count_points_not_data() { + // A triangle plus a fan of 4 vertices = 7 points -> 7 colours. + let t = styled_face(&[], 7); + assert_eq!(mesh(&t[0]).triangles.len(), 3); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +fn styles(attrs: &[u32]) -> Vec<(u32, u16)> { + let t = styled_face(attrs, 0); + assert!(matches!(t[1], Tessellation::Wire(_))); + mesh(&t[0]).triangle_styles.iter().map(|g| (g.style, g.behaviour)).collect() +} + +#[test] +fn face_line_attributes_style_its_triangles() { + assert!(styles(&[]).is_empty()); + assert_eq!(styles(&[5]), [(5, 0x30); 3]); + // One per entity: the triangle, then both triangles of the fan. + assert_eq!(styles(&[4, 7]), [(4, 0x30), (7, 0x30), (7, 0x30)]); + // An entity styled 0 inherits, as do entities past the list. + assert_eq!(styles(&[4, 0, 9]), [(4, 0x30), (0, 0x30), (0, 0x30)]); +} + +#[test] +fn markup_is_read_past() { + let mut b = W::default(); + b.uint(176).bit(false).uint(3); + for c in [1., 2., 3.] { + b.double(c); + } + uints(&mut b, &[6, 0]); + b.uint(1).string(Some("R1")).string(Some("label")).put(7, 8); + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + assert_eq!(t[0], Tessellation::Markup); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +#[test] +fn schema_fields_are_skipped_after_each_entity() { + // A newer producer added one UnsignedInteger to TESS_3D and one to TESS_Face. + let mut s = W::default(); + s.uint(2).uint(172).uint(1).uint(3).uint(174).uint(1).uint(3); + let sb = s.bytes(); + let schema = Schema::read(&mut BitReader::new(&sb)).unwrap(); + + let mut b = W::default(); + square(&mut b, 172); + b.bit(true).bit(false).bit(false).uint(0).uint(0); + uints(&mut b, &[0, 0, 0, 3, 0, 6]); + b.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0); + uints(&mut b, &[1]); + b.uint(0).bit(false).uint(99); // the face's added field + b.uint(0).uint(77); // texture count, then TESS_3D's added field + wire(&mut b, false); + let t = decode(§ion(2, &b), &schema, 8137).unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]]); + assert!(matches!(t[1], Tessellation::Wire(_))); + // Without the schema the same bytes do not decode to the same thing. + assert_ne!(decode(§ion(2, &b), &Schema::default(), 8137).ok(), Some(t)); +} + +#[test] +fn attributes_names_and_user_data_are_read_past() { + let mut w = W::default(); + w.uint(305).uint(1).uint(ATTRIBUTE); + w.bit(true).uint(2); // predefined title + w.uint(2); + w.bit(false).string(Some("k")).uint(4).string(Some("v")); + w.bit(true).uint(1).uint(5).int(-1).uint(9); + w.uint(42); // the attribute's schema-added field + w.bit(false).string(Some("tess")).uint(1); + wire(&mut w, false); + w.uint(5).put(0b10110, 5); + let mut sw = W::default(); + sw.uint(1).uint(ATTRIBUTE).uint(1).uint(3); + let sb = sw.bytes(); + let schema = Schema::read(&mut BitReader::new(&sb)).unwrap(); + let bytes = w.bytes(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137); + let t = ctx.file_structure_tessellation().unwrap(); + assert!(matches!(t[..], [Tessellation::Wire(_)])); + assert_eq!(ctx.name.as_deref(), Some("tess")); + assert_eq!(ctx.r.position(), w.len()); +} + +/// A one-triangle TESS_3D_Compressed. `full` takes every optional branch: stored normals, +/// colours, line attributes, texture, behaviours. Otherwise `three_t` picks the 3T edge form. +fn compressed(w: &mut W, v: u32, full: bool, three_t: bool) { + compressed_with_angles(w, v, full, three_t, 10); +} + +/// [`compressed`] whose `full` form stores `angle_bits`-bit normal angles. +fn compressed_with_angles(w: &mut W, v: u32, full: bool, three_t: bool, angle_bits: u8) { + w.uint(173).bit(false).bit(true).double(0.001); + if v >= 7031 { + w.put(0, 32).put(0, 32).put(0, 32); + } + // point_array: nine 2-bit values. + w.bit(false).uint(9); + for _ in 0..9 { + w.put(2, 8); + } + for _ in 0..9 { + w.bit(false).bit(true); + } + // edge_status_array: the 3T form (Huffman) or the T form. + if full { + w.bit(true).huffman(2, 2, &[(0, 0b10, 2)], &[0, 0, 0]); + } else if three_t { + w.bit(false).uint(3).put(0, 8).put(0, 8).put(0, 8); + } else { + w.bit(false).uint(1).put(0, 8); + } + // triangle_face_array [0]. `full`: four references, so the flagless reference array is + // Huffman; else none. + w.bit(false).uint(1).put(1, 8).bit(false); + if full { + w.uint(4).put(0b1111, 4); + w.huffman(6, 2, &[(1, 0b10, 2), (0, 0b11, 2)], &[1, 0, 0, 0]); + w.put(0, 4); + } else { + w.uint(3).put(0, 3).uint(0); + } + if full { + w.bit(false).put(u64::from(angle_bits), 8).uint(3).put(0b101, 3); + w.bit(true).huffman(10, 2, &[(5, 0b10, 2)], &[5, 5]); + w.bit(true); // is_face_planar + w.bit(true).bit(true).bit(false).uint(5); + for c in [1, 255, 0, 0, 128] { + w.put(c, 8); + } + w.bit(true).bit(false); + } else { + w.bit(true).bit(false).double(0.5).put(0, 8); + w.bit(false).bit(false); + } + w.bit(false).uint(1).put(1, 8).put(0, 8); // line_attribute_array + if full { + w.bit(false).uint(1).put(0, 32).uint(32); + w.uint(1).put(1, 5).bit(true).double(0.5).uint(2); + w.put(0, 32).put(0, 32).bit(false).bit(true); + w.bit(true).bit(false).uint(1).put(1, 8); + } else { + w.bit(true).bit(false); + } +} + +#[test] +fn a_compressed_mesh_is_read_to_its_end() { + for (full, three_t) in [(false, false), (false, true), (true, true)] { + for v in [7030, 7031, 8137] { + let mut b = W::default(); + compressed(&mut b, v, full, three_t); + let indices = [0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9]; + tess_3d(&mut b, v, &Mesh { indices: &indices, faces: &[(0x2 | 0x4, 0, &[1, 1, 4])], ..Mesh::default() }); + let w = section(2, &b); + let bytes = w.bytes(); + let schema = Schema::default(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, v); + let t = ctx.file_structure_tessellation().unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }, Tessellation::Mesh(_)]), "full={full} v={v}: {t:?}"); + assert_eq!(ctx.r.position(), w.len(), "full={full} v={v}"); + } + } +} + +#[test] +fn refusals_and_damage_are_errors() { + // Edge statuses neither T nor 3T long. + let mut c = W::default(); + c.uint(173).bit(false).bit(true).double(0.001); + c.put(0, 32).put(0, 32).put(0, 32); + c.bit(false).uint(0).bit(false).uint(2).put(0, 16); + c.bit(false).uint(1).put(1, 8).bit(false); + assert!(matches!(decode(§ion(1, &c), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); + // Optimised vertex colours. + let mut o = W::default(); + square(&mut o, 175); + o.uint(0).bit(true).bit(false).bit(false).bit(true); + assert!(matches!(decode(§ion(1, &o), &Schema::default(), 8137), Err(PrcError::Unsupported(_)))); + // A point index that is not a multiple of 3, and one past the end. + for bad in [4, 12] { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, bad, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() }); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); + } + // A fan whose count claims a billion points. + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0], faces: &[(0x4, 0, &[1, 0x3FFF_FFFF])], ..Mesh::default() }); + assert!(decode(§ion(1, &b), &Schema::default(), 8137).is_err()); + // A tessellation count far past the data. + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(u32::MAX); + assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Truncated(_)))); + // A wrong entity type where the section's own belongs. + let mut w = W::default(); + w.uint(306); + assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Malformed(_)))); +} + +#[test] +fn every_truncation_of_a_section_is_an_error_not_a_panic() { + let mut b = W::default(); + compressed(&mut b, 8137, true, true); + wire(&mut b, true); + let bytes = section(2, &b).bytes(); + for end in 0..bytes.len() { + let cut = bytes.get(..end).unwrap(); + // The zero padding of the last byte may still decode; anything shorter must not. + let _ = Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation(); + } + let cut = bytes.get(..bytes.len() / 2).unwrap(); + assert!(Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation().is_err()); +} + +/// A one-file-structure PRC stream whose tessellation section is `tess`, with an empty +/// globals section schema. +fn prc_file(tess: &[u8]) -> Vec { + let mut globals = W::default(); + globals.uint(0); + prc_file_with(&globals.bytes(), tess) +} + +/// As [`prc_file`] with the globals section `globals`. +fn prc_file_with(globals: &[u8], tess: &[u8]) -> Vec { + use std::io::Write as _; + const HEADER_LEN: usize = 107; + let zlib = |data: &[u8]| { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + }; + let u32s = |out: &mut Vec, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes())); + let sections: [&[u8]; 5] = [globals, b"", tess, b"", b""]; + let mut body = b"PRC".to_vec(); + u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]); + let mut offs = vec![HEADER_LEN]; + for s in sections { + offs.push(HEADER_LEN + body.len()); + body.extend(zlib(s)); + } + let mf_start = HEADER_LEN + body.len(); + body.extend(zlib(b"")); + let mf_end = HEADER_LEN + body.len(); + let mut out = b"PRC".to_vec(); + u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]); + u32s(&mut out, &[5, 6, 7, 8, 0, 6]); + u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::>()); + u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]); + out.extend(body); + out +} + +#[test] +fn a_prc_file_yields_its_tessellations() { + let f = crate::PrcFile::parse(&prc_file(&textured_square().bytes())).unwrap(); + let t = f.file_structures[0].tessellations().unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2], [0, 2, 3]]); +} + +#[test] +fn normal_angles_wider_than_16_bits_are_refused_by_name() { + let mut b = W::default(); + compressed_with_angles(&mut b, 8137, true, true, 17); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(m)) if m == "17-bit normal angles")); +} + +/// An entity's counted array is charged against the reader's element allowance. +#[test] +fn counted_entity_arrays_are_charged_against_the_allowance() { + let mut w = W::default(); + w.uint(3).uint(1).uint(2).uint(3); + let bytes = w.bytes(); + let schema = Schema::default(); + let read = |n| Ctx::new(BitReader::with_elements(&bytes, n), &schema, 8137).uints("indices"); + assert!(matches!(read(2), Err(PrcError::TooManyElements { .. }))); + assert_eq!(read(3).unwrap(), [1, 2, 3]); +} + +#[test] +fn a_face_behaviour_wider_than_16_bits_is_malformed() { + let r = decode(&styled_face_section(&[1], 0, 0x1_0030), &Schema::default(), 8137); + assert!(matches!(r, Err(PrcError::Malformed(m)) if m == "face behaviour 65584 is wider than 16 bits")); +} + +#[test] +fn coordinates_that_are_not_whole_points_are_malformed() { + let mut partial = SQUARE.to_vec(); + partial.push(7.0); + let mut b = W::default(); + let faces: &[(u32, u32, &[u32])] = &[(0x2, 0, &[1])]; + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, 3, 0, 6], faces, coords: Some(&partial), ..Mesh::default() }); + let r = decode(§ion(1, &b), &Schema::default(), 8137); + assert!(matches!(r, Err(PrcError::Malformed(m)) if m == "13 coordinates are not whole points")); + // A partial last normal drops the stored normals, as a slot naming none does. + assert_eq!(stored_normals(&[0.0, 0.0, 1.0, 0.0], &[[0, 0, 0]], 1), (Vec::new(), Vec::new())); +} + +/// Elements a tessellation section spends from a full allowance. +fn spent(section: &W) -> usize { + let bytes = section.bytes(); + let schema = Schema::default(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137); + ctx.file_structure_tessellation().unwrap(); + crate::MAX_DECODED_ELEMENTS - ctx.r.elements_left() +} + +#[test] +fn faces_reusing_one_index_range_are_charged_for_each_triangle_they_make() { + const INDICES: [u32; 12] = [0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9]; + let square = |faces: &[(u32, u32, &[u32])]| { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces, ..Mesh::default() }); + spent(§ion(1, &b)) + }; + let one = square(&[(0x2, 0, &[2])]); + let three = square(&[(0x2, 0, &[2]), (0x2, 0, &[2]), (0x2, 0, &[2])]); + // Per extra face: its count entry, its one data element and its two triangles, each + // 12 bytes of corners and 12 of normal slots. + assert_eq!(three - one, 2 * (2 + 2 * 4)); +} + +#[test] +fn reused_textured_faces_are_charged_for_every_corner_array() { + // The textured square, then one untextured triangle's normal and point slots. + const INDICES: [u32; 24] = [0, 0, 0, 0, 2, 3, 0, 4, 6, 0, 0, 0, 0, 4, 6, 0, 6, 9, 0, 0, 0, 3, 0, 6]; + let square = |faces: &[(u32, u32, &[u32])], sets: u32| { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces, textures: sets, uvs: &[0.0; 8], ..Mesh::default() }); + spent(§ion(1, &b)) + }; + let face: (u32, u32, &[u32]) = (0x200, 0, &[2]); + let untextured: (u32, u32, &[u32]) = (0x2, 18, &[1]); + let one = square(&[face], 1); + let three = square(&[face, face, face], 1); + // Per extra face: its count, its data element and two triangles of corners, normal slots + // and one texture set, 16 bytes a corner triple. + assert_eq!(three - one, 2 * (2 + 2 * 6)); + // One textured face gives every triangle of the mesh a texture triple. + let mixed = square(&[face, untextured], 1); + assert_eq!(mixed - one, 2 + 6); +} + +#[test] +fn a_face_is_charged_before_it_is_triangulated() { + // One face claiming a million triangles over a 12-entry index array. + const INDICES: [u32; 12] = [0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9]; + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces: &[(0x2, 0, &[1_000_000])], ..Mesh::default() }); + let bytes = section(1, &b).bytes(); + let schema = Schema::default(); + let r = Ctx::new(BitReader::with_elements(&bytes, 1000), &schema, 8137).file_structure_tessellation(); + assert!(matches!(r, Err(PrcError::TooManyElements { .. })), "{r:?}"); +} + +#[test] +fn the_globals_schema_and_the_tessellations_share_one_allowance() { + // A schema of one entry with three tokens: four elements. + let mut globals = W::default(); + globals.uint(1).uint(999).uint(3).uint(0).uint(0).uint(0); + let tess = textured_square(); + let f = crate::PrcFile::parse(&prc_file_with(&globals.bytes(), &tess.bytes())).unwrap(); + let need = spent(&tess) + 4; + let within = |elements| f.file_structures[0].tessellations_within(crate::bits::Budget { elements, ..crate::bits::Budget::FULL }); + assert!(within(need).is_ok()); + assert!(matches!(within(need - 1), Err(PrcError::TooManyElements { .. }))); +} diff --git a/crates/prc/src/tess/triangulate.rs b/crates/prc/src/tess/triangulate.rs new file mode 100644 index 0000000000..6611e4abf7 --- /dev/null +++ b/crates/prc/src/tess/triangulate.rs @@ -0,0 +1,282 @@ +//! A `TESS_Face`'s triangulated data turned into triangles [WD 7.8.5.5, 7.8.6]. + +use super::TriangleStyle; +use crate::PrcError; +use crate::entity::malformed; + +/// A fan/strip count's point-count bits; bit 30 is `NORMAL_Single` [WD 7.8.6.2]. +const COUNT_MASK: u32 = 0x3FFF_FFFF; +const NORMAL_SINGLE: u32 = 0x4000_0000; + +/// Texture-coordinate sets a face may carry. Each set costs a slot per point and a corner +/// triple per triangle of the whole mesh, so the count is capped. +pub(super) const MAX_TEXTURE_SETS: usize = 8; + +pub(super) struct Face { + pub(super) flags: u32, + pub(super) start: usize, + pub(super) data: Vec, + pub(super) textures: usize, + /// `line_attributes`, each a style index + 1: none = the owner's graphics, one = the + /// whole face's, more = one per triangulation entity [WD 7.8.6]. + pub(super) styles: Vec, + /// The behaviour bits read when `styles` is not empty. + pub(super) behaviour: u16, +} + +/// A `used_entities_flag` block's shape [WD 7.8.5.5]. +#[derive(Clone, Copy)] +enum Shape { + Triangles, + Fan, + Strip, +} + +/// One `used_entities_flag` block. +#[derive(Clone, Copy)] +struct Block { + bit: u32, + shape: Shape, + /// One normal per entity rather than per point. + one_normal: bool, + textured: bool, +} + +const fn block(bit: u32, shape: Shape, one_normal: bool, textured: bool) -> Block { + Block { bit, shape, one_normal, textured } +} + +/// The blocks in stream order, low bit first [WD 7.8.5.5]. +const BLOCKS: [Block; 12] = [ + block(0x0002, Shape::Triangles, false, false), + block(0x0004, Shape::Fan, false, false), + block(0x0008, Shape::Strip, false, false), + block(0x0020, Shape::Triangles, true, false), + block(0x0040, Shape::Fan, true, false), + block(0x0080, Shape::Strip, true, false), + block(0x0200, Shape::Triangles, false, true), + block(0x0400, Shape::Fan, false, true), + block(0x0800, Shape::Strip, false, true), + block(0x2000, Shape::Triangles, true, true), + block(0x4000, Shape::Fan, true, true), + block(0x8000, Shape::Strip, true, true), +]; + +/// One triangulation entity's index slots: per point its position, normal slot (when the +/// array stores normals) and texture slots. +#[derive(Default)] +struct Slots { + verts: Vec, + normals: Vec, + /// Per texture set, each point's slot. + textures: Vec>, +} + +/// What a mesh's triangles carry besides positions, built in step with them: corner normal +/// slots and, per texture set, corner texture slots (`None` for a triangle whose face stores +/// fewer sets). +#[derive(Default)] +pub(super) struct Corners { + pub(super) normals: Vec<[u32; 3]>, + pub(super) textures: Vec>>, +} + +impl Face { + /// Entity sizes per set block, from `TriangulatedData` [WD 7.8.6]. A triangle block is + /// one size word (its triangle count); fans and strips list a vertex count each. + fn entities(&self) -> Result)>, PrcError> { + let mut d = self.data.iter().copied(); + let mut next = || d.next().ok_or_else(|| malformed("triangulated data ends early".into())); + let mut out = Vec::new(); + for b in BLOCKS { + if self.flags & b.bit == 0 { + continue; + } + let sizes = match b.shape { + Shape::Triangles => vec![next()?], + Shape::Fan | Shape::Strip => { + let n = next()? as usize; + if n > self.data.len() { + return Err(malformed("triangulated data ends early".into())); + } + (0..n).map(|_| next()).collect::>()? + } + }; + out.push((b, sizes)); + } + Ok(out) + } + + /// Points the face's index slots name: the vertex-colour count (pdf-issues #820). + pub(super) fn point_count(&self) -> Result { + let mut n = 0usize; + for (b, sizes) in self.entities()? { + for s in sizes { + let s = (s & COUNT_MASK) as usize; + n = n.saturating_add(if matches!(b.shape, Shape::Triangles) { s.saturating_mul(3) } else { s }); + } + } + Ok(n) + } + + /// Triangles [`Self::triangulate`] makes: one per triangle-block count, two fewer than the + /// points of each fan and strip. + pub(super) fn triangle_count(&self) -> Result { + let mut n = 0usize; + for (b, sizes) in self.entities()? { + for s in sizes { + let s = (s & COUNT_MASK) as usize; + n = n.saturating_add(if matches!(b.shape, Shape::Triangles) { s } else { s.saturating_sub(2) }); + } + } + Ok(n) + } + + /// Texture sets the face's triangles carry: its count when one of its blocks is textured, + /// at most [`MAX_TEXTURE_SETS`] (a face past it is refused when triangulated). + pub(super) fn texture_sets(&self) -> usize { + if BLOCKS.iter().any(|b| b.textured && self.flags & b.bit != 0) { self.textures.min(MAX_TEXTURE_SETS) } else { 0 } + } + + /// Emit the face's triangles into `out`; `normals` = the index array stores normal slots, + /// in which case each triangle's corner normal slots go to `corners`, as do its corner + /// texture slots. Returns the triangles each triangulation entity emitted, in order: one + /// entity per triangle of a triangle block, per fan, per strip [WD 7.8.6]. + pub(super) fn triangulate( + &self, + indices: &[u32], + normals: bool, + n_points: usize, + out: &mut Vec<[u32; 3]>, + corners: &mut Corners, + ) -> Result, PrcError> { + if self.textures > MAX_TEXTURE_SETS { + return Err(malformed(format!("{} texture-coordinate sets on a face", self.textures))); + } + let mut entities = Vec::new(); + let mut slots = indices.get(self.start..).unwrap_or(&[]).iter().copied(); + for (b, sizes) in self.entities()? { + let t = if b.textured { self.textures } else { 0 }; + for word in sizes { + let count = (word & COUNT_MASK) as usize; + // Points in this entity, and whether each carries its own normal slot. + let (points, per_point_normal) = match b.shape { + Shape::Triangles => (count.saturating_mul(3), !b.one_normal), + Shape::Fan | Shape::Strip => (count, !b.one_normal || word & NORMAL_SINGLE == 0), + }; + if points.saturating_mul(t.saturating_add(1)) > indices.len() { + return Err(malformed("face runs past the triangulated index array".into())); + } + let s = read_slots(&mut slots, b.shape, points, t, normals.then_some(per_point_normal), n_points)?; + let made = emit(b.shape, &s, out, corners); + match b.shape { + Shape::Triangles => entities.extend(std::iter::repeat_n(1, made)), + Shape::Fan | Shape::Strip => entities.push(made), + } + } + } + Ok(entities) + } + + /// Append one [`TriangleStyle`] per triangle of the face, whose entities emitted + /// `entities[i]` triangles each. Entities past the face's styles inherit (style 0). + pub(super) fn styles_into(&self, entities: &[usize], out: &mut Vec) { + let whole = match self.styles.as_slice() { + [] => Some(0), + [one] => Some(*one), + _ => None, + }; + for (i, &n) in entities.iter().enumerate() { + let style = whole.unwrap_or_else(|| self.styles.get(i).copied().unwrap_or(0)); + out.extend(std::iter::repeat_n(TriangleStyle { style, behaviour: self.behaviour }, n)); + } + } +} + +/// The 8-byte elements of [`crate::MAX_DECODED_ELEMENTS`] one triangle holds: its corners +/// (12 bytes), its normal-slot corners when the index array stores them (12), a corner triple +/// per texture set (16 each) and its style when the mesh is styled (8). +pub(super) fn triangle_elements(normals: bool, sets: usize, styled: bool) -> usize { + let normals = if normals { 2 } else { 0 }; + 2usize.saturating_add(normals).saturating_add(sets.saturating_mul(2)).saturating_add(usize::from(styled)) +} + +/// One entity's `points` index slots. `normals` is `None` when the array stores no normal +/// slots, else whether every point has its own (rather than one per entity). +fn read_slots( + slots: &mut impl Iterator, + shape: Shape, + points: usize, + texture_sets: usize, + normals: Option, + n_points: usize, +) -> Result { + let mut take = || slots.next().ok_or_else(|| malformed("face runs past the triangulated index array".into())); + // The caller bounded points * (texture_sets + 1) by the index array's length. + let mut s = Slots { textures: vec![Vec::with_capacity(points); texture_sets], ..Slots::default() }; + let mut current = 0; + for k in 0..points { + let new_entity = match shape { + Shape::Triangles => k % 3 == 0, + Shape::Fan | Shape::Strip => k == 0, + }; + if let Some(per_point) = normals { + if per_point || new_entity { + current = take()?; + } + s.normals.push(current); + } + for set in &mut s.textures { + set.push(take()?); + } + let p = take()?; + if !p.is_multiple_of(3) || p as usize / 3 >= n_points { + return Err(malformed(format!("point index {p} is not a point"))); + } + s.verts.push(p / 3); + } + Ok(s) +} + +/// Append the triangles of one entity's `slots` to `out` and `corners`, keeping every +/// texture set as long as `out`. Returns how many it made. +fn emit(shape: Shape, slots: &Slots, out: &mut Vec<[u32; 3]>, corners: &mut Corners) -> usize { + let before = out.len(); + assemble(shape, &slots.verts, out); + assemble(shape, &slots.normals, &mut corners.normals); + let made = out.len().saturating_sub(before); + while corners.textures.len() < slots.textures.len() { + corners.textures.push(vec![None; before]); + } + for (k, set) in corners.textures.iter_mut().enumerate() { + match slots.textures.get(k) { + Some(own) => { + let mut tris = Vec::with_capacity(made); + assemble(shape, own, &mut tris); + set.extend(tris.into_iter().map(Some)); + } + None => set.extend(std::iter::repeat_n(None, made)), + } + } + made +} + +/// Append the triangles `shape` makes of `verts`. +fn assemble(shape: Shape, verts: &[u32], out: &mut Vec<[u32; 3]>) { + match shape { + Shape::Triangles => out.extend(verts.as_chunks::<3>().0.iter().copied()), + Shape::Fan => { + if let Some((&c, rim)) = verts.split_first() { + out.extend(rim.windows(2).filter_map(|w| match w { + [a, b] => Some([c, *a, *b]), + _ => None, + })); + } + } + Shape::Strip => out.extend(verts.windows(3).enumerate().filter_map(|(k, w)| match w { + [a, b, c] if k % 2 == 0 => Some([*a, *b, *c]), + [a, b, c] => Some([*b, *a, *c]), + _ => None, + })), + } +} diff --git a/crates/prc/src/testw.rs b/crates/prc/src/testw.rs new file mode 100644 index 0000000000..37e90606b6 --- /dev/null +++ b/crates/prc/src/testw.rs @@ -0,0 +1,166 @@ +//! Test-only PRC bit writer: the inverse of [`crate::BitReader`]. + +#![allow(clippy::expect_used)] + +use crate::acof; + +/// An MSB-first bit sink for building test streams. +#[derive(Debug, Default, Clone)] +pub(crate) struct W { + bits: Vec, +} + +impl W { + /// The low `n` bits of `v` (`n` ≤ 64), most significant first. + pub(crate) fn put(&mut self, v: u64, n: u32) -> &mut Self { + for i in (0..n).rev() { + self.bits.push((v >> i) & 1 == 1); + } + self + } + + /// One bit; a PRC `Boolean`. + pub(crate) fn bit(&mut self, b: bool) -> &mut Self { + self.bits.push(b); + self + } + + /// Bits written so far. + pub(crate) fn len(&self) -> usize { + self.bits.len() + } + + /// Every bit of `other`, unaligned. + pub(crate) fn append(&mut self, other: &Self) -> &mut Self { + self.bits.extend_from_slice(&other.bits); + self + } + + /// An `UnsignedInteger` [WD 11.10]. + pub(crate) fn uint(&mut self, mut v: u32) -> &mut Self { + while v != 0 { + self.bit(true).put(u64::from(v & 0xff), 8); + v >>= 8; + } + self.bit(false) + } + + /// An `Integer` [WD 11.11]: two's-complement groups, low first, each followed by a + /// continue bit. + pub(crate) fn int(&mut self, v: i32) -> &mut Self { + if v == 0 { + return self.bit(false); + } + self.bit(true); + let mut v = i64::from(v); + loop { + let group = v & 0xff; + self.put(group.cast_unsigned(), 8); + v >>= 8; + let done = (v == 0 && group & 0x80 == 0) || (v == -1 && group & 0x80 != 0); + self.bit(!done); + if done { + return self; + } + } + } + + /// A `String` [WD 11.4]: presence bit, byte count, raw UTF-8 bytes. + pub(crate) fn string(&mut self, s: Option<&str>) -> &mut Self { + let Some(s) = s else { + return self.bit(false); + }; + self.bit(true).uint(u32::try_from(s.len()).expect("a short test string")); + for b in s.bytes() { + self.put(u64::from(b), 8); + } + self + } + + /// A finite `Double` [WD 11.17]: zero as the two-bit zero code; anything else as its + /// exponent row, the sign, then the mantissa as literal bytes. + pub(crate) fn double(&mut self, v: f64) -> &mut Self { + if v == 0.0 { + return self.put(0b01, 2); + } + let bits = v.to_bits(); + let exponent = u32::try_from((bits >> 52) & 0x7ff).expect("11 bits"); + let (n, c) = acof::exponent_code(exponent).expect("the Double table has a row per exponent"); + let le = bits.to_le_bytes(); + self.put(u64::from(c), n).bit(v < 0.0).bit(true).put(u64::from(le[6] & 0x0f), 4); + for b in le[..6].iter().rev() { + self.bit(true).put(u64::from(*b), 8); + } + self + } + + /// A Huffman container [WD 12.2]: `leaves` are `(symbol, stored code, length)`, each value + /// is emitted as its leaf's code. + pub(crate) fn huffman(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], values: &[i32]) -> &mut Self { + let mask = (1u32 << nbits) - 1; + let codes: Vec<(u32, u32)> = values + .iter() + .map(|&v| { + let l = leaves.iter().find(|l| l.0 & mask == v.cast_unsigned() & mask).expect("a leaf per value"); + (l.1, l.2) + }) + .collect(); + self.huffman_raw(nbits, len_width, leaves, &codes) + } + + /// As [`Self::huffman`] with each element's `(code, length)` given. + pub(crate) fn huffman_raw(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], codes: &[(u32, u32)]) -> &mut Self { + let mut blob = Self::huffman_head(nbits, len_width, leaves); + lsb(&mut blob, u32::try_from(codes.len()).expect("a short test array"), 32); + for &(c, l) in codes { + for i in (0..l).rev() { + blob.push((c >> i) & 1 == 1); + } + } + self.huffman_words(blob) + } + + /// A Huffman container whose element count is `count` with no elements written. + pub(crate) fn huffman_count(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], count: u32) -> &mut Self { + let mut blob = Self::huffman_head(nbits, len_width, leaves); + lsb(&mut blob, count, 32); + self.huffman_words(blob) + } + + fn huffman_head(nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)]) -> Vec { + let mut blob = Vec::new(); + lsb(&mut blob, u32::try_from(leaves.len()).expect("a short test table"), nbits + 1); + lsb(&mut blob, len_width, 8); + for &(s, c, l) in leaves { + lsb(&mut blob, s, nbits); + lsb(&mut blob, l, len_width); + lsb(&mut blob, c, l); + } + blob + } + + /// The payload as whole 32-bit words of LSB-first bytes, then the last word's used bits. + fn huffman_words(&mut self, mut blob: Vec) -> &mut Self { + let bits = blob.len(); + let words = bits.div_ceil(32); + blob.resize(words * 32, false); + self.uint(u32::try_from(words).expect("a short test payload")); + for byte in blob.chunks(8) { + let b = byte.iter().enumerate().fold(0u64, |a, (i, &x)| a | (u64::from(x) << i)); + self.put(b, 8); + } + self.uint(u32::try_from(bits - (words - 1) * 32).expect("at most 32")) + } + + /// The bits packed MSB-first, the last byte zero-padded. + pub(crate) fn bytes(&self) -> Vec { + self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect() + } +} + +/// The low `n` bits of `v`, least significant first. +fn lsb(blob: &mut Vec, v: u32, n: u32) { + for i in 0..n { + blob.push((v >> i) & 1 == 1); + } +} diff --git a/crates/prc/src/tree/markup.rs b/crates/prc/src/tree/markup.rs new file mode 100644 index 0000000000..193c4fa219 --- /dev/null +++ b/crates/prc/src/tree/markup.rs @@ -0,0 +1,199 @@ +//! What a tree carries beside its geometry, read past and not modelled: markup data (PMI) +//! and views [WD 7.3.10.4, 7.7], and a scene's lights and clipping planes [WD 7.5.15-7.5.18, +//! 7.11.13]. The tree section has no length to skip by, so each is read field by field. + +use super::{ + AMBIENT_LIGHT, ANNOTATION_ITEM, ANNOTATION_REFERENCE, ANNOTATION_SET, DIRECTIONAL_LIGHT, LIGHT_INTENSITY_FROM, MARKUP_LINKED_ITEM, MAX_DEPTH, + MKP_LEADER, MKP_MARKUP, MKP_VIEW, POINT_LIGHT, SPOT_LIGHT, SURF_PLANE, VIEW_FLAGS_FROM, +}; +use crate::PrcError; +use crate::entity::{Ctx, malformed}; + +impl Ctx<'_, '_> { + /// `MarkupData` [WD 7.3.10.4]: linked items, leaders, markups, then annotation entities. + pub(super) fn markup_data(&mut self) -> Result<(), PrcError> { + let n = self.count(1, "markup linked items")?; + for _ in 0..n { + self.markup_linked_item()?; + } + let n = self.count(1, "leaders")?; + for _ in 0..n { + self.leader()?; + } + let n = self.count(1, "markups")?; + for _ in 0..n { + self.mkp_markup()?; + } + let n = self.count(1, "annotation entities")?; + for _ in 0..n { + self.annotation_entity(0)?; + } + Ok(()) + } + + /// A counted list of `MKP_View`s. + pub(super) fn views(&mut self) -> Result<(), PrcError> { + let n = self.count(1, "views")?; + for _ in 0..n { + self.view()?; + } + Ok(()) + } + + /// A counted list of `ReferenceOnPRCBase`s. + fn references(&mut self, what: &'static str) -> Result<(), PrcError> { + let n = self.count(1, what)?; + for _ in 0..n { + self.reference_on_prc_base()?; + } + Ok(()) + } + + /// `MISC_MarkupLinkedItem` (204) [WD 7.4.5]. + // prc-rs: a `has_reference_data` flag and its reference data follow the content entity + // reference (pdf-issues #722). + fn markup_linked_item(&mut self) -> Result<(), PrcError> { + self.expect_type(MARKUP_LINKED_ITEM)?; + self.base_with_graphics()?; + self.r.unsigned_integer()?; // local coordinate system + 1 + if self.r.bit()? { + self.reference_data()?; + } + if self.r.bit()? { + self.reference_data()?; + } + for _ in 0..4 { + self.r.bit()?; // show and delete, markup and leader + } + self.schema.skip_added_fields(MARKUP_LINKED_ITEM, &mut self.r)?; + self.user_data() + } + + /// `MKP_Leader` (503) [WD 7.7.5; pdf-issues #698]. + // prc-rs: the leader's field order. + fn leader(&mut self) -> Result<(), PrcError> { + self.expect_type(MKP_LEADER)?; + self.base_with_graphics()?; + for _ in 0..2 { + if self.r.bit()? { + self.reference_on_prc_base()?; // first, second linked item + } + } + self.r.unsigned_integer()?; // tessellation + 1 + self.schema.skip_added_fields(MKP_LEADER, &mut self.r)?; + self.user_data() + } + + /// `MKP_Markup` (502) [WD 7.7.4]. + // prc-rs: the markup's field order. + fn mkp_markup(&mut self) -> Result<(), PrcError> { + self.expect_type(MKP_MARKUP)?; + self.base_with_graphics()?; + self.r.unsigned_integer()?; // type + self.r.unsigned_integer()?; // subtype + self.references("markup linked items")?; + self.references("markup leaders")?; + self.r.unsigned_integer()?; // tessellation + 1 + self.schema.skip_added_fields(MKP_MARKUP, &mut self.r)?; + self.user_data() + } + + /// An annotation item (504), set (505) or reference (506) [WD 7.7.6-7.7.8]; sets nest. + // prc-rs: the annotation entities' field orders. + fn annotation_entity(&mut self, depth: usize) -> Result<(), PrcError> { + if depth > MAX_DEPTH { + return Err(malformed("annotation sets nest too deep".into())); + } + let t = self.r.unsigned_integer()?; + if !matches!(t, ANNOTATION_ITEM | ANNOTATION_SET | ANNOTATION_REFERENCE) { + return Err(malformed(format!("entity type {t} as an annotation"))); + } + self.base_with_graphics()?; + match t { + ANNOTATION_ITEM => self.reference_on_prc_base()?, + ANNOTATION_SET => { + let n = self.count(1, "annotation set")?; + for _ in 0..n { + self.annotation_entity(depth + 1)?; + } + } + _ => self.references("annotation reference")?, + } + self.schema.skip_added_fields(t, &mut self.r)?; + self.user_data() + } + + /// `MKP_View` (501) [WD 7.7.3]. + // prc-rs: the view's field order, and its flags, linked items and filters from version + // 8137. + fn view(&mut self) -> Result<(), PrcError> { + self.expect_type(MKP_VIEW)?; + self.base_with_graphics()?; + self.references("view annotations")?; + self.plane()?; + if self.r.bit()? { + self.scene_display_parameters()?; + } + if self.version >= VIEW_FLAGS_FROM { + for _ in 0..3 { + self.r.bit()?; // annotation view, default view, direction + } + self.references("view linked items")?; + let n = self.count(1, "view filters")?; + for _ in 0..n { + self.filter()?; + } + } + self.schema.skip_added_fields(MKP_VIEW, &mut self.r)?; + self.user_data() + } + + /// `SURF_Plane` (86) [WD 7.11.13; pdf-issues #658]: `ContentSurface` [WD 7.11.3.2], a + /// `Transformation3D` with no presence flag, the `Domain` [WD 8.2.4] and the + /// parameterisation's four coefficients. + // prc-rs: the plane's field order. + pub(super) fn plane(&mut self) -> Result<(), PrcError> { + self.expect_type(SURF_PLANE)?; + if self.r.bit()? { + self.content_prc_base()?; // base geometry: attributes and name + self.r.unsigned_integer()?; // identifier + } + self.r.unsigned_integer()?; // extend type + self.transformation_3d()?; + for _ in 0..8 { + self.r.double()?; // domain (4), coefficients (4) + } + self.schema.skip_added_fields(SURF_PLANE, &mut self.r) + } + + /// A light (731-734) [WD 7.5.15-7.5.18]. + // prc-rs: the lights' field orders, and a directional light's intensity from version 8030 + // (pdf-issues #745). + pub(super) fn light(&mut self) -> Result<(), PrcError> { + let t = self.r.unsigned_integer()?; + if !(AMBIENT_LIGHT..=SPOT_LIGHT).contains(&t) { + return Err(malformed(format!("entity type {t} as a light"))); + } + self.content_prc_ref_base()?; + for _ in 0..3 { + self.r.unsigned_integer()?; // ambient, diffuse, specular colour + 1 + } + if matches!(t, POINT_LIGHT | SPOT_LIGHT) { + self.vector3()?; // location + for _ in 0..3 { + self.r.double()?; // attenuation + } + } + if matches!(t, DIRECTIONAL_LIGHT | SPOT_LIGHT) { + self.vector3()?; // direction + } + if t == DIRECTIONAL_LIGHT && self.version >= LIGHT_INTENSITY_FROM { + self.r.double()?; + } + if t == SPOT_LIGHT { + self.r.double()?; // fall-off angle + self.r.double()?; // fall-off exponent + } + self.schema.skip_added_fields(t, &mut self.r) + } +} diff --git a/crates/prc/src/tree/markup_tests.rs b/crates/prc/src/tree/markup_tests.rs new file mode 100644 index 0000000000..dcd658bb62 --- /dev/null +++ b/crates/prc/src/tree/markup_tests.rs @@ -0,0 +1,280 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::tests::{Occ, PLAIN, S_RED, base, graphics, part_with, plate, ref_base, single}; +use crate::PrcError; +use crate::bits::BitReader; +use crate::entity::Ctx; +use crate::testw::W; +use crate::{Schema, Tessellation}; + +/// Read after each entity, to show it was read to its last bit and no further. +const SENTINEL: u32 = 0x5A5A; + +/// Runs `read` over `w` then the sentinel, at authoring version `version`. +fn read_at(w: &W, version: u32, read: impl FnOnce(&mut Ctx<'_, '_>) -> Result<(), PrcError>) -> Result<(), PrcError> { + let mut all = W::default(); + all.append(w).uint(SENTINEL); + let bytes = all.bytes(); + let schema = Schema::default(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, version); + read(&mut ctx)?; + assert_eq!(ctx.r.unsigned_integer()?, SENTINEL, "read past the wrong number of bits"); + Ok(()) +} + +fn read(w: &W, read: impl FnOnce(&mut Ctx<'_, '_>) -> Result<(), PrcError>) -> Result<(), PrcError> { + read_at(w, 8137, read) +} + +/// Each truncation of `w` is an error, never a panic. +fn truncations_fail(w: &W, read: impl Fn(&mut Ctx<'_, '_>) -> Result<(), PrcError>) { + let bytes = w.bytes(); + let schema = Schema::default(); + for end in 0..bytes.len().saturating_sub(1) { + let mut ctx = Ctx::new(BitReader::new(&bytes[..end]), &schema, 8137); + assert!(read(&mut ctx).is_err(), "cut at {end}"); + } +} + +fn reference(w: &mut W, uid: u32) { + w.uint(205).uint(237).bit(true).uint(uid); +} + +fn markup_data(linked: &[W], leaders: &[W], markups: &[W], annotations: &[W]) -> W { + let mut w = W::default(); + for list in [linked, leaders, markups, annotations] { + w.uint(u32::try_from(list.len()).unwrap()); + for e in list { + w.append(e); + } + } + w +} + +fn linked_item() -> W { + let mut w = W::default(); + w.uint(204); + graphics(&mut w, None, 1, PLAIN); + w.uint(0).bit(true); + reference(&mut w, 7); + w.bit(true).uint(206).uint(149).bit(true).bit(true).uint(1).uint(1).uint(1).uint(0); + w.bit(true).bit(false).bit(true).bit(false).uint(0); + w +} + +fn leader() -> W { + let mut w = W::default(); + w.uint(503); + graphics(&mut w, None, 2, PLAIN); + w.bit(true); + reference(&mut w, 3); + w.bit(false).uint(4).uint(0); + w +} + +fn markup() -> W { + let mut w = W::default(); + w.uint(502); + graphics(&mut w, Some("Note"), 3, PLAIN); + w.uint(1).uint(2).uint(1); + reference(&mut w, 7); + w.uint(2); + reference(&mut w, 2); + reference(&mut w, 2); + w.uint(3).uint(0); + w +} + +fn annotation(t: u32, inner: impl FnOnce(&mut W)) -> W { + let mut w = W::default(); + w.uint(t); + graphics(&mut w, None, 4, PLAIN); + inner(&mut w); + w.uint(0); + w +} + +fn annotation_item() -> W { + annotation(504, |w| reference(w, 3)) +} + +fn annotation_set(members: &[W]) -> W { + annotation(505, |w| { + w.uint(u32::try_from(members.len()).unwrap()); + for m in members { + w.append(m); + } + }) +} + +fn annotation_reference() -> W { + annotation(506, |w| { + w.uint(1); + reference(w, 3); + }) +} + +fn plane(w: &mut W) { + w.uint(86).bit(true); + base(w, Some("Front")); + w.uint(9).uint(0).put(0x01, 8); + for v in [1., 2., 3., 0., 0., 1., 1., 1., 1., 0., 0.] { + w.double(v); + } +} + +fn light(t: u32) -> W { + let mut w = W::default(); + w.uint(t); + ref_base(&mut w, None, 5); + w.uint(1).uint(2).uint(3); + let doubles = match t { + 732 => 6, + 733 => 4, + 734 => 11, + _ => 0, + }; + for _ in 0..doubles { + w.double(0.5); + } + w +} + +/// A scene with `lights` and `planes` clipping planes; `is_absolute` is written from 8137. +fn scene(lights: &[W], planes: usize, version: u32) -> W { + let mut w = W::default(); + w.uint(741); + ref_base(&mut w, None, 6); + w.bit(true).uint(u32::try_from(lights.len()).unwrap()); + for l in lights { + w.append(l); + } + w.bit(false).bit(false).uint(u32::try_from(planes).unwrap()); + for _ in 0..planes { + plane(&mut w); + } + w.uint(0).uint(0).uint(0); + if version >= 8137 { + w.bit(true); + } + w +} + +fn filter(w: &mut W) { + w.uint(320); + ref_base(w, None, 8); + w.bit(true).bit(true).uint(0).bit(true).uint(0).uint(0); +} + +fn view(version: u32) -> W { + let mut w = W::default(); + w.uint(501); + graphics(&mut w, Some("Top"), 7, PLAIN); + w.uint(1); + reference(&mut w, 3); + plane(&mut w); + w.bit(true).append(&scene(&[light(731)], 1, version)); + if version >= 8137 { + w.bit(true).bit(false).bit(true).uint(1); + reference(&mut w, 7); + w.uint(1); + filter(&mut w); + } + w.uint(0); + w +} + +fn views(vs: &[W]) -> W { + let mut w = W::default(); + w.uint(u32::try_from(vs.len()).unwrap()); + for v in vs { + w.append(v); + } + w +} + +#[test] +fn a_markup_linked_item_is_read_past() { + let w = markup_data(&[linked_item()], &[], &[], &[]); + read(&w, |c| c.markup_data()).unwrap(); + truncations_fail(&w, |c| c.markup_data()); +} + +#[test] +fn a_leader_is_read_past() { + let w = markup_data(&[], &[leader()], &[], &[]); + read(&w, |c| c.markup_data()).unwrap(); + truncations_fail(&w, |c| c.markup_data()); +} + +#[test] +fn a_markup_is_read_past() { + let w = markup_data(&[], &[], &[markup()], &[]); + read(&w, |c| c.markup_data()).unwrap(); + truncations_fail(&w, |c| c.markup_data()); +} + +#[test] +fn annotation_items_sets_and_references_are_read_past() { + let set = annotation_set(&[annotation_item(), annotation_set(&[annotation_reference()])]); + let w = markup_data(&[], &[], &[], &[annotation_item(), set, annotation_reference()]); + read(&w, |c| c.markup_data()).unwrap(); + truncations_fail(&w, |c| c.markup_data()); +} + +#[test] +fn annotation_sets_nested_past_the_ceiling_are_an_error() { + let mut e = annotation_item(); + for _ in 0..=super::MAX_DEPTH { + e = annotation_set(&[e]); + } + let w = markup_data(&[], &[], &[], &[e]); + assert!(matches!(read(&w, |c| c.markup_data()), Err(PrcError::Malformed(_)))); +} + +#[test] +fn an_annotation_of_another_type_is_an_error() { + let w = markup_data(&[], &[], &[], &[annotation(502, |_| {})]); + assert!(matches!(read(&w, |c| c.markup_data()), Err(PrcError::Malformed(_)))); +} + +#[test] +fn a_view_is_read_past_with_its_flags_only_from_8137() { + let w = views(&[view(8137)]); + read(&w, |c| c.views()).unwrap(); + truncations_fail(&w, |c| c.views()); + read_at(&views(&[view(8030)]), 8030, |c| c.views()).unwrap(); +} + +#[test] +fn scene_lights_of_each_kind_are_read_past() { + let w = scene(&[light(731), light(732), light(733), light(734)], 0, 8137); + read(&w, |c| c.scene_display_parameters()).unwrap(); + truncations_fail(&w, |c| c.scene_display_parameters()); + let mut old = W::default(); + old.uint(733); + ref_base(&mut old, None, 5); + old.uint(1).uint(2).uint(3).double(0.).double(0.).double(1.); + read_at(&old, 8020, |c| c.light()).unwrap(); +} + +#[test] +fn scene_clipping_planes_are_read_past() { + let w = scene(&[], 2, 8137); + read(&w, |c| c.scene_display_parameters()).unwrap(); + truncations_fail(&w, |c| c.scene_display_parameters()); +} + +#[test] +fn a_tree_with_markups_and_views_still_places_its_parts() { + let mut tail = markup_data(&[linked_item()], &[leader()], &[markup()], &[annotation_set(&[annotation_item()])]); + tail.append(&views(&[view(8137)])); + let part = part_with(Some("Plate"), PLAIN, 1, |w| super::tests::poly_item(w, 7, 0, 1, (S_RED, super::SHOW)), &tail); + let occs = [Occ { name: Some("Asm"), sons: vec![1], markups: Some(tail.clone()), ..Occ::default() }, Occ { part: 1, ..Occ::default() }]; + let f = single(&[part, plate(S_RED)], &occs); + let nodes = f.model_tree().unwrap(); + assert_eq!(nodes.len(), 2); + let p = f.placements().unwrap(); + assert_eq!(p.len(), 1); + assert!(matches!(f.file_structures[0].tessellations().unwrap().first(), Some(Tessellation::Mesh(_)))); +} diff --git a/crates/prc/src/tree/mod.rs b/crates/prc/src/tree/mod.rs new file mode 100644 index 0000000000..3ed9d8eb20 --- /dev/null +++ b/crates/prc/src/tree/mod.rs @@ -0,0 +1,329 @@ +//! The product tree: the tree section (`FileStructureTree`, 304), the globals' styles and +//! coordinate systems (303) and the model file (301), walked into placed tessellations +//! [WD 7.3.3-7.3.11, 7.5, 7.6]. +//! +//! Markups (PMI), views, scene lights and clipping planes are read past and not modelled. + +// prc-rs: where the working draft leaves an entity's field order open, the order here is +// prc-rs's format description's; each such entity's reader carries its own marker. + +use std::sync::Arc; + +use crate::bits::{BitReader, Budget}; +use crate::container::offset; +use crate::entity::{Ctx, malformed}; +use crate::geometry::{Matrix, Vec3}; +use crate::{PrcError, PrcFile, Schema, SectionKind, UniqueId}; + +mod markup; +mod node; +mod overrides; +mod read; +mod style; +mod walk; + +pub use node::{ModelNode, NameSource}; +pub use overrides::EntityOverrides; +use overrides::{Override, Target}; +pub(crate) use style::Graphics; +use style::{Globals, Paint, resolve_style}; +pub use style::{Rgba, StyleAlpha}; +use walk::{At, Walk}; + +const BASE_WITH_GRAPHICS: u32 = 2; +const MODEL_FILE: u32 = 301; +const FILE_STRUCTURE: u32 = 302; +const FILE_STRUCTURE_GLOBALS: u32 = 303; +const FILE_STRUCTURE_TREE: u32 = 304; +const PRODUCT_OCCURRENCE: u32 = 310; +const PART_DEFINITION: u32 = 311; +const FILTER: u32 = 320; +const SURF_PLANE: u32 = 86; +const CARTESIAN_TRANSFORMATION: u32 = 202; +const ENTITY_REFERENCE: u32 = 203; +const MARKUP_LINKED_ITEM: u32 = 204; +const REFERENCE_ON_PRC_BASE: u32 = 205; +const REFERENCE_ON_TOPOLOGY: u32 = 206; +const GENERAL_TRANSFORMATION: u32 = 207; +const RI_BREP_MODEL: u32 = 232; +const RI_CURVE: u32 = 233; +const RI_DIRECTION: u32 = 234; +const RI_PLANE: u32 = 235; +const RI_POINT_SET: u32 = 236; +const RI_POLY_BREP_MODEL: u32 = 237; +const RI_POLY_WIRE: u32 = 238; +const RI_SET: u32 = 239; +const RI_COORDINATE_SYSTEM: u32 = 240; +const MKP_VIEW: u32 = 501; +const MKP_MARKUP: u32 = 502; +const MKP_LEADER: u32 = 503; +const ANNOTATION_ITEM: u32 = 504; +const ANNOTATION_SET: u32 = 505; +const ANNOTATION_REFERENCE: u32 = 506; +const STYLE: u32 = 701; +const MATERIAL: u32 = 702; +const PICTURE: u32 = 703; +const TEXTURE_APPLICATION: u32 = 711; +const TEXTURE_DEFINITION: u32 = 712; +const TEXTURE_TRANSFORMATION: u32 = 713; +const LINE_PATTERN: u32 = 721; +const AMBIENT_LIGHT: u32 = 731; +const POINT_LIGHT: u32 = 732; +const DIRECTIONAL_LIGHT: u32 = 733; +const SPOT_LIGHT: u32 = 734; +const SCENE_DISPLAY_PARAMETERS: u32 = 741; +const CAMERA: u32 = 742; +/// prc-rs: a scene's `is_absolute` exists from this authoring version. +const ABSOLUTE_SCENE_FROM: u32 = 8137; +/// prc-rs: a view's flags, linked items and filters exist from this authoring version. +const VIEW_FLAGS_FROM: u32 = 8137; +/// prc-rs: a directional light's intensity exists from this authoring version. +const LIGHT_INTENSITY_FROM: u32 = 8030; +/// `TextureDefinition` mapping type "operator" and function "blend", 0-based +/// (pdf-issues #485). +const TEXTURE_MAPPING_OPERATOR: i32 = 3; +const TEXTURE_FUNCTION_BLEND: i32 = 3; +/// Texture application mode: alpha test [WD 7.5.7]. +const TEXTURE_ALPHA_TEST: u8 = 0x02; + +/// Ceiling on `RI_Set` nesting, the occurrence walk's depth and a prototype chain's length. +const MAX_DEPTH: usize = 64; +/// Ceiling on occurrences visited in one walk: prototypes share subtrees, so a small file can +/// describe an exponential walk. +const MAX_VISITS: usize = 1 << 20; +/// Ceiling on placements one walk yields. +pub const MAX_PLACEMENTS: usize = 1 << 20; + +/// `GraphicsContent` behaviour bits [WD 7.2.4.2]. +const SHOW: u16 = 0x0001; +const SON_HERIT_COLOR: u16 = 0x0008; +const FATHER_HERIT_COLOR: u16 = 0x0010; +const REMOVED: u16 = 0x2000; +/// `product_behavior` SUPPRESSED [WD 7.3.10]. +const SUPPRESSED: u8 = 0x01; + +/// Per file structure, entry `b` is the paint of biased style `b`. +type Palettes = Arc<[Arc<[Option]>]>; + +/// A tessellation drawn at a place in the model: world = [`Self::matrix`] × the +/// tessellation's own coordinates. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Placement { + /// Index into [`PrcFile::file_structures`]. + pub file_structure: usize, + /// Index into that file structure's [`tessellations`](crate::FileStructure::tessellations), + /// as the tree stores it: [`PrcFile::placements`] does not decode that section, so an + /// index past its end is the caller's to refuse. + pub tessellation: usize, + /// The occurrence path's locations, then the representation item's coordinate systems. + pub matrix: Matrix, + /// The colour of the style the tree resolves for this item; `None` when no style + /// reaches it or the style names no colour this reader resolves (a textured material + /// without a base). Faces with their own style are coloured by + /// [`Self::triangle_colours`]. + pub colour: Option, + /// The graphics from the root to this item, outermost first. + chain: Vec, + palettes: Palettes, + /// An occurrence's entity reference replacing the item's style. + item_override: Option, + /// Per face index, sorted, an entity reference replacing that face's style. + face_overrides: Vec<(u32, Graphics)>, +} + +/// The paint of the style `won` names, in its file structure's globals. +fn paint_of(palettes: &Palettes, won: Graphics) -> Option { + palettes.get(won.fs)?.get(offset(won.style)).copied().flatten() +} + +impl Placement { + /// Per triangle of `mesh` (this placement's tessellation), its colour as + /// [`Self::colour`] describes, with each face's own style taking part in the inheritance + /// [WD 7.2.4, 7.8.6]; `None` when no face of `mesh` carries a style or an override, so + /// every triangle is [`Self::colour`]. + #[must_use] + pub fn triangle_colours(&self, mesh: &crate::TriangleMesh) -> Option>> { + if mesh.triangle_styles.is_empty() && self.face_overrides.is_empty() { + return None; + } + let faces = overrides::triangle_faces(mesh); + let by_face = |k: usize| { + let f = faces.get(k)?; + let i = self.face_overrides.binary_search_by_key(f, |(i, _)| *i).ok()?; + self.face_overrides.get(i).map(|(_, g)| *g) + }; + let colour = |won| paint_of(&self.palettes, won).map(|p| p.rgba); + if mesh.triangle_styles.is_empty() { + let whole = self.item_override.unwrap_or_else(|| resolve_style(&self.chain)); + return Some((0..mesh.triangles.len()).map(|k| colour(by_face(k).unwrap_or(whole))).collect()); + } + let mut chain = self.chain.clone(); + chain.push(Graphics::default()); + let looks = mesh.triangle_styles.iter().enumerate().map(|(k, s)| { + if let Some(won) = by_face(k).or(self.item_override) { + return colour(won); + } + if let Some(last) = chain.last_mut() { + *last = Graphics { style: s.style, bits: s.behaviour, fs: self.file_structure }; + } + colour(resolve_style(&chain)) + }); + Some(looks.collect()) + } +} + +/// A representation item that can be drawn, flattened out of any `RI_Set`. +#[derive(Debug, Clone, PartialEq)] +struct Item { + /// `index_local_coordinate_system + 1` of each enclosing set and the item, outermost + /// first; 0 entries dropped. + local: Vec, + /// `index_tessellation + 1`; never 0 here. + tessellation: u32, + /// The part's graphics, each enclosing set's, then the item's own. + graphics: Vec, + /// The PRC unique id of each enclosing set, then the item's own. + uids: Vec, + /// A B-rep model's stored `(context, body)` ids [WD 7.6.2]. + brep: Option<(u32, u32)>, +} + +/// A `ProductOccurrence` (310) as far as placing and naming. +#[derive(Debug, Clone, PartialEq, Default)] +struct Product { + part: u32, + prototype: u32, + prototype_fs: Option, + external: u32, + external_fs: Option, + sons: Vec, + /// Its own graphics hide it (Show clear or Removed set). + hidden: bool, + suppressed: bool, + graphics: Graphics, + location: Option, + name: Option, + /// Its entity references that name a target. + overrides: Vec, +} + +/// One file structure's tree section. +#[derive(Debug, Clone, PartialEq, Default)] +struct Tree { + parts: Vec>, + /// Each part definition's name, parallel to `parts`. + part_names: Vec>, + products: Vec, +} + +/// The model file's roots: file structure id and biased occurrence index. +#[derive(Debug, Clone, PartialEq)] +struct ModelFile { + roots: Vec<(UniqueId, u32)>, +} + +impl PrcFile { + /// Where each drawable tessellation sits in the model: the product-occurrence tree walked + /// from the model file's roots, each occurrence's location composed onto its father's, + /// and each representation item's local coordinate systems applied last [WD 7.3.10.1, + /// 7.6.3.2], coloured with [`StyleAlpha::ZeroUnset`] and entity references applied by + /// [`EntityOverrides::Subtree`]. Suppressed occurrences and hidden entities are left + /// out; a tessellation several occurrences draw appears once per occurrence. Empty when + /// the file stores no tree ([`Self::stores_tree`]). + /// + /// # Errors + /// [`PrcError::Unsupported`] when an item or an entity reference names a reference + /// coordinate system and the globals carry fonts or fill patterns; [`PrcError::Malformed`] for a tree index naming no entity, + /// a cycle of occurrences or prototypes, or a tree past [`MAX_PLACEMENTS`] or the depth + /// ceiling; read errors as [`crate::FileStructure::tessellations`]. + pub fn placements(&self) -> Result, PrcError> { + self.placements_with(StyleAlpha::default(), EntityOverrides::default()) + } + + /// [`Self::placements`], combining style transparency with material alpha by `rule` and + /// applying occurrences' entity references by `scope`. + /// + /// # Errors + /// As [`Self::placements`]. + pub fn placements_with(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result, PrcError> { + Ok(self.walk(rule, scope)?.0) + } + + /// The assembly tree as a model-tree panel lists it: every product occurrence the model + /// file's roots reach, depth first, with its display name and stored visibility. Hidden + /// and suppressed subtrees are listed (with `drawn` false) though [`Self::placements`] + /// leaves them out; each node's `placements` range indexes that list, so attributes each + /// placed mesh to its part. Empty when the file stores no tree. + /// + /// # Errors + /// As [`Self::placements`]. + pub fn model_tree(&self) -> Result, PrcError> { + Ok(self.walk(StyleAlpha::default(), EntityOverrides::default())?.1) + } + + /// Whether the file stores a tree: its model file holds an entity. ISO 14739-1 requires + /// one, but a tessellation-only file still draws (unplaced), so its absence reads as an + /// empty tree, not an error. + #[must_use] + pub fn stores_tree(&self) -> bool { + self.model_file.iter().any(|&b| b != 0) + } + + fn walk(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result<(Vec, Vec), PrcError> { + self.walk_within(rule, scope, Budget::FULL) + } + + /// [`Self::walk`] with `budget` shared by all its readers. + pub(crate) fn walk_within(&self, rule: StyleAlpha, scope: EntityOverrides, budget: Budget) -> Result<(Vec, Vec), PrcError> { + if !self.stores_tree() { + return Ok((Vec::new(), Vec::new())); + } + // One allowance for the whole walk, so many sections cannot each spend a full one. + let mut left = budget; + let mut trees = Vec::with_capacity(self.file_structures.len()); + for fs in &self.file_structures { + // The globals open with the schema every reader of this structure uses: read it + // once, charged to the walk, and go on from there for the globals themselves. + let mut globals_reader = BitReader::with_budget(fs.section(SectionKind::Globals), left); + let schema = Schema::read(&mut globals_reader)?; + left = globals_reader.budget(); + let mut ctx = Ctx::new(BitReader::with_budget(fs.section(SectionKind::Tree), left), &schema, fs.authoring_version); + let tree = ctx.file_structure_tree()?; + left = ctx.r.budget(); + // The globals carry the coordinate systems and the colours; colours alone are not + // worth refusing the placements over. + let mut ctx = Ctx::new(globals_reader.with_remaining(left), &schema, fs.authoring_version); + let globals = ctx.globals(); + left = ctx.r.budget(); + let globals = match globals { + Ok(g) => g, + Err(e) if places_by_system(&tree) => return Err(e), + Err(_) => Globals::default(), + }; + trees.push((fs.id, tree, globals)); + } + let mut r = BitReader::with_budget(&self.model_file, left); + let schema = Schema::read(&mut r)?; + let model = Ctx::new(r, &schema, self.header.authoring_version).model_file()?; + let mut walk = Walk::new(trees.iter().map(|(id, t, g)| (*id, t, g)).collect(), rule, scope); + for (id, root) in model.roots { + let fs = trees.iter().position(|t| t.0 == id).ok_or_else(|| malformed("a root names an unknown file structure".into()))?; + if let Some(index) = root.checked_sub(1) { + walk.occurrence(fs, offset(index), &Matrix::IDENTITY, &[], At::ROOT)?; + } + } + Ok((walk.out, walk.nodes)) + } +} + +/// Whether an item of `tree`, or an entity reference one of its occurrences holds, names a +/// coordinate system: then the globals that store them must have been read. +fn places_by_system(tree: &Tree) -> bool { + tree.parts.iter().flatten().any(|i| !i.local.is_empty()) || tree.products.iter().flat_map(|p| &p.overrides).any(|o| o.local != 0) +} + +#[cfg(test)] +mod markup_tests; +#[cfg(test)] +mod tests; diff --git a/crates/prc/src/tree/node.rs b/crates/prc/src/tree/node.rs new file mode 100644 index 0000000000..19a8e04e5c --- /dev/null +++ b/crates/prc/src/tree/node.rs @@ -0,0 +1,68 @@ +//! The assembly tree as a list: what a model-tree panel shows. + +use std::ops::Range; + +/// Where a [`ModelNode`]'s name came from. PRC does not say which name a +/// viewer should show; this reader takes the first present of these, in order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum NameSource { + /// The occurrence's own name. + Occurrence, + /// The name of an occurrence in its prototype chain. + Prototype, + /// The name of the part definition it draws. + Part, + /// No name anywhere: [`ModelNode::name`] is `None`. + Unnamed, +} + +/// One product occurrence of a PRC model's assembly tree, as +/// [`PrcFile::model_tree`](crate::PrcFile::model_tree) lists it: depth +/// first, a parent before its children, children in file order. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ModelNode { + /// The name to show, by [`Self::name_from`]; `None` when unnamed. + pub name: Option, + /// Where [`Self::name`] came from. + pub name_from: NameSource, + /// Index of the parent node in the list; `None` for a root. + pub parent: Option, + /// 0 for a root. + pub depth: usize, + /// Index into [`crate::PrcFile::file_structures`] of the structure + /// holding this occurrence. + pub file_structure: usize, + /// The occurrence's 0-based index in that structure's tree. + pub occurrence: usize, + /// The occurrence's own graphics hide it (Show clear, or Removed) + /// [WD 7.2.4.2]: the visibility the file stores, which a viewer opens on. + pub hidden: bool, + /// The occurrence is suppressed (`product_behavior`) [WD 7.3.10]. + pub suppressed: bool, + /// Whether anything of this node is drawn: false when it or an + /// ancestor is hidden or suppressed. + pub drawn: bool, + /// Whether the occurrence (or its prototype) carries a part definition, + /// i.e. geometry of its own rather than only children. + pub has_part: bool, + /// The placements this node and its descendants draw: indices into + /// [`PrcFile::placements`](crate::PrcFile::placements) (or + /// [`placements_with`](crate::PrcFile::placements_with), which yields the same order): + /// the meshes attributed to this occurrence's part and its descendants' parts. Empty when + /// nothing under it is drawn. + pub placements: Range, +} + +impl ModelNode { + /// The label the node is listed under: its name, or `occurrence F:I` + /// (file structure, occurrence) when it has none. + #[must_use] + pub fn label(&self) -> String { + match &self.name { + Some(name) => name.clone(), + None => format!("occurrence {}:{}", self.file_structure, self.occurrence), + } + } +} diff --git a/crates/prc/src/tree/overrides.rs b/crates/prc/src/tree/overrides.rs new file mode 100644 index 0000000000..a85a83ba09 --- /dev/null +++ b/crates/prc/src/tree/overrides.rs @@ -0,0 +1,128 @@ +//! Product-occurrence entity references: the colour, visibility and placement an occurrence +//! imposes on an entity it reaches [WD 7.3.10.1, 7.4.4]. + +use super::{Graphics, Item, RI_BREP_MODEL, RI_COORDINATE_SYSTEM, UniqueId}; + +/// `PRC_TYPE_TOPO_Face` [WD 6.2]. +const TOPO_FACE: u32 = 149; + +/// Which entities an occurrence's entity references reach. ISO 14739-1 says a reference +/// overrides the referenced entity's properties, and not where. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[non_exhaustive] +pub enum EntityOverrides { + /// Only where the occurrence's own subtree places the target, so one instance of a + /// shared part can be coloured alone. + #[default] + Subtree, + /// Every placement of the target anywhere in the model. + Everywhere, + /// Read past them: every entity keeps its own and inherited graphics. + Ignore, +} + +/// One `MISC_EntityReference` held by a product occurrence. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Override { + /// The reference's graphics; style 0 leaves the target's colour alone. + pub(super) graphics: Graphics, + /// The graphics hide the target (Show clear or Removed set). + pub(super) hidden: bool, + /// `index_local_coordinate_system + 1` replacing the target's; 0 none. + pub(super) local: u32, + pub(super) target: Target, +} + +/// What an entity reference names. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Target { + /// `ReferenceOnPRCBase`: the entity of type `kind` whose PRC unique id is `uid` in + /// structure `fs` (`None`: the occurrence's own). + Entity { kind: u32, fs: Option, uid: u32 }, + /// `ReferenceOnTopology` into the B-rep stored as `(context, body)`: its topological + /// items of type `kind` at `indices` (0-based). With `fs` `None` it matches that B-rep in + /// any structure the subtree reaches: producers write "same structure" for an assembly's + /// reference into a part's own structure. + Topology { kind: u32, fs: Option, brep: (u32, u32), indices: Vec }, +} + +impl Target { + /// The structure the reference names, if it names one. + pub(super) fn fs(&self) -> Option { + match self { + Target::Entity { fs, .. } | Target::Topology { fs, .. } => *fs, + } + } +} + +/// An entity reference in force: the structure of the occurrence holding it, the structure +/// it names (`None`: any), and the reference. +pub(super) type Active<'t> = (usize, Option, &'t Override); + +/// The overrides that reach one representation item. +#[derive(Debug, Default)] +pub(super) struct Reached { + /// Graphics replacing the item's resolved style, in its owner's structure. + pub(super) item: Option, + pub(super) hidden: bool, + /// The owner's structure and its local coordinate system. + pub(super) local: Option<(usize, u32)>, + /// Per face index, sorted, graphics replacing that face's style. + pub(super) faces: Vec<(u32, Graphics)>, +} + +impl Reached { + /// Applies `active` innermost first, so an outer occurrence's override wins over an + /// inner one's, as an assembly's appearance beats its sub-assembly's. + pub(super) fn collect(active: &[Active<'_>], fs: usize, item: &Item) -> Self { + let mut r = Reached::default(); + // A map, not a list searched per index: one reference may name millions of faces. + let mut faces = std::collections::BTreeMap::new(); + for &(owner, named, ov) in active.iter().rev() { + let in_fs = named.is_none_or(|n| n == fs); + let g = Graphics { fs: owner, ..ov.graphics }; + match &ov.target { + Target::Entity { kind, uid, .. } => { + if !in_fs || !is_item(*kind) || !item.uids.contains(uid) { + continue; + } + if ov.graphics.style != 0 { + r.item = Some(g); + } + r.hidden = ov.hidden; + if ov.local != 0 { + r.local = Some((owner, ov.local)); + } + } + Target::Topology { kind, brep, indices, .. } => { + if !in_fs || *kind != TOPO_FACE || item.brep != Some(*brep) || ov.graphics.style == 0 { + continue; + } + for &f in indices { + faces.insert(f, g); + } + } + } + } + r.faces = faces.into_iter().collect(); + r + } +} + +/// Representation-item types a `ReferenceOnPRCBase` can colour. +fn is_item(kind: u32) -> bool { + (RI_BREP_MODEL..=RI_COORDINATE_SYSTEM).contains(&kind) +} + +/// Per triangle of `mesh`, the index of the `TESS_Face` it was read from (`u32::MAX` for +/// none). +pub(super) fn triangle_faces(mesh: &crate::TriangleMesh) -> Vec { + let mut out = vec![u32::MAX; mesh.triangles.len()]; + for (i, range) in mesh.faces.iter().enumerate() { + let face = u32::try_from(i).unwrap_or(u32::MAX); + for f in out.get_mut(range.clone()).into_iter().flatten() { + *f = face; + } + } + out +} diff --git a/crates/prc/src/tree/read.rs b/crates/prc/src/tree/read.rs new file mode 100644 index 0000000000..ff3affaeee --- /dev/null +++ b/crates/prc/src/tree/read.rs @@ -0,0 +1,452 @@ +//! The tree section's and the model file's entities: part definitions, representation items, +//! product occurrences and their transformations and entity references [WD 7.3, 7.4, 7.6]. + +use super::{ + ABSOLUTE_SCENE_FROM, BASE_WITH_GRAPHICS, CAMERA, CARTESIAN_TRANSFORMATION, ENTITY_REFERENCE, FILE_STRUCTURE, FILE_STRUCTURE_TREE, FILTER, + GENERAL_TRANSFORMATION, Graphics, Item, MAX_DEPTH, MODEL_FILE, Matrix, ModelFile, Override, PART_DEFINITION, PRODUCT_OCCURRENCE, Product, + REFERENCE_ON_PRC_BASE, REFERENCE_ON_TOPOLOGY, REMOVED, RI_BREP_MODEL, RI_COORDINATE_SYSTEM, RI_CURVE, RI_DIRECTION, RI_PLANE, RI_POINT_SET, + RI_POLY_BREP_MODEL, RI_POLY_WIRE, RI_SET, SCENE_DISPLAY_PARAMETERS, SHOW, SUPPRESSED, Target, Tree, UniqueId, Vec3, +}; +use crate::PrcError; +use crate::entity::{Ctx, malformed}; + +impl Ctx<'_, '_> { + pub(super) fn vector3(&mut self) -> Result { + Ok(Vec3([self.r.double()?, self.r.double()?, self.r.double()?])) + } + + pub(super) fn unique_id(&mut self) -> Result { + let mut id = [0; 4]; + for v in &mut id { + *v = self.r.unsigned_integer()?; + } + Ok(UniqueId(id)) + } + + /// `ContentPRCRefBase` [WD 7.2.3.2; pdf-issues #697]: the base, then the CAD ids and the + /// PRC unique id, which becomes [`Ctx::uid`]. + pub(super) fn content_prc_ref_base(&mut self) -> Result<(), PrcError> { + self.content_prc_base()?; + self.r.unsigned_integer()?; // CAD identifier + self.r.unsigned_integer()?; // CAD persistent identifier + self.uid = self.r.unsigned_integer()?; + Ok(()) + } + + /// `PRCBaseWithGraphics` [WD 7.2.4; pdf-issues #405]: whether the entity's own graphics + /// hide it (Show clear or Removed set), and its graphics. Reused graphics + /// (`same_graphics`) are the current graphics and never hide. + pub(super) fn base_with_graphics(&mut self) -> Result<(bool, Graphics), PrcError> { + self.content_prc_ref_base()?; + let hidden = if self.r.bit()? { + false + } else { + self.r.unsigned_integer()?; // layer + 1 + let style = self.r.unsigned_integer()?; + let lo = u16::from(self.r.character()?); + let hi = u16::from(self.r.character()?); + let bits = lo | hi << 8; + self.graphics = Graphics { style, bits, fs: 0 }; + bits & SHOW == 0 || bits & REMOVED != 0 + }; + // prc-rs: schema additions to the base itself follow the graphics. + self.schema.skip_added_fields(BASE_WITH_GRAPHICS, &mut self.r)?; + Ok((hidden, self.graphics)) + } + + /// `CartesianTransformation` (202) or `GeneralTransformation` (207), type code included + /// [WD 7.4.8, 7.4.9, 7.4.11; pdf-issues #430]. + pub(super) fn transformation(&mut self) -> Result { + let t = self.r.unsigned_integer()?; + let m = match t { + GENERAL_TRANSFORMATION => { + // Column-major [WD 7.4.9]. + let mut m = Matrix::IDENTITY; + for col in 0..4 { + for row in &mut m.0 { + if let Some(c) = row.get_mut(col) { + *c = self.r.double()?; + } + } + } + m + } + CARTESIAN_TRANSFORMATION => self.transformation_3d()?, + t => return Err(malformed(format!("entity type {t} as a transformation"))), + }; + self.schema.skip_added_fields(t, &mut self.r)?; + Ok(m) + } + + /// The embedded `Transformation3D` [WD 7.4.11.1]: a behaviour byte, then each field its + /// bits name. + pub(super) fn transformation_3d(&mut self) -> Result { + let b = self.r.character()?; + let mut m = Matrix::IDENTITY; + let set_col = |m: &mut Matrix, col: usize, v: Vec3| { + for (row, x) in m.0.iter_mut().zip(v.0) { + if let Some(c) = row.get_mut(col) { + *c = x; + } + } + }; + if b & 0x01 != 0 { + let t = self.vector3()?; + set_col(&mut m, 3, t); + } + if b & 0x20 != 0 { + for col in 0..3 { + let v = self.vector3()?; + set_col(&mut m, col, v); + } + } else if b & 0x02 != 0 { + let x = self.vector3()?; + let y = self.vector3()?; + // Mirrored (0x04): the third axis is y × x. + let z = if b & 0x04 != 0 { y.cross(x) } else { x.cross(y) }; + set_col(&mut m, 0, x); + set_col(&mut m, 1, y); + set_col(&mut m, 2, z); + } + let mut scale = [1.0; 3]; + if b & 0x10 != 0 { + scale = self.vector3()?.0; + } + // prc-rs: with both scale bits set, both are read and multiply. + if b & 0x08 != 0 { + let s = self.r.double()?; + scale = scale.map(|v| v * s); + } + for row in m.0.iter_mut().take(3) { + for (c, s) in row.iter_mut().zip(scale) { + *c *= s; + } + } + if b & 0x40 != 0 { + let h = [self.r.double()?, self.r.double()?, self.r.double()?, self.r.double()?]; + if let Some(r) = m.0.get_mut(3) { + *r = h; + } + } + Ok(m) + } + + /// A polymorphic representation item [WD 7.6]; drawable ones are pushed onto `out` with + /// `local` (the enclosing sets' coordinate systems) prefixed. + // prc-rs: the item's own fields follow its local coordinate system and tessellation. + fn representation_item(&mut self, at: &Item, out: &mut Vec, depth: usize) -> Result<(), PrcError> { + if depth > MAX_DEPTH { + return Err(malformed("representation sets nest too deep".into())); + } + let t = self.r.unsigned_integer()?; + let (hidden, own) = self.base_with_graphics()?; + let mut item = at.clone(); + item.uids.push(self.uid); + item.graphics.push(own); + let cs = self.r.unsigned_integer()?; + let tess = self.r.unsigned_integer()?; + if cs != 0 { + item.local.push(cs); + } + let (drawable, brep) = if t == RI_SET { + let n = self.count(1, "representation set")?; + let mut members = Vec::new(); + for _ in 0..n { + self.representation_item(&item, &mut members, depth + 1)?; + } + if !hidden { + out.extend(members); + } + (false, None) + } else { + self.leaf_item_fields(t)? + }; + self.schema.skip_added_fields(t, &mut self.r)?; + self.user_data()?; + if drawable && !hidden && tess != 0 { + out.push(Item { tessellation: tess, brep, ..item }); + } + Ok(()) + } + + /// The type-specific fields of a representation item other than `RI_Set` [WD 7.6]; + /// whether the item can be drawn, and a B-rep model's `(context, body)`. + fn leaf_item_fields(&mut self, t: u32) -> Result<(bool, Option<(u32, u32)>), PrcError> { + match t { + RI_BREP_MODEL => { + let brep = self.exact_tolerance()?; + self.r.bit()?; // is_closed + return Ok((true, brep)); + } + RI_CURVE | RI_PLANE => { + self.exact_tolerance()?; + } + RI_DIRECTION => { + if self.r.bit()? { + self.vector3()?; // origin + } + self.vector3()?; + return Ok((false, None)); + } + RI_POINT_SET => { + let n = self.count(6, "point set")?; + for _ in 0..n { + self.vector3()?; + } + } + RI_POLY_BREP_MODEL => { + self.r.bit()?; // is_closed + } + RI_POLY_WIRE => {} + RI_COORDINATE_SYSTEM => { + self.transformation()?; + return Ok((false, None)); + } + t => return Err(malformed(format!("entity type {t} as a representation item"))), + } + Ok((true, None)) + } + + /// A B-rep, curve or plane item's optional exact-geometry `(context, body)` ids + /// [WD 7.6.2]. + fn exact_tolerance(&mut self) -> Result, PrcError> { + if self.r.bit()? { + return Ok(Some((self.r.unsigned_integer()?, self.r.unsigned_integer()?))); + } + Ok(None) + } + + /// `SceneDisplayParameters` (741) [WD 7.5.19]: read past, lights and clipping planes + /// included. + // prc-rs: the scene's field order, and `is_absolute` from version 8137. + pub(super) fn scene_display_parameters(&mut self) -> Result<(), PrcError> { + self.expect_type(SCENE_DISPLAY_PARAMETERS)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_active + let n = self.count(1, "scene lights")?; + for _ in 0..n { + self.light()?; + } + if self.r.bit()? { + self.camera()?; + } + if self.r.bit()? { + self.vector3()?; // rotation centre + } + let n = self.count(1, "scene clipping planes")?; + for _ in 0..n { + self.plane()?; + } + self.r.unsigned_integer()?; // background style + 1 + self.r.unsigned_integer()?; // default style + 1 + let n = self.count(2, "scene default styles")?; + for _ in 0..n { + self.r.unsigned_integer()?; // entity type + self.r.unsigned_integer()?; // style + 1 + } + if self.version >= ABSOLUTE_SCENE_FROM { + self.r.bit()?; // is_absolute + } + self.schema.skip_added_fields(SCENE_DISPLAY_PARAMETERS, &mut self.r) + } + + /// `Camera` (742) [WD 7.5.20]: read past. + // prc-rs: the camera's field order. + fn camera(&mut self) -> Result<(), PrcError> { + self.expect_type(CAMERA)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_orthographic + for _ in 0..3 { + self.vector3()?; // position, look, up + } + for _ in 0..6 { + self.r.double()?; // x, y, ratio, near, far, zoom + } + self.schema.skip_added_fields(CAMERA, &mut self.r) + } + + /// `MISC_EntityReference` (203) [WD 7.4.4, 7.4.10]; `None` when it names no target. + // prc-rs: the local coordinate system precedes the optional reference data. + fn entity_reference(&mut self) -> Result, PrcError> { + self.expect_type(ENTITY_REFERENCE)?; + let (hidden, graphics) = self.base_with_graphics()?; + let local = self.r.unsigned_integer()?; + let target = if self.r.bit()? { self.reference_data()? } else { None }; + self.schema.skip_added_fields(ENTITY_REFERENCE, &mut self.r)?; + self.user_data()?; + Ok(target.map(|target| Override { graphics, hidden, local, target })) + } + + /// `ReferenceData` [WD 7.4.10.2]: a `ReferenceOnPRCBase` (205) or a + /// `ReferenceOnTopology` (206), type code included; `None` when it names no target. + pub(super) fn reference_data(&mut self) -> Result, PrcError> { + let t = self.r.unsigned_integer()?; + let target = match t { + REFERENCE_ON_PRC_BASE => Some(self.reference_on_prc_base_body()?), + REFERENCE_ON_TOPOLOGY => self.reference_on_topology()?, + t => return Err(malformed(format!("entity type {t} as reference data"))), + }; + self.schema.skip_added_fields(t, &mut self.r)?; + Ok(target) + } + + /// `ReferenceOnPRCBase` (205) [WD 7.4.6] after its type code. + fn reference_on_prc_base_body(&mut self) -> Result { + let kind = self.r.unsigned_integer()?; + let fs = self.file_identifier()?; + let uid = self.r.unsigned_integer()?; + Ok(Target::Entity { kind, fs, uid }) + } + + /// `ReferenceOnPRCBase` (205) [WD 7.4.6], type code included: read past. + pub(super) fn reference_on_prc_base(&mut self) -> Result<(), PrcError> { + self.expect_type(REFERENCE_ON_PRC_BASE)?; + self.reference_on_prc_base_body()?; + self.schema.skip_added_fields(REFERENCE_ON_PRC_BASE, &mut self.r) + } + + /// `ReferenceOnTopology` (206) after its type code; `None` without a body. + fn reference_on_topology(&mut self) -> Result, PrcError> { + let kind = self.r.unsigned_integer()?; + if !self.r.bit()? { + return Ok(None); + } + let fs = self.file_identifier()?; + let brep = (self.r.unsigned_integer()?, self.r.unsigned_integer()?); + let indices = self.uints("topology reference indices")?; + Ok(Some(Target::Topology { kind, fs, brep, indices })) + } + + /// `ASM_Filter` (320) [WD 7.3.12]: read past. + // prc-rs: the filter's field order. + pub(super) fn filter(&mut self) -> Result<(), PrcError> { + self.expect_type(FILTER)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_active + self.r.bit()?; // layers inclusive + self.uints("filter layers")?; + self.r.bit()?; // entities inclusive + let n = self.count(1, "filter entities")?; + for _ in 0..n { + self.entity_reference()?; + } + self.schema.skip_added_fields(FILTER, &mut self.r)?; + self.user_data() + } + + /// `PartDefinition` (311): its drawable items and its name. + fn part_definition(&mut self) -> Result<(Vec, Option), PrcError> { + self.expect_type(PART_DEFINITION)?; + let (hidden, own) = self.base_with_graphics()?; + let name = self.name.clone(); + self.vector3()?; // bounding box + self.vector3()?; + let n = self.count(1, "representation items")?; + let mut items = Vec::new(); + let at = Item { local: Vec::new(), tessellation: 0, graphics: vec![own], uids: Vec::new(), brep: None }; + for _ in 0..n { + self.representation_item(&at, &mut items, 0)?; + } + self.markup_data()?; + self.views()?; + self.schema.skip_added_fields(PART_DEFINITION, &mut self.r)?; + self.user_data()?; + if hidden { + items.clear(); + } + Ok((items, name)) + } + + /// `FileIdentifier` [WD 7.3.10.2.2]: `None` for the same structure. + fn file_identifier(&mut self) -> Result, PrcError> { + if self.r.bit()? { Ok(None) } else { self.unique_id().map(Some) } + } + + /// `ProductOccurrence` (310) [WD 7.3.10]. + fn product_occurrence(&mut self) -> Result { + self.expect_type(PRODUCT_OCCURRENCE)?; + let (hidden, graphics) = self.base_with_graphics()?; + let mut p = Product { hidden, graphics, name: self.name.clone(), part: self.r.unsigned_integer()?, ..Product::default() }; + p.prototype = self.r.unsigned_integer()?; + if p.prototype != 0 { + p.prototype_fs = self.file_identifier()?; + } + p.external = self.r.unsigned_integer()?; + if p.external != 0 { + p.external_fs = self.file_identifier()?; + } + p.sons = self.uints("son occurrences")?; + p.suppressed = self.r.character()? & SUPPRESSED != 0; + // ProductInformation [WD 7.3.10.3]: unit flag, unit, load status, information. + self.r.bit()?; + self.r.double()?; + self.r.character()?; + self.r.integer()?; + if self.r.bit()? { + p.location = Some(self.transformation()?); + } + let n = self.count(1, "entity references")?; + for _ in 0..n { + p.overrides.extend(self.entity_reference()?); + } + self.markup_data()?; + self.views()?; + if self.r.bit()? { + self.filter()?; + } + let n = self.count(1, "display filters")?; + for _ in 0..n { + self.filter()?; + } + let n = self.count(1, "scene display parameters")?; + for _ in 0..n { + self.scene_display_parameters()?; + } + self.schema.skip_added_fields(PRODUCT_OCCURRENCE, &mut self.r)?; + self.user_data()?; + Ok(p) + } + + /// `FileStructureTree` (304) [WD 7.3.6], with its `FileStructure` (302) inside. + pub(super) fn file_structure_tree(&mut self) -> Result { + self.expect_type(FILE_STRUCTURE_TREE)?; + self.content_prc_base()?; + let n = self.count(1, "part definitions")?; + let mut tree = Tree::default(); + for _ in 0..n { + let (items, name) = self.part_definition()?; + tree.parts.push(items); + tree.part_names.push(name); + } + let n = self.count(1, "product occurrences")?; + for _ in 0..n { + tree.products.push(self.product_occurrence()?); + } + self.expect_type(FILE_STRUCTURE)?; + self.content_prc_base()?; + self.r.unsigned_integer()?; // next_available_index + self.r.unsigned_integer()?; // product_occurrence_index + 1 (the structure's root) + self.schema.skip_added_fields(FILE_STRUCTURE, &mut self.r)?; + self.schema.skip_added_fields(FILE_STRUCTURE_TREE, &mut self.r)?; + self.user_data()?; + Ok(tree) + } + + /// `ModelFile` (301) after its schema [WD 7.3.3; pdf-issues #724, #740]: `units_in_mm` is + /// always present. + pub(super) fn model_file(&mut self) -> Result { + self.expect_type(MODEL_FILE)?; + self.content_prc_base()?; + self.r.bit()?; // units_from_cad_file + self.r.double()?; // units_in_mm + let n = self.count(6, "root occurrences")?; + let mut roots = Vec::with_capacity(n); + for _ in 0..n { + let id = self.unique_id()?; + let index = self.r.unsigned_integer()?; + self.r.bit()?; // is_active + roots.push((id, index)); + } + Ok(ModelFile { roots }) + } +} diff --git a/crates/prc/src/tree/style.rs b/crates/prc/src/tree/style.rs new file mode 100644 index 0000000000..d313fb2f64 --- /dev/null +++ b/crates/prc/src/tree/style.rs @@ -0,0 +1,348 @@ +//! Graphics, styles, materials and the globals that hold them [WD 7.2.4, 7.3.5, 7.5]. + +use super::{ + FATHER_HERIT_COLOR, FILE_STRUCTURE_GLOBALS, LINE_PATTERN, MATERIAL, Matrix, PICTURE, RI_COORDINATE_SYSTEM, SON_HERIT_COLOR, STYLE, + TEXTURE_ALPHA_TEST, TEXTURE_APPLICATION, TEXTURE_DEFINITION, TEXTURE_FUNCTION_BLEND, TEXTURE_MAPPING_OPERATOR, TEXTURE_TRANSFORMATION, Vec3, +}; +use crate::PrcError; +use crate::container::offset; +use crate::entity::{Ctx, malformed}; + +/// One entity's `GraphicsContent` [WD 7.2.4]: `style` is `line_style_index + 1` (0 none), +/// `bits` the behaviour bits. `fs` is the file structure whose globals `style` indexes: the +/// one whose tree holds the entity. The walk sets it; a reader leaves it 0. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct Graphics { + pub(crate) style: u32, + pub(crate) bits: u16, + pub(crate) fs: usize, +} + +/// A straight (not premultiplied) colour, each channel 0-1; `a` is the opacity. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[non_exhaustive] +pub struct Rgba { + /// Red. + pub r: f64, + /// Green. + pub g: f64, + /// Blue. + pub b: f64, + /// Opacity: 0 transparent, 1 opaque. + pub a: f64, +} + +/// How a style's `transparency` combines with its material's diffuse alpha. ISO 14739-1 +/// defines both and not their combination, and gives a diffuse alpha of 0.0 no "unset" +/// meaning. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[non_exhaustive] +pub enum StyleAlpha { + /// As [`Self::StyleWins`], except that a material diffuse alpha of exactly 0.0 under a + /// style with no transparency reads as unset, so opaque. Producers write 0.0 on the + /// materials of models meant to be seen; read literally, every part is invisible. + #[default] + ZeroUnset, + /// The style's transparency, when it has one, is the opacity; the material alpha, read + /// literally (0.0 invisible), applies only to a style without one. + StyleWins, + /// The opacity is material alpha × style transparency. + Multiply, +} + +/// A style's resolved colour, and whether its opacity comes from reading a zero material +/// alpha as unset. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Paint { + pub(crate) rgba: Rgba, + pub(crate) alpha_unset: bool, +} + +/// A `Style` (701) as far as colour: `index` is `colour_or_material + 1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Style { + pub(crate) is_material: bool, + pub(crate) index: u32, + pub(crate) transparency: Option, +} + +/// A `materials` entry as far as colour. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Material { + /// `Material` (702): `diffuse + 1` (a colour index) and the diffuse alpha. + Plain { diffuse: u32, alpha: f64 }, + /// `TextureApplication` (711) [WD 7.5.6]: `material_generic_index + 1`, the material + /// under the texture. + Textured { base: u32 }, +} + +/// What `FileStructureGlobals` contributes to placing and colouring. +#[derive(Debug, Clone, PartialEq, Default)] +pub(crate) struct Globals { + /// Entry `i` places an item with biased local coordinate system `i + 1`. + pub(crate) systems: Vec, + /// RGB, `a` 1. + pub(crate) colours: Vec, + pub(crate) materials: Vec, + pub(crate) styles: Vec