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#" "#; +"# + .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 = 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!( + "{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##" + + + +"## + .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##" + + + +"##, + 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 { + layout_inner(tpl, data, None) +} + +/// [`layout`], with what scripts set (`ov`) applied to page-master content page by page: a +/// field hidden on the second page made from a page area is hidden there only. The rest of the +/// template takes `ov` before this ([`crate::script::apply_overrides`]). +pub fn layout_with_overrides(tpl: &Template, data: Option<&DataNode>, ov: &crate::script::Overrides) -> Result { + layout_inner(tpl, data, Some(ov)) +} + +fn layout_inner(tpl: &Template, data: Option<&DataNode>, overrides: Option<&crate::script::Overrides>) -> Result { let areas = match &tpl.root.page_set { Some(ps) if !ps.areas.is_empty() => ps.areas.clone(), _ => vec![PageArea { @@ -179,10 +240,11 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result occur_min: 0, }], }; - let mut values = HashMap::new(); + let values = collect_values(tpl, data); // One copy of the root for the whole layout: measurements are cached by node address. let root_node = Node::Subform(Box::new(tpl.root.clone())); - collect_values(std::slice::from_ref(&root_node), &mut values, 0); + let mut template_names = HashSet::new(); + collect_names(std::slice::from_ref(&root_node), &mut template_names, 0); let mut l = Layouter { tpl, data, @@ -191,6 +253,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result pages: Vec::new(), cur: None, names: HashMap::new(), + taken: HashSet::new(), + template_names, values, warnings: Vec::new(), warned: HashSet::new(), @@ -199,8 +263,14 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result image_bytes: 0, measured: RefCell::new(HashMap::new()), measures: Cell::new(0), + drawn_chars: Cell::new(0), leaves: Vec::new(), + splitting: None, + field_border: None, + views: Views::ALL, frames: Vec::new(), + overrides, + master_copies: Vec::new(), }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; l.new_page()?; @@ -214,6 +284,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result for p in &mut l.pages { fill_page_count(&mut p.items, &count); } + #[cfg(test)] + LAID_OUT_ITEMS.with(|c| c.set(l.items)); Ok(Form { pages: l.pages, fields: l.fields, warnings: l.warnings }) } @@ -223,6 +295,7 @@ fn fill_page_count(items: &mut [Item], count: &str) { match it { Item::Text(s) if s.text.contains(PAGE_COUNT_MARK) => s.text = s.text.replace(PAGE_COUNT_MARK, count), Item::Turned(t) => fill_page_count(&mut t.items, count), + Item::Only { item, .. } => fill_page_count(std::slice::from_mut(item.as_mut()), count), _ => {} } } @@ -233,3 +306,9 @@ fn fill_page_count(items: &mut [Item], count: &str) { thread_local! { pub(crate) static EMBED_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +// The item count the last layout on this thread ended with, against `MAX_ITEMS`. +#[cfg(test)] +thread_local! { + pub(crate) static LAID_OUT_ITEMS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index 54259ede47..1952bc7982 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -1,31 +1,107 @@ -use super::items::{Action, Item, Rounded}; -use super::{HashMap, Layout, MAX_DEPTH, Node, Rect, Script, date_letters, iso_to_pattern}; +use super::items::{Action, BorderShape, Item, Rounded, Widget, WidgetBorder}; +use super::{DataNode, DataPath, HashMap, HashSet, Layout, MAX_DEPTH, Node, Rect, Script, Template, Ui, data_path, date_letters, iso_to_pattern}; +use crate::data::{data_date_letters, data_to_iso, fields_with_ids}; -pub(super) fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { +/// The value of each field with an `id`, for the rich text that embeds it (floating fields, +/// XFA 3.3 pp. 221–222, p. 1222), with its data path and its template default: the data's value +/// as the field shows it (a multi-select list's picks on separate lines), else the default. +pub(super) fn collect_values(tpl: &Template, data: Option<&DataNode>) -> HashMap { + let mut out = HashMap::new(); + for (id, som, f) in fields_with_ids(tpl) { + let path = data_path(tpl, &som); + // A date shows in its picture clause, as the field does (p. 221): the display picture, + // else the one it is edited in. + let date = (f.ui == Ui::DateTimeEdit) + .then(|| f.display_picture.as_deref().or(f.picture.as_deref()).and_then(date_letters)) + .flatten() + .map(|pattern| (pattern, data_date_letters(f))); + let mut src = EmbedSource { path, default: f.value.plain().unwrap_or_default(), value: String::new(), date }; + src.value = src.shown(data_value(data, &src.path)); + out.insert(id.to_string(), src); + } + out +} + +/// What an embedded field shows: see [`collect_values`]. +pub(super) struct EmbedSource { + pub(super) path: DataPath, + pub(super) default: String, + /// The value of its first instance, as shown. + pub(super) value: String, + /// A date field's display pattern and data pattern. + date: Option<(String, Option)>, +} + +impl EmbedSource { + /// What the field shows for the data's value `data` (the default without one). + pub(super) fn shown(&self, data: Option) -> String { + match (data, &self.date) { + (Some(v), Some((pattern, data_pattern))) => iso_to_pattern(&data_to_iso(&v, data_pattern.as_deref()), pattern), + (Some(v), None) => v, + (None, _) => self.default.clone(), + } + } +} + +/// The names the template's fields, exclusion groups and the groups' member fields take as +/// written ([`clean_name`]): a name the layout makes up for a repeated field (`Cell4` → +/// `Cell4_2`) must not be one of these, or two fields would share it. (A group's radio buttons +/// are the group's PDF field; a member that isn't one is a PDF field with its own name.) +pub(super) fn collect_names(nodes: &[Node], out: &mut HashSet, depth: usize) { if depth > MAX_DEPTH { return; } for n in nodes { match n { Node::Field(f) => { - if let Some(id) = &f.common.id { - out.insert(id.clone(), f.value.plain().unwrap_or_default()); + if let Some(name) = &f.common.name { + out.insert(clean_name(name)); } } - Node::Subform(s) => { - collect_values(&s.children, out, depth + 1); - if let Some(ps) = &s.page_set { - for a in &ps.areas { - collect_values(&a.items, out, depth + 1); + Node::ExclGroup(g) => { + if let Some(name) = &g.common.name { + out.insert(clean_name(name)); + } + // Its members are drawn as fields with their own names (`draw_field`), so a + // repeat drawn before the group must not take one. + for f in &g.fields { + if let Some(name) = &f.common.name { + out.insert(clean_name(name)); } } } - Node::Area(a) => collect_values(&a.children, out, depth + 1), - Node::ExclGroup(_) | Node::Draw(_) => {} + Node::Subform(s) => { + collect_names(&s.children, out, depth + 1); + if let Some(ps) = &s.page_set { + for a in &ps.areas { + collect_names(&a.items, out, depth + 1); + } + } + } + Node::Area(a) => collect_names(&a.children, out, depth + 1), + Node::Draw(_) => {} } } } +/// `path` with the instance numbers `scope` gives the same names, segment by segment from the +/// root, up to where the two part: the instance of a repeating subform the text is laid out in. +pub(super) fn instance_path(path: &DataPath, scope: &DataPath) -> DataPath { + let mut at = path.clone(); + for (seg, (name, index)) in at.iter_mut().zip(scope) { + if seg.0 != *name { + break; + } + seg.1 = *index; + } + at +} + +/// The value a field shows from `data` at `path`: unless it is blank, the data's wins. +pub(super) fn data_value(data: Option<&DataNode>, path: &DataPath) -> Option { + data.and_then(|d| d.value_at(path)).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) +} + pub(super) fn clamp_opt(v: f64, min: Option, max: Option) -> f64 { let mut v = v; if let Some(m) = min { @@ -53,12 +129,19 @@ pub(super) fn solid_extent(item: &Item) -> Option<(f64, f64)> { Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), Item::Turned(t) => Some(t.extent), Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, + Item::Only { item, .. } => solid_extent(item), } } /// `item` as the part above `cut` and the part below: solid items go whole to the side their /// top is on, fills and lines crossing the cut are cut in two. pub(super) fn divide(item: Item, cut: f64) -> (Option, Option) { + if let Item::Only { views, item } = item { + // Each part shows where the whole did. + let wrap = |i: Item| Item::Only { views, item: Box::new(i) }; + let (a, b) = divide(*item, cut); + return (a.map(wrap), b.map(wrap)); + } if let Some((top, _)) = solid_extent(&item) { return if top < cut - 0.01 { (Some(item), None) } else { (None, Some(item)) }; } @@ -108,8 +191,64 @@ pub(super) fn divide(item: Item, cut: f64) -> (Option, Option) { } } +/// How much of a widget a page break must leave above it for the field to split there: a line of +/// its text. +pub(super) fn split_line(w: &Widget) -> f64 { + (w.face.size * 1.2).max(1.0) +} + +/// The rest of an empty field's widget below a page break: its box and the look of its border. +pub(super) type WidgetRest = (Rect, WidgetBorder); + +/// An empty field's widget cut at `cut`: the widget down to the cut, and the rest of its box below +/// it to go on in the following content areas ([`widget_box`] draws each piece of it). A widget the +/// cut doesn't cross stays whole on its side, as does one with no room above the cut for a line of +/// its text: the second item is the widget when it all goes below. +pub(super) fn split_widget(item: Item, cut: f64) -> (Option, Option, Option) { + let Item::Widget(mut w) = item else { return (Some(item), None, None) }; + let line = split_line(&w); + if w.rect.bottom() <= cut + 0.01 { + return (Some(Item::Widget(w)), None, None); + } + if w.rect.y + line > cut { + return (None, Some(Item::Widget(w)), None); + } + let rest = Rect::new(w.rect.x, cut, w.rect.w, w.rect.bottom() - cut); + w.rect.h = cut - w.rect.y; + let border = w.border.clone(); + (Some(Item::Widget(w)), None, Some((rest, border))) +} + +/// One piece of a split widget's box at `rect`: its fill, and its border closed on every side (a +/// border's default `break="close"`, XFA 3.3 pp. 629–630). +pub(super) fn widget_box(rect: Rect, border: &WidgetBorder) -> Vec { + let mut items = Vec::new(); + if let Some(color) = border.fill { + items.push(Item::Fill { rect, color }); + } + if let Some(color) = border.color { + let width = border.width.max(0.0); + let i = width / 2.0; + let (x0, y0, x1, y1) = (rect.x + i, rect.y + i, rect.right() - i, rect.bottom() - i); + // The border's corners in drawing order: each edge joins two in a row. + let corners: &[(f64, f64)] = match border.shape { + BorderShape::Full => &[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)], + BorderShape::Underline => &[(x0, y1), (x1, y1)], + BorderShape::None => &[], + }; + items.extend(corners.windows(2).filter_map(|edge| match *edge { + [from, to] => Some(Item::Line { from, to, width, color, dashed: false }), + _ => None, + })); + } + items +} + /// `item` moved by (`dx`, `dy`). pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item { + if let Item::Only { views, item: inner } = item { + return Item::Only { views, item: Box::new(moved(*inner, dx, dy)) }; + } match &mut item { Item::Fill { rect, .. } | Item::Image { rect, .. } => { rect.x += dx; @@ -136,6 +275,8 @@ pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item { t.extent = (t.extent.0 + dy, t.extent.1 + dy); t.items = std::mem::take(&mut t.items).into_iter().map(|i| moved(i, dx, dy)).collect(); } + // Taken apart above. + Item::Only { .. } => {} } item } diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 02659c812e..6007005a3d 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -1,17 +1,32 @@ use super::common::clean_name; use super::items::{Item, Rounded, Turned}; use super::{ - Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Presence, Rect, SomIndices, Stroke, + Arc, Border, Ctx, Draw, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Rect, SomIndices, Stroke, Subform, Value, XfaError, data_path, text, }; impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── drawing + /// Draw table row `row` at `rect` in columns `cols`, shown where it and what it is inside say + /// ([`Common::views`]). pub(super) fn draw_row(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if depth > MAX_DEPTH { return Ok(()); } + self.warn_other_views(&row.common); + let views = self.views.and(row.common.views()); + if !views.any() { + return Ok(()); + } + let outer = std::mem::replace(&mut self.views, views); + let drawn = self.draw_row_cells(row, cols, rect, ctx, depth, sib); + self.views = outer; + drawn + } + + /// [`Self::draw_row`]'s border and cells, once where the row shows is set. + pub(super) fn draw_row_cells(&mut self, row: &Subform, cols: &[f64], rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if let Some(b) = &row.common.border { self.draw_border(b, rect)?; } @@ -33,7 +48,7 @@ impl Layouter<'_> { if !c.presence.occupies() { return Ok(()); } - let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + let (w, h) = self.size_at(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1, sib); let (x, y) = c.top_left(w, h); let rect = Rect::new(inner.x + x, inner.y + y, w, h); let start = self.item_count(); @@ -58,14 +73,27 @@ impl Layouter<'_> { } } + /// Draw `node` at `rect`, shown where it and what it is inside say ([`Common::views`]): an + /// invisible object that `relevant` prints is drawn for print only, and one shown nowhere + /// draws nothing. pub(super) fn draw_node(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if depth > MAX_DEPTH { return Ok(()); } let c = node.common(); - if c.presence == Presence::Invisible { + self.warn_other_views(c); + let views = self.views.and(c.views()); + if !views.any() { return Ok(()); } + let outer = std::mem::replace(&mut self.views, views); + let drawn = self.draw_node_in(node, rect, ctx, depth, sib); + self.views = outer; + drawn + } + + /// [`Self::draw_node`] once where the node shows is set. + pub(super) fn draw_node_in(&mut self, node: &Node, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { match node { Node::Draw(d) => { let start = self.item_count(); @@ -115,23 +143,53 @@ impl Layouter<'_> { if !k.common().presence.occupies() { continue; } - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); // A repeating subform here (a table in an area of a positioned page, - // say) gets its instances, as in a flowed one, one under another. - for instance in 0..self.instances(k, &ctx.som) { - self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sibs.at(i) + instance)?; + // say) gets its instances, as in a flowed one, one under another, + // each as tall as its own content, and the rows past them are + // reported as there. + let instances = self.laid_out_instances(k, &ctx.som); + for instance in 0..instances { + let sib = sibs.at(i).saturating_add(instance); + let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, sib); + self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, sib)?; y += h; } } } Layout::LrTb | Layout::RlTb => { // A repeating subform here (rows of fields under a heading) gets its - // instances, as in the other layouts. + // instances, as in the other layouts, and the rows past them are reported. + for k in s.children.iter().filter(|k| k.common().presence.occupies()) { + self.laid_out_instances(k, &ctx.som); + } let (placed, _) = self.pack_instances(&s.children, inner.w, &ctx, depth, &ctx.som); - for (i, instance, r) in placed { - if let Some(k) = s.children.get(i) { + // The sibling indices of each `subformSet` the steps go into, found once per + // set (known by where its children are); the subform's own are `sibs`. + let mut set_sibs: std::collections::HashMap = std::collections::HashMap::new(); + for (at, r) in placed { + // Down the steps: a member of a `subformSet` takes the set's SOM path. + let (mut nodes, mut node_ctx, mut node_depth) = (s.children.as_slice(), ctx.clone(), depth + 1); + let mut target = None; + for (step, &(i, instance)) in at.iter().enumerate() { + let Some(k) = nodes.get(i) else { break }; + let index = if step == 0 { + sibs.at(i) + } else { + set_sibs.entry(nodes.as_ptr() as usize).or_insert_with(|| SomIndices::of(nodes)).at(i) + }; + let sib = index + instance; + match k { + Node::Subform(set) if set.set && step + 1 < at.len() => { + node_ctx = node_ctx.with_som(&set.common, sib); + nodes = &set.children; + node_depth += 1; + } + _ => target = Some((k, sib)), + } + } + if let Some((k, sib)) = target { let x = if s.layout == Layout::RlTb { inner.right() - r.x - r.w } else { inner.x + r.x }; - self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &ctx, depth + 1, sibs.at(i) + instance)?; + self.draw_node(k, Rect::new(x, inner.y + r.y, r.w, r.h), &node_ctx, node_depth, sib)?; } } } @@ -146,11 +204,16 @@ impl Layouter<'_> { if let Node::Subform(r) = k && r.layout == Layout::Row { - let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1); - self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib)?; - y += h; + // A repeating row (a table in a positioned subform, say) gets its + // instances one under another, as in a flowed table (XFA 3.3, + // "Tables", p. 327), and the rows past them are reported. + for instance in 0..self.laid_out_instances(k, &ctx.som) { + let h = self.row_height(r, &cols, inner.w, &ctx, depth + 1, csib.saturating_add(instance)); + self.draw_row(r, &cols, Rect::new(inner.x, y, inner.w, h), &ctx, depth + 1, csib + instance)?; + y += h; + } } else { - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, csib); self.draw_node(k, Rect::new(inner.x, y, w, h), &ctx, depth + 1, csib)?; y += h; } @@ -222,19 +285,50 @@ impl Layouter<'_> { Ok(()) } - pub(super) fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para) -> Result<(), XfaError> { + /// Draw `v` in `rect`; `scope` is the SOM of what holds it, for the fields it embeds. + pub(super) fn draw_value_text(&mut self, v: &Value, rect: Rect, font: &Font, para: &Para, scope: &str) -> Result<(), XfaError> { let rich = match v { Value::Text(t) => text::plain(t), Value::Rich(r) => r.clone(), _ => return Ok(()), }; let face = Face::of(font); + // Each pass over the text — a re-measure that shrinks it, or the final layout — counts + // its characters against the draw budget, so large text drawn in many instances stops. + let cost = rich.plain().chars().count().max(1); + // The values of embedded fields are laid out with it, so each pass counts those too: a + // short text embedding a long value is as much work as the value written out. + let embed = |id: &str, max: usize| { + let v = self.embed_in(id, max, scope); + self.drawn_chars.set(self.drawn_chars.get().saturating_add(v.chars().count())); + v + }; // Text that doesn't fit the height it was given (the template's face is narrower than // the standard one drawn in its place, so it wraps to more lines) is drawn smaller, // down to 70%, rather than over what lies below. - let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0; - let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) }; - let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id, max| self.embed(id, max)); + // A pass is not started once the budget is spent, and one that spends it stops the rest. + let fits = |k: f64| -> Result { + self.check_drawn_chars()?; + self.drawn_chars.set(self.drawn_chars.get().saturating_add(cost)); + let fit = text::measure_height(&rich, rect.w / k, para, &face, &embed) <= rect.h / k + 1.0; + self.check_drawn_chars()?; + Ok(fit) + }; + let mut scale = 1.0; + if rect.h >= 4.0 && !fits(1.0)? { + scale = 0.7; + for k in [0.95, 0.9, 0.85, 0.8, 0.75] { + if fits(k)? { + scale = k; + break; + } + } + } + self.drawn_chars.set(self.drawn_chars.get().saturating_add(cost)); + self.check_drawn_chars()?; + let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &embed); + // The layout itself expands the embedded values once more. + self.check_drawn_chars()?; if scale < 1.0 { for s in &mut block.spans { s.x = rect.x + (s.x - rect.x) * scale; @@ -245,6 +339,8 @@ impl Layouter<'_> { self.push_block(block) } + /// Draw a `draw` element (static text, an image, a line, a rectangle or an arc) in `rect`, + /// inside its border and margin. fn draw_draw(&mut self, d: &Draw, rect: Rect, ctx: &Ctx) -> Result<(), XfaError> { if let Some(b) = &d.common.border { self.draw_border(b, rect)?; @@ -252,8 +348,8 @@ impl Layouter<'_> { let ctx = ctx.child(&d.common); let inner = rect.inset(&d.common.margin); match &d.value { - Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para), - Value::Image { content_type, data } => self.draw_image(data, content_type, inner), + Value::Text(_) | Value::Rich(_) => self.draw_value_text(&d.value, inner, &ctx.font, &ctx.para, &ctx.som), + Value::Image { content_type, data, .. } => self.draw_image(data, content_type, inner), Value::Line { slope_up, edge } => { if !edge.visible() { return Ok(()); @@ -270,6 +366,10 @@ impl Layouter<'_> { } } + /// Draw exclusion group `g` at `rect`: its border, then each of its buttons where its layout + /// puts it, as one radio group, the one its data selects turned on. A group that must be + /// answered makes each button required. `sib` is the group's index among same-named + /// siblings, for its SOM path. fn draw_group(&mut self, g: &ExclGroup, rect: Rect, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if let Some(b) = &g.common.border { self.draw_border(b, rect)?; @@ -279,8 +379,10 @@ impl Layouter<'_> { let selected: Option = self.data.and_then(|d| d.text_at(&data_path(self.tpl, &ctx.som))).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()); let inner = rect.inset(&g.common.margin); - let group = self.unique_name(g.common.name.as_deref().unwrap_or("group")); + let group = self.unique_name(g.common.name.as_deref().unwrap_or("group"), g.common.name.is_none()); let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); + // Each field is measured under the SOM index it is drawn at. + let sibs = SomIndices::of(&nodes); let placed: Vec<(usize, Rect)> = match g.layout { Layout::Tb => { let mut y = 0.0; @@ -289,7 +391,7 @@ impl Layouter<'_> { if !k.common().presence.occupies() { continue; } - let (w, h) = self.size_of(k, inner.w, &ctx, depth + 1); + let (w, h) = self.size_at(k, inner.w, &ctx, depth + 1, sibs.at(i)); out.push((i, Rect::new(0.0, y, w, h))); y += h; } @@ -301,20 +403,38 @@ impl Layouter<'_> { .filter(|(_, k)| k.common().presence.occupies()) .map(|(i, k)| { let c = k.common(); - let (w, h) = self.size_of(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1); + let (w, h) = self.size_at(k, (inner.w - c.x.unwrap_or(0.0)).max(1.0), &ctx, depth + 1, sibs.at(i)); let (x, y) = c.top_left(w, h); (i, Rect::new(x, y, w, h)) }) .collect(), _ => self.pack(&nodes, inner.w, &ctx, depth).0, }; - let sibs = SomIndices::of(&nodes); for (i, r) in placed { if let Some(f) = g.fields.get(i) { + // Each button shows where it and the group say ([`Common::views`]), as any field + // drawn through [`Self::draw_node`] does. + self.warn_other_views(&f.common); + let views = self.views.and(f.common.views()); + if !views.any() { + continue; + } let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| (i + 1).to_string()); let tip = f.tooltip.clone().or_else(|| g.tooltip.clone()); let chosen = selected.as_deref() == Some(on.as_str()); - self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i))?; + // A group that must be answered makes each of its buttons required, as the group + // field they make is. + let required; + let f = if g.required && !f.required { + required = Field { required: true, ..f.clone() }; + &required + } else { + f + }; + let outer = std::mem::replace(&mut self.views, views); + let drawn = self.draw_field(f, Rect::new(inner.x + r.x, inner.y + r.y, r.w, r.h), &ctx, Some((&group, &on, tip, chosen)), sibs.at(i)); + self.views = outer; + drawn?; } } Ok(()) @@ -336,12 +456,26 @@ impl Layouter<'_> { self.push(Item::Image { rect: fitted, data: Arc::new(data.to_vec()), content_type: content_type.to_string() }) } - /// A field name made from `base` that no earlier field of the form has: the first keeps it, - /// later ones get `_2`, `_3`, …. - pub(super) fn unique_name(&mut self, base: &str) -> String { + /// A field name made from `base` that no other field of the form has: the first keeps it, + /// later ones get `_2`, `_3`, …, skipping a name another field already has or that the + /// template gives another field (a template field named `Cell4_2` keeps it, and the second + /// `Cell4` becomes `Cell4_3`). A `fallback` base, the name an unnamed field or group gets + /// (`field`, `group`), is made up too: it doesn't take a name the template gives another + /// field either, and a template field named `field` still gets it. + pub(super) fn unique_name(&mut self, base: &str, fallback: bool) -> String { let base = clean_name(base); - let n = self.names.entry(base.clone()).or_insert(0); - *n += 1; - if *n == 1 { base } else { format!("{base}_{n}") } + // The template's own name, while no other field has it. + if !fallback && self.taken.insert(base.clone()) { + return base; + } + loop { + let n = self.names.entry(base.clone()).or_insert(0); + *n = n.saturating_add(1); + let candidate = if *n == 1 { base.clone() } else { format!("{base}_{n}") }; + let reserved = (fallback || *n > 1) && self.template_names.contains(&candidate); + if !reserved && self.taken.insert(candidate.clone()) { + return candidate; + } + } } } diff --git a/crates/xfa/src/layout/field.rs b/crates/xfa/src/layout/field.rs index cc15f16788..b2914fae79 100644 --- a/crates/xfa/src/layout/field.rs +++ b/crates/xfa/src/layout/field.rs @@ -1,8 +1,8 @@ use super::common::{button_action, date_pattern}; use super::items::{BorderShape, Item, Widget, WidgetBorder, WidgetKind}; use super::{ - Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, XfaError, - data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text, + Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, Views, + XfaError, data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text, }; impl Layouter<'_> { @@ -20,12 +20,17 @@ impl Layouter<'_> { if let Some(b) = &f.common.border && f.ui != Ui::Button { + let start = self.item_count(); self.draw_border(b, rect)?; + self.field_border = Some((start, self.item_count())); } let fctx = ctx.child(&f.common); let inner = rect.inset(&f.common.margin); let (mut ui, mut cap_rect) = (inner, None); - if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, ctx) { + // A caption without a font of its own is drawn in the field's (XFA 3.3, "Formatting Text + // That Appears as Fixed or Variable Content", p. 43), in the paragraph around the field, + // as it is measured. + if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, &fctx, &ctx.para) { let reserve = auto_check_reserve(f, placement, inner.w, Some(inner.h)).unwrap_or(reserve); let r = reserve.max(0.0); match placement { @@ -50,9 +55,9 @@ impl Layouter<'_> { if let (Some(cr), Some(cap)) = (cap_rect, &f.caption) && cap.presence == Presence::Visible { - let font = ctx.font.apply(&cap.font); + let font = fctx.font.apply(&cap.font); let para = ctx.para.apply(&cap.para); - self.draw_value_text(&cap.value, cr, &font, ¶)?; + self.draw_value_text(&cap.value, cr, &font, ¶, &ctx.som)?; } let som_path = || { if fctx.som.is_empty() { @@ -95,7 +100,7 @@ impl Layouter<'_> { let box_ = ui.inset(&f.ui_margin); match (&from_data, &f.value) { (Some(d), _) => self.draw_image(d, "", box_)?, - (None, Value::Image { content_type, data }) => self.draw_image(data, content_type, box_)?, + (None, Value::Image { content_type, data, .. }) => self.draw_image(data, content_type, box_)?, _ => {} } // Then the field itself, which clicking gives a new picture. @@ -112,7 +117,7 @@ impl Layouter<'_> { if !value.trim().is_empty() { let mut para = fctx.para.clone(); para.h_align = HAlign::Center; - self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, ¶)?; + self.draw_value_text(&Value::Text(value), ui.inset(&f.ui_margin), &fctx.font, ¶, &fctx.som)?; } return Ok(()); } @@ -120,7 +125,7 @@ impl Layouter<'_> { } if f.access == Access::NonInteractive && f.ui != Ui::Button { // Shown as text, not as a field. - return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para); + return self.draw_value_text(&f.value, ui.inset(&f.ui_margin), &fctx.font, &fctx.para, &fctx.som); } // A text field that grew past what a content area holds can't be one widget: its text // is drawn as lines the page breaks fall between, and stays what the data holds. @@ -145,14 +150,14 @@ impl Layouter<'_> { } let face = match (&f.ui, &f.caption) { // Buttons show their caption in the caption's font. - (Ui::Button, Some(cap)) => Face::of(&ctx.font.apply(&cap.font)).unscaled(), + (Ui::Button, Some(cap)) => Face::of(&fctx.font.apply(&cap.font)).unscaled(), _ => Face::of(&fctx.font).unscaled(), }; let h_align = match (&f.ui, &f.caption) { (Ui::Button, Some(cap)) => ctx.para.apply(&cap.para).h_align, _ => fctx.para.h_align, }; - let name = self.unique_name(f.common.name.as_deref().unwrap_or("field")); + let name = self.unique_name(f.common.name.as_deref().unwrap_or("field"), f.common.name.is_none()); let som = som_path(); let read_only = matches!(f.access, Access::ReadOnly | Access::Protected); // The data's value wins over the template's default. @@ -263,6 +268,9 @@ impl Layouter<'_> { action, items, no_scroll: f.no_scroll, + // `push` sets where it shows. + views: Views::ALL, + required: f.required, }))) } } diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 8f656d7eaf..89134f611f 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,20 +1,45 @@ -use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; +use super::common::{WidgetRest, clamp_opt, divide, is_row, moved, solid_extent, split_line, split_widget, splittable, widget_box}; use super::items::Item; -use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; +use super::{ + Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_INSTANCES, MAX_MEASURES, Node, Rect, SomIndices, Splitting, Subform, XfaError, data_path, + instance_count, +}; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, /// in the next when it fits there, and across as many as it takes when it is taller than a /// content area (a positioned or fixed-height subform holding more than a page). - fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + fn flow_block(&mut self, node: &Node, x: f64, size: (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + self.place_block(node, x, size, ctx, depth, sib, true) + } + + /// [`Self::flow_block`]; `may_split` is false when a field that might have been split turned + /// out not to be (see below), to place it as any other block. + #[allow(clippy::too_many_arguments)] + fn place_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize, may_split: bool) -> Result<(), XfaError> { let (rect, y, _) = self.cursor(); - if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { + // A field a page break may split starts here, and what doesn't fit goes on in the next + // content area (XFA 3.3 pp. 291–292), rather than the whole field moving there. + let splits = may_split && matches!(node, Node::Field(f) if f.splits_at_page_break()); + // A growable flowing subform (`lr-tb`/`rl-tb`, no `h`) is split where its objects allow + // ("splittable: in margins and where consensus exists among contained objects", XFA 3.3 + // p. 1520), so it starts here and the rest goes on in the next content area, as one + // taller than a content area does. Anything else that fits one moves there whole. + let flows_on = matches!(node, Node::Subform(s) if matches!(s.layout, Layout::LrTb | Layout::RlTb) && s.common.h.is_none()); + if !splits && !flows_on && y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { self.make_room(h, 0.0)?; } let (rect, y, _) = self.cursor(); let first = self.pages.last().map_or(0, |p| p.items.len()); + // What drawing it changes besides the page, to undo if it then isn't split: only a field that + // doesn't fit here can turn out so. The item count goes back with the items taken off the + // page; the measurement budget doesn't, as that work was done. (A text field draws no + // picture, so the picture budget is untouched.) + let undo = (splits && y + h > rect.bottom() + 0.01).then(|| (self.fields, self.names.clone(), self.taken.clone(), self.items)); self.leaves.clear(); + self.field_border = None; self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; + let border_items = self.field_border.take(); let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); // What was drawn, when it is lower than the block's own height says (content that // outgrows a fixed height) and ends off the page. @@ -32,7 +57,60 @@ impl Layouter<'_> { self.place(h); return Ok(()); } - self.split_block(first, y, drawn) + let Some(som) = (if splits { self.splittable_widget_since(first, rect.bottom() - self.frames_bottom()) } else { None }) else { + if let Some((fields, names, taken, items)) = undo { + // Not split after all (a value, or no room for a line of it here): undone, and + // placed as before, moving to the next content area whole. + if let Some(p) = self.pages.last_mut() { + p.items.truncate(first); + } + self.fields = fields; + self.names = names; + self.taken = taken; + self.items = items; + self.leaves.clear(); + return self.place_block(node, x, (w, h), ctx, depth, sib, false); + } + return self.split_block(first, y, drawn, flows_on); + }; + // The field breaks into pieces, each with the field's border all round (a border's + // default `break="close"`, XFA 3.3 pp. 629–630): the border drawn round the whole field + // comes out, to be drawn round each piece once the break is known. + self.leaves.clear(); + let border = match (node, border_items) { + (Node::Field(f), Some((start, end))) if first <= start => { + if let Some(p) = self.pages.last_mut() + && let Some(drawn) = p.items.get(start..end) + && !drawn.is_empty() + { + p.items.drain(start..end); + } + f.common.border.as_ref() + } + _ => None, + }; + self.splitting = Some(Splitting { som, rect: Rect::new(x, y, w, h), pieces: Vec::new() }); + let split = self.split_block(first, y, drawn, false); + let pieces = self.splitting.take().map(|s| s.pieces).unwrap_or_default(); + split?; + if let Some(b) = border { + // The last piece first: a piece's place on its page stays where it was found. + for (page, at, piece) in pieces.into_iter().rev() { + self.insert_border(b, piece, page, at)?; + } + } + Ok(()) + } + + /// The SOM path of the empty widget drawn on the last page since item `first`, when the page + /// break at `bottom` leaves room above it for a line of its text: only then is the field split. + /// A value can't run on from one widget into the next piece, and a field with no line on this + /// page moves on whole. + fn splittable_widget_since(&self, first: usize, bottom: f64) -> Option { + self.pages.last()?.items.get(first..)?.iter().find_map(|item| match item { + Item::Widget(w) => (w.value.as_deref().is_none_or(|v| v.trim().is_empty()) && w.rect.y + split_line(w) <= bottom).then(|| w.som.clone()), + _ => None, + }) } /// Remember that the items the last page got since `start` are one field or draw at @@ -51,8 +129,11 @@ impl Layouter<'_> { /// Spread the items the last page holds from index `first` on (a block drawn from `top` to /// `end`, past the bottom of its content area) over this and the following content areas. /// Breaks fall between text, pictures and fields, never through one that fits in an area; - /// fills and lines are cut at the break. - fn split_block(&mut self, first: usize, top: f64, end: f64) -> Result<(), XfaError> { + /// fills and lines are cut at the break. A flowing subform (`flows_on`: one [`Self::flow_block`] + /// splits where it would otherwise move whole) breaks the flowed subforms round it there too + /// ([`Self::break_frames`]), so they keep their border; anything else just moves on, and + /// those subforms are drawn without theirs ([`Self::finish_frame`]). + fn split_block(&mut self, first: usize, top: f64, end: f64, flows_on: bool) -> Result<(), XfaError> { let items: Vec = match self.pages.last_mut() { Some(p) if first <= p.items.len() => p.items.split_off(first), _ => return Ok(()), @@ -68,40 +149,78 @@ impl Layouter<'_> { slots.fill(Some((top, bottom))); } } + // The empty field being split: its widget is no solid item, so the break may go through it. + let (som, field) = match &self.splitting { + Some(s) => (Some(s.som.clone()), Some(s.rect)), + None => (None, None), + }; + let loose = |item: &Item| matches!((item, &som), (Item::Widget(w), Some(som)) if w.som == *som); let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); let origin_x = self.cursor().0.x; // An item's place on the current page is its drawn place moved by (dx, -shift). let (mut dx, mut shift) = (0.0, 0.0); let mut seg_top = top; let mut first_segment = true; + let mut widget_rest: Option = None; loop { + // Each pass rescans every item still to be placed. A block split across very many + // content areas, and so across very many pages, would do this once per page, costing + // time quadratic in its items; the still-unplaced count spends the work budget so a + // crafted template stops rather than run for seconds before the page limit catches it. + self.measures.set(self.measures.get().saturating_add(rest.len())); + if self.measures.get() > MAX_MEASURES { + return Err(XfaError::TooLarge("a subform is split across too many content areas to lay out".into())); + } let (rect, _, placed) = self.cursor(); - let limit = rect.bottom() + shift; - let mut cut = limit; - if rect.h < 1.0 { - // No room to split into: leave the rest here. - cut = f64::INFINITY; - } else { - // The lowest break at or above the bottom that crosses no solid item: going - // through them from the lowest top up, a break moved to an item's top can't - // cross one already passed. - let mut solid: Vec<(f64, f64)> = - rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); - solid.sort_by(|a, b| b.0.total_cmp(&a.0)); - for (t, b) in solid { - if t < cut - 0.01 && b > cut + 0.01 { - cut = t; - } + // A field being split leaves room for the bottom margins of the flowed subforms round it. + let margins = if field.is_some() { self.frames_bottom() } else { 0.0 }; + let mut limit = rect.bottom() - margins + shift; + if field.is_some() && !first_segment && limit <= seg_top + 0.01 { + // The subforms' margins leave nothing of this content area (a smaller one than + // the field started in): breaking on would leave the same nothing in each like it. + // The field goes on past their bottom margins, and from the top of the area when + // their top margins took all of it, and they, which can't hold it here, are drawn + // without their border, as round anything that runs out of them. + limit = rect.bottom() + shift; + if limit <= seg_top + 0.01 { + shift = seg_top - rect.y; + limit = rect.bottom() + shift; } - if cut <= seg_top + 0.01 { - // An item taller than what is left. Start it in a fresh area if this one - // already holds something; otherwise cut through it. - cut = if first_segment && placed && seg_top > rect.y + 0.01 { seg_top } else { limit }; + for f in &mut self.frames { + f.lost = true; } } + let cut = if rect.h < 1.0 { + // No room to split into: leave the rest here. + f64::INFINITY + } else { + let solid = rest.iter().filter(|(item, _)| !loose(item)).filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))); + lowest_break(solid, limit, seg_top, first_segment && placed && seg_top > rect.y + 0.01) + }; let mut here = Vec::new(); let mut below = Vec::new(); + // The rest of the split widget's box: a piece down to this break, closed on every + // side, and what is left of it goes on. + if let Some((r, border)) = widget_rest.take() { + let bottom = r.bottom().min(cut); + if bottom - r.y > 0.01 { + here.extend(widget_box(Rect::new(r.x, r.y, r.w, bottom - r.y), &border)); + } + if r.bottom() > cut + 0.01 { + let y = cut.max(r.y); + widget_rest = Some((Rect::new(r.x, y, r.w, r.bottom() - y), border)); + } + } for (item, kept) in rest { + if loose(&item) { + let (upper, whole, rest_box) = split_widget(item, cut); + here.extend(upper); + below.extend(whole.map(|it| (it, None))); + if rest_box.is_some() { + widget_rest = rest_box; + } + continue; + } if let Some((top, _)) = kept { if top < cut - 0.01 { here.push(item) @@ -120,10 +239,28 @@ impl Layouter<'_> { (None, None) => {} } } + // The piece of the field being split on this page, where its border goes. + if let Some(f) = field { + let (from, to) = (f.y.max(seg_top), f.bottom().min(cut)); + let page = self.pages.len().saturating_sub(1); + let at = self.pages.last().map_or(0, |p| p.items.len()); + if to - from > 0.01 + && let Some(s) = &mut self.splitting + { + s.pieces.push((page, at, Rect::new(f.x + dx, from - shift, f.w, to - from))); + // The flowed subforms round it hold this piece here, even if it draws nothing. + let here: Vec = self.frames.iter().map(|fr| self.on_area(&fr.part)).collect(); + for (fr, here) in self.frames.iter_mut().zip(here) { + fr.held |= here; + } + } + } if let Some(p) = self.pages.last_mut() { p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); } - if below.is_empty() { + // A field being split goes on to its own bottom, even when what is left of it draws + // nothing (no fill, no border). + if below.is_empty() && widget_rest.is_none() && !field.is_some_and(|f| f.bottom() > cut + 0.01) { if let Some(c) = &mut self.cur { c.y = (end - shift).min(c.rect.bottom()).max(c.y); c.placed = true; @@ -134,9 +271,29 @@ impl Layouter<'_> { rest = below; seg_top = cut; first_segment = false; - self.next_area()?; - let (rect, _, _) = self.cursor(); - shift = cut - rect.y; + let (rect, y) = if field.is_some() || flows_on { + // A field breaks the flowed subforms round it as a field moving on whole does, and + // so does a flowing subform: each closes its part at the break, after its bottom + // margin, and opens a new one, after its top margin, in the next content area. + let area_h = self.cursor().0.h; + if let Some(c) = &mut self.cur { + c.y = (cut - shift).min(c.rect.bottom()).max(c.y); + if field.is_some() { + c.placed = true; + } + } + // A flowing subform's rest that fits an area has the top margins give way to it, + // as `make_room` does. + let left = (end - cut).max(0.0); + self.break_frames(if flows_on && left <= area_h + 0.01 { left } else { 0.0 }, false)?; + let (rect, y, _) = self.cursor(); + (rect, y) + } else { + self.next_area()?; + let (rect, _, _) = self.cursor(); + (rect, rect.y) + }; + shift = cut - y; dx = rect.x - origin_x; } } @@ -147,8 +304,20 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── flow - /// Flow `node` (a subform; anything else is ignored) into the content areas. + /// Flow subform `node` (each instance the data asks for; anything else is ignored) into the + /// content areas, shown where it and what it is inside say ([`Common::views`]); hidden from + /// both, it still takes its room. pub(super) fn flow_subform(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + self.warn_other_views(node.common()); + let views = self.views.and(node.common().views()); + let outer = std::mem::replace(&mut self.views, views); + let flowed = self.flow_subform_in(node, avail, ctx, depth, sib); + self.views = outer; + flowed + } + + /// [`Self::flow_subform`] once where the subform shows is set. + pub(super) fn flow_subform_in(&mut self, node: &Node, avail: Option<(f64, f64)>, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { if depth > MAX_DEPTH { return Ok(()); } @@ -158,8 +327,7 @@ impl Layouter<'_> { self.break_frames(0.0, true)?; } // Data adds instances of a repeating subform (rows added in another viewer). - let instances = self.instances(node, &ctx.som); - self.warn_truncated(node, &ctx.som, instances); + let instances = self.laid_out_instances(node, &ctx.som); let ctx_parent = ctx; for i in 0..instances { // Every instance spends budget, so nested repeats that place nothing still stop. @@ -171,7 +339,7 @@ impl Layouter<'_> { let w = clamp_opt(sf.common.w.unwrap_or(w), sf.common.min_w, sf.common.max_w).min(w.max(1.0)); if !matches!(sf.layout, Layout::Tb | Layout::Table) || sf.common.h.is_some() { // Not splittable: one block. - let (_, h) = self.size_of(node, w, ctx_parent, depth); + let (_, h) = self.size_at(node, w, ctx_parent, depth, sib + i); self.flow_block(node, x, (w, h), ctx_parent, depth, sib + i)?; continue; } @@ -179,7 +347,7 @@ impl Layouter<'_> { // Its border and fill go round what it takes from here, a part on each content area. let framed = match self.flow_mark() { Some(part) => { - self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); + self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false, held: false }); true } None => false, @@ -202,8 +370,7 @@ impl Layouter<'_> { self.flow_subform(child, Some((inner_x, inner_w)), &ctx, depth + 1, csib)?; continue; } - let (cw, ch) = self.size_of(child, inner_w, &ctx, depth + 1); - self.flow_block(child, inner_x, (cw, ch), &ctx, depth + 1, csib)?; + self.flow_instances(child, inner_x, inner_w, &ctx, depth + 1, csib)?; } } } @@ -235,8 +402,8 @@ impl Layouter<'_> { Some(Node::Subform(l)) => Some((li, l.as_ref())), _ => None, }); - let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); let sibs = SomIndices::of(&sf.children); + let lead_h = lead.map_or(0.0, |(li, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1, sibs.at(li))); for (ci, child) in sf.children.iter().enumerate() { if !child.common().presence.occupies() { continue; @@ -246,10 +413,9 @@ impl Layouter<'_> { && row.layout == Layout::Row { // Rows added as data (in another viewer) repeat the row. - let instances = self.instances(child, &ctx.som); - self.warn_truncated(child, &ctx.som, instances); + let instances = self.laid_out_instances(child, &ctx.som); for inst in 0..instances { - let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); + let h = self.row_height(row, &cols, inner_w, ctx, depth + 1, csib.saturating_add(inst)); let led = lead.filter(|(li, _)| *li != ci); if self.make_room(h, led.map_or(0.0, |_| lead_h))? && let Some((li, l)) = led @@ -270,8 +436,23 @@ impl Layouter<'_> { self.flow_subform(child, Some((x, inner_w)), ctx, depth + 1, csib)?; continue; } - let (cw, ch) = self.size_of(child, inner_w, ctx, depth + 1); - self.flow_block(child, x, (cw, ch), ctx, depth + 1, csib)?; + self.flow_instances(child, x, inner_w, ctx, depth + 1, csib)?; + } + Ok(()) + } + + /// Flow `node`, which isn't split across content areas, as one block per instance (a + /// repeating positioned subform gets one for each row the data or its `occur` gives it, XFA + /// 3.3, "Repeating Elements using Occurrence Limits", p. 263), each as tall as its own + /// content; the data rows past them are reported. + fn flow_instances(&mut self, node: &Node, x: f64, inner_w: f64, ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + for instance in 0..self.laid_out_instances(node, &ctx.som) { + // Every instance spends budget, so nested repeats that place nothing still stop. + self.measures.set(self.measures.get().saturating_add(1)); + self.check_measures()?; + let sib = sib.saturating_add(instance); + let size = self.size_at(node, inner_w, ctx, depth, sib); + self.flow_block(node, x, size, ctx, depth, sib)?; } Ok(()) } @@ -292,19 +473,58 @@ impl Layouter<'_> { instance_count(&sf.occur, from_data) } + /// [`Self::instances`] for laying `node` out: the count every layout that repeats a subform + /// draws (flowed or positioned, top to bottom, wrapped or a table's rows), with the data rows + /// past it reported ([`Self::warn_truncated`]). + pub(super) fn laid_out_instances(&mut self, node: &Node, parent_som: &str) -> usize { + let instances = self.instances(node, parent_som); + self.warn_truncated(node, parent_som, instances); + instances + } + /// The data holds more instances of `node` than `laid_out`: say so, since calculations - /// then see only those. - fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { + /// then see only those, and say why. (The template's own counts can't ask for more: the + /// parser reads `initial` and `max` up to [`MAX_INSTANCES`].) + pub(super) fn warn_truncated(&mut self, node: &Node, parent_som: &str, laid_out: usize) { let Node::Subform(sf) = node else { return }; let from_data = match (&sf.common.name, self.data) { (Some(name), Some(d)) if sf.occur.max != Some(1) => d.count(&data_path(self.tpl, parent_som), name), _ => 0, }; - if from_data > laid_out { - self.warn_once(format!( - "{}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those)", - sf.common.name.as_deref().unwrap_or("a subform") - )); + if from_data <= laid_out { + return; } + let name = sf.common.name.as_deref().unwrap_or("a subform"); + // The template's own `occur max` is the form's rule: past it, the extra data is ignored + // (XFA 3.3, p. 336). Only the layout's own limit needs naming as a limit. + let why = if sf.occur.max.is_some_and(|m| m <= laid_out) { + String::new() + } else { + format!("; {MAX_INSTANCES} is the most one repeating subform lays out") + }; + self.warn_once(format!("{name}: the data holds {from_data} rows; only the first {laid_out} are laid out (calculations see those){why}")); + } +} + +/// The lowest break at or above `limit` that crosses none of the `solid` items' extents: going +/// through them from the lowest top up, a break moved to an item's top can't cross one already +/// passed. When that leaves nothing below `seg_top` (an item taller than what is left), the +/// item starts in a fresh area if this one already holds something (`fresh`); otherwise the +/// break cuts through it at `limit`. +fn lowest_break(solid: impl Iterator, limit: f64, seg_top: f64, fresh: bool) -> f64 { + let mut solid: Vec<(f64, f64)> = solid.filter(|e| e.0 < limit - 0.01).collect(); + solid.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut cut = limit; + for (t, b) in solid { + if t < cut - 0.01 && b > cut + 0.01 { + cut = t; + } + } + if cut > seg_top + 0.01 { + cut + } else if fresh { + seg_top + } else { + limit } } diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs index be9499cb84..c42cd1f24b 100644 --- a/crates/xfa/src/layout/items.rs +++ b/crates/xfa/src/layout/items.rs @@ -1,4 +1,4 @@ -use super::{Arc, Face, HAlign, Rect, Span}; +use super::{Arc, Face, HAlign, Rect, Span, Views}; /// How far below its baseline an underline is drawn, in the text's size: an underline is the /// level line pushed just before its span, this far below it (the PDF writer tells it apart from @@ -27,6 +27,12 @@ pub enum Item { content_type: String, }, Widget(Box), + /// An item drawn only on screen or only in print (XFA `relevant`, `presence`): written in an + /// optional content group whose usage says so. + Only { + views: Views, + item: Box, + }, /// A draw turned about its anchor. Turned(Box), } @@ -144,6 +150,10 @@ pub struct Widget { pub items: Vec, /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). pub no_scroll: bool, + /// Where the field is shown: on screen, in print, or both (its annotation flags). + pub views: Views, + /// The field must be filled in (`Required`); a radio button, when its group must be. + pub required: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -151,6 +161,8 @@ pub struct Page { pub width: f64, pub height: f64, pub items: Vec, + /// The name of the page area the page was made from (`None` for an unnamed one). + pub area: Option, } #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 7751012b8b..e8175553be 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -1,36 +1,47 @@ use super::common::clamp_opt; use super::field::auto_check_reserve; use super::{ - Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, Subform, Ui, - Value, data_path, text, + Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, SomIndices, + Subform, Ui, Value, data_path, text, }; +/// Where [`Layouter::pack_instances`] puts one object: the (child index, instance) steps to it, and +/// its place in the packed container's box. +pub(super) type Packed = (Vec<(usize, usize)>, Rect); + impl Layouter<'_> { - /// The width and height `node` takes in `avail_w`: its own size, else what its content needs - /// (containers are measured once per width and depth, and measuring stops at its caps). - pub(super) fn size_of(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + /// The width and height instance `index` of `node` under `ctx` takes in `avail_w`: its own + /// size, else what its content needs (containers are measured once per width, depth and + /// instance, and measuring stops at its caps). Instances differ when the data gives each its + /// own rows (XFA 3.3, "Tables", p. 327; Chapter 9, p. 336), so with data each is measured + /// under its own SOM path. + pub(super) fn size_at(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize, index: usize) -> (f64, f64) { let c = node.common(); let spent = self.measures.get().saturating_add(1); self.measures.set(spent); if depth > MAX_DEPTH || spent > MAX_MEASURES { return (c.w.unwrap_or(avail_w), c.h.unwrap_or(0.0)); } + let own = ctx.with_som(c, index); // Only containers recurse; their addresses are stable for the whole layout (the - // template is borrowed, the root and page areas are held by the layouter). + // template is borrowed, the root and page areas are held by the layouter). Without data + // every instance is alike, so one measurement serves them all. + let som = || if self.data.is_some() { own.som.clone() } else { String::new() }; let key = match node { - Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth)), - Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth)), + Node::Subform(s) => Some((&**s as *const Subform as usize, avail_w.to_bits(), depth, som())), + Node::Area(a) => Some((&**a as *const Area as usize, avail_w.to_bits(), depth, som())), _ => None, }; - if let Some(key) = key + if let Some(key) = &key && let Ok(cache) = self.measured.try_borrow() - && let Some(m) = cache.get(&key) + && let Some(m) = cache.get(key) && m.font == ctx.font && m.para == ctx.para { return m.size; } - let size = self.measure(node, avail_w, ctx, depth); + // A field's caption is measured in the paragraph around the field, as it is drawn. + let size = self.measure(node, avail_w, &own, &ctx.para, depth); if let Some(key) = key && let Ok(mut cache) = self.measured.try_borrow_mut() { @@ -39,15 +50,14 @@ impl Layouter<'_> { size } - /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_of`] caches it). + /// The size (width, height) of `node` within `avail_w`, uncached ([`Self::size_at`] caches it). /// Without a width, a container, a text draw or a button is as wide as its content (at most /// `avail_w`) and anything else takes `avail_w`; without a height, it is as tall as its content. - fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> (f64, f64) { + /// `ctx` holds the SOM path of the instance measured: its repeating children count their + /// instances under it, and a growing text field finds its value at it. `outer_para` is the + /// paragraph around the node, which a field's caption takes. + fn measure(&self, node: &Node, avail_w: f64, ctx: &Ctx, outer_para: &Para, depth: usize) -> (f64, f64) { let c = node.common(); - // The node's own SOM: its repeating children count their instances under it, and a - // growing text field finds its value at it. Measurements are cached per template node, - // so instance 0 stands for all of them. - let ctx = ctx.with_som(c, 0); let som = ctx.som.clone(); // A container without a width is as wide as its content (a radio group beside its // question, say), and so is a text label or a button (as wide as its caption), within @@ -58,64 +68,84 @@ impl Layouter<'_> { None => match node { Node::Subform(_) | Node::ExclGroup(_) | Node::Area(_) => { let inner = (avail_w - c.margin.horizontal()).max(1.0); - (self.content_width(node, inner, &ctx, depth) + c.margin.horizontal()).min(avail_w) + (self.content_width(node, inner, ctx, depth) + c.margin.horizontal()).min(avail_w) } Node::Draw(d) => self.natural_width(&d.value, &ctx.font, &ctx.para).map_or(avail_w, |t| (t + c.margin.horizontal()).min(avail_w)), Node::Field(f) if f.ui == Ui::Button => f .caption .as_ref() - .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para))) + .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &outer_para.apply(&cap.para))) .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)), - Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, &ctx) + c.margin.horizontal()).min(avail_w), + Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, ctx) + c.margin.horizontal()).min(avail_w), Node::Field(_) => avail_w, }, }; let w = clamp_opt(w, c.min_w, c.max_w); let h = match c.h { Some(h) => h, - None => { - let inner_w = (w - c.margin.horizontal()).max(1.0); - let content = match node { - Node::Draw(d) => self.value_height(&d.value, inner_w, &ctx), - Node::Field(f) => self.field_height(f, inner_w, &ctx), - Node::ExclGroup(g) => self.group_height(g, inner_w, &ctx, depth), - Node::Area(a) => self.positioned_height(&a.children, inner_w, &ctx, depth), - Node::Subform(s) => match s.layout { - Layout::Positioned => self.positioned_height(&s.children, inner_w, &ctx, depth), - // Each instance of a repeating child takes its height again. - Layout::Tb => s - .children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| self.size_of(k, inner_w, &ctx, depth + 1).1 * self.instances(k, &som) as f64) - .sum(), - Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, &ctx, depth, &som).1, - Layout::Table => { - let cols = self.columns(s, inner_w); - s.children - .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| match k { - Node::Subform(r) if r.layout == Layout::Row => self.row_height(r, &cols, inner_w, &ctx, depth + 1), - _ => self.size_of(k, inner_w, &ctx, depth + 1).1, - }) - .sum() - } - Layout::Row => self.row_height(s, &self.columns(s, inner_w), inner_w, &ctx, depth + 1), - }, - }; - content + c.margin.vertical() - } + None => self.content_height(node, (w - c.margin.horizontal()).max(1.0), ctx, &som, outer_para, depth) + c.margin.vertical(), }; - // A flowed subform is as tall as what it holds: other viewers do not stretch one to - // the `minH` a designer left on it. + // A subform without a height is as tall as what it holds, flowed or positioned. XFA 3.3 + // p. 847 makes `minH` its minimum; Adobe Acrobat doesn't stretch one to it (IRCC's + // IMM 5257: section 10 at `minH="58.274mm"`), and this follows Acrobat. A row, in a table + // or not, keeps its `minH`: [`Self::cells`] has already raised its height to it. let min_h = match node { - Node::Subform(s) if c.h.is_none() && s.layout != Layout::Positioned => None, + Node::Subform(_) if c.h.is_none() => None, _ => c.min_h, }; (w, clamp_opt(h, min_h, c.max_h)) } + /// The height `k` takes with all its instances under the container at `som`, one under + /// another, the first at SOM index `sib` among its siblings: each instance takes its own. + fn instances_height(&self, k: &Node, inner_w: f64, ctx: &Ctx, depth: usize, som: &str, sib: usize) -> f64 { + (0..self.instances(k, som)).map(|i| self.size_at(k, inner_w, ctx, depth, sib.saturating_add(i)).1).sum() + } + + /// The height of `node`'s content within `inner_w` (inside its margins), whatever height it + /// gives itself. `ctx` is the node's own context ([`Ctx::with_som`]), `som` its SOM path and + /// `outer_para` the paragraph around it, which a field's caption takes. + fn content_height(&self, node: &Node, inner_w: f64, ctx: &Ctx, som: &str, outer_para: &Para, depth: usize) -> f64 { + match node { + Node::Draw(d) => self.value_height(&d.value, inner_w, ctx), + Node::Field(f) => self.field_height(f, inner_w, ctx, outer_para), + Node::ExclGroup(g) => self.group_height(g, inner_w, ctx, depth), + Node::Area(a) => self.positioned_height(&a.children, inner_w, ctx, depth), + Node::Subform(s) => match s.layout { + Layout::Positioned => self.positioned_height(&s.children, inner_w, ctx, depth), + // Each instance of a repeating child takes its own height. + Layout::Tb => { + let sibs = SomIndices::of(&s.children); + s.children + .iter() + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| self.instances_height(k, inner_w, ctx, depth + 1, som, sibs.at(i))) + .sum() + } + Layout::LrTb | Layout::RlTb => self.pack_instances(&s.children, inner_w, ctx, depth, som).1, + Layout::Table => { + let cols = self.columns(s, inner_w); + let sibs = SomIndices::of(&s.children); + s.children + .iter() + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| match k { + // Each instance of a repeating row takes its own height, as drawn. + Node::Subform(r) if r.layout == Layout::Row => (0..self.instances(k, som)) + .map(|n| self.row_height(r, &cols, inner_w, ctx, depth + 1, sibs.at(i).saturating_add(n))) + .sum(), + _ => self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1, + }) + .sum() + } + // `ctx` is already this row instance's own. + Layout::Row => self.cells(s, &self.columns(s, inner_w), inner_w, ctx, depth + 1).1, + }, + } + } + /// The natural width of a container's content within `avail_w`. fn content_width(&self, node: &Node, avail_w: f64, ctx: &Ctx, depth: usize) -> f64 { let group: Vec; @@ -134,20 +164,24 @@ impl Layouter<'_> { Node::Area(a) => (&a.children, Layout::Positioned), _ => return avail_w, }; - let visible = children.iter().filter(|k| k.common().presence.occupies()); + // Each child under the SOM index it is drawn at. + let sibs = SomIndices::of(children); + let visible = children.iter().enumerate().filter(|(_, k)| k.common().presence.occupies()); match layout { Layout::LrTb | Layout::RlTb | Layout::Row => { - let sum: f64 = visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).sum(); + let sum: f64 = visible.map(|(i, k)| self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)).0).sum(); if sum <= avail_w + 0.05 { sum } else { avail_w } } Layout::Positioned => visible - .map(|k| { - let (w, h) = self.size_of(k, avail_w, ctx, depth + 1); + .map(|(i, k)| { + let (w, h) = self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)); k.standing_box(w, h).right() }) .fold(0.0, f64::max) .min(avail_w), - Layout::Tb | Layout::Table => visible.map(|k| self.size_of(k, avail_w, ctx, depth + 1).0).fold(0.0, f64::max).min(avail_w), + Layout::Tb | Layout::Table => { + visible.map(|(i, k)| self.size_at(k, avail_w, ctx, depth + 1, sibs.at(i)).0).fold(0.0, f64::max).min(avail_w) + } } } @@ -193,8 +227,12 @@ impl Layouter<'_> { } /// Where field `f`'s caption goes and how much room it takes from the field's box, or `None` - /// when it has no caption that takes room (a button's is drawn inside its box). - pub(super) fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> Option<(Placement, f64)> { + /// when it has no caption that takes room (a button's is drawn inside its box). `ctx` is the + /// field's own and `outer_para` the paragraph around the field: the caption is measured in + /// the field's font (XFA 3.3, "Formatting Text That Appears as Fixed or Variable Content", + /// p. 43) and the outer paragraph, each with the caption's own properties on top, as it is + /// drawn. + pub(super) fn caption_reserve(&self, f: &Field, inner_w: f64, ctx: &Ctx, outer_para: &Para) -> Option<(Placement, f64)> { let cap = f.caption.as_ref()?; if !cap.presence.occupies() || f.ui == Ui::Button { return None; @@ -208,7 +246,7 @@ impl Layouter<'_> { } let placement = if cap.placement == Placement::Inline { Placement::Left } else { cap.placement }; let font = ctx.font.apply(&cap.font); - let para = ctx.para.apply(&cap.para); + let para = outer_para.apply(&cap.para); let reserve = cap.reserve.unwrap_or_else(|| match placement { Placement::Top | Placement::Bottom => { let rich = match &cap.value { @@ -225,9 +263,13 @@ impl Layouter<'_> { /// The height of field `f`'s content within `inner_w` (inside its margins): its widget (a check /// box, a button's caption, or one line of text or three when multi-line, more for a text field /// that grows) plus a caption above or below it, or the taller of the two for one beside it. - fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { + /// `ctx` is the field's own and `outer_para` the paragraph around it, which its caption takes. + fn field_height(&self, f: &Field, inner_w: f64, ctx: &Ctx, outer_para: &Para) -> f64 { let font = ctx.font.apply(&f.common.font); - let lines = if f.multiline { 3.0 } else { 1.0 }; + // An empty multi-line field is three lines tall, unless it gives a minimum height (`minH`, + // XFA 3.3 p. 731): then that is where it starts, as a growable table cell does, and one + // line below it; its text, when there is more, still grows it. + let lines = if f.multiline && f.common.min_h.is_none() { 3.0 } else { 1.0 }; let ui = match f.ui { Ui::CheckButton => f.check_size.unwrap_or(10.0).max(text::line_height(font.size)), // A button shows its caption: as tall as the caption's text in its own font. @@ -238,26 +280,28 @@ impl Layouter<'_> { other => text::plain(&other.plain().unwrap_or_default()), }; let w = (inner_w - f.ui_margin.horizontal()).max(1.0); - text::measure_height(&rich, w, &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| self.embed(id, max)) + text::measure_height(&rich, w, &outer_para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| self.embed(id, max)) }); cap + f.ui_margin.vertical() } - _ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx)) + 4.0 + f.ui_margin.vertical(), + _ => (text::line_height(font.size) * lines).max(self.grown_text_height(f, inner_w, ctx, outer_para)) + 4.0 + f.ui_margin.vertical(), }; - match self.caption_reserve(f, inner_w, ctx) { + match self.caption_reserve(f, inner_w, ctx, outer_para) { Some((Placement::Top | Placement::Bottom, r)) => ui + r, Some((placement, r)) => { - // A check box's caption without a reserve is drawn in all the box leaves. + // A check box's caption without a reserve is drawn in all the box leaves; any + // other caption beside the widget is as wide as its reserve (XFA 3.3 p. 645), the + // width `draw_field` gives it. let w = match auto_check_reserve(f, placement, inner_w, None) { Some(auto) => auto, - None => inner_w - r, + None => r.min(inner_w), }; let cap = f.caption.as_ref().map(|c| { let rich = match &c.value { Value::Rich(r) => r.clone(), other => text::plain(&other.plain().unwrap_or_default()), }; - text::measure_height(&rich, w.max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| { + text::measure_height(&rich, w.max(1.0), &outer_para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| { self.embed(id, max) }) }); @@ -277,17 +321,20 @@ impl Layouter<'_> { /// The height the text of a multi-line text field takes (its value in the data, else the /// template's), for a field without a fixed height, which grows to hold its text. `ctx` - /// is the field's own (its SOM ends in the field). Zero for anything else. - fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx) -> f64 { - if !f.grows_with_text() || f.common.name.is_none() { + /// is the field's own (its SOM ends in the field) and `outer_para` the paragraph around it. + /// Zero for anything else. A nameless field takes no part in data binding (XFA 3.3 p. 194), + /// and its `ctx` ends in its parent: its text is the template's. + fn grown_text_height(&self, f: &Field, inner_w: f64, ctx: &Ctx, outer_para: &Para) -> f64 { + if !f.grows_with_text() { return 0.0; } - let value = self.data.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); + let bound = f.common.name.as_ref().and(self.data); + let value = bound.and_then(|d| d.value_at(&data_path(self.tpl, &ctx.som))).filter(|t| !t.trim().is_empty()).or_else(|| f.value.plain()); let Some(value) = value.filter(|t| !t.trim().is_empty()) else { return 0.0 }; // Beside a caption the text has what the caption leaves; a widget's text has 2pt of // padding on each side, a non-interactive field's (drawn as text) none, so it wraps // where it is drawn. - let beside = match self.caption_reserve(f, inner_w, ctx) { + let beside = match self.caption_reserve(f, inner_w, ctx, outer_para) { Some((Placement::Top | Placement::Bottom, _)) | None => 0.0, Some((_, r)) => r, }; @@ -302,73 +349,134 @@ impl Layouter<'_> { fn group_height(&self, g: &ExclGroup, inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { let nodes: Vec = g.fields.iter().map(|f| Node::Field(Box::new(f.clone()))).collect(); match g.layout { - Layout::Tb => nodes.iter().filter(|k| k.common().presence.occupies()).map(|k| self.size_of(k, inner_w, ctx, depth + 1).1).sum(), + Layout::Tb => { + let sibs = SomIndices::of(&nodes); + nodes + .iter() + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i)).1) + .sum() + } Layout::Positioned => self.positioned_height(&nodes, inner_w, ctx, depth), _ => self.packed_height(&nodes, inner_w, ctx, depth), } } fn positioned_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { + let sibs = SomIndices::of(children); children .iter() - .filter(|k| k.common().presence.occupies()) - .map(|k| { + .enumerate() + .filter(|(_, k)| k.common().presence.occupies()) + .map(|(i, k)| { let c = k.common(); - let (w, h) = self.size_of(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1); + let (w, h) = self.size_at(k, (inner_w - c.x.unwrap_or(0.0)).max(1.0), ctx, depth + 1, sibs.at(i)); k.standing_box(w, h).bottom() }) .fold(0.0, f64::max) } - /// Left-to-right packing: rows of children, wrapped at the available width, as (child, - /// instance, place). A repeating subform is packed once per instance it has under the - /// container at `som`. - pub(super) fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec<(usize, usize, Rect)>, f64) { + /// Left-to-right packing: rows of children, wrapped at the available width, as (steps, place). + /// The steps are (child index, instance) from the packed container down to the object: one + /// step for a child, more for a member of a `subformSet`, which is transparent to layout (XFA + /// 3.3 p. 357), so its members go on among the container's other children. A repeating + /// subform is packed once per instance it has under the container at `som`. + pub(super) fn pack_instances(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize, som: &str) -> (Vec, f64) { let mut out = Vec::new(); - let (mut x, mut y, mut line_h) = (0.0, 0.0, 0.0); + let mut line = (0.0, 0.0, 0.0); + self.pack_into(children, inner_w, ctx, depth, som, &[], &mut line, &mut out); + (out, line.1 + line.2) + } + + /// [`Self::pack_instances`] from `line` (x, y, the line's height so far), the steps to + /// `children` being `path`. + #[allow(clippy::too_many_arguments)] + fn pack_into( + &self, + children: &[Node], + inner_w: f64, + ctx: &Ctx, + depth: usize, + som: &str, + path: &[(usize, usize)], + line: &mut (f64, f64, f64), + out: &mut Vec, + ) { + if depth > MAX_DEPTH { + return; + } + let sibs = SomIndices::of(children); for (i, k) in children.iter().enumerate() { if !k.common().presence.occupies() { continue; } - let (w, h) = self.size_of(k, inner_w, ctx, depth + 1); - for instance in 0..self.instances(k, som) { - if x > 0.0 && x + w > inner_w + 0.05 { - y += line_h; - x = 0.0; - line_h = 0.0; + if let Node::Subform(s) = k + && s.set + { + for instance in 0..self.instances(k, som) { + let set_ctx = ctx.with_som(&s.common, sibs.at(i).saturating_add(instance)); + let at: Vec<(usize, usize)> = path.iter().copied().chain([(i, instance)]).collect(); + self.pack_into(&s.children, inner_w, &set_ctx, depth + 1, &set_ctx.som, &at, line, out); } - out.push((i, instance, Rect::new(x, y, w, h))); - x += w; - line_h = line_h.max(h); + continue; + } + for instance in 0..self.instances(k, som) { + let (w, h) = self.size_at(k, inner_w, ctx, depth + 1, sibs.at(i).saturating_add(instance)); + let (x, y, line_h) = line; + if *x > 0.0 && *x + w > inner_w + 0.05 { + *y += *line_h; + *x = 0.0; + *line_h = 0.0; + } + out.push((path.iter().copied().chain([(i, instance)]).collect(), Rect::new(*x, *y, w, h))); + *x += w; + *line_h = line_h.max(h); } } - (out, y + line_h) } /// [`Self::pack_instances`] for children that don't repeat (the fields of a group). pub(super) fn pack(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { let (placed, h) = self.pack_instances(children, inner_w, ctx, depth, ""); - (placed.into_iter().map(|(i, _, r)| (i, r)).collect(), h) + (placed.into_iter().filter_map(|(at, r)| at.first().map(|&(i, _)| (i, r))).collect(), h) } fn packed_height(&self, children: &[Node], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { self.pack(children, inner_w, ctx, depth).1 } - /// Cell rectangles of a row (relative to the row's inner box) and the row height. + /// Cell rectangles of a row (relative to the row's inner box) and the row height. Each cell + /// is measured under its own SOM index among its same-named siblings (`qty[1]`, not + /// `qty[0]`), the index [`Self::draw_row`] draws it at, so a cell that grows with its value + /// takes its own value's height. pub(super) fn cells(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> (Vec<(usize, Rect)>, f64) { let ctx = ctx.child(&row.common); let mut out = Vec::new(); let mut col = 0usize; let mut x = 0.0; let mut h: f64 = 0.0; + let sibs = SomIndices::of(&row.children); for (i, k) in row.children.iter().enumerate() { if !k.common().presence.occupies() { continue; } let span = k.common().col_span.max(1); let w = if col < cols.len() { cols.iter().skip(col).take(span).sum::() } else { k.common().w.unwrap_or((inner_w - x).max(1.0)) }; - let (_, ch) = self.size_of(k, w, &ctx, depth + 1); + let (_, ch) = self.size_at(k, w, &ctx, depth + 1, sibs.at(i)); + // A flowed subform in a cell is as tall as its content even when it gives a height: + // other viewers (Adobe Acrobat, PDF-XChange Editor) lay a section of a table row out + // that way, though a height makes a container fixed (XFA 3.3 p. 276). + let ch = match k { + Node::Subform(s) if s.common.h.is_some() && matches!(s.layout, Layout::Tb | Layout::LrTb | Layout::RlTb | Layout::Table) => { + let own = ctx.with_som(&s.common, sibs.at(i)); + // At the width it is drawn at: the column's, not the cell's own. + let inner = (w - s.common.margin.horizontal()).max(1.0); + let content = self.content_height(k, inner, &own, &own.som, &ctx.para, depth + 1); + clamp_opt(content + s.common.margin.vertical(), None, s.common.max_h) + } + _ => ch, + }; out.push((i, Rect::new(x, 0.0, w, ch))); h = h.max(ch); x += w; @@ -381,7 +489,10 @@ impl Layouter<'_> { (out, clamp_opt(h, row.common.min_h, row.common.max_h)) } - pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize) -> f64 { - self.cells(row, cols, inner_w, ctx, depth).1 + /// The height of instance `sib` of `row` in the table at `ctx`, measured as [`Self::draw_row`] + /// draws it: under the instance's own SOM path, so a cell that grows with its text takes + /// that instance's value (XFA 3.3, "Tables", p. 327; "Growable Containers", p. 275). + pub(super) fn row_height(&self, row: &Subform, cols: &[f64], inner_w: f64, ctx: &Ctx, depth: usize, sib: usize) -> f64 { + self.cells(row, cols, inner_w, &ctx.with_som(&row.common, sib), depth).1 } } diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index dac17c1169..3d4e114c9d 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -2,8 +2,8 @@ use super::EMBED_COPIED; use super::items::{Item, Page, UNDERLINE_DROP}; use super::{ - Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, Rc, Rect, - SomIndices, XfaError, text, + Block, Border, Common, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, Node, PAGE_COUNT_MARK, + PageRole, Para, Rc, Rect, SomIndices, Views, XfaError, data_path, text, }; impl Layouter<'_> { @@ -14,17 +14,43 @@ impl Layouter<'_> { } } + /// Say once when `c`'s `relevant` names a view other than print, which is drawn as if it + /// didn't: for drawn objects, flowed subforms and table rows alike. + pub(super) fn warn_other_views(&mut self, c: &Common) { + if let Some(other) = &c.relevant_other { + self.warn_once(format!("the form limits some objects to views other than print (relevant \"{other}\"); they are drawn as if it didn't")); + } + } + fn page_no(&self) -> usize { self.pages.len() } /// The text an embedded field `id` shows, at most `max` bytes of a field value (the page - /// number and count are a few bytes; the text layout cuts them). + /// number and count are a few bytes; the text layout cuts them): its first instance's. pub(super) fn embed(&self, id: &str, max: usize) -> String { + self.embed_in(id, max, "") + } + + /// [`Self::embed`] in text laid out at `scope` (the SOM of what holds it): a field in a + /// repeating subform shows the value of the instance the text is in, the one whose data path + /// takes the instance numbers `scope` gives the same names. + pub(super) fn embed_in(&self, id: &str, max: usize, scope: &str) -> String { let v = match self.tpl.page_roles.iter().find(|(i, _)| i == id).map(|(_, r)| *r) { Some(PageRole::Number) => self.page_no().to_string(), Some(PageRole::Count) => PAGE_COUNT_MARK.to_string(), - None => self.values.get(id).map(|v| text::prefix(v, max).to_string()).unwrap_or_default(), + None => match self.values.get(id) { + None => String::new(), + Some(src) => { + let at = super::common::instance_path(&src.path, &data_path(self.tpl, scope)); + let shown = if at == src.path { + std::borrow::Cow::Borrowed(src.value.as_str()) + } else { + std::borrow::Cow::Owned(src.shown(super::common::data_value(self.data, &at))) + }; + text::prefix(&shown, max).to_string() + } + }, }; #[cfg(test)] EMBED_COPIED.with(|c| c.set(c.get().saturating_add(v.len() as u64))); @@ -39,12 +65,33 @@ impl Layouter<'_> { Ok(()) } + /// `TooLarge` once the draw phase has laid out far more text than a real form does. + pub(super) fn check_drawn_chars(&self) -> Result<(), XfaError> { + if self.drawn_chars.get() > MAX_DRAW_CHARS { + return Err(XfaError::TooLarge("the form draws far more text than the reader lays out".into())); + } + Ok(()) + } + + /// Put `item` on the last page, shown where what it is inside is shown ([`Self::views`]): a + /// field's widget carries that itself, anything else drawn only on screen or only in print + /// goes in an [`Item::Only`], and what shows nowhere isn't put at all. pub(super) fn push(&mut self, item: Item) -> Result<(), XfaError> { self.check_measures()?; self.items += 1; if self.items > MAX_ITEMS { return Err(XfaError::TooLarge("the form has too many objects to lay out".into())); } + let views = self.views; + let item = match item { + Item::Widget(mut w) => { + w.views = views; + Item::Widget(w) + } + other if views == Views::ALL => other, + _ if !views.any() => return Ok(()), + other => Item::Only { views, item: Box::new(other) }, + }; if let Some(p) = self.pages.last_mut() { p.items.push(item); } @@ -97,17 +144,35 @@ impl Layouter<'_> { if let Some(u) = self.uses.get_mut(idx) { *u += 1; } - self.pages.push(Page { width: area.width, height: area.height, items: Vec::new() }); + self.pages.push(Page { width: area.width, height: area.height, items: Vec::new(), area: area.name.clone() }); let content = area.content.first().copied().unwrap_or(Rect::new(0.0, 0.0, area.width, area.height)); self.cur = Some(Cursor { area: idx, content: 0, rect: content, y: content.y, placed: false }); - // Master page content: positioned draws and fields. - let ctx = Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_default() }; + // Master page content: positioned draws and fields. The page area's own SOM index counts + // the pages made from it, so a field repeated on each page (a page number, an office-use + // box) has a path of its own per page rather than every page sharing `[0]`. + let instance = self.uses.get(idx).map_or(0, |u| u.saturating_sub(1)); + let ctx = + Ctx { font: Font::default(), para: Para::default(), som: area.name.clone().map(|n| format!("{n}[{instance}]")).unwrap_or_default() }; let page_rect = Rect::new(0.0, 0.0, area.width, area.height); - let sibs = SomIndices::of(&area.items); - for (i, item) in area.items.iter().enumerate() { - self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))?; - } - Ok(()) + // What scripts set on this page's master content (a button hidden on this page only) goes + // on a copy of it for this page; other pages keep the template's. + let prefix = format!("{}.", ctx.som); + let copy = + self.overrides.filter(|ov| !ctx.som.is_empty() && ov.presence.keys().chain(ov.access.keys()).any(|k| k.starts_with(&prefix))).map(|ov| { + let mut copy = area.items.clone(); + crate::script::apply_overrides_to(&mut copy, &ctx.som, ov, 1); + let copy = Rc::new(copy); + self.master_copies.push(Rc::clone(©)); + copy + }); + let items: &[Node] = copy.as_deref().map_or(&area.items, Vec::as_slice); + let sibs = SomIndices::of(items); + // The master page's own objects: shown where they say, whatever the body was drawing + // when it needed this page. + let body = std::mem::replace(&mut self.views, Views::ALL); + let drawn = items.iter().enumerate().try_for_each(|(i, item)| self.draw_positioned_child(item, page_rect, &ctx, 1, sibs.at(i))); + self.views = body; + drawn } /// Move to the next content area, or the next page. @@ -139,7 +204,7 @@ impl Layouter<'_> { } /// Whether the flow is still in the content area `m` was taken in. - fn on_area(&self, m: &FlowMark) -> bool { + pub(super) fn on_area(&self, m: &FlowMark) -> bool { self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) } @@ -160,7 +225,7 @@ impl Layouter<'_> { let (rect, mut y, _) = self.cursor(); let page = self.pages.len().saturating_sub(1); let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); - let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); + let filled: Vec = self.frames.iter().map(|f| f.held || self.has_items_from(page, f.part.items)).collect(); for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { y = (y + f.margin.bottom).min(rect.bottom()); if !here { @@ -180,6 +245,7 @@ impl Layouter<'_> { let Some(mark) = self.flow_mark() else { break }; let Some(f) = self.frames.get_mut(i) else { break }; f.part = mark; + f.held = false; // `max` before `min`: a NaN margin counts as none and leaves `room` a number. let top = f.margin.top.max(0.0).min(room); room = (room - top).max(0.0); @@ -188,6 +254,13 @@ impl Layouter<'_> { Ok(()) } + /// The bottom margins of the flowed subforms being laid out, which a break leaves below their + /// content ([`Self::break_frames`]). + pub(super) fn frames_bottom(&self) -> f64 { + // `max` first: a NaN margin counts as none. + self.frames.iter().map(|f| f.margin.bottom.max(0.0)).sum() + } + /// Close flowed subform `f` once its content is placed: its last part ends where the flow /// is, and its border (edges and fill) is drawn round each part, closed on every side, under /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) @@ -204,7 +277,7 @@ impl Layouter<'_> { let page = self.pages.len().saturating_sub(1); if !self.on_area(&f.part) { f.lost = true; - } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { + } else if (f.held || self.has_items_from(page, f.part.items)) && end - f.part.y >= 0.01 { f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); } if f.lost { @@ -220,7 +293,7 @@ impl Layouter<'_> { /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item /// `at` on. - fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { + pub(super) fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { let before = self.pages.last().map_or(0, |p| p.items.len()); self.draw_border(b, rect)?; let drawn: Vec = match self.pages.last_mut() { diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 7e63218c90..91b468cea7 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -11,11 +11,13 @@ pub mod data; pub mod fixtures; pub mod image; pub mod layout; +pub mod linked_images; pub mod model; pub mod packets; pub mod parse; pub mod pdf; pub mod picture; +mod proto; pub mod script; pub mod text; @@ -38,6 +40,7 @@ pub(crate) const MAX_XML_DEPTH: usize = 1_024; /// cannot under-count a real element; a stray `<` in text is invalid XML and only makes it /// over-count, which errs towards refusing the document. pub(crate) fn xml_nesting_depth(text: &str) -> usize { + /// Where `needle` next starts in `hay` at or after `from`. fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) } @@ -93,17 +96,18 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize { pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; pub use data::{ DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, clear_password_values, data_path, - has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, - write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, + has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, radio_dataset_value, radio_state_for_item, read_values, read_values_with, + remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, }; pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; -pub use parse::{measure, parse}; -pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; +pub use parse::{measure, numbers_pages, parse}; +pub use pdf::{AREA_KEY, CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; pub use picture::typed_date; pub use script::{ - FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text, - fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of, + FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields, + field_grows_with_text, field_is_embedded, fields_by_som, form_tree, form_tree_paged, has_focus_scripts, has_scripts, overrides, page_area_uses, + path_is_embedded, refresh_value, rerender, reset_default, set_overrides, template_of, }; #[derive(Debug, thiserror::Error)] @@ -156,10 +160,12 @@ pub fn render_into(doc: &mut Document) -> Result { if packets.xdp.trim().is_empty() { return Err(XfaError::Malformed("the XFA packets hold no template".into())); } - let (tpl, parse_warnings) = parse(&packets.xdp)?; + let (mut tpl, mut parse_warnings) = parse(&packets.xdp)?; + parse_warnings.extend(linked_images::resolve(&mut tpl, doc)); let data = parse_datasets(&packets.xdp); - let laid = apply_overrides(&tpl, &overrides(doc)); - let mut form = layout(&laid, data.as_ref())?; + let ov = overrides(doc); + let laid = apply_overrides(&tpl, &ov); + let mut form = layout::layout_with_overrides(&laid, data.as_ref(), &ov)?; form.warnings.splice(0..0, parse_warnings); if form.pages.is_empty() { return Err(XfaError::Malformed("the template laid out to no pages".into())); @@ -174,5 +180,11 @@ pub fn render_into(doc: &mut Document) -> Result { Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } +#[cfg(test)] +mod repeat_tests; + +#[cfg(test)] +mod table_repeat_tests; + #[cfg(test)] mod tests; diff --git a/crates/xfa/src/linked_images.rs b/crates/xfa/src/linked_images.rs new file mode 100644 index 0000000000..9fd0e6f789 --- /dev/null +++ b/crates/xfa/src/linked_images.rs @@ -0,0 +1,260 @@ +//! Pictures a template links instead of embedding (``). Since XFA 2.6 a form +//! keeps them in its PDF: each image file is a stream holding the file's bytes, named in the +//! `XFAImages` name tree of the catalog's `Names` dictionary by the URI the template writes (XFA +//! 3.3 pp. 257–258, "Storing Images Inside PDF"). Only that tree is looked in: as the spec warns, +//! a form that isn't trusted must not have its links followed outside the file. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +use crate::model::{Field, Node, Subform, Template, Value}; + +/// Most nodes of the name tree visited, and most names kept from it. +const MAX_TREE_NODES: usize = 4096; +const MAX_NAMES: usize = 4096; +/// Most name-and-value pairs looked at, a name repeated counted each time (only a new name is +/// kept, so [`MAX_NAMES`] alone doesn't end a `Names` array that repeats one). +const MAX_PAIRS: usize = 4 * MAX_NAMES; +/// Longest name kept, in bytes: a template's link is a short path or URI, and a longer name +/// could only be matched by an equally long link. +const MAX_NAME_LEN: usize = 4096; +/// Deepest the name tree is followed. +const MAX_DEPTH: usize = 32; +/// Largest picture taken from the file, as for a picture written into the template. Small in +/// tests, so a test can pass it. +#[cfg(not(test))] +const MAX_IMAGE: usize = 32 << 20; +#[cfg(test)] +const MAX_IMAGE: usize = 512; +/// Most bytes of pictures taken from the file in all, each place a picture is used counted (a +/// picture is held once per place). Small in tests, so a test can reach it. +#[cfg(not(test))] +const MAX_TOTAL: usize = 128 << 20; +#[cfg(test)] +const MAX_TOTAL: usize = 1 << 10; +/// Deepest the template is walked. +const MAX_TEMPLATE_DEPTH: usize = 256; + +/// `f` of `o`, a reference followed. A direct object is lent as it is, not copied: a tree's +/// arrays can be as long as the file allows. +fn with_resolved(doc: &Document, o: &Object, f: impl FnOnce(&Object) -> R) -> R { + match o { + Object::Ref(r) => f(&doc.get(*r)), + other => f(other), + } +} + +/// The `XFAImages` name tree: each name, as text, and the object it names. +fn names(doc: &Document) -> HashMap { + let mut walk = Walk { doc, out: HashMap::new(), seen: HashSet::new(), visited: 0, pairs: 0 }; + let Some(root) = doc.root() else { return walk.out }; + let catalog = doc.get(root); + if let Some(names) = catalog.as_dict().and_then(|c| c.get(b"Names")) { + with_resolved(doc, names, |n| { + if let Some(tree) = n.as_dict().and_then(|d| d.get(b"XFAImages")) { + walk.node(tree, 0); + } + }); + } + walk.out +} + +/// A walk of the name tree, borrowing each node from its resolved owner: nothing of the tree +/// is copied but the names kept, and only the branch being walked is held. +struct Walk<'a> { + doc: &'a Document, + out: HashMap, + seen: HashSet, + visited: usize, + /// Pairs looked at so far, repeats included. + pairs: usize, +} + +impl Walk<'_> { + fn full(&self) -> bool { + self.visited >= MAX_TREE_NODES || self.out.len() >= MAX_NAMES || self.pairs >= MAX_PAIRS + } + + fn node(&mut self, node: &Object, depth: usize) { + // Too deep: this branch is left, the others are still walked. + if self.full() || depth > MAX_DEPTH { + return; + } + self.visited += 1; + if let Some(r) = node.as_ref() + && !self.seen.insert(r) + { + return; + } + let doc = self.doc; + with_resolved(doc, node, |node| { + let Some(d) = node.as_dict() else { return }; + if let Some(pairs) = d.get(b"Names") { + with_resolved(doc, pairs, |pairs| self.pairs(pairs)); + } + if let Some(kids) = d.get(b"Kids") { + with_resolved(doc, kids, |kids| { + for kid in kids.as_array().map(Vec::as_slice).unwrap_or_default() { + if self.full() { + break; + } + self.node(kid, depth + 1); + } + }); + } + }); + } + + fn pairs(&mut self, pairs: &Object) { + let doc = self.doc; + for pair in pairs.as_array().map(Vec::as_slice).unwrap_or_default().chunks(2) { + // The names and pairs limits only: the node limit ends the walk at the next node, not + // the names of the node it has just come to. + if self.out.len() >= MAX_NAMES || self.pairs >= MAX_PAIRS { + break; + } + self.pairs += 1; + // An over-long name is left before it is turned into text. + if let [key, value] = pair + && let Some(key) = with_resolved(doc, key, |k| k.as_string().filter(|s| s.bytes.len() <= MAX_NAME_LEN).map(|s| s.to_text())) + { + self.out.entry(key).or_insert_with(|| value.clone()); + } + } + } +} + +/// Pictures found so far by their link, read from the file once each. +struct Found<'a> { + doc: &'a Document, + names: HashMap, + read: HashMap>>, + /// Bytes of the pictures in `read`, and of the copies put in places: each at most + /// [`MAX_TOTAL`], however many names the tree holds. + kept: usize, + total: usize, + warnings: Vec, +} + +impl Found<'_> { + /// A copy of the picture linked as `href` for one more place, when the file holds it and the + /// pictures kept and the copies so far leave room for it under [`MAX_TOTAL`]. + fn get(&mut self, href: &str) -> Option> { + if !self.read.contains_key(href) { + // Past the total, a picture not read yet is neither read nor kept. + let data = if self.kept >= MAX_TOTAL { None } else { self.load(href) }; + let data = match data { + Some(d) if self.kept.saturating_add(d.len()) > MAX_TOTAL => { + self.over_total(); + None + } + Some(d) => { + self.kept = self.kept.saturating_add(d.len()); + Some(d) + } + None => { + if self.kept >= MAX_TOTAL && self.names.contains_key(href) { + self.over_total(); + } + None + } + }; + self.read.insert(href.to_string(), data); + } + let len = self.read.get(href).and_then(Option::as_ref).map(Vec::len)?; + if self.total.saturating_add(len) > MAX_TOTAL { + self.over_total(); + return None; + } + self.total = self.total.saturating_add(len); + self.read.get(href).cloned().flatten() + } + + fn over_total(&mut self) { + self.warnings.push("the pictures the form keeps in the file take too much memory in all; the rest are left out".into()); + } + + /// Bytes of the pictures read so far and kept for reuse. + #[cfg(test)] + fn kept(&self) -> usize { + self.read.values().flatten().map(Vec::len).sum() + } + + fn load(&mut self, href: &str) -> Option> { + let object = self.names.get(href)?; + let object = self.doc.resolve(object); + let Object::Stream(s) = &*object else { return None }; + let data = match s.decoded_within(MAX_IMAGE) { + Ok(d) => d, + Err(_) => { + self.warnings.push(format!("a picture the form keeps in the file ({href}) could not be read; it is left out")); + return None; + } + }; + Some(data) + } + + fn value(&mut self, v: &mut Value) { + if let Value::Image { data, href: Some(href), .. } = v + && data.is_empty() + && let Some(found) = self.get(href) + { + *data = found; + } + } + + fn field(&mut self, f: &mut Field) { + self.value(&mut f.value); + if let Some(c) = f.caption.as_mut() { + self.value(&mut c.value); + } + } + + fn nodes(&mut self, nodes: &mut [Node], depth: usize) { + if depth > MAX_TEMPLATE_DEPTH { + return; + } + for n in nodes { + match n { + Node::Subform(s) => self.subform(s, depth + 1), + Node::Area(a) => self.nodes(&mut a.children, depth + 1), + Node::Field(f) => self.field(f), + Node::Draw(d) => self.value(&mut d.value), + Node::ExclGroup(g) => g.fields.iter_mut().for_each(|f| self.field(f)), + } + } + } + + fn subform(&mut self, s: &mut Subform, depth: usize) { + if let Some(set) = s.page_set.as_mut() { + for area in &mut set.areas { + self.nodes(&mut area.items, depth + 1); + } + } + self.nodes(&mut s.children, depth); + } +} + +/// Give the pictures `tpl` links to (and doesn't embed) the data `doc` keeps for them. A link +/// the file holds nothing for stays empty. What couldn't be read is returned as warnings. +pub fn resolve(tpl: &mut Template, doc: &Document) -> Vec { + resolve_found(tpl, doc).map(|f| f.warnings).unwrap_or_default() +} + +fn resolve_found<'a>(tpl: &mut Template, doc: &'a Document) -> Option> { + let names = names(doc); + if names.is_empty() { + return None; + } + let mut found = Found { doc, names, read: HashMap::new(), kept: 0, total: 0, warnings: Vec::new() }; + found.subform(&mut tpl.root, 0); + found.warnings.dedup(); + Some(found) +} + +/// [`resolve`], and the bytes of pictures it held for reuse at the end. +#[cfg(test)] +pub(crate) fn resolve_keeping(tpl: &mut Template, doc: &Document) -> (Vec, usize) { + resolve_found(tpl, doc).map_or((Vec::new(), 0), |f| (f.warnings.clone(), f.kept())) +} diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 23b685102c..9b00ee6f3e 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -326,6 +326,37 @@ impl Para { } } +/// Where an object is drawn: on screen, when the form is printed, or both (see +/// [`Common::views`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Views { + pub screen: bool, + pub print: bool, +} + +impl Views { + /// On screen and in print: what an object with nothing to say is. + pub const ALL: Views = Views { screen: true, print: true }; + + /// Where an object inside a container drawn in `self` is drawn, when it alone would be drawn + /// in `other`: only where both are. + pub fn and(self, other: Views) -> Views { + Views { screen: self.screen && other.screen, print: self.print && other.print } + } + + /// Drawn somewhere. + pub fn any(self) -> bool { + self.screen || self.print + } +} + +impl Default for Views { + /// On screen and in print. + fn default() -> Self { + Views::ALL + } +} + /// What every container and leaf shares: name, box, presence, margins, border, text defaults. #[derive(Clone, Debug, Default, PartialEq)] pub struct Common { @@ -344,12 +375,43 @@ pub struct Common { pub min_h: Option, pub max_h: Option, pub presence: Presence, + /// What `relevant` says about printing: `Some(true)` for `+print` (printed even when its + /// presence hides it on screen), `Some(false)` for `-print` (never printed), `None` when it + /// says nothing (printed as it is shown). + pub relevant: Option, + /// The first `relevant` token about a view other than print, which is left aside. + pub relevant_other: Option, pub margin: Margin, pub border: Option, pub font: FontSpec, pub para: ParaSpec, /// Table cells: how many columns this cell spans. pub col_span: usize, + /// The object's own attributes and its property elements as the template writes them, for + /// scripts to read (see [`Props`]). Shared: the live form's instances of the object and + /// copies of the template point at the same tree. + pub props: std::sync::Arc, +} + +/// What scripts read of an object's properties (XFA 3.3 ch. 3, "Properties vs. Children", +/// p. 78): the object's own attributes and the property elements the template gives it (border, +/// font, para, margin, ui, validate, occur, break, assist, format, value, bind, desc, extras, keep), +/// as written. Properties the template leaves out are not here: the scripting host supplies +/// their defaults. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Props { + pub attrs: Vec<(String, String)>, + pub elements: Vec, +} + +/// One property element: its tag, attributes, text (for elements that hold text, such as +/// `picture` or `toolTip`) and its own property elements. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Prop { + pub tag: String, + pub attrs: Vec<(String, String)>, + pub text: Option>, + pub children: Vec, } #[derive(Clone, Debug, PartialEq)] @@ -362,6 +424,14 @@ pub enum Node { } impl Common { + /// Where the object itself is drawn, before what its containers say: on screen when it is + /// visible, and in print when `relevant` says so (`+print` even when hidden or invisible, + /// `-print` never), else when it is visible. + pub fn views(&self) -> Views { + let screen = self.presence == Presence::Visible; + Views { screen, print: self.relevant.unwrap_or(screen) } + } + /// The top-left corner of a positioned box of `w` by `h`, from `x`, `y` and the anchor. pub fn top_left(&self, w: f64, h: f64) -> (f64, f64) { (self.x.unwrap_or(0.0) - w * self.anchor.h, self.y.unwrap_or(0.0) - h * self.anchor.v) @@ -451,6 +521,9 @@ pub struct Subform { /// ``: start on a new page. pub break_before_page: bool, pub break_after_page: bool, + /// Parsed from a `subformSet`: transparent to layout (XFA 3.3 p. 357), so in a flowing + /// container its members are laid out among their parent's other children. + pub set: bool, /// Tables: the width of each column. pub column_widths: Vec, /// ``: the named child (a header row) is repeated after a page break. @@ -472,6 +545,8 @@ pub struct ExclGroup { pub fields: Vec, pub tooltip: Option, pub scripts: Vec +Tipnum{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 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("'); + 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##" +"## + ) +} + +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#" + + + 10"#; + +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#" + +
{data}
+
"# + ) +} + +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("
", "") + .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!("")).collect(); + format!( + r#" + +
<{data}>{items}T
{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!("{items}
") + }) + .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#" + +
{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 = ["s1t1", &format!("s2{long}"), "s3t3"]; + 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#""#, + 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#""#, + 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#" + + +
102026-11-04202026-12-25AB +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##" + + + +"## + ); + 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##" +"##; + 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##" +"## + ); + 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!( + "" + ); + 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!(""); + 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!(""); + 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#""#); + 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#""#); + 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#""# + ) + }; + 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#""# + ); + 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#"