diff --git a/.cargo/config.toml b/.cargo/config.toml
index 5592118f6c..a1caaeaa38 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -1,2 +1,7 @@
[alias]
xtask = "run -p xtask --"
+
+# WebAssembly SIMD (every current browser has it; there's no runtime detection on wasm). The OCR
+# engine's kernels (rten-simd) and other vectorised code only use it when compiled in.
+[target.wasm32-unknown-unknown]
+rustflags = ["-C", "target-feature=+simd128"]
diff --git a/.github/workflows/house-rules.yml b/.github/workflows/house-rules.yml
new file mode 100644
index 0000000000..de64ad3ee4
--- /dev/null
+++ b/.github/workflows/house-rules.yml
@@ -0,0 +1,28 @@
+name: House rules
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - AGENTS.md
+ - CLAUDE.md
+ - .github/workflows/house-rules.yml
+ pull_request:
+ paths:
+ - AGENTS.md
+ - CLAUDE.md
+ - .github/workflows/house-rules.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: house-rules-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ house-rules:
+ uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
+ with:
+ rust: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9403a1e044..4b44d79b68 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,14 @@ concurrency:
cancel-in-progress: false
jobs:
+ # Regenerates contributors/contributors.json for the release commit; every build bundles it.
+ credits:
+ permissions:
+ contents: read
+ pull-requests: read
+ uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
+ needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
@@ -27,6 +34,7 @@ jobs:
app-title: PdfCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
+ contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
diff --git a/AGENTS.md b/AGENTS.md
index 3ab900aa95..6126d4887f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,60 @@
+
+## House rules
+
+These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
+
+1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
+2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
+3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
+4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
+5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
+6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
+7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
+8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
+9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
+10. **Keep secrets and personal details out** of the repository, logs and requests.
+11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
+12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
+13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
+14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
+15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
+
+
+## Rust Craft repositories
+
+These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
+
+1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
+2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
+3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
+4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
+5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
+6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
+
+## Licence allowlist
+
+Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
+
+- `ISC`
+- `MIT`
+- `BSD-2-Clause`
+- `BSD-3-Clause`
+- `Apache-2.0`
+- `Unicode-3.0`
+- `OFL-1.1`
+- `Ubuntu-font-1.0`
+- `Bitstream-Vera`
+- `CC0-1.0`
+- `CC-BY-4.0`
+- `CC-BY-SA-4.0`
+- `LicenseRef-Public-Domain`
+
+`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
+
+
# AGENTS.md — rules for every agent and contributor
-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.**
+These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. Day-to-day working instructions are in this file; `CLAUDE.md` imports it.
---
@@ -30,7 +84,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
### 1.2 Only open, provable licences
Every asset must be one of:
-- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0 or Bitstream-Vera.
+- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0, Bitstream-Vera or Unicode-3.0 (Unicode, Inc. data such as ICU4X's line-breaking dictionaries; already allowed for code in `deny.toml`).
- **Creative Commons:** CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0.
- **Public domain.**
- **Contributor-original:** created, photographed or generated by a contributor who owns it and licenses it under the project licence (`MIT OR Apache-2.0`).
@@ -85,7 +139,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
---
-## 2. Clean-room rules (summary; details in `CLAUDE.md` and `plan/adr/0001`)
+## 2. Clean-room rules (summary; `plan/adr/0001` is an optional local note)
- Never read, disassemble or copy anything inside the Acrobat bundle. Only names and listings are allowed, and **never** open `Contents/Resources/JavaScripts/`.
- Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation.
@@ -96,7 +150,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
## 3. Agent control (automation and MCP)
- **Everything is reachable headlessly.** Every user-facing feature must be callable without the GUI: an engine API, then a tool in `crates/automation` (`pdfcraft-cli run` and MCP use the same table). A feature is not done until its tool exists and has an end-to-end test (`crates/automation/README.md`, "Adding a tool").
-- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control `. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file. Never enable it by default, widen the bind address, or log the token.
+- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control `. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file (mode 0600 on Unix; on Windows an access list of the user and SYSTEM only, which `pdfcraft_platform::owner_only` sets and checks, and which the CLI checks again before reading the token). Never enable it by default, widen the bind address, or log the token.
- **The MCP server is opt-in.** Never make the app, the CLI or an installer start it, register it with an agent, or listen on a port by default. It runs only when a user (or an agent the user configured) launches `pdfcraft-cli mcp`. Any future network transport must be off by default, bound to loopback and authenticated with a token.
- **Safe by default.** Tools must never discard unsaved work silently, must write atomically, must honour `--root`, and must return errors an agent can act on (say what was wrong and what is allowed).
- **Track parity honestly.** Every user-facing feature has an entry in `parity/acrobat-features.toml`. Mark it `shipped` only when it works end to end and the entry cites a passing test (`evidence`); `cargo xtask parity` (part of `xtask ci`) rejects claims whose commands, tools or tests don't exist.
@@ -106,7 +160,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
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`).
+- **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 below).
- **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.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. If a platform API needs it, use a maintained safe wrapper crate; a real exception needs the owner's sign-off.
- **Treat every input-derived number as hostile.** Use `get()` instead of `[i]` or `[a..b]`. Slice strings only at char boundaries. Use checked/saturating arithmetic for lengths, offsets and counts. Guard against division by zero and NaN/inf casts, and never cast a negative value to `usize`. Cap allocations sized by the file.
@@ -115,10 +169,18 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf
- **Prove it.** Every crash fix comes with a small synthetic regression test that panicked before the fix. Turn fuzzer findings (`cargo xtask fuzz`) into such tests before fixing them.
- **Enforced by clippy.** Root `clippy.toml` lets tests use `unwrap`/`expect`/`panic!`. A crate that is clean carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it.
-## 5. Everything else
+## 5. Page interaction, fidelity and gates
-
-Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to run and look at the app.
+- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd. `cos`, `filters`, `crypt` and `arlington` stay standalone.
+- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `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`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
+- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `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.
+- **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
+- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p --all-targets -- -D warnings` and `cargo test -p ` to the crates you touched; the workspace sweep is CI's job.
+- **Review at four zooms** as you go: the clause, the function, the module, and the project (layering, wasm, undo, save fidelity, a matching automation tool, an honest `parity/` entry). Prove a test fails before the fix by reverting the fix once.
+- `plan/` is an optional gitignored notebook. Do not ask for it and do not stop when it is absent. Public status is [`ROADMAP.md`](ROADMAP.md), [`docs/gaps.md`](docs/gaps.md) and [`docs/roadmap.md`](docs/roadmap.md). "Shipped" in `parity/` means exists and tested, not parity with Acrobat.
+- `cargo run -p pdfcraft -- ` opens the app. `pdfcraft-cli run` drives the engine headlessly. `cargo xtask fuzz` findings stay in `fuzz-out/` and become synthetic tests. For UI work, look at a screenshot from `--control` or the `shot` example.
## Contributor credits (About window)
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 38c28a80ec..cfc16f78f4 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -45,7 +45,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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-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; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `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 |
@@ -174,7 +174,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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/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; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `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 |
@@ -184,7 +184,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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-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; embedded into displayed signature status captions beyond WinAnsi by crates/sign/src/status_layers.rs |
| `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 |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code |
@@ -249,7 +249,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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 |
+| `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; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `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 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 |
@@ -267,7 +267,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/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)
+## Compiled in through dependencies (7)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
@@ -277,6 +277,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
+| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one |
## Downloaded at build time (22)
@@ -314,4 +315,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui
| 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 |
+| `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); status captions of named-layer signatures in Arabic, Japanese, Thai and Devanagari, drawn as filled outlines into the display copy by crates/sign/src/status_layers.rs; 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 99073babb1..be935422ce 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -425,7 +425,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
-usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "201e790f3a05c65b2c4b0f952c9b9e1f35cd1c5b21698b3f4be131883fe90bc8"
[[asset]]
@@ -1844,7 +1844,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
-usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "55aa37e0b73e7f8cd906952dbdeb96283c6595ca64aba240a17f3455cb989114"
[[asset]]
@@ -1954,7 +1954,7 @@ source = "https://github.com/rsms/inter"
licence = "OFL-1.1"
licence_file = "assets/fonts/OFL-Inter.txt"
kind = "font"
-usage = "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"
+usage = "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; embedded into displayed signature status captions beyond WinAnsi by crates/sign/src/status_layers.rs"
sha256 = "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82"
[[asset]]
@@ -2263,6 +2263,18 @@ adobe_data = true
usage = "Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1"
sha256 = "2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41"
+[[bundled]]
+crate = "icu_segmenter_data"
+version = "2.3.0"
+path = "data/segmenter_dictionary_extended_v1.rs.data"
+title = "Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese)"
+author = "The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.)"
+source = "https://github.com/unicode-org/icu4x (via icu_segmenter_data)"
+licence = "Unicode-3.0"
+kind = "data"
+usage = "Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one"
+sha256 = "cad3821ca44fdb88ed856ff3336444aa200afe7b9d19e34ac28ae6a4459d4c6d"
+
[[fetched]]
file = "PlayfairDisplay[wght].ttf"
url = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf"
@@ -3004,7 +3016,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
-usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
+usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "ef1fec17d88794ba6c34c5cf63bdadbd234b3d4dffbcc3d55468b47e3f398472"
[[asset]]
@@ -3142,7 +3154,7 @@ source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/
licence = "OFL-1.1"
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
kind = "font"
-usage = "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"
+usage = "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); status captions of named-layer signatures in Arabic, Japanese, Thai and Devanagari, drawn as filled outlines into the display copy by crates/sign/src/status_layers.rs; 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"
[[asset]]
path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
@@ -3232,4 +3244,3 @@ licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
-
diff --git a/CLAUDE.md b/CLAUDE.md
index 5c7034b7a4..43c994c2d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,65 +1 @@
-# PdfCraft — instructions for agents
-
-PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Adobe Acrobat Pro parity. It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. It is the sibling of `../photocraft` (a Photoshop-class editor) and follows the same conventions.
-
-## 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 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.
-
-`plan/` is gitignored (local-only, like PhotoCraft). The machine-readable parity checklist lives in `parity/` (committed).
-
-## 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). 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/`).
- - Never copy GPL/AGPL code. MuPDF, Ghostscript, Poppler, veraPDF and DSS run only as external oracle processes.
- - See `plan/README.md` §Clean-room and `plan/adr/0001`.
-- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
-- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
-- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
-- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
-- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the 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.
- - No indexing or slicing with input-derived positions (use `get`, and slice strings only at char boundaries).
- - Use checked or saturating arithmetic on input-derived numbers, and guard against division by zero and NaN/inf casts.
- - 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.
-- **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.
- - Tests mirror the zooms: property → unit → crate integration → end-to-end.
- - **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
- - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch.
-- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
-- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
- - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
-- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
-- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
- - **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
-
-## Running and looking at the app
-- `cargo run -p pdfcraft -- ` opens the desktop app.
-- `cargo run -p pdfcraft-cli -- run --script steps.json --root DIR` drives the engine headlessly through the automation tools (`pdfcraft-cli tools` lists them). Use it, together with `page_render`, to check engine changes. `pdfcraft-cli mcp` is the opt-in MCP server (AGENTS.md §3).
-- `cargo xtask fuzz --time 300` mutation-fuzzes open/render/edit/save in child processes. Findings land in `fuzz-out/findings/` (git-ignored; never commit corpus-derived files). Turn every real finding into a small synthetic regression test before fixing it.
-- `cargo xtask parity [--partial]` reports Acrobat-parity progress from `parity/acrobat-features.toml`. Update the entry when a feature ships.
-- `cargo xtask demo-pdf` builds `dist/demo/pdfcraft-showcase.pdf` (needs Chrome) for visual checks.
-- For UI work, **look at the result**. Either launch `pdfcraft --control FILE doc.pdf` and use `pdfcraft-cli ui --control FILE screenshot --out x.png` (plus `inspect`, `click`, `key`, `type`, `command`, `set`), or take a headless shot with `cargo run -p pdfcraft-ui-egui --example shot`. Compare against `plan/acrobat/02-ui-ux.md`. Control-channel tests use kittest (`crates/ui-egui/tests/control.rs`).
-- Parallel agents: use a separate `CARGO_TARGET_DIR` per agent and separate git worktrees.
-
-## Current bootstrap debt (tracked in STATUS.md)
-- `pdfcraft-render` renders through the `hayro` crate directly and inspects documents through `lopdf`. Both get replaced by `cos` / `model` / the DisplayList device (M1–M2, ADR-0004).
-
-## CI workflows
-- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
-- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
-- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+@AGENTS.md
diff --git a/Cargo.lock b/Cargo.lock
index e1f271ed12..fafd037d33 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1243,7 +1243,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
+source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17"
dependencies = [
"craft-winprint-gdi",
"log",
@@ -1289,7 +1289,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
+source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -1312,10 +1312,21 @@ dependencies = [
"zip",
]
+[[package]]
+name = "craft-web-api"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
+dependencies = [
+ "js-sys",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+]
+
[[package]]
name = "craft-web-host"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
+source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -1324,7 +1335,7 @@ dependencies = [
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
-source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
+source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f"
dependencies = [
"windows-sys 0.61.2",
]
@@ -3122,6 +3133,28 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "icu_segmenter"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
+dependencies = [
+ "icu_collections",
+ "icu_locale_fallback",
+ "icu_provider",
+ "icu_segmenter_data",
+ "potential_utf",
+ "smallvec",
+ "utf8_iter",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_segmenter_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
+
[[package]]
name = "idna"
version = "1.1.0"
@@ -4235,6 +4268,7 @@ dependencies = [
"fmv-macos-events",
"log",
"pdfcraft-engine",
+ "pdfcraft-platform",
"pdfcraft-ui-egui",
"pollster 1.0.1",
"raw-window-handle",
@@ -4254,7 +4288,6 @@ version = "0.18.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
- "pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
@@ -4267,6 +4300,7 @@ dependencies = [
"hayro-syntax",
"pdfcraft-cos",
"pdfcraft-fonts",
+ "pdfcraft-geom",
"pdfcraft-model",
"thiserror 2.0.21",
]
@@ -4389,6 +4423,7 @@ name = "pdfcraft-edit"
version = "0.18.1"
dependencies = [
"hayro-syntax",
+ "icu_segmenter",
"kurbo 0.13.1",
"pdfcraft-annot",
"pdfcraft-content",
@@ -4529,10 +4564,22 @@ dependencies = [
"pdfcraft-fonts",
"png 0.18.1",
"rten",
+ "serde_json",
+ "sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
]
+[[package]]
+name = "pdfcraft-ocr-worker"
+version = "0.18.1"
+dependencies = [
+ "js-sys",
+ "pdfcraft-ocr",
+ "wasm-bindgen",
+ "web-sys",
+]
+
[[package]]
name = "pdfcraft-optimize"
version = "0.18.1"
@@ -4546,6 +4593,7 @@ dependencies = [
"pdfcraft-render",
"proptest",
"thiserror 2.0.21",
+ "thread-priority",
"zune-jpeg",
]
@@ -4627,11 +4675,16 @@ dependencies = [
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-forms",
+ "pdfcraft-geom",
"pdfcraft-model",
+ "pdfcraft-render",
"pdfcraft-testkit",
"pdfcraft-xfa",
+ "regex",
+ "roxmltree 0.21.2",
"sha2 0.11.1",
"thiserror 2.0.21",
+ "unicode-normalization",
]
[[package]]
@@ -4646,11 +4699,16 @@ dependencies = [
"kurbo 0.13.1",
"log",
"lopdf",
+ "pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
+ "pdfcraft-geom",
+ "pdfcraft-testkit",
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
+ "unicode-bidi",
+ "url",
]
[[package]]
@@ -4675,9 +4733,11 @@ dependencies = [
"p521",
"pbkdf2",
"pdfcraft-annot",
+ "pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
+ "pdfcraft-testkit",
"rc2",
"ripemd",
"rsa",
@@ -4712,6 +4772,7 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
+ "flate2",
"fontconfig-parser",
"getrandom 0.4.3",
"image",
@@ -4753,11 +4814,13 @@ dependencies = [
name = "pdfcraft-web"
version = "0.18.1"
dependencies = [
+ "craft-web-api",
"craft-web-host",
"eframe",
"egui",
"js-sys",
"pdfcraft-ui-egui",
+ "serde_json",
"wasm-bindgen-futures",
"web-sys",
]
@@ -4780,10 +4843,12 @@ dependencies = [
name = "pdfcraft-xfdf"
version = "0.18.1"
dependencies = [
+ "base64 0.23.1",
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
+ "proptest",
"roxmltree 0.21.2",
"thiserror 2.0.21",
]
@@ -5506,6 +5571,29 @@ dependencies = [
"bitflags 2.13.2",
]
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -6878,6 +6966,20 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "thread-priority"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "libc",
+ "log",
+ "rustversion",
+ "windows",
+]
+
[[package]]
name = "tiff"
version = "0.11.3"
@@ -8208,7 +8310,7 @@ dependencies = [
[[package]]
name = "winprint"
version = "0.2.1"
-source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
+source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4"
dependencies = [
"flate2",
"fmt-derive",
@@ -8670,7 +8772,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
- "zopfli",
]
[[package]]
@@ -8685,18 +8786,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-[[package]]
-name = "zopfli"
-version = "0.8.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
-dependencies = [
- "bumpalo",
- "crc32fast",
- "log",
- "simd-adler32",
-]
-
[[package]]
name = "zune-core"
version = "0.5.3"
diff --git a/Cargo.toml b/Cargo.toml
index bf6a4e3146..b0f3411624 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,11 +24,11 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
# 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 = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
+craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" }
# 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 = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
-craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] }
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.18.1" }
pdfcraft-filters = { path = "crates/filters", version = "0.18.1" }
@@ -70,7 +70,11 @@ pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
hayro = "0.7.1"
hayro-syntax = "0.7.2"
lopdf = "0.45"
-flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
+# zlib-rs: miniz_oxide took about 1.7 µs for every fixed-Huffman block, empty or not
+# (`pdfcraft_filters::FLATE_READ_WORK`). Other dependencies still build flate2's default
+# miniz_oxide backend; flate2 prefers zlib-rs when both are built, as the Windows build already did
+# through craft-update. `flate::tests::a_copy_from_before_the_start_is_damage` shows which runs.
+flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] }
weezl = "0.2"
# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
fax = "0.2.7"
@@ -80,6 +84,9 @@ hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
+# Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives
+# the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it).
+regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
diff --git a/LICENSES.toml b/LICENSES.toml
index 0617ca5d57..ac54d9f702 100644
--- a/LICENSES.toml
+++ b/LICENSES.toml
@@ -16,7 +16,7 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
[[document]]
source = "NOTICE"
destination = "NOTICE"
-sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314"
+sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb"
[[document]]
source = "assets/app-icon/LICENSE-document.txt"
@@ -60,6 +60,13 @@ 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/ICU4X-LICENSE.txt"
+licences = ["Unicode-3.0"]
+destination = "licenses/ICU4X-LICENSE.txt"
+sha256 = "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2"
+origin = "https://static.crates.io/crates/icu_segmenter_data/icu_segmenter_data-2.3.0.crate#LICENSE"
+
[[document]]
source = "licenses/egui-Hack-Regular.txt"
licences = ["MIT", "Bitstream-Vera"]
@@ -164,3 +171,9 @@ 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"]
+
+[[bundled]]
+crate = "icu_segmenter_data"
+path = "data/segmenter_dictionary_extended_v1.rs.data"
+licence = "Unicode-3.0"
+documents = ["licenses/ICU4X-LICENSE.txt"]
diff --git a/NOTICE b/NOTICE
index e8f31ade2f..eed2d654b0 100644
--- a/NOTICE
+++ b/NOTICE
@@ -36,6 +36,9 @@ list (author, source, license) is in ATTRIBUTION.md.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
+- ICU4X line-breaking data (icu_segmenter_data, via crates.io) — Unicode-3.0. Its Thai, Lao, Khmer
+ and Burmese word dictionaries are compiled in for wrapping Thai text; notice below, full licence in
+ licenses/ICU4X-LICENSE.txt.
Required notices:
@@ -48,6 +51,32 @@ Required notices:
(https://github.com/adobe-type-tools/cmap-resources/blob/master/LICENSE.txt). Neither the name of
Adobe nor the names of its contributors are used to endorse or promote this product.
+ ICU4X segmenter data (bundled by icu_segmenter_data 2.3.0; word lists, not visual assets):
+ Copyright © 2020-2024 Unicode, Inc. Portions adapted from ICU4C and/or ICU4J:
+ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy of data files and any
+ associated documentation (the "Data Files") or software and any associated documentation (the
+ "Software") to deal in the Data Files or Software without restriction, including without
+ limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the
+ Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to
+ do so, provided that either (a) this copyright and permission notice appear with all copies of
+ the Data Files or Software, or (b) this copyright and permission notice appear in associated
+ Documentation.
+
+ THE DATA FILES AND SOFTWARE ARE 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 OF THIRD PARTY RIGHTS.
+
+ IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM,
+ OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF
+ USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE.
+
+ Except as contained in this notice, the name of a copyright holder shall not be used in
+ advertising or otherwise to promote the sale, use or other dealings in these Data Files or
+ Software without prior written authorization of the copyright holder.
+
Showcase PDF (built by `cargo xtask demo-pdf`, not committed): fonts are downloaded at build time from
google/fonts at a pinned commit, verified by SHA-256, all under the SIL Open Font License 1.1 — see the
"Downloaded at build time" table in ATTRIBUTION.md.
diff --git a/README.md b/README.md
index f488d160cc..4b534f6e17 100644
--- a/README.md
+++ b/README.md
@@ -331,7 +331,7 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append
Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read.
-Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file.
+Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, or one that another user owns, or that another user or a group such as Administrators can read or write: on macOS, Linux and FreeBSD by its owner and mode (the app writes it with mode 600), on Windows by its owner and access list (the app gives it one with only you and SYSTEM on it). The app doesn't start if it can't write the file. On Windows, cmd.exe doesn't expand `~`: use `%USERPROFILE%\.pdfcraft-control.json` there.
```sh
pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets
@@ -472,11 +472,14 @@ Installers and executables are code-signed.
The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings,
logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
`%APPDATA%`; delete that file to use the normal per-user folders.
+Windows doesn't offer the portable copy as a default PDF app on its own, since it registers nothing;
+`portable.txt` has the PowerShell lines that register it for your user only, and how to undo them.
The MSI installs for your account only by default, without administrator privileges. Choose
"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install
for all users. It adds "Edit with PdfCraft"
-to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
+and "Combine files in PdfCraft" (select several PDFs: they land in the Combine files tab of the
+running PdfCraft) to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under
"Show more options"), without changing which app opens them by default. For unattended deployment
without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install):
@@ -488,7 +491,7 @@ msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU
| Property | `=0` leaves out |
|---|---|
| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut |
-| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
+| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" and "Combine files in PdfCraft" on PDF files |
| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images |
All three are on for a first install. An upgrade keeps what the installed version has: a shortcut
@@ -499,6 +502,12 @@ didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier fo
adds it, as a first install would. Use the MSI for your architecture. Per-user installation
overrides are not supported.
+To install somewhere other than the default `ArtCraft\PdfCraft` folder, change the folder on the
+welcome screen (type it, or Browse…) or pass it on the command line:
+`INSTALLFOLDER="D:\Apps\PdfCraft"`. It is installed for whoever the first screen chose (`ALLUSERS=1`
+on the command line for everyone). An upgrade keeps the folder the installed version uses unless
+the command line gives another one.
+
**Pin the checksum, not just the version.** Release candidates of a version share its MSI version
(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an
MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't
diff --git a/ROADMAP.md b/ROADMAP.md
index db0e495b38..682da0133c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,8 +1,16 @@
# PdfCraft roadmap
+> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
+
+> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
+
+> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged)
+
> **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-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; 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)
@@ -191,27 +199,28 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
## Languages
-Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
-Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
+Twelve key languages: the share of the 2,897 strings in the i18n report's denominator (the interface's strings plus every catalog key), from
+`cargo xtask i18n-report` on 2026-11-03. Detail and script notes: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
-| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
-| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
-| Hindi | `hi` | 0% | none |
-| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
-| French | `fr` | 99.3% | partial |
-| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
-| Indonesian | `id` | 0% | none |
-| Japanese | `ja` | 99.1% | partial |
-| German | `de` | 99.3% | partial |
-| Korean | `ko` | 0% | none |
-| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
+| Simplified Chinese | `zh-hans` | 88.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
+| Spanish | `es` | 98.4% | partial (dialogs / tooltips; no help) |
+| Hindi | `hi` | 97.5% | partial (Devanagari rendering unqualified) |
+| Arabic | `ar` | 97.9% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
+| French | `fr` | 98.4% | partial |
+| Portuguese (Brazil) | `pt-br` | 98.4% | partial |
+| Indonesian | `id` | 97.5% | partial |
+| Japanese | `ja` | 89.6% | partial |
+| German | `de` | 89.7% | partial |
+| Korean | `ko` | 97.5% | partial (no Hangul UI face) |
+| Vietnamese | `vi` | 98.4% | partial (dialogs / tooltips; no help) |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
-Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
-(24 interface languages including English).
+Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Finnish, Albanian, Catalan, Romanian,
+Norwegian Bokmål, Persian, Central Kurdish, Thai, Lao and Czech: **23 more catalogs**
+(35 interface languages including English).
## Upcoming
@@ -230,20 +239,33 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
+- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft.
+
+- **2026-11-07 (M2, #6449):** A non-embedded Times New Roman set as a TrueType CID font (`TimesNewRoman,Bold`, `TimesNewRomanPSMT` and the other faces) draws the glyphs its codes name, as the non-embedded Arial does: its first 258 glyphs follow the same standard Macintosh order, which the embedded Times New Roman subsets of a public French form confirm in 941 of 942 `/ToUnicode` entries (vendored hayro-interpret patch 88). That form drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" and "changement" as "ej angement". Times and Times New Roman MT Std are not read this way.
+
+- **2026-11-05 (M2, #5889):** A non-embedded Arial set as a TrueType CID font (`CIDFontType2`, `Identity-H`, as Word and PDFMaker write it) draws the glyphs its codes name. Those codes are glyph indexes in the author's Arial, whose first 258 glyphs follow the standard Macintosh order, so the standard substitute now draws each glyph by name (vendored hayro-interpret patch 76). Before, it took each code as one of its own glyph indexes, and "Low Power" drew as "@ck 'Dck Yf". Arial Narrow, Black and Unicode MS, and `CIDFontType0` fonts, are not read this way. Copying or searching those runs still needs a `/ToUnicode`.
+
+- **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged.
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
+- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged.
+
- **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 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. 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-03 (M14 docs, #5805):** `docs/localization-parity.md` and the Languages table here count every catalog with `cargo xtask i18n-report`: the share of the 2,897 strings in its denominator, on `14ae839a`. The catalog-union measure it replaces had grown to 2,895 keys and left four catalogs at "100%" that were 44 keys short. Hindi, Indonesian and Korean are listed with their catalogs (97.5% each) instead of 0, and Thai, Lao and Central Kurdish join the table. The ≈ 68% localization score is not re-scored.
- **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-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view.
+- **2026-11-02 (M3, #5716):** A snapshot is drawn at 300 ppi, or at the zoom if that is finer, so one taken zoomed out stays readable; an area too large for one 4096-pixel tile comes out as fine as a tile allows (about 147 ppi for 2000 pt). A choice of resolution and a selection that stays active remain.
- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
- **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.
@@ -257,6 +279,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- 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-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged.
- **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.
@@ -267,6 +290,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **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.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker.
- **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.
@@ -279,6 +303,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
+- **2026-10-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed.
+- **2026-10-24 (M10, `ocr.ocr-web`, review fix):** On the web, single-file and multiple-file OCR running at once no longer lose a page: the workers' answers go to the run that sent each page, instead of to whichever run collected first, and a cancelled run's late answers are let go. Regression tests cover both start orders and cancellation. Files chosen for Recognize Text in Multiple Files before the models have downloaded now wait for them instead of having to be chosen again, and idle workers close on time even when nothing is happening on the page, a full minute after their last page comes back rather than after it was handed out (checked by hand in Firefox); estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
@@ -289,6 +315,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next.
- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next.
- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script.
+- **2026-10-23 (M4, #3923):** A recovered document's original path is only a suggestion: the first Save after recovering opens Save As at that path instead of writing it, since anyone who can write the recovery folder (a shared portable `PdfCraftData`, for example) can name any file in a sidecar. The file chosen is the document's file from then on. Sidecars are read through a 64 KiB cap; a longer one is not read, and its snapshot recovers without metadata. Estimates unchanged.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
@@ -317,6 +344,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **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.
+- **2026-10-20 (M9 #79 review, M2, M8):** text extraction reads a marked-content /ActualText that marks no glyph where it paints (paths and images, cut to what is visible, along the direction they are drawn), so words drawn as outlines can be found, copied and exported; OCR and scan field detection still count such a page as having no text of its own. Redaction strips /ActualText, /Alt, /E and /T from inline marked content around paint it clips, as it already did around paint it removed, so a partly redacted outlined word leaves no name in the file. Named-layer recognition also needs the layer form to draw n1 and n3. Estimates unchanged.
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
@@ -359,6 +387,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged.
+- **2026-10-20 (M8, #3647):** Apply prunes fully removed tiling patterns, then checks retained pattern resources for text, images and unreadable content before reporting success. Shared patterns, Form-owned and inherited patterns, and cells kept by another page cause an atomic refusal when safe removal cannot be shown. Synthetic saved-file and automation regressions cover refusal, preserved marks and undo, and vector-only controls. Estimates unchanged.
+
- **2026-10-20 (M1, PR #1143 review):** Repair-aware dictionary/preflight edits and snapshot restoration preserve both accepted generations of a damaged live slot. Such edits use the existing full-save fallback; classic and packed rewrites coalesce aliases so page-tree parent links retain one object identity. Explicit generation replacement remains single-generation. Materialized xref diagnostics inspect retained primary and hybrid rows before folding, retaining the 256-object cap plus summary and discarding abandoned-index notes. Synthetic red/green controls cover preflight, undo restoration, equal values, explicit narrowing, encryption packing and four save variants. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
@@ -405,6 +435,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
+- **2026-10-16 (M9, #79):** a signature appearance that follows the named-layer convention (its /N draws one Form that holds and draws n1 and n3) shows the signature's real state: n1, n3 and n4 are redrawn in the display copy with the panel's icons and a caption in the interface language (Helvetica; else shaped in embedded Inter, or in a craft-fonts face for Arabic, Japanese and Telugu; else English, as Chinese always is). Exports, both print modes and the CLI's render show the redrawn state; saves never do. Every signature keeps its badge, since a document can clip or cover its own layers. Estimates unchanged.
+- **2026-10-16 (M9):** the page validation overlay moves to the widget's bottom-right corner as it lies on the page (so it turns with the page and the view) and takes a quarter of the widget's shorter side, at most 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field too small for a legible mark keeps its outline, a rectangle the screen cannot place is not drawn at all, and the whole widget answers the pointer. Estimates unchanged.
- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
@@ -413,6 +445,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
+- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
@@ -423,6 +456,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
+- **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged.
+- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged.
+- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
@@ -493,6 +529,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
+- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it.
+- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
+- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
@@ -553,6 +592,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
+- **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged.
+- **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native.
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
@@ -788,11 +829,15 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. |
+| 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. |
+| 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. |
| 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-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. |
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
| 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
@@ -807,6 +852,8 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
+| 2026-10-16 | minor | M9 #79: named-layer signature appearances show the signature's real state in the display copy, exports and both print modes, never in saves; captions follow the interface language. Estimates unchanged. |
+| 2026-10-16 | minor | M9: the page validation overlay moves to the widget's bottom-right corner as it lies on the page and stops growing past 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field under 32 screen points keeps its outline. Estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
@@ -856,6 +903,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
+- **2026-10-22 (M3, #3530, builds on PR #3547):** A find step keeps its match in the middle of the window after zooming: a zoom pending in the same frame no longer puts the page-top jump off to the next frame, and the rest of an eased ⌘/Ctrl-wheel zoom anchors on the match instead of the pointer. Synthetic UI regressions step through matches on pages larger and smaller than the window at 10–3200% and in every page display. Estimates unchanged.
- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
diff --git a/apps/pdfcraft-cli/build.rs b/apps/pdfcraft-cli/build.rs
index ee77f13f6a..9a6500b7f3 100644
--- a/apps/pdfcraft-cli/build.rs
+++ b/apps/pdfcraft-cli/build.rs
@@ -6,12 +6,25 @@
//! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
+/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
+/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
+/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
+/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
+/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
+const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
+
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
+ if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
+ for dll in DELAY_LOADED {
+ println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
+ }
+ println!("cargo:rustc-link-arg-bins=delayimp.lib");
+ }
let mut res = winresource::WindowsResource::new();
res.set("ProductName", "PdfCraft")
.set("FileDescription", "PdfCraft command-line tool")
diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs
index 62993ad6dc..4521181b6f 100644
--- a/apps/pdfcraft-cli/src/check.rs
+++ b/apps/pdfcraft-cli/src/check.rs
@@ -43,7 +43,18 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
let start = Instant::now();
- let bytes = read(path)?;
+ // A file that can't be read (past the input cap, say) is this file's record, not a crash of the child.
+ let bytes = match read(path) {
+ Ok(bytes) => bytes,
+ Err(e) => {
+ let status = if e.contains("larger than") { "input-too-large" } else { "read-error" };
+ let line = serde_json::json!({ "file": path, "status": status, "pages": 0, "failed_pages": 0, "warnings": 0,
+ "ms": start.elapsed().as_millis() as u64, "detail": e.chars().take(400).collect::() });
+ stdout_line(format_args!("{line}"))?;
+ // (the record is the answer `check` reads; the error and exit status are for a direct run)
+ return Err(e.into());
+ }
+ };
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
Ok(info) => {
@@ -65,7 +76,7 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
// `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
// so a panic in cos shows up as a crash of this process.
if args.iter().any(|a| a == "--edit") {
- edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
+ edit_round_trip(&read(path)?);
}
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() });
@@ -76,13 +87,19 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
fn edit_round_trip(bytes: &[u8]) {
- use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
+ use pdfcraft_cos::{DecodeBudget, Document, Object, SaveOptions, write_full, write_incremental};
let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
+ // One allowance for every stream, charged for what each decode did (failed ones too), so
+ // many small bombs can't each inflate to the per-stream limit.
+ let mut budget = DecodeBudget::page();
for num in doc.object_numbers().into_iter().take(20_000) {
+ if budget.exhausted() {
+ break;
+ }
if let Ok(o) = doc.try_get(num)
&& let Object::Stream(s) = &*o
{
- let _ = s.decoded();
+ let _ = budget.decode(s);
}
}
if let Some(root) = doc.root() {
@@ -113,7 +130,7 @@ fn collect(paths: &[&str]) -> Vec {
let identity = match p.canonicalize() {
Ok(identity) => identity,
Err(error) => {
- eprintln!("{}: could not resolve directory: {error}", p.display());
+ eprintln!("{}: could not resolve directory: {error}", p.display().to_string().escape_debug());
continue;
}
};
@@ -158,12 +175,16 @@ pub(crate) fn check(args: &[String]) -> Result<(), CliError> {
let status = r["status"].as_str().unwrap_or("?").to_string();
*counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
if status != "ok" {
+ // One record per line: a file name or detail with a line break or a terminal control in
+ // it is written escaped, so it can't forge another record (the JSON report keeps both).
+ let detail: String = r["detail"].as_str().unwrap_or("").chars().take(160).collect();
eprintln!(
- "[{}/{}] {status:<28} {} {}",
+ "[{}/{}] {:<28} {} {}",
n + 1,
files.len(),
- f.display(),
- r["detail"].as_str().unwrap_or("").chars().take(160).collect::()
+ status.escape_debug().to_string(),
+ f.display().to_string().escape_debug(),
+ detail.escape_debug()
);
}
results.push(r);
diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs
index 9d0b2057f0..cb3365a4fe 100644
--- a/apps/pdfcraft-cli/src/commands.rs
+++ b/apps/pdfcraft-cli/src/commands.rs
@@ -4,10 +4,24 @@ use super::options::{
};
use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line};
+/// Largest input file read into memory (512 MiB, as automation's `doc_open` reads).
+pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024;
+
+/// The file at `path`, read no further than [`MAX_INPUT`]: a name that leads to something
+/// endless (a device, a pipe nobody closes) fails at the cap instead of filling memory.
pub(crate) fn read(path: &str) -> Result>, String> {
- std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
+ use std::io::Read as _;
+ let mut bytes = Vec::new();
+ std::fs::File::open(path).and_then(|f| f.take(MAX_INPUT + 1).read_to_end(&mut bytes)).map_err(|e| format!("{path}: {e}"))?;
+ if bytes.len() as u64 > MAX_INPUT {
+ return Err(format!("{path}: the file is larger than 512 MiB"));
+ }
+ Ok(Arc::new(bytes))
}
+/// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the
+/// standards it declares, and the notes about damage repaired while opening it), with none of
+/// its scripts run.
pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
validate_options("info", args, INFO_OPTIONS)?;
let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
@@ -18,24 +32,33 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
let pdf_pages = info.pages.len();
let acroform_fields = info.fields.len();
+ // The document as the app opens it: the notes about damage repaired while opening, which the
+ // app shows in Document Properties, and for a dynamic XFA form the laid-out pages and fields.
+ // Inspection runs none of the document's scripts: an XFA form's initialize and calculate
+ // scripts, which the app runs on opening, are left alone, as with JavaScript turned off.
+ let mut session = pdfcraft_engine::Session::new();
+ session.set_javascript(false);
+ let opened = session.open("info.pdf", None, bytes.clone(), password).ok().and_then(|id| session.get(id));
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
- let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
- .then(|| {
- let mut s = pdfcraft_engine::Session::new();
- let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
- let d = s.get(id)?;
- d.xfa.as_ref().map(|x| {
- info = d.info.clone();
- // The in-memory layout is an appended revision, not the file being inspected.
- info.file_size = bytes.len();
- serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
- })
+ let xfa_layout = opened.filter(|_| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)).and_then(|d| {
+ d.xfa.as_ref().map(|x| {
+ info = d.info.clone();
+ // The in-memory layout is an appended revision, not the file being inspected.
+ info.file_size = bytes.len();
+ serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
})
- .flatten();
- // What the document declares as a standard, read the way the Standards panel reads it, so
- // `info` and the app agree. A file that declares nothing leaves these null and empty.
- let declared =
- pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
+ });
+ // What the document declares as a standard, as the Standards panel reads it, and the notes
+ // Document Properties shows, which include what the app repaired after reading the objects (a
+ // page tree rebuilt from the pages found by scanning), both from the session's own reading. A
+ // file the app cannot open is read once more for what the object reader declares and repaired.
+ // A file that declares nothing leaves the standards null and empty.
+ let (declared, repairs) = match opened {
+ Some(d) => (d.standards(), d.repair_log()),
+ None => pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
+ .map(|cos| (pdfcraft_preflight::declared(&cos), cos.repair_log().to_vec()))
+ .unwrap_or_default(),
+ };
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
@@ -56,6 +79,7 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
"custom_properties": info.custom.len(),
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(),
"warnings": info.warnings,
+ "repairs": repairs,
});
stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
Ok(())
@@ -366,13 +390,22 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
Some("pam") => None,
_ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
};
- // The app renders a display copy that draws comments missing their /AP streams; the CLI
- // must agree with it (and with independent viewers), while keeping the file it reads intact.
+ // The app renders a display copy that draws comments missing their /AP streams and the state
+ // of named-layer signatures (here with no trusted certificates); the CLI must agree with it
+ // (and with independent viewers), while keeping the file it reads intact.
let bytes = read(path)?;
let password = flag(args, "--password");
// The renderer takes only the user password of R2–R4 files; `Session::open` and the display
// copy take either.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
+ // A page image is an image taken out of the document, as a snapshot or Export is in the app:
+ // like `text`, it needs the document to allow copying (or the permissions password).
+ let mut session = pdfcraft_engine::Session::new();
+ if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
+ doc.check_copying().map_err(
+ |_| "render: the document's permissions don't allow copying its pages as images; pass the owner password with --password to render it",
+ )?;
+ }
let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
@@ -407,6 +440,18 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
let _ =
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
}
+ pdfcraft_render::RenderWarning::MalformedIndexedImage => {
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "warning: page {page}: an image with a malformed Indexed colour table may be drawn wrongly or not at all"
+ );
+ }
+ pdfcraft_render::RenderWarning::Type3WidthsMissing => {
+ let _ = writeln!(
+ std::io::stderr().lock(),
+ "warning: page {page}: a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give"
+ );
+ }
}
}
Ok(())
diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs
index dc419171b0..0ad18c154b 100644
--- a/apps/pdfcraft-cli/src/ui.rs
+++ b/apps/pdfcraft-cli/src/ui.rs
@@ -50,18 +50,23 @@ pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
/// Most a control file may hold. The app writes under 100 bytes.
const CONTROL_FILE_MAX: u64 = 64 * 1024;
+#[cfg(not(windows))]
const CONTROL_FILE_ADVICE: &str =
"Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
+// cmd.exe doesn't expand `~` (#3922).
+#[cfg(windows)]
+const CONTROL_FILE_ADVICE: &str =
+ r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.pdfcraft-control.json`";
/// Read the file written by `pdfcraft --control FILE`.
///
/// It names the port that receives the token and every command, typed text included, so it must
/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
/// first; the app then can't write it, and the CLI would talk to the other user's listener.
-/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
-/// else or open to other users (the app writes it with mode 600).
+/// Refused: a symbolic link, anything but a regular file, and a file owned by someone else or
+/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on
+/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922).
fn read_control_file(file: &str) -> Result {
- use std::io::Read as _;
let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
if at_path.file_type().is_symlink() {
return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
@@ -69,21 +74,41 @@ fn read_control_file(file: &str) -> Result {
if !at_path.is_file() {
return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
}
- let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
- #[cfg(unix)]
- {
- use std::os::unix::fs::MetadataExt;
- // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
- let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
- if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
- return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
+ #[cfg(not(windows))]
+ let text = {
+ use std::io::Read as _;
+ let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::MetadataExt;
+ // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
+ let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
+ if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
+ return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
+ }
+ if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
+ return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
+ }
}
- if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
+ let mut text = String::new();
+ f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
+ text
+ };
+ // Windows: the same owner and access check, on the security descriptor of the very handle the
+ // contents are read through, so nothing swapped in at the path gets through (#3922). A file
+ // planted by another user, or one other users can read, is refused; if the check itself can't
+ // run, the file isn't trusted either.
+ #[cfg(windows)]
+ let text = {
+ use pdfcraft_platform::owner_only;
+ let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
+ let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX)
+ .map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
+ if let Some(problem) = owner_only::foreign_access(&sddl, &me) {
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
}
- }
- let mut text = String::new();
- f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
+ String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))?
+ };
if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
}
@@ -112,6 +137,38 @@ fn current_uid() -> u32 {
mod tests {
use super::control_file_problem;
+ /// #3922: on Windows the CLI checks the control file's owner and access list. A file other
+ /// users can read is refused; the same file restricted to its owner is accepted.
+ #[cfg(windows)]
+ #[test]
+ fn on_windows_a_control_file_other_users_can_read_is_refused() {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
+ assert!(users.status.success());
+ let file = dir.join("ctl.json");
+ std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap();
+ let path = file.to_string_lossy().into_owned();
+ let refused = super::read_control_file(&path).unwrap_err();
+ assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}");
+ pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap();
+ assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken"));
+ // The contents come with the check, capped: exactly the limit is read, one byte more is refused.
+ let padded = |len: usize| {
+ let json = r#"{"port":4242,"token":"t0ken"}"#;
+ json.to_string() + &" ".repeat(len - json.len())
+ };
+ std::fs::write(&file, padded(64 * 1024)).unwrap();
+ assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242));
+ std::fs::write(&file, padded(64 * 1024 + 1)).unwrap();
+ assert!(super::read_control_file(&path).unwrap_err().contains("too large"));
+ // Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it.
+ std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap();
+ assert!(super::read_control_file(&path).is_err(), "not UTF-8");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn a_control_file_must_be_yours_and_private() {
// Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
diff --git a/apps/pdfcraft-cli/tests/it/check.rs b/apps/pdfcraft-cli/tests/it/check.rs
index 7a6236419c..3f32459089 100644
--- a/apps/pdfcraft-cli/tests/it/check.rs
+++ b/apps/pdfcraft-cli/tests/it/check.rs
@@ -129,3 +129,18 @@ fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
);
}
}
+
+/// A failing file's name goes to stderr escaped: a line break in it can't start a second,
+/// forged record. The JSON report keeps the name as it is.
+#[cfg(unix)]
+#[test]
+fn a_file_name_cannot_forge_a_stderr_record() {
+ let dir = Workdir::new("escaped-name");
+ let name = "bad\n[9] ok forged.pdf";
+ std::fs::write(dir.0.join("input").join(name), b"not a pdf").unwrap();
+ let out = Command::new(BIN).arg("check").arg(dir.0.join("input")).args(["--dpi", "36", "--timeout", "5"]).output().unwrap();
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ let records: Vec<&str> = stderr.lines().filter(|l| l.starts_with('[')).collect();
+ assert_eq!(records.len(), 1, "{stderr}");
+ assert!(records[0].contains("bad\\n[9] ok forged.pdf"), "{stderr}");
+}
diff --git a/apps/pdfcraft-cli/tests/it/info_repairs.rs b/apps/pdfcraft-cli/tests/it/info_repairs.rs
new file mode 100644
index 0000000000..72e8bed1a3
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/it/info_repairs.rs
@@ -0,0 +1,75 @@
+//! `info` lists the notes about damage repaired while opening, as the app shows them in Document
+//! Properties and as `doc_info` reports them in `repairs`: a rebuilt cross-reference index, and a
+//! page tree rebuilt from the pages found by scanning (a note the app adds after reading).
+
+use std::process::Command;
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+/// A PDF with `pages` pages, each drawing a rectangle (a page found by scanning has content), and
+/// `catalog` as object 1; `startxref` points at its cross-reference table, or at `bad_startxref`.
+fn fixture(catalog: &str, pages: usize, bad_startxref: Option) -> Vec {
+ let content = "0 0 0 rg 10 10 40 20 re f";
+ let kids: String = (0..pages).map(|i| format!("{} 0 R ", 4 + i)).collect();
+ let mut objs = vec![
+ catalog.to_string(),
+ format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>"),
+ format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
+ ];
+ objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 3 0 R >>".to_string()));
+ 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 off in &offsets {
+ out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
+ }
+ let start = bad_startxref.unwrap_or(xref);
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>";
+
+fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-info-repairs-{}-{name}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("f.pdf");
+ std::fs::write(&path, bytes).unwrap();
+ let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+ let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
+ let _ = std::fs::remove_dir_all(&dir);
+ json
+}
+
+fn repairs(json: &serde_json::Value) -> Vec {
+ json["repairs"].as_array().unwrap_or_else(|| panic!("no repairs array: {json}")).iter().map(|l| l.as_str().unwrap().to_string()).collect()
+}
+
+#[test]
+fn info_lists_a_rebuilt_cross_reference_index() {
+ // `startxref` points into the middle of an object, so the index is rebuilt from the headers.
+ let json = info_of(&fixture(CATALOG, 1, Some(20)), "xref");
+ assert_eq!(json["pages"], serde_json::json!(1), "{json}");
+ let lines = repairs(&json);
+ assert!(lines.iter().any(|l| l.contains("rebuilt cross-reference index")), "{lines:?}");
+}
+
+#[test]
+fn info_lists_a_page_tree_the_app_rebuilt_by_scanning() {
+ // The catalog names a page tree object the file does not have (#1656).
+ let json = info_of(&fixture("<< /Type /Catalog /Pages 9 0 R >>", 2, None), "pagetree");
+ let lines = repairs(&json);
+ assert!(lines.iter().any(|l| l.contains("page tree is missing")), "{lines:?}");
+}
+
+#[test]
+fn info_lists_no_repairs_for_a_sound_file() {
+ let json = info_of(&fixture(CATALOG, 1, None), "sound");
+ assert_eq!(json["repairs"], serde_json::json!([]), "{json}");
+}
diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs
index 30a6e25882..7b7292fe45 100644
--- a/apps/pdfcraft-cli/tests/it/main.rs
+++ b/apps/pdfcraft-cli/tests/it/main.rs
@@ -8,6 +8,7 @@ mod check;
mod edit;
mod extract;
mod help;
+mod info_repairs;
mod info_standards;
mod mcp_compact;
mod options;
diff --git a/apps/pdfcraft-cli/tests/it/options.rs b/apps/pdfcraft-cli/tests/it/options.rs
index 7955157e28..d414922998 100644
--- a/apps/pdfcraft-cli/tests/it/options.rs
+++ b/apps/pdfcraft-cli/tests/it/options.rs
@@ -113,3 +113,14 @@ fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() {
assert!(!stderr.contains("panicked"), "{stderr}");
}
}
+
+/// A file `check-one` cannot read is a record on stdout with its own status and the reason, which
+/// `check` relays, not an empty stdout that reads as a crash.
+#[test]
+fn check_one_reports_an_unreadable_file_as_a_record() {
+ let out = Command::new(BIN).args(["check-one", "missing.pdf"]).output().expect("run cli");
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ let record: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout:?}"));
+ assert_eq!(record["status"], "read-error");
+ assert!(record["detail"].as_str().is_some_and(|d| d.contains("missing.pdf")), "{record}");
+}
diff --git a/apps/pdfcraft-cli/tests/it/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs
index 6796980982..5a3e061728 100644
--- a/apps/pdfcraft-cli/tests/it/owner_password.rs
+++ b/apps/pdfcraft-cli/tests/it/owner_password.rs
@@ -130,6 +130,12 @@ fn render_accepts_the_owner_password() {
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
+ if pw == "user" {
+ // The user password opens it, but a page image is a copy the permissions forbid.
+ assert!(!out.status.success() && stderr.contains("don't allow copying"), "{alg:?} with {pw:?}: {stderr}");
+ assert!(!png.exists(), "{alg:?}: no image is written");
+ continue;
+ }
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
}
diff --git a/apps/pdfcraft-cli/tests/it/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs
index f49771b001..082faf3426 100644
--- a/apps/pdfcraft-cli/tests/it/ui_control.rs
+++ b/apps/pdfcraft-cli/tests/it/ui_control.rs
@@ -86,6 +86,24 @@ fn write_private(path: &Path, text: &str) {
opts.mode(0o600);
}
opts.open(path).unwrap().write_all(text.as_bytes()).unwrap();
+ // On Windows the file inherits its folder's access list, as the app's would before it
+ // restricts it (#3922).
+ #[cfg(windows)]
+ {
+ use pdfcraft_platform::owner_only;
+ owner_only::restrict_to_owner(path, &owner_only::current_user_sid().unwrap()).unwrap();
+ // The file the CLI is handed must already pass its check, or a refusal below would be
+ // the helper's fault, not the CLI's.
+ let sddl = owner_only::sddl_of(path).unwrap();
+ let listing = |p: &Path| Command::new("icacls").arg(p).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
+ let folder = path.parent().map(listing);
+ assert_eq!(
+ owner_only::foreign_access(&sddl, &owner_only::current_user_sid().unwrap()),
+ None,
+ "restricted file: {sddl}; icacls: {:?}; folder: {folder:?}",
+ listing(path)
+ );
+ }
}
fn ui(control: &Path, method: &str) -> Output {
diff --git a/apps/pdfcraft-cli/tests/it/xfa.rs b/apps/pdfcraft-cli/tests/it/xfa.rs
index 1b5ff7bc9d..4d5df67535 100644
--- a/apps/pdfcraft-cli/tests/it/xfa.rs
+++ b/apps/pdfcraft-cli/tests/it/xfa.rs
@@ -7,6 +7,11 @@ use std::sync::Arc;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
fn fixture(test: &str) -> PathBuf {
+ fixture_with(test, "")
+}
+
+/// The two-page form, with `event` inside its `name` field.
+fn fixture_with(test: &str, event: &str) -> PathBuf {
let xdp = r#"
@@ -15,11 +20,12 @@ fn fixture(test: &str) -> PathBuf {
First XFA page
- Ada
+ Ada {event}
Second XFA page
- "#;
+"#
+ .replace("{event}", event);
let objects = [
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
@@ -67,6 +73,21 @@ fn info_reports_the_laid_out_pages_and_fields() {
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
}
+/// `info` inspects an untrusted file: a form's initialize script, which the app runs on opening
+/// and which here would change the field's value, does not run.
+#[test]
+fn info_runs_none_of_the_forms_scripts() {
+ let script = r#" "#;
+ let path = fixture_with("info-script", script);
+ let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
+ success(&output);
+ let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
+ let warnings = info["warnings"].as_array().unwrap();
+ assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scripts changed it on opening"))), "{warnings:?}");
+ assert_eq!(info["repairs"], serde_json::json!([]), "{info}");
+}
+
#[test]
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
let path = fixture("text");
@@ -134,3 +155,21 @@ fn a_laid_out_form_keeps_the_original_copy_permissions() {
assert_eq!(info["file_size"], original.len());
assert_eq!(std::fs::read(path).unwrap(), original);
}
+
+/// A damaged dynamic XFA form: `info` lists the cross-reference index rebuilt while opening it,
+/// as Document Properties does after the form is laid out as one more revision.
+#[test]
+fn info_lists_the_repairs_of_a_laid_out_form() {
+ let path = fixture("info-damaged");
+ let bytes = std::fs::read(&path).unwrap();
+ let at = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
+ let mut damaged = bytes[..at].to_vec();
+ damaged.extend_from_slice(b"20\n%%EOF\n");
+ std::fs::write(&path, &damaged).unwrap();
+ let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
+ success(&output);
+ let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
+ let repairs = info["repairs"].as_array().unwrap();
+ assert!(repairs.iter().any(|l| l.as_str().is_some_and(|l| l.contains("rebuilt cross-reference index"))), "{repairs:?}");
+}
diff --git a/apps/pdfcraft-ocr-worker/Cargo.toml b/apps/pdfcraft-ocr-worker/Cargo.toml
new file mode 100644
index 0000000000..7360c13440
--- /dev/null
+++ b/apps/pdfcraft-ocr-worker/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "pdfcraft-ocr-worker"
+description = "PdfCraft in the browser: the Web Worker that recognises text (wasm32; trunk builds it with pdfcraft-web)"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[dependencies]
+pdfcraft-ocr = { workspace = true }
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+wasm-bindgen = "0.2"
+js-sys = "0.3"
+web-sys = { version = "0.3", features = ["DedicatedWorkerGlobalScope", "MessageEvent"] }
diff --git a/apps/pdfcraft-ocr-worker/src/main.rs b/apps/pdfcraft-ocr-worker/src/main.rs
new file mode 100644
index 0000000000..a22099e27c
--- /dev/null
+++ b/apps/pdfcraft-ocr-worker/src/main.rs
@@ -0,0 +1,101 @@
+//! PdfCraft in the browser: the Web Worker that recognises text, so reading a scanned page doesn't
+//! stop the page responding and several pages are read at once. Trunk builds it alongside
+//! `pdfcraft-web` (see apps/pdfcraft-web/index.html); the page starts and feeds the workers
+//! (crates/ui-egui/src/workflows/ocr_ui.rs).
+//!
+//! Messages are plain objects with a `type`:
+//!
+//! - `ready` (to the page): the worker is listening; send the models now.
+//! - `models` (from the page): `{ detection, recognition }`, each a `Uint8Array` holding a model
+//! file. Answered with `loaded`, or `failed` with an `error`.
+//! - `page` (from the page): `{ id, width, height, pixels }`, the RGBA pixels of a rendered page
+//! as a `Uint8Array`. Answered with `lines` `{ id, lines }`, the recognised lines as
+//! `pdfcraft_ocr::lines_to_json` writes them, or `failed` `{ id, error }`.
+
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+#[cfg(target_arch = "wasm32")]
+fn main() {
+ use wasm_bindgen::{JsCast, closure::Closure};
+
+ let Ok(scope) = js_sys::global().dyn_into::() else {
+ return;
+ };
+ let reply_to = scope.clone();
+ let mut ocr = None;
+ let on_message = Closure::::new(move |e: web_sys::MessageEvent| {
+ let _ = reply_to.post_message(&worker::handle(&mut ocr, &e.data()));
+ });
+ scope.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
+ // The handler lives as long as the worker.
+ on_message.forget();
+ let _ = scope.post_message(&worker::message(&[("type", "ready".into())]));
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn main() {
+ eprintln!("pdfcraft-ocr-worker runs in a browser: trunk builds it with pdfcraft-web");
+}
+
+#[cfg(target_arch = "wasm32")]
+mod worker {
+ use pdfcraft_ocr::Ocr;
+ use wasm_bindgen::{JsCast, JsValue};
+
+ /// The models are about 12 MB together.
+ const MAX_MODEL_BYTES: u32 = 64 << 20;
+ /// The renderer keeps pages far smaller; anything larger isn't a page.
+ const MAX_PIXEL_BYTES: u64 = 1 << 30;
+
+ pub fn message(fields: &[(&str, JsValue)]) -> JsValue {
+ let o = js_sys::Object::new();
+ for (k, v) in fields {
+ let _ = js_sys::Reflect::set(&o, &(*k).into(), v);
+ }
+ o.into()
+ }
+
+ /// Answer one message from the page.
+ pub fn handle(ocr: &mut Option, data: &JsValue) -> JsValue {
+ let get = |k: &str| js_sys::Reflect::get(data, &k.into()).unwrap_or(JsValue::UNDEFINED);
+ let id = get("id");
+ let reply = match get("type").as_string().as_deref() {
+ Some("models") => load(&get("detection"), &get("recognition")).map(|o| {
+ *ocr = Some(o);
+ message(&[("type", "loaded".into())])
+ }),
+ Some("page") => match ocr.as_ref() {
+ None => Err("the text recognition models aren't loaded".into()),
+ Some(o) => recognize(o, &get("width"), &get("height"), &get("pixels"))
+ .map(|lines| message(&[("type", "lines".into()), ("id", id.clone()), ("lines", lines.into())])),
+ },
+ _ => Err("unknown message".into()),
+ };
+ reply.unwrap_or_else(|error| message(&[("type", "failed".into()), ("id", id), ("error", error.into())]))
+ }
+
+ fn bytes(v: &JsValue, max: u64) -> Result, String> {
+ let a = v.dyn_ref::().ok_or("expected a Uint8Array")?;
+ if u64::from(a.length()) > max {
+ return Err(format!("{} bytes is too large", a.length()));
+ }
+ Ok(a.to_vec())
+ }
+
+ fn load(detection: &JsValue, recognition: &JsValue) -> Result {
+ let max = u64::from(MAX_MODEL_BYTES);
+ Ocr::from_bytes(bytes(detection, max)?, bytes(recognition, max)?).map_err(|e| e.to_string())
+ }
+
+ fn recognize(ocr: &Ocr, width: &JsValue, height: &JsValue, pixels: &JsValue) -> Result {
+ let side = |v: &JsValue| v.as_f64().filter(|v| v.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
+ let (Some(w), Some(h)) = (side(width), side(height)) else {
+ return Err("the page size is not a positive whole number".into());
+ };
+ let px = bytes(pixels, MAX_PIXEL_BYTES)?;
+ if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(4)) != Some(px.len() as u64) {
+ return Err("the pixels don't match the page size".into());
+ }
+ ocr.recognize(&px, w, h).map(|lines| pdfcraft_ocr::lines_to_json(&lines)).map_err(|e| e.to_string())
+ }
+}
diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml
index 56e10c0c29..ad5b62229c 100644
--- a/apps/pdfcraft-web/Cargo.toml
+++ b/apps/pdfcraft-web/Cargo.toml
@@ -12,9 +12,12 @@ workspace = true
[dependencies]
pdfcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
-craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
+# The page command API (storytold/craft-libs#413): window.pdfcraft over the UI control channel.
+craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
+craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
+serde_json = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
diff --git a/apps/pdfcraft-web/Trunk.toml b/apps/pdfcraft-web/Trunk.toml
index 563c2267c1..f03ccc6ea1 100644
--- a/apps/pdfcraft-web/Trunk.toml
+++ b/apps/pdfcraft-web/Trunk.toml
@@ -9,3 +9,10 @@ filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8766
+
+# Trunk leaves the OCR worker's files unhashed and unnamed in index.html: hash them and name the
+# worker for the page (xtask/src/web.rs).
+[[hooks]]
+stage = "post_build"
+command = "cargo"
+command_arguments = ["xtask", "web-worker-link"]
diff --git a/apps/pdfcraft-web/index.html b/apps/pdfcraft-web/index.html
index 46b3a25e21..f93bb0812d 100644
--- a/apps/pdfcraft-web/index.html
+++ b/apps/pdfcraft-web/index.html
@@ -5,6 +5,8 @@
PdfCraft
+
+
"#);
+ let xdp = format!(
+ r##"
+
+
+
+
+ {look}
+
+
+
+
+"##,
+ look = script("xfa.form.form.seen.rawValue = String(app.runtimeHighlight);"),
+ );
+ let mut h = open(pdfcraft_xfa::fixtures::shell(&xdp));
+ let app = h.state_mut();
+ // The document in front has Highlight fields on; the remembered setting is off.
+ app.view_defaults.highlight_fields = false;
+ app.views[0].highlight_fields = true;
+ h.run_steps(2);
+ let app = h.state_mut();
+ let first = app.views[0].id;
+ app.open_bytes("next.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(1))).unwrap();
+ // Before the next frame, the first document's script reads its own setting.
+ app.session.apply(first, pdfcraft_engine::Edit::XfaEvent { som: "form[0].look[0]".into(), activity: "click".into() }).unwrap();
+ let seen = app.session.get(first).unwrap().form.iter().find(|f| f.name == "seen").map(|f| f.value.clone()).unwrap_or_default();
+ assert_eq!(seen, vec!["true".to_string()], "the first document's script reads its own Highlight fields");
+}
+
+/// A form that turns field highlighting on (`app.runtimeHighlight = true` in a `layout:ready`
+/// script) gets it, and so does the next document, as with the Highlight fields button.
+#[test]
+fn a_form_that_turns_field_highlighting_on_gets_it() {
+ let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::layout_ready_template()));
+ h.run_steps(4);
+ assert!(h.state().views[0].highlight_fields, "the form's fields are highlighted");
+ h.state_mut().open_bytes("next.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(1))).unwrap();
+ h.run_steps(4);
+ assert!(h.state().views[1].highlight_fields, "the next document opens highlighted");
+}
diff --git a/crates/ui-egui/tests/ocr.rs b/crates/ui-egui/tests/ocr.rs
index a0af321a01..18c323d2d0 100644
--- a/crates/ui-egui/tests/ocr.rs
+++ b/crates/ui-egui/tests/ocr.rs
@@ -87,7 +87,7 @@ fn ocr_verify_screen_controls_keyboard_and_accept() {
h.run_steps(4);
assert!(h.state_mut().execute("ocr.correct"));
h.run_steps(3);
- // The screen owns the window and its controls are all present (no dead controls).
+ // The screen's controls are all present (no dead controls).
h.get_by_label("Run OCR");
h.get_by_label("Reject");
h.get_by_label("Next uncertain");
diff --git a/crates/xfa/README.md b/crates/xfa/README.md
index 2ae840a8f0..1f8daafb43 100644
--- a/crates/xfa/README.md
+++ b/crates/xfa/README.md
@@ -25,17 +25,44 @@ that warning and keeps the existing layout and accepted widget values.
margins, borders (edge order rules), captions with reserves, check-button items, pictures,
rich text (`exData` XHTML: paragraphs, `br`, bold/italic/underline/size spans, embedded fields),
text-field limits (`text/maxChars` and `exData/maxLength`, carried into AcroForm `/MaxLen`),
- inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
+ required fields and exclusion groups (``), inline JPEG, PNG and GIF images, lines, rectangles, occurrences, `breakBefore`/`breakAfter`, overflow
leaders, `columnWidths` and `colSpan`.
+- **Prototypes** (`proto`, XFA 3.3 chapter 6, "Prototypes"): before parsing, every `use` and
+ `usehref` reference into the same template is resolved and the template is written again
+ with it merged in, so Designer style sheets (`usehref=".#som($template.#subform.styles.X)"`)
+ give their fields and draws size, anchor, borders, fills, fonts and captions. The element's
+ own attributes, content and children win; `id` and `name` are not inherited; children match
+ by class and position, a matched prototype child being the prototype of the element's own;
+ a `ui` widget or `value` kind and rich text are taken from the nearest layer that has one.
+ References are `#id`, `#som(…)` (or `.#…`) and, for `use`, a SOM expression. Loops are cut
+ where they close, chains merge at most 32 layers, nesting stops at 256 elements, a SOM
+ expression longer than 1,024 bytes names nothing, SOM look-ups share a budget of 2 million
+ elements, each element's reference is resolved once however many elements reach it,
+ merging looks at no more than 64 million children, attributes and namespaces in all
+ (prototype children that write nothing, such as `proto`, count too), and the written
+ template may grow to 4 times its size (at least 1 MB, at most 64 MB). Past the nesting,
+ look-up, merge or growth budget expanding fails as a whole: no partly written template is
+ used, the template is read as it is written (unresolved), with one warning saying which
+ budget. A cut loop or chain is a warning too.
- **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a
container without a width is as wide as its content; flow across content areas and pages with
page masters chosen by occurrence; a table's header row repeats after a break; presence
`hidden` takes no space, `invisible` takes space; repeating subforms get their initial
instances, in flowed content and in a top-to-bottom container inside positioned content (a
- table in an area) alike; fields that compute the page number or count on layout show the numbers;
+ table in an area) alike; fields that compute the page number or count on layout show the numbers,
+ and so do those that compute the sheet number (`sheet(this)+1`) or count, a sheet being a page on
+ screen; an embedded (floating) field shows its value from the data, which is where a script's
+ value goes (a repeating row's own instance, a page area's field under the area, a multi-select
+ list's picks, a date in its display picture), and an event, reset (a hidden one without a
+ widget included), typed value or pick that changes one lays the form out again;
+ a field inside an exclusion group is not shown yet;
a draw placed by position turns about its anchor (`rotate`: degrees counter-clockwise, a multiple
of 90), as one item a page break doesn't cut, and its container grows to the box it stands in. A
- field, a subform or a draw in a flow doesn't turn.
+ field, a subform or a draw in a flow doesn't turn. A
+ caption without a font of its own is drawn in its field's font, and one naming only
+ some font properties takes the rest from it (XFA 3.3, "Formatting Text That Appears as Fixed
+ or Variable Content", p. 43); a field caption's paragraph
+ is still the one around the field.
- **PDF** (`pdf::write_form`): pages with content streams in the standard 14 fonts (Arial and
friends → Helvetica, Courier New → Courier, serif faces → Times; nothing is embedded), JPEG
XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate`
@@ -44,14 +71,18 @@ that warning and keeps the existing layout and accepted widget values.
scripts, and signature fields, each named by its SOM path (`form1[0].page1[0].sign[0]`, as
fields under one for each step above it) because other viewers lay a dynamic form out again
and find a signature by that name. A box whose path, or a part of it, the file already gives
- another field keeps its own name, and a new layout keeps a signature a generated box holds. Appearance streams are left empty for the forms layer to
+ another field keeps its own name, and a new layout keeps a signature a generated box holds. A field or exclusion group that must be filled in gets the `Required` flag (a radio
+ group on its group field), which viewers outline when they highlight fields. Appearance streams are left empty for the forms layer to
generate. Each field keeps its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out
twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them.
- **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data
node at its data path: its SOM path without areas, which are named in SOM expressions but are
not data scopes (`data_path`, as Adobe's viewers bind) (dates in ISO form, check and radio states by their on values), and a
- repeating subform or row gets as many instances as the data has. `write_datasets` merges the
+ repeating subform or table row gets as many instances as the data has (in flowed and positioned
+ containers alike), up to its `occur max` and at
+ most 1 000 (`MAX_INSTANCES`, a work bound; the rows past it are left out with a layout
+ warning). `write_datasets` merges the
AcroForm fields' values (by `/PCSom`, or by the Designer field names of a static form) into
the existing `xfa:data`: only the bound nodes' text changes and missing nodes are added, so
unbound data, other namespaces, attributes and comments stay byte for byte. Values it can't
@@ -73,7 +104,8 @@ be measured exponentially often in their nesting. A measurement budget backs thi
the datasets packet per event), and `rerender`, which lays the form out again from template,
data and overrides. The scripts themselves run in the `js` crate's XFA object model; the
engine runs initialize and calculate on open, change, exit, validate and calculate after a
- field changes, and click for buttons. Every generated copy of a page-master button resolves
+ field changes, click for buttons, and preSign and postSign around signing a signature box
+ (preSign first, so what it changes is signed too). Every generated copy of a page-master button resolves
its own PDF field to the shared XFA click script, so clicking an early copy works as well
as the last, including through `js_run` and after save/reopen.
While this XFA layout is active, the engine does not
@@ -88,6 +120,7 @@ be measured exponentially often in their nesting. A measurement budget backs thi
| `/PCSom` | generated fields | the field's SOM path, so values go back to the data |
| `/PCXfaLayout` | AcroForm | what laying out produced, so a saved form is not laid out again |
| `/PCXfaOverrides` | AcroForm | `<< /Presence << /som (hidden) … >> /Access << /som (readOnly) … >> >>`: presence and access scripts set, by SOM path (instance 0 of a repeating subform stands for all); applied to the template before every layout, so undo, save and reopen keep them. Adobe's viewers draw from the XFA packets and don't read it; at most 10 000 entries are read or written |
+ | `/PCXfaDate` | generated date fields | `true`: text the field's date pattern doesn't read is kept, and shown, as typed rather than refused, as an XFA form keeps it; the forms layer reads it |
| `/PCXfaNode` | generated fields above a signature field | `true`: the field names a step of the signature field's SOM path; a new layout replaces it with the fields it holds |
| `/PCXfaClick` | generated push buttons | `true`: the template has a click script for this button. Such buttons carry no `/A`: XFA JavaScript is not Acrobat JavaScript, so it is never written as a `/S /JavaScript` action other viewers would run. PdfCraft finds the script by the field's `/PCSom` |
@@ -111,9 +144,19 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
not locale-aware pictures beyond simple number and date ones, or `Get`/`Post`/`Put` (refused).
The object model covers what forms commonly use (`xfa.form`
navigation and SOM resolution, `rawValue`, `presence`, `access`, instance managers,
- `xfa.host` messages, reset, print, focus and URLs, `xfa.layout` page numbers, `xfa.event`);
- not `xfa.template`, data descriptions, `xfa.connectionSet`, `border`/`font`/`ui` properties
- (reads give a sink that accepts writes), `execEvent`, or the change event per keystroke.
+ `xfa.host` messages, reset, print, focus and URLs, `xfa.layout` page and sheet numbers,
+ `xfa.event`);
+ property objects (`border`, `font`, `para`, `margin`, `ui` and its `oneOfChild`, `validate`,
+ `calculate`, `occur`, `break`, `breakBefore`, `breakAfter`, `assist`, `format`, `bind`,
+ `desc`, `extras`, `keep`, `caption` and a field's `items`), read as the template writes them
+ (the parser keeps each object's property elements, shared by every instance and every
+ script's snapshot) with the spec's defaults for what forms read, `getElement`,
+ `isPropertySpecified`, `getAttribute`, and a node's `mandatory`, validation messages and
+ colour shorthands; writes to them are read back in the same script but change nothing yet.
+ Not `xfa.template`, data descriptions, `xfa.connectionSet`, the choice-list methods
+ (`addItem`, `clearItems`, `boundItem`; `selectedIndex` reads give a sink that accepts
+ writes), a node's `value` as its value element, `traversal` and `overflow`, property reads
+ in FormCalc, `execEvent`, or the change event per keystroke.
Validate failures show their message and keep the value, as Acrobat does for scripts.
A hidden instance of a repeating subform hides every instance (overrides apply to the template).
A repeating subform shows what its data holds once the data holds any instance, else its
@@ -162,6 +205,11 @@ let form = pdfcraft_xfa::layout_xml(template_xml)?; // pages of items, for
file's data is held as a tree and written with the packet's own namespace prefix, never as
the file's markup, and every rewrite of the packet is parsed back before it is written; the
engine then lays a dynamic form out again from it and runs its calculations.
+- **Prototypes in other documents** (`usehref="file.xdp#…"`) are not followed: that would
+ mean reading files or the network on opening a form. The reference is left out with a
+ warning. SOM references cover names, `#class` and `[n]`, not `[*]`, `..` or relative forms;
+ traversal declarations are merged like other children, not knitted together as "Resolving
+ Prototypes with Traversals" describes.
- Missing: `keep` constraints, `subformSet` relations, `rl-tb` is mirrored `lr-tb`, font
metrics are the approximate Helvetica ones, and line heights are 1.15 × size.
- Acrobat cannot be an oracle here (clean-room rules): layout follows the specification, and
diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs
index 7c387c44ef..34be028d89 100644
--- a/crates/xfa/src/data.rs
+++ b/crates/xfa/src/data.rs
@@ -34,9 +34,9 @@ pub use dates::{iso_to_pattern, pattern_to_iso};
pub(crate) use import::MAX_IMPORT_BYTES;
pub use import::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image};
pub use passwords::{clear_password_values, has_password_values};
-pub(crate) use path::template_field;
pub use path::{data_path, parse_datasets, som_to_path};
-pub use values::{FieldData, FieldDatum, is_on, read_values, read_values_with};
+pub(crate) use path::{fields_with_ids, template_field, template_object};
+pub use values::{FieldData, FieldDatum, is_on, name_text, radio_dataset_value, radio_state_for_item, read_values, read_values_with};
#[cfg(test)]
pub(crate) use write::ENSURED;
pub use write::{
@@ -55,6 +55,10 @@ pub struct DataNode {
pub name: String,
pub text: String,
pub children: Vec,
+ /// Whether the element is a data group even with no children: marked
+ /// `xfa:dataNode="dataGroup"` (XFA 3.3 p. 150), or a multi-select list's group emptied of its
+ /// picks here, which the packet writer marks so.
+ pub group: bool,
}
/// A path into the data: `(name, index among same-named siblings)` per level.
@@ -70,6 +74,31 @@ impl DataNode {
Some(node)
}
+ /// Every path to a node whose names, from here, are `names`: each instance of each step (a
+ /// repeating subform's rows), at most `cap` of them, in document order.
+ pub fn paths_named(&self, names: &[String], cap: usize) -> Vec {
+ fn walk(n: &DataNode, names: &[String], path: &mut DataPath, out: &mut Vec, cap: usize) {
+ let Some((first, rest)) = names.split_first() else {
+ if out.len() < cap {
+ out.push(path.clone());
+ }
+ return;
+ };
+ for (i, c) in n.children.iter().filter(|c| &c.name == first).enumerate() {
+ if out.len() >= cap {
+ return;
+ }
+ path.push((first.clone(), i));
+ walk(c, rest, path, out, cap);
+ path.pop();
+ }
+ }
+ let mut out = Vec::new();
+ // The depth is the length of `names`, a template's own path.
+ walk(self, names, &mut DataPath::new(), &mut out, cap);
+ out
+ }
+
/// How many children of the node at `parent` are named `name`.
pub fn count(&self, parent: &[(String, usize)], name: &str) -> usize {
self.get(parent).map_or(0, |n| n.children.iter().filter(|c| c.name == name).count())
@@ -83,11 +112,25 @@ impl DataNode {
/// elements (one way a multi-select list's picks are stored), those on separate lines.
pub fn value_at(&self, path: &[(String, usize)]) -> Option {
let n = self.get(path)?;
- if n.children.is_empty() || !n.children.iter().all(|c| c.name == "value") {
+ if !n.holds_picks() {
return Some(n.text.clone());
}
- Some(n.children.iter().map(|c| c.text.trim()).filter(|t| !t.is_empty()).take(1000).collect::>().join("\n"))
+ Some(picks_of(n.children.iter().map(|c| c.text.as_str())).collect::>().join("\n"))
}
+
+ /// Whether this node holds a multi-select list's picks: children, all `value` elements.
+ pub(crate) fn holds_picks(&self) -> bool {
+ !self.children.is_empty() && self.children.iter().all(|c| c.name == "value")
+ }
+}
+
+/// Most picks a multi-select value is read or written with.
+pub(crate) const MAX_PICKS: usize = 1000;
+
+/// The picks among `texts` (`value` elements, or the lines of a value): non-empty, trimmed, at
+/// most [`MAX_PICKS`].
+pub(crate) fn picks_of<'a>(texts: impl Iterator- ) -> impl Iterator
- {
+ texts.map(str::trim).filter(|t| !t.is_empty()).take(MAX_PICKS)
}
/// One change to the data, for [`write_data_ops`].
@@ -125,10 +168,21 @@ impl DataNode {
pub fn apply(&mut self, op: &DataOp) {
match op {
DataOp::Value { path, text } => {
- if let Some(n) = self.ensure(path)
- && n.children.is_empty()
- {
- n.text = text.clone();
+ if let Some(n) = self.ensure(path) {
+ // A multi-select list's picks, one `value` each (see `value_at`), told apart
+ // as the packet writer does: `value` leaves only, or a group holding none.
+ let picks = if n.children.is_empty() { n.group } else { n.children.iter().all(|c| c.name == "value" && c.children.is_empty()) };
+ if picks {
+ n.children = text
+ .split('\n')
+ .filter(|v| !v.is_empty())
+ .map(|v| DataNode { name: "value".into(), text: v.to_string(), ..Default::default() })
+ .collect();
+ // Emptied, it is still a group, and takes picks again.
+ n.group = true;
+ } else if n.children.is_empty() {
+ n.text = text.clone();
+ }
}
}
DataOp::Instances { parent, name, count } => {
diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs
index 565c44117d..e1c30f3f5a 100644
--- a/crates/xfa/src/data/dates.rs
+++ b/crates/xfa/src/data/dates.rs
@@ -37,7 +37,7 @@ pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String {
}
/// An ISO date as the data should hold it: in `data_pattern` when the field has one and the
-/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO.
+/// date reads back from it unchanged (a two-digit year holds only 1930 to 2029), else as ISO.
pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String {
match data_pattern {
Some(p) => {
@@ -53,6 +53,11 @@ pub(super) fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option
Option {
rest = &rest[digits.len()..];
match t.as_str() {
"yyyy" => y = Some(v),
- "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }),
+ "yy" => y = Some(if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v }),
"mm" | "m" => m = Some(v),
_ => d = Some(v),
}
diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs
index 0aefc634b5..0d573c71a9 100644
--- a/crates/xfa/src/data/merge.rs
+++ b/crates/xfa/src/data/merge.rs
@@ -271,6 +271,44 @@ fn merge(text: &str, within: Within, op: Op, warnings: &mut Vec) -> Merg
Merged::Changed(out)
}
+/// Whether `node` is a multi-select choice list's data group: element children only, every one
+/// a `value` holding text alone; or, with no children, an element marked `xfa:dataNode=
+/// "dataGroup"`, as a group with no picks is written (XFA 3.3 p. 150).
+fn is_value_group(node: roxmltree::Node) -> bool {
+ let blank = node.children().filter(|c| c.is_text()).all(|c| c.text().is_some_and(|t| t.trim().is_empty()));
+ let mut elements = node.children().filter(|c| c.is_element()).peekable();
+ if elements.peek().is_none() {
+ return blank && marked_data_group(node);
+ }
+ blank && elements.all(|c| c.tag_name().name() == "value" && !c.children().any(|g| g.is_element()))
+}
+
+/// Whether `node` carries `xfa:dataNode="dataGroup"`.
+fn marked_data_group(node: roxmltree::Node) -> bool {
+ node.attribute((XFA_DATA_NS, "dataNode")) == Some("dataGroup")
+}
+
+/// The edit that marks the data group `node` as one, so that emptied of its picks it still loads
+/// as a group and not as a data value (XFA 3.3 p. 150): `xfa:dataNode="dataGroup"` added to its
+/// start tag, with the namespace declared there when the packet has no prefix for it.
+fn mark_data_group_edit(src: &str, node: roxmltree::Node) -> Option {
+ let range = node.range();
+ let tag_end = start_tag_end(src, &range)?;
+ // Before `/>` of an empty tag, else before `>`.
+ let at = if src.get(..tag_end)?.ends_with('/') { tag_end.checked_sub(1)? } else { tag_end };
+ let text = match node.lookup_prefix(XFA_DATA_NS) {
+ Some(prefix) if !prefix.is_empty() => format!(" {prefix}:dataNode=\"dataGroup\""),
+ _ => {
+ // A prefix no namespace in scope is bound to, so declaring it rebinds nothing.
+ let prefix = (0..16)
+ .map(|i| if i == 0 { "pcxfa".to_string() } else { format!("pcxfa{i}") })
+ .find(|p| node.lookup_namespace_uri(Some(p)).is_none())?;
+ format!(" xmlns:{prefix}=\"{XFA_DATA_NS}\" {prefix}:dataNode=\"dataGroup\"")
+ }
+ };
+ Some(Edit { start: at, end: at, text })
+}
+
/// The edits that put `wants` into the `data` element.
fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Vec, warnings: &mut Vec) {
// Element children by local name, per element visited (built once each).
@@ -311,6 +349,44 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve
continue;
}
let current: String = node.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
+ // A multi-select choice list is bound to a data group of `value` elements, one per
+ // pick, and its value is theirs joined by newlines (XFA 3.3 p. 198); a new pick
+ // replaces them all, as in Acrobat (p. 199).
+ if is_value_group(node) {
+ // Every text segment of each `value`, as `current` reads a leaf (a comment may
+ // split or lead it).
+ let held: Vec = node
+ .children()
+ .filter(|c| c.is_element())
+ .map(|c| c.children().filter(|t| t.is_text()).filter_map(|t| t.text()).collect())
+ .collect();
+ // Pick by pick: one `value` holding a line break is not two picks.
+ let wanted: Vec<&str> = w.text.split('\n').filter(|v| !v.is_empty()).collect();
+ if held.iter().map(String::as_str).eq(wanted.iter().copied()) {
+ continue;
+ }
+ let inner: String = wanted.iter().map(|v| format!("{} ", escape(v).0)).collect();
+ // Emptied, the group keeps saying it is one (XFA 3.3 p. 150).
+ let needs_mark = wanted.is_empty() && !marked_data_group(node);
+ let mark = if needs_mark { mark_data_group_edit(src, node) } else { None };
+ if needs_mark && mark.is_none() {
+ // No marker can be written (no free prefix): emptied, the group would load
+ // as a data value, so it keeps its picks and the skip is reported.
+ skipped(warnings, STRUCTURED, &w.som);
+ continue;
+ }
+ match replace_edit(src, node, &inner) {
+ Some(e) => {
+ edits.extend(mark);
+ edits.push(e);
+ }
+ None => skipped(warnings, STRUCTURED, &w.som),
+ }
+ if dropped {
+ skipped(warnings, CONTROL, &w.som);
+ }
+ continue;
+ }
if node.children().any(|c| c.is_element()) {
let all: String = node.descendants().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
// Ignore existing XML indentation, but not whitespace in the accepted value.
diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs
index d3568817d2..23c5e5ba5d 100644
--- a/crates/xfa/src/data/path.rs
+++ b/crates/xfa/src/data/path.rs
@@ -1,12 +1,14 @@
-use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template};
+use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template, XFA_DATA_NS};
fn to_node(n: roxmltree::Node, depth: usize) -> DataNode {
let name = n.tag_name().name().to_string();
let children: Vec =
if depth < MAX_DEPTH { n.children().filter(|c| c.is_element()).map(|c| to_node(c, depth + 1)).collect() } else { Vec::new() };
- // The text of a leaf is its value; a container's own text is whitespace.
- let text = if children.is_empty() { n.children().filter_map(|c| c.text()).collect::() } else { String::new() };
- DataNode { name, text, children }
+ // The text of a leaf is its value (its text nodes: a comment is not part of it); a
+ // container's own text is whitespace.
+ let text = if children.is_empty() { n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::() } else { String::new() };
+ let group = n.attribute((XFA_DATA_NS, "dataNode")) == Some("dataGroup");
+ DataNode { name, text, children, group }
}
/// The `xfa:data` element of the datasets packet in `xdp` (or a bare datasets document), as a
@@ -50,7 +52,11 @@ pub fn data_path(tpl: &Template, som: &str) -> DataPath {
// The template children the next segment is looked up in; `None` until the root matched,
// and again once a segment is not a container the template has.
let mut level: Option<&[Node]> = None;
- for (i, (name, index)) in som_to_path(som).into_iter().enumerate() {
+ let segments = som_to_path(som);
+ let page_area_index = page_area_index(tpl, &segments);
+ for (i, (name, index)) in segments.into_iter().enumerate() {
+ // A page area's SOM index is the page made from it; see [`page_area_index`].
+ let index = if i == 0 { page_area_index.unwrap_or(index) } else { index };
let found = match level {
None if i == 0 && tpl.root.common.name.as_deref() == Some(name.as_str()) => Some(Container::Children(&tpl.root.children)),
None => None,
@@ -75,6 +81,36 @@ pub fn data_path(tpl: &Template, som: &str) -> DataPath {
out
}
+/// For a SOM path that starts at a page area (`Area[n].…`), the index its first segment takes in
+/// the data path, `None` for any other path. Each page made from a page area has its own SOM
+/// index, so the master content repeated on each page (a page number, an office-use box) binds
+/// apart on every page. A field whose bind shares one data node (`match="global"`, or an
+/// explicit data reference) binds as instance 0 on each page instead, so it shows the same value
+/// on all of them (XFA 3.3, "Page Background": a page-background field binds only through an
+/// explicit data reference).
+fn page_area_index(tpl: &Template, segments: &[(String, usize)]) -> Option {
+ let ((first, instance), rest) = segments.split_first()?;
+ if tpl.root.common.name.as_deref() == Some(first.as_str()) {
+ return None;
+ }
+ let areas = tpl.root.page_set.as_ref()?;
+ let area = areas.areas.iter().find(|a| a.name.as_deref() == Some(first.as_str()))?;
+ // The object the rest of the path names, inside the page area's own content.
+ let mut nodes: &[Node] = &area.items;
+ let mut target = None;
+ for (name, index) in rest {
+ let found = named_at(nodes, name, *index, 0)?;
+ target = Some(found);
+ nodes = match found {
+ Node::Subform(sf) => &sf.children,
+ Node::Area(a) => &a.children,
+ _ => &[],
+ };
+ }
+ let shared = matches!(target, Some(Node::Field(f)) if f.data_shared);
+ Some(if shared { 0 } else { *instance })
+}
+
enum Container<'a> {
Children(&'a [Node]),
/// Contents bind under the area's parent.
@@ -131,6 +167,16 @@ fn named_at<'a>(nodes: &'a [Node], name: &str, index: usize, depth: usize) -> Op
/// as the form counts it ([`named_at`]). `None` when the template has no such field, or it is
/// a button of an exclusion group (whose buttons take their value from the group).
pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Field> {
+ match template_object(tpl, som)? {
+ Node::Field(f) => Some(f),
+ _ => None,
+ }
+}
+
+/// The template object (field, exclusion group, subform...) the object at a SOM path is an
+/// instance of, found as [`template_field`] finds a field. `None` for the root and for a button
+/// of an exclusion group.
+pub(crate) fn template_object<'a>(tpl: &'a Template, som: &str) -> Option<&'a Node> {
let path = som_to_path(som);
let ((root, _), rest) = path.split_first()?;
if tpl.root.common.name.as_deref() != Some(root.as_str()) {
@@ -145,10 +191,53 @@ pub(crate) fn template_field<'a>(tpl: &'a Template, som: &str) -> Option<&'a Fie
_ => return None,
};
}
- match named_at(children, last, *last_index, 0)? {
- Node::Field(f) => Some(f),
- _ => None,
+ named_at(children, last, *last_index, 0)
+}
+
+/// Every field with an `id`, with the SOM path the layout gives it: the root subform's
+/// fields under the root, a page area's under the area (as master pages lay them out), each
+/// named object adding its name and its number among its siblings (repeating subforms as their
+/// first instance). Bounded by [`MAX_DEPTH`].
+pub(crate) fn fields_with_ids(tpl: &Template) -> Vec<(&str, String, &Field)> {
+ fn walk<'a>(nodes: &'a [Node], som: &str, out: &mut Vec<(&'a str, String, &'a Field)>, depth: usize) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ let sibs = SomIndices::of(nodes);
+ for (i, n) in nodes.iter().enumerate() {
+ let here = match &n.common().name {
+ Some(name) if som.is_empty() => format!("{name}[{}]", sibs.at(i)),
+ Some(name) => format!("{som}.{name}[{}]", sibs.at(i)),
+ None => som.to_string(),
+ };
+ match n {
+ Node::Field(f) => {
+ if let Some(id) = &f.common.id {
+ out.push((id.as_str(), here, f));
+ }
+ }
+ Node::Subform(s) => {
+ walk(&s.children, &here, out, depth + 1);
+ if let Some(ps) = &s.page_set {
+ for a in &ps.areas {
+ walk(&a.items, &a.name.as_ref().map(|n| format!("{n}[0]")).unwrap_or_default(), out, depth + 1);
+ }
+ }
+ }
+ Node::Area(a) => walk(&a.children, &here, out, depth + 1),
+ Node::ExclGroup(_) | Node::Draw(_) => {}
+ }
+ }
}
+ let mut out = Vec::new();
+ let root = tpl.root.common.name.as_ref().map(|n| format!("{n}[0]")).unwrap_or_default();
+ walk(&tpl.root.children, &root, &mut out, 1);
+ if let Some(ps) = &tpl.root.page_set {
+ for a in &ps.areas {
+ walk(&a.items, &a.name.as_ref().map(|n| format!("{n}[0]")).unwrap_or_default(), &mut out, 1);
+ }
+ }
+ out
}
pub(super) fn path_som(path: &DataPath) -> String {
diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs
index 8042e774fe..3fb8b3ca97 100644
--- a/crates/xfa/src/data/values.rs
+++ b/crates/xfa/src/data/values.rs
@@ -8,7 +8,8 @@ pub enum FieldData {
Text(String),
/// Checked or not.
Check(bool),
- /// The selected button's on value, if any.
+ /// The selected button, if any: written from its on-state name (the data gets its item, see
+ /// `radio_dataset_value`), read as the data's item, which is its export value.
Radio(Option),
/// Nothing to write (buttons, signatures).
None,
@@ -33,28 +34,90 @@ fn som_of(doc: &Document, f: &FieldDatum) -> String {
doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone())
}
+/// Each button of a radio group, in `/Kids` order: its (`/PCOn` item, on-state name, `/Opt`
+/// entry). A group without `/Kids` is its own one widget.
+fn radio_widgets(doc: &Document, f: &FieldDatum) -> Vec<(Option, Option, Option)> {
+ let Some(group) = doc.get(f.obj).as_dict().cloned() else { return Vec::new() };
+ let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]);
+ // One export value per widget, as the field's `/Opt` lists them (an entry may be an
+ // `[export display]` pair, as a choice field's are).
+ let exports: Option>> = group
+ .get(b"Opt")
+ .map(|o| doc.resolve(o))
+ .and_then(|o| o.as_array().cloned())
+ .map(|opt| {
+ opt.iter()
+ .map(|o| match &*doc.resolve(o) {
+ Object::Array(pair) => pair.first().and_then(|e| doc.resolve(e).as_string().map(|s| s.to_text())),
+ other => other.as_string().map(|s| s.to_text()),
+ })
+ .collect()
+ })
+ .filter(|opt: &Vec>| opt.len() == kids.len());
+ kids.iter()
+ .enumerate()
+ .filter_map(|(i, kid)| {
+ let widget = doc.resolve(kid).as_dict().cloned()?;
+ let original = text_of(doc, &widget, b"PCOn");
+ let state = widget
+ .get(b"AP")
+ .map(|ap| doc.resolve(ap))
+ .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned()))
+ .and_then(|n| doc.resolve(&n).as_dict().cloned())
+ .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off"));
+ let export = exports.as_ref().and_then(|e| e.get(i).cloned().flatten());
+ Some((original, state, export))
+ })
+ .collect()
+}
+
+/// An on-state name's bytes in the spelling a radio group's AcroForm value has, so the two can
+/// be compared: UTF-8 names read as themselves, other bytes (Shift-JIS states) and `#` are
+/// written `#XX`, as in PDF name syntax. This is `pdfcraft_forms::name_text`, which this crate
+/// can't depend on; the value saving gets is read through it, and the state opening returns is
+/// written back through its inverse, `pdfcraft_forms::name_bytes`.
+/// The engine checks that the two spell names alike (`xfa_and_forms_spell_names_alike`).
+pub fn name_text(bytes: &[u8]) -> String {
+ let escape = |b: u8| format!("#{b:02X}");
+ match std::str::from_utf8(bytes) {
+ Ok(s) => s.replace('#', "#23"),
+ Err(_) => bytes.iter().map(|&b| if (0x21..=0x7e).contains(&b) && b != b'#' { char::from(b).to_string() } else { escape(b) }).collect(),
+ }
+}
+
/// The datasets text for a radio group. The AcroForm value is an appearance-state name
/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item.
-fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String {
+/// Without it, a group with one `/Opt` entry per widget exports that entry: its state names may
+/// be positions (`/0`, `/1`; ISO 32000-2 Table 230), and the data holds the item (`1`, `2`).
+/// `sel` is the selected on-state name; with none selected the text is empty.
+/// [`radio_state_for_item`] reads it back.
+pub fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String {
let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else {
return String::new();
};
- let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() };
- let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]);
- for kid in kids {
- let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue };
- let original = text_of(doc, &widget, b"PCOn");
- let state = widget
- .get(b"AP")
- .map(|ap| doc.resolve(ap))
- .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned()))
- .and_then(|n| doc.resolve(&n).as_dict().cloned())
- .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off"));
- if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) {
- return original.unwrap_or_else(|| selected.to_string());
- }
+ let widgets = radio_widgets(doc, f);
+ // `sel` is an on-state name, so every widget's state is compared before any `/PCOn`: with
+ // states `/0` `/1` and items `1` `2`, the state `1` is the second widget, not the first
+ // one's item.
+ let by_state = widgets.iter().find(|(_, state, _)| state.as_deref() == Some(selected));
+ let by_item = || widgets.iter().find(|(original, _, _)| original.as_deref() == Some(selected));
+ match by_state.or_else(by_item) {
+ Some((original, _, export)) => original.clone().or_else(|| export.clone()).unwrap_or_else(|| selected.to_string()),
+ None => selected.to_string(),
}
- selected.to_string()
+}
+
+/// The on-state name of the radio button whose item is `item` in the datasets, as
+/// [`radio_dataset_value`] writes it: the first button whose `/PCOn`, else its `/Opt` entry,
+/// else its on-state name, equals `item`. So a saved selection reopens on the same button even
+/// when one button's `/PCOn` equals another's `/Opt` entry. `None` when no button writes `item`
+/// (data from elsewhere); the caller then reads it as an export value or state name.
+pub fn radio_state_for_item(doc: &Document, f: &FieldDatum, item: &str) -> Option {
+ radio_widgets(doc, f).into_iter().find_map(|(original, state, export)| {
+ let state = state?;
+ let written = original.or(export).unwrap_or_else(|| state.clone());
+ (written == item).then_some(state)
+ })
}
/// The on and off item values of a check box (`/PCItems`), or the widget's on state.
diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs
index 8f6e0f7ee8..0f6cc3a696 100644
--- a/crates/xfa/src/fixtures.rs
+++ b/crates/xfa/src/fixtures.rs
@@ -144,6 +144,43 @@ pub fn static_shell(data: &str) -> Vec {
assemble(objs)
}
+/// A static XFA form with one exclusion group `form1[0].page1[0].lang[0]` (items `1` English,
+/// `2` French) bound to the data by name, laid out as an AcroForm radio group whose `/Opt` holds
+/// the items and whose on states are the buttons' positions `/0` and `/1` (ISO 32000-2 Table 230),
+/// as public static forms write it. Its datasets hold `data` (the children of `xfa:data`).
+pub fn static_radio_shell(data: &str) -> Vec {
+ radio_shell(data, None)
+}
+
+/// [`static_radio_shell`] whose buttons also carry `/PCOn`, the item PdfCraft writes to the data
+/// for the selected button ahead of its `/Opt` entry.
+pub fn static_radio_shell_with_pcon(data: &str, pcon: [&str; 2]) -> Vec {
+ radio_shell(data, Some(pcon))
+}
+
+fn radio_shell(data: &str, pcon: Option<[&str; 2]>) -> Vec {
+ let pc = |i: usize| pcon.and_then(|p| p.get(i).map(|v| format!(" /PCOn ({v})"))).unwrap_or_default();
+ let xdp = format!(
+ "1 2 {data} "
+ );
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [9 0 R 10 0 R] >>".to_vec(),
+ b"<< /Length 0 >>\nstream\n\nendstream".to_vec(),
+ format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(),
+ b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(),
+ b"<< /T (page1[0]) /Parent 6 0 R /Kids [8 0 R] >>".to_vec(),
+ b"<< /FT /Btn /Ff 49152 /T (lang[0]) /Parent 7 0 R /Opt [(1) (2)] /V /Off /Kids [9 0 R 10 0 R] >>".to_vec(),
+ format!("<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [72 700 84 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >>{} /AP << /N << /0 11 0 R /Off 11 0 R >> >> >>", pc(0))
+ .into_bytes(),
+ format!("<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [144 700 156 712] /P 3 0 R /AS /Off /MK << /BC [0 0 0] >>{} /AP << /N << /1 11 0 R /Off 11 0 R >> >> >>", pc(1))
+ .into_bytes(),
+ b"<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream".to_vec(),
+ ];
+ assemble(objs)
+}
+
/// A static XFA form of `n` text fields `form1[0].page1[0].f[0]` on one page, whose values
/// (`v`) are only in its datasets, as when another viewer filled it in. The first field is
/// read-only.
@@ -201,6 +238,30 @@ fn assemble(objs: Vec>) -> Vec {
out
}
+/// A form with `layout:ready` scripts: the root subform reads the page count and turns field
+/// highlighting on (as forms do with `app.runtimeHighlight`), `stamp` takes a value, `seen` records the highlight its script
+/// reads after the root's, and `pageNo` numbers its page (left to the layout). `note` has a
+/// `form:ready` script, a different event.
+pub fn layout_ready_template() -> String {
+ r##"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"##
+ .to_string()
+}
+
/// A hidden subform `details` a script may show, holding a named field and three unnamed
/// objects: a caption, an old caption the form keeps hidden, and a hidden subform. Unnamed
/// objects have no SOM name of their own, so showing `details` must leave the hidden ones hidden.
@@ -565,6 +626,49 @@ pub fn date_alternatives_template() -> String {
.to_string()
}
+/// Date fields with each kind of picture: `display` a display picture only, `edit` an edit and a
+/// display picture, `either` an edit picture of two alternatives, `bare` none.
+pub fn date_pictures_template() -> String {
+ r##"
+
+
+
+
+ date{MM/DD/YYYY}
+ date{MM/DD/YYYY} date{MM/DD/YYYY}
+ date{YYYYMMDD}|date{DDMMMYY} date{YYYYMMDD}
+
+
+
+
+"##
+ .to_string()
+}
+
+/// Two signature boxes: `sign`, whose preSign script shows a notice, fills `when` and locks it, and
+/// whose postSign script fills `after`; and `plain`, with no signing scripts.
+pub fn signing_events_template() -> String {
+ r##"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"##
+ .to_string()
+}
+
/// A form of `sections` sections of `rows` rows, each row with a calculated total (half in
/// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections
/// calculate scripts, every one reading values the earlier ones settled.
@@ -739,3 +843,87 @@ if (this.rawValue == "1") {
"##
.to_string()
}
+
+/// A form whose text field `probe` counts its enters in `enters` and its exits in `exits`, whose
+/// text field `quiet` only shows a message when it is left, and whose radio button `size.large`
+/// counts its enters in `picks` and its exits in `drops` (each button of a radio group is a field
+/// with scripts of its own), whose text field `hint` clears its "Type here" when it gains focus, and
+/// whose text field `relay` puts that "Type here" back in `hint` when it loses focus.
+pub fn focus_template() -> String {
+ let js = "application/x-javascript";
+ let count = |name: &str| format!(r#""#);
+ let counter = |name: &str| {
+ format!(r#"0 "#)
+ };
+ format!(
+ r##"
+
+
+
+ {} {}
+
+
+ S
+ L {} {}
+
+ {}{}{}{}
+ Type here
+
+
+
+
+"##,
+ count("enters"),
+ count("exits"),
+ count("picks"),
+ count("drops"),
+ counter("exits"),
+ counter("enters"),
+ counter("picks"),
+ counter("drops")
+ )
+}
+
+/// A form whose two draws link their pictures instead of embedding them: `.\logo.png`, which the
+/// PDF keeps in its `XFAImages` name tree (a [`tiny_png`], under a `Kids` node, as producers
+/// split the tree), and `.\missing.png`, which it doesn't (XFA 3.3 pp. 257–258).
+pub fn linked_image_shell() -> Vec {
+ linked_image_shell_with(b"<< /Kids [7 0 R] >>", br"<< /Limits [(.\\logo.png) (.\\logo.png)] /Names [(.\\logo.png) 8 0 R] >>")
+}
+
+/// [`linked_image_shell`] with its own name tree: `root` is object 6 (the tree the catalog names),
+/// `kid` object 7, and object 8 the PNG.
+pub fn linked_image_shell_with(root: &[u8], kid: &[u8]) -> Vec {
+ linked_image_shell_holding(root, kid, TINY_PNG)
+}
+
+/// [`linked_image_shell_with`], object 8 a stream holding `picture` (a [`tiny_png`] there).
+pub fn linked_image_shell_holding(root: &[u8], kid: &[u8], picture: &[u8]) -> Vec {
+ let xdp = r##"
+
+
+
+
+
+
+
+
+
+
+
+"##;
+ let mut png = format!("<< /Length {} >>\nstream\n", picture.len()).into_bytes();
+ png.extend_from_slice(picture);
+ png.extend_from_slice(b"\nendstream");
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> /Names << /XFAImages 6 0 R >> >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(),
+ b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(),
+ format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(),
+ root.to_vec(),
+ kid.to_vec(),
+ png,
+ ];
+ assemble(objs)
+}
diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs
index eab26c6dd4..6eac6757e0 100644
--- a/crates/xfa/src/layout.rs
+++ b/crates/xfa/src/layout.rs
@@ -13,10 +13,10 @@ use std::sync::Arc;
use base64::Engine;
use crate::XfaError;
-use crate::data::{DataNode, data_date_letters, data_path, data_to_iso, date_letters, is_on, iso_to_pattern};
+use crate::data::{DataNode, DataPath, data_date_letters, data_path, data_to_iso, date_letters, is_on, iso_to_pattern};
use crate::model::{
Access, Area, Border, Common, Draw, ExclGroup, Field, Font, HAlign, Layout, Margin, Node, Occur, PageArea, PageRole, Para, Placement, Presence,
- Rect, Script, SomIndices, Stroke, Subform, Template, Ui, VAlign, Value,
+ Rect, Script, SomIndices, Stroke, Subform, Template, Ui, VAlign, Value, Views,
};
use crate::text::{self, Block, Face, Span};
@@ -28,9 +28,9 @@ mod items;
mod measure;
mod pages;
-use common::collect_values;
pub(crate) use common::date_pattern;
pub use common::formatted_value;
+use common::{collect_names, collect_values};
pub(crate) use items::UNDERLINE_DROP;
pub use items::{Action, BorderShape, Form, Item, Page, Rounded, Turned, Widget, WidgetBorder, WidgetKind};
@@ -43,8 +43,26 @@ const MAX_DEPTH: usize = 64;
/// Most size measurements of one layout. Measurements of containers are memoized, so a real
/// form stays far below this; it bounds the work a hostile template can cause.
const MAX_MEASURES: usize = 2_000_000;
-/// Most initial instances of one repeating subform.
-const MAX_INSTANCES: usize = 50;
+/// Most characters the draw phase lays out in one layout, each pass counted. A draw re-measures
+/// overflowing text up to six times to shrink it, and a repeating subform draws its text once
+/// per instance, so large text drawn many times would otherwise cost seconds; a real form's
+/// draws stay far below this. Reduced under test so the guard is exercised without laying out
+/// hundreds of megabytes of text.
+#[cfg(not(test))]
+const MAX_DRAW_CHARS: usize = 256 << 20;
+#[cfg(test)]
+const MAX_DRAW_CHARS: usize = 1 << 20;
+/// Most instances of one repeating subform laid out (a row the data adds, or the template's
+/// initial count). `occur max="-1"` sets no upper boundary (XFA 3.3, "The occur element",
+/// p. 786), so this only bounds the work of a hostile or runaway form: a long list or table
+/// is laid out whole up to here, and the rows past it are reported ([`Layouter::warn_truncated`]).
+/// It matches the largest `occur` count the parser reads and the most rows one script run may
+/// add (`MAX_ADDED` in pdfcraft-js). Laying out and listing the fields grows faster than the
+/// row count (one text field a row: 1,000 rows open in about 3 s in a debug build, 2,000 in
+/// about 13 s), so a much larger bound would let one list stall the app. The layout budgets
+/// ([`MAX_MEASURES`], [`MAX_ITEMS`], [`MAX_PAGES`]) still bound the form as a whole, however
+/// its repeats nest.
+pub(crate) const MAX_INSTANCES: usize = 1_000;
/// Largest picture taken from the data (decoded bytes), as for template pictures.
const MAX_DATA_IMAGE: usize = 32 << 20;
/// Most picture bytes one layout keeps, all copies together. Each laid-out picture holds its
@@ -81,8 +99,9 @@ impl Ctx {
}
}
-/// (address of a subform or area in the template, available width bits, depth).
-type MeasureKey = (usize, u64, usize);
+/// (address of a subform or area in the template, available width bits, depth, SOM path of the
+/// instance measured when data drives the layout).
+type MeasureKey = (usize, u64, usize, String);
/// A cached size, with the inherited text defaults it was measured with.
struct Measured {
@@ -116,6 +135,10 @@ struct Frame {
/// the area's bottom (a table row taller than an area, an overflow leader and the row after it
/// that don't fit together). The border is left out ([`Layouter::finish_frame`]).
lost: bool,
+ /// A piece of a field being split sits in the part on this content area, whether or not it
+ /// drew anything there (a widget with no border or fill draws nothing below the break): the
+ /// part counts as holding something, as one that got items does.
+ held: bool,
}
struct Cursor {
@@ -127,6 +150,15 @@ struct Cursor {
placed: bool,
}
+/// An empty field a page break is splitting ([`Layouter::splitting`]): its widget's SOM path, its
+/// box where it was drawn, and its pieces so far (page index, the page's first item of the piece,
+/// the piece's box).
+struct Splitting {
+ som: String,
+ rect: Rect,
+ pieces: Vec<(usize, usize, Rect)>,
+}
+
struct Layouter<'t> {
tpl: &'t Template,
data: Option<&'t DataNode>,
@@ -136,8 +168,13 @@ struct Layouter<'t> {
pages: Vec,
cur: Option,
names: HashMap,
+ /// Every PDF field name handed out so far.
+ taken: HashSet,
+ /// The names the template's fields, groups and groups' members take as written
+ /// ([`common::collect_names`]), which a made-up `_2`, `_3` name must not take.
+ template_names: HashSet,
/// Field values by `id`, for embedded fields in rich text.
- values: HashMap,
+ values: HashMap,
warnings: Vec,
warned: HashSet,
fields: usize,
@@ -149,12 +186,25 @@ struct Layouter<'t> {
measured: RefCell>,
/// Measurements made so far (see [`MAX_MEASURES`]).
measures: Cell,
+ /// Characters the draw phase has laid out, each pass counted (see [`MAX_DRAW_CHARS`]).
+ drawn_chars: Cell,
/// The fields and draws of the block being placed: (first item, one past the last, top,
/// bottom) on the last page. A page break keeps each whole (see `split_block`).
leaves: Vec<(usize, usize, f64, f64)>,
+ /// The empty field a page break is splitting (see `flow_block` and `split_block`).
+ splitting: Option,
+ /// The items `draw_field` drew for the border of the field drawn last: (first, one past the last).
+ field_border: Option<(usize, usize)>,
+ /// Where what is being drawn shows, as the objects it is inside say ([`Common::views`]).
+ views: Views,
/// The flowed subforms being laid out, outermost first: a break between their children
/// closes their parts on one content area and opens new ones on the next.
frames: Vec ,
+ /// What scripts set on page-master content, applied to each page's copy by its own SOM path.
+ overrides: Option<&'t crate::script::Overrides>,
+ /// The pages' copies of master content that overrides changed, kept for the whole layout so
+ /// their addresses stay unique (the measurement cache keys on them).
+ master_copies: Vec>>,
}
/// How many instances of a repeating subform to lay out: what the data holds once it holds
@@ -167,6 +217,17 @@ pub fn instance_count(occur: &Occur, from_data: usize) -> usize {
/// Lay a template out, with the values and repeat counts of `data` (the `xfa:data` element).
pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result
+Tip num{z9}
+Hi
+ "#,
+ );
+ let (f, warnings) = first_field(&xml);
+ assert!(warnings.iter().all(|w| w != PROPS_CUT_WARNING), "{warnings:?}");
+ let mut props = (*f.common.props).clone();
+ assert!(props.attrs.contains(&("w".into(), "2in".into())) && props.attrs.contains(&("access".into(), "readOnly".into())), "{props:?}");
+ let tags: Vec<&str> = props.elements.iter().map(|p| p.tag.as_str()).collect();
+ // Not events. The caption is read whole (its label's text), the object's own value only
+ // as its content element's tag.
+ assert_eq!(tags, ["ui", "border", "caption", "validate", "assist", "format", "value", "keep"]);
+ let caption = props.elements.remove(2);
+ assert_eq!(caption.children[0].children[0].text.as_deref(), Some("Label"));
+ let ui = &props.elements[0];
+ assert_eq!(ui.children.len(), 1, "extras left out: {ui:?}");
+ assert_eq!(ui.children[0].tag, "choiceList");
+ assert_eq!(ui.children[0].children[0].children[0].children[0].attrs, [("value".to_string(), "9,9,9".to_string())]);
+ let validate = &props.elements[2];
+ assert_eq!(validate.children[0].children[0].text.as_deref(), Some("Required"));
+ // A validation's script is kept (forms check whether there is one); event scripts are not.
+ assert_eq!((validate.children[1].tag.as_str(), validate.children[1].text.as_deref()), ("script", Some("true")));
+ assert!(props.elements.iter().all(|p| p.tag != "event"));
+ assert_eq!(props.elements[3].children[0].text.as_deref(), Some("Tip"));
+ assert_eq!(props.elements[4].children[0].text.as_deref(), Some("num{z9}"));
+ // The value's content element, not its data.
+ let value = &props.elements[5];
+ assert_eq!((value.children.len(), value.children[0].tag.as_str(), value.children[0].children.len()), (1, "exData", 0));
+ assert_eq!(value.children[0].text, None);
+}
+
+/// A hostile template: thousands of edges, deep nesting, a huge attribute and many attributes.
+/// What scripts are given stays within the per-object limits, and a warning says so once.
+#[test]
+fn hostile_property_trees_are_cut_to_the_limits() {
+ let edges = " ".repeat(5_000);
+ let deep = format!("{}{}", "".repeat(200), " ".repeat(200));
+ let big = "x".repeat(200_000);
+ let attrs: String = (0..500).map(|i| format!(" a{i}=\"{i}\"")).collect();
+ // Many lists of many entries: only two lists are kept (a field has two at most), each to the
+ // list's own entry limit, and the lists count against the budget like any property.
+ let entries = "e ".repeat(1_200);
+ let lists = format!("{entries} ").repeat(40);
+ for body in [
+ format!("{edges} "),
+ format!("{deep} "),
+ format!(" "),
+ format!(" "),
+ format!(" ", attrs.trim()),
+ format!("{lists} "),
+ ] {
+ let (f, warnings) = first_field(&template(&format!("{body}{body}")));
+ assert_eq!(warnings.iter().filter(|w| *w == PROPS_CUT_WARNING).count(), 1, "once: {warnings:?}");
+ let props = &f.common.props;
+ // The first two lists' entries are the lists' own (up to 1,000 each), not property
+ // elements; any further list, entries and all, counts against the budget.
+ let mut lists = 0;
+ let elements: usize = props
+ .elements
+ .iter()
+ .map(|p| match p.tag.as_str() {
+ "items" if lists < 2 => {
+ lists += 1;
+ assert!(p.children.len() <= 1_000, "entries past the list's limit");
+ 1
+ }
+ _ => count(p),
+ })
+ .sum();
+ assert!(elements <= MAX_OBJECT_PROPS, "{elements}");
+ assert!(props.elements.iter().map(depth).max().unwrap_or(0) <= 9, "deep");
+ let bytes = |p: &Prop| -> usize { p.attrs.iter().map(|(k, v)| k.len() + v.len()).sum::() + p.tag.len() };
+ fn all<'a>(p: &'a Prop, out: &mut Vec<&'a Prop>) {
+ out.push(p);
+ p.children.iter().for_each(|c| all(c, out));
+ }
+ let mut flat = Vec::new();
+ props.elements.iter().for_each(|p| all(p, &mut flat));
+ let total: usize = flat.iter().map(|p| bytes(p)).sum::() + props.attrs.iter().map(|(k, v)| k.len() + v.len()).sum::();
+ assert!(total <= MAX_OBJECT_PROP_BYTES, "{total}");
+ assert!(flat.iter().all(|p| p.attrs.len() <= 64) && props.attrs.len() <= 64);
+ }
+}
+
+/// Every instance of a repeating subform, and every live form built, points at the template's
+/// property tree: nothing is copied per instance or per build.
+#[test]
+fn the_live_form_shares_the_templates_property_trees() {
+ let xml = template(
+ r#" "#,
+ );
+ let (t, _) = parse(&xml).expect("parses");
+ let a = form_tree(&t, None, &Overrides::default());
+ let b = form_tree(&t, None, &Overrides::default());
+ let rows: Vec<_> = a.root.children.iter().filter(|c| c.name == "row").collect();
+ assert_eq!(rows.len(), 3);
+ let Some(Node::Subform(row)) = t.root.children.first() else { panic!("a row subform") };
+ for r in &rows {
+ assert!(Arc::ptr_eq(&r.props, &row.common.props));
+ assert!(Arc::ptr_eq(&r.children[0].props, &rows[0].children[0].props));
+ }
+ assert_eq!(rows[0].props.elements[0].tag, "occur");
+ assert!(Arc::ptr_eq(&a.root.children[0].props, &b.root.children[0].props));
+}
+
+/// A property element whose children are all left out (an `extras`, a `script`) has only its
+/// own text, not theirs, and nothing is reported cut.
+#[test]
+fn a_property_keeps_its_own_text_not_that_of_children_left_out() {
+ let big = "x".repeat(20_000);
+ let xml = template(&format!(
+ r#"{big} Tiphidden "#
+ ));
+ let (f, warnings) = first_field(&xml);
+ assert!(warnings.iter().all(|w| w != PROPS_CUT_WARNING), "{warnings:?}");
+ let tags: Vec<(&str, Option<&str>)> = f.common.props.elements.iter().map(|p| (p.tag.as_str(), p.text.as_deref())).collect();
+ assert_eq!(tags, [("font", None), ("assist", None), ("breakBefore", None), ("validate", None), ("border", None)]);
+ assert_eq!(f.common.props.elements[1].children[0].text.as_deref(), Some("Tip"));
+ // A validation's script keeps its text, outside the budget: the border after it is kept.
+ assert_eq!(f.common.props.elements[3].children[0].text.as_ref().map(|t| t.len()), Some(20_000));
+ assert_eq!(f.common.props.elements[4].children[0].attrs, [("thickness".to_string(), "2pt".to_string())]);
+}
+
+/// A validation keeps its first script only (it has one, XFA 3.3 p. 880), and that script's
+/// text only within the script engine's limit on a script.
+#[test]
+fn a_validation_keeps_its_first_script_within_the_script_limit() {
+ let xml = template(r#" "#);
+ let (f, _) = first_field(&xml);
+ let scripts: Vec> = f.common.props.elements[0].children.iter().map(|s| s.text.as_deref()).collect();
+ assert_eq!(scripts, [Some("first")]);
+ let huge = "x".repeat(300 * 1024);
+ let (f, warnings) =
+ first_field(&template(&format!(r#" "#)));
+ assert_eq!(f.common.props.elements[0].children[0].text, None);
+ assert_eq!(f.common.props.elements[1].attrs, [("typeface".to_string(), "Arial".to_string())]);
+ assert!(warnings.iter().any(|w| w == PROPS_CUT_WARNING), "{warnings:?}");
+}
+
+/// A field with a third `` (a public form carries an empty saved list twice) keeps it
+/// too, as an ordinary property, with no warning.
+#[test]
+fn a_third_items_list_is_kept_as_a_property() {
+ let xml =
+ template(r#"0 "#);
+ let (f, warnings) = first_field(&xml);
+ assert!(warnings.iter().all(|w| w != PROPS_CUT_WARNING), "{warnings:?}");
+ let lists: Vec = f.common.props.elements.iter().filter(|p| p.tag == "items").map(|p| p.children.len()).collect();
+ assert_eq!(lists, [0, 0, 1]);
+}
+
+/// A calculation is kept with its override, message and first script (XFA 3.3 p. 641: one
+/// script), as a validation is; the field still calculates with that script.
+#[test]
+fn a_calculation_is_kept_with_its_override_message_and_script() {
+ let xml = template(
+ r#"Computed "#,
+ );
+ let (f, _) = first_field(&xml);
+ let calc = f.common.props.elements.first().expect("the calculation");
+ assert_eq!((calc.tag.as_str(), calc.attrs.as_slice()), ("calculate", [("override".to_string(), "warning".to_string())].as_slice()));
+ let kids: Vec<(&str, Option<&str>)> = calc.children.iter().map(|k| (k.tag.as_str(), k.text.as_deref())).collect();
+ assert_eq!(kids, [("message", None), ("script", Some("a + b"))]);
+ assert_eq!(calc.children[0].children[0].text.as_deref(), Some("Computed"));
+ assert_eq!(f.calculate.as_ref().map(|s| s.text.as_str()), Some("a + b"));
+}
+
+/// A caption's rich text is kept as its words, and a field's items are kept whole, each entry
+/// with its text as the list shows it, outside the object's property budget (a long list of
+/// countries must not crowd out the other properties).
+#[test]
+fn a_caption_s_rich_text_and_a_list_s_items_are_kept() {
+ let entries: String = (0..400).map(|i| format!("Country {i} ")).collect();
+ let xml = template(&format!(
+ r#"Date of birth :
{entries} 1 "#
+ ));
+ let (f, warnings) = first_field(&xml);
+ assert!(warnings.iter().all(|w| w != PROPS_CUT_WARNING), "{warnings:?}");
+ let tags: Vec<&str> = f.common.props.elements.iter().map(|p| p.tag.as_str()).collect();
+ assert_eq!(tags, ["caption", "items", "items", "border"]);
+ let exdata = &f.common.props.elements[0].children[0].children[0];
+ assert_eq!((exdata.tag.as_str(), exdata.text.as_deref()), ("exData", Some("Date of birth:")));
+ let shown = &f.common.props.elements[1];
+ assert_eq!((shown.children.len(), shown.children[399].text.as_deref()), (400, Some("Country 399")));
+ assert_eq!(f.common.props.elements[2].attrs, [("save".to_string(), "1".to_string())]);
+ assert_eq!(f.items.len(), 400, "the list shows the same entries");
+}
+
+/// A template handed to `parse` directly is held to what a document's packets may come to as text:
+/// one byte past it is refused before any of it is parsed (the error giving its size in bytes), and
+/// a template of exactly that size, as a UTF-16 packet the reader accepted can become, is parsed.
+#[test]
+fn a_template_past_the_packet_limit_is_refused_before_parsing() {
+ let limit = crate::packets::MAX_TEMPLATE_TEXT;
+ assert!(limit > crate::packets::MAX_XDP, "room for a UTF-16 packet grown into UTF-8");
+ let head = r#""#;
+ let tail = " ";
+ let xml = format!("{head}{}{tail}", "a".repeat(limit + 1 - head.len() - tail.len()));
+ assert_eq!(xml.len(), limit + 1);
+ match crate::parse(&xml) {
+ Err(crate::XfaError::TooLarge(m)) => assert!(m.contains(&format!("{} bytes", limit + 1)), "{m}"),
+ other => panic!("{other:?}"),
+ }
+ let at = format!("{head}{}{tail}", "a".repeat(limit - head.len() - tail.len()));
+ assert!(crate::parse(&at).is_ok(), "a template at the limit is parsed");
+}
diff --git a/crates/xfa/src/proto.rs b/crates/xfa/src/proto.rs
new file mode 100644
index 0000000000..91d672f2ea
--- /dev/null
+++ b/crates/xfa/src/proto.rs
@@ -0,0 +1,529 @@
+//! Prototypes (XFA 3.3, chapter 6, "Prototypes"): an element with `use` or `usehref` inherits
+//! the attributes, data content and children of the element it names, and overrides what it
+//! declares itself. Designer's style sheets work this way: a field names its style, and its
+//! size, border, font and caption placement are the style's.
+//!
+//! [`expand`] writes the template again with every reference resolved, so the parser reads
+//! plain elements. Prototypes are resolved once, when the template is loaded, as the
+//! specification says ("Prototypes", the note before "Defining Prototypes").
+//!
+//! Rules ("Overriding Prototype Properties"): every attribute but `id`, `name`, `use` and
+//! `usehref` is inherited unless the element has its own; the prototype's data content is
+//! inherited unless the element has non-empty content of its own; children are matched by
+//! class and then by position among that class, and a matched prototype child is in turn the
+//! prototype of the element's child. A `ui` or `value` holds one alternative (one widget, one
+//! kind of content), so the nearest layer's choice is kept and only that one is merged; rich
+//! text (`exData`) is content and is taken whole from the nearest layer that has any.
+//!
+//! Only references into the same template are followed: `usehref` with no document (or the
+//! legacy `.`), as `#id` or `#som(expression)`, and `use` as `#id` or a SOM expression. A
+//! reference to another document, to nothing, or back into itself is left out, with a warning.
+//! The SOM subset is the one Designer writes: `$template.#subform.styles.Heading[1]`, each
+//! step a name or a `#class` with an optional index, looking through `proto` elements and
+//! nameless subforms.
+
+use std::collections::{HashMap, HashSet};
+
+use roxmltree::{Node, NodeId};
+
+/// Deepest element nesting written. A prototype may contain a reference to an element that
+/// contains it (Example 6.8): such loops are cut, and this bounds what remains.
+const MAX_DEPTH: usize = 256;
+/// Most layers (an element and the prototypes it inherits from) merged into one element.
+const MAX_LAYERS: usize = 32;
+/// Most bytes written: at least `MIN_OUTPUT`, at most `GROWTH` times the template and never
+/// more than `MAX_OUTPUT`. Each reference repeats its prototype, so a hostile template could
+/// otherwise grow without bound.
+const MAX_OUTPUT: usize = 64 << 20;
+const MIN_OUTPUT: usize = 1 << 20;
+const GROWTH: usize = 4;
+/// Longest SOM expression evaluated, in bytes. Designer writes some tens of bytes
+/// (`$template.#subform.designer__stylesheet.Heading[1]`); a longer one names nothing.
+const MAX_SOM: usize = 1_024;
+/// Most elements looked at to resolve all SOM references together.
+const MAX_SEARCH: usize = 2_000_000;
+/// Most children, attributes and namespaces looked at to merge all the layers together. Each
+/// reference looks through its prototype's children again, also those that write nothing
+/// (`proto`, and the `ui` and `value` alternatives not chosen), so the output budget alone does
+/// not bound this. Real forms cost one unit for every 5.5 to 36 bytes written (the largest
+/// measured, a 14 MB template, costs 2.5 million), so writing as much as the 64 MB output
+/// budget allows costs some 12 million: this keeps a margin of five over that, and stops a
+/// hostile template after about a second.
+const MAX_WORK: usize = 64_000_000;
+/// The namespace the `xml` prefix is always bound to.
+const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
+
+/// The template written again with its prototype references resolved (`None` when it has
+/// none, or when resolving them would pass a budget: the template is then read as it is), and
+/// the warnings for what was left out.
+pub(crate) fn expand(template: Node) -> (Option, Vec) {
+ let referencing = |n: &Node| {
+ n.is_element() && (n.attribute("use").is_some_and(|u| !u.trim().is_empty()) || n.attribute("usehref").is_some_and(|u| !u.trim().is_empty()))
+ };
+ if !template.descendants().any(|n| referencing(&n)) {
+ return (None, Vec::new());
+ }
+ let limit = template.range().len().saturating_mul(GROWTH).clamp(MIN_OUTPUT, MAX_OUTPUT);
+ let mut w = Writer {
+ template,
+ out: String::new(),
+ limit,
+ failed: None,
+ ids: None,
+ refs: HashMap::new(),
+ soms: HashMap::new(),
+ search: MAX_SEARCH,
+ work: MAX_WORK,
+ active: HashMap::new(),
+ missing: false,
+ external: false,
+ looped: false,
+ layers_cut: false,
+ };
+ w.element(&[template], &Scope::new(), 0);
+ let mut warnings = Vec::new();
+ if w.external {
+ warnings.push("a prototype reference (usehref) points to another document; it is not followed".into());
+ }
+ if w.missing {
+ warnings.push("a prototype reference (use or usehref) names nothing of its kind in the template; the element is read without it".into());
+ }
+ if w.looped {
+ warnings.push("a prototype reference leads back to itself; the loop is cut there".into());
+ }
+ if w.layers_cut {
+ warnings.push(format!("a chain of prototypes is longer than {MAX_LAYERS}; the farthest are left out"));
+ }
+ match w.failed {
+ Some(why) => {
+ warnings.push(format!("the template's prototypes {why}; it is read without resolving them"));
+ (None, warnings)
+ }
+ None => (Some(w.out), warnings),
+ }
+}
+
+/// Namespace bindings in scope where an element is written: URI by prefix (`None` for the
+/// default namespace).
+type Scope<'a> = HashMap, &'a str>;
+
+struct Writer<'a, 'd> {
+ template: Node<'a, 'd>,
+ out: String,
+ limit: usize,
+ /// Why expanding stopped, for the warning.
+ failed: Option,
+ /// The template's elements by `id`, built on the first `#id` reference.
+ ids: Option>>,
+ /// The prototype each element names, once resolved: a prototype shared by many elements
+ /// is reached again from each of them, and its reference (of any length) is read once.
+ refs: HashMap>>,
+ /// SOM references already resolved, by their text.
+ soms: HashMap<&'a str, Option>>,
+ /// Elements left to look at for SOM references.
+ search: usize,
+ /// Children, attributes and namespaces left to look at while merging layers.
+ work: usize,
+ /// The elements being written around the current one: inheriting from one is a loop.
+ active: HashMap,
+ missing: bool,
+ external: bool,
+ looped: bool,
+ layers_cut: bool,
+}
+
+fn escape(s: &str, attribute: bool, out: &mut String) {
+ for c in s.chars() {
+ match c {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' if attribute => out.push_str("""),
+ '\n' if attribute => out.push_str("
"),
+ '\r' if attribute => out.push_str("
"),
+ '\t' if attribute => out.push_str(" "),
+ _ => out.push(c),
+ }
+ }
+}
+
+/// Containers a SOM step looks through: they have no name of their own.
+fn transparent(n: Node) -> bool {
+ n.tag_name().name() == "proto" || n.tag_name().name() == "subform" && n.attribute("name").is_none_or(str::is_empty)
+}
+
+/// Does `n` have content of its own: non-blank text, or (`elements`) any child element?
+fn has_content(n: &Node, elements: bool) -> bool {
+ n.children().any(|c| elements && c.is_element() || c.is_text() && c.text().is_some_and(|t| !t.trim().is_empty()))
+}
+
+/// In a `ui` or a `value`, is `c` one of the alternatives of which only one is meant?
+fn alternative(tag: &str, c: &Node) -> bool {
+ c.is_element() && (tag == "value" || tag == "ui" && !matches!(c.tag_name().name(), "picture" | "extras"))
+}
+
+impl<'a, 'd> Writer<'a, 'd> {
+ /// The element `n` names as its prototype. `usehref` wins over `use` when both are set.
+ fn reference(&mut self, n: Node<'a, 'd>) -> Option> {
+ if let Some(found) = self.refs.get(&n.id()) {
+ return *found;
+ }
+ let found = self.find_reference(n);
+ // A look-up a budget cut short is not kept: expanding has failed.
+ if self.failed.is_none() {
+ self.refs.insert(n.id(), found);
+ }
+ found
+ }
+
+ fn find_reference(&mut self, n: Node<'a, 'd>) -> Option> {
+ let set = |name: &str| n.attribute(name).map(str::trim).filter(|v| !v.is_empty());
+ let found = if let Some(href) = set("usehref") {
+ match href.split_once('#') {
+ Some((doc, fragment)) if matches!(doc.trim(), "" | ".") => {
+ let fragment = fragment.trim();
+ match fragment.strip_prefix("som(").and_then(|s| s.strip_suffix(')')) {
+ Some(expr) => self.som(expr.trim()),
+ None => self.by_id(fragment),
+ }
+ }
+ _ => {
+ self.external = true;
+ return None;
+ }
+ }
+ } else {
+ let r = set("use")?;
+ match r.strip_prefix('#') {
+ Some(id) => self.by_id(id.trim()),
+ // A SOM expression; the specification's own Example 6.5 writes a bare id.
+ None => self.som(r).or_else(|| self.by_id(r)),
+ }
+ };
+ match found {
+ // A budget ran out while looking: the template is read unresolved, with that warning.
+ _ if self.failed.is_some() => None,
+ Some(t) if t == n => {
+ self.looped = true;
+ None
+ }
+ Some(t) if t.tag_name().name() == n.tag_name().name() => Some(t),
+ _ => {
+ self.missing = true;
+ None
+ }
+ }
+ }
+
+ fn by_id(&mut self, id: &str) -> Option> {
+ let template = self.template;
+ let ids = self.ids.get_or_insert_with(|| {
+ let mut ids = HashMap::new();
+ for n in template.descendants().filter(Node::is_element) {
+ if let Some(id) = n.attribute("id") {
+ ids.entry(id).or_insert(n);
+ }
+ }
+ ids
+ });
+ ids.get(id).copied()
+ }
+
+ /// A SOM expression from the template down, such as `$template.#subform.styles.Heading[1]`.
+ fn som(&mut self, expr: &'a str) -> Option> {
+ if expr.len() > MAX_SOM {
+ return None;
+ }
+ if let Some(found) = self.soms.get(expr) {
+ return *found;
+ }
+ let found = self.resolve(expr);
+ // A look-up the budget cut short has not found nothing: it is not cached, and the
+ // template is read unresolved (`step` set `failed`).
+ if self.failed.is_none() {
+ self.soms.insert(expr, found);
+ }
+ found
+ }
+
+ fn resolve(&mut self, expr: &str) -> Option> {
+ let mut steps = expr.split('.').map(str::trim).peekable();
+ while steps.peek().is_some_and(|s| matches!(*s, "$template" | "template" | "template[0]" | "xfa" | "$xfa" | "xfa[0]")) {
+ steps.next();
+ }
+ let mut at = self.template;
+ for step in steps {
+ let (name, index) = match step.split_once('[') {
+ Some((name, index)) => (name.trim(), index.strip_suffix(']')?.trim().parse::().ok()?),
+ None => (step, 0),
+ };
+ if name.is_empty() || name == "#" {
+ return None;
+ }
+ let mut matches = Vec::new();
+ self.step(at, name, index, &mut matches, 0);
+ at = *matches.get(index)?;
+ }
+ (at != self.template).then_some(at)
+ }
+
+ /// The children of `at` called `name` (or of class `#name`), looking through nameless
+ /// containers, until the one at `index` is found.
+ fn step(&mut self, at: Node<'a, 'd>, name: &str, index: usize, out: &mut Vec>, depth: usize) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ for c in at.children().filter(Node::is_element) {
+ if out.len() > index || self.failed.is_some() {
+ return;
+ }
+ if self.search == 0 {
+ self.failed = Some(format!("need more than {MAX_SEARCH} elements looked at to resolve their SOM references"));
+ return;
+ }
+ self.search -= 1;
+ let hit = match name.strip_prefix('#') {
+ Some(class) => c.tag_name().name() == class,
+ None => c.attribute("name") == Some(name),
+ };
+ if hit {
+ out.push(c);
+ } else if transparent(c) {
+ self.step(c, name, index, out, depth + 1);
+ }
+ }
+ }
+
+ /// `n` and the prototypes it inherits from, nearest first.
+ fn chain(&mut self, n: Node<'a, 'd>, layers: &mut Vec>) {
+ let mut seen = HashSet::from([n.id()]);
+ layers.push(n);
+ let mut at = n;
+ while let Some(t) = self.reference(at) {
+ if !seen.insert(t.id()) || self.active.contains_key(&t.id()) {
+ self.looped = true;
+ return;
+ }
+ if layers.len() >= MAX_LAYERS {
+ self.layers_cut = true;
+ return;
+ }
+ layers.push(t);
+ at = t;
+ }
+ }
+
+ /// Take `n` from the work budget; past it, expanding stops.
+ fn charge(&mut self, n: usize) -> bool {
+ match self.work.checked_sub(n) {
+ Some(left) => {
+ self.work = left;
+ true
+ }
+ None => {
+ self.work = 0;
+ self.failed = Some(format!("need more than {MAX_WORK} children, attributes and namespaces looked at to merge them"));
+ false
+ }
+ }
+ }
+
+ /// Write the namespace bindings `layers` have that differ from those in `scope` around their
+ /// element, by prefix, and return the scope inside it: the nearest layer's binding of a prefix
+ /// wins, and a prefix bound around the element to another URI is bound again here (an XHTML
+ /// `body` inside a template whose default namespace is XFA's).
+ fn namespaces(&mut self, layers: &[Node<'a, 'd>], scope: &Scope<'a>) -> Scope<'a> {
+ let mut inner: Scope<'a> = scope.clone();
+ let mut decided: HashSet> = HashSet::new();
+ for layer in layers {
+ for ns in layer.namespaces() {
+ let (name, uri) = (ns.name(), ns.uri());
+ if name == Some("xml") || !decided.insert(name) || scope.get(&name) == Some(&uri) {
+ continue;
+ }
+ match name {
+ Some(name) => {
+ self.out.push_str(" xmlns:");
+ self.out.push_str(name);
+ }
+ None => self.out.push_str(" xmlns"),
+ }
+ self.out.push_str("=\"");
+ escape(uri, true, &mut self.out);
+ self.out.push('"');
+ inner.insert(name, uri);
+ }
+ }
+ inner
+ }
+
+ /// Write the attributes of the element `layers` make, inside the namespace scope `inner`.
+ fn attributes(&mut self, layers: &[Node<'a, 'd>], inner: &Scope<'a>) {
+ // Attributes: the nearest layer that has one wins; `id` and `name` are not inherited.
+ // Attributes are told apart by namespace URI and local name. One whose layer's prefix is
+ // bound here to another URI (the nearest layer's binding won) is written with a fresh
+ // prefix, declared on this element.
+ let mut written: HashSet<(Option<&str>, &str)> = HashSet::new();
+ let mut fresh: HashMap<&str, String> = HashMap::new();
+ let mut next = 0usize;
+ for (i, layer) in layers.iter().enumerate() {
+ for a in layer.attributes() {
+ let inherited = i > 0 && matches!(a.name(), "id" | "name");
+ let reference = a.namespace().is_none() && matches!(a.name(), "use" | "usehref");
+ if inherited || reference || !written.insert((a.namespace(), a.name())) {
+ continue;
+ }
+ let name = match a.namespace() {
+ None => a.name().to_string(),
+ Some(XML_NS) => format!("xml:{}", a.name()),
+ Some(uri) => match layer.lookup_prefix(uri).filter(|p| !p.is_empty()) {
+ Some(p) if inner.get(&Some(p)) == Some(&uri) => format!("{p}:{}", a.name()),
+ _ => {
+ let p = match fresh.get(uri) {
+ Some(p) => p.clone(),
+ None => {
+ // `next` only grows, and skips the prefixes in scope here,
+ // so it passes at most `inner.len()` of them.
+ let p = loop {
+ let p = format!("ns{next}");
+ next = next.saturating_add(1);
+ if !inner.contains_key(&Some(p.as_str())) {
+ break p;
+ }
+ };
+ self.out.push_str(" xmlns:");
+ self.out.push_str(&p);
+ self.out.push_str("=\"");
+ escape(uri, true, &mut self.out);
+ self.out.push('"');
+ fresh.insert(uri, p.clone());
+ p
+ }
+ };
+ format!("{p}:{}", a.name())
+ }
+ },
+ };
+ self.out.push(' ');
+ self.out.push_str(&name);
+ self.out.push_str("=\"");
+ escape(a.value(), true, &mut self.out);
+ self.out.push('"');
+ }
+ }
+ }
+
+ /// Write the element that `stack` makes together: `stack[0]` over `stack[1]` over …, each
+ /// an element of the same class. `scope` holds the namespaces already declared around it.
+ fn element(&mut self, stack: &[Node<'a, 'd>], scope: &Scope<'a>, depth: usize) {
+ if self.failed.is_some() {
+ return;
+ }
+ if depth > MAX_DEPTH {
+ self.failed = Some(format!("nest deeper than {MAX_DEPTH} elements"));
+ return;
+ }
+ if self.out.len() > self.limit {
+ self.failed = Some(format!("would make it larger than {} MB", self.limit >> 20));
+ return;
+ }
+ // Every layer may itself inherit from a prototype.
+ let mut layers = Vec::new();
+ for l in stack {
+ self.chain(*l, &mut layers);
+ if layers.len() >= MAX_LAYERS {
+ break;
+ }
+ }
+ // Charge what merging looks at before looking: each layer's children (counted no
+ // further than the budget left), attributes and namespaces, and the scope around it.
+ let mut cost = scope.len().saturating_add(layers.len());
+ for l in &layers {
+ let children = l.children().take(self.work.saturating_add(1)).count();
+ cost = cost.saturating_add(children).saturating_add(l.attributes().len()).saturating_add(l.namespaces().len());
+ }
+ if self.failed.is_some() || !self.charge(cost) {
+ return;
+ }
+ let Some(&top) = layers.first() else { return };
+ for l in &layers {
+ *self.active.entry(l.id()).or_insert(0) += 1;
+ }
+ let tag = top.tag_name().name();
+ let prefix =
+ |n: Node, ns: Option<&str>| ns.and_then(|ns| n.lookup_prefix(ns)).filter(|p| !p.is_empty()).map(|p| format!("{p}:")).unwrap_or_default();
+ let qname = format!("{}{tag}", prefix(top, top.tag_name().namespace()));
+ self.out.push('<');
+ self.out.push_str(&qname);
+ let mut inner = self.namespaces(&layers, scope);
+ // An element in no namespace inside a default namespace undeclares it.
+ if top.tag_name().namespace().is_none() && inner.get(&None).is_some_and(|u| !u.is_empty()) {
+ self.out.push_str(" xmlns=\"\"");
+ inner.insert(None, "");
+ }
+ self.attributes(&layers, &inner);
+ self.out.push('>');
+ // Rich text is taken whole from the nearest layer that has any.
+ let cut = if tag == "exData" { layers.iter().position(|l| has_content(l, true)).map_or(layers.len(), |i| i + 1) } else { layers.len() };
+ let merged = layers.get(..cut).unwrap_or(&layers);
+ // Data content: the nearest layer's that has any (empty content overrides nothing).
+ let text_layer = merged.iter().position(|l| has_content(l, false)).unwrap_or(0);
+ let chosen = if matches!(tag, "ui" | "value") {
+ merged.iter().find_map(|l| l.children().find(|c| alternative(tag, c)).map(|c| c.tag_name().name()))
+ } else {
+ None
+ };
+ // Prototypes take no part in the form; of the alternatives one is kept. A class left
+ // out is left out whole, so it need not be counted either.
+ let left_out = |c: &Node| c.tag_name().name() == "proto" || alternative(tag, c) && chosen != Some(c.tag_name().name());
+ // The deeper layers' children by (class, position among that class).
+ let index = |l: &Node<'a, 'd>| {
+ let mut counts: HashMap<&str, usize> = HashMap::new();
+ let mut by: HashMap<(&'a str, usize), Node<'a, 'd>> = HashMap::new();
+ for c in l.children().filter(|c| c.is_element() && !left_out(c)) {
+ let k = counts.entry(c.tag_name().name()).or_insert(0);
+ by.insert((c.tag_name().name(), *k), c);
+ *k += 1;
+ }
+ by
+ };
+ let below: Vec<_> = merged.iter().skip(1).map(index).collect();
+ let mut done: HashSet<(&str, usize)> = HashSet::new();
+ for (i, layer) in merged.iter().enumerate() {
+ let mut counts: HashMap<&str, usize> = HashMap::new();
+ for c in layer.children() {
+ if c.is_text() && i == text_layer {
+ escape(c.text().unwrap_or_default(), false, &mut self.out);
+ }
+ if !c.is_element() || left_out(&c) {
+ continue;
+ }
+ let ctag = c.tag_name().name();
+ let n = {
+ let k = counts.entry(ctag).or_insert(0);
+ *k += 1;
+ *k - 1
+ };
+ if !done.insert((ctag, n)) {
+ continue;
+ }
+ let stack: Vec> = std::iter::once(c).chain(below.iter().skip(i).filter_map(|b| b.get(&(ctag, n)).copied())).collect();
+ self.element(&stack, &inner, depth + 1);
+ }
+ }
+ self.out.push_str("");
+ self.out.push_str(&qname);
+ self.out.push('>');
+ for l in &layers {
+ if let Some(k) = self.active.get_mut(&l.id()) {
+ *k = k.saturating_sub(1);
+ if *k == 0 {
+ self.active.remove(&l.id());
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/xfa/src/proto/tests.rs b/crates/xfa/src/proto/tests.rs
new file mode 100644
index 0000000000..eb03c505a2
--- /dev/null
+++ b/crates/xfa/src/proto/tests.rs
@@ -0,0 +1,401 @@
+//! Prototypes (`use`, `usehref`): synthetic templates only.
+
+use crate::layout::{BorderShape, Item, WidgetKind};
+use crate::model::{Node, Placement, Template, Ui, Value};
+use crate::{layout_xml, parse};
+
+const MM: f64 = 72.0 / 25.4;
+
+/// A form whose root subform holds `styles` in a Designer-style sheet (a named subform inside
+/// `proto`) and positions `body` on one page.
+fn form(styles: &str, body: &str) -> String {
+ format!(
+ r##"
+
+
+ {body}
+ {styles}
+ "##
+ )
+}
+
+fn close(a: Option, b: f64) -> bool {
+ a.is_some_and(|a| (a - b).abs() < 0.01)
+}
+
+/// The nodes of the body subform.
+fn nodes(t: &Template) -> &[Node] {
+ let Some(Node::Subform(b)) = t.root.children.first() else { panic!("no body subform: {:?}", t.root.children) };
+ &b.children
+}
+
+fn field<'t>(t: &'t Template, name: &str) -> &'t crate::model::Field {
+ nodes(t)
+ .iter()
+ .find_map(|n| match n {
+ Node::Field(f) if f.common.name.as_deref() == Some(name) => Some(f.as_ref()),
+ _ => None,
+ })
+ .unwrap_or_else(|| panic!("no field {name}"))
+}
+
+const STYLES: &str = r#"
+
+
+ 1 0 "#;
+
+const BODY: &str = r##"
+ Section
+
+ Agree "##;
+
+#[test]
+fn fields_and_draws_take_size_border_fill_caption_and_font_from_a_style_sheet() {
+ let xml = form(STYLES, BODY);
+ let (t, warnings) = parse(&xml).unwrap();
+ assert!(warnings.is_empty(), "{warnings:?}");
+ let Some(Node::Draw(section)) = nodes(&t).first() else { panic!("{:?}", nodes(&t)) };
+ let c = §ion.common;
+ assert_eq!(c.name.as_deref(), Some("Section"));
+ assert!(close(c.w, 190.5 * MM) && close(c.h, 6.0 * MM), "{c:?}");
+ assert_eq!((c.anchor.h, c.anchor.v), (0.0, 0.5));
+ assert_eq!(c.border.as_ref().and_then(|b| b.fill).map(|f| f.0), Some([0.6; 3]));
+ assert_eq!((c.font.typeface.as_deref(), c.font.bold), (Some("Arial"), Some(true)));
+ assert_eq!(section.value, Value::Text("Section".into()));
+
+ let count = field(&t, "Count");
+ assert_eq!(count.ui, Ui::NumericEdit);
+ assert!(close(count.common.w, 30.0 * MM) && close(count.common.h, 5.0 * MM));
+ assert_eq!(count.ui_border.as_ref().map(|b| b.visible_edges()), Some([true; 4]));
+ assert_eq!(count.common.font.typeface.as_deref(), Some("Arial"));
+
+ let agree = field(&t, "Agree");
+ assert_eq!(agree.ui, Ui::CheckButton);
+ assert!(close(agree.check_size, 3.5278 * MM) && close(agree.common.h, 6.0 * MM));
+ assert_eq!((agree.common.anchor.h, agree.common.anchor.v), (0.5, 0.5));
+ let cap = agree.caption.as_ref().expect("caption");
+ assert_eq!((cap.placement, cap.value.clone()), (Placement::Right, Value::Text("Agree".into())));
+ assert_eq!(cap.font.typeface.as_deref(), Some("Arial"));
+ assert_eq!(agree.items, vec!["1".to_string(), "0".to_string()]);
+
+ // Laid out: the heading's grey bar, the numeric field's box and the check box left of its
+ // caption. The style sheet itself lays nothing out.
+ let laid = layout_xml(&xml).unwrap();
+ let items = &laid.pages.first().expect("a page").items;
+ assert!(items.iter().any(|i| matches!(i, Item::Fill { rect, color } if *color == [0.6; 3] && (rect.w - 190.5 * MM).abs() < 0.01)), "{items:?}");
+ let widgets: Vec<_> = items.iter().filter_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }).collect();
+ assert_eq!(widgets.len(), 2, "{widgets:?}");
+ let count = widgets.iter().find(|w| w.name.contains("Count")).expect("Count");
+ assert_eq!(count.border.shape, BorderShape::Full);
+ let check = widgets.iter().find(|w| matches!(w.kind, WidgetKind::CheckBox { .. })).expect("check box");
+ let caption = items.iter().find_map(|i| if let Item::Text(s) = i { (s.text == "Agree").then_some(s.x) } else { None }).expect("caption");
+ assert!(check.rect.x + check.rect.w <= caption, "box {:?}, caption at {caption}", check.rect);
+}
+
+#[test]
+fn what_an_element_declares_overrides_what_it_inherits() {
+ let styles = r#"proto
+
+ Proto bold
second
"#;
+ let body = r##"own
+
+
+ 7
+ Own
"##;
+ let (t, warnings) = parse(&form(styles, body)).unwrap();
+ assert!(warnings.is_empty(), "{warnings:?}");
+ let f = field(&t, "f");
+ assert!(close(f.common.h, 7.0 * MM) && close(f.common.w, 50.0 * MM), "{:?}", f.common);
+ assert_eq!((f.common.font.typeface.as_deref(), f.common.font.size, f.common.font.bold), (Some("Courier"), Some(9.0), Some(true)));
+ // Edges match by position: the first overridden, the second inherited (Example 6.12).
+ let edges = f.common.border.as_ref().expect("border").edges.map(|e| e.thickness);
+ assert_eq!(edges, [3.0, 1.0, 3.0, 1.0]);
+ assert_eq!(f.value, Value::Text("own".into()));
+ assert_eq!(f.common.id, None, "a prototype's id is its own");
+ // Neither the name nor the id is inherited, but the content is.
+ let Some(Node::Field(unnamed)) = nodes(&t).get(1) else { panic!() };
+ assert_eq!((unnamed.common.name.as_deref(), unnamed.value.clone()), (None, Value::Text("proto".into())));
+ // usehref wins over use.
+ assert!(close(field(&t, "g").common.w, 20.0 * MM));
+ // A ui holds one widget and a value one kind of content: the nearer one replaces the other.
+ let t_field = field(&t, "t");
+ assert_eq!((t_field.ui.clone(), t_field.numeric, t_field.value.clone()), (Ui::TextEdit, false, Value::Text("7".into())));
+ // Rich text is content: the nearer layer's is taken whole.
+ let Some(Node::Draw(rich)) = nodes(&t).get(4) else { panic!() };
+ let Value::Rich(r) = &rich.value else { panic!("{:?}", rich.value) };
+ assert_eq!(r.plain(), "Own");
+}
+
+#[test]
+fn prototypes_inherit_from_prototypes() {
+ // Example 6.5: a font prototype using another, the second by a bare id.
+ let styles = r##"
+
+ "##;
+ let body = r##" "##;
+ let (t, warnings) = parse(&form(styles, body)).unwrap();
+ assert!(warnings.is_empty(), "{warnings:?}");
+ let f = field(&t, "f");
+ assert!(close(f.common.w, 40.0 * MM) && close(f.common.h, 8.0 * MM), "{:?}", f.common);
+ assert_eq!((f.common.font.typeface.as_deref(), f.common.font.size), (Some("Helvetica"), Some(10.0)));
+ assert_eq!(f.common.font.color.map(|c| c.0), Some([1.0, 0.0, 0.0]));
+}
+
+#[test]
+fn prototype_loops_end_with_a_warning() {
+ let styles = r##"
+ "##;
+ let body = r##"
+
+ "##;
+ let (t, warnings) = parse(&form(styles, body)).unwrap();
+ assert!(warnings.iter().any(|w| w.contains("leads back to itself")), "{warnings:?}");
+ assert!(!warnings.iter().any(|w| w.contains("longer than")), "a loop is cut where it closes: {warnings:?}");
+ assert!(close(field(&t, "self").common.w, 72.0));
+ let cycle = field(&t, "cycle");
+ assert!(close(cycle.common.w, 10.0 * MM) && close(cycle.common.h, 12.0 * MM), "{:?}", cycle.common);
+ // Example 6.8: the inner subform does not take its parent in again.
+ let Some(Node::Subform(client)) = nodes(&t).get(2) else { panic!() };
+ assert!(close(client.common.w, 50.0 * MM));
+ let Some(Node::Subform(inner)) = client.children.first() else { panic!("{:?}", client.children) };
+ assert_eq!(inner.common.name.as_deref(), Some("ClientName"));
+ assert!(inner.children.is_empty() && inner.common.w.is_none(), "{inner:?}");
+}
+
+#[test]
+fn a_reference_to_nothing_or_another_document_is_left_as_it_is() {
+ let styles = r#" "#;
+ let body = r##"
+
+
+
+ "##;
+ let (t, warnings) = parse(&form(styles, body)).unwrap();
+ assert!(warnings.iter().any(|w| w.contains("names nothing")), "{warnings:?}");
+ assert!(warnings.iter().any(|w| w.contains("another document")), "{warnings:?}");
+ for name in ["nowhere", "noid", "far", "bad"] {
+ assert!(close(field(&t, name).common.w, 72.0), "{name}");
+ }
+ let Some(Node::Draw(other)) = nodes(&t).get(4) else { panic!() };
+ assert!(close(other.common.w, 72.0), "a prototype is of the referencing element's class");
+}
+
+#[test]
+fn an_expansion_past_its_budget_leaves_the_template_unresolved_with_a_warning() {
+ // 200 references to a 20 kB prototype would write 4 MB from a template of a few kB.
+ let styles = format!(r#"{} "#, "x".repeat(20_000));
+ let body: String = (0..200).map(|i| format!(r##" "##)).collect();
+ let started = std::time::Instant::now();
+ let (t, warnings) = parse(&form(&styles, &body)).unwrap();
+ assert!(started.elapsed().as_secs() < 10);
+ assert!(warnings.iter().any(|w| w.contains("larger than 1 MB")), "{warnings:?}");
+ let Some(Node::Draw(d)) = nodes(&t).first() else { panic!() };
+ assert_eq!((d.common.w, &d.value), (None, &Value::Empty), "read as written");
+}
+
+#[test]
+fn hostile_references_neither_panic_nor_run_away() {
+ let refs = [
+ "#som(",
+ "#som()",
+ "#som($template.[)",
+ "#som($template.#subform[99999999999999999999999])",
+ "#som(.)",
+ "#som(#)",
+ "#",
+ ".#",
+ "#som($template.#subform.designer__stylesheet.ü[0].x[1])",
+ "#som(xfa.template.#subform[0].#proto)",
+ "#\u{feff}",
+ ];
+ let body: String = refs
+ .iter()
+ .enumerate()
+ .map(|(i, r)| format!(r#" "#))
+ .collect();
+ let (t, _) = parse(&form("", &body)).unwrap();
+ assert!(close(field(&t, "f0").common.w, 72.0));
+ // Prototypes six levels deep, each holding ten references to the next: a million elements.
+ let mut styles = String::from(r#" "#);
+ for level in (1..6).rev() {
+ let next = level + 1;
+ let refs: String = (0..10).map(|_| format!(r##" "##)).collect();
+ styles.push_str(&format!(r#"{refs} "#));
+ }
+ let started = std::time::Instant::now();
+ let (_, warnings) = parse(&form(&styles, r##" "##)).unwrap();
+ assert!(started.elapsed().as_secs() < 10);
+ assert!(warnings.iter().any(|w| w.contains("larger than")), "{warnings:?}");
+}
+
+#[test]
+fn a_som_search_past_its_budget_leaves_the_template_unresolved_with_one_warning() {
+ // 300 references, each to a style 10,000 elements into the sheet: 3 million elements looked
+ // at, past the 2 million the look-ups share. The first ones resolve before the budget runs
+ // out, so the template is either resolved in full or not at all.
+ let styles: String = (0..10_000).map(|i| format!(r#" "#)).collect();
+ let body: String =
+ (0..300).map(|i| format!(r##" "##, 9_999 - i)).collect();
+ let (t, warnings) = parse(&form(&styles, &body)).unwrap();
+ assert!(warnings.iter().any(|w| w.contains("SOM references") && w.contains("read without resolving")), "{warnings:?}");
+ assert!(!warnings.iter().any(|w| w.contains("names nothing")), "an interrupted search is not a missing target: {warnings:?}");
+ assert_eq!(field(&t, "f0").common.w, None, "read as written");
+}
+
+#[test]
+fn prototype_children_that_write_nothing_still_count_against_a_budget() {
+ // A prototype with 20,000 `proto` children, used by 20,000 fields: the children write
+ // nothing, but merging would look at 400 million of them.
+ let protos = " ".repeat(20_000);
+ let styles = format!(r#"{protos} "#);
+ let body = r##" "##.repeat(20_000).replacen(" String {
+ let doc = roxmltree::Document::parse(xml).unwrap();
+ let template = doc.descendants().find(|n| n.tag_name().name() == "template").unwrap();
+ let (out, warnings) = crate::proto::expand(template);
+ assert!(warnings.is_empty(), "{warnings:?}");
+ out.unwrap()
+}
+
+#[test]
+fn namespaces_bound_again_inside_the_template_keep_their_own_uri() {
+ const XHTML: &str = "http://www.w3.org/1999/xhtml";
+ // Rich text in a prototype and in a plain element: XHTML's default namespace inside the
+ // template's (XFA's), and a prefix bound to one URI around an element and another inside.
+ let styles = r#"See the guide
"#;
+ let body = r##"
+ Plain
+ "##;
+ let xml = form(styles, body);
+ let out = expanded(&xml);
+ let doc = roxmltree::Document::parse(&out).unwrap();
+ let bodies: Vec<_> = doc.descendants().filter(|n| n.tag_name().name() == "body").collect();
+ assert_eq!(bodies.len(), 2, "{out}");
+ for b in &bodies {
+ assert_eq!(b.tag_name().namespace(), Some(XHTML), "{out}");
+ assert!(b.descendants().filter(|n| n.is_element()).all(|n| n.tag_name().namespace() == Some(XHTML)), "{out}");
+ }
+ let p = doc.descendants().find(|n| n.attribute(("urn:inner", "b")).is_some());
+ assert!(p.is_some(), "a prefix bound again keeps the inner URI: {out}");
+ assert!(doc.descendants().any(|n| n.attribute(("urn:outer", "a")) == Some("1")), "{out}");
+ let free = doc.descendants().find(|n| n.tag_name().name() == "free").unwrap();
+ assert_eq!(free.tag_name().namespace().unwrap_or_default(), "", "an undeclared default namespace stays undeclared: {out}");
+ // And the form still reads the rich text and its link.
+ let (t, warnings) = parse(&xml).unwrap();
+ assert!(warnings.is_empty(), "{warnings:?}");
+ let Some(Node::Draw(note)) = nodes(&t).first() else { panic!() };
+ let Value::Rich(paras) = ¬e.value else { panic!("{:?}", note.value) };
+ assert!(
+ paras.paragraphs.iter().flat_map(|p| &p.runs).any(|r| r.text.contains("the guide") && r.link.as_deref() == Some("https://example.com/")),
+ "{paras:?}"
+ );
+}
+
+#[test]
+fn inherited_attributes_keep_their_namespace_where_the_prefix_is_bound_again() {
+ // The element binds `q` to one URI and its prototype to another: the element's binding
+ // wins, so the prototype's `q:` attributes need another prefix to keep their own URI.
+ let styles = r#" "#;
+ let body = r##" "##;
+ let out = expanded(&form(styles, body));
+ let doc = roxmltree::Document::parse(&out).unwrap();
+ let f = doc.descendants().find(|n| n.attribute("name") == Some("f")).unwrap();
+ assert_eq!(f.attribute(("urn:instance", "own")), Some("1"), "{out}");
+ assert_eq!(f.attribute(("urn:prototype", "own")), Some("2"), "{out}");
+ assert_eq!(f.attribute(("urn:prototype", "kind")), Some("k"), "{out}");
+ assert_eq!(f.attribute(("urn:instance", "kind")), None, "{out}");
+ assert_eq!(f.attribute(("http://www.w3.org/XML/1998/namespace", "lang")), Some("en"), "{out}");
+ assert_eq!(f.attribute("w"), Some("1in"), "{out}");
+}
+
+/// `expand` on the template of `xml`, timed.
+fn expand_timed(xml: &str) -> (Option, Vec, std::time::Duration) {
+ let doc = roxmltree::Document::parse(xml).unwrap();
+ let template = doc.descendants().find(|n| n.tag_name().name() == "template").unwrap();
+ let started = std::time::Instant::now();
+ let (out, warnings) = crate::proto::expand(template);
+ (out, warnings, started.elapsed())
+}
+
+#[test]
+fn billion_laughs_prototypes_fail_whole_within_the_output_cap() {
+ // Ten levels of prototypes, each holding ten references to the level below: a template of
+ // under 3 kB that would write 10^10 fields. Expanding fails as a whole, quickly, and
+ // nothing of it is used (no truncated template): the form is read as written.
+ let mut styles = String::from(r#" "#);
+ for level in (1..10).rev() {
+ let refs = format!(r##" "##, level + 1).repeat(10);
+ styles.push_str(&format!(r#"{refs} "#));
+ }
+ let xml = form(&styles, r##" "##);
+ assert!(xml.len() < 3_000, "{}", xml.len());
+ let (out, warnings, took) = expand_timed(&xml);
+ assert!(took.as_secs() < 5, "{took:?}");
+ assert!(out.is_none(), "a failed expansion writes nothing for the parser to read");
+ assert!(warnings.iter().any(|w| w.contains("larger than 1 MB") && w.contains("read without resolving")), "{warnings:?}");
+ let (t, _) = parse(&xml).unwrap();
+ let Some(Node::Subform(bomb)) = nodes(&t).first() else { panic!() };
+ assert!(bomb.children.is_empty(), "read as written: {bomb:?}");
+ assert!(close(field(&t, "plain").common.w, 72.0));
+}
+
+#[test]
+fn a_designer_style_sheet_on_a_long_form_resolves_in_full() {
+ // 3,000 fields and draws on Designer's style sheet, each through `#som(…)`: well inside
+ // every budget, so all of them take their style and nothing is warned about.
+ let styles: String = (0..40).map(|i| format!(r#" "#, i + 1)).collect();
+ let body: String = (0..3_000)
+ .map(|i| format!(r##" "##, i % 40))
+ .collect();
+ let xml = form(&styles, &body);
+ let (out, warnings, took) = expand_timed(&xml);
+ assert!(warnings.is_empty(), "{warnings:?}");
+ assert!(out.is_some() && took.as_secs() < 5, "{took:?}");
+ let (t, warnings) = parse(&xml).unwrap();
+ assert!(warnings.is_empty(), "{warnings:?}");
+ for i in [0usize, 39, 1_234, 2_999] {
+ let f = field(&t, &format!("f{i}"));
+ assert!(close(f.common.h, (i % 40 + 1) as f64 * MM), "f{i}: {:?}", f.common);
+ assert_eq!(f.common.font.size, Some(9.0), "f{i}");
+ }
+}
+
+#[test]
+fn a_long_reference_shared_by_many_elements_is_resolved_once() {
+ // One prototype whose own reference is a 1 MB `use` id, used by 60,000 fields: the output
+ // stays small, so only reading that reference again for every field (60 GB of text) could
+ // make this slow. A reference is resolved once per element.
+ let long = "x".repeat(1 << 20);
+ let styles = format!(r#" "#);
+ let body = r##" "##.repeat(60_000).replacen(" "#;
+ let body = format!(
+ r##" "##
+ );
+ let (t, warnings) = parse(&form(styles, &body)).unwrap();
+ assert!(warnings.iter().any(|w| w.contains("names nothing")), "{warnings:?}");
+ assert_eq!(field(&t, "long").common.h, None);
+ assert!(close(field(&t, "short").common.h, 2.0 * MM));
+}
diff --git a/crates/xfa/src/repeat_tests.rs b/crates/xfa/src/repeat_tests.rs
new file mode 100644
index 0000000000..bce2051838
--- /dev/null
+++ b/crates/xfa/src/repeat_tests.rs
@@ -0,0 +1,120 @@
+//! Repeating subforms with many instances: every row the data holds is laid out, up to the
+//! template's `occur max` and the layout's instance limit, past which the rows left out are
+//! reported (XFA 3.3, "The occur element", p. 786; Chapter 9, p. 336).
+
+use super::*;
+use crate::layout::MAX_INSTANCES;
+use crate::model::Occur;
+
+/// A form with a flowed list (`table` false) or a table (`table` true) whose `item` row
+/// repeats with `occur max`, and data holding `rows` rows, each 20pt tall: 36 to a page.
+fn rows_form(max: &str, rows: usize, table: bool) -> String {
+ let (list, row) = if table { (r#"layout="table" columnWidths="200pt""#, r#"layout="row""#) } else { (r#"layout="tb""#, r#"layout="tb""#) };
+ let data: String = (1..=rows).map(|i| format!("row {i} ")).collect();
+ format!(
+ r#"
+
+
+
+
+
+
+
+ "#
+ )
+}
+
+fn qty_widgets(pages: &[Page]) -> Vec<&Widget> {
+ pages.iter().flat_map(|p| p.items.iter()).filter_map(|it| if let Item::Widget(w) = it { Some(w.as_ref()) } else { None }).collect()
+}
+
+/// The values of the laid-out `qty` fields, in order.
+fn values(form: &Form) -> Vec {
+ qty_widgets(&form.pages).iter().map(|w| w.value.clone().unwrap_or_default()).collect()
+}
+
+fn expected(n: usize) -> Vec {
+ (1..=n).map(|i| format!("row {i}")).collect()
+}
+
+fn cut_warning(form: &Form) -> Option<&String> {
+ form.warnings.iter().find(|w| w.contains("rows; only the first"))
+}
+
+#[test]
+fn every_data_row_past_fifty_is_laid_out_and_paginated() {
+ for table in [false, true] {
+ for (rows, pages) in [(60, 2), (300, 9)] {
+ let form = layout_xml(&rows_form("-1", rows, table)).unwrap();
+ assert_eq!(values(&form), expected(rows), "table {table}, {rows} rows");
+ assert_eq!(form.pages.len(), pages, "table {table}, {rows} rows at 36 a page");
+ assert_eq!(cut_warning(&form), None, "nothing is cut: {:?}", form.warnings);
+ // Rows stay inside the content area of their page.
+ for w in qty_widgets(&form.pages) {
+ assert!(w.rect.y >= 36.0 - 0.01 && w.rect.bottom() <= 756.0 + 0.01, "{} at {:?}", w.name, w.rect);
+ }
+ }
+ }
+}
+
+#[test]
+fn occur_max_still_stops_the_rows_and_says_so() {
+ for table in [false, true] {
+ let form = layout_xml(&rows_form("5", 60, table)).unwrap();
+ assert_eq!(values(&form), expected(5), "table {table}");
+ let w = cut_warning(&form).unwrap_or_else(|| panic!("the 55 rows past occur max are reported: {:?}", form.warnings));
+ assert!(w.contains("holds 60 rows") && w.contains("first 5") && !w.contains("the most"), "occur max is the form's own rule: {w}");
+ }
+}
+
+#[test]
+fn rows_past_the_instance_limit_are_cut_with_a_warning() {
+ for table in [false, true] {
+ // Exactly at the limit: all laid out, nothing reported.
+ let form = layout_xml(&rows_form("-1", MAX_INSTANCES, table)).unwrap();
+ assert_eq!(values(&form).len(), MAX_INSTANCES, "table {table}");
+ assert_eq!(cut_warning(&form), None, "{:?}", form.warnings);
+ // One past it: the last row is left out, and the warning says so.
+ let form = layout_xml(&rows_form("-1", MAX_INSTANCES + 1, table)).unwrap();
+ assert_eq!(values(&form), expected(MAX_INSTANCES), "table {table}");
+ let w = cut_warning(&form).unwrap_or_else(|| panic!("rows past the limit are reported: {:?}", form.warnings));
+ assert!(w.contains(&format!("holds {} rows", MAX_INSTANCES + 1)) && w.contains(&format!("first {MAX_INSTANCES}")), "{w}");
+ assert!(w.contains(&format!("{MAX_INSTANCES} is the most one repeating subform lays out")), "the limit is named: {w}");
+ }
+}
+
+#[test]
+fn an_initial_count_lays_out_whole_up_to_the_limit() {
+ // `initial` comes from the template, not the data: past fifty it lays out whole, and a
+ // hostile count stops at the limit (the parser reads it up to there).
+ let tpl = |initial: &str| {
+ rows_form("-1", 0, false).replace(r#" "#, &format!(r#" "#))
+ };
+ let form = layout_xml(&tpl("300")).unwrap();
+ assert_eq!(qty_widgets(&form.pages).len(), 300);
+ assert_eq!(form.pages.len(), 9);
+ let form = layout_xml(&tpl("1000000000")).unwrap();
+ assert_eq!(qty_widgets(&form.pages).len(), MAX_INSTANCES);
+ assert_eq!(crate::layout::instance_count(&Occur { initial: usize::MAX, ..Occur::default() }, 0), 1, "max defaults to 1");
+ let unbounded = Occur { initial: usize::MAX, max: None, ..Occur::default() };
+ assert_eq!(crate::layout::instance_count(&unbounded, 0), MAX_INSTANCES);
+ assert_eq!(crate::layout::instance_count(&unbounded, usize::MAX), MAX_INSTANCES);
+}
+
+#[test]
+fn rows_cut_in_a_positioned_container_are_reported_too() {
+ // The list sits in a positioned subform, so it is drawn rather than flowed: its `tb`,
+ // `lr-tb` and `rl-tb` repeats are cut at the same bounds and must say so as a flowed one does.
+ for layout in ["tb", "lr-tb", "rl-tb"] {
+ let xml = rows_form("5", 60, false)
+ .replace(r#""#, r#""#)
+ .replace(" ", " ")
+ .replace(r#""#, &format!(r#""#))
+ .replace(" ");
+ let form = layout_xml(&xml).unwrap();
+ assert_eq!(values(&form), expected(5), "{layout}");
+ let w = cut_warning(&form).unwrap_or_else(|| panic!("{layout}: the rows past occur max are reported: {:?}", form.warnings));
+ assert!(w.contains("holds 60 rows") && w.contains("first 5"), "{layout}: {w}");
+ }
+}
diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs
index 6946aa9b1c..b2cfbab74c 100644
--- a/crates/xfa/src/script.rs
+++ b/crates/xfa/src/script.rs
@@ -3,11 +3,12 @@
//! survive undo and reopening), and laying the form out again after scripts changed it.
use std::collections::{BTreeMap, HashMap};
+use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, Object, PdfString};
use crate::data::{DataNode, data_path, parse_datasets};
-use crate::model::{Access, Field, Node, Presence, Script, SomIndices, Template};
+use crate::model::{Access, Field, Node, Presence, Props, Script, SomIndices, Template};
use crate::{Report, XfaError};
/// Private key on the AcroForm: presence scripts set, by SOM path.
@@ -15,6 +16,9 @@ pub const OVERRIDES_KEY: &[u8] = b"PCXfaOverrides";
const MAX_DEPTH: usize = 64;
const MAX_EVENTS: usize = 20_000;
+/// Most `/Fields` and `/Kids` entries [`fields_by_som`] looks at in all: fields already read, and
+/// entries that are not references, included.
+const MAX_FIELD_STEPS: usize = 1 << 20;
/// Most bytes of script the events hold together: each instance of a repeated subform holds its
/// own copy, so a long script repeated many times stops here (as much as the XFA packets hold).
pub(crate) const MAX_EVENT_SCRIPT_BYTES: usize = 64 << 20;
@@ -49,6 +53,9 @@ pub struct FormNode {
pub children: Vec,
/// Fields: the display picture (see [`Field::display_picture`]), for `formattedValue`.
pub display_picture: Option,
+ /// The object's attributes and property elements as the template gives them, for scripts to
+ /// read: shared with the template and every other instance of the object, never copied.
+ pub props: Arc,
}
/// A script the template runs for an event of one node instance.
@@ -269,6 +276,7 @@ impl Walker<'_> {
index,
children: Vec::new(),
display_picture: f.display_picture.clone(),
+ props: Arc::clone(&f.common.props),
}
}
@@ -333,6 +341,7 @@ impl Walker<'_> {
index,
children,
display_picture: None,
+ props: Arc::clone(&sf.common.props),
});
}
}
@@ -349,6 +358,7 @@ impl Walker<'_> {
occur_max: Some(1),
index: sib,
children,
+ props: Arc::clone(&a.common.props),
..Default::default()
});
}
@@ -360,6 +370,7 @@ impl Walker<'_> {
som: som.clone(),
kind: Some(NodeKind::Draw),
value: d.value.plain().unwrap_or_default(),
+ props: Arc::clone(&d.common.props),
presence: self.presence(d.common.name.as_deref(), &som, d.common.presence),
access: "open".into(),
occur_min: 1,
@@ -393,6 +404,7 @@ impl Walker<'_> {
occur_max: Some(1),
index: sib,
children,
+ props: Arc::clone(&g.common.props),
..Default::default()
});
}
@@ -406,32 +418,48 @@ impl Walker<'_> {
/// from the template and `ov`) and every scripted event, in document order, within
/// [`MAX_FORM_NODES`] objects.
pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> LiveForm {
+ form_tree_paged(tpl, data, ov, &HashMap::new())
+}
+
+/// [`form_tree`] for a laid-out form whose page areas made `uses` pages each, by name
+/// ([`page_area_uses`]): the master content of each of those pages is a node of its own
+/// (`master[0]`, `master[1]`, …), as the layout names its fields, so a button repeated on every
+/// page runs its click script as itself. A page area not in `uses` gets one node.
+pub fn form_tree_paged(tpl: &Template, data: Option<&DataNode>, ov: &Overrides, uses: &HashMap) -> LiveForm {
let mut w = Walker { tpl, data, ov, events: Vec::new(), script_bytes: 0, nodes: 0, truncated: false };
let root = Node::Subform(Box::new(tpl.root.clone()));
let mut nodes = w.children(std::slice::from_ref(&root), "", 0);
let mut root_node = nodes.pop().unwrap_or_default();
// Page masters' fields (Print and Save buttons, page numbers) belong to the layout model
- // in XFA; they hang off the root here, under their page area's name, as the layouter
- // names them, so their click scripts can run.
+ // in XFA; they hang off the root here, under their page area's name, one node per page made
+ // from it, as the layouter names them, so their click scripts can run.
if let Some(ps) = &tpl.root.page_set {
for (i, area) in ps.areas.iter().enumerate() {
- let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
- let children = w.children(&area.items, &som, 1);
- if children.is_empty() {
- continue;
+ // A name two areas share stays one node: its pages can't be told apart by it.
+ let unique = area.name.as_ref().is_some_and(|n| ps.areas.iter().filter(|a| a.name.as_ref() == Some(n)).count() == 1);
+ let pages = match &area.name {
+ Some(n) if unique => uses.get(n).copied().unwrap_or(1).clamp(1, crate::layout::MAX_PAGES),
+ _ => 1,
+ };
+ for page in 0..pages {
+ let som = area.name.clone().map(|n| format!("{n}[{page}]")).unwrap_or_else(|| format!("#pageArea[{i}]"));
+ let children = w.children(&area.items, &som, 1);
+ if children.is_empty() {
+ break;
+ }
+ root_node.children.push(FormNode {
+ name: area.name.clone().unwrap_or_else(|| "#pageArea".into()),
+ som,
+ kind: Some(NodeKind::Area),
+ presence: "visible".into(),
+ access: "open".into(),
+ occur_min: 1,
+ occur_max: Some(1),
+ index: page,
+ children,
+ ..Default::default()
+ });
}
- root_node.children.push(FormNode {
- name: area.name.clone().unwrap_or_else(|| "#pageArea".into()),
- som,
- kind: Some(NodeKind::Area),
- presence: "visible".into(),
- access: "open".into(),
- occur_min: 1,
- occur_max: Some(1),
- index: 0,
- children,
- ..Default::default()
- });
}
}
LiveForm { root: root_node, events: w.events, truncated: w.truncated }
@@ -440,12 +468,15 @@ pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> Liv
/// Does the template declare any script (an event, calculation or validation)? Forms without
/// one need no live form and run nothing.
pub fn has_scripts(tpl: &Template) -> bool {
+ /// Is any of `s` a non-blank script?
fn any(s: &[Script]) -> bool {
s.iter().any(|s| !s.text.trim().is_empty())
}
+ /// Has field `f` an event, calculate or validate script?
fn field(f: &Field) -> bool {
any(&f.scripts) || [&f.calculate, &f.validate].into_iter().flatten().any(|s| !s.text.trim().is_empty())
}
+ /// Has any object among `nodes` (and their containers, `depth` levels down) a script?
fn walk(nodes: &[Node], depth: usize) -> bool {
depth <= MAX_DEPTH
&& nodes.iter().any(|n| match n {
@@ -459,81 +490,105 @@ pub fn has_scripts(tpl: &Template) -> bool {
any(&tpl.root.scripts) || walk(&tpl.root.children, 1) || tpl.root.page_set.as_ref().is_some_and(|ps| ps.areas.iter().any(|a| walk(&a.items, 1)))
}
-/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
-/// instance of a repeating subform hide them all).
-pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
- /// Set the presence `ov` gives each of `nodes` (children of `parent_som`) and their descendants,
- /// down to [`MAX_DEPTH`], and the access it gives a field outside an exclusion group. An unnamed
- /// draw, area or subform shares its parent's SOM path, so it takes no override of its own.
- fn walk(nodes: &mut [Node], parent_som: &str, ov: &Overrides, depth: usize) {
- if depth > MAX_DEPTH {
- return;
- }
- let sibs = SomIndices::of(nodes);
- for (i, n) in nodes.iter_mut().enumerate() {
- let sib = sibs.at(i);
- let name_default = match n {
- Node::Field(_) => Some("field"),
- Node::ExclGroup(_) => Some("group"),
- _ => None,
- };
- let name = n.common().name.as_deref().or(name_default);
- let som = child_som(parent_som, name, sib);
- // An unnamed draw, area or subform has no SOM name: `som` is its parent's, and so are
- // the overrides under it.
- let presence = name.and_then(|_| ov.presence.get(&som)).map(|p| Presence::parse(Some(p)));
- let access = name.and_then(|_| ov.access.get(&som)).cloned();
- match n {
- Node::Subform(s) => {
- if let Some(p) = presence {
- s.common.presence = p;
- }
- walk(&mut s.children, &som, ov, depth + 1);
+/// Does any field of the template (in the body, an exclusion group or a master page) have an
+/// `enter` or `exit` script? Forms without one have nothing to run when the focus moves.
+pub fn has_focus_scripts(tpl: &Template) -> bool {
+ /// Has field `f` a non-blank enter or exit script?
+ fn field(f: &Field) -> bool {
+ f.scripts.iter().any(|s| matches!(s.activity.as_str(), "enter" | "exit") && !s.text.trim().is_empty())
+ }
+ /// Has any field among `nodes` (and their containers, `depth` levels down) one?
+ fn walk(nodes: &[Node], depth: usize) -> bool {
+ depth <= MAX_DEPTH
+ && nodes.iter().any(|n| match n {
+ Node::Subform(sf) => walk(&sf.children, depth + 1),
+ Node::Area(a) => walk(&a.children, depth + 1),
+ Node::Field(f) => field(f),
+ Node::ExclGroup(g) => g.fields.iter().any(field),
+ Node::Draw(_) => false,
+ })
+ }
+ walk(&tpl.root.children, 1) || tpl.root.page_set.as_ref().is_some_and(|ps| ps.areas.iter().any(|a| walk(&a.items, 1)))
+}
+
+/// Set the presence `ov` gives each of `nodes` (children of `parent_som`) and their descendants,
+/// down to [`MAX_DEPTH`], and the access it gives a field outside an exclusion group. An unnamed
+/// draw, area or subform shares its parent's SOM path, so it takes no override of its own.
+pub(crate) fn apply_overrides_to(nodes: &mut [Node], parent_som: &str, ov: &Overrides, depth: usize) {
+ if depth > MAX_DEPTH {
+ return;
+ }
+ let sibs = SomIndices::of(nodes);
+ for (i, n) in nodes.iter_mut().enumerate() {
+ let sib = sibs.at(i);
+ let name_default = match n {
+ Node::Field(_) => Some("field"),
+ Node::ExclGroup(_) => Some("group"),
+ _ => None,
+ };
+ let name = n.common().name.as_deref().or(name_default);
+ let som = child_som(parent_som, name, sib);
+ // An unnamed draw, area or subform has no SOM name: `som` is its parent's, and so are
+ // the overrides under it.
+ let presence = name.and_then(|_| ov.presence.get(&som)).map(|p| Presence::parse(Some(p)));
+ let access = name.and_then(|_| ov.access.get(&som)).cloned();
+ match n {
+ Node::Subform(s) => {
+ if let Some(p) = presence {
+ s.common.presence = p;
}
- Node::Area(a) => {
- if let Some(p) = presence {
- a.common.presence = p;
- }
- walk(&mut a.children, &som, ov, depth + 1);
+ apply_overrides_to(&mut s.children, &som, ov, depth + 1);
+ }
+ Node::Area(a) => {
+ if let Some(p) = presence {
+ a.common.presence = p;
}
- Node::Field(f) => {
- if let Some(p) = presence {
- f.common.presence = p;
- }
- if let Some(a) = access {
- f.access = match a.as_str() {
- "readOnly" => Access::ReadOnly,
- "protected" => Access::Protected,
- "nonInteractive" => Access::NonInteractive,
- _ => Access::Open,
- };
- }
+ apply_overrides_to(&mut a.children, &som, ov, depth + 1);
+ }
+ Node::Field(f) => {
+ if let Some(p) = presence {
+ f.common.presence = p;
}
- Node::Draw(d) => {
- if let Some(p) = presence {
- d.common.presence = p;
- }
+ if let Some(a) = access {
+ f.access = match a.as_str() {
+ "readOnly" => Access::ReadOnly,
+ "protected" => Access::Protected,
+ "nonInteractive" => Access::NonInteractive,
+ _ => Access::Open,
+ };
}
- Node::ExclGroup(g) => {
- if let Some(p) = presence {
- g.common.presence = p;
- }
- let fields: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
- let fsibs = SomIndices::of(&fields);
- for (fi, f) in g.fields.iter_mut().enumerate() {
- let fsom = child_som(&som, f.common.name.as_deref().or(Some("field")), fsibs.at(fi));
- if let Some(p) = ov.presence.get(&fsom) {
- f.common.presence = Presence::parse(Some(p));
- }
+ }
+ Node::Draw(d) => {
+ if let Some(p) = presence {
+ d.common.presence = p;
+ }
+ }
+ Node::ExclGroup(g) => {
+ if let Some(p) = presence {
+ g.common.presence = p;
+ }
+ let fields: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect();
+ let fsibs = SomIndices::of(&fields);
+ for (fi, f) in g.fields.iter_mut().enumerate() {
+ let fsom = child_som(&som, f.common.name.as_deref().or(Some("field")), fsibs.at(fi));
+ if let Some(p) = ov.presence.get(&fsom) {
+ f.common.presence = Presence::parse(Some(p));
}
}
}
}
}
+}
+
+/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
+/// instance of a repeating subform hide them all). Page-master content is left as it is: a page
+/// area makes many pages, each with its own SOM path, so `ov` is applied to it page by page as
+/// the pages are laid out ([`crate::layout::layout_with_overrides`]).
+pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
let mut t = tpl.clone();
if !ov.is_empty() {
let mut root = vec![Node::Subform(Box::new(t.root.clone()))];
- walk(&mut root, "", ov, 0);
+ apply_overrides_to(&mut root, "", ov, 0);
if let Some(Node::Subform(r)) = root.pop() {
t.root = *r;
}
@@ -543,12 +598,35 @@ pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
/// The generated fields of the AcroForm by SOM path: `som → (field name, is a radio group)`.
pub fn fields_by_som(doc: &Document) -> HashMap {
- fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, out: &mut HashMap, depth: usize) {
- if depth > MAX_DEPTH || out.len() > 100_000 {
- return;
+ // Each field is read once: a field listed twice in `/Kids` (or among its own kids) would
+ // otherwise be walked again for every path to it, doubling the work at each level. Fields
+ // are told apart by object number, as `Document::get` finds them, so `5 0 R` and `5 1 R`
+ // are one. A field is read again only when it is reached shallower than before: first reached
+ // at `MAX_DEPTH`, its kids would otherwise never be read. `steps` counts every entry looked at, references or not, so fields sharing one
+ // long `/Kids` array stay bounded too; `walk_all` returns false once it is spent, and every
+ // loop then stops.
+ struct Walk {
+ out: HashMap,
+ /// The shallowest depth each object number was read at.
+ seen: HashMap,
+ steps: usize,
+ }
+ fn walk_all(doc: &Document, kids: &[Object], prefix: &str, w: &mut Walk, depth: usize) -> bool {
+ kids.iter().all(|k| {
+ w.steps += 1;
+ if w.steps > MAX_FIELD_STEPS || w.out.len() > 100_000 {
+ return false;
+ }
+ k.as_ref().is_none_or(|r| walk(doc, r, prefix, w, depth))
+ })
+ }
+ fn walk(doc: &Document, r: pdfcraft_cos::ObjRef, prefix: &str, w: &mut Walk, depth: usize) -> bool {
+ if depth > MAX_DEPTH || w.seen.get(&r.num).is_some_and(|&read| read <= depth) {
+ return true;
}
+ w.seen.insert(r.num, depth);
let o = doc.get(r);
- let Some(d) = o.as_dict() else { return };
+ let Some(d) = o.as_dict() else { return true };
let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text()));
let name = match (&t, prefix.is_empty()) {
(Some(t), true) => t.clone(),
@@ -558,19 +636,75 @@ pub fn fields_by_som(doc: &Document) -> HashMap {
if let Some(som) = d.get(crate::pdf::SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text()))
&& t.is_some()
{
- out.insert(som, name.clone());
+ w.out.insert(som, name.clone());
}
- if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
- for k in kids.iter().filter_map(Object::as_ref) {
- walk(doc, k, &name, out, depth + 1);
+ // Read in place: a copy of the array at every level would cost its length each time.
+ let kids = d.get(b"Kids").map(|k| doc.resolve(k));
+ walk_all(doc, kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), &name, w, depth + 1)
+ }
+ let Some((_, acro)) = acroform(doc) else { return HashMap::new() };
+ let fields = acro.get(b"Fields").map(|f| doc.resolve(f));
+ let mut w = Walk { out: HashMap::new(), seen: HashMap::new(), steps: 0 };
+ walk_all(doc, fields.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(), "", &mut w, 0);
+ w.out
+}
+
+/// What a reset gives the field or exclusion group at `som` in the data: its template default
+/// as saved (a choice list's item value, not its shown text, as [`Field::saved_value`] gives
+/// it), or nothing when it has none. A group's default is the on value of the first of its
+/// buttons whose own default is that on value. `None` for an object a reset restores no value
+/// of: not a field or group of the template, or a button, signature or picture field.
+pub fn reset_default(tpl: &Template, som: &str) -> Option {
+ match crate::data::template_object(tpl, som)? {
+ Node::Field(f) => {
+ if matches!(f.ui, crate::model::Ui::Button | crate::model::Ui::Signature | crate::model::Ui::ImageEdit) {
+ return None;
}
+ Some(f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default())
+ }
+ Node::ExclGroup(g) => Some(
+ g.fields
+ .iter()
+ .find_map(|f| {
+ let on = f.items.first().map(|s| s.trim()).filter(|s| !s.is_empty())?;
+ (f.value.plain()?.trim() == on).then(|| on.to_string())
+ })
+ .unwrap_or_default(),
+ ),
+ _ => None,
+ }
+}
+
+/// How many pages the layout made from each named page area of `tpl`: the master content of the
+/// n-th page from area `master` is `master[n-1]`. Each page the layout wrote names its area
+/// ([`crate::AREA_KEY`]), so a page whose master fields are all hidden still counts; a file laid
+/// out before pages were named shows it by its generated fields. Areas neither shows are left out
+/// (one page's worth, to [`form_tree_paged`]).
+pub fn page_area_uses(doc: &Document, tpl: &Template) -> HashMap {
+ let mut out: HashMap = HashMap::new();
+ let Some(ps) = &tpl.root.page_set else { return out };
+ let is_area = |name: &str| tpl.root.common.name.as_deref() != Some(name) && ps.areas.iter().any(|a| a.name.as_deref() == Some(name));
+ // The flat page list laying the form out writes.
+ let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"Pages")).map(|p| doc.resolve(p)));
+ let kids =
+ pages.and_then(|p| p.as_dict().and_then(|d| d.get(b"Kids")).map(|k| doc.resolve(k))).and_then(|k| k.as_array().cloned()).unwrap_or_default();
+ for page in kids.iter().filter_map(Object::as_ref).take(crate::layout::MAX_PAGES) {
+ let page = doc.get(page);
+ let Some(name) = page.as_dict().and_then(|d| d.get(crate::AREA_KEY)).and_then(Object::as_string).map(PdfString::to_text) else { continue };
+ if is_area(&name) {
+ let pages = out.entry(name).or_insert(0);
+ *pages = pages.saturating_add(1).min(crate::layout::MAX_PAGES);
}
}
- let mut out = HashMap::new();
- let Some((_, acro)) = acroform(doc) else { return out };
- let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
- for f in fields.iter().filter_map(Object::as_ref) {
- walk(doc, f, "", &mut out, 0);
+ for som in fields_by_som(doc).keys() {
+ let first = som.split('.').next().unwrap_or_default();
+ let Some((name, rest)) = first.split_once('[') else { continue };
+ let Some(index) = rest.strip_suffix(']').and_then(|i| i.parse::().ok()) else { continue };
+ if !is_area(name) {
+ continue;
+ }
+ let pages = out.entry(name.to_string()).or_insert(1);
+ *pages = (*pages).max(index.saturating_add(1)).min(crate::layout::MAX_PAGES);
}
out
}
@@ -582,7 +716,7 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result
let data = parse_datasets(&packets.xdp);
let ov = overrides(doc);
let laid = apply_overrides(tpl, &ov);
- let form = crate::layout(&laid, data.as_ref())?;
+ let form = crate::layout::layout_with_overrides(&laid, data.as_ref(), &ov)?;
if form.pages.is_empty() {
return Err(XfaError::Malformed("the template laid out to no pages".into()));
}
@@ -656,7 +790,9 @@ pub fn template_of(doc: &Document) -> Result {
if packets.xdp.trim().is_empty() {
return Err(XfaError::Malformed("the XFA packets hold no template".into()));
}
- crate::parse(&packets.xdp).map(|(t, _)| t)
+ let (mut tpl, _) = crate::parse(&packets.xdp)?;
+ crate::linked_images::resolve(&mut tpl, doc);
+ Ok(tpl)
}
/// Whether the template field at `som` is multiline text without a fixed height.
@@ -665,6 +801,30 @@ pub fn field_grows_with_text(tpl: &Template, som: &str) -> bool {
crate::data::template_field(tpl, som).is_some_and(Field::grows_with_text)
}
+/// Is the field at `som` embedded in rich text (a floating field, XFA 3.3 p. 1222)? Its value
+/// is shown in that text, so a new one needs the form laid out again. A page area's fields count
+/// too, and so does any field bound to the same data as an embedded one.
+pub fn field_is_embedded(tpl: &Template, som: &str) -> bool {
+ !tpl.embedded_paths.is_empty() && path_is_embedded(tpl, &data_path(tpl, som))
+}
+
+/// The fields rich text embeds, by the SOM path the layout gives them (a repeating subform's
+/// first instance), with their template defaults: what a reset gives a floating field that has
+/// no widget of its own.
+pub fn embedded_fields(tpl: &Template) -> Vec<(String, String)> {
+ crate::data::fields_with_ids(tpl)
+ .into_iter()
+ .filter(|(id, _, _)| tpl.embedded.contains(*id))
+ .map(|(_, som, f)| (som, f.value.plain().unwrap_or_default()))
+ .collect()
+}
+
+/// Does `path` hold the data of a field embedded in rich text, in any instance of a repeating
+/// subform? See [`field_is_embedded`].
+pub fn path_is_embedded(tpl: &Template, path: &crate::DataPath) -> bool {
+ !tpl.embedded_paths.is_empty() && tpl.embedded_paths.contains(&path.iter().map(|(name, _)| name.clone()).collect::>())
+}
+
/// Give the field at `som` in `live`, and every other field bound to the same data node (a
/// field inside an area shares its node with one outside, since areas are left out of data
/// paths), the value [`form_tree`] would give it from `data` now, so a value change needs no
diff --git a/crates/xfa/src/table_repeat_tests.rs b/crates/xfa/src/table_repeat_tests.rs
new file mode 100644
index 0000000000..11acfb66c0
--- /dev/null
+++ b/crates/xfa/src/table_repeat_tests.rs
@@ -0,0 +1,365 @@
+//! A table (`layout="table"`, its rows `layout="row"`) placed in positioned content repeats a
+//! row subform once per instance, as a table in flowed content does: the template's `occur`
+//! and the data's rows decide how many (XFA 3.3, "Tables", p. 327; "Repeating Elements using
+//! Occurrence Limits", p. 263; Chapter 9, p. 336), and the data rows past `occur max` or the
+//! layout's own limit are reported, not dropped silently.
+
+use super::*;
+use crate::layout::Item;
+use crate::model::Rect;
+
+/// Where the table sits.
+#[derive(Clone, Copy, Debug)]
+enum Place {
+ /// At its own `x`/`y` in a positioned subform.
+ Positioned,
+ /// In a top-to-bottom subform at a position in a positioned subform, with a field after it.
+ PositionedFlow,
+ /// In the flowed body, with a field after it.
+ Flowed,
+}
+
+/// A form with a table whose header row (`head`, shown once) and footer row (`foot`) frame a
+/// repeating `item` row with `occur` attributes `occur`, and data holding `rows` items (and two
+/// header rows, which the template shows once). Each row is 20pt tall.
+fn table_form(place: Place, occur: &str, rows: usize) -> String {
+ let items: String = (1..=rows).map(|i| format!("row {i} ")).collect();
+ let table = r#"
+
+
+
+ "#
+ .replace("OCCUR", occur);
+ let after = r#" "#;
+ let (page, data) = match place {
+ Place::Positioned => {
+ (format!(r#"{} "#, table.replace("POS", r#"x="36pt" y="72pt""#)), "page1")
+ }
+ Place::PositionedFlow => (
+ format!(
+ r#"{}{after} "#,
+ table.replace("POS", "")
+ ),
+ "page1> (format!(r#"{}{after} "#, table.replace("POS", "")), "page1"),
+ };
+ let close: String = data.split("><").collect::>().iter().rev().map(|n| format!("{n}>")).collect();
+ format!(
+ r#"
+
+
+ {page}
+
+<{data}>{close}
+ "#
+ )
+}
+
+/// A laid-out widget: (field name, SOM, value, rect).
+type Laid = (String, String, Option, Rect);
+
+/// The laid-out widgets, in order.
+fn laid_out(form: &Form) -> Vec {
+ form.pages
+ .iter()
+ .flat_map(|p| p.items.iter())
+ .filter_map(|it| if let Item::Widget(w) = it { Some((w.name.clone(), w.som.clone(), w.value.clone(), w.rect)) } else { None })
+ .collect()
+}
+
+fn named<'a>(all: &'a [Laid], field: &str) -> Vec<&'a Laid> {
+ all.iter().filter(|w| w.1.ends_with(&format!(".{field}[0]"))).collect()
+}
+
+fn qty_values(all: &[Laid]) -> Vec {
+ named(all, "qty").iter().map(|w| w.2.clone().unwrap_or_default()).collect()
+}
+
+fn expected(n: usize) -> Vec {
+ (1..=n).map(|i| format!("row {i}")).collect()
+}
+
+fn cut_warning(form: &Form) -> Option<&String> {
+ form.warnings.iter().find(|w| w.contains("rows; only the first"))
+}
+
+/// Header, the rows and footer one under another, 20pt apart, from the header's top.
+fn assert_stacked(all: &[Laid], rows: usize, what: &str) {
+ let head = named(all, "label");
+ assert_eq!(head.len(), 1, "{what}: the header row is shown once: {head:?}");
+ let top = head[0].3.y;
+ let qty = named(all, "qty");
+ assert_eq!(qty.len(), rows, "{what}");
+ for (i, w) in qty.iter().enumerate() {
+ assert!((w.3.y - (top + 20.0 * (i as f64 + 1.0))).abs() < 0.01, "{what}: row {i} at {:?}, header at {top}", w.3);
+ }
+ let foot = named(all, "total");
+ assert_eq!(foot.len(), 1, "{what}: the footer row is shown once");
+ assert!((foot[0].3.y - (top + 20.0 * (rows as f64 + 1.0))).abs() < 0.01, "{what}: footer at {:?} after {rows} rows", foot[0].3);
+}
+
+#[test]
+fn a_table_in_positioned_content_gets_a_row_per_data_row() {
+ for place in [Place::Positioned, Place::PositionedFlow] {
+ let form = layout_xml(&table_form(place, r#"min="1" max="-1""#, 5)).unwrap();
+ let all = laid_out(&form);
+ assert_eq!(qty_values(&all), expected(5), "{place:?}");
+ let soms: Vec<&str> = named(&all, "qty").iter().map(|w| w.1.as_str()).collect();
+ let prefix = if matches!(place, Place::Positioned) { "form[0].page1[0].table[0]" } else { "form[0].page1[0].wrap[0].table[0]" };
+ let want: Vec = (0..5).map(|i| format!("{prefix}.item[{i}].qty[0]")).collect();
+ assert_eq!(soms, want, "{place:?}: each row with its own SOM index");
+ assert_stacked(&all, 5, &format!("{place:?}"));
+ assert_eq!(named(&all, "label")[0].2.as_deref(), Some("H"), "{place:?}: the header takes the first header data row");
+ assert!(cut_warning(&form).is_none(), "{place:?}: {:?}", form.warnings);
+ }
+}
+
+#[test]
+fn a_table_in_positioned_content_is_measured_with_its_rows() {
+ // What follows the table in a top-to-bottom container comes after the footer, not after one
+ // row: the table is measured with every instance.
+ let form = layout_xml(&table_form(Place::PositionedFlow, r#"min="1" max="-1""#, 5)).unwrap();
+ let all = laid_out(&form);
+ let foot = named(&all, "total")[0].3;
+ let after = named(&all, "after")[0].3;
+ assert!((after.y - foot.bottom()).abs() < 0.01, "after {after:?}, footer {foot:?}");
+}
+
+#[test]
+fn occur_max_bounds_a_positioned_table_and_the_rows_cut_are_reported() {
+ for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
+ let form = layout_xml(&table_form(place, r#"min="1" max="3""#, 5)).unwrap();
+ let all = laid_out(&form);
+ assert_eq!(qty_values(&all), expected(3), "{place:?}");
+ assert_stacked(&all, 3, &format!("{place:?}"));
+ let warning = cut_warning(&form).unwrap_or_else(|| panic!("{place:?}: {:?}", form.warnings));
+ assert!(warning.contains("item") && warning.contains("5 rows") && warning.contains("first 3"), "{place:?}: {warning}");
+ }
+}
+
+#[test]
+fn without_data_rows_a_positioned_table_takes_its_initial_count() {
+ let form = layout_xml(&table_form(Place::Positioned, r#"min="1" max="-1" initial="2""#, 0)).unwrap();
+ let all = laid_out(&form);
+ assert_stacked(&all, 2, "initial 2");
+ assert!(cut_warning(&form).is_none(), "{:?}", form.warnings);
+}
+
+#[test]
+fn a_positioned_table_lays_out_the_rows_a_flowed_one_does() {
+ // One rule for both: the same rows, values, spacing and warnings, wherever the table sits.
+ for (occur, rows) in [(r#"min="1" max="-1""#, 5), (r#"min="2" max="4""#, 7), (r#"min="1" max="-1""#, 0), (r#"min="3""#, 1)] {
+ let flowed = layout_xml(&table_form(Place::Flowed, occur, rows)).unwrap();
+ let positioned = layout_xml(&table_form(Place::Positioned, occur, rows)).unwrap();
+ let (f, p) = (laid_out(&flowed), laid_out(&positioned));
+ assert_eq!(qty_values(&p), qty_values(&f), "{occur} with {rows} rows");
+ let offsets = |all: &[Laid]| -> Vec {
+ let top = named(all, "label").first().map_or(0.0, |w| w.3.y);
+ all.iter().filter(|w| w.0 != "after").map(|w| w.3.y - top).collect()
+ };
+ assert_eq!(offsets(&p), offsets(&f), "{occur} with {rows} rows");
+ assert_eq!(cut_warning(&positioned).is_some(), cut_warning(&flowed).is_some(), "{occur} with {rows} rows");
+ }
+}
+
+#[test]
+fn hostile_occur_values_and_data_floods_lay_a_positioned_table_out_within_bounds() {
+ for occur in [
+ r#"min="-3" max="-7" initial="99999999999999999999""#,
+ r#"min="abc" max="1e9" initial="-1""#,
+ r#"min="999999" max="999999" initial="999999""#,
+ r#"max="0""#,
+ r#"min="0" max="0" initial="0""#,
+ ] {
+ for rows in [0, 4] {
+ let form = layout_xml(&table_form(Place::Positioned, occur, rows)).unwrap();
+ let flowed = layout_xml(&table_form(Place::Flowed, occur, rows)).unwrap();
+ assert_eq!(qty_values(&laid_out(&form)).len(), qty_values(&laid_out(&flowed)).len(), "{occur} with {rows} rows");
+ }
+ }
+ // More data rows than the layout lays out: bounded, and said.
+ let rows = 1_200;
+ let form = layout_xml(&table_form(Place::Positioned, r#"min="1" max="-1""#, rows)).unwrap();
+ let n = qty_values(&laid_out(&form)).len();
+ assert!((1..rows).contains(&n), "{n} of {rows} rows");
+ let warning = cut_warning(&form).unwrap_or_else(|| panic!("{:?}", form.warnings));
+ assert!(warning.contains(&format!("{rows} rows")) && warning.contains(&format!("first {n}")), "{warning}");
+}
+
+/// A repeating positioned `entry` subform, each instance holding a positioned table whose `item`
+/// rows repeat, with a field after the entries; the data gives entry `i` `rows[i]` rows.
+fn nested_form(place: Place, rows: &[usize]) -> String {
+ let entries: String = rows
+ .iter()
+ .enumerate()
+ .map(|(e, n)| {
+ let items: String = (1..=*n).map(|i| format!("{e}.{i} ")).collect();
+ format!(" ")
+ })
+ .collect();
+ let entry = r#"
+
+
+ "#;
+ let after = r#" "#;
+ let (page, open, close) = match place {
+ Place::PositionedFlow => (
+ format!(
+ r#"{entry}{after} "#
+ ),
+ "",
+ " ",
+ ),
+ _ => (format!(r#"{entry}{after} "#), "", " "),
+ };
+ format!(
+ r#"
+
+
+ {page}
+
+{open}{entries}{close}
+ "#
+ )
+}
+
+#[test]
+fn each_instance_of_a_repeating_container_is_measured_with_its_own_rows() {
+ // Instances of one repeating subform hold different numbers of rows in the data (XFA 3.3,
+ // "Tables", p. 327; Chapter 9, p. 336): each takes the height of its own rows, so the rows
+ // of one never run into the next instance or what follows.
+ for place in [Place::Flowed, Place::PositionedFlow] {
+ for rows in [[1, 5], [5, 1], [2, 3]] {
+ let what = format!("{place:?} {rows:?}");
+ let form = layout_xml(&nested_form(place, &rows)).unwrap();
+ let all = laid_out(&form);
+ let qty = named(&all, "qty");
+ let want: Vec = rows.iter().enumerate().flat_map(|(e, n)| (1..=*n).map(move |i| format!("{e}.{i}"))).collect();
+ assert_eq!(qty.iter().map(|w| w.2.clone().unwrap_or_default()).collect::>(), want, "{what}");
+ let top = qty[0].3.y;
+ for (i, w) in qty.iter().enumerate() {
+ assert!((w.3.y - (top + 20.0 * i as f64)).abs() < 0.01, "{what}: row {i} at {:?}, first at {top}", w.3);
+ }
+ let after = named(&all, "after")[0].3;
+ let last = qty[qty.len() - 1].3;
+ assert!((after.y - last.bottom()).abs() < 0.01, "{what}: after {after:?}, last row {last:?}");
+ }
+ }
+}
+
+#[test]
+fn each_row_instance_takes_the_height_of_its_own_growing_text() {
+ // A repeating row whose cell grows with its text (a multi-line field without a height) is
+ // as tall as its own instance's value (XFA 3.3, "Tables", p. 327; "Growable Containers",
+ // p. 271): a long value makes its row taller and pushes the rows after it down, wherever
+ // the table sits.
+ let long = "a long value that wraps over several lines of the cell ".repeat(6);
+ for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
+ let xml = table_form(place, r#"min="1" max="-1""#, 3)
+ .replace(
+ r#" "#,
+ r#" "#,
+ )
+ .replace("row 2 ", &format!("{long} "));
+ let form = layout_xml(&xml).unwrap();
+ let all = laid_out(&form);
+ let qty = named(&all, "qty");
+ assert_eq!(qty.len(), 3, "{place:?}");
+ let (short, tall) = (qty[0].3, qty[1].3);
+ assert!(tall.h > short.h + 20.0, "{place:?}: the long row {tall:?} is no taller than a short one {short:?}");
+ for pair in qty.windows(2) {
+ let (a, b) = (pair[0].3, pair[1].3);
+ assert!((b.y - a.bottom()).abs() < 0.01, "{place:?}: row at {b:?} after {a:?}");
+ }
+ let foot = named(&all, "total")[0].3;
+ assert!((foot.y - qty[2].3.bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", qty[2].3);
+ if let Some(after) = named(&all, "after").first() {
+ assert!((after.3.y - foot.bottom()).abs() < 0.01, "{place:?}: after {:?}, footer {foot:?}", after.3);
+ }
+ }
+}
+
+/// A multi-line `qty` text field 100pt wide that grows with its text, with `access` and
+/// template value `value`.
+fn qty_cell(access: &str, value: &str) -> String {
+ format!(
+ r#"{value} "#
+ )
+}
+
+/// [`table_form`] with two same-named cells per `item` row, `qty[0]` and `qty[1]` (`first` and
+/// `second`), in two 100pt columns, and the data of row `i` holding `data[i]`.
+fn same_name_cells_form(place: Place, first: &str, second: &str, data: [&str; 3]) -> String {
+ let mut xml = table_form(place, r#"min="1" max="-1""#, 3)
+ .replace(r#"columnWidths="200pt""#, r#"columnWidths="100pt 100pt""#)
+ .replace(r#" "#, &format!("{first}{second}"));
+ for (i, d) in data.iter().enumerate() {
+ xml = xml.replace(&format!("row {} ", i + 1), &format!("- {d}
"));
+ }
+ xml
+}
+
+#[test]
+fn a_row_is_as_tall_as_its_tallest_same_named_cell() {
+ // Each cell of a row is measured under its own SOM index among its same-named siblings,
+ // the index it is drawn and bound at (XFA 3.3, "Tables", p. 327; "Growable Containers",
+ // p. 271; Chapter 9, p. 336): a short `qty[0]` beside a long `qty[1]` makes the row as tall
+ // as the long one, and the rows and content after it start below it.
+ let long = "a long value that wraps over several lines of the cell".repeat(6);
+ let cell = qty_cell("open", "");
+ for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
+ let data = ["s1 t1 ", &format!("s2 {long} "), "s3 t3 "];
+ let form = layout_xml(&same_name_cells_form(place, &cell, &cell, data)).unwrap();
+ let all = laid_out(&form);
+ let first = named(&all, "qty");
+ let second: Vec<&Laid> = all.iter().filter(|w| w.1.ends_with(".qty[1]")).collect();
+ assert_eq!(first.iter().map(|w| w.2.clone().unwrap_or_default()).collect::>(), ["s1", "s2", "s3"], "{place:?}");
+ assert_eq!(second.len(), 3, "{place:?}");
+ assert_eq!(second[1].2.as_deref().map(str::trim), Some(long.trim()), "{place:?}");
+ let tall = second[1].3;
+ assert!(tall.h > 60.0, "{place:?}: the long cell {tall:?} did not grow");
+ // Both cells of the row take its height, and the next row starts below it.
+ assert!((first[1].3.h - tall.h).abs() < 0.01, "{place:?}: qty[0] {:?} beside qty[1] {tall:?}", first[1].3);
+ for cells in [&first, &second] {
+ for pair in cells.windows(2) {
+ let (a, b) = (pair[0].3, pair[1].3);
+ assert!((b.y - a.bottom()).abs() < 0.01, "{place:?}: row at {b:?} after {a:?}");
+ }
+ }
+ let foot = named(&all, "total")[0].3;
+ assert!((foot.y - second[2].3.bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", second[2].3);
+ if let Some(after) = named(&all, "after").first() {
+ assert!((after.3.y - foot.bottom()).abs() < 0.01, "{place:?}: after {:?}, footer {foot:?}", after.3);
+ }
+ }
+}
+
+#[test]
+fn a_long_non_interactive_same_named_cell_stays_in_its_row() {
+ // A non-interactive `qty[1]` is drawn as text, not as a widget; measured under its own SOM
+ // index (where the data holds nothing, so its long template value is what shows), each row
+ // holds its lines, and none run into the row after it or the footer.
+ let long = "longword ".repeat(12);
+ for place in [Place::Positioned, Place::PositionedFlow, Place::Flowed] {
+ let data = ["s1 ", "s2 ", "s3 "];
+ let form = layout_xml(&same_name_cells_form(place, &qty_cell("open", ""), &qty_cell("nonInteractive", &long), data)).unwrap();
+ let all = laid_out(&form);
+ let rows: Vec = named(&all, "qty").iter().map(|w| w.3).collect();
+ assert_eq!(rows.len(), 3, "{place:?}");
+ let lines: Vec = form
+ .pages
+ .iter()
+ .flat_map(|p| p.items.iter())
+ .filter_map(|it| if let Item::Text(s) = it { Some(s) } else { None })
+ .filter(|s| s.text.contains("longword"))
+ .map(|s| s.baseline)
+ .collect();
+ assert!(lines.len() >= 6, "{place:?}: {lines:?}");
+ let per_row: Vec = rows.iter().map(|r| lines.iter().filter(|&&b| b > r.y && b <= r.bottom()).count()).collect();
+ assert_eq!(per_row.iter().sum::(), lines.len(), "{place:?}: lines {lines:?} outside rows {rows:?}");
+ assert!(per_row.iter().all(|&n| n == per_row[0] && n > 1), "{place:?}: lines per row {per_row:?}");
+ let foot = named(&all, "total")[0].3;
+ assert!((foot.y - rows[2].bottom()).abs() < 0.01, "{place:?}: footer {foot:?} after {:?}", rows[2]);
+ }
+}
diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs
index 2e70a81472..02e8a205e5 100644
--- a/crates/xfa/src/tests.rs
+++ b/crates/xfa/src/tests.rs
@@ -147,6 +147,171 @@ fn horizontally_scaled_text_is_written_with_tz_inside_its_own_graphics_state() {
/// The sample template parses without warnings into what the layout uses: its page areas,
/// page-number draws, rich-text title, field font, caption and widget border, check box,
/// date field, radio group, table columns and page break all come through.
+/// Floating fields (XFA 3.3 p. 1222): the template knows which field ids rich text embeds,
+/// whether in a draw, a field's value or its caption; and a field that shows the sheet number
+/// (`sheet(this)+1`, however spaced) or the sheet count plays the page role, as every page is a
+/// sheet of its own on screen. A script that reads `sheet(this)` alone plays none.
+#[test]
+fn the_template_knows_its_floating_fields_and_sheet_roles() {
+ let span = |id: &str| format!(r##" "##);
+ let rich =
+ |id: &str| format!(r#"x {}
"#, span(id));
+ let ready = |id: &str, script: &str| {
+ format!(
+ r#" "#
+ )
+ };
+ let tpl = format!(
+ r#"
+{}{}{}
+{}
+{} {}
+ "#,
+ ready("sheet", "this.rawValue = xfa.layout.sheet(this) + 1"),
+ ready("sheets", "this.rawValue = xfa.layout.sheetCount()"),
+ ready("bare", "this.rawValue = xfa.layout.sheet(this)"),
+ rich("inDraw"),
+ rich("inCaption"),
+ rich("inValue"),
+ );
+ let (tpl, _) = parse(&tpl).unwrap();
+ assert_eq!(tpl.embedded.iter().map(String::as_str).collect::>(), ["inCaption", "inDraw", "inValue"]);
+ assert_eq!(tpl.page_roles, vec![("sheet".to_string(), PageRole::Number), ("sheets".to_string(), PageRole::Count)]);
+}
+
+/// An embedded sheet number is the page's own on every page, not a constant 1, and the sheet
+/// count is the number of pages.
+#[test]
+fn an_embedded_sheet_number_counts_the_pages() {
+ let span = |id: &str| format!(r##" "##);
+ let ready = |id: &str, script: &str| {
+ format!(
+ r#" "#
+ )
+ };
+ let tpl = format!(
+ r#"
+
+ {}{}
+ Sheet {} of {}
+
+first
+second
+ "#,
+ ready("sheet", "this.rawValue = xfa.layout.sheet(this)+1"),
+ ready("sheets", "this.rawValue = xfa.layout.sheetCount()"),
+ span("sheet"),
+ span("sheets")
+ );
+ let form = layout_xml(&tpl).unwrap();
+ assert_eq!(form.pages.len(), 2);
+ for (i, page) in form.pages.iter().enumerate() {
+ let shown = texts(page).join(" ");
+ assert!(shown.contains(&format!("Sheet {} of 2", i + 1)), "page {}: {shown}", i + 1);
+ }
+}
+
+/// A FormCalc script names the field `$`, and a field may have a ready script that does
+/// something else before the one that gives the sheet number: both still play the role.
+#[test]
+fn sheet_roles_are_read_from_formcalc_and_from_any_ready_script() {
+ let tpl = r#"
+
+
+
+
+
+
+
+
+ "#;
+ let (tpl, _) = parse(tpl).unwrap();
+ // FormCalc names methods case-insensitively, JavaScript does not.
+ assert_eq!(
+ tpl.page_roles,
+ vec![
+ ("fc".to_string(), PageRole::Number),
+ ("fcCase".to_string(), PageRole::Number),
+ ("fcCount".to_string(), PageRole::Count),
+ ("second".to_string(), PageRole::Number)
+ ]
+ );
+}
+
+/// The data paths of embedded fields are the ones scripts write: a page area's field under the
+/// area, as master pages lay it out; a field in a repeating row by name, whatever its instance.
+#[test]
+fn embedded_fields_are_known_by_the_data_paths_scripts_write() {
+ let span = |id: &str| format!(r##" "##);
+ let tpl = format!(
+ r#"
+
+
+ Stamp {}
+
+
+
+ Amount {}
+
+
+ "#,
+ span("stamp"),
+ span("amount")
+ );
+ let (tpl, _) = parse(&tpl).unwrap();
+ let names = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>();
+ assert_eq!(tpl.embedded_paths.iter().cloned().collect::>(), [names(&["form", "row", "amount"]), names(&["front", "stamp"])]);
+ assert!(crate::script::field_is_embedded(&tpl, "front[0].stamp[0]"), "a page area's field");
+ assert!(crate::script::field_is_embedded(&tpl, "form[0].row[3].amount[0]"), "any row");
+ assert!(!crate::script::field_is_embedded(&tpl, "form[0].plain[0]"));
+}
+
+/// Text embedding a field shows the value the data gives it: each row of a repeating subform
+/// its own row's, a page area's field the value scripts write under the area, a multi-select
+/// list its picks, a date in its display picture (XFA 3.3 p. 221) in every row; a row without
+/// data shows the template's default, not the first row's value.
+#[test]
+fn embedded_text_shows_each_instances_data() {
+ let span = |id: &str| format!(r##" "##);
+ let rich = |label: &str, id: &str| {
+ format!(r#"{label} {}
"#, span(id))
+ };
+ let xml = format!(
+ r#"
+
+
+
+ {}
+
+
+ none
+ date{{MM/DD/YYYY}}
+ {}
+ {}
+
+A B
+{}
+
+
+10 2026-11-04 |
20 2026-12-25 |
|
A B
+Paid
+ "#,
+ rich("Stamp", "stamp"),
+ rich("Amount", "amount"),
+ rich("Due", "due"),
+ rich("Picks", "picks")
+ );
+ let (tpl, _) = parse(&xml).unwrap();
+ let data = parse_datasets(&xml).unwrap();
+ let form = crate::layout::layout(&tpl, Some(&data)).unwrap();
+ let all: Vec = form.pages.iter().flat_map(texts).collect();
+ let joined = all.join(" | ");
+ for want in ["Stamp Paid", "Amount 10", "Amount 20", "Amount none", "Due 11/04/2026", "Due 12/25/2026", "Picks A"] {
+ assert!(joined.contains(want), "{want} in {joined}");
+ }
+ assert!(joined.contains('B'), "both picks: {joined}");
+}
+
#[test]
fn the_template_parses_with_fonts_captions_and_rich_text() {
let (tpl, warnings) = parse(&template(2)).unwrap();
@@ -188,6 +353,46 @@ fn the_template_parses_with_fonts_captions_and_rich_text() {
assert!(last.break_before_page);
}
+/// A page area that declares far more content areas than any real master keeps only the first
+/// 128, with a warning. `split_block` walks every content area of a page before starting a new
+/// one, re-scanning the unplaced items each time, so tens of thousands of them cost time
+/// quadratic in a splittable subform's paragraphs. Capping the count bounds that walk.
+#[test]
+fn a_page_area_keeps_at_most_a_hundred_and_twenty_eight_content_areas() {
+ let areas = (0..200).map(|i| format!("", i)).collect::();
+ let xml = format!(
+ r##"
+
+
+
+
+ {areas}
+
+
+
+
+"##
+ );
+ let (tpl, warnings) = parse(&xml).unwrap();
+ let ps = tpl.root.page_set.as_ref().unwrap();
+ assert_eq!(ps.areas[0].content.len(), 128, "the content areas are capped");
+ assert!(warnings.iter().any(|w| w.contains("content areas")), "a warning records the cap: {warnings:?}");
+ // Still said after a hundred other warnings (CodeRabbit review on #5844): images that can't
+ // be decoded, parsed before the page set, fill the warnings' shared cap first.
+ let bad = "!!! ".repeat(120);
+ let crowded = xml.replace("", &format!("{bad}"));
+ let (_, warnings) = parse(&crowded).unwrap();
+ assert!(warnings.len() > 100, "the other warnings came first: {}", warnings.len());
+ assert!(warnings.iter().any(|w| w.contains("content areas")), "the cap is still recorded");
+ // Many capped page areas say it once, found without scanning every warning (it comes first).
+ let page_area = |n: usize| format!(r#" {areas} "#);
+ let many: String = (0..50).map(page_area).collect();
+ let crowded = crowded.replacen(&page_area(0).replace("p0", "front"), &many, 1);
+ let (_, warnings) = parse(&crowded).unwrap();
+ assert_eq!(warnings.iter().filter(|w| w.contains("content areas")).count(), 1, "said once");
+ assert!(warnings[0].contains("content areas"), "kept first: {:?}", warnings.first());
+}
+
#[test]
fn rich_text_max_length_reaches_the_field_and_layout() {
for (attribute, expected) in [
@@ -299,6 +504,10 @@ fn tables_break_across_pages_and_repeat_their_header_row() {
assert_eq!(all.len(), all.iter().collect::>().len(), "field names are unique");
}
+/// Laying a dynamic form out into its PDF replaces the placeholder page with the template's pages
+/// and adds its fields (a radio group once), keeps the XFA packets and `/NeedsRendering` for Adobe's
+/// viewers, writes each `/DR` font as an object of its own, and marks the form so it isn't laid out
+/// twice.
#[test]
fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() {
let mut doc = Document::open(Arc::new(shell(&template(2)))).unwrap();
@@ -337,6 +546,71 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() {
assert!(!is_dynamic(&doc));
}
+/// A field or exclusion group that must be filled in (``) becomes a
+/// Required field, which viewers outline when they highlight a form's fields: a radio group on the
+/// group field, its buttons' flags being the group's (a button of its own that must be chosen
+/// makes the group required too). `nullTest="warning"` only recommends a value,
+/// so it, like no test, leaves the field optional. A push button keeps no value (ISO 32000-2
+/// §12.7.5.2.2), so it is never required.
+#[test]
+fn fields_that_must_be_filled_in_are_required() {
+ let xdp = r##"
+
+
+
+
+
+1 0
+Go
+
+ Y
+ N
+
+
+ A
+
+
+ B
+ C
+
+ "##;
+ let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
+ render_into(&mut doc).unwrap();
+ let catalog = doc.get(doc.root().unwrap());
+ let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
+ let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
+ let required: std::collections::BTreeMap = fields
+ .iter()
+ .map(|f| {
+ let d = doc.resolve(f).as_dict().unwrap().clone();
+ let name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
+ (name, d.int(b"Ff").unwrap_or(0) & 2 != 0)
+ })
+ .collect();
+ let leaf = |n: &str| required.iter().find(|(k, _)| k.contains(n)).map(|(_, r)| *r);
+ assert_eq!(leaf("must"), Some(true), "{required:?}");
+ assert_eq!(leaf("should"), Some(false), "a warning only recommends a value");
+ assert_eq!(leaf("may"), Some(false));
+ assert_eq!(leaf("agree"), Some(true), "a check box too");
+ assert_eq!(leaf("go"), Some(false), "a push button holds no value to fill in");
+ assert_eq!(leaf("answer"), Some(true), "the radio group field");
+ assert_eq!(leaf("optional"), Some(false));
+ assert_eq!(leaf("second"), Some(true), "a later button that must be chosen makes its group required");
+ // The buttons themselves carry no Required flag: theirs are the group's.
+ for group in ["answer", "second"] {
+ let field = fields
+ .iter()
+ .map(|f| doc.resolve(f).as_dict().unwrap().clone())
+ .find(|d| d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).is_some_and(|t| t.contains(group)));
+ let kids = field.and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k).as_array().cloned().unwrap_or_default())).unwrap_or_default();
+ assert!(!kids.is_empty(), "{group} has its buttons as kids");
+ for kid in &kids {
+ let ff = doc.resolve(kid).as_dict().and_then(|d| d.int(b"Ff")).unwrap_or(0);
+ assert_eq!(ff & 2, 0, "{group}: a button is not Required itself");
+ }
+ }
+}
+
/// A dynamic form's own text that the standard fonts can't draw is laid out anyway and reported
/// once, never written as `?` silently.
#[test]
@@ -356,6 +630,264 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() {
assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts")));
}
+/// A template whose page shows objects only on screen (`relevant="-print"`), only in print
+/// (`presence="invisible" relevant="+print"`) and everywhere, as fields and as draws, some of
+/// them inside a screen-only subform. `others` is put in the catalog (its own layers).
+fn relevant_template() -> String {
+ let field = |name: &str, attrs: &str| {
+ format!(r#"{name} "#)
+ };
+ let draw = |name: &str, attrs: &str| format!(r#"{name} text "#);
+ format!(
+ r##"
+
+
+ {}{}{}{}{}{}{}{}
+ {}{}
+ "##,
+ field("everywhere", ""),
+ field("screenOnly", r#"relevant="-print""#),
+ field("printOnly", r#"presence="invisible" relevant="+print""#),
+ field("nowhere", r#"presence="invisible""#),
+ draw("shown", ""),
+ draw("screenDraw", r#"relevant="-print""#),
+ draw("paperDraw", r#"presence="invisible" relevant="+print""#),
+ draw("otherView", r#"relevant="-screen""#),
+ field("inScreenBox", ""),
+ field("printInScreenBox", r#"relevant="+print""#),
+ )
+}
+
+/// `relevant` decides printing (`+print` prints even what its presence hides on screen, `-print`
+/// never prints) and presence decides the screen, inside containers too: a field's widget says
+/// where it shows, and a draw shown only on screen or only in print is wrapped as such. A token
+/// about another view is left aside with one warning.
+#[test]
+fn relevant_and_presence_say_where_fields_and_draws_show() {
+ let form = layout_xml(&relevant_template()).unwrap();
+ let page = &form.pages[0];
+ let views_of = |name: &str| widgets(page).iter().find(|w| w.name == name).map(|w| w.views);
+ let (screen, print) = (Views { screen: true, print: false }, Views { screen: false, print: true });
+ assert_eq!(views_of("everywhere"), Some(Views::ALL));
+ assert_eq!(views_of("screenOnly"), Some(screen));
+ assert_eq!(views_of("printOnly"), Some(print), "an invisible field that +print prints is drawn for print only");
+ assert_eq!(views_of("nowhere"), None, "an invisible field is still drawn nowhere");
+ assert_eq!(views_of("inScreenBox"), Some(screen), "inside a screen-only subform");
+ assert_eq!(views_of("printInScreenBox"), Some(screen), "+print can't print what its container keeps off paper");
+ // Draws: wrapped where they show only on screen or only in print.
+ let text = |want: &str| {
+ page.items.iter().find_map(|i| match i {
+ Item::Text(s) if s.text.contains(want) => Some(Views::ALL),
+ Item::Only { views, item } => matches!(item.as_ref(), Item::Text(s) if s.text.contains(want)).then_some(*views),
+ _ => None,
+ })
+ };
+ assert_eq!(text("shown text"), Some(Views::ALL));
+ assert_eq!(text("screenDraw text"), Some(screen));
+ assert_eq!(text("paperDraw text"), Some(print));
+ assert_eq!(text("otherView text"), Some(Views::ALL), "a view other than print is left aside");
+ let warned: Vec<&String> = form.warnings.iter().filter(|w| w.contains("relevant")).collect();
+ assert_eq!(warned.len(), 1, "{:?}", form.warnings);
+ assert!(warned[0].contains("-screen"), "{warned:?}");
+}
+
+/// The buttons of an exclusion group show where each one's own `relevant` and presence say,
+/// inside what the group says, as other fields do; a view other than print is warned about.
+#[test]
+fn exclusion_group_buttons_show_where_each_says() {
+ let button = |name: &str, on: &str, attrs: &str| {
+ format!(r#"{on} "#)
+ };
+ let body = format!(
+ r#"
+ {}{}{}{}{}
+ {}
+ "#,
+ button("plain", "1", ""),
+ button("screenOnly", "2", r#"relevant="-print""#),
+ button("printOnly", "3", r#"presence="invisible" relevant="+print""#),
+ button("nowhere", "4", r#"presence="invisible""#),
+ button("otherView", "5", r#"relevant="-preview""#),
+ button("printInScreenGroup", "1", r#"relevant="+print""#),
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let views_of = |name: &str| widgets(&form.pages[0]).iter().find(|w| w.som.ends_with(&format!(".{name}[0]"))).map(|w| w.views);
+ let (screen, print) = (Views { screen: true, print: false }, Views { screen: false, print: true });
+ assert_eq!(views_of("plain"), Some(Views::ALL));
+ assert_eq!(views_of("screenOnly"), Some(screen));
+ assert_eq!(views_of("printOnly"), Some(print), "an invisible button that +print prints is drawn for print only");
+ assert_eq!(views_of("nowhere"), None, "an invisible button is drawn nowhere");
+ assert_eq!(views_of("otherView"), Some(Views::ALL), "a view other than print is left aside");
+ assert_eq!(views_of("printInScreenGroup"), Some(screen), "+print can't print what its group keeps off paper");
+ let warned: Vec<&String> = form.warnings.iter().filter(|w| w.contains("relevant")).collect();
+ assert!(warned.len() == 1 && warned[0].contains("-preview"), "{:?}", form.warnings);
+}
+
+/// A page count shown only on screen or only in print is filled in like any other, and a view
+/// other than print named on a flowed subform or a table row is warned about as on a draw.
+#[test]
+fn restricted_page_counts_are_filled_in_and_other_views_warned_about_everywhere() {
+ let xfa_ns = r#"xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/""#;
+ let body = format!(
+ r##"
+
+ Pages:
+
+
+ Flowed
+ Cell
+ "##
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let texts: Vec = form.pages[0]
+ .items
+ .iter()
+ .filter_map(|i| match i {
+ Item::Only { item, .. } => match item.as_ref() {
+ Item::Text(s) => Some(s.text.clone()),
+ _ => None,
+ },
+ _ => None,
+ })
+ .collect();
+ assert!(texts.iter().any(|t| t.contains("Pages:") && t.contains('1')), "{texts:?}");
+ assert!(
+ form.pages
+ .iter()
+ .flat_map(|p| &p.items)
+ .all(|i| !matches!(i, Item::Only { item, .. } if matches!(item.as_ref(), Item::Text(s) if s.text.contains('\u{E000}')))),
+ "no page-count mark left"
+ );
+ for token in ["-screen", "-preview"] {
+ assert!(form.warnings.iter().any(|w| w.contains(token)), "{token}: {:?}", form.warnings);
+ }
+}
+
+/// A master page's own objects show where they say, even when the page was started by a
+/// screen-only subform that ran onto it: its footer isn't made screen-only too.
+#[test]
+fn a_master_page_started_by_a_screen_only_subform_keeps_its_own_objects_everywhere() {
+ let rows: String = (0..30).map(|i| format!(r#"Row {i} "#)).collect();
+ let xdp = format!(
+ r##"
+
+
+ Footer
+ {rows}
+ "##
+ );
+ let form = layout_xml(&xdp).unwrap();
+ assert!(form.pages.len() >= 2, "{} pages", form.pages.len());
+ for (i, page) in form.pages.iter().enumerate() {
+ let footer = page.items.iter().any(|it| matches!(it, Item::Text(s) if s.text.contains("Footer")));
+ assert!(footer, "page {i}'s footer shows everywhere, not wrapped as screen-only");
+ assert!(page.items.iter().any(|it| matches!(it, Item::Only { .. })), "page {i} has the screen-only rows");
+ }
+}
+
+/// The widgets' annotation flags, the draws' optional content and the catalog's layers: shown
+/// everywhere prints (`/F 4`), screen only doesn't print (`/F 0`), print only is NoView too
+/// (`/F 36`); screen-only and print-only draws are marked content in groups whose `/Usage` and the
+/// default configuration's `/AS` say where they show, the print-only one off on screen; the
+/// document's own layer stays as it was; and laying the form out again adds nothing twice.
+#[test]
+fn screen_only_and_print_only_objects_are_written_with_flags_and_layers() {
+ let xdp = relevant_template();
+ let mut bytes = shell(&xdp);
+ // A layer of the document's own, off, listed in /Order.
+ let s = String::from_utf8_lossy(&bytes).into_owned();
+ let s = s.replacen(
+ "/AcroForm << /XFA 5 0 R /Fields [] >> >>",
+ "/AcroForm << /XFA 5 0 R /Fields [] >> /OCProperties << /OCGs [<< /Type /OCG /Name (Mine) >>] /D << /Order [] /OFF [] >> >> >>",
+ 1,
+ );
+ assert_ne!(s.as_bytes(), bytes.as_slice(), "the shell's catalog was found");
+ bytes = s.into_bytes();
+ let mut doc = Document::open(Arc::new(bytes)).unwrap();
+ render_into(&mut doc).unwrap();
+ let check = |doc: &Document| {
+ // Widget flags by field name.
+ let catalog = doc.get(doc.root().unwrap());
+ let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap());
+ let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone();
+ let flags: std::collections::BTreeMap = fields
+ .iter()
+ .map(|f| doc.resolve(f))
+ .filter_map(|f| {
+ let d = f.as_dict()?;
+ Some((d.get(b"T").and_then(Object::as_string)?.to_text(), d.int(b"F").unwrap_or(-1)))
+ })
+ .collect();
+ assert_eq!(flags.get("everywhere"), Some(&4));
+ assert_eq!(flags.get("screenOnly"), Some(&0));
+ assert_eq!(flags.get("printOnly"), Some(&36));
+ // The groups, the configuration and the usage applications.
+ let props = doc.resolve(catalog.as_dict().unwrap().get(b"OCProperties").unwrap()).as_dict().unwrap().clone();
+ let ocgs = doc.resolve(props.get(b"OCGs").unwrap()).as_array().unwrap().clone();
+ assert_eq!(ocgs.len(), 3, "the document's own layer and the two groups, each once");
+ let mine = doc.resolve(ocgs.first().unwrap());
+ assert_eq!(mine.as_dict().unwrap().get(b"Name").and_then(Object::as_string).map(|s| s.to_text()).as_deref(), Some("Mine"));
+ let group = |key: &[u8]| {
+ ocgs.iter()
+ .filter_map(Object::as_ref)
+ .find(|r| doc.get(*r).as_dict().is_some_and(|d| d.name(crate::pdf::RELEVANT_KEY) == Some(key)))
+ .unwrap()
+ };
+ let (screen, print) = (group(b"Screen"), group(b"Print"));
+ let state = |r: pdfcraft_cos::ObjRef, event: &[u8], key: &[u8]| {
+ let g = doc.get(r);
+ let usage = g.as_dict().unwrap().get(b"Usage").unwrap().as_dict().unwrap().clone();
+ usage.get(event).unwrap().as_dict().unwrap().name(key).unwrap().to_vec()
+ };
+ assert_eq!((state(screen, b"View", b"ViewState"), state(screen, b"Print", b"PrintState")), (b"ON".to_vec(), b"OFF".to_vec()));
+ assert_eq!((state(print, b"View", b"ViewState"), state(print, b"Print", b"PrintState")), (b"OFF".to_vec(), b"ON".to_vec()));
+ let d = doc.resolve(props.get(b"D").unwrap()).as_dict().unwrap().clone();
+ let refs = |o: Option<&Object>| doc.resolve(o.unwrap()).as_array().unwrap().iter().filter_map(Object::as_ref).collect::>();
+ assert_eq!(refs(d.get(b"OFF")), [print], "only the print-only group starts off");
+ assert!(refs(d.get(b"Order")).is_empty(), "the document's /Order is left as it was");
+ let apps = doc.resolve(d.get(b"AS").unwrap()).as_array().unwrap().clone();
+ assert_eq!(apps.len(), 2, "one usage application per event");
+ for (app, event) in apps.iter().zip([&b"View"[..], b"Print"]) {
+ let app = doc.resolve(app).as_dict().unwrap().clone();
+ assert_eq!(app.name(b"Event"), Some(event));
+ assert_eq!(refs(app.get(b"OCGs")), [screen, print]);
+ }
+ // The page's marked content and resources.
+ let kids = doc
+ .resolve(doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()).as_dict().unwrap().get(b"Kids").unwrap())
+ .as_array()
+ .unwrap()
+ .clone();
+ let page = doc.resolve(kids.first().unwrap()).as_dict().unwrap().clone();
+ let res = doc.resolve(page.get(b"Resources").unwrap()).as_dict().unwrap().clone();
+ let properties = res.get(b"Properties").unwrap().as_dict().unwrap().clone();
+ assert_eq!((properties.reference(b"PCXfaScreen"), properties.reference(b"PCXfaPrint")), (Some(screen), Some(print)));
+ let Object::Stream(c) = &*doc.resolve(page.get(b"Contents").unwrap()) else { panic!("a content stream") };
+ let ops = String::from_utf8_lossy(&c.decoded().unwrap()).into_owned();
+ let inside = |group: &str, text: &str| {
+ let start = ops.find(&format!("/OC /{group} BDC")).unwrap_or(usize::MAX);
+ let at = ops.find(text).unwrap_or(0);
+ let end = ops.get(start.min(ops.len())..).and_then(|rest| rest.find("EMC")).map(|e| start + e).unwrap_or(0);
+ start < at && at < end
+ };
+ assert!(inside("PCXfaScreen", "(screenDraw text)") && inside("PCXfaPrint", "(paperDraw text)"), "{ops}");
+ // Shown everywhere: outside any marked content (here, before the first).
+ assert!(ops.find("(shown text)") < ops.find("/OC /"), "{ops}");
+ };
+ check(&doc);
+ // Laid out again: the same groups, nothing added twice.
+ let (tpl, _) = parse(&xdp).unwrap();
+ rerender(&mut doc, &tpl).unwrap();
+ check(&doc);
+ // Stripping the XFA keeps the layers the pages use, without PdfCraft's key.
+ strip(&mut doc).unwrap();
+ let catalog = doc.get(doc.root().unwrap());
+ let props = doc.resolve(catalog.as_dict().unwrap().get(b"OCProperties").unwrap()).as_dict().unwrap().clone();
+ let ocgs = doc.resolve(props.get(b"OCGs").unwrap()).as_array().unwrap().clone();
+ assert_eq!(ocgs.len(), 3);
+ assert!(ocgs.iter().all(|g| !doc.resolve(g).as_dict().unwrap().contains(crate::pdf::RELEVANT_KEY)));
+}
+
/// A field whose ui doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for
/// one line, `vScrollPolicy="off"` for several) is written with DoNotScroll (`/Ff` bit 24), so it
/// takes no more text than it shows; the other direction's policy, or the default, doesn't.
@@ -612,6 +1144,48 @@ fn links_to_anything_but_web_and_email_addresses_stay_plain_text() {
assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}");
}
+/// A link in text shown only on screen or only in print works only where its text shows: in
+/// print-only text it is NoView (`/F 36`), so it can't be clicked on screen over nothing, and in
+/// screen-only text it doesn't print (`/F 0`). Its pieces still join into one link across a change
+/// of style and the underline drawn with them.
+#[test]
+fn links_in_screen_only_and_print_only_text_work_where_their_text_shows() {
+ let draw = |name: &str, attrs: &str| {
+ format!(
+ r#"forms@example .com
"#
+ )
+ };
+ let xdp = format!(
+ r##"
+
+
+ {}{}{}
+ "##,
+ draw("everywhere", ""),
+ draw("screen", r#"relevant="-print""#),
+ draw("paper", r#"presence="invisible" relevant="+print""#),
+ );
+ let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
+ render_into(&mut doc).unwrap();
+ // Each link annotation's address and flags.
+ let catalog = doc.get(doc.root().unwrap());
+ let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
+ let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone();
+ let page = doc.resolve(kids.first().unwrap());
+ let annots = doc.resolve(page.as_dict().unwrap().get(b"Annots").unwrap()).as_array().unwrap().clone();
+ let links: Vec<(String, i64)> = annots
+ .iter()
+ .map(|a| doc.resolve(a).as_dict().unwrap().clone())
+ .filter(|d| d.name(b"Subtype") == Some(&b"Link"[..]))
+ .map(|d| {
+ let action = doc.resolve(d.get(b"A").unwrap()).as_dict().unwrap().clone();
+ (String::from_utf8_lossy(&action.get(b"URI").and_then(Object::as_string).unwrap().bytes).into_owned(), d.int(b"F").unwrap())
+ })
+ .collect();
+ let expected = [("https://example.com/everywhere", 4), ("https://example.com/screen", 0), ("https://example.com/paper", 36)];
+ assert_eq!(links, expected.map(|(u, f)| (u.to_string(), f)), "one link each, with flags where its text shows");
+}
+
#[test]
fn documents_without_xfa_are_not_touched() {
let mut doc = Document::new_empty();
@@ -678,6 +1252,98 @@ fn empty_repeating_subforms_spend_the_layout_budget() {
assert!(layout_xml(&tpl).is_ok());
}
+#[test]
+fn a_block_split_across_very_many_pages_spends_the_layout_budget() {
+ // One-point content areas slice a flowed subform's paragraphs one line to a page, so the
+ // block is split across hundreds of pages; split_block rescans the still-unplaced
+ // paragraphs once per page, which is quadratic in their number. Fewer paragraphs than this
+ // lay out (188 pages here), so the page limit does not catch it; the rescans spend the
+ // work budget instead.
+ let areas: String = (0..128).map(|i| format!("")).collect();
+ let paras: String = (0..2500).map(|i| format!("Paragraph number {i} with a few words to fill a line.
")).collect();
+ let tpl = format!(
+ "\
+ {areas} \
+ \
+ {paras} \
+ "
+ );
+ match layout_xml(&tpl) {
+ // It says what ran out, not that the form is nested too deeply (CodeRabbit review on #5844).
+ Err(XfaError::TooLarge(m)) => assert!(m.contains("split across"), "{m}"),
+ other => panic!("a block split across very many pages must be refused: {other:?}"),
+ }
+}
+
+#[test]
+fn large_text_drawn_in_many_instances_spends_the_draw_budget() {
+ // A draw whose text overflows its fixed height is re-measured up to six times to shrink it,
+ // and a repeating subform draws it once per instance. Large text drawn in many instances
+ // used to cost one full text layout per instance per pass, for seconds of work on a small
+ // file; each pass now counts its characters against the draw budget.
+ let words = "word ".repeat(4000); // 20,000 characters
+ let draw = format!("{words} ");
+ let many =
+ format!("{draw} ");
+ assert!(matches!(layout_xml(&many), Err(XfaError::TooLarge(_))), "large text in many instances must be refused");
+ // One copy of the same draw lays out: the budget does not reject an ordinary form.
+ let one = format!("{draw} ");
+ assert!(layout_xml(&one).is_ok(), "a single large draw must lay out");
+}
+
+/// The draw budget counts the values of embedded fields as well as the text around them: a short
+/// text embedding a long value, drawn in many instances, is as much work as the value written out
+/// each time (CodeRabbit review on #5852).
+#[test]
+fn embedded_values_drawn_in_many_instances_spend_the_draw_budget() {
+ let value = "word ".repeat(4000); // 20,000 characters
+ let span = r##" "##;
+ let draw = format!(
+ r#"x {span}
"#
+ );
+ let field = format!(r#"{value} "#);
+ let many =
+ format!(r#"{field} {draw} "#);
+ assert!(matches!(layout_xml(&many), Err(XfaError::TooLarge(_))), "a long embedded value in many instances must be refused");
+ // One copy lays out: the budget does not reject an ordinary form.
+ let one = format!(r#"{field}{draw} "#);
+ assert!(layout_xml(&one).is_ok(), "a single draw embedding a long value must lay out");
+}
+
+/// The last layout pass of a draw counts against the budget too: a draw that fits is measured
+/// once and laid out once, each pass expanding its embedded value, and when the form's last draw
+/// crosses the budget in that final pass it is still refused (CodeRabbit review on #5852).
+#[test]
+fn the_draw_that_crosses_the_budget_in_its_last_pass_is_refused() {
+ // 15,000 characters: with the 1 MiB test budget, the tenth such draw crosses it during its
+ // final layout pass, after every check before that pass has passed.
+ let value = "word ".repeat(3000);
+ let span = r##" "##;
+ let form = |n: usize| {
+ format!(
+ r#"{value} x {span}
"#
+ )
+ };
+ assert!(layout_xml(&form(9)).is_ok());
+ assert!(matches!(layout_xml(&form(10)), Err(XfaError::TooLarge(_))), "the draw crossing the budget in its last pass must be refused");
+}
+
+/// Once a fit pass spends the budget, no more passes are made: a draw that doesn't fit and embeds
+/// a long value is measured once, not seven times, before it is refused (CodeRabbit review on
+/// #5852).
+#[test]
+fn fit_passes_stop_once_the_draw_budget_is_spent() {
+ let value = "word ".repeat(text::MAX_EMBEDDED / 5 + 1000);
+ let span = r##" "##;
+ let tpl = format!(
+ r#"{value} x {span}
"#
+ );
+ crate::layout::EMBED_COPIED.with(|c| c.set(0));
+ assert!(matches!(layout_xml(&tpl), Err(XfaError::TooLarge(_))));
+ let copied = crate::layout::EMBED_COPIED.with(|c| c.get());
+ assert!(copied <= 2 * text::MAX_EMBEDDED as u64, "{copied} bytes copied: more passes after the budget was spent");
+}
+
#[test]
fn a_packet_stream_listed_many_times_is_read_once() {
let mut doc = Document::open(Arc::new(shell(" "))).unwrap();
@@ -895,19 +1561,34 @@ fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate
live.children.iter().find_map(|c| node_at(c, som))
}
-/// A date its data picture can't hold (2050 in a two-digit year) is written as ISO and reads
+/// A date its data picture can't hold (2030 in a two-digit year) is written as ISO and reads
/// back as written.
#[test]
fn a_date_its_data_picture_cant_hold_is_written_as_iso() {
use crate::data::{data_to_iso, iso_to_data};
assert_eq!(iso_to_data("2026-01-02".into(), Some("yymmdd")), "260102");
- // A two-digit year reads back as 1950 to 2049: 2050 and 1949 keep ISO, and read back as written.
- for iso in ["2050-01-02", "1949-12-31"] {
+ // A two-digit year reads back as 1930 to 2029, so those years are written in it.
+ for (iso, data) in [("2029-12-31", "291231"), ("1930-01-01", "300101"), ("1949-12-31", "491231"), ("1950-01-01", "500101")] {
+ assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), data, "{iso}");
+ }
+ // 2030 and 1929 keep ISO, and read back as written.
+ for iso in ["2030-01-01", "1929-12-31", "2050-01-02"] {
assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), iso);
assert_eq!(data_to_iso(iso, Some("yymmdd")), iso);
}
}
+/// A two-digit year splits its century at 30, the XFA picture clause default: `00` to `29` are
+/// 2000 to 2029 and `30` to `99` are 1930 to 1999, typed and in the data alike.
+#[test]
+fn a_two_digit_year_splits_its_century_at_30() {
+ use crate::data::data_to_iso;
+ for (text, iso) in [("291231", "2029-12-31"), ("300101", "1930-01-01"), ("491231", "1949-12-31"), ("500101", "1950-01-01")] {
+ assert_eq!(pattern_to_iso(text, "yymmdd").as_deref(), Some(iso), "{text}");
+ assert_eq!(data_to_iso(text, Some("yymmdd")), iso, "{text}");
+ }
+}
+
/// The datasets fill the fields and add the rows of repeating subforms.
#[test]
fn month_and_weekday_names_in_date_patterns() {
@@ -2102,6 +2783,47 @@ fn page_area_without_occur_serves_every_body_page() {
assert_eq!(soms[4].iter().filter(|s| s.contains("PrintButton")).count(), 1, "the required page is laid out once: {soms:?}");
}
+#[test]
+fn page_master_content_repeated_on_each_page_has_its_own_som_index() {
+ // One page area (no occur limit) serves three body pages and carries a field in its own
+ // master content. Each page's copy of that field gets the page area's instance index, so the
+ // three do not share one SOM path (and one data node).
+ let section = |n: u32| {
+ format!(
+ r#" "#
+ )
+ };
+ let xdp = format!(
+ r##"
+
+
+
+
+
+
+
+
+
+ {}{}{}
+
+
+
+"##,
+ section(1),
+ section(2),
+ section(3)
+ );
+ let form = layout_xml(&xdp).unwrap();
+ let page_nos: Vec = form.pages.iter().flat_map(|p| widgets(p)).filter(|w| w.som.contains("pageNo")).map(|w| w.som.clone()).collect();
+ assert_eq!(page_nos, ["Body[0].pageNo[0]", "Body[1].pageNo[0]", "Body[2].pageNo[0]"], "each page's master field has its own index");
+ // Its data path keeps that index, so each page binds apart; a field whose bind is global
+ // shares one data node, so every page binds it as instance 0 (XFA 3.3, "Page Background").
+ let (tpl, _) = crate::parse(&xdp).unwrap();
+ let dp = |som: &str| crate::data_path(&tpl, som);
+ assert_eq!(dp("Body[2].pageNo[0]"), crate::som_to_path("Body[2].pageNo[0]"));
+ assert_eq!(dp("Body[2].total[0]"), crate::som_to_path("Body[0].total[0]"), "a global field binds as instance 0 on each page");
+}
+
/// A page area's `occur min` is a page it must have even when the body never reaches it, and a
/// body that already used it does not get another. An area with no `` needs none.
#[test]
@@ -2412,8 +3134,9 @@ fn rounded_corners_make_a_border_one_rounded_shape() {
let round = at(0.0).expect("the rounded border is one shape");
assert_eq!((round.rect.w, round.rect.h, round.radii), (144.0, 72.0, [18.0; 4]));
assert_eq!((round.fill, round.edge), (Some([230.0 / 255.0; 3]), Some((1.0, [1.0, 0.0, 0.0], false))));
+ // Two corners alternate: the first is drawn top-left and bottom-right.
let mixed = at(1.2).expect("the box with two corners given");
- assert_eq!((mixed.radii, mixed.inverted), ([7.2, 14.4, 14.4, 14.4], [false, true, true, true]));
+ assert_eq!((mixed.radii, mixed.inverted), ([7.2, 14.4, 7.2, 14.4], [false, true, false, true]));
assert_eq!(at(2.4).expect("the huge radius").radii, [36.0; 4], "at most half the shorter side");
assert!(at(3.6).is_none() && at(4.8).is_none(), "hidden corners and unlike edges keep a square box");
let fill_only = at(6.0).expect("a fill with hidden edges is rounded too");
@@ -2431,6 +3154,34 @@ fn rounded_corners_make_a_border_one_rounded_shape() {
assert_eq!((lines_at(3.6), lines_at(4.8)), (4, 2));
}
+/// Corners not listed are drawn the way edges are, from the corner on the same side, as
+/// readers draw them (XFA 3.3 p. 38 gives the order top-left, top-right, bottom-right,
+/// bottom-left, and says the last corner listed is reused): one serves all four, two
+/// alternate, three reuse the second for the bottom-left.
+#[test]
+fn corners_not_listed_follow_the_edge_pattern() {
+ let radii = |corners: &str| {
+ let body = format!(
+ r#"d {corners} "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ rounded(&form.pages[0]).first().map(|r| r.radii)
+ };
+ let (round, square) = (r#" "#, r#" "#);
+ assert_eq!(radii(&format!("{round}{square}")), Some([18.0, 0.0, 18.0, 0.0]), "round top-left and bottom-right");
+ assert_eq!(radii(&format!("{square}{round}")), Some([0.0, 18.0, 0.0, 18.0]));
+ assert_eq!(
+ radii(r#" "#),
+ Some([5.0, 10.0, 15.0, 10.0]),
+ "the second serves the bottom-left"
+ );
+ assert_eq!(radii(round), Some([18.0; 4]));
+ for (listed, sides) in [(1, [0, 0, 0, 0]), (2, [0, 1, 0, 1]), (3, [0, 1, 2, 1]), (4, [0, 1, 2, 3])] {
+ assert_eq!(std::array::from_fn::<_, 4, _>(|i| crate::parse::drawn_side(listed, i)), sides.map(Some), "{listed}");
+ }
+ assert_eq!(crate::parse::drawn_side(0, 2), None);
+}
+
/// A rounded box a page break cuts keeps its rounded corners on each page, square where it was
/// cut, and its edge stops there instead of closing across the cut.
#[test]
@@ -2783,8 +3534,14 @@ fn the_form_packet_restores_the_presence_and_access_the_form_was_saved_with() {
assert_eq!(locked, [true, true, false, false]);
}
+/// A subform without a height, flowed or positioned, is as tall as what it holds, whatever its
+/// `minH`. XFA 3.3 p. 847 makes `minH` the minimum of a growable subform; this follows Adobe
+/// Acrobat instead. IRCC's IMM 5257 (04-2022, visitor visa) ends its first page with section 10
+/// (`MaritalStatus`), a positioned subform at `minH="58.274mm"` holding about 80pt, then an
+/// office-use box: Acrobat draws the section as tall as its fields, the box after it on page 1
+/// (5 pages); stretched to 165pt, the section pushed the box onto a page of its own.
#[test]
-fn a_flowed_subform_is_as_tall_as_its_content_whatever_its_minimum_height() {
+fn a_subform_is_as_tall_as_its_content_whatever_its_minimum_height() {
// A bar with a stale 3in `minH` holds one 0.2in line; the row after it follows directly.
let body = |bar: &str| {
format!(
@@ -2798,8 +3555,67 @@ fn a_flowed_subform_is_as_tall_as_its_content_whatever_its_minimum_height() {
};
let flowed = top(r#"layout="lr-tb""#);
assert!((top(r#"layout="tb""#) - flowed).abs() < 0.01);
- // A positioned subform keeps its minimum: nothing else says how tall it is.
- assert!((top("") - flowed - (216.0 - 14.4)).abs() < 0.01, "{} against {flowed}", top(""));
+ assert!((top("") - flowed).abs() < 0.01, "positioned: {} against {flowed}", top(""));
+}
+
+/// The cases around a positioned subform's `minH`: a `minH` below the content, content taller
+/// than `minH`, a `minH` above the content, a fixed `h` (alone and with a `minH` below it), and
+/// hidden children (which take no room). A table row, which no form we test gives a `minH`
+/// without an `h`, keeps its `minH` as XFA 3.3 p. 847 (subform, `minH`) says.
+#[test]
+fn a_positioned_subform_takes_its_content_height_and_a_row_keeps_its_minimum() {
+ // The field after a positioned 7.5in-wide bar starts where the bar ends.
+ let after = |bar_attrs: &str, content: &str| {
+ let body = format!(
+ r#"{content} "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ widgets(&form.pages[0])[0].rect.y
+ };
+ let line = |h: &str| format!(r#"Line "#);
+ let bare = after("", &line("0.2in"));
+ let inch = 72.0;
+ // A `minH` below the content, and content taller than the `minH`: the content's height.
+ assert!((after(r#"minH="0.1in""#, &line("0.2in")) - bare).abs() < 0.01);
+ assert!((after(r#"minH="0.1in""#, &line("1in")) - after("", &line("1in"))).abs() < 0.01);
+ assert!(after("", &line("1in")) - bare > 0.7 * inch, "taller content pushes the field down");
+ // A `minH` above the content: still the content's height, not the 3in.
+ let above = after(r#"minH="3in""#, &line("0.2in"));
+ assert!((above - bare).abs() < 0.01, "{above} against {bare}");
+ assert!((after(r#"minH="3in""#, &line("1in")) - after("", &line("1in"))).abs() < 0.01);
+ // A fixed `h` is the height, whatever the content.
+ assert!((after(r#"h="2in""#, &line("0.2in")) - after(r#"h="2in""#, &line("1in"))).abs() < 0.01);
+ assert!(after(r#"h="2in""#, &line("0.2in")) - bare > 1.7 * inch, "h=2in, not the 0.2in content");
+ let fixed = after(r#"h="2in" minH="1in""#, &line("0.2in"));
+ assert!((fixed - after(r#"h="2in""#, &line("0.2in"))).abs() < 0.01, "a minH below h changes nothing: {fixed}");
+ // A hidden child takes no room.
+ let hidden = format!(r#"{}Hidden "#, line("0.2in"));
+ assert!((after(r#"minH="3in""#, &hidden) - bare).abs() < 0.01, "{} against {bare}", after(r#"minH="3in""#, &hidden));
+
+ // A table row keeps its `minH`: one holding a 0.2in cell with `minH="3in"` is 3in tall.
+ let after_table = |row_min_h: &str| {
+ let body = format!(
+ r#"One "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ widgets(&form.pages[0])[0].rect.y
+ };
+ let grown = after_table(r#"minH="3in""#) - after_table("");
+ assert!((grown - 2.8 * inch).abs() < 0.01, "the row grew by {grown}pt");
+}
+
+#[test]
+fn a_caption_beside_a_check_box_wraps_in_its_reserve() {
+ // A 7.5in check box whose right-hand caption reserves 6.5in: two lines of text, not one or
+ // two words to a line in the 1in the box keeps. The field after it follows directly.
+ let words = "Applicant does not authorize the office to permit a participating office access to the application as filed. ".repeat(2);
+ let body = format!(
+ r#"{words} "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let w = widgets(&form.pages[0]);
+ let gap = w[1].rect.y - w[0].rect.y;
+ assert!(gap < 72.0, "the check box took {gap}pt");
}
#[test]
@@ -3329,6 +4145,155 @@ fn a_signature_field_does_not_repeat_a_name_another_field_has() {
}
}
+/// A caption without a font of its own is drawn in its field's font, not the font around the
+/// field (XFA 3.3, "Formatting Text That Appears as Fixed or Variable Content", p. 43): text is
+/// formatted by its containing element's font properties, inherited from an ancestor when it
+/// has none. A caption font that names only some properties takes the rest from the field.
+#[test]
+fn a_caption_without_a_font_of_its_own_takes_its_fields() {
+ let field = |name: &str, caption_font: &str| {
+ format!(
+ r#"{caption_font}{name}: "#
+ )
+ };
+ let body = format!(
+ r#"{}{}{} "#,
+ field("plain", ""),
+ field("own", r#" "#),
+ field("sized", r#" "#)
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let faces: Vec<(String, crate::text::Family, f64)> = form.pages[0]
+ .items
+ .iter()
+ .filter_map(|i| if let Item::Text(s) = i { Some((s.text.clone(), s.face.family, s.face.size)) } else { None })
+ .collect();
+ assert_eq!(
+ faces,
+ [
+ ("plain:".to_string(), crate::text::Family::Helvetica, 8.0),
+ ("own:".to_string(), crate::text::Family::Times, 11.0),
+ ("sized:".to_string(), crate::text::Family::Helvetica, 12.0)
+ ]
+ );
+}
+
+#[test]
+fn a_button_caption_without_a_font_of_its_own_takes_its_fields() {
+ // An Arial 8pt button without a height: a caption naming no font is shown in Arial 8pt, not
+ // the 10pt Courier default, and the button is as tall as one 8pt line; one naming only a size
+ // keeps the field's typeface.
+ let button = |caption_font: &str| {
+ let items = positioned(&format!(
+ r#"{caption_font}Go "#
+ ));
+ items.iter().find_map(|i| if let Item::Widget(w) = i { Some((w.face.family, w.face.size, w.rect.h)) } else { None }).expect("the button")
+ };
+ let (family, size, h) = button("");
+ assert_eq!((family, size), (crate::text::Family::Helvetica, 8.0));
+ assert!((h - text::line_height(8.0)).abs() < 0.05, "{h}");
+ let (family, size, h) = button(r#" "#);
+ assert_eq!((family, size), (crate::text::Family::Helvetica, 12.0));
+ assert!((h - text::line_height(12.0)).abs() < 0.05, "{h}");
+}
+
+/// A caption is measured as it is drawn: in its field's font (XFA 3.3, "Formatting Text That
+/// Appears as Fixed or Variable Content", p. 43) and in the paragraph around the field, not the
+/// field's own, which formats the field's value. A field paragraph's space above or line height
+/// gives its caption no extra room.
+#[test]
+fn a_caption_is_measured_in_the_font_and_paragraph_it_is_drawn_in() {
+ let field = |name: &str, para: &str, caption: &str| {
+ format!(
+ r#"Cap "#
+ )
+ };
+ let body = format!(
+ r#"{}{} "#,
+ field("top", r#"spaceAbove="0.5in""#, r#"placement="top""#),
+ field("left", r#"lineHeight="0.75in""#, r#"placement="left" reserve="1in""#),
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let ws = widgets(&form.pages[0]);
+ let rect = |n: &str| ws.iter().find(|w| w.name == n).map(|w| w.rect).unwrap();
+ let line = crate::text::line_height(14.0);
+ let (top, top_next) = (rect("top"), rect("top_next"));
+ // Above the box, the caption takes one line of the field's 14pt font.
+ assert!((top.y - 36.0 - line).abs() < 0.01, "the top caption's room is one 14pt line: {}", top.y - 36.0);
+ assert!((top_next.y - top.bottom()).abs() < 0.01, "and the field ends where its box does: {} {}", top_next.y, top.bottom());
+ // Beside the box, a one-line caption leaves the field the box's height.
+ let (left, left_next) = (rect("left"), rect("left_next"));
+ assert!((left_next.y - left.y - (line + 4.0)).abs() < 0.01, "a one-line caption beside the box adds no height: {}", left_next.y - left.y);
+}
+
+/// The room a caption with no font and no reserve takes is worked out in its field's font,
+/// resolved before the room is: beside the box, the caption text's width; above it, one line.
+/// A 24pt field's caption takes four times the room of a 6pt one's, and neither takes the
+/// room of the 10pt Courier default.
+#[test]
+fn a_caption_without_a_font_or_a_reserve_takes_room_in_its_fields_font() {
+ // A 5in field at x=1in in 24pt or 6pt Arial, its caption naming no font and no reserve.
+ let field = |size: f64, placement: &str| {
+ let items = positioned(&format!(
+ r#"Name: "#
+ ));
+ let rect = items.iter().find_map(|i| if let Item::Widget(w) = i { Some(w.rect) } else { None }).expect("the field");
+ let cap = items.iter().find_map(|i| if let Item::Text(s) = i { Some((s.x, s.face.family, s.face.size)) } else { None }).expect("the caption");
+ (rect, cap)
+ };
+ let helvetica = |size: f64| text::Face {
+ family: crate::text::Family::Helvetica,
+ bold: false,
+ italic: false,
+ size,
+ color: [0.0; 3],
+ underline: false,
+ h_scale: 1.0,
+ };
+ let courier_room = text::Face { family: crate::text::Family::Courier, ..helvetica(10.0) }.width("Name:") + 2.0;
+ let mut rooms = Vec::new();
+ for size in [24.0, 6.0] {
+ let (rect, (cap_x, family, cap_size)) = field(size, "left");
+ // The box starts after the caption's room: its text's width in the field's font, plus 2pt.
+ let room = rect.x - cap_x;
+ let expected = helvetica(size).width("Name:") + 2.0;
+ assert!((room - expected).abs() < 0.05, "{size}pt: the caption takes {room}pt, not {expected}pt");
+ assert!((room - courier_room).abs() > 1.0, "{size}pt: not the default's {courier_room}pt");
+ // And it is drawn in that font, whole, in the room it was given.
+ assert_eq!((family, cap_size), (crate::text::Family::Helvetica, size), "drawn in the field's font");
+ rooms.push(room - 2.0);
+ // Above the box, the caption takes one line of the field's font.
+ // (The field's top is the box's, 0.5in down the page.)
+ let (top, _) = field(size, "top");
+ let above = top.y - 36.0;
+ assert!((above - text::line_height(size)).abs() < 0.05, "{size}pt: the top caption takes {above}pt");
+ }
+ assert!((rooms[0] / rooms[1] - 4.0).abs() < 0.01, "24pt against 6pt: {rooms:?}");
+}
+
+/// A caption font that names only a size takes the rest, its weight, style and family, from
+/// its field's font (XFA 3.3, "Formatting Text That Appears as Fixed or Variable Content", p. 43).
+#[test]
+fn a_caption_font_naming_only_a_size_takes_the_fields_weight_style_and_family() {
+ let items = positioned(
+ r#"Name: "#,
+ );
+ let face = items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.face.clone()) } else { None }).expect("the caption");
+ assert_eq!((face.family, face.bold, face.italic, face.size), (crate::text::Family::Times, true, true, 12.0));
+}
+
+/// A caption font that sets only the weight keeps its field's posture: bold from the caption,
+/// italic from the field, both on the caption (XFA 3.3, "Formatting Text That Appears as Fixed or
+/// Variable Content", p. 43).
+#[test]
+fn a_caption_font_naming_only_a_weight_keeps_the_fields_posture() {
+ let items = positioned(
+ r#"Name: "#,
+ );
+ let face = items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.face.clone()) } else { None }).expect("the caption");
+ assert_eq!((face.family, face.bold, face.italic, face.size), (crate::text::Family::Helvetica, true, true, 8.0));
+}
+
/// A signature box the file already holds a signature for shows that signature, the file's own
/// objects, and is locked; the other box stays unsigned.
#[test]
@@ -3573,7 +4538,8 @@ fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unre
assert!(import_data(&mut doc, &deepish, Some("form"), None).is_err());
assert_eq!(crate::data_of(&doc).unwrap().text_at(&som_to_path("form[0].page1[0].country[0]")), Some("CA"), "untouched");
// The data holds more rows than are laid out: said, not hidden.
- let rows: String = (0..60).map(|i| format!("- r{i}
1
")).collect();
+ let n = crate::layout::MAX_INSTANCES + 10;
+ let rows: String = (0..n).map(|i| format!("- r{i}
1
")).collect();
let many = scripted_template().replace(
" ",
&format!(
@@ -3581,7 +4547,7 @@ fn imported_data_is_written_with_the_packets_own_prefix_and_never_leaves_it_unre
),
);
let form = layout_xml(&many).unwrap();
- assert!(form.warnings.iter().any(|w| w.contains("60 rows") && w.contains("first 50")), "{:?}", form.warnings);
+ assert!(form.warnings.iter().any(|w| w.contains(&format!("{n} rows")) && w.contains(&format!("first {}", n - 10))), "{:?}", form.warnings);
}
#[test]
@@ -3880,6 +4846,92 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() {
assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6);
}
+/// `ready` is two events: `ref="$layout"` is layout:ready (`layoutReady`), anything else stays
+/// form:ready (`ready`), in the template and in the live form; page numbering is recognized.
+/// A field's page-number role comes from its layout:ready scripts, also when a form:ready script
+/// comes first; a field whose layout:ready script only reads the count gets none.
+#[test]
+fn page_roles_come_from_layout_ready_scripts() {
+ let xdp = r##"
+
+
+
+
+
+
+
+
+
+
+
+ "##;
+ let (tpl, _) = parse(xdp).unwrap();
+ assert_eq!(tpl.page_roles, vec![("no".to_string(), PageRole::Number)]);
+}
+
+/// A `layout:ready` script is an activity of its own (`layoutReady`), told apart from `form:ready`
+/// (`ready`), in the template and in the live form's events.
+#[test]
+fn a_layout_ready_event_is_told_apart_from_form_ready() {
+ let (tpl, _) = parse(&crate::fixtures::layout_ready_template()).unwrap();
+ assert_eq!(tpl.root.scripts.iter().map(|s| s.activity.as_str()).collect::>(), ["layoutReady"]);
+ let Node::Subform(page) = &tpl.root.children[0] else { panic!() };
+ let activity = |name: &str| match page.children.iter().find(|n| n.common().name.as_deref() == Some(name)) {
+ Some(Node::Field(f)) => f.scripts.iter().map(|s| s.activity.clone()).collect::>(),
+ _ => panic!("no field {name}"),
+ };
+ assert_eq!(activity("stamp"), ["layoutReady"]);
+ assert_eq!(activity("note"), ["ready"], "form:ready keeps its name");
+ // The live form's events carry the same names.
+ let live = form_tree(&tpl, None, &Overrides::default());
+ assert!(live.events.iter().any(|e| e.som == "form[0]" && e.activity == "layoutReady"), "{:?}", live.events);
+}
+
+/// A script numbers its own field's page when it gives that field a layout page call's result
+/// (`page`, `absPage`, `pageCount`): read from its code, not a comment or a string, by whole
+/// names, over several lines, and in bounded time for a hostile script. Page numbering is what
+/// the layout does itself.
+#[test]
+fn a_script_that_gives_its_field_a_page_call_numbers_it() {
+ let js = |s: &str| crate::numbers_pages(s, false);
+ let formcalc = |s: &str| crate::numbers_pages(s, true);
+ assert!(js("this.rawValue = xfa.layout.page(this);"));
+ assert!(js("this.rawValue = xfa.layout.pageCount();"));
+ assert!(!js("this.rawValue = \"laid out\";"));
+ // Only the code counts: a mention in a comment or a string is not a page number.
+ assert!(!js("// xfa.layout.pageCount() was here\nthis.rawValue = \"ready\";"));
+ assert!(!js("/* xfa.layout.page(this) */ this.rawValue = 'see pageCount';"));
+ assert!(!js("this.rawValue = \"it's \\\"pageCount\\\"\";"));
+ assert!(js("var s = 'pageCount'; this.rawValue = xfa.layout.pageCount();"), "a string before the call");
+ assert!(formcalc("$ = xfa.layout.pageCount()"));
+ assert!(!formcalc("; xfa.layout.pageCount()\n$ = \"pageCount \"\" here\""));
+ assert!(formcalc("$ = \"a;b\" ; not a comment's end\n$ = xfa.layout.page($)"), "a ; inside a string isn't a comment");
+ // Only a script that gives its own field the page call's result numbers that field.
+ assert!(!js("other.rawValue = xfa.layout.pageCount(); this.rawValue = 'x';"), "it numbers another field");
+ assert!(!js("var n = xfa.layout.pageCount(); this.rawValue = 'pages: ' + n;"), "it only reads the count");
+ assert!(!js("if (this.rawValue == xfa.layout.page(this)) { }"), "a comparison, not an assignment");
+ assert!(formcalc("$.rawValue = xfa.layout.page($)"));
+ // The calls are matched as whole names, spaces allowed before the parenthesis.
+ assert!(!js("var pageCounter = 1; this.rawValue = pageCounter;"));
+ assert!(!js("this.rawValue = mylayout.page(this);"));
+ assert!(js("this.rawValue = xfa.layout.page (this);"));
+ assert!(js("this.rawValue = xfa . layout . absPage(this);"));
+ // An assignment over several lines is read whole; the next statement is not part of it.
+ assert!(js("this.rawValue =\n xfa.layout.page(this);"));
+ assert!(js("this.rawValue = 'Page ' +\n xfa.layout.page(this)"));
+ assert!(!js("this.rawValue = 'x'\nother.rawValue = xfa.layout.page(this)"), "the next line is another statement");
+ assert!(js("if (true) { this.rawValue = xfa.layout.page(this) }"));
+ assert!(!js("that.this.rawValue = xfa.layout.page(this);"), "not the script's own field");
+ assert!(formcalc("$ =\n xfa.layout.page($)"));
+ assert!(!formcalc("$record.x = xfa.layout.page($)"), "not the field");
+ // A hostile script (a bracket left open, then a name repeated many times) is read in bounded
+ // time: each expression stops at its length cap, and only so many places are looked at.
+ let hostile = "$ = (\n".repeat(200_000);
+ let started = std::time::Instant::now();
+ assert!(!formcalc(&hostile));
+ assert!(started.elapsed() < std::time::Duration::from_secs(2), "took {:?}", started.elapsed());
+}
+
/// `typed_date` rewrites a date typed by another of a field's edit-picture patterns in the first
/// one, and leaves alone a date the first pattern reads, text no pattern reads, a field with one
/// pattern and a field that isn't a date.
@@ -3910,6 +4962,8 @@ fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keep
let short = "form[0].short[0]";
assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps");
assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001");
+ assert_eq!(typed(short, "19451018").as_deref(), Some("451018"), "45 reads back as 1945");
+ assert_eq!(typed(short, "20301018"), None, "2030 as 30 would read back as 1930");
// DDMMYYYY reads 20011203 as 1203-01-20, which YYMMDD can't keep; YYYYMMDD reads 2001-12-03.
assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps");
}
@@ -3957,7 +5011,12 @@ fn embedded_values_are_copied_out_only_as_far_as_the_budget() {
"#
);
crate::layout::EMBED_COPIED.with(|c| c.set(0));
- layout_xml(&tpl).unwrap();
+ // The draw budget counts embedded values too (1 MiB in tests), so it may stop the draw
+ // before its last pass; either way no more than this is copied out.
+ match layout_xml(&tpl) {
+ Ok(_) | Err(XfaError::TooLarge(_)) => {}
+ Err(e) => panic!("{e:?}"),
+ }
let copied = crate::layout::EMBED_COPIED.with(|c| c.get());
// The draw is measured at most seven times before it is laid out once.
assert!(copied <= 8 * text::MAX_EMBEDDED as u64 + 8 * 4 * 201, "{copied} bytes of embedded values copied");
@@ -4050,6 +5109,29 @@ fn long_names_repeated_by_an_index_are_capped() {
assert!(data.len() < 2_000_000, "{} bytes of data built", data.len());
}
+/// Forms with an `enter` or `exit` script, in the body, a radio group or a page master, have
+/// something to run when the focus moves; forms with other scripts only, or blank ones, don't.
+#[test]
+fn focus_scripts_are_found_wherever_the_template_has_them() {
+ let has = |xml: &str| has_focus_scripts(&parse(xml).unwrap().0);
+ assert!(has(&crate::fixtures::focus_template()));
+ let clicks =
+ crate::fixtures::focus_template().replace(r#"activity="enter""#, r#"activity="click""#).replace(r#"activity="exit""#, r#"activity="click""#);
+ assert!(!has(&clicks), "click scripts only");
+ let large = r#"L ", ["1", "2"]))).unwrap();
+ let group = FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].lang[0]".into(), data: FieldData::None };
+ let write = |sel: &str| crate::data::radio_dataset_value(&doc, &group, &Some(sel.to_string()));
+ assert_eq!(write("0"), "1");
+ assert_eq!(write("1"), "2");
+ // `2` is no state: it is the second button's item.
+ assert_eq!(write("2"), "2");
+ // Neither: written as given; nothing selected: empty.
+ assert_eq!(write("9"), "9");
+ assert_eq!(crate::data::radio_dataset_value(&doc, &group, &None), "");
+}
+
+/// A radio group's value is an on-state name as the AcroForm reads it: `#` and bytes that are
+/// not UTF-8 (a Shift-JIS state) written `#XX`. Saving and opening spell the buttons' states the
+/// same way, so a state `/A#23B` (the bytes `A#B`) or `/#82#CD` still finds its button and its
+/// `/Opt` item, both ways. Read lossily, neither state matched: saving wrote the state's text as
+/// the item, and opening returned a state no button has.
+#[test]
+fn radio_states_with_hash_or_non_utf8_bytes_find_their_buttons() {
+ let pdf = String::from_utf8(crate::fixtures::static_radio_shell(" "))
+ .unwrap()
+ .replace("/N << /0 ", "/N << /A#23B ")
+ .replace("/N << /1 ", "/N << /#82#CD ");
+ let doc = Document::open(Arc::new(pdf.into_bytes())).unwrap();
+ let group = FieldDatum { obj: pdfcraft_cos::ObjRef::new(8, 0), name: "form1[0].page1[0].lang[0]".into(), data: FieldData::None };
+ let write = |sel: &str| crate::data::radio_dataset_value(&doc, &group, &Some(sel.to_string()));
+ assert_eq!(write("A#23B"), "1", "the first button writes its /Opt item");
+ assert_eq!(write("#82#CD"), "2", "the second button writes its /Opt item");
+ assert_eq!(crate::data::radio_state_for_item(&doc, &group, "1").as_deref(), Some("A#23B"));
+ assert_eq!(crate::data::radio_state_for_item(&doc, &group, "2").as_deref(), Some("#82#CD"));
+ // The spelling is the forms crate's `name_text`.
+ for (bytes, text) in [(&b"Yes"[..], "Yes"), ("はい".as_bytes(), "はい"), (b"A#1", "A#231"), (b"\x82\xcd", "#82#CD"), (b"\xff#\x00", "#FF#23#00")]
+ {
+ assert_eq!(crate::data::name_text(bytes), text, "{bytes:?}");
+ }
+}
+
+/// #5153 review: a row outside a table keeps its `minH`, as a table row does (XFA 3.3 p. 847):
+/// one holding a 0.2in cell with `minH="3in"` is 3in tall, in a top-to-bottom and in a
+/// left-to-right parent, and the field after it starts 2.8in lower than after a row without one.
+#[test]
+fn a_row_outside_a_table_keeps_its_minimum_height() {
+ let inch = 72.0;
+ for parent in ["tb", "lr-tb"] {
+ let after = |row_min_h: &str| {
+ let body = format!(
+ r#"One "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ widgets(&form.pages[0])[0].rect.y
+ };
+ let grown = after(r#"minH="3in""#) - after("");
+ assert!((grown - 2.8 * inch).abs() < 0.01, "{parent}: the row grew by {grown}pt");
+ }
+}
+
+/// What a script sets on page-master content (a field hidden on the second page made from a
+/// page area) is applied to that page's copy only, when the form is laid out again: the other
+/// pages keep the field.
+#[test]
+fn a_script_hiding_master_content_on_one_page_hides_it_there_only() {
+ let section = |n: u32| {
+ format!(
+ r#" "#
+ )
+ };
+ let xdp = format!(
+ r##"
+
+
+
+
+
+
+
+
+ {}{}{}
+
+
+
+"##,
+ section(1),
+ section(2),
+ section(3)
+ );
+ let (tpl, _) = crate::parse(&xdp).unwrap();
+ let mut ov = Overrides::default();
+ ov.presence.insert("Body[1].pageNo[0]".into(), "hidden".into());
+ let laid = apply_overrides(&tpl, &ov);
+ let form = crate::layout::layout_with_overrides(&laid, None, &ov).unwrap();
+ let has: Vec = form.pages.iter().map(|p| widgets(p).iter().any(|w| w.som.contains("pageNo"))).collect();
+ assert_eq!(has, [true, false, true], "hidden on the second page only");
+}
+
+/// Hidden master widgets still count their page area, and removed pages leave no
+/// stale count when a page set has several page areas (wireless89, #5368).
+#[test]
+fn page_area_uses_follow_hidden_masters_and_shrink_with_multiple_areas() {
+ let xml = fixtures::repeated_print_button_template()
+ .replace(
+ "",
+ r#"
+ "#,
+ )
+ .replace("", "")
+ .replace("name=\"PrintButton1\"", "name=\"PrintButton1\" presence=\"hidden\"");
+ let (tpl, _) = parse(&xml).unwrap();
+ let mut doc = Document::open(Arc::new(shell(&xml))).unwrap();
+ render_into(&mut doc).unwrap();
+ let uses = page_area_uses(&doc, &tpl);
+ assert_eq!(uses.get("Cover"), Some(&1));
+ assert_eq!(uses.get("master"), Some(&2));
+ let mut ov = Overrides::default();
+ ov.presence.insert("form[0].page3[0]".into(), "hidden".into());
+ set_overrides(&mut doc, &ov).unwrap();
+ rerender(&mut doc, &tpl).unwrap();
+ assert_eq!(page_area_uses(&doc, &tpl).get("master"), Some(&1));
+ ov.presence.insert("form[0].page2[0]".into(), "hidden".into());
+ set_overrides(&mut doc, &ov).unwrap();
+ rerender(&mut doc, &tpl).unwrap();
+ let uses = page_area_uses(&doc, &tpl);
+ assert_eq!(uses.get("Cover"), Some(&1));
+ assert!(!uses.contains_key("master"), "removed pages leave no stale occurrence metadata: {uses:?}");
+}
+
+/// A `subformSet` is transparent to layout (XFA 3.3 p. 357): in a positioned subform its members
+/// sit at their own positions, side by side as a public stamp-duty form's two columns do, not one
+/// under the other; in a flowed one they still go one under another.
+#[test]
+fn a_subform_set_in_a_positioned_subform_places_its_members_where_they_are() {
+ let xml = |layout: &str| {
+ format!(
+ r#"
+
+
+
+ left
+ right
+
+
+ "#
+ )
+ };
+ let baseline = |layout: &str, text: &str| {
+ let form = crate::layout::layout(&crate::parse(&xml(layout)).unwrap().0, None).unwrap();
+ form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { (s.text == text).then_some(s.baseline) } else { None }).unwrap()
+ };
+ assert!((baseline(r#"h="3in""#, "left") - baseline(r#"h="3in""#, "right")).abs() < 0.01, "positioned: side by side");
+ assert!(baseline(r#"layout="tb""#, "right") - baseline(r#"layout="tb""#, "left") > 140.0, "flowed: one under the other");
+}
+
+/// In a flowing (`lr-tb`) subform a `subformSet`'s members go on among their parent's other
+/// children (XFA 3.3 p. 357): beside a 1in field in a 2in row, the first 0.6in member takes the
+/// rest of that line and the second wraps, rather than the set moving to the next line whole.
+/// The members keep the set in their SOM paths.
+#[test]
+fn a_subform_set_in_an_lr_tb_subform_flows_its_members_with_their_siblings() {
+ let body = r#"
+
+
+
+
+
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ let w = widgets(&form.pages[0]);
+ let at = |name: &str| w.iter().find(|w| w.name == name).map(|w| (w.som.clone(), w.rect.x, w.rect.y)).unwrap();
+ let (_, ax, ay) = at("a");
+ let (xsom, xx, xy) = at("x");
+ let (ysom, yx, yy) = at("y");
+ assert!((xy - ay).abs() < 0.5 && (xx - (ax + 72.0)).abs() < 0.5, "the first member goes on beside a: a at ({ax}, {ay}), x at ({xx}, {xy})");
+ assert!(yy > ay + 1.0 && (yx - ax).abs() < 0.5, "the second wraps to the next line: y at ({yx}, {yy})");
+ assert!(xsom.ends_with("row[0].grp[0].m1[0].x[0]") && ysom.ends_with("row[0].grp[0].m2[0].y[0]"), "{xsom} {ysom}");
+}
+
+/// In a table, and in one of its rows, a `subformSet`'s members still go one under another: the
+/// set lays them out top to bottom there rather than taking the table's or the row's layout.
+#[test]
+fn a_subform_set_in_a_table_or_a_row_puts_its_members_one_under_another() {
+ let set = |name: &str| {
+ format!(
+ r#"
+
+
+ "#
+ )
+ };
+ let body = format!(
+ r#"
+ {}
+ {}
+ "#,
+ set("s"),
+ set("q")
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let w = widgets(&form.pages[0]);
+ let at = |name: &str| w.iter().find(|w| w.name == name).map(|w| (w.rect.x, w.rect.y)).unwrap();
+ for (first, second) in [("s1", "s2"), ("q1", "q2")] {
+ let ((x1, y1), (x2, y2)) = (at(first), at(second));
+ assert!((x1 - x2).abs() < 0.5 && y2 > y1 + 20.0, "{second} under {first}: ({x1}, {y1}) then ({x2}, {y2})");
+ }
+}
+
+/// The SOM path and place of each widget of the template's field `name`, in layout order, found
+/// by its SOM path (a repeated field's PDF name is made up: `x_2`).
+fn widgets_named(form: &Form, name: &str) -> Vec<(String, f64, f64)> {
+ let tail = format!(".{name}[0]");
+ widgets(&form.pages[0]).iter().filter(|w| w.som.ends_with(&tail)).map(|w| (w.som.clone(), w.rect.x, w.rect.y)).collect()
+}
+
+/// #5369 review: a `subformSet` inside a `subformSet` is transparent too: in an `lr-tb` row the
+/// inner set's member goes on beside the field before the outer set, the outer set's own member
+/// wraps, and the field after the sets follows it. Each member keeps both sets in its SOM path.
+#[test]
+fn nested_subform_sets_in_an_lr_tb_subform_flow_their_members_with_their_siblings() {
+ let body = r#"
+
+
+
+
+
+
+
+
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ let at = |name: &str| widgets_named(&form, name).into_iter().next().unwrap();
+ let ((_, ax, ay), (xsom, xx, xy), (ysom, yx, yy), (_, bx, by)) = (at("a"), at("x"), at("y"), at("b"));
+ assert!((xy - ay).abs() < 0.5 && (xx - (ax + 72.0)).abs() < 0.5, "x goes on beside a: a ({ax}, {ay}), x ({xx}, {xy})");
+ assert!(yy > ay + 1.0 && (yx - ax).abs() < 0.5, "y wraps: ({yx}, {yy})");
+ assert!((by - yy).abs() < 0.5 && (bx - (yx + 43.2)).abs() < 0.5, "b follows y: ({bx}, {by})");
+ assert!(xsom.ends_with("row[0].outer[0].inner[0].m1[0].x[0]") && ysom.ends_with("row[0].outer[0].m2[0].y[0]"), "{xsom} {ysom}");
+}
+
+/// #5369 review: in a positioned subform, the members of a `subformSet` nested in another sit at
+/// their own positions, side by side, as the members of one set do.
+#[test]
+fn nested_subform_sets_in_a_positioned_subform_place_their_members_where_they_are() {
+ let body = r#"
+
+
+
+
+
+
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ let at = |name: &str| widgets_named(&form, name).into_iter().next().unwrap();
+ let ((psom, px, py), (_, qx, qy)) = (at("p"), at("q"));
+ assert!((py - qy).abs() < 0.5 && (qx - px - 288.0).abs() < 0.5, "side by side, 4in apart: p ({px}, {py}), q ({qx}, {qy})");
+ assert!(psom.ends_with("box[0].outer[0].inner[0].l[0].p[0]"), "{psom}");
+}
+
+/// #5369 review: an empty `subformSet`, and one holding only an empty one, takes no room: in an
+/// `lr-tb` row the field after it goes on beside the field before it, and in a positioned or a
+/// `tb` subform the layout goes on as if it weren't there.
+#[test]
+fn an_empty_subform_set_takes_no_room() {
+ for (attrs, after_a) in [(r#"layout="lr-tb" w="2in""#, (72.0, 0.0)), (r#"w="7in" h="2in""#, (0.0, 0.0)), (r#"layout="tb" w="7in""#, (0.0, 21.6))]
+ {
+ let body = format!(
+ r#"
+
+
+
+
+ "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let at = |name: &str| widgets_named(&form, name).into_iter().next().unwrap();
+ let ((_, ax, ay), (_, bx, by)) = (at("a"), at("b"));
+ assert!((bx - ax - after_a.0).abs() < 0.5 && (by - ay - after_a.1).abs() < 0.5, "{attrs}: a ({ax}, {ay}), b ({bx}, {by})");
+ }
+}
+
+/// #5369 review: a repeating member of a `subformSet` flows instance by instance among its
+/// parent's other children in an `lr-tb` row, as a repeating child of the row would: the first
+/// instance beside the field before the set, the others wrapping, each with its own index.
+#[test]
+fn a_repeating_member_of_a_subform_set_flows_instance_by_instance() {
+ let body = r#"
+
+
+
+
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ let (_, ax, ay) = widgets_named(&form, "a").into_iter().next().unwrap();
+ let x = widgets_named(&form, "x");
+ assert_eq!(x.len(), 3, "{x:?}");
+ let places: Vec<(f64, f64)> = x.iter().map(|(_, px, py)| (px - ax, py - ay)).collect();
+ for ((got_x, got_y), (want_x, want_y)) in places.iter().zip([(72.0, 0.0), (0.0, 21.6), (43.2, 21.6)]) {
+ assert!((got_x - want_x).abs() < 0.5 && (got_y - want_y).abs() < 0.5, "{places:?}");
+ }
+ for (i, (som, _, _)) in x.iter().enumerate() {
+ assert!(som.ends_with(&format!("row[0].grp[0].m[{i}].x[0]")), "{som}");
+ }
+}
+
+/// #5369 review: a repeating `subformSet` lays its members out once per instance, each instance
+/// flowing on from the last in an `lr-tb` row, and each member taking its set's instance index.
+#[test]
+fn a_repeating_subform_set_flows_each_instance_of_its_members() {
+ let body = r#"
+
+
+
+
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ let (_, ax, ay) = widgets_named(&form, "a").into_iter().next().unwrap();
+ let x = widgets_named(&form, "x");
+ assert_eq!(x.len(), 2, "{x:?}");
+ let places: Vec<(f64, f64)> = x.iter().map(|(_, px, py)| (px - ax, py - ay)).collect();
+ for ((got_x, got_y), (want_x, want_y)) in places.iter().zip([(72.0, 0.0), (0.0, 21.6)]) {
+ assert!((got_x - want_x).abs() < 0.5 && (got_y - want_y).abs() < 0.5, "{places:?}");
+ }
+ for (i, (som, _, _)) in x.iter().enumerate() {
+ assert!(som.ends_with(&format!("row[0].grp[{i}].m[0].x[0]")), "{som}");
+ }
+}
+
+/// A flowed subform in a table cell is as tall as its content even when it gives a height, as
+/// other viewers lay out a public form's per-child section (a `tb` subform with `h="186mm"` in a
+/// row); a positioned cell, and a flowed subform outside a table, keep their heights.
+#[test]
+fn a_flowed_subform_in_a_table_cell_is_as_tall_as_its_content() {
+ let after_y = |cell: &str| {
+ let body = format!(
+ r#"{cell}
+ "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ widgets(&form.pages[0]).iter().find(|w| w.name == "after").map(|w| w.rect.y).unwrap()
+ };
+ let fields = r#" "#;
+ // The table starts at 0.5in (36pt); its content is two 0.3in fields (43.2pt).
+ let flowed = after_y(&format!(r#"{fields} "#));
+ assert!((flowed - (36.0 + 43.2)).abs() < 0.5, "flowed cell: the next object at {flowed}");
+ let positioned = after_y(r#" "#);
+ assert!((positioned - (36.0 + 360.0)).abs() < 0.5, "positioned cell keeps h: {positioned}");
+ let outside = {
+ let body = format!(
+ r#"{fields} "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ widgets(&form.pages[0]).iter().find(|w| w.name == "after").map(|w| w.rect.y).unwrap()
+ };
+ assert!((outside - (36.0 + 360.0)).abs() < 0.5, "a flowed subform outside a table keeps h: {outside}");
+ // Measured at the column's width, where it is drawn, not its own: in a 1in column, two 0.8in
+ // fields of a 5in-wide lr-tb cell go on two lines (43.2pt), not one.
+ let narrow = {
+ let two =
+ r#" "#;
+ let body = format!(
+ r#"{two}
+ "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ widgets(&form.pages[0]).iter().find(|w| w.name == "after").map(|w| w.rect.y).unwrap()
+ };
+ assert!((narrow - (36.0 + 43.2)).abs() < 0.5, "measured at the column's width: the next object at {narrow}");
+}
+
+/// The page and top of the field `after` once `body` is laid out in a top-to-bottom root whose
+/// content area starts 0.5in (36pt) down the page.
+fn page_and_y_of_after(body: &str) -> (usize, f64) {
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ form.pages
+ .iter()
+ .enumerate()
+ .find_map(|(i, p)| widgets(p).iter().find(|w| w.som.ends_with(".after[0]") || w.name == "after").map(|w| (i, w.rect.y)))
+ .unwrap()
+}
+
+/// A 2in by 0.3in text field.
+fn text_field(name: &str) -> String {
+ format!(r#" "#)
+}
+
+/// `cell` as the only cell of a one-row table with `columns`, followed by the field `after`.
+fn table_then_after(columns: &str, cell: &str) -> String {
+ format!(
+ r#"{cell} {}"#,
+ text_field("after")
+ )
+}
+
+/// #5370 review: moving the measurement of a node with no height into `content_height` leaves
+/// each kind of node as tall as its content: a draw, a field, an exclusion group, an area and a
+/// subform of every layout, each followed by a field that starts where it ends.
+#[test]
+fn every_kind_of_node_without_a_height_is_as_tall_as_its_content() {
+ let after = |node: &str| {
+ let (page, y) = page_and_y_of_after(&format!("{node}{}", text_field("after")));
+ assert_eq!(page, 0, "{node}");
+ y - 36.0
+ };
+ let two = format!("{}{}", text_field("a"), text_field("b"));
+ // Containers of 0.3in fields: their heights are exact.
+ let area = after(r#" "#);
+ assert!((area - 93.6).abs() < 0.5, "area: {area}");
+ let positioned = after(r#" "#);
+ assert!((positioned - 93.6).abs() < 0.5, "positioned subform: {positioned}");
+ let tb = after(&format!(r#"{two} "#));
+ assert!((tb - 43.2).abs() < 0.5, "tb subform: {tb}");
+ let lr_tb = after(&format!(r#"{two} "#));
+ assert!((lr_tb - 43.2).abs() < 0.5, "lr-tb subform, one field a line: {lr_tb}");
+ let rl_tb = after(&format!(r#"{two} "#));
+ assert!((rl_tb - 21.6).abs() < 0.5, "rl-tb subform, both fields on a line: {rl_tb}");
+ let table =
+ after(&format!(r#"{two} "#));
+ assert!((table - 21.6).abs() < 0.5, "table subform: {table}");
+ let group = after(
+ r#" "#,
+ );
+ assert!((group - 57.6).abs() < 0.5, "exclusion group: {group}");
+ let margins = after(&format!(r#" {two} "#));
+ assert!((margins - (43.2 + 21.6)).abs() < 0.5, "tb subform with margins: {margins}");
+ // A field and a draw are as tall as a line of their text: more than its 10pt size, and one
+ // line, not two.
+ let field = after(r#" "#);
+ assert!(field > 10.0 && field < 20.0, "field: {field}");
+ let draw = after(r#"One line "#);
+ assert!(draw > 10.0 && draw < 20.0, "draw: {draw}");
+}
+
+/// #5370 review: a flowed subform in a table cell grows to its content when the height it gives
+/// is too small or zero, its margins count, a `maxH` still caps it, and a cell that spans two
+/// columns is measured at both columns' width.
+#[test]
+fn a_flowed_cell_grows_past_a_small_height_and_keeps_margins_and_max_height() {
+ let after = |cell: &str| {
+ let (page, y) = page_and_y_of_after(&table_then_after("5in", cell));
+ assert_eq!(page, 0, "{cell}");
+ y - 36.0
+ };
+ let two = format!("{}{}", text_field("a"), text_field("b"));
+ let small = after(&format!(r#"{two} "#));
+ assert!((small - 43.2).abs() < 0.5, "a 0.2in cell holding 0.6in: {small}");
+ let zero = after(&format!(r#"{two} "#));
+ assert!((zero - 43.2).abs() < 0.5, "a zero-height cell: {zero}");
+ let margins = after(&format!(r#" {two} "#));
+ assert!((margins - 57.6).abs() < 0.5, "content and margins: {margins}");
+ let three = format!("{two}{}", text_field("x"));
+ let capped = after(&format!(r#"{three} "#));
+ assert!((capped - 36.0).abs() < 0.5, "maxH caps the content height: {capped}");
+ let table = after(&format!(
+ r#"{two} "#
+ ));
+ assert!((table - 21.6).abs() < 0.5, "a table-layout cell: {table}");
+ // Two 1in columns; a 1in-wide lr-tb cell spanning both fits two 0.8in fields on one line.
+ let spanned = {
+ let two_narrow =
+ r#" "#;
+ let (_, y) = page_and_y_of_after(&table_then_after(
+ "1in 1in",
+ &format!(r#"{two_narrow} "#),
+ ));
+ y - 36.0
+ };
+ assert!((spanned - 21.6).abs() < 0.5, "measured across both spanned columns: {spanned}");
+}
+
+/// #5370 review: only a flowed subform in a cell gives up its height. A field, an exclusion
+/// group and a positioned or row subform in a cell keep theirs, and the row is as tall as its
+/// tallest cell.
+#[test]
+fn other_cells_keep_their_heights_and_the_row_takes_the_tallest() {
+ let after = |cells: &str| page_and_y_of_after(&table_then_after("2.5in 2.5in", cells)).1 - 36.0;
+ let field = after(r#" "#);
+ assert!((field - 144.0).abs() < 0.5, "a field cell: {field}");
+ let group =
+ after(r#" "#);
+ assert!((group - 144.0).abs() < 0.5, "an exclusion group cell: {group}");
+ let positioned = after(&format!(r#"{} "#, text_field("a")));
+ assert!((positioned - 144.0).abs() < 0.5, "a positioned cell: {positioned}");
+ let row = after(&format!(r#"{} "#, text_field("a")));
+ assert!((row - 144.0).abs() < 0.5, "a row-layout cell: {row}");
+ // A flowed cell beside a 2in field: the field is the taller.
+ let beside = after(&format!(
+ r#"{} "#,
+ text_field("a")
+ ));
+ assert!((beside - 144.0).abs() < 0.5, "the taller cell sets the row: {beside}");
+}
+
+/// #5370 review: an empty flowed cell is as tall as nothing, so its row is as tall as its other
+/// cell; a flowed cell inside a flowed cell of a nested table gives up its height too.
+#[test]
+fn an_empty_flowed_cell_and_a_nested_one_are_as_tall_as_their_content() {
+ let after = |cells: &str| page_and_y_of_after(&table_then_after("2.5in 2.5in", cells)).1 - 36.0;
+ let empty = after(&format!(r#"{} "#, text_field("f")));
+ assert!((empty - 21.6).abs() < 0.5, "an empty flowed cell: {empty}");
+ let alone = after(r#" "#);
+ assert!((alone - 5.0).abs() < 0.5, "an empty flowed cell is as tall as its margins: {alone}");
+ let two = format!("{}{}", text_field("a"), text_field("b"));
+ let nested = after(&format!(
+ r#"{two} "#
+ ));
+ assert!((nested - 43.2).abs() < 0.5, "a nested table's flowed cell: {nested}");
+}
+
+/// #5370 review: a flowed cell holding a repeating subform counts each instance, and a repeating
+/// row of such cells takes each row's content height. The repeated fields are found by SOM path,
+/// since their PDF names are made up.
+#[test]
+fn repeating_content_and_repeating_rows_of_flowed_cells_take_their_content_height() {
+ let line = format!(r#" {} "#, text_field("v"));
+ let (_, y) = page_and_y_of_after(&table_then_after("5in", &format!(r#"{line} "#)));
+ assert!((y - 36.0 - 64.8).abs() < 0.5, "three 0.3in instances: {}", y - 36.0);
+ let form = layout_xml(&one_area_form(
+ r#"layout="tb""#,
+ &format!(
+ r#"{}{} {}"#,
+ text_field("a"),
+ text_field("b"),
+ text_field("after")
+ ),
+ ))
+ .unwrap();
+ let ws = widgets(&form.pages[0]);
+ let top = |som: &str| {
+ ws.iter()
+ .find(|w| w.som.ends_with(som))
+ .map(|w| w.rect.y)
+ .unwrap_or_else(|| panic!("no {som} in {:?}", ws.iter().map(|w| &w.som).collect::>()))
+ };
+ assert!((top("r[1].c[0].a[0]") - top("r[0].c[0].a[0]") - 43.2).abs() < 0.5, "the second row starts 0.6in below the first");
+ assert!((top("after[0]") - 36.0 - 86.4).abs() < 0.5, "two rows of 0.6in: {}", top("after[0]") - 36.0);
+}
+
+/// #5370 review: a flowed cell giving a height taller than the page no longer pushes what follows
+/// it to another page; huge margins in a narrow column and flowed cells nested deeper than the
+/// template reader follows are laid out without panicking.
+#[test]
+fn a_flowed_cell_taller_than_the_page_huge_margins_and_deep_nesting_stay_bounded() {
+ let two = format!("{}{}", text_field("a"), text_field("b"));
+ let (page, y) = page_and_y_of_after(&table_then_after("5in", &format!(r#"{two} "#)));
+ assert_eq!(page, 0, "the next object stays on the first page");
+ assert!((y - 36.0 - 43.2).abs() < 0.5, "{}", y - 36.0);
+ let (_, y) = page_and_y_of_after(&table_then_after(
+ "1in",
+ &format!(r#" {two} "#),
+ ));
+ assert!(y.is_finite() && (y - 36.0 - 50.4).abs() < 0.5, "margins wider than the column: {}", y - 36.0);
+ // Flowed cells nested 200 tables deep are cut off where the template is, not followed.
+ let open = r#""#;
+ let deep = format!("{}{}{}", open.repeat(200), text_field("a"), " ".repeat(200));
+ assert!(layout_xml(&one_area_form(r#"layout="tb""#, &deep)).is_ok());
+}
+
+/// A picture the template links (`href`) is drawn from the `XFAImages` name tree the PDF keeps
+/// it in (XFA 3.3 pp. 257–258), through the tree's `Kids`. A link the file holds nothing for
+/// stays out, as before, and is never looked for outside the file.
+#[test]
+fn a_linked_picture_is_drawn_from_the_pdfs_xfa_images() {
+ let mut doc = Document::open(Arc::new(crate::fixtures::linked_image_shell())).unwrap();
+ let mut tpl = crate::script::template_of(&doc).unwrap();
+ let mut pictures = Vec::new();
+ let mut walk = |nodes: &[Node]| {
+ for n in nodes {
+ if let Node::Subform(s) = n {
+ for c in &s.children {
+ if let Node::Draw(d) = c
+ && let Value::Image { data, href, .. } = &d.value
+ {
+ pictures.push((href.clone(), data.len()));
+ }
+ }
+ }
+ }
+ };
+ walk(&tpl.root.children);
+ assert_eq!(pictures, vec![(Some(r".\logo.png".into()), crate::fixtures::tiny_png().len()), (Some(r".\missing.png".into()), 0)]);
+ assert!(crate::linked_images::resolve(&mut tpl, &doc).is_empty(), "nothing to say of a link the file doesn't hold");
+ let report = render_into(&mut doc).unwrap();
+ let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ let images = doc
+ .object_numbers()
+ .into_iter()
+ .filter(
+ |n| matches!(&*doc.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")),
+ )
+ .count();
+ assert_eq!(images, 2, "the linked PNG and its soft mask");
+ assert_eq!(report.warnings.iter().filter(|w| w.contains("not a JPEG, PNG")).count(), 1, "only the missing one: {:?}", report.warnings);
+}
+
+/// A hostile `XFAImages` tree neither hangs nor panics: `Kids` that lead back to themselves, a
+/// name that isn't a string, and a name for something that isn't a stream. The picture is then
+/// left out, as a link the file holds nothing for.
+#[test]
+fn a_hostile_xfa_images_tree_is_walked_once_and_gives_nothing() {
+ for (root, kid) in [
+ (&b"<< /Kids [6 0 R 7 0 R] >>"[..], &b"<< /Kids [6 0 R 7 0 R] /Names [42 8 0 R] >>"[..]),
+ (&b"<< /Names [(.\\logo.png) 7 0 R] >>"[..], &b"<< /Kids [6 0 R] >>"[..]),
+ (&b"<< /Kids 7 0 R >>"[..], &b"[6 0 R]"[..]),
+ ] {
+ let mut doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(root, kid))).unwrap();
+ let mut tpl = crate::script::template_of(&doc).unwrap();
+ assert!(crate::linked_images::resolve(&mut tpl, &doc).is_empty());
+ let report = render_into(&mut doc).unwrap();
+ assert!(report.warnings.iter().any(|w| w.contains("not a JPEG, PNG")), "{:?}", report.warnings);
+ let images = doc.object_numbers().into_iter().filter(|n| matches!(&*doc.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))).count();
+ assert_eq!(images, 0, "no picture drawn");
+ }
+}
+
+/// The picture data a draw named `name` ends up with, in the template's first subform.
+fn linked_picture(tpl: &crate::model::Template, name: &str) -> Option> {
+ tpl.root.children.iter().find_map(|n| match n {
+ Node::Subform(s) => s.children.iter().find_map(|c| match c {
+ Node::Draw(d) if d.common.name.as_deref() == Some(name) => match &d.value {
+ Value::Image { data, .. } => Some(data.clone()),
+ _ => None,
+ },
+ _ => None,
+ }),
+ _ => None,
+ })
+}
+
+/// #5393 review: a `Names` array that repeats one name counts every pair it holds, not only the
+/// names kept: the walk stops at its limit however often the name repeats, so the name listed
+/// after twenty thousand repeats is never reached.
+#[test]
+fn a_repeated_xfa_images_name_counts_against_the_walk() {
+ let kid = format!(r"<< /Names [{}(.\\logo.png) 8 0 R] >>", r"(.\\dup.png) 8 0 R ".repeat(20_000));
+ let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(b"<< /Kids [7 0 R] >>", kid.as_bytes()))).unwrap();
+ let mut tpl = crate::script::template_of(&doc).unwrap();
+ crate::linked_images::resolve(&mut tpl, &doc);
+ assert_eq!(linked_picture(&tpl, "logo"), Some(Vec::new()), "past the limit the logo's name isn't reached");
+}
+
+/// The last node the walk reaches, the 4,096th, still gives its names: the node limit stops
+/// the walk from going on, not the node it has just come to.
+#[test]
+fn the_last_xfa_images_node_the_walk_reaches_gives_its_names() {
+ // The root, 4,094 empty kids, and the kid holding the logo: 4,096 nodes in all.
+ let root = format!("<< /Kids [{}7 0 R] >>", "<< >> ".repeat(4_094));
+ let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(root.as_bytes(), br"<< /Names [(.\\logo.png) 8 0 R] >>"))).unwrap();
+ let mut tpl = crate::script::template_of(&doc).unwrap();
+ crate::linked_images::resolve(&mut tpl, &doc);
+ assert_eq!(linked_picture(&tpl, "logo"), Some(crate::fixtures::tiny_png()), "the 4,096th node's name is read");
+}
+
+/// #5393 review: a name longer than any link is left before it is turned into text; the names
+/// beside it are still read.
+#[test]
+fn an_over_long_xfa_images_name_is_left() {
+ let long = "L".repeat(5_000);
+ let kid = format!(r"<< /Names [({long}) 8 0 R (.\\logo.png) 8 0 R] >>");
+ let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(b"<< /Kids [7 0 R] >>", kid.as_bytes()))).unwrap();
+ let xml = format!(
+ r#"
+
+
+
+
+ "#
+ );
+ let (mut tpl, _) = crate::parse(&xml).unwrap();
+ crate::linked_images::resolve(&mut tpl, &doc);
+ assert_eq!(linked_picture(&tpl, "long"), Some(Vec::new()), "the over-long name isn't kept");
+ assert_eq!(linked_picture(&tpl, "logo"), Some(crate::fixtures::tiny_png()), "the name beside it is");
+}
+
+/// #5393 review: a name the tree holds whose stream isn't a picture is taken as it is, and
+/// drawing it draws nothing and warns, as a picture the template holds that isn't one does.
+#[test]
+fn a_linked_stream_that_is_not_a_picture_draws_nothing_and_warns() {
+ let mut doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_holding(
+ b"<< /Kids [7 0 R] >>",
+ br"<< /Names [(.\\logo.png) 8 0 R] >>",
+ b"this is not a picture",
+ )))
+ .unwrap();
+ // The stream is found and taken as the logo's data.
+ let mut tpl = crate::script::template_of(&doc).unwrap();
+ assert!(crate::linked_images::resolve(&mut tpl, &doc).is_empty());
+ assert_eq!(linked_picture(&tpl, "logo"), Some(b"this is not a picture".to_vec()));
+ let report = render_into(&mut doc).unwrap();
+ // One warning for the logo and the missing picture alike (warnings are said once).
+ assert!(report.warnings.iter().any(|w| w.contains("not a JPEG, PNG")), "{:?}", report.warnings);
+ let images = doc
+ .object_numbers()
+ .into_iter()
+ .filter(
+ |n| matches!(&*doc.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")),
+ )
+ .count();
+ assert_eq!(images, 0, "no picture drawn");
+}
+
+/// #5393 review: a picture larger than the limit (small in tests) is left out with a warning,
+/// not read whole.
+#[test]
+fn an_over_large_linked_picture_is_left_with_a_warning() {
+ let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_holding(
+ b"<< /Kids [7 0 R] >>",
+ br"<< /Names [(.\\logo.png) 8 0 R] >>",
+ &[7u8; 600],
+ )))
+ .unwrap();
+ let mut tpl = crate::script::template_of(&doc).unwrap();
+ let warnings = crate::linked_images::resolve(&mut tpl, &doc);
+ assert!(warnings.iter().any(|w| w.contains("could not be read")), "{warnings:?}");
+ assert_eq!(linked_picture(&tpl, "logo"), Some(Vec::new()));
+}
+
+/// An over-deep branch of the `XFAImages` tree is left on its own: the walk goes on to the
+/// branches beside it, and the picture one of them holds is found.
+#[test]
+fn an_over_deep_xfa_images_branch_does_not_stop_the_walk() {
+ let mut deep = String::from("<< /Names [(x) 8 0 R] >>");
+ for _ in 0..40 {
+ deep = format!("<< /Kids [{deep}] >>");
+ }
+ // The deep branch is listed first, so it is walked first.
+ let root = format!("<< /Kids [{deep} 7 0 R] >>");
+ let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(root.as_bytes(), br"<< /Names [(.\\logo.png) 8 0 R] >>"))).unwrap();
+ let tpl = crate::script::template_of(&doc).unwrap();
+ let logo = tpl.root.children.iter().find_map(|n| match n {
+ Node::Subform(s) => s.children.iter().find_map(|c| match c {
+ Node::Draw(d) if d.common.name.as_deref() == Some("logo") => Some(d.value.clone()),
+ _ => None,
+ }),
+ _ => None,
+ });
+ assert!(matches!(logo, Some(Value::Image { data, .. }) if !data.is_empty()), "the logo is found beside the deep branch");
+}
+
+/// A `Kids` array far longer than the nodes walked is walked in order, up to the limit, and not
+/// held whole: the picture its first kid names is found, however many kids follow.
+#[test]
+fn a_wide_xfa_images_kids_array_is_walked_in_order_within_the_limit() {
+ let root = format!("<< /Kids [7 0 R {}] >>", "<< >> ".repeat(10_000));
+ let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(root.as_bytes(), br"<< /Names [(.\\logo.png) 8 0 R] >>"))).unwrap();
+ let tpl = crate::script::template_of(&doc).unwrap();
+ let logo = tpl.root.children.iter().find_map(|n| match n {
+ Node::Subform(s) => s.children.iter().find_map(|c| match c {
+ Node::Draw(d) if d.common.name.as_deref() == Some("logo") => Some(d.value.clone()),
+ _ => None,
+ }),
+ _ => None,
+ });
+ assert!(matches!(logo, Some(Value::Image { data, .. }) if !data.is_empty()), "the first kid's logo is found");
+}
+
+/// A picture used in many places is held once per place, so every place counts against the
+/// pictures' total (small in tests): past it the rest are left out, with a warning.
+#[test]
+fn a_linked_picture_used_in_many_places_counts_each_place() {
+ let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell())).unwrap();
+ let draws: String = (0..30)
+ .map(|i| format!(r#" "#))
+ .collect();
+ let xml = format!(
+ r#"
+
+ {draws} "#
+ );
+ let (mut tpl, _) = crate::parse(&xml).unwrap();
+ let warnings = crate::linked_images::resolve(&mut tpl, &doc);
+ let filled =
+ tpl.root.children.iter().filter(|n| matches!(n, Node::Draw(d) if matches!(&d.value, Value::Image { data, .. } if !data.is_empty()))).count();
+ let one = crate::fixtures::tiny_png().len();
+ assert_eq!(filled, (1 << 10) / one, "as many places as the total holds");
+ assert!(warnings.iter().any(|w| w.contains("too much memory")), "{warnings:?}");
+}
+
+/// Pictures read from the file and kept for reuse count against the total too (small in tests):
+/// a tree with many names, each read once, holds no more than the total, however many names
+/// there are; past it a name is neither read nor kept.
+#[test]
+fn pictures_kept_for_reuse_stay_under_the_total_whatever_the_number_of_names() {
+ let names: String = (0..30).map(|i| format!("(n{i}) 8 0 R ")).collect();
+ let kid = format!("<< /Names [{names}] >>");
+ let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(b"<< /Kids [7 0 R] >>", kid.as_bytes()))).unwrap();
+ let draws: String =
+ (0..30).map(|i| format!(r#" "#)).collect();
+ let xml = format!(
+ r#"
+
+ {draws} "#
+ );
+ let (mut tpl, _) = crate::parse(&xml).unwrap();
+ let (warnings, kept) = crate::linked_images::resolve_keeping(&mut tpl, &doc);
+ assert!(kept <= 1 << 10, "kept {kept} bytes");
+ assert!(warnings.iter().any(|w| w.contains("too much memory")), "{warnings:?}");
+}
+
+/// The pages of `form[0].last[0].f[0]` and of the field `after` that follows `last`, when `last`
+/// holds `before` ahead of `f` and `after` behind it. A breakAfter acts after its subform, so the
+/// page of `after` shows whether it broke.
+fn pages_around_break(before: &str, after: &str) -> (Option, Option) {
+ let xml = format!(
+ r#"
+
+ {before} {after}
+ "#
+ );
+ let t = crate::parse(&xml).unwrap().0;
+ let form = crate::layout::layout(&t, None).unwrap();
+ let page_of = |som: &str| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == som));
+ (page_of("form[0].last[0].f[0]"), page_of("form[0].after[0]"))
+}
+
+/// A ` ` without a targetType breaks nothing: its default is `auto`, layout goes on
+/// in the current container (XFA 3.3 p. 638), as a public form's last section expects. Written
+/// out as `auto` it breaks nothing either. One to a page area, or a content area, still starts a
+/// new page; so does a `breakAfter` to one.
+#[test]
+fn a_break_before_without_a_target_type_does_not_start_a_page() {
+ let page_before = |brk: &str| pages_around_break(brk, "").0;
+ assert_eq!(page_before(" "), Some(0), "auto: no break");
+ assert_eq!(page_before(r#" "#), Some(0));
+ assert_eq!(page_before(r#" "#), Some(1));
+ assert_eq!(page_before(r#" "#), Some(1));
+ let page_after = |brk: &str| pages_around_break("", brk).1;
+ assert_eq!(page_after(" "), Some(0), "auto: no break");
+ assert_eq!(page_after(r#" "#), Some(0));
+ assert_eq!(page_after(r#" "#), Some(1));
+ assert_eq!(page_after(r#" "#), Some(1));
+}
+
+/// targetType is one of `auto`, `contentArea` and `pageArea` (XFA 3.3 pp. 636-638). An empty or
+/// unknown value, in either direction, names neither a page nor a content area, so it breaks
+/// nothing; the names are case-sensitive.
+#[test]
+fn a_break_with_an_empty_or_unknown_target_type_does_not_start_a_page() {
+ for value in ["", " ", "page", "pagearea", "PageArea", "next"] {
+ let before = format!(r#" "#);
+ assert_eq!(pages_around_break(&before, "").0, Some(0), "breakBefore targetType={value:?}");
+ let after = format!(r#" "#);
+ assert_eq!(pages_around_break("", &after).1, Some(0), "breakAfter targetType={value:?}");
+ }
+}
+
+/// The deprecated `` form (XFA 3.3 pp. 633-634): `auto`, its default, forces
+/// no transition; `pageArea` and `contentArea` start a new page; an empty or unknown value breaks
+/// nothing.
+#[test]
+fn the_deprecated_break_before_form_breaks_only_to_a_page_or_content_area() {
+ let page = |brk: &str| pages_around_break(brk, "").0;
+ assert_eq!(page(" "), Some(0), "auto: no break");
+ assert_eq!(page(r#" "#), Some(0));
+ assert_eq!(page(r#" "#), Some(0));
+ assert_eq!(page(r#" "#), Some(0));
+ assert_eq!(page(r#" "#), Some(1));
+ assert_eq!(page(r#" "#), Some(1));
+}
+
+/// Review on #5010: the text the reader returns is held to what `parse` accepts. A packet within
+/// the 64 MB byte limit that isn't valid UTF-8 grows as each bad byte becomes a three-byte
+/// replacement character: 33 MiB of them come to 99 MiB of text, past the limit, and are refused
+/// as too large rather than handed to `parse`, which would refuse them only later.
+#[test]
+fn packets_whose_text_grows_past_the_template_limit_are_refused() {
+ let mut doc = Document::open(Arc::new(shell(" "))).unwrap();
+ let mut bytes = b"".to_vec();
+ bytes.resize(bytes.len() + (33 << 20), 0xFF);
+ assert!(bytes.len() <= crate::packets::MAX_XDP, "the packet is within the byte limit");
+ let packet = pdfcraft_cos::ObjRef::new(5, 0);
+ doc.set(packet, Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &bytes)));
+ match read_packets(&doc) {
+ Err(XfaError::TooLarge(m)) => assert!(m.contains("bytes of text"), "{m}"),
+ other => panic!("{:?}", other.map(|p| p.map(|p| p.xdp.len()))),
+ }
+ // As one stream rather than an array of named parts too.
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |c| {
+ let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap();
+ acro.set(b"XFA".to_vec(), Object::Ref(packet));
+ c.set(b"AcroForm".to_vec(), Object::Dict(acro));
+ })
+ .unwrap();
+ assert!(matches!(read_packets(&doc), Err(XfaError::TooLarge(_))));
+}
+
+#[test]
+fn fields_by_som_reads_a_field_listed_many_times_once() {
+ // A field lists itself twice and its one widget twice: walked once per path to it, that is
+ // 2^64 visits at the depth limit.
+ let mut doc = Document::new_empty();
+ let widget = Object::Ref(som_field(&mut doc, "name", Some("form1[0].name[0]"), None));
+ let parent = doc.add(Object::Null);
+ let kids = Object::Array(vec![Object::Ref(parent), widget.clone(), Object::Ref(parent), widget]);
+ doc.set(parent, field_named("form1", None, Some(kids)));
+ let by_som = fields_by_som_of(doc, vec![Object::Ref(parent)]);
+ assert_eq!(by_som.len(), 1, "{by_som:?}");
+ assert_eq!(by_som.get("form1[0].name[0]").map(String::as_str), Some("form1.name"));
+}
+
+#[test]
+fn fields_by_som_reads_a_field_listed_under_many_generations_once() {
+ // `Document::get` finds a field by object number alone, so `5 0 R` and `5 1 R` are one field.
+ // The AcroForm lists one under 2,000 generations: read again for each, its 1,000 kids would
+ // spend the walk's 2^20 steps before the field listed after it.
+ let mut doc = Document::new_empty();
+ let leaf = som_field(&mut doc, "leaf", None, None);
+ let a = som_field(&mut doc, "a", None, Some(Object::Array(vec![Object::Ref(leaf); 1_000])));
+ let b = som_field(&mut doc, "b", Some("form1[0].b[0]"), None);
+ let mut fields: Vec = (0..2_000).map(|g| Object::Ref(pdfcraft_cos::ObjRef::new(a.num, g))).collect();
+ fields.push(Object::Ref(b));
+ let by_som = fields_by_som_of(doc, fields);
+ assert_eq!(by_som.get("form1[0].b[0]").map(String::as_str), Some("b"), "{by_som:?}");
+}
+
+#[test]
+fn fields_by_som_counts_kids_that_are_not_references() {
+ // 2,000 fields share one `/Kids` array of 1,000 nulls. Skipped uncounted, the nulls would
+ // cost nothing against the walk's 2^20 steps, however many fields share however long an
+ // array. Counted, they spend the steps here, and the walk stops before the last field.
+ let mut doc = Document::new_empty();
+ let nulls = doc.add(Object::Array(vec![Object::Null; 1_000]));
+ let first = som_field(&mut doc, "first", Some("form1[0].first[0]"), None);
+ let last = som_field(&mut doc, "last", Some("form1[0].last[0]"), None);
+ let mut fields = vec![Object::Ref(first)];
+ fields.extend((0..2_000).map(|i| Object::Ref(som_field(&mut doc, &format!("f{i}"), None, Some(Object::Ref(nulls))))));
+ fields.push(Object::Ref(last));
+ let by_som = fields_by_som_of(doc, fields);
+ assert!(by_som.contains_key("form1[0].first[0]"), "{by_som:?}");
+ assert!(!by_som.contains_key("form1[0].last[0]"), "{by_som:?}");
+}
+
+#[test]
+fn fields_by_som_reads_a_field_again_when_it_is_reached_shallower() {
+ // Field `s` is listed at the bottom of a chain 64 deep, which is walked first, and again
+ // under `b`. Reached first where its kids are past the depth limit, it is read again under
+ // `b`, and the field under it is found by its SOM path.
+ let mut doc = Document::new_empty();
+ let leaf = som_field(&mut doc, "leaf", Some("form1[0].leaf[0]"), None);
+ let shared = som_field(&mut doc, "s", None, Some(Object::Array(vec![Object::Ref(leaf)])));
+ let mut deep = shared;
+ for i in (0..64).rev() {
+ deep = som_field(&mut doc, &format!("d{i}"), None, Some(Object::Array(vec![Object::Ref(deep)])));
+ }
+ let b = som_field(&mut doc, "b", None, Some(Object::Array(vec![Object::Ref(shared)])));
+ let by_som = fields_by_som_of(doc, vec![Object::Ref(deep), Object::Ref(b)]);
+ assert_eq!(by_som.get("form1[0].leaf[0]").map(String::as_str), Some("b.s.leaf"), "{by_som:?}");
+}
+
+/// An AcroForm field named `t`, with SOM path `som` and `kids` when given.
+fn field_named(t: &str, som: Option<&str>, kids: Option) -> Object {
+ let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t));
+ let mut d = Dict::new();
+ d.set(b"T".to_vec(), s(t));
+ if let Some(som) = som {
+ d.set(crate::pdf::SOM_KEY.to_vec(), s(som));
+ }
+ if let Some(kids) = kids {
+ d.set(b"Kids".to_vec(), kids);
+ }
+ Object::Dict(d)
+}
+
+/// [`field_named`], added to `doc`.
+fn som_field(doc: &mut Document, t: &str, som: Option<&str>, kids: Option) -> pdfcraft_cos::ObjRef {
+ doc.add(field_named(t, som, kids))
+}
+
+/// [`fields_by_som`] of `doc`, its catalog's AcroForm listing `fields`.
+fn fields_by_som_of(mut doc: Document, fields: Vec) -> std::collections::HashMap {
+ let mut acro = Dict::new();
+ acro.set(b"Fields".to_vec(), Object::Array(fields));
+ let mut cat = Dict::new();
+ cat.set(b"Type".to_vec(), Object::Name(b"Catalog".to_vec()));
+ cat.set(b"AcroForm".to_vec(), Object::Dict(acro));
+ let cat = doc.add(Object::Dict(cat));
+ doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat));
+ fields_by_som(&doc)
+}
+
+/// An empty multi-line field with a minimum height and no height (a growable table cell, as a
+/// public form's activity list has) starts at its minimum, not three lines; text it can't hold
+/// grows it; one without a minimum stays three lines tall.
+#[test]
+fn an_empty_multiline_field_with_a_minimum_height_starts_there() {
+ let xml = |cell: &str| {
+ format!(
+ r#"
+
+
+ "#
+ )
+ };
+ let height = |cell: &str, value: Option<&str>| {
+ let t = crate::parse(&xml(cell)).unwrap().0;
+ let data = value.map(|v| {
+ parse_datasets(&format!(
+ "{v} "
+ ))
+ .unwrap()
+ });
+ let form = crate::layout::layout(&t, data.as_ref()).unwrap();
+ widgets(&form.pages[0]).first().map(|w| w.rect.h + 2.0 * 0.508 * 72.0 / 25.4).unwrap()
+ };
+ assert!((height(r#"minH="7.62mm""#, None) - 21.6).abs() < 0.01, "{}", height(r#"minH="7.62mm""#, None));
+ assert!(height(r#"minH="7.62mm""#, Some(&"word ".repeat(80))) > 40.0, "text grows it");
+ assert!(height("", None) > 38.0, "three lines without a minimum: {}", height("", None));
+}
+
+/// A growable `lr-tb` subform that doesn't fit what is left of the page is split there (XFA 3.3
+/// p. 1520), its first rows on this page and the rest on the next, as a public evaluation form's
+/// sections are; one with a height, a positioned one, still moves whole.
+#[test]
+fn a_growable_lr_tb_subform_is_split_at_the_page_break() {
+ let xml = |attrs: &str| {
+ let rows: String = (0..6).map(|i| format!(r#" "#)).collect();
+ format!(
+ r#"
+
+ {rows}
+ "#
+ )
+ };
+ let pages_of = |attrs: &str| {
+ let t = crate::parse(&xml(attrs)).unwrap().0;
+ let form = crate::layout::layout(&t, None).unwrap();
+ (0..6)
+ .map(|i| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == format!("form[0].s[0].f{i}[0]"))).unwrap_or(9))
+ .collect::>()
+ };
+ assert_eq!(pages_of(r#"layout="lr-tb""#), [0, 0, 0, 0, 1, 1], "split at the break");
+ assert_eq!(pages_of(r#"layout="lr-tb" h="6in""#), [1, 1, 1, 1, 1, 1], "a fixed height moves whole");
+}
+
+/// Where a row group lands against a page break, for each case the splitting rule tells apart:
+/// a growable `lr-tb`/`rl-tb` one that fits what is left stays whole on this page, one that
+/// doesn't is split at the break, and one taller than a whole page goes on over pages; one with
+/// a height, and a positioned one, move whole to the next page when they fit there.
+#[test]
+fn a_row_group_against_a_page_break_splits_only_when_growable_and_flowing() {
+ // A 6in field, then the group of `n` 1in rows (7in wide, one to a line) in a 10in area.
+ let pages_of = |attrs: &str, n: usize, positioned: bool| {
+ let rows: String = (0..n)
+ .map(|i| {
+ if positioned {
+ format!(r#" "#)
+ } else {
+ format!(r#" "#)
+ }
+ })
+ .collect();
+ let xml = format!(
+ r#"
+
+ {rows}
+ "#
+ );
+ let t = crate::parse(&xml).unwrap().0;
+ let form = crate::layout::layout(&t, None).unwrap();
+ (0..n)
+ .map(|i| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == format!("form[0].s[0].f{i}[0]"))).unwrap_or(99))
+ .collect::>()
+ };
+ // Fits the 4in left: stays on this page, whole.
+ assert_eq!(pages_of(r#"layout="lr-tb""#, 3, false), [0, 0, 0], "a group that fits stays");
+ // Taller than a page (14in): starts here, 4 rows, then 10 on the next page.
+ let mut over = vec![0; 4];
+ over.extend([1; 10]);
+ assert_eq!(pages_of(r#"layout="lr-tb""#, 14, false), over, "a group taller than a page goes on over pages");
+ // Right-to-left rows split at the break as left-to-right ones do.
+ assert_eq!(pages_of(r#"layout="rl-tb""#, 6, false), [0, 0, 0, 0, 1, 1], "rl-tb splits at the break");
+ // A height, or a positioned layout: moved whole when it fits the next page.
+ assert_eq!(pages_of(r#"layout="lr-tb" h="6in""#, 6, false), [1; 6], "a fixed height moves whole");
+ assert_eq!(pages_of("", 6, true), [1; 6], "a positioned group moves whole");
+}
+
+/// The rows of a table whose cells are empty multi-line fields with a minimum height and no
+/// height start at that minimum (XFA 3.3 p. 731), so a table taller than a page puts as many of
+/// them on it as that height allows, and the rest on the next; cells with a height keep it.
+#[test]
+fn table_rows_of_minimum_height_cells_fill_the_page_at_that_height() {
+ let pages = |cell: &str, n: usize| {
+ let rows: String = (0..n)
+ .map(|i| format!(r#" "#))
+ .collect();
+ let xml = format!(
+ r#"
+
+ {rows}
+ "#
+ );
+ let t = crate::parse(&xml).unwrap().0;
+ let form = crate::layout::layout(&t, None).unwrap();
+ let per_page: Vec = form.pages.iter().map(|p| widgets(p).iter().filter(|w| w.som.ends_with(".c[0]")).count()).collect();
+ // Every cell of every row, on every page: a shorter `d` cell, or a shorter row further
+ // down, leaves the counts above as they are.
+ let heights: Vec =
+ form.pages.iter().flat_map(|p| widgets(p)).filter(|w| w.som.ends_with(".c[0]") || w.som.ends_with(".d[0]")).map(|w| w.rect.h).collect();
+ (per_page, heights)
+ };
+ // 0.4in (28.8pt) rows: 25 to a 10in page. At three lines (38.5pt), 18 would fit.
+ let (per_page, h) = pages(r#"minH="0.4in""#, 30);
+ assert_eq!(per_page, [25, 5], "row heights {h:?}");
+ assert!(h.len() == 60 && h.iter().all(|c| (c - 28.8).abs() < 0.01), "every cell starts at its minimum: {h:?}");
+ // A table that fits keeps all its rows on the page, each at the minimum.
+ let (per_page, h) = pages(r#"minH="0.4in""#, 10);
+ assert_eq!(per_page, [10]);
+ assert!(h.len() == 20 && h.iter().all(|c| (c - 28.8).abs() < 0.01), "every cell starts at its minimum: {h:?}");
+ // Cells with a height: 0.5in rows, 20 to a page.
+ assert_eq!(pages(r#"h="0.5in""#, 30).0, [20, 10]);
+}
+
+/// An unnamed growable multi-line field grows to hold its text as a named one does, with a
+/// minimum height or without. A nameless field takes no part in data binding (XFA 3.3 p. 194),
+/// so the text it shows, and the layout measures, is the template's.
+#[test]
+fn an_unnamed_growable_field_grows_to_hold_its_text() {
+ let lay = |name: &str, min_h: &str| {
+ let body = format!(
+ r#"{} "#,
+ "word ".repeat(80)
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let ws = widgets(&form.pages[0]);
+ let field = ws.iter().find(|w| w.name != "after").map(|w| w.rect.h).unwrap();
+ let after = ws.iter().find(|w| w.name == "after").map(|w| w.rect.y).unwrap();
+ (field, after)
+ };
+ for min_h in ["", r#"minH="0.3in""#] {
+ let named = lay(r#"name="a""#, min_h);
+ assert!(named.0 > 100.0, "the named field grows: {named:?}");
+ let unnamed = lay("", min_h);
+ assert!(
+ (unnamed.0 - named.0).abs() < 0.01 && (unnamed.1 - named.1).abs() < 0.01,
+ "{min_h}: unnamed {unnamed:?}, named {named:?} (field height, next field's top)"
+ );
+ }
+}
+
+/// A growable `lr-tb` subform split at a page break inside a bordered, filled `tb` section breaks
+/// that section with it, as a break between the section's children does: a closed part on each
+/// page, its bottom margin ending the first and its top margin starting the second, no warning.
+#[test]
+fn a_split_lr_tb_subform_keeps_the_border_of_the_section_round_it() {
+ let rows: String = (0..6).map(|i| format!(r#" "#)).collect();
+ let body = format!(
+ r#"{rows} "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let rows_on: Vec =
+ (0..6).map(|i| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.name == format!("f{i}"))).unwrap_or(9)).collect();
+ // After 0.1in of margin and the 6in field, three 1in rows fit the 10in area.
+ assert_eq!(rows_on, [0, 0, 0, 1, 1, 1]);
+ let fills = |page: usize| -> Vec {
+ form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect()
+ };
+ let lines = |page: usize| form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count();
+ let (first, second) = (fills(0), fills(1));
+ assert_eq!((first.len(), second.len()), (1, 1), "a part on each page: {:?}", form.warnings);
+ assert_eq!((lines(0), lines(1)), (4, 4), "each part's border is closed");
+ // Page 1: the top margin, the field, three rows, the bottom margin.
+ assert!((first[0].y - 36.0).abs() < 0.01 && (first[0].bottom() - (36.0 + 7.2 + 432.0 + 216.0 + 14.4)).abs() < 0.01, "{:?}", first[0]);
+ // Page 2: the top margin again, the other three rows, the bottom margin.
+ assert!((second[0].y - 36.0).abs() < 0.01 && (second[0].bottom() - (36.0 + 7.2 + 216.0 + 14.4)).abs() < 0.01, "{:?}", second[0]);
+ let f3 = widgets(&form.pages[1]).into_iter().find(|w| w.name == "f3").map(|w| w.rect).unwrap();
+ assert!((f3.y - 43.2).abs() < 0.01, "the second part starts after the top margin: {f3:?}");
+ assert!(form.warnings.is_empty(), "{:?}", form.warnings);
+}
+
+/// Two PDF fields never share a name: a made-up `_2`, `_3` name skips one the template gives
+/// another field. Whichever comes first in layout order, the field the template names `Cell4_2`
+/// keeps that name and the second `Cell4` takes the next free one. Before, the second `Cell4`
+/// became `Cell4_2` as well, so a script writing by that name hit the wrong cell.
+#[test]
+fn a_made_up_field_name_never_takes_one_the_template_uses() {
+ for rows in [["Cell4", "Cell4_2", "Cell4"], ["Cell4", "Cell4", "Cell4_2"]] {
+ let body: String = rows
+ .iter()
+ .enumerate()
+ .map(|(i, name)| {
+ format!(r#" "#)
+ })
+ .collect();
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let all = widgets(&form.pages[0]);
+ let names: Vec<&str> = all.iter().map(|w| w.name.as_str()).collect();
+ let mut unique = names.clone();
+ unique.sort_unstable();
+ unique.dedup();
+ assert_eq!(unique.len(), names.len(), "{rows:?}: {names:?}");
+ let template_named = rows.iter().position(|n| *n == "Cell4_2").unwrap();
+ let som = format!("form[0].row{template_named}[0].Cell4_2[0]");
+ let got = all.iter().find(|w| w.som == som).map(|w| w.name.as_str());
+ assert_eq!(got, Some("Cell4_2"), "{rows:?}: the template's own name is kept ({names:?})");
+ }
+}
+
+/// #5225 review: a member of an exclusion group keeps the name the template gives it, whatever
+/// is drawn before it: a repeated field drawn first doesn't take it as its made-up name.
+#[test]
+fn a_made_up_field_name_never_takes_an_exclusion_group_members_name() {
+ let field = |name: &str| format!(r#" "#);
+ let group = r#"A B "#;
+ // The repeated field first, then the group; and the other way round.
+ for body in [format!("{}{}{group}", field("Cell4"), field("Cell4")), format!("{group}{}{}", field("Cell4"), field("Cell4"))] {
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let all = widgets(&form.pages[0]);
+ let shown: Vec<(String, String)> = all.iter().map(|w| (w.som.clone(), w.name.clone())).collect();
+ let member = all.iter().find(|w| w.som.ends_with(".Choice[0].Cell4_2[0]")).map(|w| w.name.as_str());
+ assert_eq!(member, Some("Cell4_2"), "the member keeps its name: {shown:?}");
+ let mut names: Vec<&str> = all.iter().map(|w| w.name.as_str()).collect();
+ names.sort_unstable();
+ names.dedup();
+ assert_eq!(names.len(), all.len(), "no two widgets share a name: {shown:?}");
+ }
+}
+
+/// #5225 review: a form whose fields don't repeat keeps every template name as it is, a name
+/// that looks like a made-up one (`Total_2`) included, in every subform. Reserving the template's
+/// names must not push a field off its own.
+#[test]
+fn a_form_without_repeats_keeps_every_template_name() {
+ let names = ["Name", "Total", "Total_2", "Date"];
+ let body: String = names
+ .iter()
+ .enumerate()
+ .map(|(i, name)| {
+ format!(r#" "#)
+ })
+ .collect();
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let got: Vec = widgets(&form.pages[0]).iter().map(|w| w.name.clone()).collect();
+ assert_eq!(got, names, "the template's names, unchanged");
+}
+
+/// An unnamed field's made-up name (`field`) doesn't take the name a later field has in the
+/// template: the field the template names `field` keeps it, and the unnamed one before it takes
+/// the next free name.
+#[test]
+fn an_unnamed_field_never_takes_a_name_the_template_uses() {
+ let body = r#"
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ let all = widgets(&form.pages[0]);
+ // In layout order: the unnamed field, then the one the template names `field`.
+ let names: Vec<&str> = all.iter().map(|w| w.name.as_str()).collect();
+ assert_eq!(names.get(1), Some(&"field"), "{names:?}");
+ assert_ne!(names.first(), Some(&"field"), "{names:?}");
+}
+
/// A form with `draws` in a subform placed by position, on a letter page whose content area is a
/// quarter inch in, with `master` on the page itself.
fn form_of_draws(master: &str, draws: &str) -> String {
@@ -4291,6 +6625,49 @@ fn a_draw_turns_counter_clockwise_about_its_anchor() {
}
}
+/// A turned draw that only prints is written in the print-only group inside its turn: the marked
+/// content and the turn's `q`/`Q` nest, never cross, and the draw still turns as one shown everywhere.
+#[test]
+fn a_turned_print_only_draw_nests_its_group_inside_the_turn() {
+ let draws = [
+ line_draw("1in", "0.95in", r#"presence="invisible" relevant="+print""#),
+ line_draw("3in", "0.95in", r#"rotate="90" presence="invisible" relevant="+print""#),
+ line_draw("5in", "0.95in", ""),
+ ]
+ .concat();
+ let (doc, page) = written(&form_of_draws("", &draws));
+ let content = page_content(&doc, &page);
+ let mut open = Vec::new();
+ let mut turned_groups = 0;
+ for token in content.split_whitespace() {
+ match token {
+ "q" | "BDC" => {
+ if token == "BDC" && open.contains(&"cm") {
+ turned_groups += 1;
+ }
+ open.push(token);
+ }
+ "cm" if open.last() == Some(&"q") => open.push("cm"),
+ "Q" => {
+ if open.last() == Some(&"cm") {
+ open.pop();
+ }
+ assert_eq!(open.pop(), Some("q"), "a Q closes a q: {content}");
+ }
+ "EMC" => assert_eq!(open.pop(), Some("BDC"), "an EMC closes a BDC: {content}"),
+ _ => {}
+ }
+ }
+ assert!(open.is_empty(), "{content}");
+ assert_eq!(turned_groups, 1, "the turned draw is in a group of its own inside the turn: {content}");
+ let lines = stroked_lines(&content);
+ assert_eq!(lines.len(), 3, "{lines:?}");
+ let top = 792.0 - 18.0 - 0.95 * 72.0;
+ let [from, to] = lines[1];
+ assert!((from.0 - 234.0).abs() < 0.01 && (from.1 - top).abs() < 0.01, "{from:?}");
+ assert!((to.0 - from.0 - 28.8).abs() < 0.01 && (to.1 - from.1 - 50.4).abs() < 0.01, "{to:?}");
+}
+
/// The anchor is the point it turns about, whichever corner that is: a box anchored at its bottom
/// right stands up and to the left of it, not where a top-left anchor would turn it.
#[test]
@@ -4578,6 +6955,673 @@ fn a_container_is_as_wide_as_a_turned_draw_stands() {
}
}
+/// A page break splits an empty multi-line field that grows with its text (XFA 3.3 pp. 291–292:
+/// text splits between lines, and a field's `keep intact` is `none`): it starts where it is, its
+/// widget runs to the bottom of the content area, and the rest of its box, closed on every side,
+/// goes on at the top of the next. Kept intact, holding a value or on one line, the field moves
+/// to the next content area whole, as before.
+#[test]
+fn an_empty_growing_field_is_split_by_a_page_break() {
+ let form = |field_extra: &str, multi: &str| {
+ let body = format!(
+ r#"above
+{field_extra}
+
+ Notes
+ "#
+ );
+ layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap()
+ };
+ let notes = |page: &Page| widgets(page).into_iter().find(|w| w.name == "notes").map(|w| w.rect);
+ // The content area runs from 36 to 756; the field starts at 36 + 576 = 612 and is 216 tall.
+ let split = form("", r#"multiLine="1""#);
+ assert_eq!(split.pages.len(), 2);
+ let first = notes(&split.pages[0]).expect("the widget starts on the first page");
+ assert!((first.y - 648.0).abs() < 0.5 && (first.bottom() - 756.0).abs() < 0.5, "down to the bottom of the area: {first:?}");
+ assert!(notes(&split.pages[1]).is_none(), "one widget");
+ let edges: Vec<((f64, f64), (f64, f64))> =
+ split.pages[1].items.iter().filter_map(|i| if let Item::Line { from, to, .. } = i { Some((*from, *to)) } else { None }).collect();
+ assert_eq!(edges.len(), 4, "the rest of the box, closed: {edges:?}");
+ let (top, bottom) = edges.iter().flat_map(|(a, b)| [a.1, b.1]).fold((f64::INFINITY, 0.0_f64), |(t, b), y| (t.min(y), b.max(y)));
+ assert!((top - 36.0).abs() < 1.0 && (bottom - 108.0).abs() < 1.0, "at the top of the next area, 72 tall: {top}..{bottom}");
+ for (extra, multi, why) in [
+ (r#" "#, r#"multiLine="1""#, "kept intact"),
+ (r#"typed "#, r#"multiLine="1""#, "holding a value"),
+ ("", "", "on one line"),
+ ] {
+ let whole = form(extra, multi);
+ assert_eq!(whole.pages.len(), 2, "{why}");
+ assert!(notes(&whole.pages[0]).is_none(), "{why}: nothing of it on the first page");
+ let moved = notes(&whole.pages[1]).unwrap_or_else(|| panic!("{why}: the field is on the second page"));
+ assert!((moved.y - 72.0).abs() < 0.5, "{why}: whole, under its caption at the top: {moved:?}");
+ }
+}
+
+/// A field split by a page break has its own border drawn round each piece (a border's default
+/// `break="close"`, XFA 3.3 pp. 629–630): an edge at the bottom of the first page and one at the
+/// top of the next. One whose widget would have no room above the break for a line of its text
+/// moves to the next page whole, its caption and border with it.
+#[test]
+fn a_split_field_closes_its_border_on_each_piece_and_a_sliver_moves_whole() {
+ let form = |filler: &str| {
+ let body = format!(
+ r#"above
+
+
+
+ Notes
+ "#
+ );
+ layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap()
+ };
+ // Level lines across the field (its border's top and bottom) on `page`, top to bottom.
+ let levels = |page: &Page| -> Vec {
+ let mut ys: Vec = page
+ .items
+ .iter()
+ .filter_map(|i| match i {
+ Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && (to.0 - from.0).abs() > 500.0 => Some(from.1),
+ _ => None,
+ })
+ .collect();
+ ys.sort_by(f64::total_cmp);
+ ys
+ };
+ // The field starts at 612, with 144 left in the area (down to 756).
+ let split = form("8in");
+ assert_eq!(split.pages.len(), 2);
+ let (first, next) = (levels(&split.pages[0]), levels(&split.pages[1]));
+ let near = |got: &[f64], want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0);
+ assert!(near(&first, &[612.0, 756.0]), "first piece closed, once: {first:?}");
+ assert!(near(&next, &[36.0, 108.0]), "next piece closed, once: {next:?}");
+ // 9.9in of filler leaves 0.1in above the break, under the widget's 0.5in caption: no room.
+ let sliver = form("9.9in");
+ assert_eq!(sliver.pages.len(), 2);
+ assert!(levels(&sliver.pages[0]).is_empty(), "nothing of the field left on the first page: {:?}", levels(&sliver.pages[0]));
+ assert!(!texts(&sliver.pages[0]).iter().any(|t| t == "Notes"), "its caption moves with it");
+ let whole = levels(&sliver.pages[1]);
+ assert!(whole.iter().any(|y| (y - 36.0).abs() < 1.0) && whole.iter().any(|y| (y - 252.0).abs() < 1.0), "whole on the next page: {whole:?}");
+}
+
+/// A field split inside a bordered section breaks the section with it, as a field moving on whole
+/// does: the section's border closes on the first page under its bottom margin, which the field
+/// leaves free, and opens again on the next, where the rest of the field sits under its top margin.
+#[test]
+fn a_field_split_inside_a_bordered_section_keeps_the_section_border_on_each_page() {
+ let body = r#"
+
+
+ above
+
+
+
+ Notes
+
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ assert_eq!(form.pages.len(), 2);
+ // Level lines `width` long on `page`, top to bottom: the section's are 540 long, the field's 504.
+ let levels = |page: &Page, width: f64| -> Vec {
+ let mut ys: Vec = page
+ .items
+ .iter()
+ .filter_map(|i| match i {
+ Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && ((to.0 - from.0).abs() - width).abs() < 1.0 => Some(from.1),
+ _ => None,
+ })
+ .collect();
+ ys.sort_by(f64::total_cmp);
+ ys
+ };
+ let near = |got: &[f64], want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0);
+ // The section opens at 36 and its filler ends at 36 + 18 + 576 = 630, where the field starts.
+ let (sec, field) = (levels(&form.pages[0], 540.0), levels(&form.pages[0], 504.0));
+ assert!(near(&sec, &[36.0, 756.0]), "the section closed at the bottom of the area: {sec:?}");
+ assert!(near(&field, &[630.0, 738.0]), "the field's first piece ends above the section's bottom margin: {field:?}");
+ // 216 - 108 = 108 of the field are left, under the section's top margin.
+ let (sec, field) = (levels(&form.pages[1], 540.0), levels(&form.pages[1], 504.0));
+ assert!(near(&sec, &[36.0, 180.0]), "the section opened again round the rest: {sec:?}");
+ assert!(near(&field, &[54.0, 162.0]), "the rest of the field under the section's top margin: {field:?}");
+}
+
+/// A field with no room above the break for a line of its widget is placed exactly as one that
+/// can't split: inside a bordered section with a top margin that goes on to the next page, it
+/// sits where a field kept intact does, under the section's margin there.
+#[test]
+fn a_sliver_field_moves_on_as_a_field_kept_intact_does() {
+ let form = |keep: &str| {
+ let body = format!(
+ r#"
+
+
+ above
+ {keep}
+
+
+ Notes
+
+ "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let at = form.pages.iter().enumerate().find_map(|(i, p)| widgets(p).into_iter().find(|w| w.name == "notes").map(|w| (i, w.rect)));
+ (form.pages.len(), at)
+ };
+ let (pages, at) = form("");
+ assert_eq!((pages, at), form(r#" "#), "the sliver field and one kept intact");
+ assert_eq!(at.map(|(page, _)| page), Some(1), "on the second page");
+}
+
+/// #5525 review: a field laid out again to move on whole (no room above the break for a line)
+/// counts its items once against `MAX_ITEMS`, as a field kept intact does: what the first try
+/// drew is taken off the page, and its count with it.
+#[test]
+fn a_field_laid_out_again_counts_its_items_once() {
+ let laid_out = |keep: &str| {
+ let body = format!(
+ r#"
+ above
+ {keep}
+
+
+ Notes
+
+ "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ let on_pages: usize = form.pages.iter().map(|p| p.items.len()).sum();
+ (form.pages.len(), on_pages, crate::layout::LAID_OUT_ITEMS.with(std::cell::Cell::get))
+ };
+ let (pages, on_pages, counted) = laid_out("");
+ assert_eq!(pages, 2, "the field moves on to page 2");
+ assert_eq!((pages, on_pages, counted), laid_out(r#" "#), "laid out again, as kept intact");
+}
+
+/// #5525 review: the level lines about `len` long on `page` (a border's top and bottom edges),
+/// top to bottom.
+fn level_lines(page: &Page, len: f64) -> Vec {
+ let mut ys: Vec = page
+ .items
+ .iter()
+ .filter_map(|i| match i {
+ Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && ((to.0 - from.0).abs() - len).abs() < 1.0 => Some(from.1),
+ _ => None,
+ })
+ .collect();
+ ys.sort_by(f64::total_cmp);
+ ys
+}
+
+/// #5525 review: whether `got` holds the values of `want`, each within a point.
+fn near_all(got: &[f64], want: &[f64]) -> bool {
+ got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0)
+}
+
+/// #5525 review: the pages (by index) holding the widget at SOM path `som`, with its box on each.
+/// A repeated field's PDF name is made up, so the widget is found by its SOM path.
+fn widget_pieces(form: &Form, som: &str) -> Vec<(usize, Rect)> {
+ form.pages.iter().enumerate().flat_map(|(i, p)| widgets(p).into_iter().filter(|w| w.som == som).map(move |w| (i, w.rect))).collect()
+}
+
+/// #5525 review: the baseline of the first text span `text` on `page`.
+fn baseline_of(page: &Page, text: &str) -> Option {
+ page.items.iter().find_map(|i| match i {
+ Item::Text(s) if s.text == text => Some(s.baseline),
+ _ => None,
+ })
+}
+
+/// #5525 review: an empty growing field `name` with its own border all round and no widget
+/// border, `min_h` tall, `extra` inside it.
+fn bordered_notes(name: &str, min_h: &str, extra: &str) -> String {
+ format!(
+ r#"{extra}
+
+
+ "#
+ )
+}
+
+/// #5525 review: a field split over two pages. With 144 points left on the first page, a 216
+/// point field runs to the bottom of it and its other 72 points go on at the top of the next,
+/// its border closed round each piece; what follows the field starts under that rest, not
+/// under a whole field moved to the next page.
+#[test]
+fn a_field_split_over_two_pages_has_what_follows_start_under_its_rest() {
+ let body = format!(
+ r#"above
+{}
+after "#,
+ bordered_notes("notes", "3in", "")
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ assert_eq!(form.pages.len(), 2);
+ let pieces = widget_pieces(&form, "form[0].notes[0]");
+ assert!(
+ matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5),
+ "one widget, on the first page: {pieces:?}"
+ );
+ let (first, next) = (level_lines(&form.pages[0], 540.0), level_lines(&form.pages[1], 540.0));
+ assert!(near_all(&first, &[612.0, 756.0]), "first piece, closed: {first:?}");
+ assert!(near_all(&next, &[36.0, 108.0]), "the other 72 points, closed: {next:?}");
+ let after = baseline_of(&form.pages[1], "after").expect("what follows is on the second page");
+ assert!(after > 108.0 && after < 144.0, "under the rest of the field: {after}");
+}
+
+/// #5525 review: a field split over three pages. A 936 point field with 144 points left on the
+/// first page fills that, the whole of the second page's content area, and 72 points of the
+/// third; its border is closed round each of the three pieces, and what follows starts under
+/// the last.
+#[test]
+fn a_field_split_over_three_pages_closes_its_border_round_every_piece() {
+ let body = format!(
+ r#"above
+{}
+after "#,
+ bordered_notes("notes", "13in", "")
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ assert_eq!(form.pages.len(), 3);
+ let pieces = widget_pieces(&form, "form[0].notes[0]");
+ assert!(
+ matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5),
+ "one widget, on the first page: {pieces:?}"
+ );
+ let levels: Vec> = form.pages.iter().map(|p| level_lines(p, 540.0)).collect();
+ assert!(near_all(&levels[0], &[612.0, 756.0]), "first piece: {levels:?}");
+ assert!(near_all(&levels[1], &[36.0, 756.0]), "a whole content area: {levels:?}");
+ assert!(near_all(&levels[2], &[36.0, 108.0]), "the last 72 points: {levels:?}");
+ let after = baseline_of(&form.pages[2], "after").expect("what follows is on the third page");
+ assert!(after > 108.0 && after < 144.0, "under the last piece: {after}");
+}
+
+/// #5525 review: a field taller than a page that starts at the top of one (the page break is
+/// right above it) splits at the bottom of that content area rather than running off the page:
+/// 864 points become 720 here and 144 on the next page.
+#[test]
+fn a_field_taller_than_a_page_starting_at_its_top_splits_at_its_bottom() {
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "12in", ""))).unwrap();
+ assert_eq!(form.pages.len(), 2);
+ let pieces = widget_pieces(&form, "form[0].notes[0]");
+ assert!(
+ matches!(pieces.as_slice(), [(0, r)] if (r.y - 36.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5),
+ "the widget fills the first content area, no more: {pieces:?}"
+ );
+ assert!(near_all(&level_lines(&form.pages[0], 540.0), &[36.0, 756.0]), "{:?}", level_lines(&form.pages[0], 540.0));
+ assert!(near_all(&level_lines(&form.pages[1], 540.0), &[36.0, 180.0]), "{:?}", level_lines(&form.pages[1], 540.0));
+}
+
+/// #5525 review: a field that starts exactly at a page break (what is above it fills the content
+/// area) has no room at all on that page: it moves to the next page whole, its border with it,
+/// and nothing of it is left behind.
+#[test]
+fn a_field_starting_exactly_at_a_page_break_moves_on_whole() {
+ let body = format!(
+ r#"above
+{}"#,
+ bordered_notes("notes", "3in", "")
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ assert_eq!(form.pages.len(), 2);
+ assert!(level_lines(&form.pages[0], 540.0).is_empty(), "no border left behind: {:?}", level_lines(&form.pages[0], 540.0));
+ let pieces = widget_pieces(&form, "form[0].notes[0]");
+ assert!(
+ matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.bottom() - 252.0).abs() < 0.5),
+ "whole at the top of the next page: {pieces:?}"
+ );
+ assert!(near_all(&level_lines(&form.pages[1], 540.0), &[36.0, 252.0]), "{:?}", level_lines(&form.pages[1], 540.0));
+}
+
+/// #5525 review: the room a field needs above a page break to split there is one line of its
+/// text (10 point Courier, 12 points). With exactly 12 points left the field splits, its widget
+/// on the last 12 points of the page; with 11 it has no room and moves to the next page whole.
+#[test]
+fn a_field_splits_with_one_line_of_room_and_moves_on_whole_with_less() {
+ let form = |filler: &str| {
+ let body = format!(
+ r#"above
+{}"#,
+ bordered_notes("notes", "3in", "")
+ );
+ layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap()
+ };
+ let line = form("708pt");
+ let pieces = widget_pieces(&line, "form[0].notes[0]");
+ assert!(
+ matches!(pieces.as_slice(), [(0, r)] if (r.y - 744.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5),
+ "one line on the first page: {pieces:?}"
+ );
+ assert!(near_all(&level_lines(&line.pages[1], 540.0), &[36.0, 240.0]), "the other 204 points: {:?}", level_lines(&line.pages[1], 540.0));
+ let less = form("709pt");
+ let pieces = widget_pieces(&less, "form[0].notes[0]");
+ assert!(matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "whole on the next page: {pieces:?}");
+ assert!(level_lines(&less.pages[0], 540.0).is_empty(), "nothing of it left on the first page");
+}
+
+/// #5525 review: two such fields in a row are split by their own page breaks, and the second
+/// starts from nothing of the first: the first (216 points) splits over pages one and two, the
+/// second (792 points) starts under the first's rest and splits over pages two and three, and
+/// each page holds only the borders of its own pieces. A second field that fits after a split
+/// one is drawn whole, once.
+#[test]
+fn two_fields_in_a_row_are_each_split_by_their_own_page_break() {
+ let form = |second: &str| {
+ let body = format!(
+ r#"above
+{}
+{}"#,
+ bordered_notes("a", "3in", ""),
+ bordered_notes("b", second, "")
+ );
+ layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap()
+ };
+ let both = form("11in");
+ assert_eq!(both.pages.len(), 3);
+ let a = widget_pieces(&both, "form[0].a[0]");
+ let b = widget_pieces(&both, "form[0].b[0]");
+ assert!(matches!(a.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "a: {a:?}");
+ assert!(matches!(b.as_slice(), [(1, r)] if (r.y - 108.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "b: {b:?}");
+ let levels: Vec> = both.pages.iter().map(|p| level_lines(p, 540.0)).collect();
+ assert!(near_all(&levels[0], &[612.0, 756.0]), "a's first piece: {levels:?}");
+ assert!(near_all(&levels[1], &[36.0, 108.0, 108.0, 756.0]), "a's rest and b's first piece: {levels:?}");
+ assert!(near_all(&levels[2], &[36.0, 180.0]), "b's other 144 points: {levels:?}");
+ let fits = form("1in");
+ assert_eq!(fits.pages.len(), 2);
+ let b = widget_pieces(&fits, "form[0].b[0]");
+ assert!(matches!(b.as_slice(), [(1, r)] if (r.y - 108.0).abs() < 0.5 && (r.h - 72.0).abs() < 0.5), "b whole: {b:?}");
+ let levels = level_lines(&fits.pages[1], 540.0);
+ assert!(near_all(&levels, &[36.0, 108.0, 108.0, 180.0]), "a's rest, and b once: {levels:?}");
+}
+
+/// #5525 review: the field of the second instance of a repeated subform splits like any other,
+/// found by its SOM path (its PDF name is made up), while the first instance's field, which
+/// fits, stays whole.
+#[test]
+fn a_field_in_a_repeated_subform_splits_in_the_instance_that_reaches_the_break() {
+ let body = format!(
+ r#"above
+
+{}
+ "#,
+ bordered_notes("notes", "3in", "")
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ assert_eq!(form.pages.len(), 2);
+ let first = widget_pieces(&form, "form[0].row[0].notes[0]");
+ let second = widget_pieces(&form, "form[0].row[1].notes[0]");
+ assert!(matches!(first.as_slice(), [(0, r)] if (r.y - 468.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "first whole: {first:?}");
+ assert!(matches!(second.as_slice(), [(0, r)] if (r.y - 684.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "second split: {second:?}");
+ let (page0, page1) = (level_lines(&form.pages[0], 540.0), level_lines(&form.pages[1], 540.0));
+ assert!(near_all(&page0, &[468.0, 684.0, 684.0, 756.0]), "{page0:?}");
+ assert!(near_all(&page1, &[36.0, 180.0]), "{page1:?}");
+}
+
+/// #5525 review: a field split inside two nested sections, each with a quarter-inch bottom and
+/// top margin, leaves both bottom margins free under its first piece (it ends 36 points above
+/// the bottom of the content area) and goes on under both top margins on the next page.
+#[test]
+fn a_field_split_inside_nested_sections_leaves_room_for_both_margins() {
+ let body = r#"
+
+
+
+ above
+
+
+
+
+
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ assert_eq!(form.pages.len(), 2);
+ // The filler starts under both top margins, at 72, so the field starts at 648.
+ let (first, next) = (level_lines(&form.pages[0], 504.0), level_lines(&form.pages[1], 504.0));
+ assert!(near_all(&first, &[648.0, 720.0]), "first piece ends above both bottom margins: {first:?}");
+ assert!(near_all(&next, &[72.0, 216.0]), "the other 144 points under both top margins: {next:?}");
+}
+
+/// #5525 review: `` decides whether a page break may split the field (XFA 3.3
+/// p. 292): `contentArea` and `pageArea` keep it whole, moving it to the next page, while
+/// `none` and a value XFA doesn't define leave a field's default, `none`, and it splits.
+#[test]
+fn keep_intact_values_decide_whether_the_field_splits() {
+ let at = |keep: &str| {
+ let body = format!(
+ r#"above
+{}"#,
+ bordered_notes("notes", "3in", &format!(r#" "#))
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ widget_pieces(&form, "form[0].notes[0]")
+ };
+ for keep in ["contentArea", "pageArea"] {
+ let pieces = at(keep);
+ assert!(matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "{keep}: whole: {pieces:?}");
+ }
+ for keep in ["none", "sideways", ""] {
+ let pieces = at(keep);
+ assert!(
+ matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5),
+ "{keep:?}: split: {pieces:?}"
+ );
+ }
+}
+
+/// #5525 review: extreme and malformed sizes. A 1000 inch empty field splits over exactly 100
+/// pages, its widget on the first only and its border closed on each; a `minH` past the largest
+/// length a measurement may have (100,000 points) splits over 139 pages; fields that would need
+/// more pages than a form may have (500) are refused with an error rather than a panic or a
+/// hang; and a negative, NaN or non-numeric `minH`, or a content area too short for a line,
+/// lays out without panicking.
+#[test]
+fn a_split_field_survives_huge_and_malformed_sizes() {
+ let huge = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "1000in", ""))).unwrap();
+ assert_eq!(huge.pages.len(), 100);
+ assert!(matches!(widget_pieces(&huge, "form[0].notes[0]").as_slice(), [(0, _)]), "one widget, on the first page");
+ for (i, p) in huge.pages.iter().enumerate() {
+ assert!(near_all(&level_lines(p, 540.0), &[36.0, 756.0]), "page {i}: {:?}", level_lines(p, 540.0));
+ }
+ let capped = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "100000in", ""))).unwrap();
+ assert_eq!(capped.pages.len(), 139, "100,000 points over 720 point content areas");
+ let four: String = (0..4).map(|i| bordered_notes(&format!("n{i}"), "100000in", "")).collect();
+ let too_many = layout_xml(&one_area_form(r#"layout="tb""#, &four));
+ assert!(matches!(too_many, Err(XfaError::TooLarge(_))), "{:?}", too_many.map(|f| f.pages.len()));
+ for min_h in ["-3in", "NaNin", "abc", "0"] {
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", min_h, ""))).unwrap();
+ assert!(!form.pages.is_empty(), "{min_h}");
+ }
+ let tiny = one_area_form(r#"layout="tb""#, &bordered_notes("notes", "3in", "")).replace(r#"h="10in""#, r#"h="0.5pt""#);
+ let _ = layout_xml(&tiny);
+}
+
+/// #5525 review: the widget's own border (the text edit's) of a field split over three pages is
+/// closed round every piece below the widget, not only the first: the middle page gets a box
+/// from the top of its content area to the bottom, and the last page one round the last 72
+/// points, each with its top and bottom edge.
+#[test]
+fn a_widget_border_split_over_three_pages_is_closed_round_every_piece() {
+ let body = r#"above
+ "#;
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap();
+ assert_eq!(form.pages.len(), 3);
+ // The edges are half a point wide, drawn a quarter of a point inside the box.
+ let (middle, last) = (level_lines(&form.pages[1], 539.5), level_lines(&form.pages[2], 539.5));
+ assert!(near_all(&middle, &[36.25, 755.75]), "the middle piece, closed at the bottom of the page: {middle:?}");
+ assert!(near_all(&last, &[36.25, 107.75]), "the last piece, closed at its top: {last:?}");
+}
+
+/// #5525 review: a bordered section round an empty field split over three pages keeps its
+/// border on every page, even when what is left of the field draws nothing there (no widget
+/// border or fill): the middle page and the last still hold a part of the section. With or
+/// without the field's own border, the section is closed on page one under its bottom margin,
+/// runs the whole content area on page two, and closes under the field's last 144 points on
+/// page three.
+#[test]
+fn a_bordered_section_round_a_field_split_over_three_pages_is_closed_on_every_page() {
+ for field_border in ["", " "] {
+ let body = format!(
+ r#"
+
+
+ above
+ {field_border}
+
+
+ "#
+ );
+ let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap();
+ assert_eq!(form.pages.len(), 3, "{field_border:?}");
+ let sec: Vec> = form.pages.iter().map(|p| level_lines(p, 540.0)).collect();
+ // The field starts at 36 + 18 + 576 = 630 and is 936 tall: 108 on page one (down to
+ // 738), 684 on page two (54 to 738), and the last 144 on page three (54 to 198).
+ assert!(near_all(&sec[0], &[36.0, 756.0]), "{field_border:?}: page one: {sec:?}");
+ assert!(near_all(&sec[1], &[36.0, 756.0]), "{field_border:?}: page two, a whole content area: {sec:?}");
+ assert!(near_all(&sec[2], &[36.0, 216.0]), "{field_border:?}: page three, under the field's last piece: {sec:?}");
+ if !field_border.is_empty() {
+ let field: Vec> = form.pages.iter().map(|p| level_lines(p, 504.0)).collect();
+ assert!(near_all(&field[0], &[630.0, 738.0]), "{field:?}");
+ assert!(near_all(&field[1], &[54.0, 738.0]), "{field:?}");
+ assert!(near_all(&field[2], &[54.0, 198.0]), "{field:?}");
+ }
+ }
+}
+
+/// #5525 review: a field split into content areas of different sizes keeps moving down even
+/// where the margins of the sections round it leave no room. The first page area (used once) has
+/// a 720 point content area; the next has one only 36 points tall, under a section's 72 point
+/// bottom margin (or with its 72 point top margin taking the whole of it). The field still goes on
+/// 36 points a page, from the top of each small area, instead of breaking to new pages until the
+/// form has too many; the section, which can't hold it there, is drawn without its border, and
+/// the layout says so.
+#[test]
+fn a_field_split_into_areas_smaller_than_its_sections_margins_still_moves_on() {
+ let form = |margin: &str, border: &str| {
+ let xml = format!(
+ r##"
+
+
+
+
+
+
+
+
+ {border}
+ above
+ {}
+
+
+
+
+"##,
+ bordered_notes("notes", "3in", "")
+ );
+ layout_xml(&xml)
+ };
+ // Under a 72 point bottom margin the field starts at 612 and its first piece ends at 684; under
+ // a 72 point top margin it starts at 684 and its first piece ends at 756. Either way 144 points
+ // are left, four small areas' worth.
+ for (margin, first) in [(r#"bottomInset="1in""#, [612.0, 684.0]), (r#"topInset="1in""#, [684.0, 756.0])] {
+ let laid_out = form(margin, "");
+ let pages = match &laid_out {
+ Ok(f) => &f.pages,
+ Err(e) => panic!("{margin}: {e:?}"),
+ };
+ assert_eq!(pages.len(), 5, "{margin}");
+ let levels: Vec> = pages.iter().map(|p| level_lines(p, 540.0)).collect();
+ assert!(near_all(&levels[0], &first), "{margin}: first piece: {levels:?}");
+ for (i, l) in levels.iter().enumerate().skip(1) {
+ assert!(near_all(l, &[36.0, 72.0]), "{margin}: page {i}, the whole small area: {levels:?}");
+ }
+ let bordered = form(margin, " ").unwrap_or_else(|e| panic!("{margin}: bordered: {e:?}"));
+ assert_eq!(bordered.pages.len(), 5, "{margin}: bordered");
+ assert!(bordered.warnings.iter().any(|w| w.contains("without its border")), "{margin}: {:?}", bordered.warnings);
+ }
+}
+
+/// A template for the reset defaults: a choice list showing `Label` and saving `code`, with
+/// `Label` its default, and an exclusion group whose second button is on by default.
+fn reset_defaults_template() -> Template {
+ let xml = r#"
+Label Other code other Label
+A B B
+A
+
+ "#;
+ crate::parse::parse(xml).unwrap().0
+}
+
+/// A reset gives a choice list the default it saves (the item value), not the text it shows,
+/// as the live form reads it.
+#[test]
+fn a_choice_list_resets_to_its_saved_default() {
+ let tpl = reset_defaults_template();
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].kind[0]").as_deref(), Some("code"));
+}
+
+/// An exclusion group resets to the on value of the button that is on by default, or to
+/// nothing; its buttons, and push buttons, restore no value of their own.
+#[test]
+fn an_exclusion_group_resets_to_its_default_button() {
+ let tpl = reset_defaults_template();
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].pick[0]").as_deref(), Some("B"));
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].none[0]").as_deref(), Some(""));
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].pick[0].b[0]"), None);
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].go[0]"), None);
+ assert_eq!(reset_default(&tpl, "form[0].page1[0]"), None);
+ assert_eq!(reset_default(&tpl, "form[0].page1[0].missing[0]"), None);
+}
+
+/// A reset writes a multi-select list's default picks as `value` elements, in the packet and in
+/// the copy scripts read, as any value write does; other structured content (rich text) is left
+/// as it is, with a warning.
+#[test]
+fn a_reset_replaces_structured_multi_select_picks() {
+ let xdp = template_with_data(1, "a b rich
");
+ let path = som_to_path("form[0].tags[0]");
+ let value_at = |doc: &Document| data_of(doc).and_then(|d| d.value_at(&path)).unwrap_or_default();
+
+ let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap();
+ write_data_ops(
+ &mut doc,
+ &[DataOp::Value {
+ path: path.clone(),
+ text: "c
+d&e"
+ .into(),
+ }],
+ None,
+ )
+ .unwrap();
+ assert_eq!(
+ value_at(&doc),
+ "c
+d&e"
+ );
+ let packets = read_packets(&doc).unwrap().unwrap();
+ assert!(packets.xdp.contains("c d&e "), "{}", packets.xdp);
+ let mut copy = data_of(&Document::open(Arc::new(shell(&xdp))).unwrap()).unwrap();
+ copy.apply(&DataOp::Value {
+ path: path.clone(),
+ text: "c
+d&e"
+ .into(),
+ });
+ assert_eq!(
+ copy.value_at(&path).as_deref(),
+ Some(
+ "c
+d&e"
+ )
+ );
+
+ let body = som_to_path("form[0].body[0]");
+ let r = write_data_ops(&mut doc, &[DataOp::Value { path: body.clone(), text: String::new() }], None).unwrap();
+ assert!(!r.warnings.is_empty());
+ assert_eq!(data_of(&doc).unwrap().get(&body).map(|n| n.children.len()), Some(1));
+ copy.apply(&DataOp::Value { path: body.clone(), text: String::new() });
+ assert_eq!(copy.get(&body).map(|n| n.children.len()), Some(1));
+}
+
/// Only a template that is a packet of the XDP is the form's: one inside imported data (a
/// dataset holding XML that looks like a template) is not read, and neither are its scripts.
#[test]
@@ -4587,3 +7631,126 @@ fn a_template_inside_the_data_is_not_the_form() {
let real = " ";
assert_eq!(parse(real).unwrap().0.root.common.name.as_deref(), Some("form1"));
}
+
+/// A multi-select choice list is bound to a data group of `value` elements, one per pick, and its
+/// value is theirs joined by newlines (XFA 3.3 p. 198). New picks replace the group's `value`
+/// elements, values off the list included, as Acrobat discards them (p. 199): the group was left
+/// as it was, so the old picks came back on reading, in the datasets and in the scripts' data.
+#[test]
+fn a_multi_select_lists_data_group_takes_its_new_picks() {
+ let shell =
+ crate::fixtures::static_shell("rye barley wheat ");
+ let mut doc = Document::open(Arc::new(shell)).unwrap();
+ let path = som_to_path("form1[0].page1[0].grains[0]");
+ assert!(write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written);
+ let data = crate::script::data_of(&doc).unwrap();
+ assert_eq!(data.value_at(&path).as_deref(), Some("wheat\nmillet & oats"));
+ assert_eq!(data.count(&path, "value"), 2, "one value element per pick");
+ assert!(!write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written, "the same picks change nothing");
+ assert!(write_data_value(&mut doc, &path, "").unwrap().written);
+ assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some(""), "nothing picked");
+ // Emptied, the group is marked as one (XFA 3.3 p. 150), so it is still written as picks.
+ let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
+ assert!(xdp.contains(" "), "{xdp}");
+ assert!(
+ write_data_value(
+ &mut doc,
+ &path,
+ "rye
+millet"
+ )
+ .unwrap()
+ .written
+ );
+ assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2, "picks again, not text");
+ // A pick split or led by a comment is read whole: the same pick is no change, and clearing
+ // a pick that reads as empty through its first segment alone still clears it.
+ let commented =
+ crate::fixtures::static_shell("rye wheat ");
+ let mut doc = Document::open(Arc::new(commented)).unwrap();
+ assert!(
+ !write_data_value(
+ &mut doc,
+ &path,
+ "rye
+wheat"
+ )
+ .unwrap()
+ .written,
+ "the same picks, read past the comments"
+ );
+ assert_eq!(
+ crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(),
+ Some(
+ "rye
+wheat"
+ ),
+ "and read back without them"
+ );
+ assert!(write_data_value(&mut doc, &path, "").unwrap().written, "cleared");
+ assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 0);
+ // Where `xfa` names another namespace and `pcxfa` is taken too, the marker's prefix is a free
+ // one, declared on the group, so no attribute in scope changes meaning.
+ let shadowed = crate::fixtures::static_shell(
+ "rye ",
+ );
+ let mut doc = Document::open(Arc::new(shadowed)).unwrap();
+ assert!(write_data_value(&mut doc, &path, "").unwrap().written);
+ let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp;
+ assert!(xdp.contains("xmlns:pcxfa1=\"http://www.xfa.org/schema/xfa-data/1.0/\" pcxfa1:dataNode=\"dataGroup\""), "{xdp}");
+ assert!(xdp.contains("xmlns:pcxfa=\"urn:taken\" pcxfa:note=\"x\""), "the taken prefix keeps its namespace: {xdp}");
+ // With every candidate prefix taken, no marker can be written: the group keeps its pick
+ // and the skip is reported, rather than emptied into what would load as a data value.
+ let taken: String = (0..16).map(|i| if i == 0 { " xmlns:pcxfa=\"urn:t\"".to_string() } else { format!(" xmlns:pcxfa{i}=\"urn:t\"") }).collect();
+ let crowded =
+ crate::fixtures::static_shell(&format!("rye "));
+ let mut doc = Document::open(Arc::new(crowded)).unwrap();
+ let r = write_data_value(&mut doc, &path, "").unwrap();
+ assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured content")), "{:?}", r.warnings);
+ assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some("rye"));
+ // One `value` whose text holds a line break is one pick, not the two it would join to.
+ let one = crate::fixtures::static_shell(
+ "one
+two ",
+ );
+ let mut doc = Document::open(Arc::new(one)).unwrap();
+ assert!(
+ write_data_value(
+ &mut doc, &path, "one
+two"
+ )
+ .unwrap()
+ .written,
+ "two picks replace the one"
+ );
+ assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2);
+ // The in-memory data, which scripts read before the packet is written, follows the same rule.
+ let mut tree = crate::script::data_of(
+ &Document::open(Arc::new(crate::fixtures::static_shell("rye "))).unwrap(),
+ )
+ .unwrap();
+ tree.apply(&DataOp::Value { path: path.clone(), text: "millet\nwheat".into() });
+ assert_eq!(tree.value_at(&path).as_deref(), Some("millet\nwheat"));
+ // Emptied in memory, the group still takes picks, not text.
+ tree.apply(&DataOp::Value { path: path.clone(), text: String::new() });
+ assert_eq!((tree.count(&path, "value"), tree.value_at(&path).as_deref()), (0, Some("")));
+ tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() });
+ assert_eq!((tree.count(&path, "value"), tree.text_at(&path)), (2, Some("")), "picks again, not text");
+ assert_eq!(tree.value_at(&path).as_deref(), Some("rye\nmillet"));
+ let tree_of = |data: &str| crate::script::data_of(&Document::open(Arc::new(crate::fixtures::static_shell(data))).unwrap()).unwrap();
+ // A group read emptied, as the packet writer marks it, takes picks the same way.
+ let mut tree = tree_of(" ");
+ tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() });
+ assert_eq!(tree.count(&path, "value"), 2);
+ // A node holding anything but `value` leaves is not a list's picks: it is left as it was,
+ // as the packet writer leaves it (and reports the skip).
+ for structured in [
+ "rye x ",
+ "rye ",
+ ] {
+ let mut tree = tree_of(structured);
+ let before = tree.clone();
+ tree.apply(&DataOp::Value { path: path.clone(), text: "millet".into() });
+ assert_eq!(tree, before, "{structured}");
+ }
+}
diff --git a/crates/xfdf/Cargo.toml b/crates/xfdf/Cargo.toml
index f868c95719..d82e75394e 100644
--- a/crates/xfdf/Cargo.toml
+++ b/crates/xfdf/Cargo.toml
@@ -14,4 +14,8 @@ pdfcraft-annot = { workspace = true }
pdfcraft-forms = { workspace = true }
pdfcraft-model = { workspace = true }
roxmltree = "0.21"
+base64 = "0.23"
thiserror = { workspace = true }
+
+[dev-dependencies]
+proptest = { workspace = true }
diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md
index d286a457c3..0c823c2ee0 100644
--- a/crates/xfdf/README.md
+++ b/crates/xfdf/README.md
@@ -14,9 +14,30 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o
name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink,
default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads
(`inreplyto`); field values nested by name, with text preserved across XML comments and processing instructions.
+ Formatting travels both ways as XFDF 3.0 names it: rich text (``, the `/RC`
+ XHTML as markup; plain text in it is the comment's text), default style (``),
+ border width, style and dash pattern (`width`, `style`, `dashes`; also from an older `/Border`
+ array), cloudy borders (`style="cloudy"`, `intensity`), `rotation`, `justification` (free text
+ `left`/`centered`/`right`, redaction `0`–`2`), `fringe` (`/RD`) on every type, line leaders and
+ captions (`leaderLength`, `leaderExtend`, `leader-offset`, `caption`, `caption-style`,
+ `caption-offset-h`/`-v`), the caret `symbol` and redaction `overlay-text`/`overlay-text-repeat`.
+ The intent (`/IT`) is written as both `intent` and `IT`, and either is read.
+- **Appearances** (``, `appearance.rs`): a stamp's `/AP`, as XFDF 3.0 defines it for
+ stamps, base 64 of the dictionary written as `DICT`/`STREAM`/`ARRAY`/`NAME`/`INT`/`FIXED`/`BOOL`/
+ `STRING`/`NULL` elements with stream data in `DATA` (XFDF 3.0 "Stream encoding": `filtered`/`ascii`
+ or `raw`/`hex`), the shape XFDF files from other tools carry. Copy and paste carries every
+ comment's appearance but a redaction mark's, which PdfCraft always draws and import never
+ reads. Import keeps an appearance it can read and otherwise draws the comment and records the reason in
+ `Report::skipped` (the engine puts it in the repair log); it refuses one over 16 MiB, 64 MiB per
+ import, 64 nesting levels or 100,000 values. Export leaves out one past those limits or whose
+ rect has no area, and stops at the 64 MiB an import reads; where `/AS` selects one of several
+ states, the selected stream is written (and import, with no `/AS` in XFDF, refuses a normal
+ appearance of several states). Import and FDF import are all or nothing: on an error the
+ document is left as it was.
- **FDF**: fields (with `/Kids` hierarchies) and indirect comments. Nested annotation values
- are copied under FDF object numbers; shared references and cycles remain shared. Document
- links and appearances are excluded. Source `/Page` and `/Pages` dictionaries become null
+ are copied under FDF object numbers; shared references and cycles remain shared. A stamp's appearance
+ (`/AP`, with the forms and images it draws) is copied, as in XFDF, and an `/AP` in the file comes
+ back on import as objects of the document (a stream shared by several comments once); document links are excluded. Source `/Page` and `/Pages` dictionaries become null
at every copy depth, so nested destination actions cannot carry the source page tree or content.
Type names may be indirect; lookup checks each reference generation, is iterative, stops at
cycles and refuses chains beyond
@@ -28,7 +49,9 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o
levels or a conservative 64 MiB serialization budget instead of exporting partial data.
These are copied-graph limits: source parsing and page discovery retain their existing
COS/parser limits, with no total source-memory bound. Unknown indirect streams retain their
- encoded bytes. FDF import semantics are unchanged.
+ encoded bytes. FDF import copies a comment's streams only when the comment lands on a page, and
+ refuses the file (nothing imported) when its comments expand past 1,000,000 values or 64 MiB, or
+ nest more than 256 levels, rather than import part of an appearance.
- **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and
tab-delimited text (a row of names, a row of values).
@@ -44,7 +67,7 @@ tables remains heuristic. Both write a cell that a spreadsheet would run as a fo
or `+` or `-` when it isn't a plain number) with a leading `'`; CSV import takes off the `'` that
export added, so exported data imports back unchanged.
-Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws
-appearances PdfCraft can draw, and sets values through the form's own checks (formats,
+Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), keeps the
+appearance the file carries or draws one where PdfCraft can, and sets values through the form's own checks (formats,
validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read
with `roxmltree`.
diff --git a/crates/xfdf/src/appearance.rs b/crates/xfdf/src/appearance.rs
new file mode 100644
index 0000000000..3690a19f42
--- /dev/null
+++ b/crates/xfdf/src/appearance.rs
@@ -0,0 +1,377 @@
+//! A comment's appearance dictionary (`/AP`) in XFDF.
+//!
+//! ISO 19444-1 (XFDF 3.0, "appearance") carries a stamp's `/AP` as a base 64 encoded string. What
+//! the string holds is the dictionary written as XML, one element per PDF object: `DICT`, `STREAM`,
+//! `ARRAY`, `NAME`, `INT`, `FIXED` (a real number), `BOOL`, `STRING` and `NULL`, each with its
+//! dictionary key in `KEY` and a simple value in `VAL`; a stream's data is a `DATA` element whose
+//! `MODE` and `ENCODING` follow XFDF 3.0 "Stream encoding" (`filtered`/`ascii` for short printable
+//! data, `raw`/`hex` otherwise). That is what the XFDF files in the wild hold (for example the
+//! stamps attached to PDFBOX-4437 and PDFBOX-4478). Indirect objects are written in place, so the
+//! XML is self-contained.
+//!
+//! Both directions are bounded: the decoded XML, the stream data, the nesting and the number of
+//! values. Anything unreadable makes the whole appearance unusable; the caller then records it and
+//! draws the comment from its other entries.
+
+use base64::Engine as _;
+
+use super::common::{check_form_xml_depth, esc, parse_form_xml};
+use super::{Dict, Document, ObjRef, Object, PdfString};
+
+/// At most this much decoded XML in one appearance (and at most this much XML is written for one).
+pub(crate) const MAX_APPEARANCE_BYTES: usize = 16 << 20;
+/// At most this much appearance data (decoded XML) in one import, over all of its comments.
+pub(crate) const MAX_IMPORT_APPEARANCE_BYTES: usize = 64 << 20;
+/// How deeply objects may nest inside one appearance.
+const MAX_NESTING: usize = 64;
+/// How many PDF values one appearance may hold.
+const MAX_VALUES: usize = 100_000;
+/// XFDF 3.0 "Stream encoding": data shorter than this that is all printable ASCII is written as it
+/// is (`filtered`/`ascii`); everything else as hexadecimal (`raw`/`hex`).
+const ASCII_DATA_LIMIT: usize = 4096;
+
+/// The base 64 `` text for annotation `d`'s appearance dictionary, or `None` when it
+/// has no normal appearance stream or is past the limits (the comment is then exported without
+/// one).
+///
+/// XFDF has no appearance state (`/AS`), so where `/N`, `/R` or `/D` holds one stream per state
+/// only the selected state's stream is written (ISO 32000-2 §12.5.5: `/AS` picks it); a normal
+/// appearance with no selected stream has nothing to draw and nothing is written.
+///
+/// `budget` is what the export may still write (decoded XML), shared by all of its comments, as
+/// import reads at most [`MAX_IMPORT_APPEARANCE_BYTES`]: an appearance past it is left out rather
+/// than written for import to skip. It is charged for what is written.
+pub(crate) fn encode(doc: &Document, d: &Dict, budget: &mut usize) -> Option {
+ if !has_appearance(doc, d) {
+ return None;
+ }
+ let ap = d.get(b"AP").map(|a| doc.resolve(a))?;
+ let mut ap = ap.as_dict()?.clone();
+ let state = d.get(b"AS").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec));
+ for key in [b"N".as_slice(), b"R", b"D"] {
+ let Some(entry) = ap.get(key).map(|e| doc.resolve(e)) else { continue };
+ let Object::Dict(states) = entry.as_ref() else { continue };
+ match state.as_deref().and_then(|s| states.get(s)).cloned() {
+ Some(selected) => ap.set(key.to_vec(), selected),
+ None if key == b"N" => return None,
+ None => {
+ ap.remove(key);
+ }
+ }
+ }
+ let limit = MAX_APPEARANCE_BYTES.min(*budget);
+ let mut writer = Writer { doc, out: String::new(), values: MAX_VALUES, path: Vec::new(), limit };
+ writer.dict(Some(b"AP"), &ap, 0)?;
+ // The checks while writing leave room for the closing tags and line breaks: the finished XML
+ // must fit what the reader accepts, or the comment would arrive without its appearance.
+ if writer.out.len() > limit {
+ return None;
+ }
+ *budget = budget.saturating_sub(writer.out.len());
+ let b64 = base64::engine::general_purpose::STANDARD.encode(writer.out.as_bytes());
+ // Line breaks every 76 characters, as base 64 text usually has them; the reader skips them.
+ let mut wrapped = String::with_capacity(b64.len() + b64.len() / 76 + 1);
+ for (i, c) in b64.chars().enumerate() {
+ if i > 0 && i % 76 == 0 {
+ wrapped.push('\n');
+ }
+ wrapped.push(c);
+ }
+ Some(wrapped)
+}
+
+struct Writer<'a> {
+ doc: &'a Document,
+ out: String,
+ values: usize,
+ /// The references being written, so a reference back to one of them ends the cycle.
+ path: Vec,
+ /// At most this much XML.
+ limit: usize,
+}
+
+impl Writer<'_> {
+ fn key_attr(key: Option<&[u8]>) -> String {
+ key.map(|k| format!(" KEY=\"{}\"", esc(&pdfcraft_forms::name_text(k)))).unwrap_or_default()
+ }
+
+ fn spend(&mut self, depth: usize) -> Option<()> {
+ if depth > MAX_NESTING || self.out.len() > self.limit {
+ return None;
+ }
+ self.values = self.values.checked_sub(1)?;
+ Some(())
+ }
+
+ fn dict(&mut self, key: Option<&[u8]>, d: &Dict, depth: usize) -> Option<()> {
+ self.spend(depth)?;
+ self.out.push_str(&format!("\n", Self::key_attr(key)));
+ for (k, v) in d.iter() {
+ self.value(Some(k), v, depth + 1)?;
+ }
+ self.out.push_str(" \n");
+ Some(())
+ }
+
+ fn value(&mut self, key: Option<&[u8]>, o: &Object, depth: usize) -> Option<()> {
+ self.spend(depth)?;
+ let k = Self::key_attr(key);
+ match o {
+ Object::Ref(r) => {
+ if self.path.contains(r) {
+ // A cycle: the object is already being written further up.
+ self.out.push_str(&format!(" \n"));
+ return Some(());
+ }
+ self.path.push(*r);
+ let target = self.doc.get(*r);
+ let written = self.value(key, &target, depth + 1);
+ self.path.pop();
+ written?;
+ }
+ Object::Null => self.out.push_str(&format!(" \n")),
+ Object::Bool(b) => self.out.push_str(&format!("\n")),
+ Object::Int(i) => self.out.push_str(&format!("\n")),
+ // Rust prints the shortest decimal that reads back as the same number, never with an
+ // exponent; a non-finite number (which PDF can't hold) is written as 0.
+ Object::Real(r) => self.out.push_str(&format!("\n", if r.is_finite() { *r } else { 0.0 })),
+ Object::Name(n) => self.out.push_str(&format!("\n", esc(&pdfcraft_forms::name_text(n)))),
+ Object::String(s) => match std::str::from_utf8(&s.bytes) {
+ // Text XML can hold as it is (`esc` would drop other control characters).
+ Ok(t) if t.chars().all(|c| !(c < ' ' && !matches!(c, '\t' | '\n' | '\r')) && c != '\u{FFFE}' && c != '\u{FFFF}') => {
+ self.out.push_str(&format!("\n", esc(t)));
+ }
+ _ => self.out.push_str(&format!("\n", hex_of(&s.bytes, false))),
+ },
+ Object::Array(a) => {
+ self.out.push_str(&format!("\n"));
+ for v in a {
+ self.value(None, v, depth + 1)?;
+ }
+ self.out.push_str(" \n");
+ }
+ // A page (or the page tree) reached from the appearance is the source document's, not
+ // the comment's: written in place it would carry every page along.
+ // `/Type` may itself be a reference (`resolve` follows a bounded number of them).
+ Object::Dict(d) if d.get(b"Type").is_some_and(|t| matches!(self.doc.resolve(t).as_name(), Some(b"Page" | b"Pages"))) => {
+ self.out.push_str(&format!(" \n"));
+ }
+ Object::Dict(d) => self.dict(key, d, depth)?,
+ Object::Stream(s) => {
+ // XFDF 3.0 counts a carriage return as printable, but XML reads one, alone or before
+ // a line feed, as a line feed (XML 1.0 §2.11): data holding one is written as
+ // hexadecimal so it comes back byte for byte (the 4 KiB rule "is not a rule").
+ let printable = s.raw.iter().all(|&b| (b > 32 && b < 127) || b == b'\n');
+ let ascii = s.raw.len() < ASCII_DATA_LIMIT && printable && !s.dict.contains(b"Filter");
+ // The data, in the encoding it is written in, must fit what is left of the budget
+ // (as text it is at least its own length; the finished XML is checked again).
+ let size = if ascii { s.raw.len() } else { s.raw.len().saturating_mul(2) };
+ if size.saturating_add(self.out.len()) > self.limit {
+ return None;
+ }
+ self.out.push_str(&format!("\n"));
+ // `Length` follows from the data.
+ for (dk, dv) in s.dict.iter().filter(|(dk, _)| dk.as_slice() != b"Length") {
+ self.value(Some(dk), dv, depth + 1)?;
+ }
+ if ascii {
+ // Printable ASCII is valid UTF-8.
+ let text = String::from_utf8_lossy(&s.raw);
+ self.out.push_str(&format!("{} \n", esc_data(&text)));
+ } else {
+ self.out.push_str(&format!("{} \n", hex_of(&s.raw, true)));
+ }
+ self.out.push_str(" \n");
+ }
+ }
+ Some(())
+ }
+}
+
+/// XFDF 3.0 "Stream encoding", method 1: the XML control characters as entities; line breaks stay.
+fn esc_data(s: &str) -> String {
+ s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
+}
+
+/// Bytes as upper-case hexadecimal, with a line feed after every 80 characters when `wrap`.
+fn hex_of(bytes: &[u8], wrap: bool) -> String {
+ const DIGITS: &[u8; 16] = b"0123456789ABCDEF";
+ let mut out = String::with_capacity(bytes.len().saturating_mul(2).saturating_add(bytes.len() / 40));
+ for (i, b) in bytes.iter().enumerate() {
+ if wrap && i > 0 && i % 40 == 0 {
+ out.push('\n');
+ }
+ out.push(char::from(DIGITS[usize::from(b >> 4)]));
+ out.push(char::from(DIGITS[usize::from(b & 15)]));
+ }
+ out
+}
+
+/// Hexadecimal digits back to bytes, ignoring white space; `None` for any other character or an
+/// odd number of digits.
+fn bytes_of_hex(text: &str) -> Option> {
+ let digits: Vec = text.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
+ if !digits.len().is_multiple_of(2) {
+ return None;
+ }
+ let value = |c: u8| char::from(c).to_digit(16).and_then(|d| u8::try_from(d).ok());
+ digits.as_chunks::<2>().0.iter().map(|[h, l]| Some(value(*h)? << 4 | value(*l)?)).collect()
+}
+
+/// An appearance read from XFDF, before it is put into a document.
+#[derive(Debug, PartialEq)]
+pub(crate) enum Node {
+ Value(Object),
+ Array(Vec),
+ Dict(Vec<(Vec, Node)>),
+ Stream(Vec<(Vec, Node)>, Vec),
+}
+
+/// Read the text of an `` element: base 64, then the XML it holds. `budget` is what
+/// the import may still read (decoded bytes) and is charged. The result is the `/AP` dictionary,
+/// with a normal appearance (`/N`) that is one stream: XFDF has no appearance state (`/AS`), so a
+/// dictionary of states would leave nothing selected to draw (ISO 32000-2 §12.5.5).
+pub(crate) fn decode(text: &str, budget: &mut usize) -> Result {
+ let limit = MAX_APPEARANCE_BYTES.min(*budget);
+ // Base 64 is 4 characters for 3 bytes: refuse a text that decodes past the limit before
+ // decoding it.
+ let mut compact = String::new();
+ for c in text.chars().filter(|c| !c.is_whitespace()) {
+ if compact.len() / 4 * 3 > limit {
+ return Err(format!("the appearance is larger than {} MiB", limit >> 20));
+ }
+ compact.push(c);
+ }
+ let engine = base64::engine::GeneralPurpose::new(
+ &base64::alphabet::STANDARD,
+ base64::engine::GeneralPurposeConfig::new().with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
+ );
+ let bytes = engine.decode(compact.as_bytes()).map_err(|e| format!("the appearance is not base 64: {e}"))?;
+ if bytes.len() > limit {
+ return Err(format!("the appearance is larger than {} MiB", limit >> 20));
+ }
+ *budget = budget.saturating_sub(bytes.len());
+ let xml = std::str::from_utf8(&bytes).map_err(|_| "the appearance is not UTF-8 text".to_string())?;
+ let xml = xml.trim_start_matches('\u{feff}');
+ check_form_xml_depth(xml).map_err(|e| e.to_string())?;
+ let parsed = parse_form_xml(xml).map_err(|e| format!("the appearance is not XML: {e}"))?;
+ let root = parsed.root_element();
+ if !root.tag_name().name().eq_ignore_ascii_case("DICT") {
+ return Err("the appearance is not a dictionary".into());
+ }
+ let mut values = MAX_VALUES;
+ let node = read(root, 0, &mut values)?;
+ let usable = match &node {
+ Node::Dict(entries) => entries.iter().any(|(k, v)| k.as_slice() == b"N" && matches!(v, Node::Stream(..))),
+ _ => false,
+ };
+ if !usable {
+ return Err("the appearance has no normal appearance stream".into());
+ }
+ Ok(node)
+}
+
+fn attr<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> {
+ node.attributes().find(|a| a.name().eq_ignore_ascii_case(name)).map(|a| a.value())
+}
+
+fn read(node: roxmltree::Node, depth: usize, values: &mut usize) -> Result {
+ if depth > MAX_NESTING {
+ return Err(format!("the appearance nests objects more than {MAX_NESTING} deep"));
+ }
+ *values = values.checked_sub(1).ok_or_else(|| format!("the appearance holds more than {MAX_VALUES} values"))?;
+ let tag = node.tag_name().name().to_ascii_uppercase();
+ let val = || attr(node, "VAL").ok_or_else(|| format!("{tag} has no VAL"));
+ let entries = |values: &mut usize| -> Result, Node)>, String> {
+ let mut out = Vec::new();
+ for child in node.children().filter(|c| c.is_element() && !c.tag_name().name().eq_ignore_ascii_case("DATA")) {
+ let key = attr(child, "KEY").ok_or_else(|| format!("an entry of {tag} has no KEY"))?;
+ out.push((pdfcraft_forms::name_bytes(key), read(child, depth + 1, values)?));
+ }
+ Ok(out)
+ };
+ Ok(match tag.as_str() {
+ "DICT" => Node::Dict(entries(values)?),
+ "ARRAY" => {
+ let mut items = Vec::new();
+ for child in node.children().filter(|c| c.is_element()) {
+ items.push(read(child, depth + 1, values)?);
+ }
+ Node::Array(items)
+ }
+ "STREAM" => {
+ let mut dict = entries(values)?;
+ let data = node.children().find(|c| c.is_element() && c.tag_name().name().eq_ignore_ascii_case("DATA")).ok_or("a stream has no DATA")?;
+ let text: String = data.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
+ let bytes = match attr(data, "ENCODING").map(str::to_ascii_lowercase).as_deref() {
+ Some("hex") => bytes_of_hex(&text).ok_or("stream data is not hexadecimal")?,
+ Some("ascii") | None => text.into_bytes(),
+ Some(other) => return Err(format!("unknown stream encoding {other}")),
+ };
+ // `filtered` data is already decoded: the filters no longer apply to it.
+ if attr(data, "MODE").is_some_and(|m| m.eq_ignore_ascii_case("filtered")) {
+ dict.retain(|(k, _)| !matches!(k.as_slice(), b"Filter" | b"DecodeParms" | b"FFilter" | b"FDecodeParms"));
+ }
+ dict.retain(|(k, _)| k.as_slice() != b"Length");
+ Node::Stream(dict, bytes)
+ }
+ "NAME" => Node::Value(Object::Name(pdfcraft_forms::name_bytes(val()?))),
+ "INT" => Node::Value(Object::Int(val()?.trim().parse::().map_err(|_| "INT is not an integer")?)),
+ "FIXED" => {
+ let v = val()?.trim().parse::().ok().filter(|v| v.is_finite()).ok_or("FIXED is not a number")?;
+ Node::Value(Object::Real(v))
+ }
+ "BOOL" => Node::Value(Object::Bool(match val()?.trim() {
+ "true" => true,
+ "false" => false,
+ _ => return Err("BOOL is neither true nor false".into()),
+ })),
+ "STRING" => {
+ let v = val()?;
+ let bytes = if attr(node, "ENCODING").is_some_and(|e| e.eq_ignore_ascii_case("hex")) {
+ bytes_of_hex(v).ok_or("STRING is not hexadecimal")?
+ } else {
+ v.as_bytes().to_vec()
+ };
+ Node::Value(Object::String(PdfString::literal(bytes)))
+ }
+ "NULL" => Node::Value(Object::Null),
+ other => return Err(format!("unknown appearance element {other}")),
+ })
+}
+
+/// Put a read appearance into `doc`: each stream becomes an object of its own. Returns the `/AP`
+/// value. Null dictionary entries are left out (ISO 32000-2 §7.3.7: they mean the entry is absent).
+pub(crate) fn materialize(doc: &mut Document, node: Node) -> Object {
+ let dict = |doc: &mut Document, entries: Vec<(Vec, Node)>| {
+ let mut d = Dict::new();
+ for (k, v) in entries {
+ let v = materialize(doc, v);
+ if !matches!(v, Object::Null) {
+ d.set(k, v);
+ }
+ }
+ d
+ };
+ match node {
+ Node::Value(o) => o,
+ Node::Array(items) => Object::Array(items.into_iter().map(|n| materialize(doc, n)).collect()),
+ Node::Dict(entries) => Object::Dict(dict(doc, entries)),
+ Node::Stream(entries, raw) => {
+ let d = dict(doc, entries);
+ Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream { dict: d, raw: raw.into() })))
+ }
+ }
+}
+
+/// Whether an annotation dictionary carries an appearance the document can draw: `/AP` with a
+/// normal appearance that is a stream, or a dictionary of streams (one per state).
+pub(crate) fn has_appearance(doc: &Document, d: &Dict) -> bool {
+ let Some(ap) = d.get(b"AP").map(|a| doc.resolve(a)) else { return false };
+ let Some(normal) = ap.as_dict().and_then(|ap| ap.get(b"N")).map(|n| doc.resolve(n)) else { return false };
+ match normal.as_ref() {
+ Object::Stream(_) => true,
+ Object::Dict(states) => !states.is_empty() && states.iter().all(|(_, s)| matches!(doc.resolve(s).as_ref(), Object::Stream(_))),
+ _ => false,
+ }
+}
diff --git a/crates/xfdf/src/fdf.rs b/crates/xfdf/src/fdf.rs
index ceb4ad99eb..6c2f169931 100644
--- a/crates/xfdf/src/fdf.rs
+++ b/crates/xfdf/src/fdf.rs
@@ -42,13 +42,23 @@ struct FdfCopy<'a> {
objects: Vec,
numbers: HashMap,
pending: Vec<(ObjRef, usize)>,
+ /// The leaf pages of the source document, by identity: whatever their `/Type` says (or does not).
+ source_pages: HashSet,
nodes: usize,
bytes: usize,
}
impl<'a> FdfCopy<'a> {
fn new(doc: &'a Document) -> Self {
- Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES }
+ Self {
+ doc,
+ objects: Vec::new(),
+ numbers: HashMap::new(),
+ pending: Vec::new(),
+ source_pages: HashSet::new(),
+ nodes: MAX_FDF_OBJECTS,
+ bytes: MAX_FDF_BYTES,
+ }
}
fn charge(&mut self, bytes: usize) -> Result<(), DataError> {
@@ -86,13 +96,16 @@ impl<'a> FdfCopy<'a> {
Ok(())
}
- /// Skip source-document links and appearances without cloning their values. The
- /// dictionary and every retained entry are charged before any owned copy is made.
+ /// Skip source-document links without cloning their values. A stamp's appearance (`/AP`) is
+ /// copied with its streams and resources, so the stamp looks the same where it is imported;
+ /// other comments carry their formatting entries and leave the appearance out, as XFDF does.
+ /// The dictionary and every retained entry are charged before any owned copy is made.
fn annotation_dict(&mut self, dict: &Dict) -> Result {
self.charge_value(0)?;
+ let stamp = dict.name(b"Subtype") == Some(b"Stamp".as_slice());
let mut out = Dict::new();
for (key, value) in dict.iter() {
- if matches!(key.as_slice(), b"P" | b"AP" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") {
+ if matches!(key.as_slice(), b"P" | b"Popup" | b"IRT" | b"Parent" | b"FS" | b"Sound") || (key.as_slice() == b"AP" && !stamp) {
continue;
}
self.dict_entry(&mut out, key, value, 1)?;
@@ -178,7 +191,7 @@ impl<'a> FdfCopy<'a> {
while let Some((source, index)) = self.pending.get(cursor).copied() {
// ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader
// errors distinct so damaged objects cannot be silently lost during export.
- let value = if doc.generation(source.num) != source.generation {
+ let value = if doc.generation(source.num) != source.generation || self.source_pages.contains(&source) {
std::sync::Arc::new(Object::Null)
} else {
match doc.try_get(source.num) {
@@ -202,7 +215,7 @@ impl<'a> FdfCopy<'a> {
}
}
-/// FDF with field values and/or comments (appearances are not carried). Each comment is an
+/// FDF with field values and/or comments, a stamp with its appearance. Each comment is an
/// indirect object of the FDF, so a reply's `/IRT` can point at its parent's object there
/// (§12.7.8.3.1). Nested values are copied and renumbered, including shared or cyclic graphs.
/// Source `/Page` and `/Pages` dictionaries become null at every copy depth.
@@ -247,6 +260,8 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> R
// Keep the page/array owners once. Handles retain positions, never annotation
// bodies: the document cache can evict and reparse even a repeated source reference.
let pages = pdfcraft_model::pages(doc);
+ // Page discovery accepts a leaf with no /Type: the export keeps these out by reference too.
+ copy.source_pages = pages.iter().map(|p| p.obj).collect();
let mut arrays = Vec::new();
let mut picked: Vec<(Option, usize, usize)> = Vec::new();
for (pi, page) in pages.iter().enumerate() {
@@ -356,27 +371,69 @@ impl ImportBudget {
}
}
+/// Where [`deep`] copies to: the document, and the streams already copied into it (by their
+/// reference in the FDF), so a stream two comments share is copied once.
+struct DeepTarget<'a> {
+ doc: &'a mut Document,
+ streams: HashMap,
+}
+
+/// How deeply [`deep`] follows a comment's objects, references included. A form that draws a form
+/// costs four levels (the reference, the stream's dictionary, `/Resources`, `/XObject`), and
+/// export writes a chain of them however long, so this leaves room for one sixty forms deep.
+const MAX_COPY_DEPTH: usize = 4 * MAX_DEPTH;
+
/// Copy an object out of another document, resolving references. A reference back to an object
-/// being copied becomes null.
-fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result {
+/// being copied becomes null. A stream (an appearance and the images and forms it draws) becomes
+/// an object of `dst`, its data charged to the budget. Objects nested past [`MAX_COPY_DEPTH`] are
+/// an error, not a partial copy: the comment would arrive with part of its appearance missing.
+fn deep(
+ src: &Document,
+ dst: &mut DeepTarget,
+ o: &Object,
+ depth: usize,
+ path: &mut Vec,
+ budget: &mut ImportBudget,
+) -> Result {
budget.spend(1, 0)?;
- if depth > 16 {
- return Ok(Object::Null);
+ if depth > MAX_COPY_DEPTH {
+ return Err(DataError::Malformed(format!("a comment's objects nest more than {MAX_COPY_DEPTH} levels deep")));
}
Ok(match o {
Object::Ref(r) => {
if path.contains(r) {
return Ok(Object::Null);
}
+ if let Some(copied) = dst.streams.get(r) {
+ return Ok(Object::Ref(*copied));
+ }
+ let target = src.get(*r);
+ if let Object::Stream(stream) = target.as_ref() {
+ budget.spend(0, stream.raw.len())?;
+ path.push(*r);
+ let dict = deep(src, dst, &Object::Dict(stream.dict.clone()), depth + 1, path, budget);
+ path.pop();
+ let Object::Dict(mut dict) = dict? else { return Ok(Object::Null) };
+ dict.remove(b"Length");
+ let copied = dst.doc.add(Object::Stream(pdfcraft_cos::Stream { dict, raw: stream.raw.clone() }));
+ dst.streams.insert(*r, copied);
+ return Ok(Object::Ref(copied));
+ }
path.push(*r);
- let copy = deep(src, &src.get(*r), depth + 1, path, budget);
+ let copy = deep(src, dst, &target, depth + 1, path, budget);
path.pop();
copy?
}
- Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?),
+ Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, dst, x, depth + 1, path, budget)).collect::>()?),
+ // A page or page tree the comment reaches is the file's, not the comment's: copied in, it
+ // would bring its contents along (as export, which nulls them; `resolve` bounds the hops
+ // of an indirect /Type).
+ Object::Dict(d) if d.get(b"Type").is_some_and(|t| matches!(src.resolve(t).as_name(), Some(b"Page" | b"Pages"))) => Object::Null,
Object::Dict(d) => {
- Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?)
+ Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, dst, v, depth + 1, path, budget)?))).collect::>()?)
}
+ // A stream is always an indirect object (ISO 32000-2 §7.3.8); one held directly is not
+ // read.
Object::Stream(_) => Object::Null,
Object::String(s) => {
budget.spend(0, s.bytes.len())?;
@@ -469,8 +526,17 @@ pub(crate) fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect();
let mut placed: HashMap = HashMap::new();
let mut later: HashMap, Option)> = HashMap::new();
+ let mut copied_streams: HashMap = HashMap::new();
for a in body.get(b"Annots").map(|a| fdf.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
let Some(mut src) = fdf.resolve(&a).as_dict().cloned() else { continue };
+ // Only a comment of a known type on a page of this document is copied in: its streams
+ // become objects of the document.
+ let on_page = src.get(b"Page").and_then(|p| fdf.resolve(p).as_int()).and_then(|p| usize::try_from(p).ok()).is_some_and(|p| p < pages.len());
+ let known =
+ src.get(b"Subtype").map(|s| fdf.resolve(s)).is_some_and(|s| s.as_name().is_some_and(|s| SUBTYPES.iter().any(|(x, _)| x.as_bytes() == s)));
+ if !on_page || !known {
+ continue;
+ }
// The parent is another comment: link it below rather than copying it in.
let irt = src.get(b"IRT").cloned();
src.remove(b"IRT");
@@ -479,8 +545,19 @@ pub(crate) fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result = a.as_ref().into_iter().collect();
- let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue };
+ let mut target = DeepTarget { doc: &mut *doc, streams: std::mem::take(&mut copied_streams) };
+ let copy = deep(&fdf, &mut target, &Object::Dict(src), 0, &mut path, &mut budget);
+ copied_streams = target.streams;
+ let Object::Dict(mut d) = copy? else { continue };
let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else {
continue;
};
diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs
index c42d414918..984d1cdbe5 100644
--- a/crates/xfdf/src/lib.rs
+++ b/crates/xfdf/src/lib.rs
@@ -2,8 +2,10 @@
//!
//! - **XFDF** (ISO 19444-1, Acrobat's "Export all to data file" / "Import comments", and form
//! data): comments with their geometry, colours, flags, authors, dates, replies, pop-ups,
-//! ink, quadrilaterals, line ends and callouts; field values, nested by name.
-//! - **FDF** (ISO 32000-2 §12.7.8): fields and comments in PDF syntax.
+//! ink, quadrilaterals, line ends, callouts, formatting (rich text, default style, border
+//! style and dashes, rotation, justification, fringe, line leaders) and a stamp's appearance;
+//! field values, nested by name.
+//! - **FDF** (ISO 32000-2 §12.7.8): fields and comments in PDF syntax, with their appearances.
//! - Form data as **XML**, **CSV** and **tab-delimited text** (Acrobat's Export data formats).
//!
//! Import merges: a comment whose name (`/NM`) already exists on its page replaces it; field
@@ -18,6 +20,7 @@ use std::io::Write as _;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use pdfcraft_forms::{FieldKind, FieldValue};
+mod appearance;
mod common;
mod fdf;
mod xfdf;
@@ -36,6 +39,8 @@ use xfdf::{apply_values, import_xfdf};
#[cfg(test)]
mod tests;
+#[cfg(test)]
+mod tests_format;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DataError {
@@ -85,6 +90,9 @@ pub struct Report {
pub fields: usize,
/// Field values the form refused (format, validation, unknown options).
pub rejected: Vec,
+ /// What the import left out of a comment it did import, each as "name: why" (an appearance
+ /// it could not read: the comment is drawn from its other entries instead).
+ pub skipped: Vec,
}
/// Form data as XML, CSV or tab-delimited text (Acrobat's Export data formats).
@@ -330,8 +338,15 @@ fn tab_delimited(t: &str) -> Result)>, DataError> {
}
/// Import comments and/or field values from XFDF, FDF, XML, CSV or tab-delimited text
-/// (detected from the content).
+/// (detected from the content). All or nothing: on an error `doc` is left as it was.
pub fn import(doc: &mut Document, bytes: &[u8]) -> Result {
+ let mut work = doc.clone();
+ let report = import_into(&mut work, bytes)?;
+ *doc = work;
+ Ok(report)
+}
+
+fn import_into(doc: &mut Document, bytes: &[u8]) -> Result {
let report = if bytes.starts_with(b"%FDF") {
import_fdf(doc, bytes)?
} else {
diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs
index 96e9179faa..906e3e1c40 100644
--- a/crates/xfdf/src/tests.rs
+++ b/crates/xfdf/src/tests.rs
@@ -143,9 +143,10 @@ fn values(doc: &Document) -> Vec<(String, Vec)> {
fields(doc).into_iter().map(|f| (f.name, f.value)).collect()
}
-/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by
-/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its
-/// own `name`: importing it found a comment replying to itself and the pair was lost.
+/// #2666: Replace Text writes a Caret and a StrikeOut grouped with it (`/IRT`, `/RT /Group`). XFDF
+/// names the `/IRT` annotation by its `/NM`, so when both carried one id the strikeout's
+/// `inreplyto` was its own `name`: importing it found a comment replying to itself and the pair
+/// was lost.
#[test]
fn a_replace_text_pair_survives_an_xfdf_round_trip() {
let mut src = blank();
@@ -170,8 +171,8 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() {
let xfdf = export_xfdf(&src, true, false, "a.pdf");
assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}");
- assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}");
- assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself");
+ assert!(xfdf.contains("inreplyto=\"caret\""), "the strikeout is grouped with the caret: {xfdf}");
+ assert!(!xfdf.contains("inreplyto=\"strike\""), "never a reply to itself");
let mut dst = blank();
let r = import(&mut dst, xfdf.as_bytes()).unwrap();
@@ -180,6 +181,136 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() {
let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect();
assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}");
assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive");
+ assert!(found.iter().any(|s| s.subtype == "StrikeOut" && s.is_group_member()), "still grouped (/RT /Group) with the caret: {found:?}");
+ // Review on #5689 (CodeRabbit): the intents travel too, so the imported pair is still
+ // Replace Text, and deleting its strikeout takes the caret and its replacement with it.
+ assert!(xfdf.contains("intent=\"Replace\"") && xfdf.contains("intent=\"StrikeOutTextEdit\""), "{xfdf}");
+ let strike = summaries(&dst).into_iter().find(|s| s.subtype == "StrikeOut").expect("the strikeout");
+ pdfcraft_annot::delete_annotation(&mut dst, 0, strike.index).unwrap();
+ assert!(summaries(&dst).iter().all(|s| s.subtype != "Caret"), "the caret goes with the strikeout");
+}
+
+/// Review on #5689 (CodeRabbit): XFDF links a reply to its target by the target's `name`, so a
+/// group whose primary has no `/NM` lost its `inreplyto` on export and came back as two loose
+/// comments. Both layouts: the caret primary (this PR) and the strikeout primary (older files).
+#[test]
+fn an_unnamed_group_primary_gets_an_export_only_name() {
+ for caret_primary in [true, false] {
+ let mut src = blank();
+ let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
+ let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
+ let unnamed = Meta { date: None, id: String::new() };
+ add_text_replacement(
+ &mut src,
+ TextReplacement {
+ page: 0,
+ quads: &quads,
+ replacement: "new words",
+ author: "Ada",
+ strike: &style,
+ caret: &style,
+ strike_meta: &unnamed,
+ caret_meta: &unnamed,
+ },
+ )
+ .unwrap();
+ let page = pdfcraft_model::pages(&src)[0].obj;
+ let annots = src.get(page).as_dict().unwrap().get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap();
+ let refs: Vec = annots.iter().filter_map(Object::as_ref).collect();
+ let is = |doc: &Document, r: ObjRef, s: &[u8]| doc.get(r).as_dict().and_then(|d| d.name(b"Subtype")) == Some(s);
+ let strike = *refs.iter().find(|r| is(&src, **r, b"StrikeOut")).unwrap();
+ let caret = *refs.iter().find(|r| is(&src, **r, b"Caret")).unwrap();
+ let (primary, sub) = if caret_primary { (caret, strike) } else { (strike, caret) };
+ if !caret_primary {
+ src.update_dict(strike, |d| {
+ d.remove(b"IRT");
+ d.remove(b"RT");
+ })
+ .unwrap();
+ src.update_dict(caret, |d| {
+ d.set(b"IRT".to_vec(), Object::Ref(strike));
+ d.set(b"RT".to_vec(), Object::name("Group"));
+ })
+ .unwrap();
+ }
+ // A comment already named what the made-up name would be: the two must not collide.
+ let clash = format!("pdfcraft-export-{}-{}", primary.num, primary.generation);
+ let note = NewAnnotation {
+ page: 0,
+ shape: Shape::Note { at: [300.0, 300.0], icon: Default::default() },
+ style: style.clone(),
+ contents: "note".into(),
+ author: "Ada".into(),
+ };
+ add_annotation(&mut src, ¬e, &Meta { date: None, id: clash.clone() }).unwrap();
+
+ let xfdf = export_xfdf(&src, true, false, "a.pdf");
+ let doc = roxmltree::Document::parse(&xfdf).unwrap();
+ let el = |t: &str| doc.descendants().find(|n| n.has_tag_name(t)).unwrap_or_else(|| panic!("no <{t}>: {xfdf}"));
+ let (tag, sub_tag) = if caret_primary { ("caret", "strikeout") } else { ("strikeout", "caret") };
+ let name = el(tag).attribute("name").unwrap_or_else(|| panic!("the unnamed primary gets a name ({caret_primary}): {xfdf}"));
+ assert_eq!(el(sub_tag).attribute("inreplyto"), Some(name), "{xfdf}");
+ assert_ne!(name, clash, "a made-up name never repeats a real /NM");
+ assert_eq!(doc.descendants().filter(|n| n.attribute("name") == Some(name)).count(), 1, "{xfdf}");
+ assert!(src.get(primary).as_dict().unwrap().get(b"NM").is_none(), "the name is for the export only");
+ assert!(src.get(sub).as_dict().unwrap().get(b"NM").is_none());
+
+ let mut dst = blank();
+ import(&mut dst, xfdf.as_bytes()).unwrap();
+ // The imported subordinate is grouped under the imported primary again.
+ let dpage = pdfcraft_model::pages(&dst)[0].obj;
+ let dannots = dst.get(dpage).as_dict().unwrap().get(b"Annots").and_then(|a| dst.resolve(a).as_array().cloned()).unwrap();
+ let drefs: Vec = dannots.iter().filter_map(Object::as_ref).collect();
+ let (p_sub, s_sub): (&[u8], &[u8]) = if caret_primary { (b"Caret", b"StrikeOut") } else { (b"StrikeOut", b"Caret") };
+ let dprimary = *drefs.iter().find(|r| is(&dst, **r, p_sub)).unwrap();
+ let dsub = *drefs.iter().find(|r| is(&dst, **r, s_sub)).unwrap();
+ let irt = dst.get(dsub).as_dict().unwrap().get(b"IRT").and_then(Object::as_ref);
+ assert_eq!(irt, Some(dprimary), "the group survives the round trip ({caret_primary})");
+ }
+}
+
+/// An annotation written inline in `/Annots` (a direct dictionary, not a reference) is exported
+/// under its `/NM` too, so a made-up name must not repeat it either.
+#[test]
+fn an_export_only_name_never_repeats_an_inline_annotations_nm() {
+ let mut src = blank();
+ let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
+ let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
+ let unnamed = Meta { date: None, id: String::new() };
+ add_text_replacement(
+ &mut src,
+ TextReplacement {
+ page: 0,
+ quads: &quads,
+ replacement: "new words",
+ author: "Ada",
+ strike: &style,
+ caret: &style,
+ strike_meta: &unnamed,
+ caret_meta: &unnamed,
+ },
+ )
+ .unwrap();
+ let page = pdfcraft_model::pages(&src)[0].obj;
+ let mut annots = src.get(page).as_dict().unwrap().get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap();
+ let primary =
+ annots.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().and_then(|d| d.name(b"Subtype")) == Some(&b"Caret"[..])).unwrap();
+ // An inline note already named what the made-up name would be.
+ let clash = format!("pdfcraft-export-{}-{}", primary.num, primary.generation);
+ let mut note = Dict::new();
+ note.set(b"Type".to_vec(), Object::name("Annot"));
+ note.set(b"Subtype".to_vec(), Object::name("Text"));
+ note.set(b"NM".to_vec(), PdfString::text(&clash));
+ note.set(b"Rect".to_vec(), Object::Array(vec![300.into(), 300.into(), 320.into(), 320.into()]));
+ annots.push(Object::Dict(note));
+ src.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(annots))).unwrap();
+
+ let xfdf = export_xfdf(&src, true, false, "a.pdf");
+ let doc = roxmltree::Document::parse(&xfdf).unwrap();
+ let caret = doc.descendants().find(|n| n.has_tag_name("caret")).unwrap_or_else(|| panic!("no : {xfdf}"));
+ let name = caret.attribute("name").unwrap_or_else(|| panic!("the unnamed primary gets a name: {xfdf}"));
+ assert_ne!(name, clash, "a made-up name never repeats an inline annotation's /NM: {xfdf}");
+ assert_eq!(doc.descendants().filter(|n| n.attribute("name") == Some(&clash[..])).count(), 1, "{xfdf}");
}
#[test]
@@ -438,8 +569,10 @@ fn fdf_keeps_replies_and_review_status_on_their_note() {
// Every cross-reference entry points at its object (other readers trust the table).
let tail = std::str::from_utf8(&fdf[fdf.windows(5).position(|w| w == b"xref\n").unwrap()..]).unwrap();
let xref: usize = tail.lines().skip_while(|l| *l != "startxref").nth(1).unwrap().parse().unwrap();
- assert!(fdf[xref..].starts_with(b"xref\n0 6\n"), "{tail}");
- for (num, line) in tail.lines().skip(3).take(5).enumerate() {
+ // The root, the four comments and their appearance streams.
+ let count: usize = tail.lines().nth(1).and_then(|l| l.strip_prefix("0 ")).unwrap().parse().unwrap();
+ assert!(fdf[xref..].starts_with(b"xref\n") && count >= 6, "{tail}");
+ for (num, line) in tail.lines().skip(3).take(count - 1).enumerate() {
let at: usize = line[..10].parse().unwrap();
assert!(fdf[at..].starts_with(format!("{} 0 obj\n", num + 1).as_bytes()), "object {}: {line}", num + 1);
}
@@ -1575,6 +1708,43 @@ fn xml_form_import_preserves_text_around_comments_and_instructions() {
}
}
+/// Page discovery accepts a leaf without /Type, so a comment's nested reference to one must not
+/// carry the page or its content into the export.
+#[test]
+fn fdf_export_nulls_a_nested_link_to_a_page_without_a_type() {
+ let source = {
+ let mut doc = Document::new_empty();
+ let root = doc.root().unwrap();
+ let tree = doc.get(root).as_dict().unwrap().reference(b"Pages").unwrap();
+ let contents = doc.add(pdfcraft_cos::Stream::from_raw(Dict::new(), b"% private-page-content\n".repeat(4_096)));
+ let mut page = Dict::new();
+ page.set(b"Parent".to_vec(), Object::Ref(tree));
+ page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
+ page.set(b"Contents".to_vec(), Object::Ref(contents));
+ let page = doc.add(page);
+ doc.update_dict(tree, |d| {
+ d.set(b"Type".to_vec(), Object::name("Pages"));
+ d.set(b"Count".to_vec(), Object::Int(1));
+ d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
+ })
+ .unwrap();
+ let mut target = Dict::new();
+ target.set(b"Label".to_vec(), PdfString::text("kept"));
+ target.set(b"Target".to_vec(), Object::Ref(page));
+ fdf_note_with_data(&mut doc, Object::Array(vec![Object::Dict(target)]));
+ write_full(&doc, &SaveOptions::default()).unwrap()
+ };
+ let source = Document::open(Arc::new(source)).unwrap();
+ assert_eq!(pdfcraft_model::pages(&source).len(), 1);
+ let bytes = export_fdf(&source, true, false, "source.pdf").unwrap();
+ assert!(bytes.len() < 4_096, "the untyped page's content must stay in the PDF ({} bytes)", bytes.len());
+ let fdf = Document::open(Arc::new(bytes)).unwrap();
+ let data = exported_note_data(&fdf);
+ let target = data.as_array().unwrap()[0].as_dict().unwrap();
+ assert_eq!(target.get(b"Label").unwrap().as_string().unwrap().to_text(), "kept");
+ assert_eq!(*fdf.resolve(target.get(b"Target").unwrap()), Object::Null);
+}
+
#[test]
fn fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree() {
for indirect_type in [false, true] {
@@ -2101,6 +2271,48 @@ fn an_fdf_comment_popup_without_a_rect_is_dropped() {
assert_eq!(popups, 0);
}
+/// A check box field whose two kids have the on states `A` and `B`.
+fn check_box_kids() -> Document {
+ let objs = [
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
+ "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
+ "<< /Fields [5 0 R] >>",
+ "<< /FT /Btn /T (pair) /V /Off /Kids [6 0 R 7 0 R] >>",
+ "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [50 700 64 714] /P 3 0 R /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>",
+ "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [80 700 94 714] /P 3 0 R /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>",
+ "<< /Length 0 >>\nstream\n\nendstream",
+ ];
+ 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());
+ Document::open(Arc::new(out)).unwrap()
+}
+
+/// Imported data that names a check box kid's on state ticks that kid (ISO 32000-2 §12.7.5.2.3:
+/// the field's value is the on state of the kid that is on), not the first kid.
+#[test]
+fn imported_values_tick_the_check_box_kid_they_name() {
+ let mut src = check_box_kids();
+ set_value(&mut src, "pair", &pdfcraft_forms::FieldValue::Text("B".into())).unwrap();
+ let xfdf = export_xfdf(&src, false, true, "form.pdf");
+ let mut dst = check_box_kids();
+ import(&mut dst, xfdf.as_bytes()).unwrap();
+ let all = fields(&dst);
+ let pair = all.iter().find(|f| f.name == "pair").unwrap();
+ assert_eq!(pair.value, ["B"], "{xfdf}");
+ assert_eq!((pair.widget_on(0), pair.widget_on(1)), (false, true));
+}
+
/// Copy: the chosen comments travel with their replies and nothing else.
#[test]
fn exporting_selected_comments_takes_them_and_their_replies() {
@@ -2155,6 +2367,19 @@ fn importing_comments_as_copies_adds_them_on_the_page_with_fresh_names() {
assert_eq!(summaries(&other).len(), 4, "the named import replaced its own earlier copy");
}
+/// Comment import never takes the file's field values, whatever the placement (a non-finite
+/// offset, which is ignored, and no page or prefix made it the full import).
+#[test]
+fn comment_import_leaves_field_values_alone() {
+ let xfdf = r#"from the file "#;
+ for offset in [Some([f64::NAN, 0.0]), None] {
+ let mut doc = one_field_named("Name", "mine");
+ let r = import_comments(&mut doc, xfdf, None, None, offset).unwrap();
+ assert_eq!((r.comments, r.fields), (1, 0), "{offset:?}: {r:?}");
+ assert_eq!(values(&doc), [("Name".to_string(), vec!["mine".to_string()])], "{offset:?}");
+ }
+}
+
/// Other tools write comments straight into the page's `/Annots` array, without an object of
/// their own: they copy like any other, and two source comments sharing a name give two copies.
#[test]
diff --git a/crates/xfdf/src/tests_format.rs b/crates/xfdf/src/tests_format.rs
new file mode 100644
index 0000000000..35a9735ede
--- /dev/null
+++ b/crates/xfdf/src/tests_format.rs
@@ -0,0 +1,1216 @@
+//! Comment formatting and appearances through XFDF (ISO 19444-1, XFDF 3.0) and FDF
+//! (ISO 32000-2 §12.7.8): each property both ways, appearance streams with their resources, and
+//! files that are malformed or too large.
+
+use std::sync::Arc;
+
+use base64::Engine as _;
+use pdfcraft_cos::{SaveOptions, Stream, write_full};
+use proptest::prelude::*;
+
+use super::*;
+
+/// A document with `n` empty pages.
+fn pages(n: usize) -> Document {
+ let mut doc = Document::new_empty();
+ let root = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
+ let mut kids = Vec::new();
+ for _ in 0..n {
+ let mut p = Dict::new();
+ p.set(b"Type".to_vec(), Object::name("Page"));
+ p.set(b"Parent".to_vec(), Object::Ref(root));
+ p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
+ kids.push(Object::Ref(doc.add(p)));
+ }
+ doc.update_dict(root, |d| {
+ d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
+ d.set(b"Kids".to_vec(), Object::Array(kids));
+ })
+ .unwrap();
+ doc
+}
+
+fn page_ref(doc: &Document, i: usize) -> ObjRef {
+ pdfcraft_model::pages(doc)[i].obj
+}
+
+/// Add an annotation dictionary to page 0 as an object of its own.
+fn put(doc: &mut Document, mut d: Dict) -> ObjRef {
+ let page = page_ref(doc, 0);
+ d.set(b"Type".to_vec(), Object::name("Annot"));
+ d.set(b"P".to_vec(), Object::Ref(page));
+ let r = doc.add(Object::Dict(d));
+ doc.update_dict(page, |p| match p.get_mut(b"Annots") {
+ Some(Object::Array(a)) => a.push(Object::Ref(r)),
+ _ => p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(r)])),
+ })
+ .unwrap();
+ r
+}
+
+fn nums(v: &[f64]) -> Object {
+ Object::Array(v.iter().map(|x| Object::Real(*x)).collect())
+}
+
+fn annot(sub: &str, nm: &str, rect: [f64; 4]) -> Dict {
+ let mut d = Dict::new();
+ d.set(b"Subtype".to_vec(), Object::name(sub));
+ d.set(b"NM".to_vec(), PdfString::text(nm));
+ d.set(b"Rect".to_vec(), nums(&rect));
+ d.set(b"F".to_vec(), Object::Int(4));
+ d
+}
+
+/// The imported annotation named `nm`.
+fn named(doc: &Document, nm: &str) -> Dict {
+ let r = xfdf::find_by_name(doc, nm).unwrap_or_else(|| panic!("no comment {nm}"));
+ doc.get(r).as_dict().unwrap().clone()
+}
+
+fn f64s(doc: &Document, o: Option<&Object>) -> Vec {
+ o.map(|o| doc.resolve(o)).and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default()
+}
+
+fn text(doc: &Document, d: &Dict, k: &[u8]) -> Option {
+ d.get(k).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text()))
+}
+
+fn sub_dict(doc: &Document, d: &Dict, k: &[u8]) -> Dict {
+ d.get(k).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default()
+}
+
+const RICH: &str = r#"Bold & plain
two
"#;
+
+/// A free text comment with every formatting entry XFDF 3.0 gives an element or attribute for.
+fn formatted_freetext() -> Dict {
+ let mut d = annot("FreeText", "ft", [100.0, 100.0, 300.0, 200.0]);
+ d.set(b"Contents".to_vec(), PdfString::text("Bold & plain\rtwo"));
+ d.set(b"RC".to_vec(), PdfString::text(RICH));
+ d.set(b"DA".to_vec(), PdfString::literal(b"0 0 1 rg /Helv 14 Tf".to_vec()));
+ d.set(b"DS".to_vec(), PdfString::text("font: Helvetica,sans-serif 14.0pt; text-align:right; color:#0000FF"));
+ d.set(b"Q".to_vec(), Object::Int(2));
+ d.set(b"Rotate".to_vec(), Object::Int(90));
+ d.set(b"RD".to_vec(), nums(&[1.5, 2.0, 3.0, 4.0]));
+ let mut bs = Dict::new();
+ bs.set(b"W".to_vec(), Object::Real(2.0));
+ bs.set(b"S".to_vec(), Object::name("D"));
+ bs.set(b"D".to_vec(), nums(&[3.0, 2.0]));
+ d.set(b"BS".to_vec(), Object::Dict(bs));
+ d
+}
+
+fn formatted_line() -> Dict {
+ let mut d = annot("Line", "ln", [50.0, 50.0, 250.0, 120.0]);
+ d.set(b"L".to_vec(), nums(&[60.0, 60.0, 240.0, 60.0]));
+ d.set(b"LL".to_vec(), Object::Real(-12.0));
+ d.set(b"LLE".to_vec(), Object::Real(3.0));
+ d.set(b"LLO".to_vec(), Object::Real(1.5));
+ d.set(b"Cap".to_vec(), Object::Bool(true));
+ d.set(b"CP".to_vec(), Object::name("Top"));
+ d.set(b"CO".to_vec(), nums(&[4.0, -2.0]));
+ d.set(b"IT".to_vec(), Object::name("LineDimension"));
+ let mut bs = Dict::new();
+ bs.set(b"W".to_vec(), Object::Real(1.0));
+ bs.set(b"S".to_vec(), Object::name("D"));
+ bs.set(b"D".to_vec(), nums(&[4.0, 3.0, 2.0, 3.0]));
+ d.set(b"BS".to_vec(), Object::Dict(bs));
+ d
+}
+
+fn round_trip_xfdf(src: &Document) -> (String, Document) {
+ let xml = export_xfdf(src, true, false, "src.pdf");
+ let mut dst = pages(2);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ (xml, dst)
+}
+
+#[test]
+fn free_text_formatting_is_exported_as_xfdf_elements_and_attributes() {
+ let mut src = pages(1);
+ put(&mut src, formatted_freetext());
+ let xml = export_xfdf(&src, true, false, "src.pdf");
+ for want in [
+ r#"justification="right""#,
+ r#"rotation="90""#,
+ r#"width="2""#,
+ r#"style="dash""#,
+ r#"dashes="3,2""#,
+ r#"fringe="1.5,2,3,4""#,
+ "font: Helvetica,sans-serif 14.0pt; text-align:right; color:#0000FF ",
+ // The rich text's element, as markup and without its XML declaration.
+ r#"Bold & plain
two
"#,
+ ] {
+ assert!(xml.contains(want), "missing {want} in\n{xml}");
+ }
+ // What it writes is XML the reader accepts.
+ assert!(roxmltree::Document::parse(&xml).is_ok());
+}
+
+#[test]
+fn free_text_formatting_comes_back_from_xfdf() {
+ let mut src = pages(1);
+ put(&mut src, formatted_freetext());
+ let (_, dst) = round_trip_xfdf(&src);
+ let d = named(&dst, "ft");
+ assert_eq!(text(&dst, &d, b"RC").as_deref(), Some(RICH));
+ assert_eq!(text(&dst, &d, b"DS").as_deref(), Some("font: Helvetica,sans-serif 14.0pt; text-align:right; color:#0000FF"));
+ assert_eq!(text(&dst, &d, b"Contents").as_deref(), Some("Bold & plain\rtwo"));
+ assert_eq!(d.int(b"Q"), Some(2));
+ assert_eq!(d.int(b"Rotate"), Some(90));
+ assert_eq!(f64s(&dst, d.get(b"RD")), vec![1.5, 2.0, 3.0, 4.0]);
+ let bs = sub_dict(&dst, &d, b"BS");
+ assert_eq!(bs.name(b"S"), Some(b"D".as_slice()), "a dashed border comes back dashed, not solid");
+ assert_eq!(f64s(&dst, bs.get(b"D")), vec![3.0, 2.0]);
+ assert_eq!(bs.get(b"W").and_then(Object::as_f64), Some(2.0));
+}
+
+#[test]
+fn line_leaders_and_caption_round_trip() {
+ let mut src = pages(1);
+ put(&mut src, formatted_line());
+ let (xml, dst) = round_trip_xfdf(&src);
+ for want in [
+ r#"leaderLength="-12""#,
+ r#"leaderExtend="3""#,
+ r#"leader-offset="1.5""#,
+ r#"caption="yes""#,
+ r#"caption-style="Top""#,
+ r#"caption-offset-h="4" caption-offset-v="-2""#,
+ r#"dashes="4,3,2,3""#,
+ ] {
+ assert!(xml.contains(want), "missing {want} in\n{xml}");
+ }
+ let d = named(&dst, "ln");
+ assert_eq!(d.get(b"LL").and_then(Object::as_f64), Some(-12.0));
+ assert_eq!(d.get(b"LLE").and_then(Object::as_f64), Some(3.0));
+ assert_eq!(d.get(b"LLO").and_then(Object::as_f64), Some(1.5));
+ assert_eq!(d.get(b"Cap"), Some(&Object::Bool(true)));
+ assert_eq!(d.name(b"CP"), Some(b"Top".as_slice()));
+ assert_eq!(f64s(&dst, d.get(b"CO")), vec![4.0, -2.0]);
+ assert_eq!(f64s(&dst, sub_dict(&dst, &d, b"BS").get(b"D")), vec![4.0, 3.0, 2.0, 3.0]);
+}
+
+#[test]
+fn square_circle_caret_and_redaction_attributes_round_trip() {
+ let mut src = pages(1);
+ let mut sq = annot("Square", "sq", [10.0, 10.0, 110.0, 110.0]);
+ sq.set(b"RD".to_vec(), nums(&[5.0, 5.0, 5.0, 5.0]));
+ let mut be = Dict::new();
+ be.set(b"S".to_vec(), Object::name("C"));
+ be.set(b"I".to_vec(), Object::Real(2.0));
+ sq.set(b"BE".to_vec(), Object::Dict(be));
+ put(&mut src, sq);
+ let mut ci = annot("Circle", "ci", [120.0, 10.0, 220.0, 110.0]);
+ let mut bs = Dict::new();
+ bs.set(b"W".to_vec(), Object::Real(3.0));
+ bs.set(b"S".to_vec(), Object::name("B"));
+ ci.set(b"BS".to_vec(), Object::Dict(bs));
+ put(&mut src, ci);
+ let mut ca = annot("Caret", "ca", [230.0, 10.0, 250.0, 30.0]);
+ ca.set(b"Sy".to_vec(), Object::name("P"));
+ ca.set(b"RD".to_vec(), nums(&[1.0, 1.0, 1.0, 1.0]));
+ put(&mut src, ca);
+ let mut rd = annot("Redact", "rd", [300.0, 10.0, 400.0, 30.0]);
+ rd.set(b"OverlayText".to_vec(), PdfString::text("WITHHELD"));
+ rd.set(b"Repeat".to_vec(), Object::Bool(true));
+ rd.set(b"Q".to_vec(), Object::Int(1));
+ put(&mut src, rd);
+ // A solid border that still holds a dash array keeps it, and stays solid.
+ let mut solid = annot("Square", "solid", [120.0, 200.0, 220.0, 300.0]);
+ let mut bs = Dict::new();
+ bs.set(b"W".to_vec(), Object::Real(1.0));
+ bs.set(b"S".to_vec(), Object::name("S"));
+ bs.set(b"D".to_vec(), nums(&[2.0]));
+ solid.set(b"BS".to_vec(), Object::Dict(bs));
+ put(&mut src, solid);
+ // An older /Border array: width 2, dashes 4 on 1.
+ let mut old = annot("Square", "old", [10.0, 200.0, 110.0, 300.0]);
+ old.set(b"Border".to_vec(), Object::Array(vec![0.into(), 0.into(), 2.into(), nums(&[4.0, 1.0])]));
+ put(&mut src, old);
+ let (xml, dst) = round_trip_xfdf(&src);
+ for want in [
+ r#"style="cloudy" intensity="2""#,
+ r#"fringe="5,5,5,5""#,
+ r#"style="bevelled""#,
+ r#"symbol="paragraph""#,
+ r#"overlay-text="WITHHELD""#,
+ r#"overlay-text-repeat="true""#,
+ r#"justification="1""#,
+ ] {
+ assert!(xml.contains(want), "missing {want} in\n{xml}");
+ }
+ let sq = named(&dst, "sq");
+ assert_eq!(f64s(&dst, sq.get(b"RD")), vec![5.0; 4]);
+ assert_eq!(sub_dict(&dst, &sq, b"BE").get(b"I").and_then(Object::as_f64), Some(2.0));
+ assert_eq!(sub_dict(&dst, &named(&dst, "ci"), b"BS").name(b"S"), Some(b"B".as_slice()));
+ let ca = named(&dst, "ca");
+ assert_eq!(ca.name(b"Sy"), Some(b"P".as_slice()));
+ assert_eq!(f64s(&dst, ca.get(b"RD")), vec![1.0; 4]);
+ let rd = named(&dst, "rd");
+ assert_eq!(text(&dst, &rd, b"OverlayText").as_deref(), Some("WITHHELD"));
+ assert_eq!(rd.get(b"Repeat"), Some(&Object::Bool(true)));
+ assert_eq!(rd.int(b"Q"), Some(1));
+ let solid = sub_dict(&dst, &named(&dst, "solid"), b"BS");
+ assert_eq!((solid.name(b"S"), f64s(&dst, solid.get(b"D"))), (Some(b"S".as_slice()), vec![2.0]));
+ let old = sub_dict(&dst, &named(&dst, "old"), b"BS");
+ assert_eq!((old.get(b"W").and_then(Object::as_f64), old.name(b"S")), (Some(2.0), Some(b"D".as_slice())));
+ assert_eq!(f64s(&dst, old.get(b"D")), vec![4.0, 1.0]);
+}
+
+/// The XFDF the audit imported: what another tool writes for formatted comments.
+#[test]
+fn formatting_written_by_another_tool_is_imported() {
+ let xml = r#"
+
+
+Hello there
+font: 12pt Helvetica 0 g /Helv 12 Tf
+
+ "#;
+ let mut dst = pages(1);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ let a = named(&dst, "a");
+ assert_eq!(a.int(b"Q"), Some(2));
+ assert_eq!(a.int(b"Rotate"), Some(90));
+ assert_eq!(f64s(&dst, a.get(b"RD")), vec![2.0; 4]);
+ assert_eq!(text(&dst, &a, b"DS").as_deref(), Some("font: 12pt Helvetica"));
+ assert_eq!(
+ text(&dst, &a, b"RC").as_deref(),
+ Some(r#"Hello there
"#)
+ );
+ // Without , the comment's text is the rich text's.
+ assert_eq!(text(&dst, &a, b"Contents").as_deref(), Some("Hello there"));
+ let bs = sub_dict(&dst, &a, b"BS");
+ assert_eq!(bs.name(b"S"), Some(b"D".as_slice()));
+ assert_eq!(f64s(&dst, bs.get(b"D")), vec![3.0, 2.0]);
+ let b = named(&dst, "b");
+ assert_eq!(b.get(b"LL").and_then(Object::as_f64), Some(10.0));
+ assert_eq!(b.get(b"LLE").and_then(Object::as_f64), Some(2.0));
+ assert_eq!(b.get(b"LLO").and_then(Object::as_f64), Some(1.0));
+}
+
+#[test]
+fn plain_rich_text_is_the_comments_text_and_paragraphs_become_lines() {
+ let xml = r#"
+just text
+
+
+ one
+ two
+
+
+ "#;
+ let mut dst = pages(1);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ let p = named(&dst, "p");
+ assert_eq!(text(&dst, &p, b"Contents").as_deref(), Some("just text"));
+ assert!(!p.contains(b"RC"));
+ assert_eq!(text(&dst, &named(&dst, "q"), b"Contents").as_deref(), Some("one\rtwo"));
+}
+
+#[test]
+fn rich_text_that_is_not_xml_survives_as_text() {
+ let mut src = pages(1);
+ let mut d = annot("Text", "bad", [10.0, 10.0, 30.0, 30.0]);
+ d.set(b"Contents".to_vec(), PdfString::text("x"));
+ d.set(b"RC".to_vec(), PdfString::text("unclosed & raw"));
+ put(&mut src, d);
+ let (xml, dst) = round_trip_xfdf(&src);
+ assert!(xml.contains("<p>unclosed & raw "), "{xml}");
+ let d = named(&dst, "bad");
+ assert_eq!(text(&dst, &d, b"RC").as_deref(), Some("
unclosed & raw"));
+ assert_eq!(text(&dst, &d, b"Contents").as_deref(), Some("x"));
+}
+
+/// An image stamp: its appearance draws an image XObject from its resources.
+fn image_stamp(doc: &mut Document, nm: &str, sub: &str) -> (Vec, Vec) {
+ let pixels: Vec = (0..=255u8).cycle().take(3 * 16 * 16).collect();
+ let mut im = Dict::new();
+ im.set(b"Type".to_vec(), Object::name("XObject"));
+ im.set(b"Subtype".to_vec(), Object::name("Image"));
+ im.set(b"Width".to_vec(), Object::Int(16));
+ im.set(b"Height".to_vec(), Object::Int(16));
+ im.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
+ im.set(b"BitsPerComponent".to_vec(), Object::Int(8));
+ let im = doc.add(Object::Stream(Stream { dict: im, raw: pixels.clone().into() }));
+ let content = b"q 200 0 0 100 0 0 cm /Im0 Do Q".to_vec();
+ let mut xo = Dict::new();
+ xo.set(b"Im0".to_vec(), Object::Ref(im));
+ let mut res = Dict::new();
+ res.set(b"XObject".to_vec(), Object::Dict(xo));
+ 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(), nums(&[0.0, 0.0, 200.0, 100.0]));
+ form.set(b"Resources".to_vec(), Object::Dict(res));
+ form.set(b"Name".to_vec(), PdfString::text("logo"));
+ let n = doc.add(Object::Stream(Stream { dict: form, raw: content.clone().into() }));
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Ref(n));
+ let mut d = annot(sub, nm, [100.0, 500.0, 300.0, 600.0]);
+ d.set(b"Name".to_vec(), Object::name("Draft"));
+ d.set(b"AP".to_vec(), Object::Dict(ap));
+ put(doc, d);
+ (content, pixels)
+}
+
+/// The normal appearance of `d`, and its `/Im0` image's data.
+fn appearance_of(doc: &Document, d: &Dict) -> (Vec, Vec) {
+ let n = doc.resolve(sub_dict(doc, d, b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("no appearance stream") };
+ let im = sub_dict(doc, &sub_dict(doc, &n.dict, b"Resources"), b"XObject").get(b"Im0").map(|o| doc.resolve(o));
+ let pixels = match im.as_deref() {
+ Some(Object::Stream(s)) => s.decoded().unwrap(),
+ _ => Vec::new(),
+ };
+ (n.decoded().unwrap(), pixels)
+}
+
+#[test]
+fn an_image_stamp_keeps_its_appearance_through_xfdf() {
+ let mut src = pages(1);
+ let (content, pixels) = image_stamp(&mut src, "logo", "Stamp");
+ let (xml, dst) = round_trip_xfdf(&src);
+ assert!(xml.contains(""), "{xml}");
+ let d = named(&dst, "logo");
+ assert_eq!(appearance_of(&dst, &d), (content, pixels), "the stamp comes back with its own picture, not a drawn 'DRAFT'");
+ // And it survives a save.
+ let saved = Document::open(Arc::new(write_full(&dst, &SaveOptions::default()).unwrap())).unwrap();
+ let d = named(&saved, "logo");
+ assert_eq!(appearance_of(&saved, &d).1.len(), 3 * 16 * 16);
+}
+
+#[test]
+fn file_export_carries_only_stamp_appearances_and_copies_carry_every_one() {
+ let mut src = pages(1);
+ let (content, _) = image_stamp(&mut src, "sq", "Square");
+ let xml = export_xfdf(&src, true, false, "src.pdf");
+ assert!(!xml.contains(""), "XFDF 3.0 gives only stamps an appearance element");
+ let copy = export_xfdf_comments(&src, &[(0, 0)], "src.pdf");
+ assert!(copy.contains(""));
+ let mut dst = pages(1);
+ import_comments(&mut dst, ©, Some(0), Some("paste"), Some([10.0, -10.0])).unwrap();
+ let d = named(&dst, "paste-1");
+ assert_eq!(appearance_of(&dst, &d).0, content, "a pasted copy looks the same");
+ assert_eq!(f64s(&dst, d.get(b"Rect")), vec![110.0, 490.0, 310.0, 590.0]);
+}
+
+#[test]
+fn fdf_carries_only_stamp_appearances() {
+ let mut src = pages(1);
+ image_stamp(&mut src, "sq", "Square");
+ let mut ft = formatted_freetext();
+ ft.set(b"NM".to_vec(), PdfString::text("box"));
+ let ft = put(&mut src, ft);
+ let n = sub_dict(&src, &named(&src, "sq"), b"AP");
+ src.update_dict(ft, |d| d.set(b"AP".to_vec(), Object::Dict(n.clone()))).unwrap();
+ let fdf = export_fdf(&src, true, false, "src.pdf").unwrap();
+ assert!(!fdf.windows(3).any(|w| w == b"/AP"), "a text box and a square carry their formatting, not their appearance");
+ let mut dst = pages(1);
+ import(&mut dst, &fdf).unwrap();
+ let ft = named(&dst, "box");
+ assert_eq!((ft.int(b"Q"), ft.int(b"Rotate")), (Some(2), Some(90)));
+ assert_eq!(text(&dst, &ft, b"DS").as_deref(), text(&src, &formatted_freetext(), b"DS").as_deref());
+ let mut stamp = pages(1);
+ image_stamp(&mut stamp, "st", "Stamp");
+ assert!(export_fdf(&stamp, true, false, "src.pdf").unwrap().windows(3).any(|w| w == b"/AP"));
+}
+
+/// The intent (/IT) goes out under both spellings XFDF files use, and either comes back.
+#[test]
+fn the_intent_is_written_and_read_as_intent_or_it() {
+ let mut src = pages(1);
+ let mut d = annot("FreeText", "tw", [10.0, 10.0, 90.0, 30.0]);
+ d.set(b"IT".to_vec(), Object::name("FreeTextTypewriter"));
+ put(&mut src, d);
+ let xml = export_xfdf(&src, true, false, "src.pdf");
+ assert!(xml.contains(r#"intent="FreeTextTypewriter" IT="FreeTextTypewriter""#), "{xml}");
+ let only_it = r#"
+
+ /Helvetica 9 Tf font: Helvetica ,sans-serif 9.00pt;color:#FF0000
+
+ "#;
+ let mut dst = pages(1);
+ import(&mut dst, only_it.as_bytes()).unwrap();
+ let a = named(&dst, "a");
+ assert_eq!(a.name(b"IT"), Some(b"FreeTextTypewriter".as_slice()));
+ assert_eq!((a.int(b"Q"), a.int(b"Rotate"), text(&dst, &a, b"Subj").as_deref()), (Some(0), Some(0), Some("Typewriter")));
+ assert_eq!(f64s(&dst, a.get(b"Rect")), vec![71.0004, 737.255, 138.977, 747.299]);
+ assert_eq!(named(&dst, "b").name(b"IT"), Some(b"FreeTextCallout".as_slice()));
+}
+
+#[test]
+fn a_stamp_without_area_exports_no_appearance() {
+ let mut src = pages(1);
+ image_stamp(&mut src, "flat", "Stamp");
+ let r = xfdf::find_by_name(&src, "flat").unwrap();
+ src.update_dict(r, |d| d.set(b"Rect".to_vec(), nums(&[100.0, 500.0, 100.0, 600.0]))).unwrap();
+ assert!(!export_xfdf(&src, true, false, "src.pdf").contains(""));
+}
+
+/// The appearance encoding other tools write (the stamps attached to PDFBOX-4437 and
+/// PDFBOX-4478 hold this shape): data written `filtered`/`ascii` under a dictionary that still
+/// names its filter, and `raw`/`hex`.
+#[test]
+fn an_appearance_written_by_another_tool_is_imported() {
+ let ap = r#"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+3020302031302031302072650A66
+
+
+
+
+
+
+q
+/Fm0 Do
+0 0 1 rg 0 0 100 50 re f
+Q
+
+
+ "#;
+ let b64 = base64::engine::general_purpose::STANDARD.encode(ap);
+ let xml = format!(
+ r#"{b64} "#
+ );
+ let mut dst = pages(1);
+ let report = import(&mut dst, xml.as_bytes()).unwrap();
+ assert!(report.skipped.is_empty(), "{:?}", report.skipped);
+ let d = named(&dst, "s");
+ let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("no appearance stream") };
+ assert!(!n.dict.contains(b"Filter"), "filtered data is already decoded");
+ assert_eq!(n.decoded().unwrap(), b"q\n/Fm0 Do\n0 0 1 rg 0 0 100 50 re f\nQ\n");
+ let fm = sub_dict(&dst, &sub_dict(&dst, &n.dict, b"Resources"), b"XObject").get(b"Fm0").map(|o| dst.resolve(o)).unwrap();
+ let Object::Stream(fm) = fm.as_ref() else { panic!("no form") };
+ assert_eq!(fm.decoded().unwrap(), b"0 0 10 10 re\nf");
+ assert_eq!(fm.dict.get(b"Interpolate"), Some(&Object::Bool(true)));
+}
+
+/// XFDF with one comment of type `sub` whose `` text is `inner`.
+fn appearance_xfdf(inner: &str, sub: &str) -> String {
+ format!(
+ r##"<{sub} page="0" rect="10,10,110,60" name="x" color="#FF0000">{inner} {sub}> "##
+ )
+}
+
+fn b64(s: &str) -> String {
+ base64::engine::general_purpose::STANDARD.encode(s)
+}
+
+#[test]
+fn unreadable_appearances_are_skipped_recorded_and_drawn_again() {
+ let deep = format!(
+ r#"{} {}00 "#,
+ r#""#.repeat(70),
+ " ".repeat(70)
+ );
+ for (why, inner) in [
+ ("not base 64", "@@@not base64@@@".to_string()),
+ ("not XML", b64(" ")),
+ ("no normal appearance", b64(r#" "#)),
+ ("a stream without data", b64(r#" "#)),
+ ("odd hexadecimal", b64(r#"ABC "#)),
+ ("an unknown element", b64(r#"q Q "#)),
+ ("an unknown encoding", b64(r#"q Q "#)),
+ ("a bad number", b64(r#"q Q "#)),
+ ("a DTD", b64(r#"]>&a; "#)),
+ ("too deep", b64(&deep)),
+ (
+ "states with none selected",
+ b64(r#"q Q "#),
+ ),
+ ] {
+ let mut dst = pages(1);
+ let report = import(&mut dst, appearance_xfdf(&inner, "square").as_bytes()).unwrap_or_else(|e| panic!("{why}: {e}"));
+ assert_eq!(report.comments, 1, "{why}: the comment itself is imported");
+ assert_eq!(report.skipped.len(), 1, "{why}: {:?}", report.skipped);
+ assert!(report.skipped[0].starts_with("x: "), "{why}: {:?}", report.skipped);
+ // PdfCraft draws the square from its own entries instead.
+ let d = named(&dst, "x");
+ let n = sub_dict(&dst, &d, b"AP").get(b"N").map(|o| dst.resolve(o));
+ assert!(matches!(n.as_deref(), Some(Object::Stream(_))), "{why}: drawn again");
+ }
+}
+
+#[test]
+fn an_oversized_appearance_is_refused_before_it_is_decoded() {
+ let mut budget = 1000;
+ let inner = b64(&format!(r#"{} "#, "q ".repeat(2000)));
+ assert!(appearance::decode(&inner, &mut budget).is_err());
+ assert_eq!(budget, 1000, "nothing was charged for a refused appearance");
+ let mut budget = appearance::MAX_IMPORT_APPEARANCE_BYTES;
+ let big = "A".repeat(appearance::MAX_APPEARANCE_BYTES / 3 * 4 + 8);
+ let err = appearance::decode(&big, &mut budget).unwrap_err();
+ assert!(err.contains("larger than"), "{err}");
+}
+
+#[test]
+fn many_appearances_share_one_import_budget() {
+ let mut budget = 100;
+ let one = b64(r#"0 0 m 1 1 l S "#);
+ let mut read = 0;
+ while appearance::decode(&one, &mut budget).is_ok() {
+ read += 1;
+ assert!(read < 10);
+ }
+ assert!(read >= 1 && budget < 100);
+}
+
+#[test]
+fn an_imported_redaction_mark_is_always_drawn_by_pdfcraft() {
+ let inner = b64(
+ r#"%nothing "#,
+ );
+ let mut dst = pages(1);
+ import(&mut dst, appearance_xfdf(&inner, "redact").as_bytes()).unwrap();
+ let d = named(&dst, "x");
+ let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("no appearance") };
+ assert_ne!(n.decoded().unwrap(), b"%nothing", "the file's appearance could hide what the mark removes");
+}
+
+#[test]
+fn appearance_cycles_and_strings_survive_export() {
+ let mut src = pages(1);
+ image_stamp(&mut src, "c", "Stamp");
+ let d = named(&src, "c");
+ let n = sub_dict(&src, &d, b"AP").get(b"N").and_then(Object::as_ref).unwrap();
+ // The form's resources name the form itself, and hold a binary string.
+ let Object::Stream(mut form) = (*src.get(n)).clone() else { panic!("no form") };
+ let mut xo = Dict::new();
+ xo.set(b"Self".to_vec(), Object::Ref(n));
+ let mut res = sub_dict(&src, &form.dict, b"Resources");
+ res.set(b"Pattern".to_vec(), Object::Dict(xo));
+ res.set(b"Bin".to_vec(), Object::String(PdfString::literal(vec![0u8, 1, 0xFE, 0xFF])));
+ form.dict.set(b"Resources".to_vec(), Object::Dict(res));
+ src.set(n, Object::Stream(form));
+ let (xml, dst) = round_trip_xfdf(&src);
+ assert!(xml.contains(""));
+ let d = named(&dst, "c");
+ let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("no form") };
+ let res = sub_dict(&dst, &n.dict, b"Resources");
+ assert_eq!(res.get(b"Bin"), Some(&Object::String(PdfString::literal(vec![0u8, 1, 0xFE, 0xFF]))));
+ // The reference back to the form ends there (as null, so the entry is left out).
+ assert!(sub_dict(&dst, &res, b"Pattern").is_empty());
+}
+
+#[test]
+fn an_appearance_that_names_its_page_does_not_carry_the_page_along() {
+ let mut src = pages(1);
+ image_stamp(&mut src, "pg", "Stamp");
+ let d = named(&src, "pg");
+ let n = sub_dict(&src, &d, b"AP").get(b"N").and_then(Object::as_ref).unwrap();
+ let page = page_ref(&src, 0);
+ let Object::Stream(mut form) = (*src.get(n)).clone() else { panic!("no form") };
+ form.dict.set(b"PieceInfo".to_vec(), Object::Ref(page));
+ src.set(n, Object::Stream(form));
+ let xml = export_xfdf(&src, true, false, "src.pdf");
+ let b64: String = xml.split("").nth(1).unwrap().split(" ").next().unwrap().split_whitespace().collect();
+ let inner = String::from_utf8(base64::engine::general_purpose::STANDARD.decode(b64).unwrap()).unwrap();
+ assert!(inner.contains(r#" "#), "{inner}");
+ assert!(!inner.contains("Kids") && !inner.contains("MediaBox"));
+}
+
+#[test]
+fn fdf_carries_appearances_with_their_resources() {
+ let mut src = pages(1);
+ let (content, pixels) = image_stamp(&mut src, "logo", "Stamp");
+ put(&mut src, formatted_freetext());
+ let fdf = export_fdf(&src, true, false, "src.pdf").unwrap();
+ assert!(fdf.windows(4).any(|w| w == b"/AP "), "the FDF holds the appearance");
+ let mut dst = pages(1);
+ import(&mut dst, &fdf).unwrap();
+ let d = named(&dst, "logo");
+ assert_eq!(appearance_of(&dst, &d), (content, pixels));
+ // Formatting already travelled in FDF and still does.
+ let ft = named(&dst, "ft");
+ assert_eq!((ft.int(b"Q"), ft.int(b"Rotate")), (Some(2), Some(90)));
+ assert_eq!(text(&dst, &ft, b"RC").as_deref(), Some(RICH));
+ let saved = write_full(&dst, &SaveOptions::default()).unwrap();
+ assert!(Document::open(Arc::new(saved)).is_ok());
+}
+
+/// A shading's function sits a dozen levels below the comment (appearance, form, resources,
+/// pattern, shading, function, functions, …); an FDF import nulled everything past 16 levels, so
+/// a gradient stamp lost its colours.
+#[test]
+fn deeply_nested_appearance_resources_survive_fdf_and_xfdf() {
+ let mut src = pages(1);
+ image_stamp(&mut src, "deep", "Stamp");
+ let d = named(&src, "deep");
+ let n = sub_dict(&src, &d, b"AP").get(b"N").and_then(Object::as_ref).unwrap();
+ let Object::Stream(mut form) = (*src.get(n)).clone() else { panic!("no form") };
+ let mut inner = Object::Array(vec![Object::Real(0.25), Object::Real(0.5), Object::Real(0.75)]);
+ for _ in 0..20 {
+ let mut level = Dict::new();
+ level.set(b"Next".to_vec(), inner);
+ inner = Object::Dict(level);
+ }
+ let mut res = sub_dict(&src, &form.dict, b"Resources");
+ res.set(b"Pattern".to_vec(), inner);
+ form.dict.set(b"Resources".to_vec(), Object::Dict(res));
+ src.set(n, Object::Stream(form));
+ let innermost = |doc: &Document| {
+ let d = named(doc, "deep");
+ let n = doc.resolve(sub_dict(doc, &d, b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("no form") };
+ let mut at = sub_dict(doc, &n.dict, b"Resources").get(b"Pattern").cloned().unwrap();
+ for _ in 0..20 {
+ at = at.as_dict().and_then(|d| d.get(b"Next")).cloned().unwrap_or(Object::Null);
+ }
+ f64s(doc, Some(&at))
+ };
+ let mut dst = pages(1);
+ import(&mut dst, &export_fdf(&src, true, false, "src.pdf").unwrap()).unwrap();
+ assert_eq!(innermost(&dst), vec![0.25, 0.5, 0.75], "FDF");
+ let (_, dst) = round_trip_xfdf(&src);
+ assert_eq!(innermost(&dst), vec![0.25, 0.5, 0.75], "XFDF");
+}
+
+#[test]
+fn fdf_comments_off_the_document_copy_no_streams_in() {
+ let mut src = pages(3);
+ // A stamp on the third page, imported into a one-page document: nothing to place it on.
+ image_stamp(&mut src, "far", "Stamp");
+ let r = xfdf::find_by_name(&src, "far").unwrap();
+ let p2 = page_ref(&src, 2);
+ let p0 = page_ref(&src, 0);
+ src.update_dict(p0, |p| {
+ p.remove(b"Annots");
+ })
+ .unwrap();
+ src.update_dict(p2, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(r)]))).unwrap();
+ let fdf = export_fdf(&src, true, false, "src.pdf").unwrap();
+ let mut dst = pages(1);
+ let before = dst.add(Object::Null);
+ assert!(import(&mut dst, &fdf).is_err(), "nothing in the file is for this document");
+ let after = dst.add(Object::Null);
+ assert_eq!(after.num, before.num + 1, "the stamp's appearance and image were not copied in");
+}
+
+proptest! {
+ #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
+
+ /// Whatever formatting a free text and a line comment carry comes back the same.
+ #[test]
+ fn formatting_round_trips(
+ q in 0i64..3,
+ rotate in prop::sample::select(vec![0i64, 90, 180, 270]),
+ width in 0.0f64..20.0,
+ dashes in prop::collection::vec(0.5f64..30.0, 1..6),
+ fringe in prop::collection::vec(0.0f64..50.0, 4),
+ ll in -100.0f64..100.0,
+ lle in 0.0f64..50.0,
+ llo in 0.0f64..50.0,
+ ds in "[a-zA-Z0-9:;.# ]{0,40}",
+ ) {
+ // XFDF writes numbers to four decimals.
+ let r = |v: f64| (v * 10_000.0).round() / 10_000.0;
+ let mut src = pages(1);
+ let mut ft = formatted_freetext();
+ ft.set(b"Q".to_vec(), Object::Int(q));
+ ft.set(b"Rotate".to_vec(), Object::Int(rotate));
+ ft.set(b"RD".to_vec(), nums(&fringe));
+ ft.set(b"DS".to_vec(), PdfString::text(&ds));
+ let mut bs = Dict::new();
+ bs.set(b"W".to_vec(), Object::Real(width));
+ bs.set(b"S".to_vec(), Object::name("D"));
+ bs.set(b"D".to_vec(), nums(&dashes));
+ ft.set(b"BS".to_vec(), Object::Dict(bs));
+ put(&mut src, ft);
+ let mut ln = formatted_line();
+ ln.set(b"LL".to_vec(), Object::Real(ll));
+ ln.set(b"LLE".to_vec(), Object::Real(lle));
+ ln.set(b"LLO".to_vec(), Object::Real(llo));
+ put(&mut src, ln);
+ let (_, dst) = round_trip_xfdf(&src);
+ let ft = named(&dst, "ft");
+ prop_assert_eq!(ft.int(b"Q"), Some(q));
+ prop_assert_eq!(ft.int(b"Rotate"), Some(rotate));
+ prop_assert_eq!(f64s(&dst, ft.get(b"RD")), fringe.iter().map(|v| r(*v)).collect::>());
+ prop_assert_eq!(text(&dst, &ft, b"DS"), Some(ds.clone()));
+ let bs = sub_dict(&dst, &ft, b"BS");
+ prop_assert_eq!(bs.get(b"W").and_then(Object::as_f64), Some(r(width)));
+ prop_assert_eq!(f64s(&dst, bs.get(b"D")), dashes.iter().map(|v| r(*v)).collect::>());
+ let ln = named(&dst, "ln");
+ prop_assert_eq!(ln.get(b"LL").and_then(Object::as_f64), Some(r(ll)));
+ prop_assert_eq!(ln.get(b"LLE").and_then(Object::as_f64), Some(r(lle)));
+ prop_assert_eq!(ln.get(b"LLO").and_then(Object::as_f64), Some(r(llo)));
+ }
+
+ /// Any appearance dictionary of numbers, names, strings and nested data comes back the same.
+ #[test]
+ fn appearance_values_round_trip(
+ ints in prop::collection::vec(any::(), 0..8),
+ reals in prop::collection::vec(-1.0e6f64..1.0e6, 0..8),
+ names in prop::collection::vec("[A-Za-z#/ ]{1,12}", 0..5),
+ strings in prop::collection::vec(prop::collection::vec(any::(), 0..20), 0..5),
+ data in prop::collection::vec(any::(), 0..300),
+ ) {
+ let mut src = pages(1);
+ let mut extra = Dict::new();
+ extra.set(b"Ints".to_vec(), Object::Array(ints.iter().map(|i| Object::Int(*i)).collect()));
+ extra.set(b"Reals".to_vec(), Object::Array(reals.iter().map(|v| Object::Real(*v)).collect()));
+ extra.set(b"Names".to_vec(), Object::Array(names.iter().map(|n| Object::Name(n.as_bytes().to_vec())).collect()));
+ extra.set(b"Strings".to_vec(), Object::Array(strings.iter().map(|s| Object::String(PdfString::literal(s.clone()))).collect()));
+ let mut form = Dict::new();
+ form.set(b"Subtype".to_vec(), Object::name("Form"));
+ form.set(b"BBox".to_vec(), nums(&[0.0, 0.0, 10.0, 10.0]));
+ form.set(b"Extra".to_vec(), Object::Dict(extra.clone()));
+ let n = src.add(Object::Stream(Stream { dict: form, raw: data.clone().into() }));
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Ref(n));
+ let mut d = annot("Stamp", "p", [0.0, 0.0, 10.0, 10.0]);
+ d.set(b"AP".to_vec(), Object::Dict(ap));
+ put(&mut src, d);
+ let (_, dst) = round_trip_xfdf(&src);
+ let d = named(&dst, "p");
+ let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("no appearance") };
+ prop_assert_eq!(n.raw.as_ref(), data.as_slice());
+ prop_assert_eq!(sub_dict(&dst, &n.dict, b"Extra"), extra);
+ }
+}
+
+/// A stamp on page 0 whose normal appearance is one form stream holding `content`.
+fn content_stamp(doc: &mut Document, nm: &str, content: Vec) -> ObjRef {
+ 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(), nums(&[0.0, 0.0, 100.0, 50.0]));
+ let n = doc.add(Object::Stream(Stream { dict: form, raw: content.into() }));
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Ref(n));
+ let mut d = annot("Stamp", nm, [100.0, 500.0, 200.0, 550.0]);
+ d.set(b"AP".to_vec(), Object::Dict(ap));
+ put(doc, d);
+ n
+}
+
+/// The `` XML export wrote for the first comment of `xml`.
+fn exported_appearance(xml: &str) -> String {
+ let b64: String = xml.split("").nth(1).unwrap().split(" ").next().unwrap().split_whitespace().collect();
+ String::from_utf8(base64::engine::general_purpose::STANDARD.decode(b64).unwrap()).unwrap()
+}
+
+/// The write-time checks leave out the closing tags and the hexadecimal line breaks: an appearance
+/// just under the limit there came out over it, and import then refused what export wrote.
+#[test]
+fn an_exported_appearance_always_fits_what_import_reads() {
+ let mut src = pages(1);
+ content_stamp(&mut src, "big", vec![0u8; (appearance::MAX_APPEARANCE_BYTES / 2) - 4096]);
+ let d = named(&src, "big");
+ if let Some(text) = appearance::encode(&src, &d, &mut appearance::MAX_IMPORT_APPEARANCE_BYTES.clone()) {
+ let mut budget = appearance::MAX_IMPORT_APPEARANCE_BYTES;
+ appearance::decode(&text, &mut budget).expect("what export writes, import reads");
+ }
+ // Just under the limit once the line breaks are counted (one per 80 hexadecimal digits): it
+ // is written, and read back.
+ let mut src = pages(1);
+ content_stamp(&mut src, "near", vec![0u8; appearance::MAX_APPEARANCE_BYTES / 2 * 80 / 81 - 4096]);
+ let d = named(&src, "near");
+ let text = appearance::encode(&src, &d, &mut appearance::MAX_IMPORT_APPEARANCE_BYTES.clone()).expect("under the limit");
+ let mut budget = appearance::MAX_IMPORT_APPEARANCE_BYTES;
+ appearance::decode(&text, &mut budget).expect("what export writes, import reads");
+}
+
+/// A page whose `/Type` is a reference is still the source page: written in place it would carry
+/// the page's contents into a file meant to hold one comment.
+#[test]
+fn a_page_with_an_indirect_type_stays_out_of_an_exported_appearance() {
+ let mut src = pages(1);
+ let n = content_stamp(&mut src, "pg", b"q Q".to_vec());
+ let page = page_ref(&src, 0);
+ let page_type = src.add(Object::name("Page"));
+ src.update_dict(page, |p| p.set(b"Type".to_vec(), Object::Ref(page_type))).unwrap();
+ let Object::Stream(mut form) = (*src.get(n)).clone() else { panic!("no form") };
+ form.dict.set(b"PieceInfo".to_vec(), Object::Ref(page));
+ src.set(n, Object::Stream(form));
+ let inner = exported_appearance(&export_xfdf(&src, true, false, "src.pdf"));
+ assert!(inner.contains(r#" "#), "{inner}");
+ assert!(!inner.contains("MediaBox"), "{inner}");
+}
+
+/// XML reads a carriage return as a line feed, so data holding one goes out as hexadecimal.
+#[test]
+fn stream_data_with_carriage_returns_comes_back_byte_for_byte() {
+ let mut src = pages(1);
+ let data = b"q\r/Im0\rDo\r\nQ\r".to_vec();
+ content_stamp(&mut src, "cr", data.clone());
+ let (_, dst) = round_trip_xfdf(&src);
+ let d = named(&dst, "cr");
+ let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("no appearance") };
+ assert_eq!(n.raw.as_ref(), data.as_slice());
+}
+
+/// XFDF has no appearance state: the stream `/AS` selects is what goes out.
+#[test]
+fn the_selected_appearance_state_is_what_xfdf_carries() {
+ let mut src = pages(1);
+ let on = content_stamp(&mut src, "st", b"0 0 1 rg 0 0 100 50 re f".to_vec());
+ let off = src.add(Object::Stream(Stream { dict: Dict::new(), raw: b"%off".to_vec().into() }));
+ let mut states = Dict::new();
+ states.set(b"On".to_vec(), Object::Ref(on));
+ states.set(b"Off".to_vec(), Object::Ref(off));
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Dict(states.clone()));
+ ap.set(b"D".to_vec(), Object::Dict(states));
+ let r = xfdf::find_by_name(&src, "st").unwrap();
+ src.update_dict(r, |d| {
+ d.set(b"AP".to_vec(), Object::Dict(ap.clone()));
+ d.set(b"AS".to_vec(), Object::name("On"));
+ })
+ .unwrap();
+ let (_, dst) = round_trip_xfdf(&src);
+ let d = named(&dst, "st");
+ let ap = sub_dict(&dst, &d, b"AP");
+ for key in [b"N".as_slice(), b"D"] {
+ let s = dst.resolve(ap.get(key).unwrap());
+ let Object::Stream(s) = s.as_ref() else { panic!("{key:?} is not the selected stream: {s:?}") };
+ assert_eq!(s.raw.as_ref(), b"0 0 1 rg 0 0 100 50 re f");
+ }
+ // A state no stream answers to has nothing to draw: no appearance goes out.
+ src.update_dict(r, |d| d.set(b"AS".to_vec(), Object::name("Gone"))).unwrap();
+ assert!(!export_xfdf(&src, true, false, "src.pdf").contains(""));
+}
+
+/// Forms that draw forms: FDF export writes the chain however long, and import copied only the
+/// first dozen and nulled the rest without a word. A chain sixty forms deep comes back whole; one
+/// deeper than import follows is refused rather than imported in part.
+#[test]
+fn a_chain_of_forms_comes_back_whole_from_fdf_or_is_refused() {
+ let chain = |forms: usize| {
+ let mut src = pages(1);
+ put(&mut src, annot("Text", "first", [10.0, 10.0, 30.0, 30.0]));
+ let mut inner = src.add(Object::Stream(Stream { dict: Dict::new(), raw: b"%innermost".to_vec().into() }));
+ for _ in 0..forms {
+ let mut xo = Dict::new();
+ xo.set(b"Fm".to_vec(), Object::Ref(inner));
+ let mut res = Dict::new();
+ res.set(b"XObject".to_vec(), Object::Dict(xo));
+ let mut form = Dict::new();
+ form.set(b"Subtype".to_vec(), Object::name("Form"));
+ form.set(b"Resources".to_vec(), Object::Dict(res));
+ inner = src.add(Object::Stream(Stream { dict: form, raw: b"/Fm Do".to_vec().into() }));
+ }
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Ref(inner));
+ let mut d = annot("Stamp", "chain", [100.0, 500.0, 200.0, 550.0]);
+ d.set(b"AP".to_vec(), Object::Dict(ap));
+ put(&mut src, d);
+ export_fdf(&src, true, false, "src.pdf").unwrap()
+ };
+ let mut dst = pages(1);
+ import(&mut dst, &chain(60)).unwrap();
+ let d = named(&dst, "chain");
+ let mut at = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap());
+ for level in 0..60 {
+ let Object::Stream(s) = at.as_ref() else { panic!("level {level}: {at:?}") };
+ let fm = sub_dict(&dst, &sub_dict(&dst, &s.dict, b"Resources"), b"XObject").get(b"Fm").cloned().unwrap_or(Object::Null);
+ at = dst.resolve(&fm);
+ }
+ let Object::Stream(innermost) = at.as_ref() else { panic!("the innermost form was lost: {at:?}") };
+ assert_eq!(innermost.raw.as_ref(), b"%innermost");
+ let mut dst = pages(1);
+ let before = dst.add(Object::Null);
+ let err = import(&mut dst, &chain(80)).unwrap_err();
+ assert!(err.to_string().contains("nest more than"), "{err}");
+ // All or nothing: the note before the stamp, and the forms copied so far, are not left behind.
+ assert_eq!(dst.add(Object::Null).num, before.num + 1);
+ assert!(xfdf::find_by_name(&dst, "first").is_none());
+}
+
+/// A `/P` an FDF comment holds points into the FDF; it is replaced, and nothing it reaches is
+/// copied into the document.
+#[test]
+fn an_fdf_comments_page_link_copies_nothing_in() {
+ let fdf = |p: &str| {
+ format!(
+ "%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [2 0 R] >> >>\nendobj\n2 0 obj\n<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (t) /Page 0{p} >>\nendobj\n3 0 obj\n<< /Type /Page /Junk 4 0 R >>\nendobj\n4 0 obj\n<< /Length 5 >>\nstream\nhello\nendstream\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"
+ )
+ .into_bytes()
+ };
+ let added = |bytes: &[u8]| {
+ let mut dst = pages(1);
+ let before = dst.add(Object::Null);
+ import(&mut dst, bytes).unwrap();
+ let p = named(&dst, "t").reference(b"P");
+ assert_eq!(p, Some(page_ref(&dst, 0)));
+ dst.add(Object::Null).num - before.num
+ };
+ assert_eq!(added(&fdf(" /P 3 0 R")), added(&fdf("")), "the FDF's page and its stream were not copied in");
+}
+
+/// Two line breaks in a row leave a blank line in the comment's plain text.
+#[test]
+fn consecutive_rich_text_line_breaks_are_kept() {
+ let xml = r#"
+one two
three
four
+ "#;
+ let mut dst = pages(1);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ assert_eq!(text(&dst, &named(&dst, "b"), b"Contents").as_deref(), Some("one\r\rtwo\r\rthree\rfour"));
+}
+
+/// Rich text that is not XML, on a comment with no plain text, comes back as rich text, not as
+/// the comment's text (a line would otherwise show the markup as its caption).
+#[test]
+fn rich_text_alone_that_is_not_xml_comes_back_as_rich_text() {
+ let mut src = pages(1);
+ let mut d = annot("Line", "ln", [10.0, 10.0, 90.0, 30.0]);
+ d.set(b"L".to_vec(), nums(&[10.0, 20.0, 90.0, 20.0]));
+ d.set(b"RC".to_vec(), PdfString::text("unclosed"));
+ put(&mut src, d);
+ let (_, dst) = round_trip_xfdf(&src);
+ let d = named(&dst, "ln");
+ assert_eq!(text(&dst, &d, b"RC").as_deref(), Some("
unclosed"));
+ assert_ne!(text(&dst, &d, b"Contents").as_deref(), Some("
unclosed"));
+}
+
+/// A prefix the rich text inherits from the XFDF around it is declared on it, so the rich text
+/// is XML of its own and goes out again as markup.
+#[test]
+fn rich_text_keeps_the_namespaces_it_inherits() {
+ let xml = r#"
+hi hi
+ "#;
+ let mut dst = pages(1);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ let rc = text(&dst, &named(&dst, "ns"), b"RC").unwrap();
+ assert!(common::parse_form_xml(&rc).is_ok(), "{rc}");
+ let out = export_xfdf(&dst, true, false, "dst.pdf");
+ assert!(
+ out.contains(r#"%nothing
"#,
+ );
+ for extra in [r#" style="cloudy""#, r#" overlay-text="日本語""#] {
+ let xml = appearance_xfdf(&inner, "redact").replace(r#"name="x""#, &format!(r#"name="x"{extra}"#));
+ let mut dst = pages(1);
+ let before = dst.add(Object::Null);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ let d = named(&dst, "x");
+ let n = dst.resolve(sub_dict(&dst, &d, b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("{extra}: no appearance") };
+ let drawn = n.decoded().unwrap();
+ assert_ne!(drawn, b"%nothing", "{extra}");
+ assert!(String::from_utf8_lossy(&drawn).contains("h S"), "{extra}: the outline is stroked: {}", String::from_utf8_lossy(&drawn));
+ assert!(!d.contains(b"BE"), "{extra}");
+ // The file's appearance was not read in at all.
+ for num in before.num..dst.add(Object::Null).num {
+ if let Object::Stream(s) = dst.get(ObjRef::new(num, 0)).as_ref() {
+ assert_ne!(s.raw.as_ref(), b"%nothing", "{extra}");
+ }
+ }
+ }
+}
+
+/// Appearances share one budget over the whole import: past it, the rest are skipped.
+#[test]
+fn the_import_budget_is_shared_by_every_comment() {
+ let one = b64(r#"0 0 m 1 1 l S "#);
+ let size = base64::engine::general_purpose::STANDARD.decode(&one).unwrap().len();
+ let stamps: String = ["a", "b", "c"]
+ .iter()
+ .map(|nm| format!(r#"{one} "#))
+ .collect();
+ let xml = format!(r#"{stamps} "#);
+ let mut dst = pages(1);
+ let report = xfdf::import_xfdf_with(&mut dst, &xml, None, None, None, size * 5 / 2, false).unwrap();
+ assert_eq!(report.comments, 3);
+ assert_eq!(report.skipped.len(), 1, "{:?}", report.skipped);
+ assert!(report.skipped[0].starts_with("c: "), "{:?}", report.skipped);
+}
+
+/// A dash array of any length export writes comes back (65 numbers were written and dropped).
+#[test]
+fn a_long_dash_array_comes_back() {
+ let mut src = pages(1);
+ let dashes: Vec = (1..=65).map(f64::from).collect();
+ let mut bs = Dict::new();
+ bs.set(b"W".to_vec(), Object::Real(1.0));
+ bs.set(b"S".to_vec(), Object::name("D"));
+ bs.set(b"D".to_vec(), nums(&dashes));
+ let mut d = annot("Square", "sq", [10.0, 10.0, 90.0, 60.0]);
+ d.set(b"BS".to_vec(), Object::Dict(bs));
+ put(&mut src, d);
+ let (_, dst) = round_trip_xfdf(&src);
+ assert_eq!(f64s(&dst, sub_dict(&dst, &named(&dst, "sq"), b"BS").get(b"D")), dashes);
+}
+
+/// Export stops writing appearances where import would stop reading them, so every appearance
+/// written comes back.
+#[test]
+fn export_writes_no_more_appearance_than_import_reads() {
+ let mut src = pages(1);
+ content_stamp(&mut src, "a", b"0 0 m 1 1 l S".to_vec());
+ let d = named(&src, "a");
+ let mut probe = usize::MAX;
+ let one = appearance::encode(&src, &d, &mut probe).unwrap();
+ let size = usize::MAX - probe;
+ let mut budget = size * 3 / 2;
+ assert_eq!(appearance::encode(&src, &d, &mut budget), Some(one));
+ assert_eq!(appearance::encode(&src, &d, &mut budget), None, "past what import reads");
+}
+
+/// The export loop shares one budget over all of its comments: of five stamps whose appearances
+/// together are past what import reads, the fifth goes out without one, and import reads every
+/// appearance that was written.
+#[test]
+fn the_export_loop_writes_no_more_appearance_than_import_reads() {
+ let mut src = pages(1);
+ // About 14 MiB of XML each (hexadecimal): four fit in 64 MiB, five do not.
+ let form = content_stamp(&mut src, "s1", vec![0u8; 7 << 20]);
+ for nm in ["s2", "s3", "s4", "s5"] {
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Ref(form));
+ let mut d = annot("Stamp", nm, [100.0, 500.0, 200.0, 550.0]);
+ d.set(b"AP".to_vec(), Object::Dict(ap));
+ put(&mut src, d);
+ }
+ let xml = export_xfdf(&src, true, false, "src.pdf");
+ assert_eq!(xml.matches("").count(), 4);
+ let mut dst = pages(1);
+ let report = xfdf::import_xfdf(&mut dst, &xml).unwrap();
+ assert_eq!(report.comments, 5);
+ assert!(report.skipped.is_empty(), "{:?}", report.skipped);
+}
+
+/// Printable data is written as it is, so a budget it fits as text takes it (the size check
+/// counted it as hexadecimal, twice as long, and left the appearance out).
+#[test]
+fn printable_appearance_data_that_fits_the_budget_as_text_is_written() {
+ let mut src = pages(1);
+ // Printable as XFDF 3.0 counts it: no spaces.
+ content_stamp(&mut src, "a", "q\nQ\n".repeat(500).into_bytes());
+ let d = named(&src, "a");
+ let mut probe = usize::MAX;
+ let one = appearance::encode(&src, &d, &mut probe).unwrap();
+ let mut budget = usize::MAX - probe;
+ assert!(budget < 3000, "written as text, not hexadecimal: {budget}");
+ assert_eq!(appearance::encode(&src, &d, &mut budget), Some(one), "fits exactly");
+ assert_eq!(budget, 0);
+}
+
+/// A copied redaction mark goes without its appearance (import draws the mark itself and never
+/// reads one), so it leaves the shared budget to the comments whose appearances are read.
+#[test]
+fn a_copied_redaction_mark_spends_no_appearance_budget() {
+ let mut src = pages(1);
+ let form = content_stamp(&mut src, "st", b"0 0 m 1 1 l S".to_vec());
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Ref(form));
+ let mut rd = annot("Redact", "rd", [300.0, 10.0, 400.0, 30.0]);
+ rd.set(b"AP".to_vec(), Object::Dict(ap));
+ put(&mut src, rd);
+ let copy = export_xfdf_comments(&src, &[(0, 0), (0, 1)], "src.pdf");
+ assert_eq!(copy.matches("").count(), 1, "{copy}");
+ let redact = copy.split("").next().unwrap();
+ assert!(!redact.contains(""), "{copy}");
+}
+
+/// A page an FDF stamp's appearance reaches (its /Type a reference here) is not copied in with
+/// its contents.
+#[test]
+fn an_fdf_appearance_that_reaches_a_page_copies_no_page_in() {
+ let fdf = b"%FDF-1.2\n1 0 obj\n<< /FDF << /Annots [2 0 R] >> >>\nendobj\n\
+2 0 obj\n<< /Type /Annot /Subtype /Stamp /Rect [10 10 60 60] /NM (s) /Page 0 /AP << /N 3 0 R >> >>\nendobj\n\
+3 0 obj\n<< /Subtype /Form /BBox [0 0 1 1] /PieceInfo 4 0 R /Length 3 >>\nstream\nq Q\nendstream\nendobj\n\
+4 0 obj\n<< /Type 6 0 R /Contents 5 0 R >>\nendobj\n\
+5 0 obj\n<< /Length 11 >>\nstream\npagecontent\nendstream\nendobj\n\
+6 0 obj\n/Page\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
+ let mut dst = pages(1);
+ let before = dst.add(Object::Null);
+ import(&mut dst, fdf).unwrap();
+ for num in before.num..dst.add(Object::Null).num {
+ if let Object::Stream(s) = dst.get(ObjRef::new(num, 0)).as_ref() {
+ assert_ne!(s.raw.as_ref(), b"pagecontent", "the page's contents were copied in");
+ }
+ }
+ let n = dst.resolve(sub_dict(&dst, &named(&dst, "s"), b"AP").get(b"N").unwrap());
+ let Object::Stream(n) = n.as_ref() else { panic!("no appearance") };
+ assert!(matches!(n.dict.get(b"PieceInfo"), None | Some(Object::Null)), "{:?}", n.dict.get(b"PieceInfo"));
+}
+
+/// Rich text keeps the default (XHTML) namespace it inherits from ``, and a
+/// prefix it redeclares with spaces around `=` is not declared twice.
+#[test]
+fn rich_text_keeps_an_inherited_default_namespace() {
+ let xml = r#"
+hi hi
+hi hi
+ "#;
+ let mut dst = pages(1);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ let rc = text(&dst, &named(&dst, "d"), b"RC").unwrap();
+ let parsed = common::parse_form_xml(&rc).unwrap();
+ assert_eq!(parsed.root_element().tag_name().namespace(), Some("http://www.w3.org/1999/xhtml"), "{rc}");
+ let rc = text(&dst, &named(&dst, "w"), b"RC").unwrap();
+ assert!(common::parse_form_xml(&rc).is_ok(), "{rc}");
+ assert!(!rc.contains(r#"xmlns:a="urn:a""#), "{rc}");
+}
+
+/// A declaration written inside another attribute's value declares nothing, so the prefix the
+/// rich text inherits is still declared on it; and a prefixed root keeps the default (XHTML)
+/// namespace its unprefixed descendants inherit. A `>` in an attribute value is not the end of
+/// the start tag.
+#[test]
+fn rich_text_namespaces_come_from_attributes_and_the_scope() {
+ let xml = r#"
+hi hi
+hi hi
+hi hi
+ "#;
+ let mut dst = pages(1);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ let rc = text(&dst, &named(&dst, "v"), b"RC").unwrap();
+ let parsed = common::parse_form_xml(&rc).unwrap_or_else(|e| panic!("{e}: {rc}"));
+ assert!(parsed.root_element().attributes().any(|a| a.namespace() == Some("urn:b")), "{rc}");
+ let rc = text(&dst, &named(&dst, "g"), b"RC").unwrap();
+ assert!(common::parse_form_xml(&rc).is_ok(), "a `>` in a value does not end the start tag: {rc}");
+ let rc = text(&dst, &named(&dst, "p"), b"RC").unwrap();
+ let parsed = common::parse_form_xml(&rc).unwrap();
+ let p = parsed.descendants().find(|n| n.has_tag_name("p")).unwrap();
+ assert_eq!(p.tag_name().namespace(), Some("http://www.w3.org/1999/xhtml"), "{rc}");
+}
+
+/// A redaction mark whose /Rect has no area shows its quads all the same: the /Rect grows to hold
+/// them.
+#[test]
+fn a_redaction_marks_rect_holds_its_quads() {
+ let xml = r#" "#;
+ let mut dst = pages(1);
+ import(&mut dst, xml.as_bytes()).unwrap();
+ assert_eq!(f64s(&dst, named(&dst, "r").get(b"Rect")), vec![10.0, 20.0, 90.0, 50.0]);
+}
diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs
index e2137a98f0..efbf5033f0 100644
--- a/crates/xfdf/src/xfdf.rs
+++ b/crates/xfdf/src/xfdf.rs
@@ -1,6 +1,6 @@
use super::common::{
FLAGS, MAX_DEPTH, SUBTYPES, arr, check_form_xml_depth, csv, esc, hex, n, nums_of, parse_form_xml, parse_hex, quad_has_area, rect_strict, text_of,
- walk_xfdf,
+ walk_xfdf, xml_nesting_depth,
};
use super::*;
@@ -99,31 +99,259 @@ fn read_own_keys(node: roxmltree::Node, d: &mut Dict) {
}
}
-/// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one
-/// (other tools leave /NM out), a name new to the document for this export, so a reply copied
-/// with it still finds it.
+/// Rich text deeper than this is written as text rather than as markup, so the XFDF it goes into
+/// stays well inside the nesting the reader accepts (`MAX_XML_DEPTH`).
+const MAX_RICH_DEPTH: usize = 512;
+
+/// Rich text (`/RC`) as `` holds it: its root element alone (no XML
+/// declaration), when the text is well-formed XML; `None` otherwise.
+fn rich_text_element(rc: &str) -> Option<&str> {
+ let t = rc.trim_start_matches('\u{feff}');
+ if xml_nesting_depth(t) > MAX_RICH_DEPTH {
+ return None;
+ }
+ let parsed = parse_form_xml(t).ok()?;
+ t.get(parsed.root_element().range())
+}
+
+/// The formatting attributes of a comment: XFDF 3.0 "Border style attributes" (`width`, `style`,
+/// `dashes`), "Border effect attributes" (`style="cloudy"`, `intensity`), and the free text
+/// (`rotation`, `justification`), stamp (`rotation`), circle/square/caret (`fringe`), line
+/// (`leaderLength`, `leaderExtend`, `leader-offset`, `caption`, `caption-style`,
+/// `caption-offset-h`/`-v`), caret (`symbol`) and redaction (`overlay-text`,
+/// `overlay-text-repeat`, `justification`) attributes.
+fn format_attrs(doc: &Document, d: &Dict, sub: &[u8], attrs: &mut String) {
+ let num = |k: &[u8]| d.get(k).and_then(|o| doc.resolve(o).as_f64()).filter(|v| v.is_finite());
+ let name = |k: &[u8]| d.get(k).and_then(|o| doc.resolve(o).as_name().map(<[u8]>::to_vec));
+ let list = |v: &[f64]| v.iter().map(|x| n(*x)).collect::>().join(",");
+ // The border: /BS, or (without one) the older /Border array [h v width dashes].
+ let (width, style, dashes) = match d.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()) {
+ Some(bs) => (
+ bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()),
+ bs.get(b"S").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)),
+ nums_of(doc, bs.get(b"D")),
+ ),
+ None => {
+ let border = d.get(b"Border").map(|b| doc.resolve(b)).and_then(|b| b.as_array().cloned()).unwrap_or_default();
+ let dash = border.get(3).map(|x| nums_of(doc, Some(x))).unwrap_or_default();
+ (border.get(2).and_then(|w| doc.resolve(w).as_f64()), (!dash.is_empty()).then(|| b"D".to_vec()), dash)
+ }
+ };
+ if let Some(w) = width.filter(|w| w.is_finite()) {
+ let _ = write!(attrs, " width=\"{}\"", n(w));
+ }
+ // One `style` attribute names both: a cloudy border effect (/BE /S /C) takes it.
+ let cloudy = d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice()));
+ if let Some(be) = cloudy {
+ attrs.push_str(" style=\"cloudy\"");
+ if let Some(i) = be.get(b"I").and_then(|i| doc.resolve(i).as_f64()).filter(|i| i.is_finite()) {
+ let _ = write!(attrs, " intensity=\"{}\"", n(i));
+ }
+ } else if let Some(s) = match style.as_deref() {
+ Some(b"D") => Some("dash"),
+ Some(b"B") => Some("bevelled"),
+ Some(b"I") => Some("inset"),
+ Some(b"U") => Some("underline"),
+ _ => None,
+ } {
+ let _ = write!(attrs, " style=\"{s}\"");
+ }
+ // The dash array (/D) whatever the style, as `dashes` maps it; only `style="dash"` draws it.
+ if !dashes.is_empty() && dashes.iter().all(|v| v.is_finite()) {
+ let _ = write!(attrs, " dashes=\"{}\"", list(&dashes));
+ }
+ if let Some(r) = d.get(b"Rotate").and_then(|o| doc.resolve(o).as_int()) {
+ let _ = write!(attrs, " rotation=\"{r}\"");
+ }
+ if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) {
+ // Free text names its justification; a redaction mark gives the number (XFDF 3.0).
+ match (sub, q) {
+ (b"FreeText", 0) => attrs.push_str(" justification=\"left\""),
+ (b"FreeText", 1) => attrs.push_str(" justification=\"centered\""),
+ (b"FreeText", 2) => attrs.push_str(" justification=\"right\""),
+ (b"FreeText", _) => {}
+ (_, q) => {
+ let _ = write!(attrs, " justification=\"{q}\"");
+ }
+ }
+ }
+ let rd = nums_of(doc, d.get(b"RD"));
+ if rd.len() == 4 && rd.iter().all(|v| v.is_finite()) {
+ let _ = write!(attrs, " fringe=\"{}\"", list(&rd));
+ }
+ if let Some(ll) = num(b"LL") {
+ let _ = write!(attrs, " leaderLength=\"{}\"", n(ll));
+ }
+ if let Some(lle) = num(b"LLE") {
+ let _ = write!(attrs, " leaderExtend=\"{}\"", n(lle));
+ }
+ if let Some(llo) = num(b"LLO") {
+ let _ = write!(attrs, " leader-offset=\"{}\"", n(llo));
+ }
+ if let Some(Object::Bool(cap)) = d.get(b"Cap").map(|o| doc.resolve(o)).as_deref() {
+ let _ = write!(attrs, " caption=\"{}\"", if *cap { "yes" } else { "no" });
+ }
+ if let Some(cp) = name(b"CP") {
+ let _ = write!(attrs, " caption-style=\"{}\"", esc(&String::from_utf8_lossy(&cp)));
+ }
+ if let [h, v] = nums_of(doc, d.get(b"CO")).as_slice()
+ && h.is_finite()
+ && v.is_finite()
+ {
+ let _ = write!(attrs, " caption-offset-h=\"{}\" caption-offset-v=\"{}\"", n(*h), n(*v));
+ }
+ match name(b"Sy").as_deref() {
+ Some(b"P") => attrs.push_str(" symbol=\"paragraph\""),
+ Some(b"None") => attrs.push_str(" symbol=\"none\""),
+ _ => {}
+ }
+ if let Some(t) = text_of(doc, d, b"OverlayText") {
+ let _ = write!(attrs, " overlay-text=\"{}\"", esc(&t));
+ }
+ if let Some(Object::Bool(repeat)) = d.get(b"Repeat").map(|o| doc.resolve(o)).as_deref() {
+ let _ = write!(attrs, " overlay-text-repeat=\"{repeat}\"");
+ }
+}
+
+/// The formatting attributes of an XFDF comment element back as dictionary entries (see
+/// [`format_attrs`]). Values that are not numbers, or out of range, are left out.
+fn read_format(node: roxmltree::Node, d: &mut Dict) {
+ let num = |a: &str| node.attribute(a).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite());
+ let style = node.attribute("style").map(str::trim);
+ let dashes = node.attribute("dashes").map(csv).filter(|v| !v.is_empty() && v.iter().all(|x| *x >= 0.0) && v.iter().any(|x| *x > 0.0));
+ let bs_style = match style {
+ Some("dash") => Some("D"),
+ Some("bevelled" | "beveled") => Some("B"),
+ Some("inset") => Some("I"),
+ Some("underline") => Some("U"),
+ Some("solid") => Some("S"),
+ _ => None,
+ };
+ let width = num("width");
+ if width.is_some() || bs_style.is_some() || dashes.is_some() {
+ let mut bs = Dict::new();
+ // XFDF 3.0: the width is 1 and the style solid unless given.
+ bs.set(b"W".to_vec(), Object::Real(width.unwrap_or(1.0).max(0.0)));
+ bs.set(b"S".to_vec(), Object::name(bs_style.unwrap_or("S")));
+ if let Some(dv) = &dashes {
+ bs.set(b"D".to_vec(), arr(dv));
+ }
+ d.set(b"BS".to_vec(), Object::Dict(bs));
+ }
+ if style == Some("cloudy") {
+ let mut be = Dict::new();
+ be.set(b"S".to_vec(), Object::name("C"));
+ let intensity = num("intensity").unwrap_or(1.0);
+ be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0)));
+ d.set(b"BE".to_vec(), Object::Dict(be));
+ }
+ if let Some(r) = node.attribute("rotation").and_then(|r| r.trim().parse::().ok()) {
+ d.set(b"Rotate".to_vec(), Object::Int(i64::from(r)));
+ }
+ let q = match node.attribute("justification").map(str::trim) {
+ Some("left" | "0") => Some(0),
+ Some("centered" | "center" | "1") => Some(1),
+ Some("right" | "2") => Some(2),
+ _ => None,
+ };
+ if let Some(q) = q {
+ d.set(b"Q".to_vec(), Object::Int(q));
+ }
+ if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) {
+ d.set(b"RD".to_vec(), arr(&rd));
+ }
+ if let Some(ll) = num("leaderLength") {
+ d.set(b"LL".to_vec(), Object::Real(ll));
+ }
+ if let Some(lle) = num("leaderExtend").filter(|v| *v >= 0.0) {
+ d.set(b"LLE".to_vec(), Object::Real(lle));
+ }
+ if let Some(llo) = num("leader-offset").filter(|v| *v >= 0.0) {
+ d.set(b"LLO".to_vec(), Object::Real(llo));
+ }
+ match node.attribute("caption").map(str::trim) {
+ Some("yes" | "true") => d.set(b"Cap".to_vec(), Object::Bool(true)),
+ Some("no" | "false") => d.set(b"Cap".to_vec(), Object::Bool(false)),
+ _ => {}
+ }
+ if let Some(cp) = node.attribute("caption-style").map(str::trim).filter(|c| matches!(*c, "Inline" | "Top")) {
+ d.set(b"CP".to_vec(), Object::name(cp));
+ }
+ let (h, v) = (num("caption-offset-h"), num("caption-offset-v"));
+ if h.is_some() || v.is_some() {
+ d.set(b"CO".to_vec(), arr(&[h.unwrap_or(0.0), v.unwrap_or(0.0)]));
+ }
+ match node.attribute("symbol").map(str::trim) {
+ Some("paragraph" | "P") => d.set(b"Sy".to_vec(), Object::name("P")),
+ Some("none" | "None") => d.set(b"Sy".to_vec(), Object::name("None")),
+ _ => {}
+ }
+ if let Some(t) = node.attribute("overlay-text") {
+ d.set(b"OverlayText".to_vec(), PdfString::text(t));
+ }
+ match node.attribute("overlay-text-repeat").map(str::trim) {
+ Some("true" | "yes") => d.set(b"Repeat".to_vec(), Object::Bool(true)),
+ Some("false" | "no") => d.set(b"Repeat".to_vec(), Object::Bool(false)),
+ _ => {}
+ }
+}
+
+/// A `` element's plain text, a line per paragraph: what `/Contents` gets
+/// when the element comes without ``.
+fn rich_plain(node: roxmltree::Node) -> String {
+ let mut out = String::new();
+ for n in node.descendants() {
+ // A line break is a line of its own, so two in a row leave a blank line; a paragraph
+ // starts on a new line.
+ if n.is_element() && n.tag_name().name() == "br" {
+ out.push('\r');
+ } else if n.is_element() && matches!(n.tag_name().name(), "p" | "div" | "li") {
+ if !out.is_empty() && !out.ends_with('\r') {
+ out.push('\r');
+ }
+ } else if let Some(t) = n.text().filter(|_| n.is_text()) {
+ // Indentation between elements is not text.
+ if !(t.trim().is_empty() && t.contains('\n')) {
+ out.push_str(&t.replace("\r\n", "\r").replace('\n', "\r"));
+ }
+ }
+ }
+ out.trim_end_matches('\r').to_string()
+}
+
+/// The name of every annotation, for `inreplyto`: its /NM, or, for one without one (other tools
+/// leave /NM out) that is in `only` or that another annotation replies to or is grouped under
+/// (`/IRT`, ISO 32000-2 §12.5.6.2; a Replace Text pair is such a group), a name new to the
+/// document for this export, so the link survives the round trip. Made-up names are never written
+/// into the PDF.
fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option<&HashSet<(usize, usize)>>) -> HashMap {
let mut names: HashMap = HashMap::new();
let mut unnamed: Vec<(usize, usize, ObjRef)> = Vec::new();
+ let mut targets: HashSet = HashSet::new();
// Every name in use, those of annotations written straight into the page array too, so a
// given name is new.
let mut taken: HashSet = HashSet::new();
for (pi, p) in pages.iter().enumerate() {
for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() {
- let nm = doc.resolve(&a).as_dict().and_then(|d| text_of(doc, d, b"NM"));
+ let d = doc.resolve(&a).as_dict().cloned();
+ if let Some(t) = d.as_ref().and_then(|d| d.get(b"IRT")).and_then(Object::as_ref) {
+ targets.insert(t);
+ }
+ let nm = d.as_ref().and_then(|d| text_of(doc, d, b"NM")).filter(|nm| !nm.is_empty());
if let Some(nm) = &nm {
taken.insert(nm.clone());
}
+ // Only an indirect annotation can be an `/IRT` target, so only it is keyed here.
let Some(r) = a.as_ref() else { continue };
match nm {
Some(nm) => {
names.insert(r, nm);
}
- None if only.is_some_and(|only| only.contains(&(pi, ai))) => unnamed.push((pi, ai, r)),
- None => {}
+ None => unnamed.push((pi, ai, r)),
}
}
}
+ unnamed.retain(|(pi, ai, r)| only.is_some_and(|only| only.contains(&(*pi, *ai))) || targets.contains(r));
for (pi, ai, r) in unnamed {
let mut name = format!("pdfcraft-{pi}-{ai}");
while taken.contains(&name) {
@@ -135,15 +363,42 @@ fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option
names
}
-/// The elements inside a comment's XFDF element: its text, default appearance, ink, vertices
-/// and pop-up (on page `pi`).
-fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) {
- if let Some(c) = text_of(doc, d, b"Contents") {
- let _ = write!(out, "{} ", esc(&c));
+/// The elements inside a comment's XFDF element: its text (plain and rich), default appearance
+/// and style, ink, vertices, appearance (with `ap`) and pop-up (on page `pi`).
+fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize, ap: Option<&mut usize>) {
+ let rc = text_of(doc, d, b"RC");
+ match text_of(doc, d, b"Contents") {
+ Some(c) => {
+ let _ = write!(out, "{} ", esc(&c));
+ }
+ // Rich text written as text (below) is read back as rich text only beside ``;
+ // alone it would become the comment's plain text.
+ None if rc.as_deref().is_some_and(|rc| rich_text_element(rc).is_none()) => out.push_str(" "),
+ None => {}
+ }
+ if let Some(rc) = rc {
+ match rich_text_element(&rc) {
+ // Rich text is an XML (XHTML) document (ISO 32000-2 Table 172, RC): its element goes in as markup.
+ Some(markup) => {
+ let _ = write!(out, "{markup} ");
+ }
+ // Text that is not well-formed XML goes in as text, so nothing is lost.
+ None => {
+ let _ = write!(out, "{} ", esc(&rc));
+ }
+ }
}
if let Some(da) = text_of(doc, d, b"DA") {
let _ = write!(out, "{} ", esc(&da));
}
+ if let Some(ds) = text_of(doc, d, b"DS") {
+ let _ = write!(out, "{} ", esc(&ds));
+ }
+ if let Some(budget) = ap
+ && let Some(b64) = super::appearance::encode(doc, d, budget)
+ {
+ let _ = write!(out, "{b64} ");
+ }
let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
if !ink.is_empty() {
out.push_str("");
@@ -180,9 +435,11 @@ fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) {
/// `own`: write each comment's PdfCraft keys too (see [`own_keys`]), for a copy and paste.
fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, usize)>>, own: bool) {
let pages = pdfcraft_model::pages(doc);
- // NM of every annotation, for inreplyto. A selected annotation without one (other tools
- // leave /NM out) gets a name for this export, so a reply copied with it still finds it.
+ // NM of every annotation, for inreplyto. A selected or replied-to annotation without one
+ // (other tools leave /NM out) gets a name for this export, so its replies still find it.
let names = annotation_names(doc, &pages, only);
+ // What the import reads of appearances, over all of the comments.
+ let mut appearance_budget = super::appearance::MAX_IMPORT_APPEARANCE_BYTES;
for (pi, p) in pages.iter().enumerate() {
for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() {
if only.is_some_and(|only| !only.contains(&(pi, ai))) {
@@ -203,8 +460,8 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u
n(rect[1].max(rect[3]))
);
}
- // The name: /NM, or the one given above for this export.
- if let Some(nm) = text_of(doc, &d, b"NM").or_else(|| a.as_ref().and_then(|r| names.get(&r).cloned())) {
+ // Its `/NM`, or the name given above for this export.
+ if let Some(nm) = a.as_ref().and_then(|r| names.get(&r)).cloned().or_else(|| text_of(doc, &d, b"NM")) {
let _ = write!(attrs, " name=\"{}\"", esc(&nm));
}
for (key, attr) in [
@@ -233,9 +490,7 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u
if let Some(o) = d.get(b"CA").and_then(|o| doc.resolve(o).as_f64()) {
let _ = write!(attrs, " opacity=\"{}\"", n(o));
}
- if let Some(w) = d.get(b"BS").and_then(|b| doc.resolve(b).as_dict().and_then(|b| b.get(b"W").and_then(Object::as_f64))) {
- let _ = write!(attrs, " width=\"{}\"", n(w));
- }
+ format_attrs(doc, &d, sub, &mut attrs);
let quads = nums_of(doc, d.get(b"QuadPoints"));
if !quads.is_empty() {
let _ = write!(attrs, " coords=\"{}\"", quads.iter().map(|v| n(*v)).collect::>().join(","));
@@ -254,26 +509,14 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u
let cl = nums_of(doc, d.get(b"CL"));
if cl.len() == 4 || cl.len() == 6 {
let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(","));
- let rd = nums_of(doc, d.get(b"RD"));
- if rd.len() == 4 {
- let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(","));
- }
if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) {
let _ = write!(attrs, " head=\"{}\"", esc(&le));
}
}
// What the comment is meant as (/IT): typewriter text, a callout, a cloud, an arrow.
+ // Written under both spellings XFDF files use for it, `intent` and `IT`.
if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) {
- let _ = write!(attrs, " intent=\"{}\"", esc(&it));
- }
- // A cloudy border (/BE /S /C) and how far its arcs bulge.
- if let Some(be) =
- d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice()))
- {
- attrs.push_str(" style=\"cloudy\"");
- if let Some(i) = be.get(b"I").and_then(Object::as_f64) {
- let _ = write!(attrs, " intensity=\"{}\"", n(i));
- }
+ let _ = write!(attrs, " intent=\"{0}\" IT=\"{0}\"", esc(&it));
}
if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) {
let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt));
@@ -282,7 +525,13 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u
let _ = write!(attrs, " pdfcraft:keys=\"{}\"", esc(&keys));
}
let _ = write!(out, "<{tag}{attrs}>");
- xfdf_annot_children(doc, out, &d, pi);
+ // XFDF 3.0 carries a stamp's appearance, and none for a rect with no area; a copy and
+ // paste (`own`) carries every comment's, so the copy looks the same, except a
+ // redaction mark's, which import never reads (PdfCraft draws the mark) and which would
+ // only use up the budget the others share.
+ let has_area = rect.len() == 4 && rect[0] != rect[2] && rect[1] != rect[3];
+ let carried = has_area && sub != b"Redact" && (own || sub == b"Stamp");
+ xfdf_annot_children(doc, out, &d, pi, carried.then_some(&mut appearance_budget));
let _ = writeln!(out, "{tag}>");
}
}
@@ -588,7 +837,7 @@ pub(crate) fn apply_values(doc: &mut Document, values: &[(String, Vec)],
let first = vals.first().cloned().unwrap_or_default();
let v = match f.kind {
FieldKind::Text => FieldValue::Text(first),
- FieldKind::CheckBox => FieldValue::Check(!first.is_empty() && first != "Off"),
+ FieldKind::CheckBox => f.check_box_value(Some(first.as_str())),
FieldKind::Radio => FieldValue::Radio((!first.is_empty() && first != "Off").then_some(first)),
// XML, CSV and text hold a multi-select list's values in one cell, joined by ", "
// (as export_data writes them); split it unless the whole cell is one option.
@@ -659,12 +908,7 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt
if let Some(o) = node.attribute("opacity").and_then(|o| o.parse::().ok()) {
d.set(b"CA".to_vec(), Object::Real(o.clamp(0.0, 1.0)));
}
- if let Some(w) = node.attribute("width").and_then(|w| w.parse::().ok()) {
- let mut bs = Dict::new();
- bs.set(b"W".to_vec(), Object::Real(w.max(0.0)));
- bs.set(b"S".to_vec(), Object::name("S"));
- d.set(b"BS".to_vec(), Object::Dict(bs));
- }
+ read_format(node, &mut d);
if let Some(q) = node.attribute("coords") {
d.set(b"QuadPoints".to_vec(), arr(&csv(q)));
}
@@ -680,22 +924,14 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt
Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]),
);
}
- if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) {
+ // The intent (/IT), spelt `intent` or `IT`.
+ let intent = node.attribute("intent").filter(|it| !it.is_empty()).or_else(|| node.attribute("IT").filter(|it| !it.is_empty()));
+ if let Some(it) = intent {
d.set(b"IT".to_vec(), Object::name(it));
}
- if node.attribute("style") == Some("cloudy") {
- let mut be = Dict::new();
- be.set(b"S".to_vec(), Object::name("C"));
- let intensity = node.attribute("intensity").and_then(|i| i.parse::().ok()).filter(|i| i.is_finite()).unwrap_or(1.0);
- be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0)));
- d.set(b"BE".to_vec(), Object::Dict(be));
- }
if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) {
d.set(b"CL".to_vec(), arr(&cl));
- if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) {
- d.set(b"RD".to_vec(), arr(&rd));
- }
- d.set(b"IT".to_vec(), Object::name(node.attribute("intent").unwrap_or("FreeTextCallout")));
+ d.set(b"IT".to_vec(), Object::name(intent.unwrap_or("FreeTextCallout")));
// A callout's /LE is one name, not the head/tail pair of a line.
match node.attribute("head") {
Some(h) => d.set(b"LE".to_vec(), Object::name(h)),
@@ -712,6 +948,9 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt
"defaultappearance" => {
d.set(b"DA".to_vec(), PdfString::literal(child.text().unwrap_or("").as_bytes().to_vec()));
}
+ "defaultstyle" => {
+ d.set(b"DS".to_vec(), PdfString::text(&child.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::()));
+ }
"inklist" => {
let list: Vec = child
.children()
@@ -731,10 +970,120 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt
_ => {}
}
}
+ if let Some(rich) = node.children().find(|c| c.has_tag_name("contents-richtext")) {
+ read_rich_text(rich, node.children().any(|c| c.has_tag_name("contents")), &mut d);
+ }
read_own_keys(node, &mut d);
Some(d)
}
+/// `` back as `/RC` (XFDF 3.0 "The contents and contents-richtext elements in
+/// annotations"): its markup, as written in the file, after an XML declaration. Plain text in it is the comment's text
+/// (`/Contents`) when the element comes alone; beside `` it is kept as `/RC` (rich text
+/// PdfCraft exported as text because it was not well-formed XML). Markup without ``
+/// gives `/Contents` its plain text too, so the comment's text shows where rich text is not read.
+fn read_rich_text(rich: roxmltree::Node, has_contents: bool, d: &mut Dict) {
+ let elements: Vec = rich.children().filter(|c| c.is_element()).collect();
+ let (Some(first), Some(last)) = (elements.first(), elements.last()) else {
+ let text: String = rich.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
+ if has_contents {
+ d.set(b"RC".to_vec(), PdfString::text(&text));
+ } else {
+ d.set(b"Contents".to_vec(), PdfString::text(&text.replace("\r\n", "\r").replace('\n', "\r")));
+ }
+ return;
+ };
+ let source = rich.document().input_text();
+ let Some(markup) = source.get(first.range().start..last.range().end) else { return };
+ let markup = match elements.as_slice() {
+ [_] => standalone(rich, markup),
+ _ => markup.to_string(),
+ };
+ // Rich text is an XML document of its own; export left its declaration out.
+ d.set(b"RC".to_vec(), PdfString::text(&format!("{markup}")));
+ if !has_contents {
+ d.set(b"Contents".to_vec(), PdfString::text(&rich_plain(rich)));
+ }
+}
+
+/// Whether start tag `tag` (from `<` up to its closing `>`) declares attribute `name` (white
+/// space may come before the `=`). Attribute values are skipped: `title=" xmlns:b='x'"` declares
+/// nothing.
+fn declares(tag: &str, name: &str) -> bool {
+ let mut quote = None;
+ let mut after_space = false;
+ for (i, c) in tag.char_indices() {
+ if let Some(q) = quote {
+ if c == q {
+ quote = None;
+ }
+ continue;
+ }
+ if c == '"' || c == '\'' {
+ quote = Some(c);
+ } else if after_space && tag.get(i..).and_then(|rest| rest.strip_prefix(name)).is_some_and(|a| a.trim_start().starts_with('=')) {
+ return true;
+ }
+ after_space = c.is_whitespace();
+ }
+ false
+}
+
+/// Where the start tag at the beginning of `markup` ends: its first `>` outside an attribute
+/// value (the end of `markup` when there is none).
+fn start_tag_end(markup: &str) -> usize {
+ let mut quote = None;
+ for (i, c) in markup.char_indices() {
+ match quote {
+ Some(q) if c == q => quote = None,
+ Some(_) => {}
+ None if c == '"' || c == '\'' => quote = Some(c),
+ None if c == '>' => return i,
+ None => {}
+ }
+ }
+ markup.len()
+}
+
+/// The one element `markup` (cut out of `rich`, a ``) as an XML
+/// document of its own: the namespaces it inherits from the XFDF around it are declared on it,
+/// the default one (XHTML declared on ``, say; not XFDF's own, which a
+/// standalone document has no use for) and the prefixes it uses. Unchanged when nothing is
+/// inherited, or when the declarations would not make it parse.
+fn standalone(rich: roxmltree::Node, markup: &str) -> String {
+ // The start tag's name ends at white space, `/` or `>`.
+ let Some(name_end) = markup.get(1..).and_then(|t| t.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map(|i| i + 1) else {
+ return markup.to_string();
+ };
+ let start_tag = markup.get(..start_tag_end(markup)).unwrap_or(markup);
+ let mut declared = String::new();
+ // The default namespace in scope where the element sits: its own when it has no prefix, and
+ // its unprefixed descendants' when it has one.
+ if let Some(uri) = rich.namespaces().find(|ns| ns.name().is_none()).map(|ns| ns.uri())
+ && !uri.is_empty()
+ && uri != XFDF_NS
+ && !declares(start_tag, "xmlns")
+ {
+ let _ = write!(declared, " xmlns=\"{}\"", esc(uri));
+ }
+ for ns in rich.namespaces() {
+ let Some(prefix) = ns.name() else { continue };
+ if prefix == "xml" || !markup.contains(&format!("{prefix}:")) || declares(start_tag, &format!("xmlns:{prefix}")) {
+ continue;
+ }
+ let _ = write!(declared, " xmlns:{prefix}=\"{}\"", esc(ns.uri()));
+ }
+ if declared.is_empty() {
+ return markup.to_string();
+ }
+ let (Some(head), Some(tail)) = (markup.get(..name_end), markup.get(name_end..)) else { return markup.to_string() };
+ let fixed = format!("{head}{declared}{tail}");
+ if parse_form_xml(&fixed).is_ok() { fixed } else { markup.to_string() }
+}
+
+/// XFDF's own namespace (ISO 19444-1).
+const XFDF_NS: &str = "http://ns.adobe.com/xfdf/";
+
/// Put an annotation dictionary on a page, replacing one with the same `/NM`. Returns its
/// reference.
/// `/F` bits an imported file may not set on a redaction mark: Invisible, Hidden, NoView — so a
@@ -764,6 +1113,11 @@ pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result
// without usable /QuadPoints removes its /Rect, which the outline is then drawn from.
d.remove(b"CA");
d.remove(b"ca");
+ // The outline is PdfCraft's own (an appearance the file carries could hide the mark), and
+ // a redaction has no border effect (ISO 32000-2 Table 195 lists none; §12.5.4 gives
+ // /BE to squares, circles, polygons and free text), which the outline could not draw.
+ d.remove(b"AP");
+ d.remove(b"BE");
let quads_drawable = d.get(b"QuadPoints").and_then(Object::as_array).is_some_and(|q| {
let v: Vec = q.iter().filter_map(Object::as_f64).filter(|v| v.is_finite()).collect();
let (quads, rest) = v.as_chunks::<8>();
@@ -783,7 +1137,32 @@ pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result
}
}
}
+ // The outline is drawn inside /Rect: it is grown to hold every quad, so the whole area
+ // the mark removes shows (a /Rect with no area would show none of it).
+ let quads: Vec =
+ d.get(b"QuadPoints").and_then(Object::as_array).map(|q| q.iter().filter_map(Object::as_f64).collect()).unwrap_or_default();
+ if !quads.is_empty() {
+ let rect = rect_strict(doc, d.get(b"Rect"));
+ let start = rect.map(|[x0, y0, x1, y1]| [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]);
+ let bounds = quads
+ .as_chunks::<2>()
+ .0
+ .iter()
+ .fold(start, |b, [x, y]| Some(b.map_or([*x, *y, *x, *y], |[l, bo, r, t]| [l.min(*x), bo.min(*y), r.max(*x), t.max(*y)])));
+ if let Some(b) = bounds
+ && Some(b) != rect
+ {
+ d.set(b"Rect".to_vec(), arr(&b));
+ }
+ }
}
+ // An appearance the file carries is how the comment looked where it came from, and is kept
+ // (XFDF 3.0 "stamp": it takes precedence over the icon). A redaction mark is always drawn by
+ // PdfCraft, so what it marks for removal is shown.
+ let redact = d.name(b"Subtype") == Some(b"Redact".as_slice());
+ // A redaction mark with an area removes something when applied.
+ let marks = redact && d.contains(b"QuadPoints");
+ let keep_appearance = !redact && super::appearance::has_appearance(doc, &d);
let nm = d.get(b"NM").and_then(|n| n.as_string().map(|s| s.to_text()));
let annots: Vec = doc
.get(page_ref)
@@ -827,8 +1206,17 @@ pub(crate) fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result
}
None => doc.add(Object::Dict(d)),
};
- // Our own appearance where we can draw one; others keep none (viewers draw a default).
- let _ = pdfcraft_annot::set_appearance(doc, r);
+ // Otherwise our own appearance where we can draw one; others keep none (viewers draw a default).
+ if !keep_appearance {
+ let drawn = pdfcraft_annot::set_appearance(doc, r);
+ // A redaction mark that removes something but shows nothing is refused: applying it
+ // would remove content the reviewer never saw marked.
+ if let Err(why) = drawn
+ && marks
+ {
+ return Err(DataError::Malformed(format!("a redaction mark could not be drawn, so what it removes would not show ({why})")));
+ }
+ }
kept.push(Object::Ref(r));
doc.update_dict(page_ref, |p| p.set(b"Annots".to_vec(), Object::Array(kept)))?;
Ok(r)
@@ -864,19 +1252,35 @@ pub fn import_comments(
fresh: Option<&str>,
offset: Option<[f64; 2]>,
) -> Result {
- import_xfdf_with(doc, text, page, fresh, offset.filter(|o| o.iter().all(|v| v.is_finite())))
+ // Into a copy, kept only when the whole import succeeds.
+ let mut work = doc.clone();
+ let report = import_xfdf_with(
+ &mut work,
+ text,
+ page,
+ fresh,
+ offset.filter(|o| o.iter().all(|v| v.is_finite())),
+ super::appearance::MAX_IMPORT_APPEARANCE_BYTES,
+ true,
+ )?;
+ *doc = work;
+ Ok(report)
}
pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result {
- import_xfdf_with(doc, text, None, None, None)
+ import_xfdf_with(doc, text, None, None, None, super::appearance::MAX_IMPORT_APPEARANCE_BYTES, false)
}
-fn import_xfdf_with(
+/// The import, with `appearance_budget` decoded bytes of appearance for all of its comments;
+/// `comments_only` leaves the file's field values out (comments placed as copies).
+pub(crate) fn import_xfdf_with(
doc: &mut Document,
text: &str,
target_page: Option,
fresh: Option<&str>,
offset: Option<[f64; 2]>,
+ mut appearance_budget: usize,
+ comments_only: bool,
) -> Result {
check_form_xml_depth(text)?;
let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?;
@@ -912,6 +1316,18 @@ fn import_xfdf_with(
let own_page = node.attribute("page").and_then(|p| p.parse::().ok());
let Some(page) = target_page.or(own_page).and_then(|p| pages.get(p).copied()) else { continue };
let Some(mut d) = annot_from_xml(node, subtype, page) else { continue };
+ // The appearance the file carries (XFDF 3.0 "appearance"), when it can be read.
+ // A redaction mark is always drawn by PdfCraft (see `place`): its appearance is not read.
+ if let Some(ap) = node.children().find(|c| c.has_tag_name("appearance")).filter(|_| *subtype != "Redact") {
+ let text: String = ap.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect();
+ match super::appearance::decode(&text, &mut appearance_budget) {
+ Ok(tree) => {
+ let ap = super::appearance::materialize(doc, tree);
+ d.set(b"AP".to_vec(), ap);
+ }
+ Err(why) => report.skipped.push(format!("{}: {why}", node.attribute("name").unwrap_or(&tag))),
+ }
+ }
let assigned = fresh.map(|prefix| format!("{prefix}-{}", n + 1));
if let Some(name) = &assigned {
// A copy under a name of its own (one even where the original had none).
@@ -956,7 +1372,7 @@ fn import_xfdf_with(
}
}
// Comments placed as copies: the file's field values are not part of the paste.
- if fresh.is_some() || target_page.is_some() || offset.is_some() {
+ if comments_only {
return Ok(report);
}
// Fields.
diff --git a/docs/contributors.md b/docs/contributors.md
index fa7e988611..b24d1cd053 100644
--- a/docs/contributors.md
+++ b/docs/contributors.md
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
- username.
+ username. Release builds regenerate it for the release commit
+ (`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md
index 038e63970a..31289fad66 100644
--- a/docs/editing-existing-text.md
+++ b/docs/editing-existing-text.md
@@ -1,5 +1,29 @@
# Editing existing text and source subsets
+In **Edit text and images**, text drawn inside a Form XObject (grouped artwork) is edited
+with one click: clicking its text prepares that page in an undoable **Prepare grouped text**
+step and opens the paragraph clicked for typing. Nothing is prepared while pages are only
+shown or scrolled. A drag still moves the selected group as a whole; a click on grouped
+artwork that preparation wouldn't open (a logo, a refused group) selects it as before, and
+changes nothing. To select a text group for moving, right-click it. While a paragraph is open
+or another edit is waiting, the click selects the group instead, as before; the next click
+prepares it. The group's original operators, clip, placement and resources become page
+content; shared forms on other pages stay unchanged. Ungroup (the artwork's right-click menu)
+draws one placement in place with the same code. Headless callers use `text_prepare`, then
+list `text_paragraphs` and call `text_edit`.
+This edits existing glyph operators; it adds neither a cover rectangle nor an Add Text box.
+Preparation refuses knockout/blended groups, groups that blend in a colour space other than
+the page group's (DeviceRGB on a page that names none), forms whose default
+colour spaces differ from the page's, tagged forms, forms drawn inside text objects or
+unfinished paths, groups whose nested content can't be checked and unreadable content. Forms
+that draw only images or vector artwork are left grouped. Decoding is capped at 16 MiB, 64
+placements and eight expansion levels per page; text grouped deeper is reported. Unsupported
+groups remain intact: `text_prepare` lists each with its reason, and the repair log records
+them when the page is prepared. A narrowly fingerprinted legacy THSarabunPSK exporter map can
+also be repaired in copied page fonts, only when every glyph the subset kept advances as its
+assumed Thai character does; `text_prepare` repairs it also for text that was never grouped,
+and the repair log records both preparation and map recovery.
+
Click a text region in **Edit a PDF → Edit text and images**. One editor contains its
source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory;
source font programs, colour, size and baseline anchors stay intact. A textless preview
@@ -49,7 +73,46 @@ with a fallback even when the replacement has no explicit line break (#2750). Ch
the source can draw keep that font and its advances; only missing characters enter the
generated Type 3 fonts, in batches. Wrapping and alignment measure both faces, including
character spacing and horizontal scaling. Explicit family/weight changes and the existing
-turned, right-to-left and visual-order paths retain their selection rules. The styled-path fallback grouping below remains independent of this single-style path.
+turned, right-to-left and visual-order paths retain their selection rules. The styled-path
+fallback grouping below remains independent of this single-style path.
+
+**Keep source fonts** retains the existing Thai family, weight and point size. For legacy
+THSarabunPSK subsets, PdfCraft reconstructs an editor-only Unicode cmap from the PDF's
+Unicode and CID-to-glyph maps. HarfRust uses the retained Thai positional glyphs, and the
+saved text refers to the original PDF font resources. Several subsets of the same exact
+face can supply Thai and punctuation glyphs, including a Thai run whose clusters are split
+across subsets, and a subset whose glyph for a character is empty is skipped; Regular and
+Bold stay separate. Source font
+programs are never modified or replaced. Text settings, including character/word spacing,
+horizontal scale, rise, color and paint mode, remain in the PDF text state.
+
+A source subset that lacks a needed contextual glyph produces an error asking for the
+complete original face or an explicit replacement. Cross-family replacement requires an
+explicit choice. The document-font menu offers **Noto Sans Thai**: pass its `text_fonts`
+resource (normally `builtin:thai`) as `text_edit.font_resource` or a range's `font_resource`. This replacement
+requires the optional craft-fonts Thai input and currently uses Regular. A matching Noto
+subset can still be completed by that same bundled family when it is Regular and upright; a
+subset its name or descriptor marks bold or italic needs an explicit replacement.
+
+The bundled variable Thai face is written as bounded Type 3 glyph outlines, with shaped
+vowel/tone positions and logical Unicode text, rather than embedded as a static font.
+Saved paragraphs can be reopened and edited again without installing a font. Modern Thai
+shaping of vowels and tone marks uses the source's GPOS mark positioning (a `mark` or `mkmk` feature in the script and language system the shaper
+selects for Thai, not just a GPOS table); GSUB substitution is optional, as specified by
+[the OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai).
+Verified legacy Thai PUA fonts use positional substitutions instead of requiring GPOS; their
+alternates are used only when each kept one has a mark's zero advance (or, for the two
+consonant forms, an advance) and, where the PDF's own map names its character, the character
+that alternate stands for. A THSarabunPSK program with its own Thai mark
+positioning, such as a complete face loaded with Load font, is shaped by that layout instead,
+still in its own codes. One without it takes the legacy path only when every Thai glyph it keeps
+is where the legacy glyph order puts it, and is otherwise shaped as an ordinary source font. A
+subset needs only the glyphs its new text uses. When no subset can show the new text in its
+existing codes, a complete program of the same face and style (one added with Load font, CFF
+included) shapes the whole span with its own layout, Latin rows too, rather than the edit being
+refused; a Regular program never completes text the source marks bold or italic.
+Mixed contextual style runs and dictionary word segmentation remain limited. No font binary
+or release pin changes are needed.
When mixed-style text needs outline fallback, characters using the same actual fallback face
share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same
@@ -67,6 +130,13 @@ sign before an amount: the line-start and line-end rules of JIS X 4051, as in Un
breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only
at spaces.
+Text pasted from a browser or a word processor often carries an invisible break instead of a
+newline. Every Unicode row break starts a new row: U+2028 LINE SEPARATOR, U+2029 PARAGRAPH
+SEPARATOR, U+0085 NEXT LINE, the vertical tab, the form feed and the information separators
+(U+001C to U+001E), alongside the carriage return and the newline. These move the text down a
+row rather than being looked up in a font, so a paste no longer fails because no face has an
+outline for them. Other white space is still written as one space.
+
Select text within the editor before changing size, colour, font, underline or spacing to
format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the
same properties; invalid or overlapping ranges fail atomically. Untouched source formatting
@@ -96,8 +166,16 @@ On a scanned page, recognised text (Scan & OCR) is an invisible layer over the p
text groups it into phrases: words joined by a word space, with a table cell, a tab stop and each
scanned line kept apart. Applying an edit draws the new text visibly in the scan's ink colour (or
the one chosen) and first covers the old words with the paper colour sampled around them. Each
-word keeps its position and width, and a longer phrase grows rightwards rather than wrapping. The
-cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface.
+word keeps its position and width, and a longer phrase grows rightwards rather than wrapping.
+OCR sizes each word from its own box, so a phrase's words can come out several points apart: the
+editor and the rewrite set the whole phrase in one size, the median of its words' sizes.
+A text edit covers and redraws only the words it changes: the rest of the line stays the scan, and
+a deleted word leaves its gap, because the words after it are pixels. A replacement a little too
+wide is squeezed across to fit its place (by up to a quarter); wider than that it takes over the
+word it would run into, and new words with nothing to replace take a neighbour.
+The whole phrase is redrawn for a change of formatting or a new line, and for a layer whose words
+would move without the ones before them. While the phrase is open, the editor still shows all of
+it in the layer's Helvetica. The cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface.
This applies to any invisible text (rendering mode 3), not just a PdfCraft OCR layer: editing
invisible text, such as another OCR tool's layer, makes the rewritten paragraph visible over a
painted cover in the sampled paper colour. Edit text also groups invisible text differently: its lines never
diff --git a/docs/editing-images.md b/docs/editing-images.md
index 4ebafd115f..16a78f711a 100644
--- a/docs/editing-images.md
+++ b/docs/editing-images.md
@@ -18,9 +18,10 @@ content, resources, clipping box, and transparency settings remain intact. Movin
deleting one use of a shared figure does not alter its other placements or pages.
**Replace Image** and **Save Image As** apply only to raster Image XObjects; these menu
-items are disabled for grouped Form artwork. The tool does not ungroup Forms, edit their
-individual internal elements, select arbitrary ungrouped vector paths, or edit inline
-images (`BI`/`ID`/`EI`). A Form's bounding box may include whitespace.
+items are disabled for grouped Form artwork. **Ungroup** (right-click) draws one placement's
+content in place so its text and pictures can be edited; while grouped, the tool does not edit
+a Form's individual internal elements. It does not select arbitrary ungrouped vector paths or
+edit inline images (`BI`/`ID`/`EI`). A Form's bounding box may include whitespace.
For automation, `page_images` lists both kinds with `kind: "image"` or `kind: "form"`.
Forms have `pixels: [0, 0]`, since they have no single bitmap resolution. Use the returned
diff --git a/docs/editing-multiple-objects.md b/docs/editing-multiple-objects.md
index 7881be6a7c..3cf4a6b167 100644
--- a/docs/editing-multiple-objects.md
+++ b/docs/editing-multiple-objects.md
@@ -13,6 +13,30 @@ In **Edit a PDF → Edit text & images**, select several items on the same page:
- Modifier-clicks prefer added items and text over background artwork, so labels remain selectable.
- Clear the selection or double-click an item to return to ordinary text editing and image resizing.
Finish or discard a text draft before starting a multi-selection.
+- **⌘A / Ctrl+A** selects every item on the current page (paragraphs, artwork and added items),
+ as Acrobat's Edit PDF does, instead of the page's text. Inside a paragraph or text box open for
+ typing it still selects that text.
+
+## Copy, cut and paste
+
+With items selected together, **⌘C / Ctrl+C** (Edit ▸ Copy) copies them and **⌘X / Ctrl+X**
+(Edit ▸ Cut) copies them, then deletes them as one undo step. **⌘V / Ctrl+V** (Edit ▸ Paste) pastes
+them onto the current page of any open document, also after the original changed or closed, as
+one undo step, and selects what it pasted. Onto the page they came from they land 18 points down
+and to the right, as Acrobat offsets a paste onto the same page; elsewhere where they were; each
+further paste onto a page steps another 18 points; a copy that would land off the page is centred.
+
+A paste draws the same: it copies the items' content operators with the graphics state they were
+drawn in (transformation, clip, colours, transparency, text state) and every resource they use
+(fonts, images and Form artwork, ExtGStates, colour spaces, patterns, shadings), merged into the
+target page's resources under names that don't clash, as Ungroup merges them. Within a document the
+same font and image objects are drawn again. Into another document they are copied with what they
+refer to, each once whatever the cycles, sharing objects it already holds; a paste whose resources
+take more than 20,000 objects or 256 MB is refused with that reason. Pasted items are ordinary page
+content: an added text box comes as page text, untagged and in no layer. The system clipboard gets
+the copied text (or, without text, the first picture) for other applications; a newer copy there,
+here or in another application, wins ⌘V. Text drawn as a clipping path before the selection is
+refused, as is artwork without resources of its own that names one, where a paste would change it.
The supported items are the existing paragraph boxes, raster Image XObjects, whole Form artwork,
and text/images added by PdfCraft. A Form remains one item, including its nested artwork and
@@ -75,6 +99,17 @@ session. For a CLI script, include `doc_open` in that script before the move:
]
```
+`{"all": true}` instead of `objects` takes every item on the page, as ⌘A does. `object_copy` takes
+the same references (with `cut: true` it deletes them afterwards, one undo step) and `object_paste
+{doc, page}` pastes them, into the same or another open document, as the app does:
+
+```json
+[
+ {"tool":"object_copy","args":{"doc":1,"page":1,"all":true}},
+ {"tool":"object_paste","args":{"doc":2,"page":3}}
+]
+```
+
Use the actual generation and references from your document, rather than assuming these example
values. `offset` is in displayed points: positive x moves right, positive y moves down. The optional
generation guard rejects a changed document before interpreting references. Re-list after any edit,
@@ -82,6 +117,9 @@ undo, redo, or save; there is no claim that an index persists across generations
`pdfcraft-cli run --script steps.json --root DIR` or call these tools through the opt-in MCP server.
Regression coverage: `crates/edit/src/tests.rs` (glyph bytes, neighbours, split streams, shared Forms,
-rotated/cropped/UserUnit pages and refusal), `crates/engine/src/tests.rs` (permissions, history,
-rendering and save/reopen), `crates/automation/tests/it/automation.rs` (generation guards and hostile
-references), and `crates/ui-egui/tests/it/object_selection.rs` (real-shell selection and gestures).
+rotated/cropped/UserUnit pages and refusal), `crates/edit/src/objects/clip_tests.rs` (copies that
+render the same pixels, renamed resources, hostile content), `crates/engine/src/tests.rs` and
+`crates/engine/src/object_clip_tests.rs` (permissions, history, rendering, save/reopen, pastes into
+another document, cycles and the size limit), `crates/automation/tests/it/automation.rs` (generation
+guards, hostile references, copy/cut/paste tools), and `crates/ui-egui/tests/it/object_selection.rs`
+(real-shell selection, gestures, ⌘A and the clipboard keys).
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index cc3e231979..288b7ca0cf 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -80,7 +80,7 @@ features total).
| Target | Acrobat | PdfCraft | Fidelity | Tests and evidence |
|---|---|---|---|---|
-| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and borderless inferred tables; positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial |
+| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and inferred tables (bordered only along the grid lines the page draws); positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial |
| Word 97–2003 (`.doc`) | yes | no | none | |
| Excel (`.xlsx`), XML Spreadsheet 2003 | yes | no | none | `create.export-xlsx` planned |
| PowerPoint (`.pptx`) | yes | slides per page: title from largest text, body text, images, tables (no slide overflow reflow / normAutofit; first page sets presentation dimensions) | basic | `create.export-pptx` partial |
diff --git a/docs/gaps.md b/docs/gaps.md
index e11eef9642..1895b55574 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -348,7 +348,8 @@ update the numbers.
### 16. UI precision gaps
- **Missing:** moving several selected objects across pages (same-page moves shipped, #844), single-key tool accelerators,
- rulers, grids and guides, loupe and dynamic zoom, multiple windows and split view, comment
+ rulers, grids and guides, loupe and dynamic zoom, multiple windows and spreadsheet split (split
+ view shipped), comment
pop-ups that can be moved, connector lines, customisable quick tools; shortcut display and tool-mode bugs (#746, #721), macOS window controls
(#789).
- **Estimate:** 30–60 h. **Doc:** [ui-parity.md](ui-parity.md).
@@ -363,9 +364,11 @@ update the numbers.
### 18. Print production tools absent
- **Missing:** Output Preview, separations, total area coverage, overprint preview, ink manager,
- colour conversion, printer marks, fix hairlines, flattener preview, colour management settings;
- print as image; PostScript output.
-- **Evidence:** 18 print-production features planned (L area).
+ colour conversion, fix hairlines, flattener preview, colour management settings;
+ print as image; PostScript output. Printer marks are in (Print production ▸ Add printer marks…).
+ colour conversion, printer marks, flattener preview, colour management settings;
+ print as image; PostScript output. Fix hairlines is in (Print production ▸ Fix hairlines…).
+- **Evidence:** 17 print-production features planned (L area).
- **Estimate:** 25–45 h (part of the L area's 30–55). **Doc:** [target-app-parity.md](target-app-parity.md).
### 19. Preferences mostly missing
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
index da2b8d92b1..455c7bcd78 100644
--- a/docs/hardware-parity.md
+++ b/docs/hardware-parity.md
@@ -1,6 +1,6 @@
# Hardware and platform parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (web printing through the browser's print dialog; earlier: shared craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (web OCR shipped: Web Workers with SIMD, checked in a browser; earlier: web printing through the browser's print dialog; earlier: shared craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
Each hardware feature Acrobat Pro uses, per platform, ours against Acrobat's. Acrobat runs on macOS
and Windows (plus a limited web viewer/editor in Document Cloud); PdfCraft runs on macOS, Windows,
@@ -34,12 +34,14 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30
| Windows (x64, x86, ARM64) | yes (x64, ARM64) | yes | signed installers | — |
| Linux | no | yes | AppImage, deb, rpm, Flatpak, tarball | Chinese faces in Flatpak (#689) |
| FreeBSD | no | yes | package | — |
-| Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | OCR, crash recovery, PKCS #12 signing, hosted deployment |
+| Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | OCR as fast as native (Web Workers with SIMD read a page about 17× slower), crash recovery, PKCS #12 signing, hosted deployment |
## Revision history
| Date | Change | Summary |
|---|---|---|
+| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`) shipped after a browser check |
+| 2026-10-12 | minor | Web platform row: OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, partial); what's left is speed |
| 2026-10-11 | minor | Web printers row: printing through the browser's print dialog (gap 4 closed); Web platform row no longer lists printing |
| 2026-10-10 | major | Created from the code, `vendor/README.md`, `.github/workflows/release.yml`, the print crate's README and user issues |
| 2026-10-10 | minor | Windows printing shipped (#756): the Windows row's gap is closed; the web row keeps "printing" |
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 1944575ce3..4e30fcdf0a 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,24 +1,32 @@
# Localization parity
-> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (Turkish: 2,867 entries, 0 missing in `cargo xtask i18n-report` on 2026-11-03; AI-assisted)
-> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (every catalog restated from `cargo xtask i18n-report` on `main` at `14ae839a`: 2,897 strings in the report's denominator; Hindi, Indonesian, Korean, Thai, Lao and Central Kurdish listed with their catalogs. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to
add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md).
## How this was measured
-- **Strings translated** is measured: the catalogs in `crates/ui-egui/src/i18n/.tsv` (since moved to craft-i18n's `locales//`), counted
- as distinct `(context, English)` keys with a non-empty translation. The denominator is the union of
- keys across all catalogs, counting language-specific context rows such as `theme`/`Appearance`.
- **That union is a moving target**: every change that adds a string widens it, so a count is only
- comparable with others measured on the same date, and a catalog that reached 100% falls behind
- without losing a single translation. Every figure here therefore carries its date. The current
- union is **2,798** keys as of 2026-10-25, against the **2,143-key snapshot** of 2026-10-10; earlier
- revisions quote the union of their own date (see the revision history), not this one. The
- twelve-language percentage table below still quotes the 2026-10-10 snapshot for every language
- except Spanish, Vietnamese, Lithuanian and Brazilian Portuguese, which are measured against the current union. English is
+- **Strings translated** is measured with `cargo xtask i18n-report`, which CI also runs: the
+ English strings in its denominator, less the ones a catalog has no translation for. The
+ denominator is the UI source's literals, the command, All tools and engine wordings, and every
+ key of every bundled catalog, which brings in the contextual, plural and dynamically looked-up
+ strings the scan can't see. On `main` at `14ae839a` (2026-11-03) that is **2,897** strings, and
+ every count and percentage in the tables below is of that number. A key only some catalogs have
+ counts as missing in the others; a contextual one (such as `theme`/`Appearance`) is met by a
+ plain translation of the same English, but a `@plural` one only by its own plural forms. The
+ denominator still grows whenever the interface gains a string, so a catalog that was complete
+ falls behind without losing a translation, and every figure here carries its date. English is
built in.
+- **Catalogs:** the figures above were taken from the in-repo catalogs
+ (`crates/ui-egui/src/i18n/.tsv`), which have since moved to craft-i18n's `locales//`.
+ The report reads the catalogs the build embeds, so measuring again needs `CRAFT_I18N_DIR` set to a
+ craft-i18n checkout at the commit in `craft-i18n.rev`; without it the report says it was
+ skipped.
+- **Before 2026-11-03** this page counted distinct `(context, English)` keys against the union of
+ keys across all catalogs: 2,143 keys on 2026-10-10, 2,798 on 2026-10-25, and 2,895 on
+ `14ae839a`. That union also grew whenever one catalog added a key no other had, so it moved every
+ row at once. Earlier revisions in the history quote it, at the size of their own date.
- **Not counted:** error details from the engine and the operating system, which are shown in
English inside a translated frame; there is no translated user documentation or help yet (Acrobat
ships localized help).
@@ -32,64 +40,71 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R
## The twelve key languages
-| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) |
+| Language | Code | UI strings translated (of 2,897, 2026-11-03) | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) |
|---|---|---|---|---|---|---|---|---|
| English | `en` | built in (100%) | all; README and docs | Latin | yes | **full** | yes | 0 |
-| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 |
-| Spanish | `es` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 |
-| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | none | no | 10–18 |
-| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 |
-| French | `fr` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 |
-| Portuguese (Brazil) | `pt-br` | 2,798 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 |
-| Indonesian | `id` | 0 | none | Latin | no | none | no | 3–5 |
-| Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 |
-| German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 |
-| Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 |
-| Vietnamese | `vi` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 |
+| Simplified Chinese | `zh-hans` | 2,560 (88.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 |
+| Spanish | `es` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 |
+| Hindi | `hi` | 2,825 (97.5%) | dialogs and tooltips; no help | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | partial | no | 10–18 |
+| Arabic | `ar` | 2,836 (97.9%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 |
+| French | `fr` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 |
+| Portuguese (Brazil) | `pt-br` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 |
+| Indonesian | `id` | 2,825 (97.5%) | dialogs and tooltips; no help | Latin | no | partial | no | 3–5 |
+| Japanese | `ja` | 2,597 (89.6%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 |
+| German | `de` | 2,600 (89.7%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 |
+| Korean | `ko` | 2,825 (97.5%) | dialogs and tooltips; no help | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | partial | yes | 6–10 |
+| Vietnamese | `vi` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 |
-**Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only
-60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and
-Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h**
+**Localization dimension ≈ 68%** (scored 2026-10-13, before the counts above): the mean over the
+twelve, counting a catalog's percentage of the 2,143-key snapshot but only 60% for Chinese and
+Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and Korean, which had no
+catalog yet. Vietnamese moved from 0% to 100% in that mean (59% → 68%). Hindi, Indonesian and
+Korean now have catalogs, so the score understates them. It is not re-scored here, because that
+decides how their script caveats count (Devanagari rendering unqualified, no Hangul UI face). **≈ 55–110 h**
to bring all twelve to `full`, of which ≈ 35–60 h is engineering (remaining RTL qualification, a web Hans face, bidi text)
and the rest catalogs and fixes; native-speaker review needs humans.
No language is `full` except English: every catalog leaves engine error details in English, and
-none has translated help. The **To `full`** hours of Vietnamese and Spanish are therefore residual
-help/docs polish (catalog and script work are already done), not missing UI strings.
+none has translated help. The **To `full`** hours of Vietnamese and Spanish are therefore mostly
+help/docs polish: their script work is done, and each catalog lacks 46 strings on 2026-11-03.
## Other shipped languages
-| Language | Code | UI strings translated | Status | Acrobat ships it |
+| Language | Code | UI strings translated (of 2,897, 2026-11-03) | Status | Acrobat ships it |
|---|---|---|---|---|
-| Traditional Chinese | `zh-hant` | 2,016 (94.1%) | partial (same Hans/Hant face caveat) | yes |
-| Russian | `ru` | 2,043 (95.3%) | partial | yes |
-| Bulgarian | `bg` | 2,094 (97.7%) | partial | no |
-| Czech | `cs` | 2,794 entries (all but four keys of the current union; the engine's sentences, weekdays and certificate fields) | partial | yes |
-| Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no |
-| Hungarian | `hu` | 2,126 (99.2%) | partial | yes |
-| Ukrainian | `uk` | 2,142 (100%) | partial | yes |
-| Italian | `it` | 2,736 entries (all but 62 keys of the current union) | partial | yes |
-| Kazakh | `kk` | 2,539 entries | partial | no |
-| Polish | `pl` | 2,611 entries | partial | yes |
-| Slovenian | `sl` | 2,536 entries | partial | no |
-| Greek | `el` | 2,545 entries | partial | no |
-| Turkish | `tr` | 2,867 entries (0 missing in `cargo xtask i18n-report`, 2026-11-03) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes |
-| Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no |
-| Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes |
-| Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no |
-| Catalan | `ca` | 2,798 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no |
-| Romanian | `ro` | 2,798 entries (every current key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no |
-| Norwegian Bokmål | `nb` | 2,707 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes |
-| Persian | `fa` | 2,785 entries (all but 13 keys of the current 2,798-key union) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no |
+| Traditional Chinese | `zh-hant` | 2,559 (88.3%) | partial (same Hans/Hant face caveat) | yes |
+| Russian | `ru` | 2,515 (86.8%) | partial | yes |
+| Bulgarian | `bg` | 2,564 (88.5%) | partial | no |
+| Czech | `cs` | 2,847 (98.3%) | partial | yes |
+| Telugu | `te` | 2,513 (86.7%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no |
+| Hungarian | `hu` | 2,599 (89.7%) | partial | yes |
+| Ukrainian | `uk` | 2,600 (89.7%) | partial | yes |
+| Italian | `it` | 2,790 (96.3%) | partial | yes |
+| Kazakh | `kk` | 2,609 (90.1%) | partial | no |
+| Polish | `pl` | 2,681 (92.5%) | partial | yes |
+| Slovenian | `sl` | 2,618 (90.4%) | partial | no |
+| Greek | `el` | 2,615 (90.3%) | partial | no |
+| Turkish | `tr` | 2,895 (99.9%) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes |
+| Lithuanian | `lt` | 2,851 (98.4%) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no |
+| Finnish | `fi` | 2,849 (98.3%) | partial; AI-assisted, native proofreading pending | yes |
+| Albanian | `sq` | 2,604 (89.9%) | partial; not yet reviewed by a native speaker | no |
+| Catalan | `ca` | 2,849 (98.3%) | partial: no translated help, engine error details in English; native-speaker review by the contributor | no |
+| Romanian | `ro` | 2,849 (98.3%) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no |
+| Norwegian Bokmål | `nb` | 2,758 (95.2%) | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes |
+| Persian | `fa` | 2,801 (96.7%) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no |
+| Central Kurdish (Sorani) | `ckb` | 2,754 (95.1%) | partial; right-to-left like Arabic | no |
+| Thai | `th` | 2,751 (95.0%) | partial | no |
+| Lao | `lo` | 2,504 (86.4%) | partial | no |
-**20 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences).
-The entry counts without percentages are as of 2026-10-25; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review.
+**23 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences).
+Their presence does not imply a detailed linguistic or runtime review.
Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat.
## Revision history
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | minor | Every catalog restated from `cargo xtask i18n-report` on `14ae839a` (of the 2,897 strings in the report's denominator), replacing the catalog-union measure, which had reached 2,895 keys and left rows "100%" that were 44 short (#5805). Hindi, Indonesian and Korean restated from 0 to their catalogs, and Thai, Lao and Central Kurdish added to the table. The ≈ 68% score is not re-scored |
| 2026-10-25 | minor | Arabic current state corrected: catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build still has no Hans face and the release-pinned craft-fonts still has no Telugu face. Scores and estimates unchanged |
| 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed |
| 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% |
diff --git a/docs/localization.md b/docs/localization.md
index 96822877a3..a7860fa1c0 100644
--- a/docs/localization.md
+++ b/docs/localization.md
@@ -1,6 +1,6 @@
# Interface language
-Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **فارسی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
+Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **فارسی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. The status caption PdfCraft draws into a named-layer signature appearance ("Signature valid" and its siblings) follows it too, on screen, in exports and in print; it is never saved. It is drawn in Helvetica, or shaped in embedded Inter, or shaped in a craft-fonts face for Arabic, Japanese, Telugu, Thai and Devanagari when the build has one (drawn as outlines), and otherwise in English, as Chinese always is; saving never writes it.
Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable.
diff --git a/docs/releasing.md b/docs/releasing.md
index 72206e16f1..c0bb41fd5b 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -128,9 +128,16 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable.
this.
- `installer-ui.wxs` supplies native welcome, maintenance, progress, files-in-use and outcome
dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages.
- The welcome dialog has a "Create a desktop shortcut" checkbox and an "Edit with PdfCraft"
- right-click menu checkbox, both ticked by default. `/qn` and `/qb` stay unattended and create
- both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed;
+ The welcome dialog has an "Install to" row (a `PathEdit`, and Browse… into `BrowseFolder`, a
+ native folder browser: drive list, folder list, Up, New folder, typed path; #1139), a "Create a
+ desktop shortcut" checkbox and an "Add Edit and Combine files to the right-click menu of PDF
+ files" checkbox, both ticked by default. Install sets the folder (`SetTargetPath`) before the
+ dialog closes. `INSTALLFOLDER` is secure, so the choice reaches the elevated install, and
+ `INSTALLFOLDER=` works on the command line. The row starts on the folder
+ `install-policy.wxi` chose for the scope picked on the first page (the recorded folder on an
+ update), and an update installs into the recorded folder unless the command line gives one
+ (`INSTALLATION.md`). `/qn` and `/qb` stay unattended and create both unless
+ `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed;
`INSTALLIMAGECONTEXTMENU=0` (no checkbox) leaves out the image menu. An update (the launcher runs
`msiexec /passive` with no properties) keeps the desktop shortcut only if `PdfCraft.lnk` is still
on the all-users desktop, so deleting it once keeps it away (#1058), and keeps each menu only if
@@ -144,7 +151,10 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable.
limit for a `Document` verb) start one `pdfcraft.exe ""` each, and the single-instance
hand-off opens them as tabs. On Windows 11 it is under "Show more options"; a first-level entry
needs an `IExplorerCommand` handler and package identity (a sparse MSIX package), which the MSI
- doesn't have.
+ doesn't have. "Combine files in PdfCraft" (#585) is a second verb in the same component,
+ `PdfCraft.Combine`, running `pdfcraft --combine ""` once per selected file; the
+ single-instance hand-off sends them with `"combine": true`, and the running app adds them to
+ its Combine files tab (`OsEvent::Combine`). Both verbs come and go with `INSTALLPDFCONTEXTMENU`.
- The image right-click entry (#190) is the same kind of verb, `PdfCraft.CreatePdf`, for ten image
types, in its own component (`PdfcraftImageContextMenu`) since it became optional. Its rows moved
out of `PdfcraftApp`; that's safe because `MajorUpgrade` removes the old version before installing
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 522aff27aa..4becf2e6e3 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,11 +1,15 @@
# PdfCraft roadmap: milestones and what's next
+> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation, with reported deep or unsafe groups and a per-glyph check of the Thai map repair; estimates unchanged)
+
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
> **2026-11-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged.
+> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged)
+
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged)
@@ -216,7 +220,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
-| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Optional --two-paged applies noncontinuous two-page layout to explicitly opened CLI files without changing defaults; explicit --layout wins. |
+| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Optional --two-paged applies noncontinuous two-page layout to explicitly opened CLI files without changing defaults; explicit --layout wins. Crop pages is directly available in the quick toolbar (#5330). |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
@@ -239,15 +243,15 @@ total matches the parity estimate.
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15). Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
-| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed. Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. Candidate #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. |
+| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed. Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. Thai editing (#2964, #3185): supported Form text is editable; legacy Thai retains source fonts and text settings. Cross-family replacement is explicit; unsafe groups and mixed contextual styles remain partial. Candidate #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed. Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly). |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
-| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
-| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
-| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
+| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
+| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
+| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate. Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA Reading colours: pending page backgrounds follow the configured colour (#2059). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
@@ -296,6 +300,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
## Revision history
+**2026-10-31 · minor:** M3/M4 #5330: direct quick-toolbar Crop pages action reuses command permissions and the existing crop tool. Estimates unchanged.
+
**2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged.
**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
@@ -312,11 +318,15 @@ Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | patch | M10 `ocr.ocr-web` ported onto #977's engines and preprocessing: the page prepares each page, a Web Worker runs ocrs, the downloaded models join the engine cache. Estimates unchanged. |
+| 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. |
| 2026-10-25 | minor | M7, #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged. |
| 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier |
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
| 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer |
+| 2026-11-02 | patch | M3 #5708: pages shorter than the window sit in its middle, on a whole physical pixel |
+| 2026-11-02 | patch | M3 #5716: a snapshot keeps its resolution when zoomed out; a choice of resolution remains |
| 2026-11-02 | patch | M14 #5737, #5460: Preferences turns off the update check at start, on by default |
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. |
@@ -334,6 +344,7 @@ Estimates unchanged.
| 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. |
| 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. 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-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. |
| 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. |
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
@@ -365,6 +376,8 @@ Estimates unchanged.
| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
+| 2026-10-16 | minor | M9 #79: named-layer signature appearances show the signature's real state in the display copy, exports and both print modes, never in saves; captions follow the interface language. Estimates unchanged. |
+| 2026-10-16 | minor | M9: the page validation overlay moves to the widget's bottom-right corner as it lies on the page and stops growing past 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field under 32 screen points keeps its outline. Estimates unchanged. |
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
| 2026-10-19 | minor | M3 #3360: retain each document’s scroll position when switching Read and the docking workspace at fixed zoom. Estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
@@ -380,6 +393,8 @@ Estimates unchanged.
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
| 2026-10-14 | minor | M12: pending page backgrounds respect reading colours (#2059); estimates unchanged. |
+| 2026-10-12 | minor | M10 `ocr.ocr-web` shipped: Web Worker OCR checked by hand in a browser. Estimates unchanged. |
+| 2026-10-12 | minor | M10 `ocr.ocr-web` (partial): the web build downloads the OCR models on first use (the web zip ships them) and reads pages in Web Workers built with SIMD. Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index 44364aee66..126496547d 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -137,7 +137,7 @@ workflows, exchange edge cases and everything outside the mainstream slice are e
| Print | 8% | 55% | Good on macOS and Linux; Windows prints since #847 (#756), not yet re-scored |
| Highlight, sticky notes, text comments | 12% | 85% | All markup types, replies, properties |
| Fill in forms | 10% | 85% | Every AcroForm field type, Acrobat's formatting and calculations |
-| Fill & Sign: typed, drawn or image signature | 10% | 85% | Fill & Sign tools; flatten on save |
+| Fill & Sign: typed, drawn or image signature | 10% | 85% | Fill & Sign tools; flatten on save (opt-in, and asked on the first save after signing) |
| Rotate, delete, reorder and extract pages | 8% | 85% | Organize grid, drag reorder, thumbnails |
| Combine files | 6% | 85% | Combine tab with bookmarks per file |
| Fix a typo in existing text | 5% | 40% | Latin paragraphs only; font may be substituted |
@@ -191,7 +191,7 @@ features total and are shown for orientation; the others add to it.
| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); native craft-fonts builds embed a Simplified Chinese face (LXGW WenKai Lite) but the web build has none; Arabic labels stay logical with explicit RTL direction and mirrored stock chrome, while native/browser/IME workflows are unqualified; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) |
| Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) |
| Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) |
-| Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) |
+| Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; web OCR reads in Web Workers but is far slower than native, and we lack web recovery | [hardware-parity.md](hardware-parity.md) |
| Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 | within features | No plug-in SDK or folder-level JavaScript; Action Wizard partial. Agent automation (CLI, MCP, UI control channel) has no Acrobat equivalent | [gaps.md](gaps.md) |
| AI features | ≈ 0% (in scope) | 1% | 20–40 | within features | Acrobat's AI Assistant is cloud-only (out of scope). Local, opt-in provider interface (`misc.ai-provider`) not started | [gaps.md](gaps.md) |
| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | | |
@@ -303,6 +303,8 @@ licensed OCR or AI model.
| 2026-10-26 | minor | `optimize.mono-compression` became partial (CCITT Group 4), counted on the table's own measurement: K Optimize and standards 15 shipped and 11 partial of 58 (35.3%), no longer listing CCITT as missing; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497). Estimates unchanged. |
| 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. |
| 2026-10-20 | minor | M12 #3673: the Character encoding rule accepts Identity-H/V fonts with Adobe CJK collections, or a predefined non-Identity CMap, while rejecting unknown names and foreign registries. Synthetic checker and read-only automation regressions passed after failing before the fix; per-glyph mapping validation and embedded-font extraction remain separate. Breadth, readiness and hours unchanged. |
+| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`) shipped after a browser check; scores not re-judged |
+| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`, partial): the web zip serves the models, Recognize Text downloads them on first use, and pages are read in Web Workers built with SIMD. Platforms text updated; scores not re-judged |
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
| 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged |
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index a0ea857f1a..f580b84c2a 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it; a comment's text, border or line, and fill each at an opacity of its own) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-29 · **Last updated:** 2026-10-29 · **Change:** minor (full screen plays page transitions and display times, hides a still pointer, and a right-click or Shift+Enter goes back a page #379) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the
2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is
@@ -32,8 +32,8 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped |
| Document tabs | yes | yes, with unsaved marker | shipped |
| Multiple windows, drag a tab out | yes | no | planned |
-| Split window, spreadsheet split | yes | no | planned |
-| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned |
+| Split window, spreadsheet split | yes | split right or down (⌘\\, tab menu, a tab dropped on the page), one document or two; no four-pane spreadsheet split | split shipped; spreadsheet split planned |
+| Read mode, full screen | yes | yes; full screen is one fitted page or spread at a time on black, and background clicks and keys turn pages while clicks on links, comments, fields and other page objects do not (#1459), a right-click or Shift+Enter goes back, page transitions and display times play (#379), a still pointer hides | shipped; loop after the last page and the full-screen preferences planned |
| Light, dark, system theme | yes | yes | shipped |
| Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped |
| Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned |
@@ -86,6 +86,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Date | Change | Summary |
|---|---|---|
+| 2026-10-29 | minor | Full screen plays page transitions (/Trans) and display times (/Dur), hides a still pointer, and a right-click or Shift+Enter goes back a page (#379) |
| 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once |
| 2026-10-24 | minor | Comment Properties sets a text box's text, border and fill opacity and a shape's line and fill opacity each on its own (Acrobat: one per comment); Make Properties Default carries them into the tool |
| 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. |
diff --git a/docs/windows-printing.md b/docs/windows-printing.md
new file mode 100644
index 0000000000..e96566fc3d
--- /dev/null
+++ b/docs/windows-printing.md
@@ -0,0 +1,25 @@
+# Windows print jobs rejected before submission
+
+A report on PdfCraft 0.19.0 described the preview closing without output while Edge printed the same PDF. On the investigated Windows machine, the HUAWEI CV81Z driver accepted the PrintTicket (paper, grayscale, duplex and collation), and its capabilities XML parsed successfully.
+
+The native submission test failed before queueing: `StorageFile::GetFileFromPathAsync` returned `0x80070005` for the temporary PDF. Ordinary desktop file access could read it. This isolates one reproducible failure; it does not establish the cause of every report of an empty print queue.
+
+The winprint loader uses `CreateRandomAccessStreamOnFile` with read-only access, then calls `PdfDocument::LoadFromStreamAsync`. The stream is backed by the job file and reads on demand. There is no whole-job `InMemoryRandomAccessStream` copy, `DataWriter`, transfer loop or per-chunk `StoreAsync().join()`. The path is made absolute and embedded NUL characters are rejected before calling the native API. The temporary file remains alive throughout rendering and submission through craft-print's existing lifetime management. PDF decoding and rendering can still allocate memory; the change removes the loader's full-file copy, not all printing memory use.
+
+Upstream loader: https://github.com/storytold/winprint.rs/pull/15 (`51934db3`). Dependency follow-up: https://github.com/storytold/craft-libs/pull/636 (`7ee35a9f`, includes merged #605). PdfCraft updates only its normal `craft-print` dependency. There is no global patch or personal-fork source. The loader revision descends from `64e7afd4`, preserving the multi-threaded Direct2D factory fix for #5006. Upstream reviews are still required before release.
+
+The public `craft_print::rgb` GDI transport remains pinned to main's `64e7afd4` revision. It is independent of the internal PDF-loader pin `51934db3`: retaining the public source identity avoids incompatible Rust types when a downstream application also uses that GDI revision directly. PdfCraft resolves one GDI package at that stable revision.
+
+## Evidence
+
+- Original Windows loader: synthetic A4 submission failed at PDF loading with `0x80070005`. The earlier memory-stream prototype submitted the same HUAWEI job; physical output was not independently confirmed.
+- Final file-backed loader: five focused tests pass, covering a hidden PDF, read-only stream behavior, reading the final byte of a 256 MiB file, a missing file, and a path containing NUL.
+- The hidden-file test was observed failing with the original StorageFile loader. The read-only test was observed failing with the earlier in-memory implementation and passing with the final loader.
+- Isolated craft-print suite with the final loader and multi-threaded Direct2D source: 63 passed, 3 hardware tests ignored. These diagnostic tests are not a full build of the final dependency graph; the disabled PDFium downloader build dependencies were omitted locally.
+- The file-backed loader at `b334ebbb` was subsequently submitted natively to the HUAWEI CV81Z; the reporter confirmed physical output of the synthetic A4 page. This used an isolated diagnostic workspace and writable TEMP directory, not the packaged UI. See https://github.com/storytold/winprint.rs/pull/15#issuecomment-6704470691.
+- The current loader pin `51934db3` also includes the review repairs: standard-library NUL validation, GetPage(0) before document/file cleanup, RAII temporary-file cleanup, and the explicit WinRT feature. In a complete Windows checkout with normal build dependencies, five loader tests and 14 Print Schema tests passed; injected failures left no temporary files behind.
+- Full final-graph build/quality gates must be rerun for the updated pins. Packaged-UI and wider printer/duplex qualification remain unverified; single-page output does not establish those.
+
+When reporting a different failure, include the PdfCraft version, printer/driver name, displayed error and whether a job appears in the Windows queue. Saving the print-ready PDF and printing it in another viewer remains a workaround until the fix ships.
+
+The loader pin also includes a bounded 30-second wait in the large-file native stream regression test. Production `PdfDocument::LoadFromStreamAsync` still has no dedicated timeout; the terminal print-completion timeout does not cover document loading. Current dependency-graph CI must pass before merging; earlier-head results do not qualify this update.
diff --git a/docs/word-count.md b/docs/word-count.md
index 4b3d02cba4..a211236075 100644
--- a/docs/word-count.md
+++ b/docs/word-count.md
@@ -25,8 +25,9 @@ Explicit Table/Code/Figure marked content still works there. Cyclic and oversize
graphs are bounded and reported as incomplete.
PDF `/ActualText` replacements are respected. If one replacement spans both included and
-excluded glyphs, there is no reliable mapping of its words to the selected content: counting
-fails with an explanation instead of silently returning the wrong total. Disable exclusions to
+excluded glyphs (or, with no glyphs, included and excluded outlines or pictures), there is no
+reliable mapping of its words to the selected content: counting fails with an explanation
+instead of silently returning the wrong total. Disable exclusions to
count that page. Unreadable pages likewise fail the operation, without a misleading partial total.
Images alone contain no extractable words. Run Scan & OCR first when counting scanned pages.
diff --git a/licenses/ICU4X-LICENSE.txt b/licenses/ICU4X-LICENSE.txt
new file mode 100644
index 0000000000..c9be6012c5
--- /dev/null
+++ b/licenses/ICU4X-LICENSE.txt
@@ -0,0 +1,46 @@
+UNICODE LICENSE V3
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright © 2020-2024 Unicode, Inc.
+
+NOTICE TO USER: Carefully read the following legal agreement. BY
+DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
+SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
+TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
+DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of data files and any associated documentation (the "Data Files") or
+software and any associated documentation (the "Software") to deal in the
+Data Files or Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, and/or sell
+copies of the Data Files or Software, and to permit persons to whom the
+Data Files or Software are furnished to do so, provided that either (a)
+this copyright and permission notice appear with all copies of the Data
+Files or Software, or (b) this copyright and permission notice appear in
+associated Documentation.
+
+THE DATA FILES AND SOFTWARE ARE 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 OF
+THIRD PARTY RIGHTS.
+
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
+BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
+OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
+WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
+FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall
+not be used in advertising or otherwise to promote the sale, use or other
+dealings in these Data Files or Software without prior written
+authorization of the copyright holder.
+
+SPDX-License-Identifier: Unicode-3.0
+
+—
+
+Portions of ICU4X may have been adapted from ICU4C and/or ICU4J.
+ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml
index 6e9f9fa607..d781ad6c7c 100644
--- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml
+++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml
@@ -25,6 +25,10 @@ finish-args:
- --share=ipc
# GPU access for the wgpu renderer (Vulkan, or GL as a fallback).
- --device=dri
+ # Screen readers: AccessKit asks the accessibility bus (org.a11y.Bus) whether one is running
+ # and publishes the window there over AT-SPI. Without it the sandbox hides the bus and the
+ # app is invisible to Orca and other assistive technology (#4612).
+ - --talk-name=org.a11y.Bus
# Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and
# find driverless network printers through Avahi (`lpstat -e`). Without these the Print
# dialog lists no printers.
diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml
index c9086ab077..fba62fe382 100644
--- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml
+++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml
@@ -26,6 +26,10 @@ finish-args:
- --share=ipc
# GPU access for the wgpu renderer (Vulkan, or GL as a fallback).
- --device=dri
+ # Screen readers: AccessKit asks the accessibility bus (org.a11y.Bus) whether one is running
+ # and publishes the window there over AT-SPI. Without it the sandbox hides the bus and the
+ # app is invisible to Orca and other assistive technology (#4612).
+ - --talk-name=org.a11y.Bus
# Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and
# find driverless network printers through Avahi (`lpstat -e`). Without these the Print
# dialog lists no printers.
diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in
index cfc7d2d08e..f38f186c82 100644
--- a/packaging/macos/Info.plist.in
+++ b/packaging/macos/Info.plist.in
@@ -6,6 +6,46 @@
CFBundleDevelopmentRegion
en
+
+ CFBundleLocalizations
+
+ en
+ ja
+ zh-Hans
+ zh-Hant
+ cs
+ pt-BR
+ de
+ es
+ fr
+ ru
+ kk
+ bg
+ te
+ hu
+ uk
+ it
+ ar
+ ckb
+ fa
+ pl
+ sl
+ el
+ tr
+ lt
+ id
+ vi
+ fi
+ sq
+ th
+ ca
+ ro
+ lo
+ hi
+ ko
+ nb
+
CFBundleDisplayName
PdfCraft
CFBundleName
diff --git a/packaging/web/README.md b/packaging/web/README.md
index 3149dc75d5..f5a51b4259 100644
--- a/packaging/web/README.md
+++ b/packaging/web/README.md
@@ -8,7 +8,9 @@ static site in `pdfcraft-web-/`:
| `index.html` | The page. It loads everything through relative URLs. |
| `pdfcraft-web-.js` | wasm-bindgen glue (generated, ES module) |
| `pdfcraft-web-_bg.wasm` | The app, about 13 MB, or 5 MB with compression |
+| `pdfcraft-ocr-worker-.js`, `_bg.wasm`, `_loader.js` | The text recognition worker (about 3 MB), started only when OCR runs |
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
+| `models/` | The text recognition (OCR) models, about 12 MB, with their licences (CC-BY-SA-4.0) |
There is no server-side code. Upload the folder's contents anywhere that serves static files.
@@ -55,6 +57,26 @@ location /pdfcraft/ {
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
+## Text recognition (OCR)
+
+Scan & OCR ▸ Recognize Text downloads `models/text-detection.onnx` and
+`models/text-recognition.onnx`, relative to the page, the first time it is opened in a visit.
+Keep `models/` beside `index.html`. The app loads only the files whose SHA-256 matches
+`ATTRIBUTION.toml`, so a wrong file fails like a missing one. Their file names carry no content
+hash, so the app asks the server before using a cached copy (`no-cache`): the server must answer
+that check correctly (an ETag, or a `Last-Modified` that changes whenever the file does). Without
+the models, everything else works and the Recognize Text dialog says the download failed.
+
+Pages are read in Web Workers (`pdfcraft-ocr-worker-_loader.js`, a module worker), so the
+app keeps responding, and two pages are read at once on a machine with three or more cores. Each
+worker needs about 600 MB while it reads; idle workers close after a minute. The workers need no
+special headers, but a `Content-Security-Policy` must allow them (`worker-src 'self'`, or
+`script-src 'self'`). Where they can't start, the app reads the pages itself, a page at a time,
+and doesn't respond while each page is read.
+
+From a source build, `trunk build --release` doesn't fetch the models. Add them with
+`cargo xtask models dist/web/models` (after the build, which replaces `dist/web`).
+
## Embedding in a page (iframe)
```html
@@ -136,6 +158,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
- **The event and message names are the interface.** `pdfcraft:ready`, `pdfcraft:open`, `pdfcraft:save`, `pdfcraft:dirty` and `pdfcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
+### Driving it from the page (`window.pdfcraft`)
+
+`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. The API is installed after `pdfcraft:ready` is posted, and `window.pdfcraft` is the canvas element before that, so wait for a function rather than for the name: `await new Promise(r => { const t = setInterval(() => typeof pdfcraft.request === "function" && (clearInterval(t), r()), 50); })`.
+
+```js
+const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog
+await pdfcraft.request("ui.set", { key: "show_page_edge_shadows", value: "false" });
+const { data } = await pdfcraft.screenshot(); // the window as PNG (base64)
+```
+
+Opening a PDF, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
+
## Renderer selection and fallback flags
PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
@@ -150,3 +184,7 @@ PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls
For example: `