diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d8549142f7..9403a1e044 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -42,3 +42,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+ LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index a8541c1942..e8fa4adac6 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (255)
+## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -203,6 +203,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
+| `crates/prc/src/acof/table.rs` | PRC Double prefix-code table (as a Rust table) | Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2) | MIT | Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header. | Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data. |
| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
@@ -262,6 +263,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/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)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 783ecc6c9f..f998b6fc3d 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -2156,6 +2156,17 @@ adobe_data = true
usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree."
sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
+[[asset]]
+path = "crates/prc/src/acof/table.rs"
+title = "PRC Double prefix-code table (as a Rust table)"
+author = "Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2)"
+source = "Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header."
+licence = "MIT"
+licence_file = "licenses/prc-rs-LICENSE.txt"
+kind = "data"
+usage = "Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data."
+sha256 = "8e705c5cde6a3ef778e8aca69759e38845b228b3f7f12949e587b19855d2114c"
+
[[bundled]]
crate = "epaint_default_fonts"
version = "0.37.0"
@@ -3189,6 +3200,17 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
+[[asset]]
+path = "assets/icons/a-large-small.svg"
+title = "Lucide icon \"a-large-small\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg)"
+licence = "ISC AND MIT"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs"
+sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec"
+
[[asset]]
path = "assets/icons/move-horizontal.svg"
title = "Lucide icon \"move-horizontal\""
diff --git a/Cargo.lock b/Cargo.lock
index 4df93ed3fe..573b215b7c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4277,6 +4277,8 @@ dependencies = [
"pdfcraft-model",
"pdfcraft-organize",
"pdfcraft-platform",
+ "pdfcraft-prc",
+ "pdfcraft-prc-render",
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
@@ -4551,6 +4553,24 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "pdfcraft-prc"
+version = "0.18.1"
+dependencies = [
+ "flate2",
+ "proptest",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "pdfcraft-prc-render"
+version = "0.18.1"
+dependencies = [
+ "flate2",
+ "pdfcraft-prc",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "pdfcraft-preflight"
version = "0.18.1"
diff --git a/Cargo.toml b/Cargo.toml
index 4b98f47c28..bf6a4e3146 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -63,6 +63,8 @@ pdfcraft-engine = { path = "crates/engine", version = "0.18.1" }
pdfcraft-platform = { path = "crates/platform", version = "0.18.1" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" }
pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false }
+pdfcraft-prc = { path = "crates/prc", version = "0.18.1" }
+pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
# pdf
hayro = "0.7.1"
diff --git a/LICENSES.toml b/LICENSES.toml
index d9ceb3180e..0617ca5d57 100644
--- a/LICENSES.toml
+++ b/LICENSES.toml
@@ -88,6 +88,13 @@ destination = "licenses/egui-emoji-icon-font-mit-license.txt"
sha256 = "b9d2c1d909aa149996fd4c91dcb92b2362a04431640c1d200959da94caf8cde1"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt"
+[[document]]
+source = "licenses/prc-rs-LICENSE.txt"
+licences = ["MIT"]
+destination = "licenses/prc-rs-LICENSE.txt"
+sha256 = "ddb846513d92153d8fa16b18ed3e7c86953a582f0d2f49f02b29f1bd569dac58"
+origin = "https://raw.githubusercontent.com/ralovich/prc-rs/287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d/LICENSE"
+
[[document]]
source = "vendor/hayro-interpret/LICENSE-APACHE"
licences = ["Apache-2.0"]
diff --git a/README.md b/README.md
index 3c1b67936a..f9a41cb3ca 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ Real-world typography: world scripts, vertical Japanese, colour emoji, gradients
### Fearless
-Every save appends your changes and leaves the original bytes untouched. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
+Ordinary saves append your changes and leave the original bytes untouched. Edits to damaged objects whose xref and header generations disagree use a full rewrite, keeping both accepted references connected to the same repaired object. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
diff --git a/ROADMAP.md b/ROADMAP.md
index 6ef9e2c369..49f7d4101d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -21,9 +21,15 @@
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged)
+
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #1143: explicit live xref/header generation repair policy and bounded diagnostics; estimates unchanged)
+
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
-> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
+> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
+> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
@@ -223,6 +229,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
+- **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-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.
@@ -231,8 +238,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
+- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
+
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
+- **2026-11-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.
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
@@ -259,6 +270,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
+- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
@@ -289,6 +301,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Estimates unchanged.
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
+- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
@@ -334,6 +347,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 (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.
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
@@ -397,6 +412,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **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-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.
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
@@ -775,6 +792,7 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
+| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
@@ -785,6 +803,7 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
+| 2026-10-15 | minor | M1: refresh object-generation reference identity, retaining current upstream progress and regression tests. Estimates unchanged. |
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
diff --git a/assets/icons/a-large-small.svg b/assets/icons/a-large-small.svg
new file mode 100644
index 0000000000..7716db3cf2
--- /dev/null
+++ b/assets/icons/a-large-small.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs
index 909250ef26..89c0f70f48 100644
--- a/crates/annot/src/appearance.rs
+++ b/crates/annot/src/appearance.rs
@@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option {
Some(res)
}
-fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
+pub(crate) fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
@@ -288,6 +288,18 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
Stream::flate(d, content)
}
+/// The `cm` that draws a picture into `at`: an image (its unit square) or a form XObject whose
+/// `/Matrix` maps it to `size` points (`None`, or a side that is not positive, draws nothing).
+pub(crate) fn picture_place(at: [f64; 4], image: bool, size: Option<(f64, f64)>) -> Option {
+ let [x0, y0, x1, y1] = at;
+ let (w, h) = (x1 - x0, y1 - y0);
+ if image {
+ return Some(format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)));
+ }
+ let (sw, sh) = size.filter(|(sw, sh)| *sw > 0.0 && *sh > 0.0)?;
+ Some(format!("{} 0 0 {} {} {} cm", n(w / sw), n(h / sh), n(x0), n(y0)))
+}
+
/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
/// when the signature is added to a turned page): 0, 90, 180 or 270.
pub(crate) fn picture_turn(d: &Dict) -> i64 {
@@ -325,7 +337,7 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
}
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
-fn helvetica_resource() -> Object {
+pub(crate) fn helvetica_resource() -> Object {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
@@ -786,15 +798,12 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
- let [x0, y0, x1, y1] = rect;
- let (w, h) = (x1 - x0, y1 - y0);
let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
- let place = if image {
- format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
- } else {
- let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
- format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0))
- };
+ let size = nums(d, b"PCPictureSize").and_then(|s| match s.as_slice() {
+ [w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)),
+ _ => None,
+ });
+ let place = picture_place(rect, image, size)?;
c.push_str(&format!("q {place} /Pic Do Q\n"));
let mut xo = Dict::new();
xo.set(b"Pic".to_vec(), Object::Ref(pic));
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index 7bd0894c66..4f24d04b08 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -22,7 +22,9 @@ mod create;
mod draw;
mod info;
mod lookup;
+mod model3d;
mod modify;
+mod threed;
mod types;
#[cfg(test)]
@@ -36,11 +38,13 @@ use lookup::{annots, page_ref};
pub use lookup::{
has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image,
};
+pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
pub(crate) use modify::n;
pub use modify::{
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
};
+pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
pub use types::{
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
@@ -70,6 +74,12 @@ pub enum AnnotError {
/// with a font that has those characters. The message names the character.
#[error("{0}")]
Undrawable(String),
+ /// A 3D model file that is neither U3D (starts with `U3D` and a zero byte) nor PRC (starts `PRC`).
+ #[error("not a 3D model: the file must be U3D (ECMA-363) or PRC (ISO 14739-1)")]
+ NotAModel,
+ /// A 3D model larger than `MAX_MODEL_BYTES`.
+ #[error("the 3D model is {size} bytes; the largest accepted is {max}")]
+ ModelTooLarge { size: usize, max: usize },
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs
new file mode 100644
index 0000000000..b50342898e
--- /dev/null
+++ b/crates/annot/src/model3d.rs
@@ -0,0 +1,77 @@
+//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a
+//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares.
+
+use super::lookup::{annots, page_ref};
+use super::threed::MAX_MODEL_BYTES;
+use super::{AnnotError, Dict, Document, Object};
+
+/// The format of a 3D stream, from its `/Subtype`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Model3dKind {
+ /// Product Representation Compact (ISO 14739-1).
+ Prc,
+ /// Universal 3D (ECMA-363).
+ U3d,
+}
+
+/// A 3D annotation's model: its format and the decoded stream.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Model3d {
+ pub kind: Model3dKind,
+ pub data: Vec,
+}
+
+/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order.
+///
+/// # Errors
+/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`].
+pub fn model_3d_annotations(doc: &Document, page: usize) -> Result, AnnotError> {
+ let list = annots(doc, page_ref(doc, page)?);
+ Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect())
+}
+
+/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded.
+///
+/// # Errors
+/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`]
+/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or
+/// U3D, or decodes past [`MAX_MODEL_BYTES`].
+pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result {
+ let list = annots(doc, page_ref(doc, page)?);
+ let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
+ let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
+ let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1));
+ if annot.name(b"Subtype") != Some(b"3D") {
+ return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at())));
+ }
+ let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?;
+ let Object::Stream(s) = stream.as_ref() else {
+ return Err(AnnotError::Invalid(format!("{} has no 3D stream", at())));
+ };
+ let kind = match s.dict.name(b"Subtype") {
+ Some(b"PRC") => Model3dKind::Prc,
+ Some(b"U3D") => Model3dKind::U3d,
+ other => {
+ let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned());
+ return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at())));
+ }
+ };
+ let data = doc
+ .with_direct_parms(s)
+ .decoded_within(MAX_MODEL_BYTES)
+ .map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?;
+ Ok(Model3d { kind, data })
+}
+
+/// What an annotation's `/3DD` names: the 3D stream itself, or the `/3D` entry of a 3D reference
+/// dictionary. A reference is followed once; the caller refuses anything that is not a stream.
+fn stream_of(doc: &Document, annot: &Dict) -> Option> {
+ let obj = doc.resolve(annot.get(b"3DD")?);
+ match obj.as_ref() {
+ Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)),
+ _ => Some(obj),
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/annot/src/model3d/tests.rs b/crates/annot/src/model3d/tests.rs
new file mode 100644
index 0000000000..90addb1bb6
--- /dev/null
+++ b/crates/annot/src/model3d/tests.rs
@@ -0,0 +1,88 @@
+use std::sync::Arc;
+
+use super::*;
+
+/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on.
+fn doc(annots: &str, extra: &[Vec]) -> Document {
+ let mut objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(),
+ format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(),
+ ];
+ objs.extend(extra.iter().cloned());
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ Document::open(Arc::new(out)).expect("opens")
+}
+
+fn stream(dict: &str, body: &[u8]) -> Vec {
+ let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes();
+ s.extend_from_slice(body);
+ s.extend_from_slice(b"\nendstream");
+ s
+}
+
+fn annot(dd: &str) -> Vec {
+ format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes()
+}
+
+#[test]
+fn a_plain_3d_stream_is_read_through_its_filters() {
+ let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]);
+ assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() }));
+}
+
+#[test]
+fn a_3dref_leads_to_the_stream_it_names() {
+ let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
+ assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec()));
+}
+
+#[test]
+fn a_u3d_stream_is_read_and_says_so() {
+ let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]);
+ assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d));
+}
+
+#[test]
+fn a_3d_annotation_without_a_stream_is_invalid() {
+ let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
+}
+
+#[test]
+fn an_unknown_format_is_invalid() {
+ let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)")));
+}
+
+#[test]
+fn a_3dref_is_followed_once_and_only_to_a_stream() {
+ // A reference naming another reference, or one that names itself, is damage: no stream.
+ let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
+ // The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key.
+ let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
+}
+
+#[test]
+fn another_annotation_is_not_a_3d_one() {
+ let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation")));
+ assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 }));
+ assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1]));
+ assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1)));
+}
diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs
index 32d28fc6ec..2ff781a3a5 100644
--- a/crates/annot/src/tests.rs
+++ b/crates/annot/src/tests.rs
@@ -2251,6 +2251,141 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() {
}
}
+/// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's
+/// catalog says so, and a newer one is left as it is.
+#[test]
+fn a_3d_model_raises_an_older_document_to_its_version() {
+ let catalog_version = |doc: &Document| {
+ let root = doc.root().unwrap();
+ doc.get(root).as_dict().unwrap().name(b"Version").map(<[u8]>::to_vec)
+ };
+ let add = |doc: &mut Document, model: &[u8]| {
+ let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" };
+ add_3d_model(doc, &new, &meta("m")).unwrap();
+ };
+ let mut doc = fixture();
+ add(&mut doc, b"U3D\0rest");
+ assert_eq!(catalog_version(&reopen(&doc)), None, "a 1.7 file needs nothing for U3D");
+ for (model, version) in [(&b"U3D\0rest"[..], &b"1.6"[..]), (b"PRC rest", b"1.7")] {
+ let mut doc = fixture();
+ doc.set_version("1.4");
+ add(&mut doc, model);
+ assert_eq!(catalog_version(&reopen(&doc)).as_deref(), Some(version));
+ }
+ let mut doc = fixture();
+ doc.set_version("2.0");
+ add(&mut doc, b"PRC rest");
+ assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is");
+}
+
+/// Before 2.0, PRC is an Adobe extension to PDF 1.7 (§7.12): a PRC model declares it, keeping
+/// other extensions and a later ADBE level; U3D and a 2.0 file need nothing.
+#[test]
+fn a_prc_model_declares_the_adobe_extension_before_pdf_2() {
+ let adbe = |doc: &Document| {
+ let root = doc.root().unwrap();
+ let ext = doc.get(root).as_dict().unwrap().get(b"Extensions").map(|e| doc.resolve(e).as_dict().unwrap().clone());
+ ext.map(|e| {
+ let adbe = e.get(b"ADBE").map(|a| a.as_dict().unwrap().clone());
+ (e.get(b"XYZ").is_some(), adbe.map(|a| (a.name(b"BaseVersion").map(<[u8]>::to_vec), a.get(b"ExtensionLevel").and_then(Object::as_int))))
+ })
+ };
+ let add = |doc: &mut Document, model: &[u8]| {
+ let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" };
+ add_3d_model(doc, &new, &meta("m")).unwrap();
+ };
+ let mut doc = fixture();
+ add(&mut doc, b"PRC rest");
+ assert_eq!(adbe(&reopen(&doc)), Some((false, Some((Some(b"1.7".to_vec()), Some(1))))));
+ let mut doc = fixture();
+ add(&mut doc, b"U3D rest");
+ assert_eq!(adbe(&reopen(&doc)), None, "U3D is in ISO 32000-1");
+ let mut doc = fixture();
+ doc.set_version("2.0");
+ add(&mut doc, b"PRC rest");
+ assert_eq!(adbe(&reopen(&doc)), None, "PRC is in ISO 32000-2");
+
+ let mut doc = fixture();
+ let root = doc.root().unwrap();
+ let mut level3 = Dict::new();
+ level3.set(b"BaseVersion".to_vec(), Object::name("1.7"));
+ level3.set(b"ExtensionLevel".to_vec(), Object::Int(3));
+ let mut ext = Dict::new();
+ ext.set(b"ADBE".to_vec(), Object::Dict(level3));
+ ext.set(b"XYZ".to_vec(), Object::Dict(Dict::new()));
+ doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext.clone()))).unwrap();
+ add(&mut doc, b"PRC rest");
+ assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(3))))), "a later level is kept");
+ ext.remove(b"ADBE");
+ let mut doc = fixture();
+ doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext))).unwrap();
+ add(&mut doc, b"PRC rest");
+ assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(1))))), "other extensions are kept");
+
+ let mut doc = fixture();
+ let mut ext = Dict::new();
+ ext.set(b"ADBE".to_vec(), Object::Array(vec![Object::Dict(Dict::new())]));
+ let ext = doc.add(Object::Dict(ext));
+ doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Ref(ext))).unwrap();
+ add(&mut doc, b"PRC rest");
+ let doc = reopen(&doc);
+ let ext = doc.resolve(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Extensions").unwrap());
+ assert!(ext.as_dict().unwrap().get(b"ADBE").unwrap().as_array().is_some(), "an array of ADBE extensions is left alone");
+}
+
+#[test]
+fn a_picture_is_placed_by_its_box_and_a_form_needs_a_positive_size() {
+ let at = [10.0, 20.0, 110.0, 70.0];
+ assert_eq!(appearance::picture_place(at, true, None).as_deref(), Some("100 0 0 50 10 20 cm"), "an image fills its unit square");
+ assert_eq!(appearance::picture_place(at, false, Some((200.0, 25.0))).as_deref(), Some("0.5 0 0 2 10 20 cm"));
+ for size in [None, Some((0.0, 25.0)), Some((200.0, -1.0))] {
+ assert_eq!(appearance::picture_place(at, false, size), None, "{size:?}");
+ }
+}
+
+/// The poster's box must lie in the rectangle, and a form poster needs a size to scale by.
+#[test]
+fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() {
+ let picture = |at: [f64; 4], size: (f64, f64)| PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size, at };
+ for (poster, why) in [
+ (picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"),
+ (picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"),
+ (picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"),
+ (picture([100.0 - 2e-7, 150.0, 100.0 - 1e-7, 200.0], (10.0, 10.0)), "lies outside within the rounding slack"),
+ ] {
+ let mut doc = fixture();
+ let new = NewModel3d {
+ page: 0,
+ rect: [100.0, 100.0, 300.0, 300.0],
+ model: b"PRC rest",
+ activation: Activation3d::default(),
+ poster: Some(poster),
+ contents: "",
+ };
+ assert!(matches!(add_3d_model(&mut doc, &new, &meta("m")), Err(AnnotError::Invalid(_))), "{why}");
+ assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added");
+ }
+}
+
+/// A poster box fitted to the rectangle in floating point ends an ulp outside it: a 7×3 picture
+/// centred in [0 100 3.6 110.8] spans -2.2e-16 to 3.6000000000000005. It is taken, inside the
+/// rectangle.
+#[test]
+fn a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken() {
+ let mut doc = fixture();
+ let at = [-2.220446049250313e-16, 104.62857142857143, 3.6000000000000005, 106.17142857142858];
+ let poster = PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size: (7.0, 3.0), at };
+ let new = NewModel3d {
+ page: 0,
+ rect: [0.0, 100.0, 3.6, 110.8],
+ model: b"PRC rest",
+ activation: Activation3d::default(),
+ poster: Some(poster),
+ contents: "",
+ };
+ assert_eq!(add_3d_model(&mut doc, &new, &meta("m")), Ok(1));
+}
+
#[test]
fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
let mut doc = fixture();
diff --git a/crates/annot/src/threed.rs b/crates/annot/src/threed.rs
new file mode 100644
index 0000000000..cf828388c0
--- /dev/null
+++ b/crates/annot/src/threed.rs
@@ -0,0 +1,292 @@
+//! 3D annotations (ISO 32000-2 §13.6.2): a U3D or PRC model shown in a rectangle on a page,
+//! with a poster (`/AP /N`) for printing, for readers without 3D, and until it is activated.
+
+use super::create::{finite, normalize, num_array};
+use super::lookup::{annots, page_ref, set_annots};
+use super::types::{FLAG_PRINT, Meta};
+use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, Stream, appearance, n};
+
+/// The largest model accepted, in bytes: well past real U3D/PRC files, and bounded because the
+/// model is held and compressed in memory.
+pub const MAX_MODEL_BYTES: usize = 256 << 20;
+
+/// What the poster says when there is no picture.
+const PLACEHOLDER_LABEL: &str = "3D model";
+
+/// A 3D stream's data format (§13.6.3): ECMA-363 U3D or ISO 14739-1 PRC.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ModelFormat {
+ U3d,
+ Prc,
+}
+
+impl ModelFormat {
+ /// The format a model file's first bytes declare: U3D files start `U3D\0` (ECMA-363 file
+ /// header block type), PRC files start `PRC`.
+ ///
+ /// # Errors
+ ///
+ /// [`AnnotError::NotAModel`] for anything else.
+ pub fn detect(bytes: &[u8]) -> Result {
+ if bytes.starts_with(b"U3D\0") {
+ Ok(Self::U3d)
+ } else if bytes.starts_with(b"PRC") {
+ Ok(Self::Prc)
+ } else {
+ Err(AnnotError::NotAModel)
+ }
+ }
+
+ /// The 3D stream's `/Subtype`.
+ pub fn subtype(self) -> &'static str {
+ match self {
+ Self::U3d => "U3D",
+ Self::Prc => "PRC",
+ }
+ }
+
+ /// The PDF version a document holding it needs (§13.6): a 3D annotation is 1.6, a PRC stream
+ /// 1.7 under Adobe's extension, declared by [`add_3d_model`] (PDF 2.0 takes PRC as standard).
+ pub fn version(self) -> &'static str {
+ match self {
+ Self::U3d => "1.6",
+ Self::Prc => "1.7",
+ }
+ }
+}
+
+/// When a reader turns the poster into the live model (§13.6.2, the activation dictionary's
+/// `/A`). The standard's default is [`Self::Click`].
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Activation {
+ /// `/XA`: when the user clicks it; it prints as its poster.
+ #[default]
+ Click,
+ /// `/PO`: when the page opens.
+ PageOpen,
+ /// `/PV`: when the page becomes visible.
+ PageVisible,
+}
+
+impl Activation {
+ /// Every activation, with the word a tool argument names it by.
+ pub const ALL: [(Self, &'static str); 3] = [(Self::Click, "click"), (Self::PageOpen, "page_open"), (Self::PageVisible, "page_visible")];
+
+ /// The `/A` name.
+ pub fn name(self) -> &'static str {
+ match self {
+ Self::Click => "XA",
+ Self::PageOpen => "PO",
+ Self::PageVisible => "PV",
+ }
+ }
+}
+
+/// A picture already in the document (an image XObject, or a form XObject whose `/Matrix` maps it
+/// to `size` points) drawn into `at`, a box inside the annotation's rectangle.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PosterPicture {
+ pub xobject: ObjRef,
+ pub image: bool,
+ pub size: (f64, f64),
+ pub at: [f64; 4],
+}
+
+/// A 3D model to add on `page` (0-based) in `rect` (user space).
+#[derive(Clone, Debug, PartialEq)]
+pub struct NewModel3d<'a> {
+ pub page: usize,
+ pub rect: [f64; 4],
+ pub model: &'a [u8],
+ pub activation: Activation,
+ /// The poster's picture; `None` draws a labelled frame.
+ pub poster: Option,
+ /// `/Contents`: the annotation's alternate description (§12.5.2).
+ pub contents: &'a str,
+}
+
+/// Check a model before anything is added: its size and its format.
+///
+/// # Errors
+///
+/// [`AnnotError::ModelTooLarge`] past [`MAX_MODEL_BYTES`], else as [`ModelFormat::detect`].
+pub fn check_model(model: &[u8]) -> Result {
+ check_model_size(model, MAX_MODEL_BYTES)?;
+ ModelFormat::detect(model)
+}
+
+fn check_model_size(model: &[u8], max: usize) -> Result<(), AnnotError> {
+ if model.len() > max {
+ return Err(AnnotError::ModelTooLarge { size: model.len(), max });
+ }
+ Ok(())
+}
+
+/// Add a 3D annotation: the model in a Flate-compressed 3D stream referenced from `/3DD` (so the
+/// annotation has its own instance, §13.6.2), `/3DA` stating only `/A`, and the poster as `/AP /N`.
+/// No view (`/3DV`, `/VA`) is written: readers open on the model's own default view. A document
+/// older than [`ModelFormat::version`] is raised to it. Returns its index in the page's `/Annots`.
+///
+/// # Errors
+///
+/// As [`check_model`]; [`AnnotError::NoSuchPage`]; [`AnnotError::Invalid`] for a rectangle with no
+/// area or a poster box outside it.
+pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Result {
+ let format = check_model(new.model)?;
+ let page = page_ref(doc, new.page)?;
+ let rect = normalize(new.rect);
+ if !finite(&new.rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
+ return Err(AnnotError::Invalid("invalid rectangle for the 3D model (each side at least 1 pt)".into()));
+ }
+ let poster = match &new.poster {
+ Some(p) => picture_poster(rect, p)?,
+ None => placeholder_poster(rect),
+ };
+ let mut stream = Dict::new();
+ stream.set(b"Type".to_vec(), Object::name("3D"));
+ stream.set(b"Subtype".to_vec(), Object::name(format.subtype()));
+ let stream = doc.add(Object::Stream(Stream::flate(stream, new.model)));
+ let poster = doc.add(Object::Stream(poster));
+ let d = annotation_dict(new, rect, page, stream, poster, meta);
+ let r = doc.add(Object::Dict(d));
+ let mut list = annots(doc, page);
+ let index = list.len();
+ list.push(Object::Ref(r));
+ set_annots(doc, page, list)?;
+ at_least_version(doc, format.version())?;
+ if format == ModelFormat::Prc && !at_least(doc, "2.0") {
+ declare_prc_extension(doc)?;
+ }
+ Ok(index)
+}
+
+/// Whether the header or the catalog `/Version` says `min` or later (§7.7.2).
+fn at_least(doc: &Document, min: &str) -> bool {
+ let reached = |v: &[u8]| v.len() >= 3 && v >= min.as_bytes();
+ let catalog = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()));
+ let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec));
+ reached(doc.version().as_bytes()) || catalog.as_deref().is_some_and(reached)
+}
+
+/// PRC came before PDF 2.0 as an Adobe extension to 1.7: such a file declares
+/// `/Extensions /ADBE` with ExtensionLevel 1 or later (§7.12). Other extensions and an ADBE entry
+/// that already has a level are kept; one that is not a dictionary (an array of them) is left alone.
+fn declare_prc_extension(doc: &mut Document) -> Result<(), AnnotError> {
+ let Some(root) = doc.root() else { return Ok(()) };
+ let catalog = doc.get(root);
+ // `None` when present but not a dictionary; an empty one when absent.
+ let dict_or_new = |o: Option<&Object>| match o.map(|o| doc.resolve(o)) {
+ None => Some(Dict::new()),
+ Some(o) => o.as_dict().cloned(),
+ };
+ let Some(mut extensions) = dict_or_new(catalog.as_dict().and_then(|c| c.get(b"Extensions"))) else { return Ok(()) };
+ let Some(mut adbe) = dict_or_new(extensions.get(b"ADBE")) else { return Ok(()) };
+ if adbe.get(b"ExtensionLevel").map(|l| doc.resolve(l)).and_then(|l| l.as_int()).is_some_and(|l| l >= 1) {
+ return Ok(());
+ }
+ adbe.set(b"BaseVersion".to_vec(), Object::name("1.7"));
+ adbe.set(b"ExtensionLevel".to_vec(), Object::Int(1));
+ extensions.set(b"ADBE".to_vec(), Object::Dict(adbe));
+ doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(extensions)))?;
+ Ok(())
+}
+
+/// A document whose header and catalog `/Version` both say less than `min` gets `/Version min` in
+/// its catalog (§7.7.2: the later of the two applies).
+fn at_least_version(doc: &mut Document, min: &str) -> Result<(), AnnotError> {
+ let Some(root) = doc.root() else { return Ok(()) };
+ if !at_least(doc, min) {
+ doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?;
+ }
+ Ok(())
+}
+
+fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: ObjRef, poster: ObjRef, meta: &Meta) -> Dict {
+ let mut d = Dict::new();
+ d.set(b"Type".to_vec(), Object::name("Annot"));
+ d.set(b"Subtype".to_vec(), Object::name("3D"));
+ d.set(b"Rect".to_vec(), num_array(&rect));
+ d.set(b"P".to_vec(), Object::Ref(page));
+ d.set(b"F".to_vec(), Object::Int(FLAG_PRINT));
+ if !new.contents.is_empty() {
+ d.set(b"Contents".to_vec(), PdfString::text(new.contents));
+ }
+ if !meta.id.is_empty() {
+ d.set(b"NM".to_vec(), PdfString::text(&meta.id));
+ }
+ if let Some(date) = &meta.date {
+ d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
+ }
+ d.set(b"3DD".to_vec(), Object::Ref(stream));
+ let mut activation = Dict::new();
+ activation.set(b"A".to_vec(), Object::name(new.activation.name()));
+ d.set(b"3DA".to_vec(), Object::Dict(activation));
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Ref(poster));
+ d.set(b"AP".to_vec(), Object::Dict(ap));
+ d
+}
+
+/// The poster drawing `p` into its box, which must lie within `rect`.
+fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result {
+ let at = normalize(p.at);
+ // A box fitted to `rect` in floating point can end a few ulps outside it: allow that, then clamp.
+ let slack = rect.iter().fold(1.0_f64, |m, v| m.max(v.abs())) * 1e-9;
+ let near = finite(&p.at) && at[0] >= rect[0] - slack && at[1] >= rect[1] - slack && at[2] <= rect[2] + slack && at[3] <= rect[3] + slack;
+ let at = [at[0].max(rect[0]), at[1].max(rect[1]), at[2].min(rect[2]), at[3].min(rect[3])];
+ if !(near && at[2] > at[0] && at[3] > at[1]) {
+ return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into()));
+ }
+ let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?;
+ let mut xobjects = Dict::new();
+ xobjects.set(b"Pic".to_vec(), Object::Ref(p.xobject));
+ let mut resources = Dict::new();
+ resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
+ Ok(appearance::form(rect, format!("q {place} /Pic Do Q\n").as_bytes(), resources))
+}
+
+/// A light frame with "3D model" centred in it, shrunk to fit (left out when even 2 pt won't).
+fn placeholder_poster(rect: [f64; 4]) -> Stream {
+ let [x0, y0, x1, y1] = rect;
+ let (w, h) = (x1 - x0, y1 - y0);
+ let mut c =
+ format!("0.94 g {} {} {} {} re f\n0.45 G 1 w {} {} {} {} re S\n", n(x0), n(y0), n(w), n(h), n(x0 + 0.5), n(y0 + 0.5), n(w - 1.0), n(h - 1.0));
+ let mut resources = Dict::new();
+ let unit = appearance::text_width(PLACEHOLDER_LABEL, 1.0);
+ let size = if unit > 0.0 { (w * 0.8 / unit).min(h * 0.4).min(14.0) } else { 0.0 };
+ if size >= 2.0 {
+ let (tx, ty) = (x0 + (w - unit * size) / 2.0, y0 + (h - size * 0.7) / 2.0);
+ c.push_str(&format!("BT 0.3 g /Helv {} Tf {} {} Td ({PLACEHOLDER_LABEL}) Tj ET\n", n(size), n(tx), n(ty)));
+ resources.set(b"Font".to_vec(), appearance::helvetica_resource());
+ }
+ appearance::form(rect, c.as_bytes(), resources)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn model_size_is_capped() {
+ assert_eq!(check_model_size(&[0; 8], 8), Ok(()));
+ assert_eq!(check_model_size(&[0; 9], 8), Err(AnnotError::ModelTooLarge { size: 9, max: 8 }));
+ }
+
+ #[test]
+ fn the_format_is_read_from_the_header() {
+ assert_eq!(ModelFormat::detect(b"U3D\0rest"), Ok(ModelFormat::U3d));
+ assert_eq!(ModelFormat::detect(b"PRC8800"), Ok(ModelFormat::Prc));
+ for junk in [&b""[..], b"U3D", b"U3DX", b"PR", b"%PDF-1.7", b"prc"] {
+ assert_eq!(ModelFormat::detect(junk), Err(AnnotError::NotAModel), "{junk:?}");
+ }
+ }
+
+ #[test]
+ fn the_placeholder_label_shrinks_to_fit_and_is_dropped_when_it_cannot() {
+ let wide = String::from_utf8(placeholder_poster([0.0, 0.0, 300.0, 200.0]).decoded().unwrap().to_vec()).unwrap();
+ assert!(wide.contains("/Helv 14 Tf") && wide.contains("(3D model) Tj"), "{wide}");
+ let tiny = placeholder_poster([0.0, 0.0, 4.0, 4.0]);
+ assert!(!String::from_utf8(tiny.decoded().unwrap().to_vec()).unwrap().contains("Tj"));
+ assert!(tiny.dict.get(b"Resources").and_then(Object::as_dict).is_none_or(|r| r.get(b"Font").is_none()));
+ }
+}
diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml
index a6e43de1f0..a825b1fe52 100644
--- a/crates/automation/Cargo.toml
+++ b/crates/automation/Cargo.toml
@@ -14,6 +14,8 @@ pdfcraft-cos = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-organize = { workspace = true }
pdfcraft-platform = { workspace = true }
+pdfcraft-prc = { workspace = true }
+pdfcraft-prc-render = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.11"
tempfile = "3.27"
diff --git a/crates/automation/README.md b/crates/automation/README.md
index 8ffa2d0b3e..d218fbcda6 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -250,6 +250,15 @@ choices and values over 4096 UTF-8 bytes return an error.
Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
+## Rendering a 3D model
+
+`model_render` draws a page's 3D model (a 3D annotation's PRC stream, directly or through a 3D
+reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1);
+`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye
+(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true.
+The model is framed to fill the image and shaded by a headlight on white. U3D models are refused
+with an error, and the annotation's own views, lighting and render modes are not applied yet.
+
## Viewer navigation history
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
diff --git a/crates/automation/src/bookmark_io.rs b/crates/automation/src/bookmark_io.rs
new file mode 100644
index 0000000000..2a06d912f4
--- /dev/null
+++ b/crates/automation/src/bookmark_io.rs
@@ -0,0 +1,209 @@
+//! Bookmark trees in and out of documents: `bookmark_export`, `bookmark_import`, `bookmark_copy`.
+//!
+//! The portable form is JSON: `{"bookmarks": [node, …]}`, where a node is `title`, `page`
+//! (1-based, or `null` for one that goes nowhere), `view` (`{"type", "values"}`: a destination
+//! type and its values in PDF user space, as the file stores them), `color`, `bold`, `italic`,
+//! `open` and `children`. What export writes, import takes back unchanged. A file also carries
+//! `"truncated": true` when the export left out bookmarks nested past the listing limit.
+use crate::{Args, Automation, Result, ToolError, failed};
+use pdfcraft_engine::{DocId, Edit, OutlineNode};
+use serde_json::{Value, json};
+
+/// Deepest nesting a bookmark tree may have (the listing reaches 33 levels).
+const MAX_DEPTH: usize = 33;
+
+/// The keys a node may have: the ones [`node_json`] writes.
+const NODE_KEYS: [&str; 8] = ["title", "page", "view", "color", "bold", "italic", "open", "children"];
+
+impl Automation {
+ pub(crate) fn bookmark_export(&self, a: &Args) -> Result {
+ let id = self.doc(a)?.id;
+ let path = a.opt_path("path")?.unwrap_or_default();
+ let tree = self.outline_of(id, &path)?;
+ let mut out = json!({ "bookmarks": tree.nodes.iter().map(node_json).collect::>(), "truncated": tree.truncated });
+ if let Some(file) = a.opt_str("out")? {
+ let target = self.resolve(file, true)?;
+ let mut saved = json!({ "bookmarks": out["bookmarks"] });
+ if tree.truncated {
+ saved["truncated"] = json!(true);
+ }
+ let text = serde_json::to_vec_pretty(&saved).map_err(failed)?;
+ crate::write_atomic(&target, &text)?;
+ out["path"] = json!(target);
+ }
+ Ok(out)
+ }
+
+ pub(crate) fn bookmark_import(&mut self, a: &Args) -> Result {
+ let (nodes, truncated) = match (a.get("bookmarks"), a.opt_str("file")?) {
+ (Some(list), None) => (nodes_of(list)?, false),
+ (None, Some(file)) => {
+ let path = self.resolve(file, false)?;
+ let value: Value = serde_json::from_slice(&crate::read_input(&path)?).map_err(|e| failed(format!("not a bookmark file: {e}")))?;
+ let list = value.get("bookmarks").ok_or_else(|| failed("not a bookmark file: it has no \"bookmarks\" list"))?;
+ (nodes_of(list)?, value.get("truncated") == Some(&Value::Bool(true)))
+ }
+ _ => return Err(ToolError::InvalidArgs("give either bookmarks or file".into())),
+ };
+ let mut out = self.write_tree(a, nodes)?;
+ out["truncated"] = json!(truncated);
+ Ok(out)
+ }
+
+ pub(crate) fn bookmark_copy(&mut self, a: &Args) -> Result {
+ let from = a.int("from")?;
+ let from = DocId(u64::try_from(from).map_err(|_| ToolError::InvalidArgs("from must be positive".into()))?);
+ let path = a.opt_path("path")?.unwrap_or_default();
+ let tree = self.outline_of(from, &path)?;
+ let mut out = self.write_tree(a, tree.nodes)?;
+ out["truncated"] = json!(tree.truncated);
+ Ok(out)
+ }
+
+ fn outline_of(&self, id: DocId, path: &[usize]) -> Result {
+ match self.session.outline_tree(id, path) {
+ // The engine names the path 0-based; the caller gave it 1-based.
+ Some(tree) => tree.map_err(|e| match e {
+ pdfcraft_organize::OutlineError::NoSuchBookmark(_) => failed(format!("there is no bookmark at {:?}", one_based(path))),
+ e => failed(e),
+ }),
+ None => Err(failed(format!("document {} is not open, or its bookmarks can't be read for editing", id.0))),
+ }
+ }
+
+ /// Write `nodes` into `doc` under `parent` at `position` (or in place of the outline with
+ /// `replace`), and say which of them went nowhere because the document lacks their page.
+ fn write_tree(&mut self, a: &Args, bookmarks: Vec) -> Result {
+ let parent = a.opt_path("parent")?.unwrap_or_default();
+ let index = a.opt_int("position")?.map_or(usize::MAX, |p| usize::try_from(p.max(1) - 1).unwrap_or(usize::MAX));
+ let replace = a.opt_bool("replace")?.unwrap_or(false);
+ let count = self.doc(a)?.info.pages.len();
+ let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, count);
+ let mut out = self.apply(a, Edit::ImportBookmarks { parent, index, bookmarks, replace })?;
+ out["unmapped"] = json!(unmapped.iter().map(|p| one_based(p)).collect::>());
+ Ok(out)
+ }
+}
+
+fn one_based(path: &[usize]) -> Vec {
+ path.iter().map(|i| i.saturating_add(1)).collect()
+}
+
+/// A node as JSON, leaving out what is at its default (no view, black, regular, no children).
+fn node_json(n: &OutlineNode) -> Value {
+ let mut v = json!({ "title": n.title, "page": n.page.map(|p| p.saturating_add(1)), "open": n.open });
+ if let Some((kind, values)) = &n.view {
+ v["view"] = json!({ "type": String::from_utf8_lossy(kind), "values": values });
+ }
+ if let Some(c) = n.color {
+ v["color"] = json!(c);
+ }
+ if n.bold {
+ v["bold"] = json!(true);
+ }
+ if n.italic {
+ v["italic"] = json!(true);
+ }
+ if !n.children.is_empty() {
+ v["children"] = json!(n.children.iter().map(node_json).collect::>());
+ }
+ v
+}
+
+fn nodes_of(list: &Value) -> Result> {
+ let mut count = 0;
+ list_of(list, &[], &mut count)
+}
+
+/// The nodes of one level; `at` is the 1-based path of their parent, for messages.
+fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result> {
+ let items = list.as_array().ok_or_else(|| bad(at, "children must be a list of bookmarks"))?;
+ if at.len() >= MAX_DEPTH && !items.is_empty() {
+ return Err(bad(at, &format!("bookmarks may nest at most {MAX_DEPTH} levels deep")));
+ }
+ *count = count.saturating_add(items.len());
+ if *count > pdfcraft_engine::MAX_IMPORT_BOOKMARKS {
+ return Err(ToolError::InvalidArgs(format!("at most {} bookmarks can be imported at once", pdfcraft_engine::MAX_IMPORT_BOOKMARKS)));
+ }
+ let mut out = Vec::with_capacity(items.len());
+ for (i, item) in items.iter().enumerate() {
+ let mut path = at.to_vec();
+ path.push(i.saturating_add(1));
+ out.push(node_of(item, &path, count)?);
+ }
+ Ok(out)
+}
+
+/// One node and, through [`list_of`], its children (recursion bounded by `MAX_DEPTH`).
+fn node_of(v: &Value, at: &[usize], count: &mut usize) -> Result {
+ let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?;
+ if let Some(k) = o.keys().find(|k| !NODE_KEYS.contains(&k.as_str())) {
+ return Err(bad(at, &format!("unknown key {k:?} (a bookmark has {})", NODE_KEYS.join(", "))));
+ }
+ let field = |k: &str| o.get(k).filter(|x| !x.is_null());
+ // An empty title is kept: files have them, and export writes them as "".
+ let title = field("title").and_then(Value::as_str).ok_or_else(|| bad(at, "needs a title"))?.to_string();
+ let page = match field("page") {
+ None => None,
+ Some(p) => Some(p.as_u64().filter(|p| *p >= 1).and_then(|p| usize::try_from(p - 1).ok()).ok_or_else(|| bad(at, "page must be 1 or more"))?),
+ };
+ let flag = |k: &str| field(k).map_or(Ok(None), |x| x.as_bool().map(Some).ok_or_else(|| bad(at, &format!("{k} must be true or false"))));
+ let color = match field("color") {
+ None => None,
+ Some(c) => {
+ let rgb: Option> = c.as_array().and_then(|c| c.iter().map(|x| x.as_f64().map(|x| x as f32)).collect());
+ match rgb.as_deref() {
+ Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]),
+ _ => return Err(bad(at, "color must be [r, g, b], each from 0 to 1")),
+ }
+ }
+ };
+ let children = match field("children") {
+ None => Vec::new(),
+ Some(list) => list_of(list, at, count)?,
+ };
+ Ok(OutlineNode {
+ title,
+ page,
+ view: field("view").map(|x| view_of(x, at)).transpose()?,
+ color,
+ italic: flag("italic")?.unwrap_or(false),
+ bold: flag("bold")?.unwrap_or(false),
+ open: flag("open")?.unwrap_or(true),
+ children,
+ })
+}
+
+/// `{"type": "XYZ", "values": [left, top, zoom]}`: a destination type with exactly its values.
+fn view_of(v: &Value, at: &[usize]) -> Result<(Vec, Vec>)> {
+ let kind = v.get("type").and_then(Value::as_str).ok_or_else(|| bad(at, "view needs a type such as XYZ or Fit"))?;
+ let values = match v.get("values").filter(|x| !x.is_null()) {
+ None => Vec::new(),
+ Some(list) => {
+ let list = list.as_array().filter(|l| l.len() <= 4).ok_or_else(|| bad(at, "view values must be a list of at most 4 numbers or nulls"))?;
+ list.iter()
+ .map(|x| match x {
+ Value::Null => Ok(None),
+ x => x.as_f64().map(Some).ok_or_else(|| bad(at, "view values must be numbers or null")),
+ })
+ .collect::>()?
+ }
+ };
+ let arity = pdfcraft_engine::outline_view_arity(kind.as_bytes()).ok_or_else(|| bad(at, VIEW_TYPES))?;
+ if values.len() != arity {
+ return Err(bad(at, &format!("a {kind} view takes {arity} values (numbers, or null to keep the reader's)")));
+ }
+ if kind == "FitR" && values.iter().any(Option::is_none) {
+ return Err(bad(at, "a FitR view needs all four values: left, bottom, right, top"));
+ }
+ Ok((kind.as_bytes().to_vec(), values))
+}
+
+const VIEW_TYPES: &str = "view type must be XYZ (3 values: left, top, zoom), Fit, FitB (none), FitH, FitV, FitBH, FitBV (1) or FitR (4)";
+
+fn bad(at: &[usize], what: &str) -> ToolError {
+ if at.is_empty() {
+ return ToolError::InvalidArgs(format!("bookmarks: {what}"));
+ }
+ ToolError::InvalidArgs(format!("bookmark {at:?}: {what}"))
+}
diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs
index ae46be87d0..135d0868ee 100644
--- a/crates/automation/src/comments.rs
+++ b/crates/automation/src/comments.rs
@@ -75,7 +75,7 @@ fn to_user(p: &PageInfo, x: f64, y: f64) -> [f64; 2] {
[ux as f64, uy as f64]
}
-fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
+pub(crate) fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
let (a, b) = (to_user(p, r[0], r[1]), to_user(p, r[2], r[3]));
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs
index f3c51d22e0..45df846ebc 100644
--- a/crates/automation/src/dispatch.rs
+++ b/crates/automation/src/dispatch.rs
@@ -69,6 +69,7 @@ impl Automation {
self.apply(&a, edit)?
}
"page_render" => return self.page_render(&a),
+ "model_render" => return self.model_render(&a),
"comment_image_preview" => return self.comment_image_preview(&a),
"text_extract" => self.text_extract(&a)?,
"text_word_count" => self.text_word_count(&a)?,
@@ -136,6 +137,7 @@ impl Automation {
out["detected"] = json!(given.is_none());
out
}
+ "page_enhance_scan" => self.page_enhance_scan(&a)?,
"page_delete" => {
let pages = self.pages(&a, "pages")?;
self.apply(&a, Edit::DeletePages { pages })?
@@ -196,6 +198,9 @@ impl Automation {
out
}
"bookmark_list" => self.bookmark_listing(&a)?,
+ "bookmark_export" => self.bookmark_export(&a)?,
+ "bookmark_import" => self.bookmark_import(&a)?,
+ "bookmark_copy" => self.bookmark_copy(&a)?,
"bookmark_add" => {
let page = self.page(&a)?;
let parent = a.opt_path("parent")?.unwrap_or_default();
@@ -769,6 +774,7 @@ impl Automation {
"comment_list" => self.comment_list(&a)?,
"comment_add" => self.comment_add(&a)?,
"stamp_custom" => self.stamp_custom(&a)?,
+ "model_add" => self.model_add(&a)?,
"comment_reply" => self.comment_reply(&a)?,
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
@@ -914,3 +920,35 @@ impl Automation {
Ok(out)
}
}
+
+impl Automation {
+ /// `page_enhance_scan`: Enhance ▸ Scanned document on the chosen pages (all by default).
+ fn page_enhance_scan(&mut self, a: &Args) -> Result {
+ let pages = match a.opt_ints("pages")? {
+ Some(_) => self.pages(a, "pages")?,
+ None => Vec::new(),
+ };
+ let level = match a.opt_int("level")? {
+ None => pdfcraft_engine::scan_clean::ScanCleanSettings::default().level,
+ Some(l @ 0..=100) => l as u8,
+ Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))),
+ };
+ let mut settings = pdfcraft_engine::scan_clean::ScanCleanSettings::at(level);
+ if let Some(on) = a.opt_bool("edge_shadows")? {
+ settings.edge_shadows = on;
+ }
+ if let Some(on) = a.opt_bool("despeckle")? {
+ settings.despeckle = on;
+ }
+ let id = self.doc(a)?.id;
+ let report = self.session.enhance_scanned_pages(id, &pages, settings).map_err(failed)?;
+ let numbered = |p: &[usize]| p.iter().map(|p| p + 1).collect::>();
+ let skipped: Vec = report.skipped.iter().map(|k| json!({ "pages": numbered(&k.pages), "name": k.name, "reason": k.reason })).collect();
+ let mut out = summary(self.doc(a)?);
+ out["pages_cleaned"] = json!(numbered(&report.pages));
+ out["images"] = json!(report.images);
+ out["not_scanned"] = json!(numbered(&report.not_scanned));
+ out["skipped"] = json!(skipped);
+ Ok(out)
+ }
+}
diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs
index 067a593888..dab2760f5d 100644
--- a/crates/automation/src/file_access.rs
+++ b/crates/automation/src/file_access.rs
@@ -394,8 +394,18 @@ mod tests {
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
// inputs and out_dir, walks only inside them (skipping links) and resolves every
// discovered file again before `process` reads it or `publish` writes it.
- const RESOLVED_BY_CALLER: &[&str] =
- &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
+ const RESOLVED_BY_CALLER: &[&str] = &[
+ "write_atomic",
+ "write_atomic_with",
+ "read_input",
+ "read_input_bytes",
+ "read_model",
+ "read_review_sidecar",
+ "process",
+ "linked",
+ "walk",
+ "publish",
+ ];
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut files = vec![];
while let Some(dir) = dirs.pop() {
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 5bcb032fce..520d836b01 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -27,6 +27,8 @@ mod links;
#[cfg(feature = "mcp")]
pub mod mcp;
mod measure;
+mod model3d;
+mod model_render;
mod printing;
mod reading;
mod redact;
@@ -50,6 +52,7 @@ use file_access::foreign_share;
use text_cache::TextCache;
mod assemble;
+mod bookmark_io;
mod dispatch;
mod documents;
mod error;
diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs
index 84cc21b258..b033871867 100644
--- a/crates/automation/src/mcp.rs
+++ b/crates/automation/src/mcp.rs
@@ -557,7 +557,7 @@ fn tool_json(t: &crate::ToolDef) -> Value {
"title": t.title,
"description": t.description,
"inputSchema": t.input_schema,
- "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save"), "openWorldHint": false },
+ "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save" | "bookmark_export"), "openWorldHint": false },
})
}
diff --git a/crates/automation/src/model3d.rs b/crates/automation/src/model3d.rs
new file mode 100644
index 0000000000..2d89e4ae60
--- /dev/null
+++ b/crates/automation/src/model3d.rs
@@ -0,0 +1,114 @@
+//! 3D model tools (Acrobat: Rich Media ▸ Add 3D, AGENTS.md §3).
+
+use std::path::Path;
+
+use pdfcraft_engine::{Activation3d, Edit, MAX_3D_MODEL_BYTES, MarkFile, Model3dFormat};
+use serde_json::Value;
+
+use crate::comments::rect_to_user;
+use crate::{Args, Automation, Result, ToolError, failed};
+
+/// The largest side of a 3D model's box, in points: the largest page PDF allows (ISO 32000-2 Annex C).
+const MAX_MODEL_SIDE: f64 = 14_400.0;
+
+impl Automation {
+ /// A U3D or PRC model in `rect` on `page`, its poster the picture at `poster` (a PDF page or an
+ /// image) fitted inside the box, or a labelled frame.
+ pub(crate) fn model_add(&mut self, a: &Args) -> Result {
+ let page = self.page(a)?;
+ let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("page {} does not exist", page + 1)))?;
+ let r = a.nums::<4>("rect")?.ok_or_else(|| ToolError::InvalidArgs("missing argument rect".into()))?;
+ let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs());
+ if !((1.0..=MAX_MODEL_SIDE).contains(&w) && (1.0..=MAX_MODEL_SIDE).contains(&h)) {
+ return Err(ToolError::InvalidArgs(format!(
+ "rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_MODEL_SIDE} pt"
+ )));
+ }
+ let activation = match a.opt_str("activation")? {
+ None => Activation3d::default(),
+ Some(word) => Activation3d::ALL
+ .iter()
+ .find(|(_, w)| *w == word)
+ .map(|(act, _)| *act)
+ .ok_or_else(|| ToolError::InvalidArgs(format!("activation must be one of: {}", Activation3d::ALL.map(|(_, w)| w).join(", "))))?,
+ };
+ let path = self.resolve(a.str("path")?, false)?;
+ let model = read_model(&path, MAX_3D_MODEL_BYTES)?;
+ let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
+ let poster = match a.opt_str("poster")? {
+ None => None,
+ Some(p) => {
+ let path = self.resolve(p, false)?;
+ let bytes = crate::read_input(&path)?;
+ let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ let page = usize::try_from(a.opt_int("poster_page")?.unwrap_or(1).max(1)).unwrap_or(1).saturating_sub(1);
+ Some(MarkFile { name, bytes: std::sync::Arc::new(bytes), page })
+ }
+ };
+ let edit = Edit::AddModel3d { page, rect: rect_to_user(&info, r), model: std::sync::Arc::new(model), poster, activation, name };
+ self.apply(a, edit)
+ }
+}
+
+/// Read a resolved model file of at most `max` bytes. Its size (from the file system) and its first
+/// bytes are checked before the rest is read, so a large file that is not a model is never loaded.
+fn read_model(path: &Path, max: usize) -> Result> {
+ use std::io::Read as _;
+ let io = |e: std::io::Error| failed(format!("{}: {e}", path.display()));
+ let too_large = |size: u64| failed(format!("the 3D model is {size} bytes; the largest accepted is {max}"));
+ let cap = u64::try_from(max).unwrap_or(u64::MAX);
+ let mut file = std::fs::File::open(path).map_err(io)?;
+ if let Ok(meta) = file.metadata()
+ && meta.is_file()
+ && meta.len() > cap
+ {
+ return Err(too_large(meta.len()));
+ }
+ let mut bytes = Vec::new();
+ (&mut file).take(4).read_to_end(&mut bytes).map_err(io)?;
+ Model3dFormat::detect(&bytes).map_err(failed)?;
+ let rest = cap.saturating_add(1).saturating_sub(bytes.len() as u64);
+ file.take(rest).read_to_end(&mut bytes).map_err(io)?;
+ if bytes.len() > max {
+ return Err(failed(format!("the 3D model is more than {max} bytes, the largest accepted")));
+ }
+ Ok(bytes)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
+ let path = dir.join(name);
+ std::fs::write(&path, bytes).unwrap();
+ path
+ }
+
+ #[test]
+ fn a_model_is_read_whole_when_it_fits() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut prc = b"PRC".to_vec();
+ prc.extend_from_slice(&[7; 40]);
+ assert_eq!(read_model(&file(dir.path(), "m.prc", &prc), 64).unwrap(), prc);
+ assert_eq!(read_model(&file(dir.path(), "m.u3d", b"U3D\0x"), 6).unwrap(), b"U3D\0x");
+ }
+
+ #[test]
+ fn a_model_past_the_cap_is_refused_by_its_size_before_it_is_read() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = file(dir.path(), "big.prc", &[b'P', b'R', b'C', 0, 0, 0, 0, 0, 0, 0]);
+ let err = read_model(&path, 4).unwrap_err().to_string();
+ assert!(err.contains("is 10 bytes") && err.contains("largest accepted is 4"), "the file's own size, from the file system: {err}");
+ }
+
+ #[test]
+ fn a_file_that_is_not_a_model_is_refused_by_its_header() {
+ let dir = tempfile::tempdir().unwrap();
+ for junk in [&b"%PDF-1.7 a page"[..], b"PR", b"", b"U3DX and more"] {
+ let err = read_model(&file(dir.path(), "junk.bin", junk), 64).unwrap_err().to_string();
+ assert!(err.contains("not a 3D model"), "{junk:?}: {err}");
+ }
+ }
+}
diff --git a/crates/automation/src/model_render.rs b/crates/automation/src/model_render.rs
new file mode 100644
index 0000000000..dbfabc6708
--- /dev/null
+++ b/crates/automation/src/model_render.rs
@@ -0,0 +1,104 @@
+//! `model_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer.
+
+use pdfcraft_engine::model3d::Model3dKind;
+use pdfcraft_prc::PrcFile;
+use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render};
+
+use super::Automation;
+use super::dispatch::Content;
+use super::error::{Result, ToolError, failed};
+use super::json::Args;
+use super::pages::encode_png;
+
+/// Default and largest side of the image, in pixels.
+pub(crate) const DEFAULT_SIDE: u32 = 512;
+pub(crate) const MAX_SIDE: u32 = 4096;
+
+/// The default view: from the front, right and above, as an isometric view looks.
+const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0];
+
+impl Automation {
+ pub(crate) fn model_render(&mut self, a: &Args) -> Result> {
+ let page = self.page(a)?;
+ let width = side(a, "width")?;
+ let height = side(a, "height")?;
+ let direction = direction(a)?;
+ let perspective = a.opt_bool("perspective")?.unwrap_or(true);
+ let doc = self.doc(a)?;
+ let found = doc.model_3d_annotations(page).map_err(failed)?;
+ let index = match a.opt_int("model")? {
+ None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?,
+ Some(n) => {
+ let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?;
+ *found
+ .get(pos)
+ .ok_or_else(|| ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())))?
+ }
+ };
+ let model = doc.model_3d(page, index).map_err(failed)?;
+ if model.kind == Model3dKind::U3d {
+ return Err(failed("this 3D model is in U3D, which can't be drawn yet; only PRC models can"));
+ }
+ let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?;
+ let meshes = assemble(&prc).map_err(failed)?;
+ let aspect = f64::from(width) / f64::from(height);
+ let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?;
+ let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?;
+ let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?;
+ Ok(vec![Content::Png { data, width: image.width, height: image.height }])
+ }
+}
+
+fn side(a: &Args, key: &str) -> Result {
+ match a.opt_int(key)? {
+ None => Ok(DEFAULT_SIDE),
+ Some(n) => u32::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)).ok_or_else(|| Args::wrong(key, &format!("between 1 and {MAX_SIDE}"))),
+ }
+}
+
+/// `direction`: three finite numbers, not all zero, from the eye towards the model.
+fn direction(a: &Args) -> Result<[f64; 3]> {
+ let Some(v) = a.get("direction") else { return Ok(DEFAULT_DIRECTION) };
+ let wrong = || Args::wrong("direction", "three numbers [x, y, z], not all zero");
+ let items = v.as_array().filter(|x| x.len() == 3).ok_or_else(wrong)?;
+ let mut out = [0.0; 3];
+ for (o, x) in out.iter_mut().zip(items) {
+ *o = x.as_f64().filter(|f| f.is_finite()).ok_or_else(wrong)?;
+ }
+ if out.iter().all(|c| *c == 0.0) {
+ return Err(wrong());
+ }
+ Ok(out)
+}
+
+/// Z is up, as CAD models are drawn, unless the view looks along it.
+fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] {
+ if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }
+}
+
+/// Straight RGBA to the premultiplied RGBA `encode_png` takes.
+fn premultiplied(mut rgba: Vec) -> Vec {
+ for [r, g, b, alpha] in rgba.as_chunks_mut::<4>().0 {
+ let a = u16::from(*alpha);
+ for c in [r, g, b] {
+ *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX);
+ }
+ }
+ rgba
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn premultiplying_scales_colour_by_alpha() {
+ assert_eq!(premultiplied(vec![200, 100, 0, 255, 200, 100, 0, 128, 9, 9, 9, 0]), vec![200, 100, 0, 255, 100, 50, 0, 128, 0, 0, 0, 0]);
+ }
+
+ #[test]
+ fn a_view_along_z_keeps_y_up() {
+ assert_eq!(up_for([0.0, 0.0, -1.0]), [0.0, 1.0, 0.0]);
+ assert_eq!(up_for(DEFAULT_DIRECTION), [0.0, 0.0, 1.0]);
+ }
+}
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 1b4b6ac4ba..14b55361c5 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -181,6 +181,13 @@ pub fn tools() -> Vec {
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
}), &["doc", "page"])),
+ t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
+ .ro()
+ .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 },
+ "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 },
+ "direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
+ "perspective": { "type": "boolean" }
+ }), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
@@ -233,6 +240,18 @@ pub fn tools() -> Vec {
}),
&["doc", "page"],
)),
+ t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, a scan drawn inside grouped Form artwork, broken data, or nothing to change). One undoable step; none when nothing changed.")
+ .cmd("ocr.enhance")
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "pages": pages("to clean up (default: all)"),
+ "level": { "type": "integer", "minimum": 0, "maximum": 100 },
+ "edge_shadows": { "type": "boolean" },
+ "despeckle": { "type": "boolean" },
+ }),
+ &["doc"],
+ )),
t("page_delete", "Delete pages", "Delete pages. Undoable until saved.")
.destructive()
.cmd("page.delete")
@@ -323,6 +342,50 @@ pub fn tools() -> Vec {
}),
&["doc", "title", "page"],
)),
+ t(
+ "bookmark_export",
+ "Export bookmarks",
+ "The bookmark tree, or the subtree at path, as a list of nodes bookmark_import takes back unchanged. A node has title, page (1-based, null when it goes nowhere), open, and when set view ({type, values}: a destination type from ISO 32000-2 §12.3.2.2 Table 149 and its values in PDF user space, as the file stores them), color ([r, g, b], 0-1), bold, italic and children. truncated is true when bookmarks nested deeper than 33 levels were left out. With out, the tree is also written there as a JSON file {\"bookmarks\": [...]}, with \"truncated\": true when that is so; out is overwritten.",
+ )
+ .destructive()
+ .with(schema(
+ json!({ "doc": doc(), "path": path("The bookmark whose subtree to export (omit for the whole outline)"), "out": path_arg() }),
+ &["doc"],
+ )),
+ t(
+ "bookmark_import",
+ "Import bookmarks",
+ "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing; a key a node does not have is refused. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.",
+ )
+ .destructive()
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "bookmarks": { "type": "array", "items": { "type": "object" }, "minItems": 1, "description": "Nodes as bookmark_export returns them." },
+ "file": path_arg(),
+ "parent": path("Parent bookmark (omit for the top level)"),
+ "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
+ "replace": { "type": "boolean", "description": "Replace the document's bookmarks instead of adding to them (default false; leave parent out)." }
+ }),
+ &["doc"],
+ )),
+ t(
+ "bookmark_copy",
+ "Copy bookmarks from another document",
+ "Copy the bookmarks of document from (or the subtree at path) into doc, by page number: under a parent bookmark (or at the top level) at a position, or with replace in place of doc's outline. A bookmark whose page doc lacks is kept without a destination and listed in unmapped (1-based paths into the copied tree). truncated is true when bookmarks nested deeper than 33 levels were left out of the copy. Undoable.",
+ )
+ .destructive()
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "from": { "type": "integer", "minimum": 1, "description": "Id of the document to copy from." },
+ "path": path("The bookmark whose subtree to copy (omit for the whole outline)"),
+ "parent": path("Parent bookmark in doc (omit for the top level)"),
+ "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
+ "replace": { "type": "boolean", "description": "Replace doc's bookmarks instead of adding to them (default false; leave parent out)." }
+ }),
+ &["doc", "from"],
+ )),
t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.")
.with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "title": { "type": "string", "minLength": 1 } }), &["doc", "path", "title"])),
t("bookmark_delete", "Delete a bookmark", "Delete a bookmark and the bookmarks under it. Undoable.")
@@ -469,6 +532,20 @@ pub fn tools() -> Vec {
}),
&["doc", "page", "path"],
)),
+ t("model_add", "Add a 3D model", "Add a 3D model (Acrobat: Rich Media ▸ Add 3D): the U3D or PRC file at path, recognised by its first bytes, as a 3D annotation in rect [x0, y0, x1, y1] (points from the top-left of the displayed page; each side 1–14400 pt; at most 256 MiB). Its poster, what prints and what readers show until the model is activated, is the picture at poster (a PDF page, poster_page default 1, or an image) fitted inside rect keeping its shape and centred; without poster, a frame labelled \"3D model\". activation: click (default), page_open or page_visible. name is its description (default: the file name). No views are added: readers open the model on its own default view. Undoable.")
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "page": { "type": "integer", "minimum": 1 },
+ "path": { "type": "string" },
+ "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
+ "poster": { "type": "string" },
+ "poster_page": { "type": "integer", "minimum": 1 },
+ "activation": { "type": "string", "enum": ["click", "page_open", "page_visible"] },
+ "name": { "type": "string" },
+ }),
+ &["doc", "page", "path", "rect"],
+ )),
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
t(
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index 13f9af4371..35c4db79c2 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -3741,6 +3741,62 @@ fn enhance_camera_image_through_tools() {
));
}
+#[test]
+fn enhance_scanned_file_cleans_the_scan_in_place_through_tools() {
+ let dir = workdir("enhance-scan");
+ // A 300 × 400 grey scan: dull paper, a dark gutter at the left, text bars and a speck.
+ let img = image::GrayImage::from_fn(300, 400, |x, y| {
+ let light = 0.55 + 0.45 * (x as f32 / 60.0).min(1.0);
+ let ink = if ((60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x)) || (x, y) == (150, 25) { 0.12 } else { 0.78 };
+ image::Luma([(ink * light * 255.0) as u8])
+ });
+ img.save(dir.join("scan.jpg")).unwrap();
+ let mut a = auto(&dir);
+ let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["scan.jpg"] }))["doc"].as_u64().unwrap();
+ ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "before.jpg" }));
+
+ let r = ok(&mut a, "page_enhance_scan", json!({ "doc": scan, "level": 100 }));
+ assert_eq!(r["undo"], "Enhance scanned file");
+ assert_eq!((r["pages_cleaned"].clone(), r["images"].clone(), r["not_scanned"].clone()), (json!([1]), json!(1), json!([])));
+ assert_eq!(r["skipped"], json!([]));
+ // In place: still one picture of the same size, still a JPEG, now with white paper.
+ let images = ok(&mut a, "page_images", json!({ "doc": scan, "page": 1 }));
+ assert_eq!((images["count"].clone(), images["images"][0]["pixels"].clone()), (json!(1), json!([300, 400])));
+ let saved = ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "after" }));
+ assert_eq!(saved["format"], "jpg");
+ let (before, after) = (image::open(dir.join("before.jpg")).unwrap().to_luma8(), image::open(dir.join("after.jpg")).unwrap().to_luma8());
+ for (x, y) in [(150, 40), (3, 40), (3, 380), (150, 25)] {
+ assert!(
+ before.get_pixel(x, y)[0] < 200 && after.get_pixel(x, y)[0] >= 230,
+ "({x}, {y}): {:?} → {:?}",
+ before.get_pixel(x, y),
+ after.get_pixel(x, y)
+ );
+ }
+ assert!(after.get_pixel(150, 62)[0] <= 60, "a stroke stays dark");
+ // Undo puts the scan back as it was.
+ ok(&mut a, "edit_undo", json!({ "doc": scan }));
+ ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "undone.jpg" }));
+ assert_eq!(std::fs::read(dir.join("undone.jpg")).unwrap(), std::fs::read(dir.join("before.jpg")).unwrap());
+
+ // Switches: despeckle alone leaves everything but the speck.
+ let r = ok(&mut a, "page_enhance_scan", json!({ "doc": scan, "pages": [1], "level": 0, "despeckle": true, "edge_shadows": false }));
+ assert_eq!(r["images"], 1);
+ // A document of text pages has no scans: nothing changes, and it says so.
+ let text = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "page_enhance_scan", json!({ "doc": text }));
+ assert_eq!((r["images"].clone(), r["not_scanned"].clone(), r["undo"].clone()), (json!(0), json!([1, 2, 3]), Value::Null));
+ // Bad arguments are refused before anything changes.
+ for bad in [
+ json!({ "doc": scan, "level": 101 }),
+ json!({ "doc": scan, "pages": [9] }),
+ json!({ "doc": scan, "despeckle": "yes" }),
+ json!({ "doc": scan, "pages": [] }),
+ ] {
+ assert!(matches!(a.call("page_enhance_scan", &bad), Err(ToolError::InvalidArgs(_))), "{bad}");
+ }
+}
+
#[test]
fn enhance_camera_image_finds_the_edges_by_itself() {
let dir = workdir("enhance-camera-auto");
@@ -10194,6 +10250,31 @@ fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_
assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1");
}
+/// Clearing a field's length limit through `form_set_props` (`"max_length": 0`) leaves it with
+/// none, even when its parent field has one it would otherwise inherit.
+#[test]
+fn clearing_a_kids_length_limit_through_tools_does_not_bring_back_its_parents() {
+ let objs = [
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
+ "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>",
+ "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
+ "<< /T (address) /FT /Tx /MaxLen 3 /Kids [6 0 R] >>",
+ "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /MaxLen 8 /Rect [10 250 150 268] /P 3 0 R >>",
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
+ ];
+ let dir = workdir("kid-max-len");
+ std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "max_length": 0 }));
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Absalom" } }));
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
+ assert!(city.get("max_length").is_none(), "no limit, not the parent's 3: {city}");
+ assert_eq!(city["value"], "Absalom", "the value is not cut to the parent's 3 characters");
+}
+
/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none,
/// even when its parent field has one it would otherwise inherit.
#[test]
@@ -10642,3 +10723,168 @@ fn multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance() {
assert_eq!(rendered, re.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), "saved appearances are identical");
std::fs::remove_dir_all(dir).unwrap();
}
+
+/// Export, then import into another document and copy between open documents: the tree comes
+/// back unchanged, a page the target lacks is reported, and the import undoes in one step.
+#[test]
+fn bookmarks_export_import_and_copy_between_documents() {
+ let dir = workdir("bookmark-io");
+ let mut a = auto(&dir);
+ let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "Intro", "page": 1 }));
+ ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "Detail", "page": 3, "parent": [1] }));
+ ok(&mut a, "bookmark_add", json!({ "doc": src, "title": "End", "page": 2 }));
+ ok(&mut a, "bookmark_set_style", json!({ "doc": src, "path": [1], "color": [1, 0, 0], "bold": true }));
+ let exported = ok(&mut a, "bookmark_export", json!({ "doc": src, "out": "marks.json" }));
+ assert_eq!(exported["truncated"], false);
+ let tree = exported["bookmarks"].clone();
+ assert_eq!((tree[0]["title"].clone(), tree[0]["color"].clone(), tree[0]["bold"].clone()), (json!("Intro"), json!([1.0, 0.0, 0.0]), json!(true)));
+ assert_eq!((tree[0]["children"][0]["title"].clone(), tree[0]["children"][0]["page"].clone()), (json!("Detail"), json!(3)));
+ assert_eq!(tree[0]["view"]["type"], "XYZ");
+ let subtree = ok(&mut a, "bookmark_export", json!({ "doc": src, "path": [1, 1] }))["bookmarks"].clone();
+ assert_eq!(subtree, json!([tree[0]["children"][0]]));
+
+ // b.pdf has two pages: Detail goes to the third, so it arrives without a destination.
+ let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
+ let imported = ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "marks.json" }));
+ assert_eq!(imported["unmapped"], json!([[1, 1]]));
+ let back = ok(&mut a, "bookmark_export", json!({ "doc": dst }))["bookmarks"].clone();
+ assert_eq!(back[0]["children"][0]["page"], json!(null));
+ assert_eq!((back[0]["title"].clone(), back[1].clone()), (tree[0]["title"].clone(), tree[1].clone()));
+ ok(&mut a, "edit_undo", json!({ "doc": dst }));
+ assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": dst }))["bookmarks"], json!([]));
+
+ // Into a document with every page, the tree is unchanged, wherever it is copied from.
+ let same = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "bookmark_add", json!({ "doc": same, "title": "Old", "page": 1 }));
+ let copied = ok(&mut a, "bookmark_copy", json!({ "doc": same, "from": src, "replace": true }));
+ assert_eq!(copied["unmapped"], json!([]));
+ assert_eq!(ok(&mut a, "bookmark_export", json!({ "doc": same }))["bookmarks"], tree);
+ ok(&mut a, "bookmark_import", json!({ "doc": same, "bookmarks": subtree, "parent": [2], "position": 1 }));
+ assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": same }))["bookmarks"][1]["children"][0]["title"], "Detail");
+}
+
+/// A one-page document whose outline has an untitled item, a /FitR destination with three of
+/// its four numbers, and a chain nested `deep` levels below them.
+fn odd_outline(deep: usize) -> Vec {
+ let first_deep = 7;
+ let mut objs = vec![
+ "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(),
+ "<< /Type /Page /Parent 2 0 R >>".into(),
+ "<< /Type /Outlines /First 5 0 R /Last 6 0 R >>".into(),
+ "<< /Title () /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>".into(),
+ format!(
+ "<< /Title (Short) /Parent 4 0 R /Prev 5 0 R /Dest [3 0 R /FitR 0 0 10]{} >>",
+ if deep > 0 { format!(" /First {first_deep} 0 R /Last {first_deep} 0 R") } else { String::new() }
+ ),
+ ];
+ for i in 0..deep {
+ let (me, parent) = (first_deep + i, if i == 0 { 6 } else { first_deep + i - 1 });
+ let child = if i + 1 < deep { format!(" /First {0} 0 R /Last {0} 0 R", me + 1) } else { String::new() };
+ objs.push(format!("<< /Title (Level) /Parent {parent} 0 R /Dest [3 0 R /Fit]{child} >>"));
+ }
+ pdf_of(&objs.iter().map(String::as_str).collect::>())
+}
+
+/// What a real file holds and the JSON form can't say exactly still copies and round-trips: an
+/// empty title stays empty, a short /FitR keeps its page and drops the view, and bookmarks past
+/// the listing depth are reported as left out at every step.
+#[test]
+fn odd_bookmarks_round_trip_and_a_cut_tree_says_so() {
+ let dir = workdir("bookmark-io-odd");
+ std::fs::write(dir.join("odd.pdf"), odd_outline(0)).unwrap();
+ std::fs::write(dir.join("deep.pdf"), odd_outline(40)).unwrap();
+ let mut a = auto(&dir);
+ let src = ok(&mut a, "doc_open", json!({ "path": "odd.pdf" }))["doc"].as_u64().unwrap();
+ let tree = ok(&mut a, "bookmark_export", json!({ "doc": src, "out": "odd.json" }))["bookmarks"].clone();
+ assert_eq!((tree[0]["title"].clone(), tree[1]["page"].clone(), tree[1].get("view")), (json!(""), json!(1), None));
+ let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "odd.json", "replace": true }))["truncated"], false);
+ // A destination with no view is written as XYZ with nulls: the same page, the reader's view.
+ let back = ok(&mut a, "bookmark_export", json!({ "doc": dst }))["bookmarks"].clone();
+ assert_eq!((back[0].clone(), back[1]["title"].clone(), back[1]["page"].clone()), (tree[0].clone(), json!("Short"), json!(1)));
+ assert_eq!(back[1]["view"], json!({ "type": "XYZ", "values": [null, null, null] }));
+ let copied = ok(&mut a, "bookmark_copy", json!({ "doc": dst, "from": src, "replace": true }));
+ assert_eq!((copied["unmapped"].clone(), copied["truncated"].clone()), (json!([]), json!(false)));
+ match a.call("bookmark_export", &json!({ "doc": src, "path": [3] })) {
+ Err(ToolError::Failed(m)) => assert!(m.contains("no bookmark at [3]"), "{m}"),
+ other => panic!("expected no bookmark, got {other:?}"),
+ }
+
+ let deep = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap();
+ assert_eq!(ok(&mut a, "bookmark_export", json!({ "doc": deep, "out": "deep.json" }))["truncated"], true);
+ let saved: Value = serde_json::from_slice(&std::fs::read(dir.join("deep.json")).unwrap()).unwrap();
+ assert_eq!(saved["truncated"], true);
+ assert_eq!(ok(&mut a, "bookmark_import", json!({ "doc": dst, "file": "deep.json", "replace": true }))["truncated"], true);
+ assert_eq!(ok(&mut a, "bookmark_copy", json!({ "doc": dst, "from": deep, "replace": true }))["truncated"], true);
+ let export = tools().into_iter().find(|t| t.name == "bookmark_export").unwrap();
+ assert!(export.destructive && !export.read_only, "bookmark_export overwrites out");
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[test]
+fn a_misspelt_bookmark_key_is_refused_not_dropped() {
+ let dir = workdir("bookmark-io-key");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let tree = json!([{ "title": "Parent", "children": [{ "title": "Red", "page": 1, "colour": [1, 0, 0] }] }]);
+ match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": tree })) {
+ Err(ToolError::InvalidArgs(m)) => assert!(m.contains("[1, 1]") && m.contains("\"colour\""), "{m}"),
+ other => panic!("expected InvalidArgs, got {other:?}"),
+ }
+ assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([]));
+ std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[test]
+fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() {
+ let dir = workdir("bookmark-io-bad");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let refused = |a: &mut Automation, bookmarks: Value| match a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": bookmarks })) {
+ Err(ToolError::InvalidArgs(m)) => m,
+ other => panic!("expected InvalidArgs, got {other:?}"),
+ };
+ let fine = json!({ "title": "Fine", "page": 1 });
+ let m = refused(&mut a, json!([fine, { "title": "Parent", "children": [{ "page": 1 }] }]));
+ assert!(m.contains("[2, 1]") && m.contains("title"), "{m}");
+ let m = refused(&mut a, json!([{ "title": 5 }]));
+ assert!(m.contains("[1]") && m.contains("title"), "{m}");
+ let m = refused(&mut a, json!([fine, { "title": "Bad", "page": 0 }]));
+ assert!(m.contains("[2]") && m.contains("page"), "{m}");
+ let m = refused(&mut a, json!([{ "title": "Bad", "color": [2, 0, 0] }]));
+ assert!(m.contains("color"), "{m}");
+ let m = refused(&mut a, json!([{ "title": "Bad", "view": { "type": "XYZ", "values": [1, 2, 3, 4, 5] } }]));
+ assert!(m.contains("view"), "{m}");
+ let mut deep = json!({ "title": "Leaf" });
+ for _ in 0..40 {
+ deep = json!({ "title": "Level", "children": [deep] });
+ }
+ assert!(refused(&mut a, json!([deep])).contains("33 levels"));
+ let m = refused(&mut a, json!([fine, { "title": "Z", "page": 1, "view": { "type": "Zoom" } }]));
+ assert!(m.contains("[2]") && m.contains("XYZ (3 values"), "{m}");
+ let m = refused(&mut a, json!([fine, { "title": "Z", "page": 1, "view": { "type": "FitH", "values": [1, 2] } }]));
+ assert!(m.contains("[2]") && m.contains("FitH view takes 1"), "{m}");
+ let m = refused(&mut a, json!([{ "title": "R", "view": { "type": "FitR", "values": [0, null, 1, 1] } }]));
+ assert!(m.contains("all four"), "{m}");
+ assert!(matches!(a.call("bookmark_import", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_))));
+ assert!(matches!(a.call("bookmark_import", &json!({ "doc": doc, "bookmarks": [fine], "file": "x.json" })), Err(ToolError::InvalidArgs(_))));
+ // A real bookmark file outside the root is refused for being outside, not for being missing.
+ let outside = dir.with_extension("outside.json");
+ std::fs::write(&outside, br#"{"bookmarks": [{"title": "Outside", "page": 1}]}"#).unwrap();
+ let name = format!("../{}", outside.file_name().unwrap().to_string_lossy());
+ match a.call("bookmark_import", &json!({ "doc": doc, "file": name })) {
+ Err(ToolError::Failed(m)) => assert!(m.contains("outside the allowed directory"), "{m}"),
+ other => panic!("expected the root to refuse it, got {other:?}"),
+ }
+ let _ = std::fs::remove_file(&outside);
+ let out = dir.with_extension("out.json");
+ let name = format!("../{}", out.file_name().unwrap().to_string_lossy());
+ match a.call("bookmark_export", &json!({ "doc": doc, "out": name })) {
+ Err(ToolError::Failed(m)) => assert!(m.contains("outside the allowed directory"), "{m}"),
+ other => panic!("expected the root to refuse it, got {other:?}"),
+ }
+ assert!(!out.exists());
+ assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([]));
+}
diff --git a/crates/automation/tests/it/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs
index a96870ca02..e65c582a73 100644
--- a/crates/automation/tests/it/automation_roots.rs
+++ b/crates/automation/tests/it/automation_roots.rs
@@ -145,6 +145,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() {
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin")));
let out = s(&base.join("out/result.pdf"));
+ std::fs::write(base.join("in/m.prc"), b"PRC model").unwrap();
+ let model = s(&base.join("in/m.prc"));
for (tool, args) in [
("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})),
("page_replace", json!({"doc": 1, "pages": [1], "path": secret})),
@@ -153,6 +155,8 @@ fn tools_that_read_a_path_are_held_to_the_read_root() {
("doc_create", json!({"from": "images", "paths": [pic]})),
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})),
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 493, 300]})),
+ ("model_add", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 100, 100]})),
+ ("model_add", json!({"doc": 1, "page": 1, "path": model, "rect": [10, 10, 100, 100], "poster": pic})),
("page_add_image", json!({"doc": 1, "page": 1, "path": pic})),
("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})),
("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})),
diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs
index d36dbe93a9..3122fe5825 100644
--- a/crates/automation/tests/it/main.rs
+++ b/crates/automation/tests/it/main.rs
@@ -8,6 +8,8 @@ mod batch;
mod button_caption;
mod form_bulk;
mod hostile_args;
+mod model_3d;
+mod model_render;
mod optimize_content_boundaries;
mod redact_after_stray_q_form;
mod redact_text_after_q;
diff --git a/crates/automation/tests/it/model_3d.rs b/crates/automation/tests/it/model_3d.rs
new file mode 100644
index 0000000000..179192f421
--- /dev/null
+++ b/crates/automation/tests/it/model_3d.rs
@@ -0,0 +1,120 @@
+//! `model_add` embeds a U3D or PRC model as a 3D annotation (ISO 32000-2 §13.6.2) with a poster,
+//! end to end: the tool, the saved file, undo, and the refusals.
+
+use pdfcraft_automation::{Automation, Content, ToolError};
+use pdfcraft_cos::{Dict, Document, Object, Stream};
+use serde_json::{Value, json};
+
+fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
+ match a.call(name, &args).unwrap().remove(0) {
+ Content::Json(value) => value,
+ other => panic!("expected JSON: {other:?}"),
+ }
+}
+
+fn png(w: u32, h: u32) -> Vec {
+ let mut out = Vec::new();
+ let mut encoder = png::Encoder::new(&mut out, w, h);
+ encoder.set_color(png::ColorType::Rgb);
+ encoder.set_depth(png::BitDepth::Eight);
+ encoder.write_header().unwrap().write_image_data(&vec![90; (w * h * 3) as usize]).unwrap();
+ out
+}
+
+fn stream(doc: &Document, obj: &Object) -> Stream {
+ match doc.resolve(obj).as_ref() {
+ Object::Stream(s) => s.clone(),
+ other => panic!("expected a stream: {other:?}"),
+ }
+}
+
+/// The 3D annotations on the first page of the saved file.
+fn saved_3d(dir: &std::path::Path) -> (Document, Vec) {
+ let doc = Document::open(std::sync::Arc::new(std::fs::read(dir.join("out.pdf")).unwrap())).unwrap();
+ let page = &pdfcraft_model::pages(&doc)[0];
+ let list = page.dict.get(b"Annots").map(|a| doc.resolve(a).as_array().cloned().unwrap()).unwrap_or_default();
+ let found = list.iter().map(|o| doc.resolve(o).as_dict().unwrap().clone()).filter(|d| d.name(b"Subtype") == Some(&b"3D"[..])).collect();
+ (doc, found)
+}
+
+#[test]
+fn model_add_embeds_the_model_with_a_fitted_poster() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut prc = b"PRC".to_vec();
+ prc.extend_from_slice(&[7; 200]);
+ std::fs::write(dir.path().join("bracket.prc"), &prc).unwrap();
+ std::fs::write(dir.path().join("poster.png"), png(80, 40)).unwrap();
+ let mut a = Automation::new().with_root(dir.path()).unwrap();
+ let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap();
+ // A 300 × 300 box from the top-left of the page: user space [100, 400, 400, 700].
+ let call =
+ json!({ "doc": doc, "page": 1, "path": "bracket.prc", "rect": [100, 100, 400, 400], "poster": "poster.png", "activation": "page_open" });
+ tool(&mut a, "model_add", call);
+ tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
+
+ let (saved, found) = saved_3d(dir.path());
+ let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") };
+ let rect: Vec = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect();
+ assert_eq!(rect, [100.0, 400.0, 400.0, 700.0]);
+ assert_eq!(annot.get(b"Contents").and_then(Object::as_string).map(|s| s.to_text()).as_deref(), Some("bracket"));
+ let model = stream(&saved, annot.get(b"3DD").unwrap());
+ assert_eq!(model.dict.name(b"Type"), Some(&b"3D"[..]));
+ assert_eq!(model.dict.name(b"Subtype"), Some(&b"PRC"[..]));
+ assert_eq!(model.dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
+ assert_eq!(model.decoded().unwrap().to_vec(), prc, "the model is stored unchanged");
+ let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone();
+ assert_eq!(activation.name(b"A"), Some(&b"PO"[..]));
+ // The 80 × 40 picture fitted in the 300 × 300 box: 300 × 150, centred top to bottom.
+ let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone();
+ let poster = stream(&saved, ap.get(b"N").unwrap());
+ let content = String::from_utf8(poster.decoded().unwrap().to_vec()).unwrap();
+ assert!(content.contains("300 0 0 150 100 475 cm /Pic Do"), "{content}");
+
+ assert_eq!(tool(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add 3D model");
+ tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
+ assert!(saved_3d(dir.path()).1.is_empty(), "undo removes it");
+}
+
+#[test]
+fn model_add_without_a_poster_draws_a_labelled_frame_and_activates_on_click() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut u3d = b"U3D\0".to_vec();
+ u3d.extend_from_slice(&[0; 60]);
+ std::fs::write(dir.path().join("part.u3d"), &u3d).unwrap();
+ let mut a = Automation::new().with_root(dir.path()).unwrap();
+ let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap();
+ tool(&mut a, "model_add", json!({ "doc": doc, "page": 1, "path": "part.u3d", "rect": [50, 50, 250, 200], "name": "Part" }));
+ tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
+
+ let (saved, found) = saved_3d(dir.path());
+ let [annot] = found.as_slice() else { panic!("one 3D annotation: {found:?}") };
+ assert_eq!(stream(&saved, annot.get(b"3DD").unwrap()).dict.name(b"Subtype"), Some(&b"U3D"[..]));
+ let activation = saved.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().clone();
+ assert_eq!(activation.name(b"A"), Some(&b"XA"[..]), "the standard's default, stated");
+ let ap = saved.resolve(annot.get(b"AP").unwrap()).as_dict().unwrap().clone();
+ let content = String::from_utf8(stream(&saved, ap.get(b"N").unwrap()).decoded().unwrap().to_vec()).unwrap();
+ assert!(content.contains("re S") && content.contains("(3D model) Tj"), "{content}");
+}
+
+#[test]
+fn model_add_refuses_what_is_not_a_model_and_adds_nothing() {
+ let dir = tempfile::tempdir().unwrap();
+ std::fs::write(dir.path().join("notes.txt"), b"PR not a model").unwrap();
+ std::fs::write(dir.path().join("part.prc"), b"PRC....").unwrap();
+ let mut a = Automation::new().with_root(dir.path()).unwrap();
+ let doc = tool(&mut a, "doc_create", json!({ "from": "blank", "width": 600, "height": 800 }))["doc"].as_u64().unwrap();
+ let not_a_model = a.call("model_add", &json!({ "doc": doc, "page": 1, "path": "notes.txt", "rect": [10, 10, 100, 100] }));
+ assert!(matches!(¬_a_model, Err(ToolError::Failed(m)) if m.contains("U3D") && m.contains("PRC")), "{not_a_model:?}");
+ for (args, why) in [
+ (json!({ "rect": [10, 10, 10, 100] }), "zero width"),
+ (json!({ "rect": [0, 0, 20000, 100] }), "wider than a PDF page can be"),
+ (json!({}), "no rect"),
+ (json!({ "rect": [10, 10, 100, 100], "activation": "now" }), "an unknown activation"),
+ ] {
+ let mut call = json!({ "doc": doc, "page": 1, "path": "part.prc" });
+ call.as_object_mut().unwrap().extend(args.as_object().unwrap().clone());
+ assert!(matches!(a.call("model_add", &call), Err(ToolError::InvalidArgs(_))), "{why}");
+ }
+ tool(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
+ assert!(saved_3d(dir.path()).1.is_empty());
+}
diff --git a/crates/automation/tests/it/model_render.rs b/crates/automation/tests/it/model_render.rs
new file mode 100644
index 0000000000..615a5346a9
--- /dev/null
+++ b/crates/automation/tests/it/model_render.rs
@@ -0,0 +1,112 @@
+//! `model_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC.
+
+use std::path::PathBuf;
+
+use pdfcraft_automation::{Automation, Content};
+use serde_json::{Value, json};
+
+/// One triangle at z = 0 facing +z, with corners (-1,-1), (1,-1) and (0,1), as a PRC file of one
+/// file structure with no product tree, in hexadecimal for the stream's `/ASCIIHexDecode`.
+const TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000cd000000d5000000dd000000e500000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c087758da2422b43084fe3140e038713464cd759381a162800006ed70828789c030000000001789c030000000001789c030000000001";
+
+/// A one-page PDF whose page holds a 3D annotation over a stream of `/Subtype /{subtype}`.
+fn pdf(subtype: &str, hex: &str) -> Vec {
+ let stream = format!("<< /Type /3D /Subtype /{subtype} /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1);
+ let objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 10 10] >> 4 0 R] >>".into(),
+ "<< /Type /Annot /Subtype /3D /Rect [20 20 180 180] /3DD 5 0 R >>".into(),
+ stream,
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+}
+
+/// A fresh folder with `prc.pdf` and `u3d.pdf` in `in`, `outside.pdf` beside it, and an
+/// automation rooted at `in`.
+fn setup(test: &str) -> (PathBuf, Automation) {
+ let dir = std::env::temp_dir().join(format!("pdfcraft-model3d-{test}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("in")).unwrap();
+ std::fs::write(dir.join("in/prc.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap();
+ std::fs::write(dir.join("in/u3d.pdf"), pdf("U3D", "553344")).unwrap();
+ std::fs::write(dir.join("outside.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap();
+ let a = Automation::new().with_root(dir.join("in")).unwrap();
+ (dir, a)
+}
+
+fn open(a: &mut Automation, path: &str) -> u64 {
+ match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) {
+ Content::Json(v) => v["doc"].as_u64().unwrap(),
+ other => panic!("expected JSON: {other:?}"),
+ }
+}
+
+/// The RGB of pixel (x, y) of a PNG.
+fn rgb(data: &[u8], x: usize, y: usize) -> [u8; 3] {
+ let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
+ let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
+ let info = reader.next_frame(&mut pixels).unwrap();
+ let channels = info.line_size / info.width as usize;
+ let at = y * info.line_size + x * channels;
+ [pixels[at], pixels[at + 1], pixels[at + 2]]
+}
+
+#[test]
+fn a_prc_model_is_drawn_over_the_background() {
+ let (_dir, mut a) = setup("prc");
+ let doc = open(&mut a, "prc.pdf");
+ let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false });
+ let out = a.call("model_render", &args).unwrap();
+ let Content::Png { data, width, height } = &out[0] else { panic!("expected a PNG") };
+ assert_eq!((*width, *height), (64, 48));
+ assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background");
+ assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on");
+ // In perspective the headlight meets the face at a slight angle, so only not-background holds.
+ let out = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap();
+ let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") };
+ assert_ne!(rgb(data, 32, 24), [255, 255, 255], "the centre is drawn in perspective too");
+ // The same model named explicitly, and the default isometric view, both draw.
+ assert!(a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok());
+}
+
+#[test]
+fn a_u3d_model_or_a_missing_one_is_a_clear_error() {
+ let (_dir, mut a) = setup("u3d");
+ let doc = open(&mut a, "u3d.pdf");
+ let err = a.call("model_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string();
+ assert!(err.contains("U3D"), "{err}");
+ let doc = open(&mut a, "prc.pdf");
+ let err = a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string();
+ assert!(err.contains("has 1 3D models, not 2"), "{err}");
+ for bad in [json!({ "direction": [0, 0, 0] }), json!({ "direction": [1, 2] }), json!({ "width": 0 }), json!({ "height": 5000 })] {
+ let mut args = json!({ "doc": doc, "page": 1 });
+ args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
+ assert!(a.call("model_render", &args).is_err(), "{bad}");
+ }
+}
+
+#[test]
+fn a_model_outside_the_root_is_never_opened_to_be_drawn() {
+ let (dir, mut a) = setup("root");
+ let outside = dir.join("outside.pdf");
+ for path in [Value::from("../outside.pdf"), Value::from(outside.to_string_lossy().into_owned())] {
+ assert!(a.call("doc_open", &json!({ "path": path })).is_err(), "{path} lies outside the root");
+ }
+ // The tool takes no path of its own: only a document opened inside the root reaches it.
+ assert!(a.call("model_render", &json!({ "doc": 1, "page": 1 })).is_err());
+ let doc = open(&mut a, "prc.pdf");
+ assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok());
+}
diff --git a/crates/cos/README.md b/crates/cos/README.md
index 7b576f293f..398b161625 100644
--- a/crates/cos/README.md
+++ b/crates/cos/README.md
@@ -53,7 +53,18 @@ another. Edits never touch the original bytes.
- **Every cross-reference form:** classic tables with their quirky subsections, cross-reference
streams, hybrid-reference files (`/XRefStm`), and object streams.
- **Repair.** A file whose xref is wrong or missing is reconstructed by scanning for `obj`
- headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged.
+ headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged. A live
+ in-file reference may match either its xref generation or a same-number object header at
+ that indexed offset; a missing, free, or neither-matching slot still resolves to null.
+ Header probes inspect at most 128 bytes at the indexed and header-relative offsets, without
+ parsing object bodies. On open, disagreements in retained xref revisions are recorded once
+ per object, up to 256 objects plus an omitted-details summary. This diagnostic scan does
+ not expand compact xref ranges; the winning xref entry remains authoritative for free
+ slots. Compressed objects require generation zero. Explicit `set` overlays accept only the
+ supplied generation; `set_existing` and `update_dict` retain both accepted generations of a
+ damaged live slot, including through snapshots, and require full rewrite. Headers beyond
+ this bounded probe are not additionally recovered. Materialized primary and hybrid rows are
+ diagnosed before folding, so a newer row does not hide a retained disagreement.
- **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing
for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields
`CosError`, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects.
@@ -66,14 +77,17 @@ another. Edits never touch the original bytes.
## Writing
-`write_incremental` appends only the edited objects, a cross-reference section **in the same
-style as the file's last one**, and a trailer with `/Prev`. The original bytes are never modified,
-which is what keeps a signature over an earlier revision valid.
+`write_incremental` ordinarily appends only the edited objects, a cross-reference section **in
+the same style as the file's last one**, and a trailer with `/Prev`, preserving the original
+bytes and signatures over earlier revisions. It delegates to `write_full` when reconstruction,
+encryption changes or `full_save_required()` demand a rewrite. Repair-aware edits to a live
+xref/header generation disagreement use this fallback rather than append a contradictory row.
`write_full` writes only what the trailer can reach, renumbered densely — the Save As and
garbage-collecting path. By default (`SaveOptions::object_streams`) it packs non-stream objects
into compressed object streams with a cross-reference stream (PDF 1.5+); turning that off writes a
-classic table any reader accepts.
+classic table any reader accepts. Both forms map accepted xref/header aliases to one output
+object, retaining shared identity such as page-tree parent links; unrelated generations stay null.
Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay
recognisable in a diff and byte-stable when re-serialized.
diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs
index 0bad88339f..68f61772f3 100644
--- a/crates/cos/src/document/objects.rs
+++ b/crates/cos/src/document/objects.rs
@@ -1,8 +1,10 @@
+use std::collections::HashSet;
+
use super::reader::find;
use super::state::{
LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, note_overlay_read, objstm_member, overlay_reads,
};
-use super::xref::{Revision, XrefEntry};
+use super::xref::{Revision, XrefEntry, XrefIndex};
use super::{
Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared,
};
@@ -131,8 +133,8 @@ impl Document {
pub fn xref_entry_is_exact(&self, num: u32) -> bool {
match self.xref_entry(num) {
None | Some(XrefEntry::Free { .. }) => true,
- Some(XrefEntry::InFile { offset, .. }) => {
- let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num);
+ Some(XrefEntry::InFile { offset, generation }) => {
+ let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id == ObjRef::new(num, generation));
// Offsets may count from a header that does not start the file.
usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
}
@@ -153,9 +155,93 @@ impl Document {
// ── object access ───────────────────────────────────────────────────────────────────────
- /// Fetch an object by reference. Missing objects are `Null` (ISO 32000-2 §7.3.10).
+ /// Fetch an object by reference. Undefined references are `Null` (ISO 32000-2 §7.3.10).
+ /// A live file slot also accepts its actual header generation when a damaged xref row
+ /// disagrees; the disagreement is recorded in [`Self::repair_log`]. Free slots never do.
pub fn get(&self, r: ObjRef) -> Arc {
- self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null))
+ self.try_get_reference(r).unwrap_or_else(|_| Arc::new(Object::Null))
+ }
+
+ pub(super) fn reference_matches(&self, r: ObjRef) -> bool {
+ match self.overlay.get(&r.num) {
+ Some(Slot::Set(generation, _)) => r.generation == *generation,
+ Some(Slot::RepairedSet(generation, alias, _)) => r.generation == *generation || r.generation == *alias,
+ Some(Slot::Freed(_)) => false,
+ None => match self.entries.lookup(r.num, &self.data) {
+ Some(XrefEntry::InFile { offset, generation }) => {
+ r.generation == generation || self.indexed_header_reference(r.num, offset) == Some(r)
+ }
+ Some(XrefEntry::InStream { .. }) => r.generation == 0,
+ None | Some(XrefEntry::Free { .. }) => false,
+ },
+ }
+ }
+
+ pub(super) fn try_get_reference(&self, r: ObjRef) -> Result, CosError> {
+ if !self.reference_matches(r) {
+ return Ok(Arc::new(Object::Null));
+ }
+ self.try_get(r.num)
+ }
+
+ /// A repair probe, not a whole-file search: only the indexed header (or the same offset
+ /// relative to a shifted PDF header) can establish this additional generation. A small
+ /// window bounds hostile whitespace/tokens; ordinary xref-matching reads are unchanged.
+ pub(super) fn indexed_header_reference(&self, num: u32, offset: u64) -> Option {
+ let at = |off: usize| {
+ let bytes = self.data.get(off..off.saturating_add(128).min(self.data.len()))?;
+ let mut lexer = Lexer::new(bytes, 0);
+ lexer.skip_ws();
+ let number = std::str::from_utf8(lexer.token()).ok()?.parse::().ok()?;
+ lexer.skip_ws();
+ let generation = std::str::from_utf8(lexer.token()).ok()?.parse::().ok()?;
+ (number == num && lexer.eat_keyword(b"obj")).then_some(ObjRef::new(number, generation))
+ };
+ let off = usize::try_from(offset).ok()?;
+ at(off).or_else(|| if self.header_offset == 0 { None } else { off.checked_add(self.header_offset).and_then(at) })
+ }
+
+ /// Add at most one bounded diagnostic per object. False stops diagnostic probing only;
+ /// parsers must still read the remaining xref rows needed to build their index.
+ pub(super) fn inspect_xref_generation_repair(&self, num: u32, entry: XrefEntry, recorded: &mut HashSet, log: &mut Vec) -> bool {
+ let XrefEntry::InFile { offset, generation } = entry else { return true };
+ if recorded.contains(&num) {
+ return true;
+ }
+ let Some(header) = self.indexed_header_reference(num, offset) else { return true };
+ if header.generation == generation {
+ return true;
+ }
+ if recorded.len() == 256 {
+ log.push("additional xref/header generation disagreements omitted after 256 objects".into());
+ return false;
+ }
+ recorded.insert(num);
+ log.push(format!("object {num}: xref generation {generation} disagrees with object header generation {}", header.generation));
+ true
+ }
+
+ /// Diagnose once at open without expanding compact ranges or decoding an xref twice.
+ /// Materialized readers retain bounded notes while they read accepted sections, before
+ /// newer rows hide older ones. Discarded parse attempts/rebuilt indexes carry no notes.
+ pub(super) fn note_xref_generation_repairs(&self, log: &mut Vec) {
+ match &*self.entries {
+ XrefIndex::Materialized(_, notes) => log.extend(notes.iter().cloned()),
+ XrefIndex::Compact { revisions, .. } => {
+ let mut recorded = HashSet::new();
+ for revision in revisions {
+ for section in std::iter::once(&revision.primary).chain(revision.hybrid.iter()) {
+ for num in section.numbers() {
+ if let Some(entry) = section.lookup(num, &self.data)
+ && !self.inspect_xref_generation_repair(num, entry, &mut recorded, log)
+ {
+ return;
+ }
+ }
+ }
+ }
+ }
+ }
}
/// Follow a reference if `o` is one; otherwise return `o` itself. An object can be nothing but
@@ -392,7 +478,7 @@ impl Document {
if let Some(slot) = self.overlay.get(&num) {
note_overlay_read();
return Ok(match slot {
- Slot::Set(_, o) => o.clone(),
+ Slot::Set(_, o) | Slot::RepairedSet(_, _, o) => o.clone(),
Slot::Freed(_) => Arc::new(Object::Null),
});
}
@@ -532,15 +618,15 @@ impl Document {
}
// A length resolved through this document's edits counts as an edit read
// on every reuse.
- if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) {
+ if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
if edits {
note_overlay_read();
}
return known;
}
let edits_read = overlay_reads();
- let length = self.try_get(r.num).ok().and_then(|o| o.as_int());
- LENGTHS.with(|m| m.borrow_mut().insert(r.num, (length, overlay_reads() != edits_read)));
+ let length = self.try_get_reference(r).ok().and_then(|o| o.as_int());
+ LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read)));
length
};
let off = offset as usize;
@@ -668,6 +754,38 @@ impl Document {
}
}
+ /// Replace an accepted existing reference without losing a damaged source slot's aliases.
+ /// Unlike [`Self::set`], this preserves both xref/header generations when they disagree.
+ /// Such an edit forces a full rewrite so the output has consistent references, rather than
+ /// appending a new contradictory xref row. Missing, free and stale references are errors.
+ pub fn set_existing(&mut self, r: ObjRef, obj: impl Into) -> Result<(), CosError> {
+ if !self.reference_matches(r) {
+ return Err(CosError::MissingObject(r.num));
+ }
+ let alias = self.alternate_generation(r);
+ if let Some(alias) = alias {
+ let obj = self.adopt(obj.into());
+ self.overlay.insert(r.num, Slot::RepairedSet(r.generation, alias, Arc::new(obj)));
+ } else {
+ self.set(r, obj);
+ }
+ Ok(())
+ }
+
+ /// Other accepted generation for a known-live reference to a damaged slot.
+ pub(super) fn alternate_generation(&self, r: ObjRef) -> Option {
+ match self.overlay.get(&r.num) {
+ Some(Slot::RepairedSet(generation, alias, _)) => Some(if r.generation == *generation { *alias } else { *generation }),
+ Some(_) => None,
+ None => match self.entries.lookup(r.num, &self.data) {
+ Some(XrefEntry::InFile { offset, generation }) => self.indexed_header_reference(r.num, offset).and_then(|header| {
+ (header.generation != generation).then_some(if r.generation == generation { header.generation } else { generation })
+ }),
+ _ => None,
+ },
+ }
+ }
+
/// Add a new indirect object and return its reference.
pub fn add(&mut self, obj: impl Into) -> ObjRef {
let r = ObjRef::new(self.allocate_num(), 0);
@@ -717,8 +835,9 @@ impl Document {
///
/// Used when the working snapshot was restored from an earlier revision and the next
/// incremental save must still append to this file (the last saved revision). Objects
- /// that already match are left untouched. Reads go through private readers so neither
- /// document's editing cache is filled.
+ /// that already match are left untouched, except members of an object stream of this file
+ /// whose stream number `current` frees or reuses: those are written again on their own.
+ /// Reads go through private readers so neither document's editing cache is filled.
pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> {
let saved_nums = self.object_numbers();
let current_nums = current.object_numbers();
@@ -736,12 +855,29 @@ impl Document {
let generation = current.generation(num);
let same = saved_nums.binary_search(&num).is_ok()
&& self.generation(num) == generation
- && saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?;
+ && self.alternate_generation(ObjRef::new(num, generation)) == current.alternate_generation(ObjRef::new(num, generation))
+ && saved_reader.try_get(ObjRef::new(num, generation))? == current_reader.try_get(ObjRef::new(num, generation))?;
if !same {
changed.push(num);
}
}
}
+ // An object stream of this file that is freed or replaced takes its members with it, so
+ // a member that is otherwise unchanged is written again on its own.
+ let gone = |n: u32| freed.binary_search(&n).is_ok() || changed.binary_search(&n).is_ok();
+ let orphaned: Vec = current_nums
+ .iter()
+ .copied()
+ .filter(|&num| changed.binary_search(&num).is_err())
+ .filter(|&num| {
+ let packed_in = match self.xref_entry(num) {
+ Some(XrefEntry::InStream { stream, .. }) => Some(stream),
+ _ => None,
+ };
+ packed_in.is_some_and(gone)
+ })
+ .collect();
+ changed.extend(orphaned);
for num in freed {
let generation = self.generation(num);
self.free(ObjRef::new(num, generation));
@@ -749,8 +885,14 @@ impl Document {
let mut current_reader = current.object_reader();
for num in changed {
let generation = current.generation(num);
- let object = current_reader.try_get(ObjRef::new(num, 0))?;
- self.set(ObjRef::new(num, generation), (*object).clone());
+ let object = current_reader.try_get(ObjRef::new(num, generation))?;
+ if let Some(alias) = current.alternate_generation(ObjRef::new(num, generation)) {
+ let object = self.adopt((*object).clone());
+ self.overlay.insert(num, Slot::RepairedSet(generation, alias, Arc::new(object)));
+ self.next_num = self.next_num.max(num.saturating_add(1));
+ } else {
+ self.set(ObjRef::new(num, generation), (*object).clone());
+ }
}
*self.trailer_mut() = current.trailer().clone();
Ok(())
@@ -761,14 +903,13 @@ impl Document {
let mut obj = (*self.get(r)).clone();
let d = obj.as_dict_mut().ok_or(CosError::NotADictionary(r))?;
f(d);
- self.set(r, obj);
- Ok(())
+ self.set_existing(r, obj)
}
/// Current generation of an object number.
pub fn generation(&self, num: u32) -> u16 {
match self.overlay.get(&num) {
- Some(Slot::Set(g, _)) => *g,
+ Some(Slot::Set(g, _) | Slot::RepairedSet(g, _, _)) => *g,
Some(Slot::Freed(g)) => *g,
None => match self.entries.lookup(num, &self.data) {
Some(XrefEntry::InFile { generation, .. }) => generation,
@@ -780,7 +921,7 @@ impl Document {
pub(crate) fn overlay_entries(&self) -> impl Iterator- >)> + '_ {
self.overlay.iter().map(|(n, s)| match s {
- Slot::Set(g, o) => (*n, *g, Some(o.clone())),
+ Slot::Set(g, o) | Slot::RepairedSet(g, _, o) => (*n, *g, Some(o.clone())),
Slot::Freed(g) => (*n, *g, None),
})
}
@@ -803,7 +944,7 @@ impl Document {
if source.peek().is_some_and(|n| *n == num) {
source.next();
}
- if matches!(slot, Slot::Set(..)) {
+ if matches!(slot, Slot::Set(..) | Slot::RepairedSet(..)) {
numbers.push(num);
}
}
@@ -821,7 +962,7 @@ impl Document {
pub(super) fn root_is_catalog(&self) -> bool {
let Some(r) = self.root() else { return false };
- matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages"))
+ matches!(self.try_get_reference(r).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages"))
}
}
diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs
index 70bbcc313e..7a4337c8da 100644
--- a/crates/cos/src/document/open.rs
+++ b/crates/cos/src/document/open.rs
@@ -1,5 +1,5 @@
use super::reader::find;
-use super::state::Credentials;
+use super::state::{Credentials, Slot};
use super::xref::XrefIndex;
use super::{Arc, BTreeMap, CosError, Dict, Document, HashSet, ObjRef, Object};
@@ -156,6 +156,9 @@ impl Document {
}
doc.reconstruct(&mut log)?;
}
+ // Header generations are plaintext, including in encrypted files. Inspect only the
+ // bounded headers, not object bodies, and retain the compact xref representation.
+ doc.note_xref_generation_repairs(&mut log);
doc.repair_log = Arc::new(log);
// Reconstruction may have found a different trailer: (re)authenticate against it.
if (!ok || !authenticated)
@@ -288,8 +291,9 @@ impl Document {
}
/// The next save must rewrite the whole file (see [`Document::require_full_save`]).
+ /// Repair-aware edits retaining two accepted generations cannot use one incremental slot.
pub fn full_save_required(&self) -> bool {
- self.full_save
+ self.full_save || self.overlay.values().any(|slot| matches!(slot, Slot::RepairedSet(..)))
}
/// Security was set or removed since the document was opened (the next save applies it).
diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs
index d21bf9403f..e1691ace90 100644
--- a/crates/cos/src/document/reader.rs
+++ b/crates/cos/src/document/reader.rs
@@ -17,8 +17,8 @@ impl Document {
return Ok((index, trailer, revisions, Vec::new()));
}
}
- let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?;
- Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities))
+ let (entries, trailer, revisions, oddities, generation_repairs) = self.read_xref_chain_materialized(log)?;
+ Ok((XrefIndex::Materialized(Arc::new(entries), Arc::new(generation_repairs)), trailer, revisions, oddities))
}
fn read_compact_xref_chain(&self, log: &mut Vec
) -> Result)>, CosError> {
@@ -323,7 +323,7 @@ impl Document {
pub(super) fn read_xref_chain_materialized(
&self,
log: &mut Vec,
- ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> {
+ ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>, Vec), CosError> {
let data = &self.data;
let tail_start = data.len().saturating_sub(4096);
let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?;
@@ -343,6 +343,9 @@ impl Document {
// At most MAX_XREF_OBJECTS entries are kept in all; a stream past that is read again.
let mut hybrids: HashMap>> = HashMap::new();
let (mut hybrid_rows, mut noted) = (0usize, HashSet::new());
+ let mut generation_repairs = Vec::new();
+ let mut recorded = HashSet::new();
+ let mut diagnose = true;
let mut next = Some(first);
while let Some(off) = next.take() {
if !visited.insert(off) || visited.len() > 10_000 {
@@ -371,6 +374,12 @@ impl Document {
}
};
revisions.push(Revision { xref_offset: off, is_stream, header_relative });
+ for (&num, &entry) in § {
+ if !diagnose {
+ break;
+ }
+ diagnose = self.inspect_xref_generation_repair(num, entry, &mut recorded, &mut generation_repairs);
+ }
// Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the
// free entries it gives objects only a cross-reference stream can locate.
let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0);
@@ -390,6 +399,12 @@ impl Document {
let mut stm = BTreeMap::new();
let read = match self.read_section(x as usize, &mut stm, &mut odd, log) {
Ok(_) => {
+ for (&num, &entry) in &stm {
+ if !diagnose {
+ break;
+ }
+ diagnose = self.inspect_xref_generation_repair(num, entry, &mut recorded, &mut generation_repairs);
+ }
for (&num, &e) in &stm {
match sect.get(&num) {
None | Some(XrefEntry::Free { .. }) => {
@@ -427,7 +442,7 @@ impl Document {
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
trailer.remove(k);
}
- Ok((entries, trailer, revisions, oddities))
+ Ok((entries, trailer, revisions, oddities, generation_repairs))
}
/// Read one section at `off`, adding entries not already known (newer sections win). `odd`
@@ -606,7 +621,7 @@ impl Document {
}
if found > 0 {
log.push(format!("the cross-reference data leaves out {found} objects the file contains; located them by their object headers"));
- self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled)));
+ self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled), Arc::default()));
}
self.located = Arc::new(located);
}
@@ -631,7 +646,7 @@ impl Document {
if entries.is_empty() {
return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
}
- self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone())));
+ self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()), Arc::default()));
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
// Objects inside object streams.
for num in objstms {
@@ -648,7 +663,7 @@ impl Document {
}
}
let object_count = entries.len();
- self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries)));
+ self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries), Arc::default()));
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
// Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a
// usable /Root (last in the file, whichever kind), else the catalog itself. One whose
@@ -658,7 +673,7 @@ impl Document {
// A file whose only trailers are cross-reference streams (and whose `startxref` is gone
// or points nowhere) keeps its /Info, /ID and /Encrypt this way.
let rank = |at: usize, d: &Dict| {
- let root = self.try_get(d.reference(b"Root")?.num).ok()?;
+ let root = self.try_get_reference(d.reference(b"Root")?).ok()?;
let Object::Dict(c) = &*root else { return None };
Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at))
};
diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs
index f2914004c1..e4cf987245 100644
--- a/crates/cos/src/document/state.rs
+++ b/crates/cos/src/document/state.rs
@@ -8,7 +8,7 @@ thread_local! {
/// retries a parse several ways, and each retry would resolve the same lengths again
/// (exponential in the chain of streams whose lengths point at further streams). Each is
/// kept with whether resolving it read unsaved edits.
- pub(super) static LENGTHS: std::cell::RefCell, bool)>> = std::cell::RefCell::new(HashMap::new());
+ pub(super) static LENGTHS: std::cell::RefCell, bool)>> = std::cell::RefCell::new(HashMap::new());
/// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`).
static OVERLAY_READS: std::cell::Cell = const { std::cell::Cell::new(0) };
}
@@ -85,6 +85,9 @@ pub(super) fn objstm_member(num: i64, offset: i64, first: Option, len: us
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Slot {
Set(u16, Arc),
+ /// Both generations accepted from a damaged live source slot survive a repair-aware edit.
+ /// A full rewrite is required: a new xref slot cannot encode two generations.
+ RepairedSet(u16, u16, Arc),
Freed(u16),
}
@@ -123,6 +126,7 @@ impl ReadState {
a == b
&& match (x, y) {
(Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p),
+ (Slot::RepairedSet(g, a, o), Slot::RepairedSet(h, b, p)) => g == h && a == b && Arc::ptr_eq(o, p),
(Slot::Freed(g), Slot::Freed(h)) => g == h,
_ => false,
}
@@ -153,7 +157,7 @@ impl ObjectReader {
}
pub(crate) fn try_get(&mut self, reference: ObjRef) -> Result, CosError> {
- let object = self.document.try_get(reference.num);
+ let object = self.document.try_get_reference(reference);
// A missed eviction here only costs a re-parse, so never fail the read on a poisoned
// lock. The editing-cache evictors propagate instead; they guard user edits.
if let Ok(mut streams) = self.document.objstms.lock() {
diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs
index 796076f0ee..bb177e79e6 100644
--- a/crates/cos/src/document/tests.rs
+++ b/crates/cos/src/document/tests.rs
@@ -601,7 +601,7 @@ pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec {
fn assert_compact_matches_materialized(doc: &Document) {
assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map");
- let (entries, _, revisions, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap();
+ let (entries, _, revisions, _, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap();
assert_eq!(revisions.as_slice(), doc.revisions());
let max = entries.keys().next_back().copied().unwrap_or(0);
assert_eq!(doc.entries.max_num(), max);
@@ -689,7 +689,7 @@ fn malformed_size_uses_the_materialized_compatibility_path() {
let at = bytes.windows(marker.len()).position(|window| window == marker).unwrap();
bytes[at..at + marker.len()].copy_from_slice(b"/Size 2");
let doc = Document::open(Arc::new(bytes)).unwrap();
- assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(_)));
+ assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(..)));
assert_eq!(doc.next_num, 4);
assert_eq!(doc.object_numbers(), vec![1, 2, 3]);
}
@@ -1337,15 +1337,18 @@ fn reads_classic_revision_and_edits_are_copy_on_write() {
fn object_numbers_merge_source_and_overlay_in_order() {
let mut doc = Document::new_empty();
doc.overlay.clear();
- doc.entries = Arc::new(XrefIndex::Materialized(Arc::new(BTreeMap::from([
- (0, XrefEntry::Free { next_generation: 65535 }),
- (2, XrefEntry::InFile { offset: 10, generation: 7 }),
- (4, XrefEntry::Free { next_generation: 9 }),
- (6, XrefEntry::InStream { stream: 80, index: 1 }),
- (8, XrefEntry::InFile { offset: 20, generation: 5 }),
- (10, XrefEntry::InFile { offset: 30, generation: 3 }),
- (u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }),
- ]))));
+ doc.entries = Arc::new(XrefIndex::Materialized(
+ Arc::new(BTreeMap::from([
+ (0, XrefEntry::Free { next_generation: 65535 }),
+ (2, XrefEntry::InFile { offset: 10, generation: 7 }),
+ (4, XrefEntry::Free { next_generation: 9 }),
+ (6, XrefEntry::InStream { stream: 80, index: 1 }),
+ (8, XrefEntry::InFile { offset: 20, generation: 5 }),
+ (10, XrefEntry::InFile { offset: 30, generation: 3 }),
+ (u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }),
+ ])),
+ Arc::default(),
+ ));
doc.set(ObjRef::new(1, 2), Object::Int(1)); // before the first live source slot
doc.set(ObjRef::new(2, 8), Object::Int(2)); // override, without duplication
doc.set(ObjRef::new(4, 9), Object::Int(4)); // reuse a source free slot
@@ -2012,6 +2015,32 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
}
+#[test]
+fn a_restored_revision_keeps_what_the_saved_file_held_in_object_streams() {
+ // The earlier revision: an empty document read back from a file whose catalog and pages
+ // sit in an object stream.
+ let earlier = Document::open(Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap())).unwrap();
+ // The saved revision adds an object the catalog links to, so a full save numbers its objects
+ // anew; an undo then returns to the earlier graph.
+ let mut later = earlier.clone();
+ let extra = later.add(Object::Int(7));
+ let root = later.root().unwrap();
+ later.update_dict(root, |c| c.set(b"Extra".to_vec(), Object::Ref(extra))).unwrap();
+ let saved = crate::write_full(&later, &crate::SaveOptions::default()).unwrap();
+ let mut base = Document::open(Arc::new(saved)).unwrap();
+ assert!(
+ base.object_numbers().iter().any(|&n| matches!(base.xref_entry(n), Some(XrefEntry::InStream { .. }))),
+ "the saved file holds objects in a stream"
+ );
+ base.overlay_changes_from(&earlier).unwrap();
+ let file = crate::write_incremental(&base, &crate::SaveOptions::default()).unwrap();
+ let reread = Document::open(Arc::new(file)).unwrap();
+ let catalog = reread.get(reread.root().unwrap()).as_dict().unwrap().clone();
+ assert!(catalog.get(b"Extra").is_none(), "the restored catalog");
+ let pages = reread.resolve(catalog.get(b"Pages").unwrap());
+ assert_eq!(pages.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Pages"[..]), "the pages survive: {pages:?}");
+}
+
/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has
/// to measure the cache again on every read (#5873).
#[test]
diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs
index 02390ff7da..771df28b80 100644
--- a/crates/cos/src/document/xref.rs
+++ b/crates/cos/src/document/xref.rs
@@ -41,7 +41,7 @@ pub(super) struct XrefRevisionSection {
#[derive(Clone, Debug)]
pub(super) enum XrefIndex {
Compact { revisions: Vec, max_num: u32, has_entries: bool },
- Materialized(Arc>),
+ Materialized(Arc>, Arc>),
}
pub(super) const MAX_COMPACT_XREF_ROWS: usize = 2_000_000;
@@ -149,8 +149,8 @@ impl XrefSection {
}
}
- pub(super) fn extend_numbers(&self, numbers: &mut Vec) {
- for range in &self.ranges {
+ pub(super) fn numbers(&self) -> impl Iterator- + '_ {
+ self.ranges.iter().flat_map(|range| {
let available = match &self.data {
XrefSectionData::Table => range.count as usize,
XrefSectionData::Stream { bytes, widths } => {
@@ -158,14 +158,12 @@ impl XrefSection {
bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0)
}
};
- for i in 0..available.min(range.count as usize) {
- if let Ok(i) = u32::try_from(i)
- && let Some(num) = range.start.checked_add(i)
- {
- numbers.push(num);
- }
- }
- }
+ (0..available.min(range.count as usize)).filter_map(move |i| u32::try_from(i).ok().and_then(|i| range.start.checked_add(i)))
+ })
+ }
+
+ pub(super) fn extend_numbers(&self, numbers: &mut Vec
) {
+ numbers.extend(self.numbers());
}
}
@@ -189,20 +187,20 @@ impl XrefRevisionSection {
impl XrefIndex {
pub(super) fn empty() -> Self {
- Self::Materialized(Arc::new(BTreeMap::new()))
+ Self::Materialized(Arc::new(BTreeMap::new()), Arc::default())
}
pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option {
match self {
Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)),
- Self::Materialized(entries) => entries.get(&num).copied(),
+ Self::Materialized(entries, _) => entries.get(&num).copied(),
}
}
pub(super) fn is_empty(&self) -> bool {
match self {
Self::Compact { has_entries, .. } => !has_entries,
- Self::Materialized(entries) => entries.is_empty(),
+ Self::Materialized(entries, _) => entries.is_empty(),
}
}
@@ -211,14 +209,14 @@ impl XrefIndex {
pub(super) fn remove_exhausted(&mut self) -> bool {
match self {
Self::Compact { .. } => false,
- Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(),
+ Self::Materialized(entries, _) => Arc::make_mut(entries).remove(&u32::MAX).is_some(),
}
}
/// Every entry, newest section first, as the materialized reader keeps them.
pub(super) fn to_map(&self, file: &[u8]) -> BTreeMap {
match self {
- Self::Materialized(entries) => entries.as_ref().clone(),
+ Self::Materialized(entries, _) => entries.as_ref().clone(),
Self::Compact { revisions, .. } => {
let mut numbers = Vec::new();
for revision in revisions {
@@ -232,13 +230,13 @@ impl XrefIndex {
pub(super) fn max_num(&self) -> u32 {
match self {
Self::Compact { max_num, .. } => *max_num,
- Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0),
+ Self::Materialized(entries, _) => entries.keys().next_back().copied().unwrap_or(0),
}
}
pub(super) fn object_numbers(&self, file: &[u8]) -> Vec {
match self {
- Self::Materialized(entries) => {
+ Self::Materialized(entries, _) => {
entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect()
}
Self::Compact { revisions, .. } => {
diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs
index dcfcb97997..6c4c71580a 100644
--- a/crates/cos/src/writer.rs
+++ b/crates/cos/src/writer.rs
@@ -307,8 +307,17 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro
if matches!(*o, Object::Null) {
continue; // dangling reference: written as null by renumbering below
}
- map.insert(r, output_number(order.len())?);
- order.push((r, matches!(*o, Object::Stream(_))));
+ // Accepted xref/header aliases denote one slot, including page-tree cycles.
+ // Check for null first: an unrelated or freed generation must remain dangling.
+ let canonical = ObjRef::new(r.num, reader.document.generation(r.num));
+ if let Some(&num) = map.get(&canonical) {
+ map.insert(r, num);
+ continue;
+ }
+ let num = output_number(order.len())?;
+ map.insert(r, num);
+ map.insert(canonical, num);
+ order.push((canonical, matches!(*o, Object::Stream(_))));
visit_refs(&o, &mut queue);
}
let version = reader.document.version().chars().take(3).collect::();
@@ -319,13 +328,15 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro
out.extend_from_slice(&[b'%', 0xE2, 0xE3, 0xCF, 0xD3, b'\n']);
// The /Encrypt dictionary and everything it references (e.g. indirect /CF crypt-filter
// dictionaries) are needed before anything can be decrypted: never inside object streams.
- let mut encryption_objects: std::collections::HashSet = std::collections::HashSet::new();
+ let mut encryption_objects: std::collections::HashSet = std::collections::HashSet::new();
let mut pending: Vec = Vec::new();
if let Some(encrypt) = trailer_in.get(b"Encrypt") {
collect_refs(encrypt, &mut |r| pending.push(r));
}
while let Some(r) = pending.pop() {
- if encryption_objects.insert(r) {
+ if let Some(&num) = map.get(&r)
+ && encryption_objects.insert(num)
+ {
collect_refs(&reader.get(r), &mut |x| pending.push(x));
}
}
@@ -333,14 +344,15 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro
// damaged and could not be repaired", before it even asks for the password), while other
// readers accept it. qpdf keeps the catalog out of object streams too. Verified with
// Acrobat DC on macOS (#774).
- let root_outside = if reader.document.output_security().0.is_some() { trailer_in.reference(b"Root") } else { None };
+ let root_outside =
+ if reader.document.output_security().0.is_some() { trailer_in.reference(b"Root").and_then(|r| map.get(&r)).copied() } else { None };
let mut rows: BTreeMap = BTreeMap::new();
// Keep only references, not renamed copies of the entire object graph. Standalone
// objects still precede object streams, preserving the established output order.
let mut packed: Vec<(u32, ObjRef)> = Vec::new();
for (i, (r, is_stream)) in order.iter().enumerate() {
let num = output_number(i)?;
- let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(r) && root_outside != Some(*r);
+ let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(&num) && root_outside != Some(num);
if in_stream {
packed.push((num, *r));
} else {
diff --git a/crates/cos/tests/it/generations.rs b/crates/cos/tests/it/generations.rs
new file mode 100644
index 0000000000..3d56628c93
--- /dev/null
+++ b/crates/cos/tests/it/generations.rs
@@ -0,0 +1,475 @@
+//! An indirect reference identifies an object number AND its generation (ISO 32000-2 §7.3.10).
+
+use std::sync::Arc;
+
+use hayro_syntax::object::{Dict as HayroDict, ObjectIdentifier};
+use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, XrefEntry, write_full, write_incremental};
+
+/// Object 3 was reused: only 3 1 R refers to the current value, never 3 0 R.
+fn reused_object() -> Arc> {
+ object_file(&[
+ (1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"),
+ (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"),
+ (3, 1, "42"),
+ ])
+}
+
+fn object_file(objects: &[(u32, u16, &str)]) -> Arc> {
+ object_file_with_entry(objects, None)
+}
+
+fn object_file_with_entry(objects: &[(u32, u16, &str)], changed: Option<(u32, u16, bool)>) -> Arc> {
+ let mut bytes = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (num, generation, value) in objects {
+ offsets.push((*num, bytes.len(), *generation));
+ bytes.extend_from_slice(format!("{num} {generation} obj\n{value}\nendobj\n").as_bytes());
+ }
+ let xref = bytes.len();
+ let size = objects.len() + 1;
+ bytes.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
+ for (num, offset, generation) in offsets {
+ let (generation, free) = changed.filter(|(n, _, _)| *n == num).map_or((generation, false), |(_, g, free)| (g, free));
+ let (offset, kind) = if free { (0, 'f') } else { (offset, 'n') };
+ bytes.extend_from_slice(format!("{offset:010} {generation:05} {kind} \n").as_bytes());
+ }
+ bytes.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
+ Arc::new(bytes)
+}
+
+fn contradictory_entry(free: bool) -> Arc> {
+ object_file_with_entry(
+ &[(1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "42")],
+ Some((3, if free { 2 } else { 0 }, free)),
+ )
+}
+
+#[test]
+fn contradictory_xref_generation_recovers_the_matching_header_and_records_repair() {
+ for warm in [false, true] {
+ let doc = Document::open(contradictory_entry(false)).unwrap();
+ if warm {
+ assert_eq!(*doc.try_get(3).unwrap(), Object::Int(42));
+ }
+ assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42));
+ assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 1))), Object::Int(42));
+ // Preserve the existing xref-matching behavior, but never accept an unrelated generation.
+ assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Int(42));
+ assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null);
+ assert!(!doc.xref_entry_is_exact(3), "a contradictory generation is not an exact xref entry");
+ assert!(
+ doc.repair_log()
+ .iter()
+ .any(|line| { line.contains("object 3") && line.contains("xref generation 0") && line.contains("header generation 1") }),
+ "{:?}",
+ doc.repair_log()
+ );
+ let repairs = doc.repair_log().to_vec();
+ for _ in 0..3 {
+ assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42));
+ }
+ assert_eq!(doc.repair_log(), repairs);
+
+ let mut edited = doc.clone();
+ edited.free(ObjRef::new(3, 1));
+ for generation in [0, 1, 2] {
+ assert_eq!(*edited.get(ObjRef::new(3, generation)), Object::Null);
+ }
+ edited.set(ObjRef::new(3, 2), Object::Int(99));
+ assert_eq!(*edited.get(ObjRef::new(3, 1)), Object::Null);
+ assert_eq!(*edited.get(ObjRef::new(3, 2)), Object::Int(99));
+ assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42));
+ }
+}
+
+/// A damaged row may name generation zero while the retained object and catalog use one.
+fn contradictory_dictionary() -> Arc> {
+ object_file_with_entry(
+ &[(1, 0, "<< /Type /Catalog /Pages 2 0 R /Current 3 1 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "<< /Value 42 >>")],
+ Some((3, 0, false)),
+ )
+}
+
+fn assert_repaired_dictionary(bytes: Vec, value: i32) {
+ let bytes = Arc::new(bytes);
+ let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
+ let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap();
+ assert_eq!(catalog.get::>(b"Current").unwrap().get::(b"Value"), Some(value));
+ let back = Document::open(bytes).unwrap();
+ let catalog = back.get(back.root().unwrap());
+ let current = back.resolve(catalog.as_dict().unwrap().get(b"Current").unwrap());
+ assert_eq!(current.as_dict().unwrap().int(b"Value"), Some(i64::from(value)));
+}
+
+#[test]
+fn repaired_header_references_survive_full_saves_and_dictionary_edits() {
+ let original = contradictory_dictionary();
+ let doc = Document::open(original.clone()).unwrap();
+ for object_streams in [false, true] {
+ assert_repaired_dictionary(write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(), 42);
+ }
+ let mut edited = doc.clone();
+ edited.update_dict(ObjRef::new(3, 1), |dict| dict.set(b"Value".to_vec(), Object::Int(77))).unwrap();
+ assert_eq!(edited.generation(3), 1);
+ let incremental = write_incremental(&edited, &SaveOptions::default()).unwrap();
+ assert!(edited.full_save_required(), "repair-aware dictionary edits normalize on full rewrite");
+ assert_repaired_dictionary(incremental, 77);
+ for object_streams in [false, true] {
+ assert_repaired_dictionary(write_full(&edited, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(), 77);
+ }
+ let mut restored = doc.clone();
+ restored.overlay_changes_from(&edited).unwrap();
+ assert_repaired_dictionary(write_incremental(&restored, &SaveOptions::default()).unwrap(), 77);
+}
+
+#[test]
+fn free_xref_slots_never_revive_retained_object_headers() {
+ for warm in [false, true] {
+ let doc = Document::open(contradictory_entry(true)).unwrap();
+ assert!(matches!(doc.xref_entry(3), Some(XrefEntry::Free { next_generation: 2 })));
+ if warm {
+ assert_eq!(*doc.try_get(3).unwrap(), Object::Null);
+ }
+ for generation in [0, 1, 2] {
+ assert_eq!(*doc.get(ObjRef::new(3, generation)), Object::Null);
+ }
+ assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 1))), Object::Null);
+ assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
+ }
+ // Control: the retained bytes really are a readable object when its slot is live.
+ let live = Document::open(contradictory_entry(false)).unwrap();
+ assert_eq!(*live.try_get(3).unwrap(), Object::Int(42));
+}
+
+#[test]
+fn incremental_generation_identity_is_visible_to_an_independent_reader() {
+ let original = reused_object();
+ let mut doc = Document::open(original.clone()).unwrap();
+ doc.set(ObjRef::new(3, 1), Object::Int(77));
+ let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ assert!(bytes.starts_with(&original));
+ let oracle = hayro_syntax::Pdf::new(Arc::new(bytes)).unwrap();
+ let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap();
+ assert_eq!(catalog.get::(b"Current"), Some(77));
+ assert_eq!(catalog.get::(b"Stale"), None);
+}
+
+#[test]
+fn generation_repair_diagnostics_are_bounded_without_disabling_recovery() {
+ let mut objects = vec![(1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>")];
+ objects.extend((3..303).map(|num| (num, 1, "42")));
+ let bytes = object_file(&objects);
+ let bytes = String::from_utf8(bytes.as_ref().clone()).unwrap().replace("00001 n \n", "00000 n \n");
+ for materialized in [false, true] {
+ let bytes = if materialized { bytes.replace("/Size 303", "/Size 302") } else { bytes.clone() };
+ let doc = Document::open(Arc::new(bytes.into_bytes())).unwrap();
+ assert_eq!(doc.repair_log().len(), 257);
+ assert!(doc.repair_log().last().unwrap().contains("omitted after 256 objects"));
+ assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42));
+ assert_eq!(*doc.get(ObjRef::new(302, 1)), Object::Int(42));
+ assert_eq!(*doc.get(ObjRef::new(302, 2)), Object::Null);
+ assert_eq!(doc.repair_log().len(), 257);
+ }
+}
+
+#[test]
+fn references_require_the_current_generation_even_with_a_warm_cache() {
+ let bytes = reused_object();
+ let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
+ assert_eq!(oracle.xref().get::(ObjectIdentifier::new(3, 0)), None);
+ assert_eq!(oracle.xref().get::(ObjectIdentifier::new(3, 1)), Some(42));
+
+ let doc = Document::open(bytes).unwrap();
+ assert!(doc.repair_log().is_empty());
+ assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null);
+ assert_eq!(*doc.try_get(3).unwrap(), Object::Int(42));
+ assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42));
+ assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null);
+ assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 0))), Object::Null);
+ assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null);
+}
+
+#[test]
+fn overlay_generation_changes_do_not_retarget_old_references_or_clones() {
+ let mut doc = Document::open(reused_object()).unwrap();
+ let snapshot = doc.clone();
+ let old = ObjRef::new(3, 1);
+ let new = ObjRef::new(3, 2);
+ doc.free(old);
+ assert_eq!(*doc.get(old), Object::Null);
+ doc.set(new, Object::Int(99));
+ assert_eq!(*doc.get(old), Object::Null);
+ assert_eq!(*doc.get(new), Object::Int(99));
+ assert_eq!(*snapshot.get(old), Object::Int(42));
+ assert_eq!(*snapshot.get(new), Object::Null);
+
+ let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ let back = Document::open(Arc::new(saved)).unwrap();
+ assert!(back.repair_log().is_empty());
+ assert_eq!(*back.get(old), Object::Null);
+ assert_eq!(*back.get(new), Object::Int(99));
+}
+
+#[test]
+fn restoring_snapshot_preserves_changes_to_nonzero_generation_objects() {
+ for generation in [1, 2] {
+ let mut saved = Document::open(reused_object()).unwrap();
+ let mut current = saved.clone();
+ let reference = ObjRef::new(3, generation);
+ current.set(reference, Object::Int(99));
+ current.update_dict(current.root().unwrap(), |catalog| catalog.set(b"Current".to_vec(), Object::Ref(reference))).unwrap();
+
+ saved.overlay_changes_from(¤t).unwrap();
+ assert_eq!(*saved.get(reference), Object::Int(99), "generation {generation}");
+ let back = Document::open(Arc::new(write_incremental(&saved, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(back.repair_log().is_empty());
+ assert_eq!(*back.get(reference), Object::Int(99), "generation {generation}");
+ let catalog = back.get(back.root().unwrap());
+ assert_eq!(*back.resolve(catalog.as_dict().unwrap().get(b"Current").unwrap()), Object::Int(99));
+ }
+}
+
+#[test]
+fn full_save_does_not_turn_stale_references_into_live_ones() {
+ let doc = Document::open(reused_object()).unwrap();
+ for object_streams in [false, true] {
+ let saved = write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap();
+ let saved = Arc::new(saved);
+ let oracle = hayro_syntax::Pdf::new(saved.clone()).unwrap();
+ let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap();
+ assert_eq!(catalog.get::(b"Current"), Some(42));
+ assert_eq!(catalog.get::(b"Stale"), None);
+ let back = Document::open(saved).unwrap();
+ assert!(back.repair_log().is_empty());
+ let catalog = back.get(back.root().unwrap());
+ let catalog = catalog.as_dict().unwrap();
+ assert_eq!(catalog.get(b"Stale"), Some(&Object::Null));
+ assert_eq!(*back.resolve(catalog.get(b"Current").unwrap()), Object::Int(42));
+ }
+}
+
+#[test]
+fn compressed_objects_only_accept_generation_zero() {
+ let doc = Document::open(reused_object()).unwrap();
+ let saved = write_full(&doc, &SaveOptions::default()).unwrap();
+ let back = Document::open(Arc::new(saved)).unwrap();
+ let catalog = back.get(back.root().unwrap());
+ let current = catalog.as_dict().unwrap().reference(b"Current").unwrap();
+ assert_eq!(current.generation, 0);
+ assert!(matches!(back.xref_entry(current.num), Some(XrefEntry::InStream { .. })));
+ assert_eq!(*back.get(current), Object::Int(42));
+ assert_eq!(*back.get(ObjRef::new(current.num, 1)), Object::Null);
+}
+
+#[test]
+fn indirect_stream_lengths_require_the_referenced_generation() {
+ for generation in [0, 1] {
+ // The embedded endstream is data when /Length resolves, but the existing missing-
+ // length recovery stops there when the reference is stale.
+ let stream = format!("<< /Length 4 {generation} R >>\nstream\nA\nendstream\nB\nendstream");
+ let doc = Document::open(object_file(&[
+ (1, 0, "<< /Type /Catalog /Pages 2 0 R >>"),
+ (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"),
+ (3, 0, &stream),
+ (4, 1, "13"),
+ ]))
+ .unwrap();
+ let object = doc.get(ObjRef::new(3, 0));
+ let Object::Stream(stream) = &*object else { panic!("expected stream") };
+ assert_eq!(&stream.raw[..], if generation == 1 { b"A\nendstream\nB".as_slice() } else { b"A".as_slice() });
+ }
+}
+
+/// A newer valid row must not hide damage in a retained older revision.
+fn shadowed_generation_disagreement(materialized: bool, shifted: bool) -> Arc> {
+ let original = contradictory_dictionary();
+ let text = std::str::from_utf8(&original).unwrap();
+ let previous: usize = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse().unwrap();
+ let mut bytes = (*original).clone();
+ let offset = bytes.len();
+ bytes.extend_from_slice(b"3 1 obj\n<< /Value 77 >>\nendobj\n");
+ let xref = bytes.len();
+ let size = if materialized { 3 } else { 4 };
+ bytes.extend_from_slice(
+ format!("xref\n3 1\n{offset:010} 00001 n \ntrailer\n<< /Size {size} /Root 1 0 R /Prev {previous} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
+ );
+ if shifted {
+ bytes.splice(..0, b"junk before the header\n".iter().copied());
+ }
+ Arc::new(bytes)
+}
+
+#[test]
+fn retained_generation_disagreements_are_reported_in_materialized_mode_too() {
+ for materialized in [false, true] {
+ for shifted in [false, true] {
+ let doc = Document::open(shadowed_generation_disagreement(materialized, shifted)).unwrap();
+ assert_eq!(doc.revisions().len(), 2, "the xref chain is retained");
+ assert!(!doc.reconstructed());
+ assert_eq!(doc.get(ObjRef::new(3, 1)).as_dict().unwrap().int(b"Value"), Some(77));
+ assert!(
+ doc.repair_log().iter().any(|line| line.contains("object 3: xref generation 0 disagrees with object header generation 1")),
+ "materialized={materialized}, shifted={shifted}: {:?}",
+ doc.repair_log()
+ );
+ }
+ }
+}
+
+#[test]
+fn repair_aware_edits_keep_both_aliases_and_full_save_normalizes_them() {
+ let mut doc = Document::open(contradictory_entry(false)).unwrap();
+ let snapshot = doc.clone();
+ doc.set_existing(ObjRef::new(3, 0), Object::Int(77)).unwrap();
+ for generation in [0, 1] {
+ assert_eq!(*doc.get(ObjRef::new(3, generation)), Object::Int(77));
+ assert_eq!(*snapshot.get(ObjRef::new(3, generation)), Object::Int(42));
+ }
+ let state = doc.read_state();
+ assert!(doc.set_existing(ObjRef::new(3, 2), Object::Int(99)).is_err());
+ assert!(state.matches(&doc));
+ assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null);
+ let mut restored = snapshot.clone();
+ restored.overlay_changes_from(&doc).unwrap();
+ for changed in [&doc, &restored] {
+ assert!(changed.full_save_required());
+ for bytes in [
+ write_incremental(changed, &SaveOptions::default()).unwrap(),
+ write_full(changed, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(),
+ ] {
+ let bytes = Arc::new(bytes);
+ let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
+ let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap();
+ assert_eq!(catalog.get::(b"Current"), Some(77));
+ assert_eq!(catalog.get::(b"Stale"), Some(77), "both source aliases stay live");
+ let back = Document::open(bytes).unwrap();
+ assert!(back.repair_log().is_empty(), "normalized output has no generation repairs");
+ }
+ }
+ // Explicit replacement keeps its narrower, caller-supplied generation contract.
+ doc.set(ObjRef::new(3, 2), Object::Int(99));
+ assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null);
+ assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Null);
+ assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Int(99));
+}
+
+#[test]
+fn hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them() {
+ let mut bytes = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (num, generation, body) in [(1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "42")] {
+ offsets.push(bytes.len());
+ bytes.extend_from_slice(format!("{num} {generation} obj\n{body}\nendobj\n").as_bytes());
+ }
+ let hybrid = bytes.len();
+ offsets.push(hybrid);
+ bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 4 2] /Index [3 1] /Length 7 >>\nstream\n");
+ bytes.push(1);
+ bytes.extend_from_slice(&(offsets[2] as u32).to_be_bytes());
+ bytes.extend_from_slice(&0u16.to_be_bytes());
+ bytes.extend_from_slice(b"\nendstream\nendobj\n");
+ let xref = bytes.len();
+ bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
+ for (i, offset) in offsets.into_iter().enumerate() {
+ let generation = u16::from(i == 2);
+ bytes.extend_from_slice(format!("{offset:010} {generation:05} n \n").as_bytes());
+ }
+ bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R /XRefStm {hybrid} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
+ let doc = Document::open(Arc::new(bytes)).unwrap();
+ assert!(!doc.reconstructed());
+ assert!(doc.xref_oddities().iter().any(|(_, num)| *num == 3), "the conflicting hybrid row is set aside");
+ assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42));
+ assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null, "the table still determines accepted references");
+ assert!(doc.repair_log().iter().any(|line| line.contains("object 3: xref generation 0 disagrees with object header generation 1")));
+}
+
+#[test]
+fn discarded_xref_repair_notes_do_not_survive_object_header_reconstruction() {
+ let bytes =
+ String::from_utf8((*contradictory_dictionary()).clone()).unwrap().replace("/Root 1 0 R", "/Root 99 0 R").replace("/Size 4", "/Size 3");
+ let doc = Document::open(Arc::new(bytes.into_bytes())).unwrap();
+ assert!(doc.reconstructed());
+ assert!(doc.repair_log().iter().all(|line| !line.contains("xref generation")), "{:?}", doc.repair_log());
+}
+
+#[test]
+fn restoring_equal_values_still_restores_the_accepted_generation_aliases() {
+ for edited in [false, true] {
+ for value in [42, 77] {
+ let mut current = Document::open(contradictory_entry(false)).unwrap();
+ if edited {
+ current.set_existing(ObjRef::new(3, 0), Object::Int(42)).unwrap();
+ }
+ let mut saved = Document::open(object_file(&[
+ (1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"),
+ (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"),
+ (3, 0, &value.to_string()),
+ ]))
+ .unwrap();
+ assert_eq!(*saved.get(ObjRef::new(3, 1)), Object::Null);
+ saved.overlay_changes_from(¤t).unwrap();
+ for generation in [0, 1] {
+ assert_eq!(*saved.get(ObjRef::new(3, generation)), Object::Int(42), "edited={edited}, value={value}");
+ }
+ assert!(saved.full_save_required());
+ }
+ }
+}
+
+#[test]
+fn repaired_page_tree_aliases_keep_one_identity_in_full_saves() {
+ let source = object_file_with_entry(
+ &[
+ (1, 0, "<< /Type /Catalog /Pages 2 1 R /Stale 2 2 R >>"),
+ (2, 1, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"),
+ (3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>"),
+ ],
+ Some((2, 0, false)),
+ );
+ for edited in [false, true] {
+ let mut doc = Document::open(source.clone()).unwrap();
+ if edited {
+ doc.update_dict(ObjRef::new(2, 0), |dict| dict.set(b"Edited".to_vec(), Object::Bool(true))).unwrap();
+ }
+ let mut restored = Document::open(source.clone()).unwrap();
+ restored.overlay_changes_from(&doc).unwrap();
+ for current in [&doc, &restored] {
+ for object_streams in [false, true] {
+ let opts = SaveOptions { object_streams, ..SaveOptions::default() };
+ let mut saves = vec![write_full(current, &opts).unwrap()];
+ if edited {
+ saves.push(write_incremental(current, &opts).unwrap());
+ }
+ for bytes in saves {
+ let back = Document::open(Arc::new(bytes)).unwrap();
+ let catalog = back.get(back.root().unwrap());
+ let catalog = catalog.as_dict().unwrap();
+ let pages_ref = catalog.reference(b"Pages").unwrap();
+ let pages = back.get(pages_ref);
+ let child_ref = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].as_ref().unwrap();
+ let child = back.get(child_ref);
+ assert_eq!(child.as_dict().unwrap().reference(b"Parent"), Some(pages_ref), "edited={edited}, packed={object_streams}");
+ assert!(matches!(catalog.get(b"Stale"), Some(Object::Null)), "unrelated generations stay null");
+ assert!(back.repair_log().is_empty());
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn restoring_explicit_sets_removes_old_generation_aliases() {
+ for value in [42, 77] {
+ let mut current = Document::open(contradictory_entry(false)).unwrap();
+ current.set(ObjRef::new(3, 0), Object::Int(42));
+ for repaired_overlay in [false, true] {
+ let mut saved = Document::open(contradictory_entry(false)).unwrap();
+ if repaired_overlay {
+ saved.set_existing(ObjRef::new(3, 0), Object::Int(value)).unwrap();
+ }
+ saved.overlay_changes_from(¤t).unwrap();
+ assert_eq!(*saved.get(ObjRef::new(3, 0)), Object::Int(42));
+ assert_eq!(*saved.get(ObjRef::new(3, 1)), Object::Null, "value={value}, overlay={repaired_overlay}");
+ }
+ }
+}
diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs
index 52cf05d6c2..1bd7ee1fd6 100644
--- a/crates/cos/tests/it/main.rs
+++ b/crates/cos/tests/it/main.rs
@@ -3,6 +3,7 @@
mod bounded_writer;
mod corpus;
mod encryption;
+mod generations;
mod proptests;
mod shared_streams;
mod writer;
diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs
index eb03523189..2716b9242e 100644
--- a/crates/cos/tests/it/writer.rs
+++ b/crates/cos/tests/it/writer.rs
@@ -358,11 +358,32 @@ fn objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams() {
let cf = doc.get(enc_ref).as_dict().unwrap().get(b"CF").cloned().unwrap();
let cf_ref = doc.add(cf);
doc.update_dict(enc_ref, |d| d.set(b"CF".to_vec(), Object::Ref(cf_ref))).unwrap();
- let packed = write_full(&doc, &SaveOptions::default()).unwrap();
- let back = Document::open(Arc::new(packed.clone())).expect("reopens");
- assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
- assert_eq!(title(&back), b"Caf\xe9 (draft)");
- assert_eq!(hayro_pages(&packed), 4);
+ // Visit this alias from Root before the encryption walk reaches /CF's other alias.
+ doc.update_dict(doc.root().unwrap(), |d| d.set(b"CFProbe".to_vec(), Object::Ref(cf_ref))).unwrap();
+ let classic = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
+ let saved = Document::open(Arc::new(classic.clone())).unwrap();
+ let cf_num = saved.get(saved.root().unwrap()).as_dict().unwrap().reference(b"CFProbe").unwrap().num;
+ for damaged in [false, true] {
+ let mut bytes = classic.clone();
+ if damaged {
+ // Equal-length edits preserve the xref offsets and its generation-zero row.
+ for (from, to) in [(format!("\n{cf_num} 0 obj"), format!("\n{cf_num} 1 obj")), (format!("/CF {cf_num} 0 R"), format!("/CF {cf_num} 1 R"))]
+ {
+ let start = bytes.windows(from.len()).position(|w| w == from.as_bytes()).unwrap();
+ bytes[start..start + from.len()].copy_from_slice(to.as_bytes());
+ }
+ }
+ let doc = Document::open(Arc::new(bytes)).unwrap();
+ let packed = write_full(&doc, &SaveOptions::default()).unwrap();
+ let back = Document::open(Arc::new(packed.clone())).expect("reopens");
+ assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
+ assert_eq!(title(&back), b"Caf\xe9 (draft)");
+ assert_eq!(hayro_pages(&packed), 4);
+ let probe = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"CFProbe").unwrap();
+ let encrypt = back.get(back.trailer().reference(b"Encrypt").unwrap());
+ assert_eq!(encrypt.as_dict().unwrap().reference(b"CF"), Some(probe));
+ assert!(matches!(back.xref_entry(probe.num), Some(pdfcraft_cos::XrefEntry::InFile { .. })), "crypt filters must stay outside object streams");
+ }
}
#[test]
diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs
index f4680bcc5c..72656fa1bb 100644
--- a/crates/edit/src/images.rs
+++ b/crates/edit/src/images.rs
@@ -1691,6 +1691,12 @@ pub(crate) fn selectable_images(
.collect())
}
+/// Whether an XObject drawn at or after operator `from` of the page's joined operators shows
+/// inside `rect` (user space).
+pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: usize) -> Result {
+ Ok(page_images(doc, page)?.iter().any(|i| i.op >= from && i.visible_rect.and_then(|v| intersection(v, rect)).is_some()))
+}
+
fn transform_image(
doc: &mut Document,
page: &pdfcraft_model::Page,
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index 44a6730acc..b95c4296f9 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -77,7 +77,7 @@ use marks::{matrix_number, n};
pub(crate) use place::undrawable;
#[cfg(test)]
use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role};
-use place::{check, check_on_top, contents, page_list, place_tagged};
+use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint};
pub use place::{stamp, undrawable_message};
#[cfg(test)]
diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs
index b535d60563..27ed7b3793 100644
--- a/crates/edit/src/place.rs
+++ b/crates/edit/src/place.rs
@@ -371,6 +371,76 @@ pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag:
Ok(mark_ref)
}
+/// Draw `ops`, written for the space whose CTM is `ctm`, on top of everything on `page`, tagged
+/// `Edited`. A rewritten invisible paragraph goes here when [`revealed_under_paint`]: an OCR
+/// layer written before the scan image it lies under would keep the revealed text hidden (#3973).
+pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: [f64; 6], ops: &[pdfcraft_content::Op]) -> Result<(), EditError> {
+ if ops.is_empty() {
+ return Ok(());
+ }
+ use pdfcraft_content::Op;
+ let mut all = vec![Op::new("q", vec![]), Op::new("cm", ctm.iter().map(|v| pdfcraft_content::num(*v)).collect())];
+ all.extend_from_slice(ops);
+ all.push(Op::new("Q", vec![]));
+ place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop)
+}
+
+/// Where a rewritten paragraph can be drawn, in user space ([`revealed_under_paint`]).
+pub(crate) struct Reach {
+ /// The paragraph's old box, where its cover goes.
+ pub rect: [f64; 4],
+ /// The move asked for (`BlockStyle::offset`).
+ pub offset: [f64; 2],
+ /// The widest line the rewrite may draw, how many lines it has, and how far apart they are.
+ pub width: f64,
+ pub rows: usize,
+ pub pitch: f64,
+ pub upright: bool,
+}
+
+impl Reach {
+ /// The old box and the moved one, grown to `width` either way (alignment may grow a line left
+ /// or right), down to `rows` lines and up one `pitch` above its top (a larger size on the first line, however many lines the old box had); turned text grows by the lines' diagonal every way.
+ /// Unbounded when a number is not finite.
+ pub(crate) fn bounds(&self) -> [f64; 4] {
+ const UNBOUNDED: [f64; 4] = [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY];
+ let ([dx, dy], r) = (self.offset, self.rect);
+ if !r.iter().chain(&self.offset).chain([&self.width, &self.pitch]).all(|v| v.is_finite()) {
+ return UNBOUNDED;
+ }
+ let moved = [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy];
+ let rows = f64::from(u32::try_from(self.rows).unwrap_or(u32::MAX));
+ let (width, height) = (self.width.abs(), self.pitch.abs() * rows);
+ let grown = if self.upright {
+ [moved[0].min(moved[2] - width), moved[1].min(moved[3] - height), moved[2].max(moved[0] + width), moved[3] + self.pitch.abs()]
+ } else {
+ let d = width.hypot(height);
+ [moved[0] - d, moved[1] - d, moved[2] + d, moved[3] + d]
+ };
+ let b = [r[0].min(grown[0]), r[1].min(grown[1]), r[2].max(grown[2]), r[3].max(grown[3])];
+ if b.iter().all(|v| v.is_finite()) { b } else { UNBOUNDED }
+ }
+}
+
+/// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after
+/// operator `at` of the page's joined `ops` paints over `reach` (user space, [`Reach::bounds`]), as the scan image
+/// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline
+/// image or a shading after it counts anywhere, and so does any XObject when the page's images
+/// cannot be listed.
+pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, reach: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool {
+ let later = ops.get(at..).unwrap_or_default();
+ if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) {
+ return invisible;
+ }
+ later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, reach, at).unwrap_or(true)
+}
+
+/// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`,
+/// none otherwise. The page was checked to take them ([`check_on_top`]) before the edit began.
+pub(crate) fn lift_on_top(over: bool, paragraph: &mut Vec) -> Vec {
+ if over { std::mem::take(paragraph) } else { Vec::new() }
+}
+
fn too_deep_on_top() -> EditError {
EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into())
}
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index e78da842b8..cbea5c21d2 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -6213,6 +6213,126 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() {
assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused");
}
+/// Invisible text moves on top only when something painted after it covers it: an XObject drawn
+/// over its box (an image, or a form holding one), or any inline image or shading. An image
+/// clipped away entirely, or drawn elsewhere, does not move it.
+#[test]
+fn revealed_text_moves_on_top_only_when_painted_over() {
+ let on_top_of = |doc: Document| {
+ let block = text::text_blocks(&doc, 0).unwrap().remove(0);
+ let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
+ let at = ops.iter().position(|o| o.is("BT")).unwrap();
+ revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at)
+ };
+ let on_top = |content: &str| on_top_of(image_page_with(content));
+ let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q";
+ assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it");
+ assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere");
+ assert!(!on_top(&format!("{scan} BT /F1 12 Tf 3 Tr 110 150 Td (Over) Tj ET")), "the scan is drawn first");
+ assert!(!on_top(&format!("BT /F1 12 Tf 0 Tr 110 150 Td (Shown) Tj ET {scan}")), "visible text stays where it is");
+ assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it");
+ assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it");
+ assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it");
+ let under = "BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET";
+ let elsewhere = "q 50 0 0 50 400 600 cm /Im0 Do Q";
+ assert!(!on_top(&format!("{under} q 0 0 10 10 re W n {scan} Q")), "the scan is clipped away entirely");
+ assert!(on_top(&format!("{under} {elsewhere} {scan}")), "two images, the second over it");
+ assert!(on_top(&format!("{under} {scan} {elsewhere}")), "two images, the first over it");
+ assert!(!on_top(&format!("{under} {elsewhere} {elsewhere}")), "two images, neither over it");
+ let in_form = |content: &str| {
+ let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> >> >>";
+ let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A");
+ let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im0 6 0 R >> >>", "/Im0 Do");
+ let objs = [
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ page.into(),
+ stream("", content),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
+ image,
+ form,
+ ];
+ on_top_of(build(&objs.iter().map(String::as_str).collect::>()))
+ };
+ assert!(in_form(&format!("{under} q 200 0 0 100 100 100 cm /Fm0 Do Q")), "a form holding the scan, over it");
+ assert!(!in_form(&format!("{under} q 50 0 0 50 400 600 cm /Fm0 Do Q")), "a form holding an image elsewhere");
+}
+
+/// Revealed OCR text is checked where the rewrite lands, not only where it was: an image drawn
+/// after it over just the moved or grown text still lifts the text on top, on both rewrite paths.
+#[test]
+fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() {
+ let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q";
+ let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() };
+ let sized = text::BlockStyle { size: Some(24.0), ..Default::default() };
+ let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() };
+ let range = text::RangeStyle {
+ range: 0..2,
+ family: None,
+ resource: None,
+ size: Some(120.0),
+ color: None,
+ underline: None,
+ char_spacing: None,
+ scale: None,
+ };
+ let enlarged = text::BlockStyle { ranges: vec![range], ..Default::default() };
+ let above = "q 200 0 0 60 100 200 cm /Im0 Do Q";
+ let cases = [
+ ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved),
+ ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved),
+ ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there, this line now runs well past the scan's left edge", &text::BlockStyle::default()),
+ ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()),
+ ("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()),
+ ];
+ let low = "q 200 0 0 30 100 300 cm /Im0 Do Q";
+ let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([
+ ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low),
+ ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low),
+ ("BT /F1 12 Tf 3 Tr 110 150 Td (Hi there) Tj ET", "Hi there", &enlarged, above),
+ ]);
+ for (content, edit, style, scan) in cases {
+ let mut doc = image_page_with(&format!("{content} {scan}"));
+ let before = text::text_blocks(&doc, 0).unwrap().remove(0);
+ let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
+ let at = ops.iter().position(|o| o.is("BT")).unwrap();
+ assert!(!revealed_under_paint(&doc, 0, true, before.rect, &ops, at), "{edit:?}: the old box alone is clear of the scan");
+ text::rewrite_block(&mut doc, 0, 0, Some(edit), style).unwrap();
+ let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
+ let scan_at = ops.iter().position(|o| o.is("Do")).unwrap();
+ let shows = ["Tj", "TJ", "'", "\""];
+ assert!(ops.iter().skip(scan_at).any(|o| shows.iter().any(|s| o.is(s))), "{edit:?}: the rewrite is drawn over the scan");
+ }
+}
+
+/// [`crate::Reach`] grows the old box to the move, the width either way and the lines below.
+#[test]
+fn a_rewrites_reach_covers_its_move_width_and_lines() {
+ let reach = |offset, width, rows, upright| crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset, width, rows, pitch: 10.0, upright }.bounds();
+ assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 110.0]);
+ assert_eq!(reach([5.0, -50.0], 20.0, 1, true), [10.0, 40.0, 35.0, 100.0]);
+ assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 110.0]);
+ assert_eq!(reach([0.0, 0.0], 30.0, 4, false), [-40.0, 40.0, 80.0, 150.0]);
+ assert_eq!(reach([f64::NAN, 0.0], 20.0, 1, true)[0], f64::NEG_INFINITY);
+ let larger = crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset: [0.0, 0.0], width: 20.0, rows: 1, pitch: 24.0, upright: true };
+ assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 124.0], "a larger size grows the line up as well as down");
+}
+
+/// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep
+/// for that is refused before either rewrite path changes anything.
+#[test]
+fn an_ocr_rewrite_refused_on_top_leaves_the_document_as_it_was() {
+ let content = format!("{} BT /F1 20 Tf 3 Tr 42 44 Td (John) Tj ET /Im Do {}", "/X BMC ".repeat(10_001), "EMC ".repeat(10_001));
+ let mut doc = text_page(&content);
+ assert!(text::text_blocks(&doc, 0).unwrap()[0].invisible);
+ let before = write_full(&doc, &SaveOptions::default()).unwrap();
+ for edit in ["Jane", "Jane\nDoe"] {
+ let error = text::rewrite_block(&mut doc, 0, 0, Some(edit), &text::BlockStyle::default()).unwrap_err();
+ assert!(error.to_string().contains("nested too deeply"), "{edit:?}: {error}");
+ assert!(write_full(&doc, &SaveOptions::default()).unwrap() == before, "{edit:?}: a refused rewrite changes nothing");
+ }
+}
+
#[test]
fn group_move_budget_refusals_keep_the_original_page_selectable() {
let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995)));
diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs
index 9ddf4db4b1..48fe956f4f 100644
--- a/crates/edit/src/text/blocks.rs
+++ b/crates/edit/src/text/blocks.rs
@@ -31,7 +31,8 @@ pub struct TextBlock {
/// Byte ranges in `editor_text` and their source line indexes.
pub spans: Vec<(std::ops::Range, usize)>,
/// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose
- /// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]).
+ /// pixels show the words. A rewrite draws the new text visibly, on top of the page when
+ /// the scan is drawn after it (see [`BlockStyle::cover`]).
pub invisible: bool,
}
diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs
index 2a9f6da61a..5114a76263 100644
--- a/crates/edit/src/text/interp.rs
+++ b/crates/edit/src/text/interp.rs
@@ -18,13 +18,33 @@ pub(super) struct TextState {
/// graphics state the text was painted with that [`TextState::ops`] doesn't restate.
pub(super) stroke: Vec,
pub(super) line_width: Option,
- ext_gstate: Vec>,
+ ext_gstate: Vec,
/// The dash pattern and line cap in force (see [`Ts`]): compared, not restated.
pub(super) dash: Dash,
pub(super) line_cap: i64,
}
impl TextState {
+ /// The state a page's content starts in (ISO 32000-2 §8.4.1, §9.3.1): what is in force on
+ /// top of the page, after its content wrapped in `q`/`Q`.
+ pub(crate) fn initial() -> Self {
+ TextState {
+ font: None,
+ char_spacing: 0.0,
+ word_spacing: 0.0,
+ scale: 1.0,
+ leading: 0.0,
+ rise: 0.0,
+ fill: Vec::new(),
+ render_mode: 0,
+ stroke: Vec::new(),
+ line_width: None,
+ ext_gstate: Vec::new(),
+ dash: (Vec::new(), 0.0),
+ line_cap: 0,
+ }
+ }
+
pub(super) fn of(ts: &Ts) -> Self {
TextState {
font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)),
@@ -54,13 +74,17 @@ impl TextState {
///
/// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force:
/// run these inside a `q`/`Q` that starts from `current`, one per state drawn.
- pub(crate) fn paint_ops(&self, current: &TextState) -> Vec {
+ ///
+ /// `ctm` is the CTM where they run. A soft mask is placed by the CTM its `gs` ran at
+ /// (ISO 32000-2 §11.6.5.2), so one selected at another CTM is selected there and the CTM
+ /// set back.
+ pub(crate) fn paint_ops(&self, current: &TextState, ctm: [f64; 6]) -> Vec {
let n = pdfcraft_content::num;
let mut v = Vec::new();
// The selections both share stay in force; the ones after them are selected again in order.
let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count();
- for name in self.ext_gstate.iter().skip(shared) {
- v.push(Op::new("gs", vec![Object::Name(name.clone())]));
+ for sel in self.ext_gstate.iter().skip(shared) {
+ sel.push_ops(&mut v, ctm);
}
// A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it.
let reselected = !v.is_empty();
@@ -222,7 +246,7 @@ pub(super) struct Ts {
/// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0).
pub(super) line_width: Option,
/// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]).
- pub(super) ext_gstate: Vec>,
+ pub(super) ext_gstate: Vec,
/// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`).
pub(super) dash: Dash,
/// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt).
@@ -255,12 +279,38 @@ const MAX_GS: usize = 32;
/// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute
/// value, so a later selection of the same dictionary overrides the earlier one completely: the
/// earlier entry goes, which keeps replaying the list equivalent to replaying the stream.
-fn select_gs(list: &mut Vec>, name: &[u8]) {
- list.retain(|n| n != name);
+fn select_gs(list: &mut Vec, name: &[u8], anchor: Option<[f64; 6]>) {
+ list.retain(|s| s.name != name);
if list.len() >= MAX_GS {
list.remove(0);
}
- list.push(name.to_vec());
+ list.push(GsSelection { name: name.to_vec(), anchor });
+}
+
+/// One `gs` selection: the ExtGState's resource name, and the CTM it ran at when the dictionary
+/// has a soft mask, which that CTM places (ISO 32000-2 §11.6.5.2).
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct GsSelection {
+ name: Vec,
+ anchor: Option<[f64; 6]>,
+}
+
+impl GsSelection {
+ /// Select this ExtGState from where the CTM is `ctm`: at its anchor, then back to `ctm`. Both
+ /// moves need an invertible matrix; without one it is selected where it runs.
+ fn push_ops(&self, v: &mut Vec, ctm: [f64; 6]) {
+ let gs = Op::new("gs", vec![Object::Name(self.name.clone())]);
+ let moves = self.anchor.filter(|a| *a != ctm).and_then(|a| {
+ let (anchor, here) = (Matrix(a), Matrix(ctm));
+ Some((anchor.then(&here.invert()?), here.then(&anchor.invert()?)))
+ });
+ let Some((to, back)) = moves else {
+ v.push(gs);
+ return;
+ };
+ let cm = |m: Matrix| Op::new("cm", m.0.iter().map(|x| pdfcraft_content::num(*x)).collect());
+ v.extend([cm(to), gs, cm(back)]);
+ }
}
/// The text-showing operators of one stream with their text, font, box and baseline.
@@ -879,14 +929,15 @@ pub(super) fn interpret(
b"Ts" => ts.rise = op.num(0).unwrap_or(0.0),
b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1),
b"gs" => {
- if let Some(name) = op.name(0) {
- select_gs(&mut ts.ext_gstate, name);
- }
let mut held = None;
let params = match op.name(0).and_then(|name| states.get(name)) {
Some(p) => super::dict_in_place(doc, p, &mut held),
None => None,
};
+ if let Some(name) = op.name(0) {
+ let masked = params.and_then(|p| p.get(b"SMask")).is_some_and(|m| doc.resolve(m).as_dict().is_some());
+ select_gs(&mut ts.ext_gstate, name, masked.then_some(ts.ctm.0));
+ }
if let Some(params) = params {
ts.gs.apply(doc, params);
// `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5).
diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs
index ec2fac984c..d27d23e0b5 100644
--- a/crates/edit/src/text/rewrite.rs
+++ b/crates/edit/src/text/rewrite.rs
@@ -702,11 +702,13 @@ fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String,
let mut ops = vec![Op::new("q", vec![])];
ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?);
// Moved: a translation inside that state.
+ let mut ctm = Matrix(plan.emit.ctm);
if let Some((ax, ay)) = plan.movement {
ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)]));
+ ctm = Matrix([1.0, 0.0, 0.0, 1.0, ax, ay]).then(&ctm);
}
// Painted as the paragraph was, including what its text object set before the first show.
- ops.extend(plan.state.paint_ops(plan.after));
+ ops.extend(plan.state.paint_ops(plan.after, ctm.0));
ops.push(Op::new("BT", vec![]));
let mut state = plan.state.clone();
state.word_spacing = 0.0;
@@ -816,6 +818,11 @@ pub(crate) fn rewrite_block_from(
let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
members.iter().try_for_each(|line| line.ensure_editable())?;
let p = page_dict(doc, page)?;
+ // Revealed OCR text may go on top of the page (see `revealed_under_paint`): a page that can't
+ // take that is refused here, before either rewrite path changes anything.
+ if b.invisible {
+ crate::check_on_top(doc, &p)?;
+ }
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?;
@@ -930,9 +937,28 @@ pub(crate) fn rewrite_block_from(
char_spacing: o_state.char_spacing,
lead,
};
- let plan =
- BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) };
+ let at = if split { start } else { emit.bt_op };
+ // Revealed OCR text that something is painted over goes on top of the page, where it starts
+ // from the initial state.
+ let widest = wrapped.iter().map(|(l, _, _)| advance.of(l)).fold(width, f64::max);
+ let reach = crate::Reach { rect: b.rect, offset: [dx, dy], width: widest, rows: wrapped.len(), pitch: lead * k, upright };
+ let over = crate::revealed_under_paint(doc, page, b.invisible, reach.bounds(), &ops, at);
+ let initial = TextState::initial();
+ let drawn_from = if over { &initial } else { after };
+ let plan = BlockPlan {
+ block: &b,
+ lines: &lines,
+ style,
+ emit,
+ after: drawn_from,
+ movement,
+ state: o_state,
+ tm,
+ size,
+ underline: style.underline == Some(true),
+ };
let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?;
+ let on_top = crate::lift_on_top(over, &mut block_ops);
let n = pdfcraft_content::num;
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
if split {
@@ -940,7 +966,6 @@ pub(crate) fn rewrite_block_from(
block_ops.push(Op::new("BT", vec![]));
block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect()));
}
- let at = if split { start } else { emit.bt_op };
let new_data = splice(&pieces, &ops, |i| {
let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
(insert, !dropped.contains(&i))
@@ -957,12 +982,14 @@ pub(crate) fn rewrite_block_from(
d.set(b"Resources".to_vec(), Object::Dict(res));
}
})?;
+ crate::draw_on_top(doc, &p, emit.ctm, &on_top)?;
Ok(LineEdit { substituted: font.substituted })
}
/// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a
/// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text.
-/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it.
+/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one
+/// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]).
pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> {
if !block.invisible {
return Ok(Vec::new());
diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs
index 885a0a7158..7a9204a8c0 100644
--- a/crates/edit/src/text_regions/styled.rs
+++ b/crates/edit/src/text_regions/styled.rs
@@ -1,3 +1,4 @@
+use super::super::interp::TextState;
use super::grouping::{across, is_centred, row_rect};
use super::{
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
@@ -401,6 +402,16 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB
Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0)))
}
+/// How far below a row of `size` the next one is written: the source's leading unless a size
+/// or line spacing is chosen.
+fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 {
+ if style.line_spacing.is_none() && style.size.is_none() {
+ source_leading
+ } else {
+ style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(size)
+ }
+}
+
type WrappedStyledRows = (StyledRows, RowRanges, Vec, Vec, Vec);
fn wrap_styled_rows(
@@ -976,6 +987,7 @@ pub(crate) fn rewrite_styled_block(
let o = &lead.origin;
// What is in force where the rewrite is drawn, and what the text after it expects.
let after = if split { &o.state } else { &o.bt_state };
+ let at = if split { start } else { o.bt_op };
validate_contextual_rows(&rows, &kept)?;
let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?;
let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?;
@@ -989,6 +1001,7 @@ pub(crate) fn rewrite_styled_block(
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
let (tx, ty) = back.apply(dx, dy);
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)]));
+ let run_ctm = Matrix([1.0, 0.0, 0.0, 1.0, tx, ty]).then(&Matrix(o.ctm)).0;
// A new row can land one leading above the next paragraph and look like the same
// paragraph. When every row is in this rewrite, a paragraph marker keeps them in one
// scope so the neighbour stays out (#2517). A row left in the source stream cannot share
@@ -1007,6 +1020,19 @@ pub(crate) fn rewrite_styled_block(
})
.rfind(|v| *v > 0.0)
.unwrap_or(first.size * 1.5);
+ // Revealed OCR text that something is painted over goes on top of the page, where it starts
+ // from the initial state.
+ let largest =
+ block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max);
+ // At least one line of the largest glyphs, so a larger range reaches up as far as it draws.
+ const LINE_EM: f64 = 1.2;
+ let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * LINE_EM);
+ let widest = measured.iter().copied().fold(width, f64::max);
+ // This path lays rows out along user-space baselines, so the paragraph is upright here.
+ let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true };
+ let over = crate::revealed_under_paint(doc, page, block.invisible, reach.bounds(), &ops, at);
+ let initial = TextState::initial();
+ let drawn_from = if over { &initial } else { after };
let mut baseline = first.origin.baseline;
for (row_index, row) in rows.iter().enumerate() {
let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i));
@@ -1073,7 +1099,7 @@ pub(crate) fn rewrite_styled_block(
// object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is
// drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next.
block_ops.push(Op::new("q", vec![]));
- block_ops.extend(state.paint_ops(after));
+ block_ops.extend(state.paint_ops(drawn_from, run_ctm));
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
let mut properties = Dict::new();
@@ -1164,21 +1190,17 @@ pub(crate) fn rewrite_styled_block(
x += segment_width;
pos = end;
}
- baseline -= if style.line_spacing.is_none() && style.size.is_none() {
- source_leading
- } else {
- style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size)
- };
+ baseline -= row_step(style, anchor.size, source_leading);
}
push_whole_paragraph_marker(&mut block_ops, marked, "EMC");
block_ops.push(Op::new("Q", vec![]));
+ let on_top = crate::lift_on_top(over, &mut block_ops);
block_ops.extend(after.ops());
if split {
block_ops.insert(0, Op::new("ET", vec![]));
block_ops.push(Op::new("BT", vec![]));
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
}
- let at = if split { start } else { o.bt_op };
// A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is
// safe. The close is not part of `block_ops`: that vector can end inside a text object
// when the rewrite splits one, and a kept row's operators come later in the stream.
@@ -1190,5 +1212,6 @@ pub(crate) fn rewrite_styled_block(
d.set(b"Contents".to_vec(), contents);
d.set(b"Resources".to_vec(), Object::Dict(res));
})?;
+ crate::draw_on_top(doc, &p, o.ctm, &on_top)?;
Ok(LineEdit { substituted })
}
diff --git a/crates/engine/src/camera_clean.rs b/crates/engine/src/camera_clean.rs
index 9c4120be56..d1768a2a83 100644
--- a/crates/engine/src/camera_clean.rs
+++ b/crates/engine/src/camera_clean.rs
@@ -1,6 +1,8 @@
-//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level): even out
-//! the lighting so the paper turns white (room-light colour casts, a hand's shadow, darker
-//! edges go with it), then raise the contrast and drop isolated specks. Colour ink stays colour.
+//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level) or a
+//! scanned page's picture (Enhance ▸ Scanned document, [`crate::scan_clean`]): even out the
+//! lighting so the paper turns white (room-light colour casts, a hand's shadow, darker edges and
+//! book gutters go with it), then raise the contrast and drop isolated specks. Colour ink stays
+//! colour.
//!
//! The paper's illumination is estimated per channel on a coarse grid of blocks, from each
//! block's bright end (the paper, not the ink), smoothed into a continuous map and divided out.
@@ -8,6 +10,8 @@
/// The strongest a level can be.
pub const MAX_LEVEL: u8 = 100;
+/// The level from which [`clean`] also drops isolated specks.
+pub const DESPECKLE_FROM: u8 = 60;
/// How an enhanced camera page looks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -66,34 +70,158 @@ pub fn apply(rgb: &[u8], width: u32, height: u32, look: Look, level: u8) -> Opti
let even = clean(rgb, width, height, 50)?;
let t = 0.45 + 0.4 * level.min(MAX_LEVEL) as f32 / MAX_LEVEL as f32;
let mut grey: Vec = even.as_chunks::<3>().0.iter().map(|p| if (lum(p) as f32 / 255.0) < t { 0 } else { 255 }).collect();
- despeckle_grey(&mut grey, w, h);
+ despeckle(&mut grey, 1, w, h);
Pixels::Grey(grey)
}
})
}
-/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest).
-/// Returns `None` when the buffer doesn't match the size.
+/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest), from
+/// [`DESPECKLE_FROM`] without isolated specks. Returns `None` when the buffer doesn't match the
+/// size.
pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option> {
+ clean_samples(rgb, 3, width, height, CleanUp { level, edge_shadows: false, despeckle: level >= DESPECKLE_FROM })
+}
+
+/// What [`clean_samples`] does.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct CleanUp {
+ /// 0 (unchanged) … 100: even out the paper's illumination and raise the contrast, as
+ /// [`clean`]'s level.
+ pub level: u8,
+ /// Even out shadows along the picture's edges first (a book's gutter, a copier's dark edge):
+ /// lightened line by line to the paper beside them, within the outer fifth of each side.
+ pub edge_shadows: bool,
+ /// Drop isolated dark specks.
+ pub despeckle: bool,
+}
+
+/// [`clean`] for samples of `n` components (1: grey, 3: RGB), with each step chosen apart (each
+/// works at level 0 too). Returns `None` when `n` is neither or the buffer doesn't match the size.
+pub fn clean_samples(px: &[u8], n: usize, width: u32, height: u32, how: CleanUp) -> Option> {
let (w, h) = (width as usize, height as usize);
- if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() {
+ if !(n == 1 || n == 3) || w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(n)? != px.len() {
return None;
}
- let level = level.min(MAX_LEVEL);
- if level == 0 {
- return Some(rgb.to_vec());
+ let level = how.level.min(MAX_LEVEL);
+ let mut out = px.to_vec();
+ if how.edge_shadows {
+ even_edges(&mut out, n, w, h);
}
+ if level > 0 {
+ out = even_out(&out, n, w, h, level);
+ }
+ if how.despeckle {
+ despeckle(&mut out, n, w, h);
+ }
+ Some(out)
+}
+
+/// The share of the width (and of the height) at each side where [`even_edges`] works.
+const EDGE_BAND: f32 = 0.2;
+/// The most [`even_edges`] lightens a line by; a darker line (a scanner's black border, a
+/// picture) is not taken for shadowed paper and stays as it is.
+const MAX_EDGE_GAIN: f32 = 3.0;
+
+/// Even out shadows that run along the picture's edges, in place: a book's gutter, a copier's
+/// dark border, a page that curved away from the glass. Within the outer [`EDGE_BAND`] of each
+/// side, each column (then each row) gets the paper's level along it (per channel, its 90th
+/// percentile, so the text across it doesn't count), and is lightened to the brightest paper of
+/// that band. The block grid of [`background`] is too coarse for such a narrow shadow: its
+/// blocks borrow the brighter paper next to them.
+fn even_edges(px: &mut [u8], n: usize, w: usize, h: usize) {
+ for vertical in [true, false] {
+ // Columns: lines across the width, each running down the height; rows the other way.
+ let (lines, along) = if vertical { (w, h) } else { (h, w) };
+ let band = ((lines as f32 * EDGE_BAND) as usize).min(lines / 2);
+ if band == 0 {
+ continue;
+ }
+ let at = |line: usize, i: usize| if vertical { i * w + line } else { line * w + i };
+ // The paper's level of every line in the bands and a few beyond them.
+ let reach = band.saturating_add(3).min(lines);
+ let measured = |line: usize| line < reach || line >= lines.saturating_sub(reach);
+ let mut level: Vec> = vec![None; lines];
+ let mut hist = [[0u32; 256]; 3];
+ for (line, slot) in level.iter_mut().enumerate().filter(|(line, _)| measured(*line)) {
+ hist.iter_mut().for_each(|hh| hh.fill(0));
+ for i in 0..along {
+ let p = at(line, i) * n;
+ for (c, hh) in hist.iter_mut().enumerate().take(n) {
+ if let Some(v) = px.get(p + c)
+ && let Some(count) = hh.get_mut(*v as usize)
+ {
+ *count += 1;
+ }
+ }
+ }
+ let want = (along as f32 * 0.9) as u32;
+ *slot = Some(std::array::from_fn(|c| {
+ let mut seen = 0;
+ let v = hist.get(c).and_then(|hh| {
+ hh.iter().position(|&count| {
+ seen += count;
+ seen > want
+ })
+ });
+ v.unwrap_or(255) as f32 / 255.0
+ }));
+ }
+ // Smoothed: the median of five neighbouring lines (fewer at the very edge, centred all
+ // the same, so a gradient isn't shifted), so one odd line (a rule, a fold) doesn't stand
+ // out.
+ let smooth: Vec > = (0..lines)
+ .map(|line| {
+ level.get(line).copied().flatten()?;
+ let r = line.min(lines - 1 - line).min(2);
+ Some(std::array::from_fn(|c| {
+ let mut near: Vec = (line - r..=line + r).filter_map(|l| level.get(l).copied().flatten()).map(|v| v[c]).collect();
+ near.sort_by(f32::total_cmp);
+ near.get(near.len() / 2).copied().unwrap_or(1.0)
+ }))
+ })
+ .collect();
+ // Each side's band, and the lines its paper is measured on (the band and a few beyond).
+ for (shadowed, measured) in [(0..band, 0..reach), (lines - band..lines, lines.saturating_sub(reach)..lines)] {
+ // The brightest paper there: what the shadow is lightened to.
+ let paper: [f32; 3] =
+ std::array::from_fn(|c| measured.clone().filter_map(|l| smooth.get(l).copied().flatten()).map(|v| v[c]).fold(0.0, f32::max));
+ for line in shadowed {
+ let Some(own) = smooth.get(line).copied().flatten() else { continue };
+ let gain: [f32; 3] = std::array::from_fn(|c| {
+ let g = paper[c] / own[c].max(1e-3);
+ if (1.0..=MAX_EDGE_GAIN).contains(&g) { g } else { 1.0 }
+ });
+ if gain.iter().take(n).all(|&g| g == 1.0) {
+ continue;
+ }
+ for i in 0..along {
+ let p = at(line, i) * n;
+ for (c, g) in gain.iter().enumerate().take(n) {
+ if let Some(v) = px.get_mut(p + c) {
+ *v = (*v as f32 * g).round().clamp(0.0, 255.0) as u8;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// The lighting evened out and the contrast raised at `level` (1–100); `px` holds `w` × `h`
+/// pixels of `n` components.
+fn even_out(px: &[u8], n: usize, w: usize, h: usize, level: u8) -> Vec {
let k = level as f32 / MAX_LEVEL as f32;
- let background = background(rgb, w, h);
+ let background = background(px, n, w, h);
// Lighting is evened out fully from half strength on; contrast keeps growing to the top.
let flatten = (2.0 * k).min(1.0);
let white = 1.0 - 0.12 * k; // what reaches white
let black = 0.30 * k; // what reaches black
- let mut out = Vec::with_capacity(rgb.len());
- for (i, px) in rgb.as_chunks::<3>().0.iter().enumerate() {
+ let mut out = Vec::with_capacity(px.len());
+ for (i, p) in px.chunks_exact(n).enumerate() {
let (x, y) = (i % w, i / w);
let bg = background.at(x, y);
- for (c, &v) in px.iter().enumerate() {
+ for (c, &v) in p.iter().enumerate() {
let v = v as f32 / 255.0;
let b = bg.get(c).copied().unwrap_or(1.0).max(0.05);
let even = (v / b).min(1.0);
@@ -102,10 +230,7 @@ pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option>
out.push((stretched * 255.0).round() as u8);
}
}
- if level >= 60 {
- despeckle(&mut out, w, h);
- }
- Some(out)
+ out
}
/// The paper's illumination: a grid of per-channel values, read back with bilinear
@@ -140,9 +265,10 @@ impl Background {
}
}
-/// Estimate the paper's illumination: per block, the 90th percentile of each channel (bright
-/// paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then smoothed.
-fn background(rgb: &[u8], w: usize, h: usize) -> Background {
+/// Estimate the paper's illumination: per block, the 90th percentile of each of the `n` channels
+/// (bright paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then
+/// smoothed. Channels past `n` stay white.
+fn background(px: &[u8], n: usize, w: usize, h: usize) -> Background {
let block = (w.min(h) / 16).clamp(8, 128);
let (cols, rows) = (w.div_ceil(block), h.div_ceil(block));
let mut cells = Vec::with_capacity(cols * rows);
@@ -150,20 +276,20 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background {
for by in 0..rows {
for bx in 0..cols {
hist.iter_mut().for_each(|hh| hh.fill(0));
- let mut n = 0u32;
+ let mut count = 0u32;
for y in by * block..((by + 1) * block).min(h) {
for x in bx * block..((bx + 1) * block).min(w) {
- if let Some(p) = rgb.get((y * w + x) * 3..(y * w + x) * 3 + 3) {
+ if let Some(p) = px.get((y * w + x) * n..(y * w + x) * n + n) {
for (c, &v) in p.iter().enumerate() {
if let Some(slot) = hist.get_mut(c).and_then(|hh| hh.get_mut(v as usize)) {
*slot += 1;
}
}
- n += 1;
+ count += 1;
}
}
}
- let want = (n as f32 * 0.9) as u32;
+ let want = (count as f32 * 0.9) as u32;
cells.push(std::array::from_fn(|c| {
let mut seen = 0;
let v = hist.get(c).and_then(|hh| {
@@ -219,19 +345,15 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background {
Background { cols, rows, block: block as f32, cells }
}
-/// [`despeckle`] for one grey byte per pixel.
-fn despeckle_grey(grey: &mut [u8], w: usize, h: usize) {
- let mut rgb: Vec = grey.iter().flat_map(|&v| [v, v, v]).collect();
- despeckle(&mut rgb, w, h);
- for (g, p) in grey.iter_mut().zip(rgb.as_chunks::<3>().0.iter()) {
- *g = p[0];
- }
-}
-
-/// Lighten dark pixels with at most one dark neighbour (specks), in place.
-fn despeckle(rgb: &mut [u8], w: usize, h: usize) {
- let lum = |p: &[u8; 3]| (p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000;
- let dark: Vec = rgb.as_chunks::<3>().0.iter().map(|p| lum(p) < 128).collect();
+/// Lighten dark pixels with at most one dark neighbour (specks), in place; `px` holds `w` × `h`
+/// pixels of `n` components (1 or 3).
+fn despeckle(px: &mut [u8], n: usize, w: usize, h: usize) {
+ let lum = |p: &[u8]| match p {
+ [r, g, b] => (*r as u32 * 299 + *g as u32 * 587 + *b as u32 * 114) / 1000,
+ [v, ..] => *v as u32,
+ [] => 255,
+ };
+ let dark: Vec = px.chunks_exact(n.max(1)).map(|p| lum(p) < 128).collect();
let is_dark = |x: isize, y: isize| {
x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && dark.get(y as usize * w + x as usize).copied().unwrap_or(false)
};
@@ -243,7 +365,7 @@ fn despeckle(rgb: &mut [u8], w: usize, h: usize) {
let neighbours =
(-1..=1).flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))).filter(|&(dx, dy)| (dx, dy) != (0, 0) && is_dark(x + dx, y + dy)).count();
if neighbours <= 1
- && let Some(p) = rgb.get_mut((y as usize * w + x as usize) * 3..(y as usize * w + x as usize) * 3 + 3)
+ && let Some(p) = px.get_mut((y as usize * w + x as usize) * n..(y as usize * w + x as usize) * n + n)
{
p.fill(255);
}
@@ -388,5 +510,40 @@ mod tests {
assert_eq!(clean(&[], 0, 0, 50), None);
assert_eq!(clean(&[0; 12], 2, 3, 50), None);
assert_eq!(clean(&[9; 3], 1, 1, 255).map(|v| v.len()), Some(3));
+ let every = CleanUp { level: 100, edge_shadows: true, despeckle: true };
+ assert_eq!(clean_samples(&[0; 12], 2, 2, 3, every), None, "two components");
+ assert_eq!(clean_samples(&[0; 5], 1, 2, 3, every), None);
+ for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 2), (5, 3)] {
+ assert_eq!(clean_samples(&vec![100; w * h], 1, w as u32, h as u32, every).map(|v| v.len()), Some(w * h));
+ }
+ }
+
+ #[test]
+ fn edge_shadows_lighten_shadowed_paper_but_not_a_black_border_or_the_middle() {
+ // Grey paper (200) with a shadow darkening the left 20 columns to 120, a black scanner
+ // border in the right 8 columns, and a dark block in the middle.
+ let (w, h) = (100usize, 60usize);
+ let mut grey = vec![200u8; w * h];
+ for y in 0..h {
+ for x in 0..w {
+ let v = if x >= w - 8 {
+ 5
+ } else if (40..60).contains(&x) && (20..40).contains(&y) {
+ 30
+ } else if x < 20 {
+ 120 + (80 * x / 20) as u8
+ } else {
+ 200
+ };
+ grey[y * w + x] = v;
+ }
+ }
+ let out = clean_samples(&grey, 1, w as u32, h as u32, CleanUp { level: 0, edge_shadows: true, despeckle: false }).unwrap();
+ for x in [0, 5, 19] {
+ assert!(out[30 * w + x].abs_diff(200) <= 3, "column {x}: {} → {}", grey[30 * w + x], out[30 * w + x]);
+ }
+ assert_eq!(out[30 * w + w - 3], 5, "a black border is no shadow");
+ assert_eq!(out[30 * w + 50], 30, "the middle is untouched");
+ assert_eq!(out[30 * w + 30], 200);
}
}
diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs
index e479232bbb..47394a7bb5 100644
--- a/crates/engine/src/catalog.rs
+++ b/crates/engine/src/catalog.rs
@@ -265,7 +265,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("In this file", "scan-text", "ocr.recognize", Ready),
item("In multiple files", "files", "ocr.recognize_batch", Ready),
- item("Enhance scanned file", "sparkles", "ocr.enhance", Planned("M10")),
+ item("Enhance scanned file", "sparkles", "ocr.enhance", Ready),
item("Enhance camera image", "camera", "ocr.enhance_camera", Ready),
item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")),
],
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index 0647726452..bd229656a6 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -355,6 +355,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"),
c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"),
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
+ c("ocr.enhance", "Enhance scanned file…", None, None, Modification, "sparkles"),
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"),
diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs
index 53b890f27c..9d5668ccf5 100644
--- a/crates/engine/src/copy_image.rs
+++ b/crates/engine/src/copy_image.rs
@@ -112,7 +112,7 @@ pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option {
}
/// The largest raster image Form `form` draws through its resources (nested Forms included).
-fn picture_in_form(cos: &Cos, form: ObjRef) -> Option {
+pub(crate) fn picture_in_form(cos: &Cos, form: ObjRef) -> Option {
let mut seen = HashSet::from([form]);
let mut best: Option<(u64, ObjRef)> = None;
let res = resources(cos, form)?;
diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs
index 8f43689aa9..40745f8c2a 100644
--- a/crates/engine/src/edit.rs
+++ b/crates/engine/src/edit.rs
@@ -6,7 +6,7 @@ use crate::{
AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label,
LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState,
Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture,
- image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
+ image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
mod labels;
@@ -119,6 +119,15 @@ pub enum Edit {
bold: Option,
italic: Option,
},
+ /// Write `bookmarks` as children of `parent` from child `index` on, or with `replace` in
+ /// place of the whole outline (`parent` must then be `[]`). A bookmark whose page this
+ /// document lacks is kept without a destination (see [`pdfcraft_organize::import_outline`]).
+ ImportBookmarks {
+ parent: Vec,
+ index: usize,
+ bookmarks: Vec,
+ replace: bool,
+ },
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
/// "Untitled" bookmark (New Bookmarks from Structure).
BookmarksFromStructure,
@@ -157,6 +166,17 @@ pub enum Edit {
file: MarkFile,
author: String,
},
+ /// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named
+ /// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an
+ /// image) fitted inside `rect`, keeping its shape and centred, or a labelled frame without one.
+ AddModel3d {
+ page: usize,
+ rect: [f64; 4],
+ model: Arc>,
+ poster: Option,
+ activation: pdfcraft_annot::Activation3d,
+ name: String,
+ },
/// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies.
DeleteAnnotation {
page: usize,
@@ -386,6 +406,13 @@ pub enum Edit {
page: usize,
words: Vec,
},
+ /// Scan & OCR ▸ Enhance ▸ Scanned document: put the scan images
+ /// [`ScanCleanJob::run`](crate::scan_clean::ScanCleanJob::run) cleaned in place of the old
+ /// ones (each object must still be an image). Built by
+ /// [`Session::apply_scan_clean`](crate::Session::apply_scan_clean).
+ EnhanceScan {
+ images: Vec<(pdfcraft_cos::ObjRef, pdfcraft_cos::Stream)>,
+ },
/// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on
/// `page`, in its own font when it can show it, else in Helvetica.
EditTextLine {
diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs
index 5343804ef3..e0ec47a2ba 100644
--- a/crates/engine/src/edit/labels.rs
+++ b/crates/engine/src/edit/labels.rs
@@ -38,6 +38,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Set bookmark destination",
"Colour",
"Style",
+ "Import bookmarks",
"New bookmarks from structure",
"New bookmarks from text styles",
"Number pages",
@@ -68,6 +69,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Add inserted text",
"Add file attachment",
"Add stamp",
+ "Add 3D model",
"Delete comment",
"Edit comment",
"Reply",
@@ -99,6 +101,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Set alternate text",
"Mark figure as decorative",
"Recognize text",
+ "Enhance scanned file",
"Run JavaScript",
"Run form script",
"Save as {level}",
@@ -193,6 +196,7 @@ impl Edit {
// The bookmark Style and Colour menus' own names, already said in every language.
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
+ Edit::ImportBookmarks { .. } => Label::of("Import bookmarks"),
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"),
Edit::NumberPages { .. } => Label::of("Number pages"),
@@ -205,6 +209,7 @@ impl Edit {
Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"),
Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)),
Edit::AddCustomStamp { .. } => Label::of("Add stamp"),
+ Edit::AddModel3d { .. } => Label::of("Add 3D model"),
Edit::DeleteAnnotation { .. } => Label::of("Delete comment"),
Edit::SetAnnotationContents { .. } => Label::of("Edit comment"),
Edit::ReplyToAnnotation { .. } => Label::of("Reply"),
@@ -248,6 +253,7 @@ impl Edit {
Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"),
Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"),
Edit::AddOcrText { .. } => Label::of("Recognize text"),
+ Edit::EnhanceScan { .. } => Label::of("Enhance scanned file"),
Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"),
Edit::XfaEvent { .. } => Label::of("Run form script"),
// The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands.
diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs
index 5ed70aed7a..c11904a74e 100644
--- a/crates/engine/src/edit/run.rs
+++ b/crates/engine/src/edit/run.rs
@@ -1,6 +1,6 @@
use super::{
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
- image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
+ image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
use crate::SourceProblem;
use crate::sources::{comment_source, import_pdf_comments};
@@ -27,6 +27,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::SetBookmarkPage { .. }
| Edit::SetBookmarkColor { .. }
| Edit::SetBookmarkStyle { .. }
+ | Edit::ImportBookmarks { .. }
| Edit::BookmarksFromStructure
| Edit::NumberPages { .. } => {
if p.assemble() {
@@ -49,6 +50,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
+ | Edit::AddModel3d { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
| Edit::ReplyToAnnotation { .. }
@@ -136,6 +138,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::MarkDecorative { .. }
| Edit::MarkTagArtifact { .. }
| Edit::AddOcrText { .. }
+ | Edit::EnhanceScan { .. }
| Edit::SetDocumentScript { .. }
| Edit::SetFieldScript { .. }
| Edit::RemoveStandardId { .. }
@@ -454,6 +457,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?,
Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?,
Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?,
+ Edit::ImportBookmarks { parent, index, bookmarks, replace } => {
+ pdfcraft_organize::import_outline(doc, parent, *index, bookmarks, *replace)?;
+ }
Edit::BookmarksFromStructure => {
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
.into_iter()
@@ -509,6 +515,16 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?;
}
+ Edit::AddModel3d { page, rect, model, poster, activation, name } => {
+ // A file that is not a model is refused before its poster is brought in.
+ pdfcraft_annot::check_model(model)?;
+ let poster = match poster {
+ Some(file) => Some(model_poster(doc, *rect, file)?),
+ None => None,
+ };
+ let new = pdfcraft_annot::NewModel3d { page: *page, rect: *rect, model, activation: *activation, poster, contents: name };
+ pdfcraft_annot::add_3d_model(doc, &new, &cx.meta())?;
+ }
Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?,
Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?,
Edit::ReplyToAnnotation { page, index, text, author } => {
@@ -689,6 +705,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::AddOcrText { page, words } => {
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
}
+ Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?,
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
Edit::EditTextBlock { page, block, text, style } => {
diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs
index fafa65b4c2..416cdc1d54 100644
--- a/crates/engine/src/history.rs
+++ b/crates/engine/src/history.rs
@@ -54,6 +54,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
+ | Edit::AddModel3d { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
| Edit::ReplyToAnnotation { .. }
@@ -77,6 +78,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::EditPageImage { .. }
+ | Edit::EnhanceScan { .. }
| Edit::AddText { .. }
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index fe139164be..2daa1da400 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -35,6 +35,7 @@ mod history;
pub mod js;
mod layers;
pub mod links;
+pub mod model3d;
pub mod ocr;
mod open;
pub mod optimizer;
@@ -43,6 +44,7 @@ mod pictures;
pub mod print_raster;
pub mod printcheck;
mod protection;
+pub mod scan_clean;
mod scan_colours;
mod scan_fields;
mod session;
@@ -71,7 +73,10 @@ pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::{CommentLayer, StampPreview};
pub use text_preview::{TextEditPreview, UnmarkedPages};
-pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
+pub use pdfcraft_organize::{
+ BoxSpec, FieldConflicts, MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name,
+ split_ranges, unmapped as outline_unmapped, view_arity as outline_view_arity,
+};
pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::Standard;
@@ -108,9 +113,10 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
pub use pdfcraft_annot::{
- AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps,
- ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
+ Activation3d, AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook,
+ Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
};
+pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat};
pub use pdfcraft_forms::detect;
pub use pdfcraft_optimize as optimize;
pub use pdfcraft_preflight as pdfa;
@@ -140,7 +146,7 @@ use layers::{apply_layer_state, set_default_layer_state, use_layer_choices};
pub use open::{OpenContext, PreparedDocument};
pub use page_filter::{PageOrientation, PageParity, filter_pages};
pub use pictures::{MarkFile, image_dimensions};
-use pictures::{embed_image, fit_picture, mark_source};
+use pictures::{embed_image, fit_picture, mark_source, model_poster};
use protection::unrestricted;
pub use protection::{Changes, Printing, Protection, SecuritySummary};
pub use session::Session;
diff --git a/crates/engine/src/model3d.rs b/crates/engine/src/model3d.rs
new file mode 100644
index 0000000000..b95a332a22
--- /dev/null
+++ b/crates/engine/src/model3d.rs
@@ -0,0 +1,19 @@
+//! The 3D models on a page (ISO 32000-2 §13.6), read so a renderer can draw them.
+
+pub use pdfcraft_annot::{MAX_MODEL_BYTES, Model3d, Model3dKind};
+
+use crate::Document;
+
+impl Document {
+ /// The `/Annots` positions of the 3D annotations on 0-based `page`, in order.
+ pub fn model_3d_annotations(&self, page: usize) -> Result, String> {
+ let editor = self.editor.as_ref().ok_or("the document can't be read")?;
+ pdfcraft_annot::model_3d_annotations(&editor.cos, page).map_err(|e| e.to_string())
+ }
+
+ /// The decoded model of the 3D annotation at `/Annots` position `index` on 0-based `page`.
+ pub fn model_3d(&self, page: usize, index: usize) -> Result {
+ let editor = self.editor.as_ref().ok_or("the document can't be read")?;
+ pdfcraft_annot::model_3d(&editor.cos, page, index).map_err(|e| e.to_string())
+ }
+}
diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs
index 49d76f07c0..d8b8835dc7 100644
--- a/crates/engine/src/pictures.rs
+++ b/crates/engine/src/pictures.rs
@@ -60,6 +60,18 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res
}
}
+/// A 3D model's poster: the picture in `file` brought into `doc` and fitted inside `rect`
+/// (user space), keeping its shape, and centred. It is drawn in user space and not counter-rotated
+/// (unlike a stamp), so on a page with `/Rotate` it turns with the page, as the model does.
+pub(crate) fn model_poster(doc: &mut pdfcraft_cos::Document, rect: [f64; 4], file: &MarkFile) -> Result {
+ let src = mark_source(doc, file)?;
+ let (sw, sh) = src.size;
+ if !(sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0) {
+ return Err(EditError::Source(format!("{}: the poster has no area", file.name)));
+ }
+ Ok(pdfcraft_annot::PosterPicture { xobject: src.xobject, image: src.image, size: (sw, sh), at: fit_picture(rect, sw, sh) })
+}
+
/// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster.
pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let format = image::guess_format(bytes).ok()?;
diff --git a/crates/engine/src/scan_clean.rs b/crates/engine/src/scan_clean.rs
new file mode 100644
index 0000000000..cef7538309
--- /dev/null
+++ b/crates/engine/src/scan_clean.rs
@@ -0,0 +1,308 @@
+//! Scan & OCR ▸ Enhance ▸ Scanned document: clean up the pictures scanned pages are made of.
+//!
+//! Unlike Enhance ▸ Camera image, the page is not rendered and replaced: each scan image is
+//! decoded, cleaned ([`crate::camera_clean::clean_samples`]) and written back **into the same
+//! image object**, at the same size and in the same colour space. So everything else on the
+//! page stays as it was: an invisible OCR text layer, comments, links, form fields, other
+//! content, and the image's own soft mask, `/Interpolate` and other entries. A JPEG stays a JPEG
+//! (at quality 90), any other grey or RGB image is stored losslessly (Flate), and a black and
+//! white (1-bit) image stays 1-bit. An image several pages draw is cleaned once.
+//!
+//! A page is a scan when its pictures cover at least half of it (as for Recognize text); the
+//! pictures each covering at least a tenth of the page are its scan. What is not cleaned is
+//! reported, never skipped silently: pages that are no scan, and scan images in a form PdfCraft
+//! can't rewrite yet (CMYK, indexed, JPEG 2000, JBIG2, CCITT fax, a stencil mask, a remapping
+//! decode array, a scan drawn inside grouped Form artwork) or whose data is broken.
+//!
+//! Like Optimize, the work is a job: [`Session::scan_clean_job`] captures the document,
+//! [`ScanCleanJob::run`] cleans anywhere (a worker thread), and [`Session::apply_scan_clean`]
+//! makes the result one undoable step, "Enhance scanned file" (the name the Scan & OCR tool has).
+
+use pdfcraft_cos::{ObjRef, Object, Stream};
+
+use crate::camera_clean::{CleanUp, MAX_LEVEL, clean_samples};
+use crate::edit::check_permission;
+use crate::{DocId, Edit, EditError, Session, guard, optimize};
+
+pub use crate::camera_clean::DESPECKLE_FROM;
+
+/// The JPEG quality a cleaned JPEG scan is written back at (as Enhance camera image).
+const QUALITY: u8 = 90;
+/// On a scanned page, an image covering at least this share of the page is part of the scan;
+/// smaller ones (a logo or a signature pasted on) are left alone.
+const PART_OF_SCAN: f64 = 0.1;
+/// Why a scan drawn through grouped Form artwork stays as it is.
+const IN_FORM: &str = "it is drawn inside grouped artwork (a form XObject), which can't be cleaned up yet";
+
+/// How scanned pages are cleaned up.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct ScanCleanSettings {
+ /// Background removal, 0 (off) … 100 (strongest), as Enhance camera image's clean-up level:
+ /// the paper's illumination is evened out (fully from 50), so a grey, yellowed or unevenly lit
+ /// background turns white; then the contrast is raised so faint strokes darken. Colour ink
+ /// stays colour.
+ pub level: u8,
+ /// Edge shadow removal: shadows along the scan's edges (a book's gutter, a copier's dark
+ /// border) are lightened to the paper next to them, line by line, before the level applies.
+ pub edge_shadows: bool,
+ /// Despeckle: isolated dark specks (dust, scanner noise), dark pixels with at most one dark
+ /// neighbour, turn white. The only change a black and white (1-bit) scan takes.
+ pub despeckle: bool,
+}
+
+impl ScanCleanSettings {
+ /// The level `level` (capped at 100) with edge shadow removal (unless the level is 0), and
+ /// despeckling from [`DESPECKLE_FROM`] as the camera tool does.
+ pub fn at(level: u8) -> Self {
+ let level = level.min(MAX_LEVEL);
+ Self { level, edge_shadows: level > 0, despeckle: level >= DESPECKLE_FROM }
+ }
+
+ /// Whether these settings change anything at all.
+ pub fn changes_something(self) -> bool {
+ self.level > 0 || self.edge_shadows || self.despeckle
+ }
+}
+
+impl Default for ScanCleanSettings {
+ fn default() -> Self {
+ Self::at(50)
+ }
+}
+
+/// What a clean-up did.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct ScanCleanReport {
+ /// Pages (0-based) whose scan was cleaned, in order.
+ pub pages: Vec,
+ /// Images rewritten. One drawn on several pages counts once.
+ pub images: usize,
+ /// Pages left alone because no picture covers most of them.
+ pub not_scanned: Vec,
+ /// Scan images left as they were, and why.
+ pub skipped: Vec,
+}
+
+/// A scan image that was not cleaned.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkippedImage {
+ /// The pages (0-based) that draw it, among those asked for.
+ pub pages: Vec,
+ /// Its resource name on the first of them.
+ pub name: String,
+ /// Why, in a sentence that follows "skipped because".
+ pub reason: String,
+}
+
+/// One image to clean: its object, its name and the pages that draw it.
+struct Target {
+ object: ObjRef,
+ name: String,
+ pages: Vec,
+}
+
+/// What cleaning scanned pages needs, independent of the session.
+pub struct ScanCleanJob {
+ cos: pdfcraft_cos::Document,
+ targets: Vec,
+ settings: ScanCleanSettings,
+ /// The document's edit generation it was taken at: a result is for that state only.
+ generation: u64,
+ report: ScanCleanReport,
+}
+
+/// The cleaned images, ready for [`Session::apply_scan_clean`], and the report.
+pub struct ScanCleaned {
+ images: Vec<(ObjRef, Stream)>,
+ generation: u64,
+ pub report: ScanCleanReport,
+}
+
+impl Session {
+ /// Capture what cleaning up the scans on `pages` (0-based; empty = all) of document `id`
+ /// needs. Errors when the document can't be changed (read-only, or its permissions forbid
+ /// changes) or a page doesn't exist.
+ pub fn scan_clean_job(&self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result {
+ let doc = self.get(id).ok_or(EditError::NoDocument)?;
+ if let Some(why) = &doc.read_only_reason {
+ return Err(EditError::ReadOnly(why.clone()));
+ }
+ let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(String::new()))?;
+ if let Some(p) = editor.cos.permissions() {
+ check_permission(&Edit::EnhanceScan { images: Vec::new() }, &p)?;
+ }
+ let all = doc.info.pages.len();
+ let mut wanted: Vec = if pages.is_empty() { (0..all).collect() } else { pages.to_vec() };
+ if let Some(p) = wanted.iter().find(|p| **p >= all) {
+ return Err(EditError::Invalid(format!("there is no page {}: the document has {all}", p.saturating_add(1))));
+ }
+ wanted.sort_unstable();
+ wanted.dedup();
+ let mut report = ScanCleanReport::default();
+ let mut targets: Vec = Vec::new();
+ // Form artwork already reported, and where in `report.skipped`.
+ let mut forms_reported: Vec<(ObjRef, usize)> = Vec::new();
+ for page in wanted {
+ let Some(info) = doc.info.pages.get(page) else { continue };
+ // Ordinary images and scan-holding Forms are classified together, so a page that
+ // mixes them gets its images cleaned and its Forms reported.
+ let Some((images, forms, own_scan)) = crate::scan_fields::scan_parts(doc, page, info) else {
+ report.not_scanned.push(page);
+ continue;
+ };
+ let forms: Vec<_> = forms.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN).collect();
+ if !own_scan && forms.is_empty() {
+ report.not_scanned.push(page);
+ continue;
+ }
+ // A scan drawn through a Form XObject is a scan all the same: say why it stays.
+ for (form, _) in forms {
+ let known = form.object.and_then(|o| forms_reported.iter().find(|(r, _)| *r == o)).and_then(|(_, k)| report.skipped.get_mut(*k));
+ match known {
+ Some(s) if !s.pages.contains(&page) => s.pages.push(page),
+ Some(_) => {}
+ None => {
+ if let Some(o) = form.object {
+ forms_reported.push((o, report.skipped.len()));
+ }
+ report.skipped.push(SkippedImage { pages: vec![page], name: form.name, reason: IN_FORM.into() });
+ }
+ }
+ }
+ for (image, _) in images.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN) {
+ let Some(object) = image.object else {
+ report.skipped.push(SkippedImage {
+ pages: vec![page],
+ name: image.name,
+ reason: "it is stored inside the page's resources rather than as an object of its own".into(),
+ });
+ continue;
+ };
+ match targets.iter_mut().find(|t| t.object == object) {
+ Some(t) if !t.pages.contains(&page) => t.pages.push(page),
+ Some(_) => {}
+ None => targets.push(Target { object, name: image.name, pages: vec![page] }),
+ }
+ }
+ }
+ Ok(ScanCleanJob { cos: editor.cos.clone(), targets, settings, generation: doc.edit_generation(), report })
+ }
+
+ /// Write the images [`ScanCleanJob::run`] cleaned into document `id` as one undoable step,
+ /// "Enhance scanned file" (none when nothing was cleaned), and return the report. Refused
+ /// when the document changed since the job was taken.
+ pub fn apply_scan_clean(&mut self, id: DocId, cleaned: ScanCleaned) -> Result {
+ let doc = self.get(id).ok_or(EditError::NoDocument)?;
+ if doc.edit_generation() != cleaned.generation {
+ return Err(EditError::Invalid("the document changed while its scans were being cleaned up; nothing was changed, try again".into()));
+ }
+ if !cleaned.images.is_empty() {
+ self.apply(id, Edit::EnhanceScan { images: cleaned.images })?;
+ }
+ Ok(cleaned.report)
+ }
+
+ /// Enhance ▸ Scanned document in one call: [`Self::scan_clean_job`], run, then
+ /// [`Self::apply_scan_clean`] (the CLI and agents use this).
+ pub fn enhance_scanned_pages(&mut self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result {
+ let job = self.scan_clean_job(id, pages, settings)?;
+ let cleaned = job.run(|_, _| true)?;
+ self.apply_scan_clean(id, cleaned)
+ }
+}
+
+impl ScanCleanJob {
+ /// The images to clean (one drawn on several pages counts once).
+ pub fn total(&self) -> usize {
+ self.targets.len()
+ }
+
+ /// Clean every scan image. `progress(done, total)` is called before each image; returning
+ /// `false` stops with [`EditError::Cancelled`]. A panic is returned as an error, so a worker
+ /// thread always reports back.
+ pub fn run(self, mut progress: impl FnMut(usize, usize) -> bool) -> Result {
+ guard(move || self.work(&mut progress)).map_err(|m| EditError::Invalid(format!("cleaning up the scans failed unexpectedly ({m})")))?
+ }
+
+ fn work(self, progress: &mut dyn FnMut(usize, usize) -> bool) -> Result {
+ let ScanCleanJob { cos, targets, settings, generation, mut report } = self;
+ let total = targets.len();
+ let mut images = Vec::new();
+ let mut changed_pages: Vec = Vec::new();
+ // One decoding allowance for the whole run, sized by the file.
+ let mut budget = pdfcraft_cos::ImageBudget::new(&cos);
+ for (done, target) in targets.into_iter().enumerate() {
+ if !progress(done, total) {
+ return Err(EditError::Cancelled);
+ }
+ let mut skip = |reason: String| report.skipped.push(SkippedImage { pages: target.pages.clone(), name: target.name.clone(), reason });
+ match clean_image(&cos, target.object, settings, &mut budget) {
+ Ok(Some(stream)) => {
+ images.push((target.object, stream));
+ changed_pages.extend(target.pages.iter().copied());
+ }
+ Ok(None) => skip("it is already clean: nothing changes at these settings".into()),
+ Err(reason) => skip(reason),
+ }
+ }
+ changed_pages.sort_unstable();
+ changed_pages.dedup();
+ report.images = images.len();
+ report.pages = changed_pages;
+ report.skipped.sort_by_key(|s| s.pages.first().copied());
+ Ok(ScanCleaned { images, generation, report })
+ }
+}
+
+/// Image `object` cleaned with `settings`: the new stream, `None` when nothing changes, or why it
+/// can't be cleaned.
+fn clean_image(
+ cos: &pdfcraft_cos::Document,
+ object: ObjRef,
+ settings: ScanCleanSettings,
+ budget: &mut pdfcraft_cos::ImageBudget,
+) -> Result, String> {
+ let obj = cos.get(object);
+ let Object::Stream(s) = &*obj else { return Err("it is not an image".into()) };
+ if s.dict.name(b"Subtype") != Some(b"Image") {
+ return Err("it is not an image".into());
+ }
+ // A colour-key mask names sample values to leave unpainted: cleaned samples would match others.
+ if matches!(s.dict.get(b"Mask").map(|m| cos.resolve(m)).as_deref(), Some(Object::Array(_))) {
+ return Err("it has a colour-key mask, which cleaning would change".into());
+ }
+ let samples = optimize::decode_samples(cos, s, budget).map_err(|e| e.to_string())?;
+ let cleaned = if samples.bilevel {
+ if !settings.despeckle {
+ return Err("it is black and white, which only Despeckle changes, and Despeckle is off".into());
+ }
+ clean_samples(&samples.data, 1, samples.width, samples.height, CleanUp { level: 0, edge_shadows: false, despeckle: true })
+ } else {
+ if !settings.changes_something() {
+ return Ok(None);
+ }
+ let how = CleanUp { level: settings.level, edge_shadows: settings.edge_shadows, despeckle: settings.despeckle };
+ clean_samples(&samples.data, samples.components, samples.width, samples.height, how)
+ }
+ .ok_or("its samples don't match its size")?;
+ if cleaned == samples.data {
+ return Ok(None);
+ }
+ let cleaned = optimize::Samples { data: cleaned, ..samples };
+ optimize::encode_samples(s, &cleaned, QUALITY).map(Some).ok_or_else(|| "it could not be written back".into())
+}
+
+/// [`Edit::EnhanceScan`]: put the cleaned images in place. Each object must still be an image.
+pub(crate) fn write_back(doc: &mut pdfcraft_cos::Document, images: &[(ObjRef, Stream)]) -> Result<(), EditError> {
+ for (object, stream) in images {
+ let is_image = matches!(&*doc.get(*object), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"));
+ if !is_image {
+ return Err(EditError::Invalid(format!("object {} is no longer an image; the scan was not changed", object.num)));
+ }
+ doc.set(*object, Object::Stream(stream.clone()));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs
new file mode 100644
index 0000000000..e58ab3e5ef
--- /dev/null
+++ b/crates/engine/src/scan_clean/tests.rs
@@ -0,0 +1,463 @@
+use std::sync::Arc;
+
+use pdfcraft_testkit::Pdf;
+
+use super::*;
+
+const W: usize = 300;
+const H: usize = 400;
+/// Isolated specks on the paper, away from the text rows.
+const SPECKS: [(usize, usize); 3] = [(150, 25), (200, 85), (120, 125)];
+
+/// The light falling on the paper at `x`: full in the middle, darker towards a shadowed left
+/// edge (as a book's gutter) and a little at the right edge.
+fn light(x: usize) -> f32 {
+ let left = (x as f32 / 60.0).min(1.0);
+ let right = ((W - 1 - x) as f32 / 30.0).min(1.0);
+ (0.55 + 0.45 * left) * (0.8 + 0.2 * right)
+}
+
+fn is_stroke(x: usize, y: usize) -> bool {
+ (60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x)
+}
+
+/// A synthetic scan, `n` components (1 grey, 3 RGB): greyish yellowed paper, a shadowed gutter,
+/// dark text strokes and a few specks.
+fn scan_pixels(n: usize) -> Vec {
+ let mut out = Vec::with_capacity(W * H * n);
+ for y in 0..H {
+ for x in 0..W {
+ let ink = if is_stroke(x, y) || SPECKS.contains(&(x, y)) { 0.12 } else { 0.8 };
+ let v = ink * light(x);
+ let px = [v, v, v * 0.85];
+ for c in px.iter().take(n) {
+ out.push((c * 255.0).round().clamp(0.0, 255.0) as u8);
+ }
+ }
+ }
+ out
+}
+
+fn jpeg(px: &[u8], n: usize) -> Vec {
+ let mut out = Vec::new();
+ let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 };
+ image::ImageEncoder::write_image(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95), px, W as u32, H as u32, ty).unwrap();
+ out
+}
+
+/// Three pages: 1 a scan (the image `image_dict`/`data` as object 4) with an invisible text
+/// layer and a comment, 2 a page of real text, 3 the same scan image drawn again.
+fn scan_pdf(image_dict: &str, data: &[u8]) -> Vec {
+ let resources = "/Resources << /XObject << /Im0 4 0 R >> /Font << /F1 5 0 R >> >>";
+ Pdf::new()
+ .obj("<< /Type /Catalog /Pages 2 0 R >>")
+ .obj("<< /Type /Pages /Kids [3 0 R 8 0 R 9 0 R] /Count 3 >>")
+ .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 6 0 R /Annots [7 0 R] >>"))
+ .stream(&format!("/Type /XObject /Subtype /Image {image_dict}"), data)
+ .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
+ .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q BT 3 Tr /F1 12 Tf 20 380 Td (Invisible OCR words) Tj ET"))
+ .obj("<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /Contents (A comment) >>")
+ .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 10 0 R >>"))
+ .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 11 0 R >>"))
+ .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET")
+ .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"))
+ .build()
+}
+
+fn grey_scan() -> Vec {
+ scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true"), &scan_pixels(1))
+}
+
+fn open(bytes: Vec) -> (Session, DocId) {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("scan.pdf", None, Arc::new(bytes), None).unwrap();
+ (s, id)
+}
+
+fn object(s: &Session, id: DocId, num: u32) -> Arc {
+ s.get(id).unwrap().editor.as_ref().unwrap().cos.get(ObjRef::new(num, 0))
+}
+
+/// The samples of the image page 1 draws, now.
+fn samples(s: &Session, id: DocId) -> optimize::Samples {
+ let doc = s.get(id).unwrap();
+ let object = doc.page_images(0)[0].object.unwrap();
+ let cos = &doc.editor.as_ref().unwrap().cos;
+ let Object::Stream(img) = &*cos.get(object) else { panic!("the image") };
+ optimize::decode_samples(cos, img, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)).unwrap()
+}
+
+fn grey_at(px: &optimize::Samples, x: usize, y: usize) -> u8 {
+ px.data[(y * W + x) * px.components]
+}
+
+#[test]
+fn a_scan_gets_white_paper_even_edges_dark_strokes_and_no_specks_in_place() {
+ let (mut s, id) = open(grey_scan());
+ let before = samples(&s, id);
+ let kept: Vec> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect();
+ let text_before = s.get(id).unwrap().text_lines(0);
+ let original = s.get(id).unwrap().bytes.clone();
+
+ let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
+ assert_eq!(report.pages, vec![0, 2], "the shared image counts for both its pages");
+ assert_eq!(report.images, 1);
+ assert_eq!(report.not_scanned, vec![1], "the text page is no scan");
+ assert!(report.skipped.is_empty(), "{:?}", report.skipped);
+ assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file"));
+
+ let after = samples(&s, id);
+ assert_eq!((after.width, after.height, after.components, after.jpeg), (W as u32, H as u32, 1, false));
+ // Paper, also in the shadowed gutter and at the right edge, turns (nearly) white.
+ for (x, y) in [(150, 40), (150, 380), (3, 40), (3, 380), (30, 200), (W - 2, 40)] {
+ assert!(grey_at(&before, x, y) < 210, "the fixture's paper is grey at ({x}, {y})");
+ assert!(grey_at(&after, x, y) >= 235, "paper at ({x}, {y}): {} → {}", grey_at(&before, x, y), grey_at(&after, x, y));
+ }
+ // The edge is evened out: the gutter is as white as the middle.
+ assert!(grey_at(&after, 150, 40).abs_diff(grey_at(&after, 3, 40)) <= 12);
+ // Strokes stay dark (darker, with the contrast), also in the gutter's light.
+ for (x, y) in [(150, 62), (61, 102), (238, 342)] {
+ assert!(grey_at(&after, x, y) <= 40, "stroke at ({x}, {y}): {}", grey_at(&after, x, y));
+ }
+ for (x, y) in SPECKS {
+ assert!(grey_at(&before, x, y) < 60);
+ assert!(grey_at(&after, x, y) >= 200, "speck at ({x}, {y}): {}", grey_at(&after, x, y));
+ }
+ // Everything but the image is as it was: the page, its invisible text layer, the comment, the
+ // font, the other pages. The image keeps its entries.
+ let now: Vec> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect();
+ assert_eq!(kept, now);
+ assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
+ let text_after = s.get(id).unwrap().text_lines(0);
+ assert!(text_after.iter().any(|l| l.text.contains("Invisible OCR words")), "{text_after:?}");
+ assert_eq!(text_before.len(), text_after.len());
+ let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") };
+ assert_eq!(img.dict.get(b"Interpolate"), Some(&Object::Bool(true)));
+ assert_eq!(img.dict.name(b"ColorSpace"), Some(&b"DeviceGray"[..]));
+
+ // Undo brings the original image, and the original file, back.
+ s.undo(id).unwrap();
+ assert_eq!(samples(&s, id), before);
+ assert_eq!(s.get(id).unwrap().bytes, original);
+ assert_eq!(s.get(id).unwrap().can_undo(), None);
+}
+
+#[test]
+fn a_jpeg_scan_stays_a_jpeg_in_colour() {
+ let px = scan_pixels(3);
+ let (mut s, id) = open(scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), &jpeg(&px, 3)));
+ let report = s.enhance_scanned_pages(id, &[0], ScanCleanSettings::at(80)).unwrap();
+ // Only page 1 was asked for; the image it shares with page 3 is changed for both.
+ assert_eq!((report.pages.as_slice(), report.images), (&[0][..], 1));
+ let after = samples(&s, id);
+ assert!(after.jpeg && after.components == 3);
+ let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") };
+ assert_eq!(img.dict.name(b"Filter"), Some(&b"DCTDecode"[..]));
+ // Yellowed paper becomes white; the strokes stay dark.
+ let at = |x: usize, y: usize| after.data[(y * W + x) * 3..][..3].to_vec();
+ for (x, y) in [(150, 40), (5, 300)] {
+ assert!(at(x, y).iter().all(|&v| v >= 230), "paper at ({x}, {y}): {:?}", at(x, y));
+ }
+ assert!(at(150, 63).iter().all(|&v| v <= 60), "stroke: {:?}", at(150, 63));
+ // The file still renders.
+ let doc = s.get(id).unwrap();
+ let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()).render(pdfcraft_render::RenderRequest {
+ page: 0,
+ scale: 1.0,
+ ..Default::default()
+ });
+ assert!(shot.error.is_none(), "{:?}", shot.error);
+}
+
+#[test]
+fn despeckle_is_its_own_switch() {
+ let speck = |settings: ScanCleanSettings| {
+ let (mut s, id) = open(grey_scan());
+ s.enhance_scanned_pages(id, &[0], settings).unwrap();
+ let after = samples(&s, id);
+ SPECKS.iter().map(|&(x, y)| grey_at(&after, x, y)).max().unwrap_or(0)
+ };
+ assert!(speck(ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(80) }) < 100, "kept without despeckle");
+ assert!(speck(ScanCleanSettings::at(80)) >= 200);
+ // Despeckle alone, at level 0, changes nothing but the specks.
+ let (mut s, id) = open(grey_scan());
+ let before = samples(&s, id);
+ s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: true }).unwrap();
+ let after = samples(&s, id);
+ let changed: Vec = before.data.iter().zip(&after.data).enumerate().filter(|(_, (a, b))| a != b).map(|(i, _)| i).collect();
+ let mut specks: Vec = SPECKS.iter().map(|&(x, y)| y * W + x).collect();
+ specks.sort_unstable();
+ assert_eq!(changed, specks);
+ // Nothing to do: no undo step, and the report says why.
+ let (mut s, id) = open(grey_scan());
+ let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: false }).unwrap();
+ assert_eq!(report.images, 0);
+ assert!(report.pages.is_empty());
+ assert_eq!(report.skipped.len(), 1);
+ assert!(report.skipped[0].reason.contains("already clean"), "{:?}", report.skipped);
+ assert_eq!(s.get(id).unwrap().can_undo(), None);
+ assert!(!s.get(id).unwrap().dirty);
+}
+
+#[test]
+fn a_black_and_white_scan_stays_one_bit_and_loses_its_specks() {
+ // 64 × 64, one bit per pixel, 1 = white: a black bar and two specks.
+ let (w, h) = (64usize, 64usize);
+ let mut bits = vec![0xFFu8; w / 8 * h];
+ let mut black = |x: usize, y: usize| bits[y * w / 8 + x / 8] &= !(0x80 >> (x % 8));
+ for y in 20..30 {
+ for x in 10..50 {
+ black(x, y);
+ }
+ }
+ black(5, 5);
+ black(58, 58);
+ let bytes = Pdf::new()
+ .obj("<< /Type /Catalog /Pages 2 0 R >>")
+ .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
+ .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 64 64] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>")
+ .stream("/Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 1", &bits)
+ .stream("", "q 64 0 0 64 0 0 cm /Im0 Do Q")
+ .build();
+ let (mut s, id) = open(bytes);
+ // Only Despeckle changes a black and white scan: without it the image is reported.
+ let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(100) }).unwrap();
+ assert_eq!(report.images, 0);
+ assert!(report.skipped.first().is_some_and(|k| k.reason.contains("black and white")), "{:?}", report.skipped);
+ let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
+ assert_eq!(report.images, 1);
+ let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") };
+ assert_eq!(img.dict.int(b"BitsPerComponent"), Some(1));
+ let px = samples(&s, id);
+ assert!(px.bilevel);
+ let at = |x: usize, y: usize| px.data[y * w + x];
+ assert_eq!((at(5, 5), at(58, 58)), (255, 255), "specks gone");
+ assert_eq!((at(10, 20), at(30, 25), at(49, 29)), (0, 0, 0), "the bar stays");
+ assert_eq!(px.data.iter().filter(|&&v| v == 0).count(), 40 * 10);
+}
+
+#[test]
+fn hostile_images_are_reported_not_a_panic() {
+ // Each page draws one image over the whole page.
+ let images: [(&str, Vec); 6] = [
+ ("/Width 100000 /Height 100000 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![0; 64]),
+ ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /DCTDecode", vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 3, 9]),
+ ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![200; 1000]),
+ ("/Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![128]),
+ ("/Width 8 /Height 8 /ColorSpace /DeviceCMYK /BitsPerComponent 8", vec![0; 256]),
+ ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Mask [250 255]", vec![200; 300 * 400]),
+ ];
+ let n = images.len();
+ let kids: String = (0..n).map(|i| format!("{} 0 R ", 3 + 3 * i)).collect();
+ let mut pdf = Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>").obj(format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>"));
+ for (i, (dict, data)) in images.iter().enumerate() {
+ let (img, contents) = (4 + 3 * i, 5 + 3 * i);
+ pdf = pdf
+ .obj(format!(
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /XObject << /Im0 {img} 0 R >> >> /Contents {contents} 0 R >>"
+ ))
+ .stream(&format!("/Type /XObject /Subtype /Image {dict}"), data)
+ .stream("", "q 300 0 0 400 0 0 cm /Im0 Do Q");
+ }
+ let (mut s, id) = open(pdf.build());
+ let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
+ assert_eq!(report.images, 0);
+ assert_eq!(report.not_scanned, vec![3], "a 1 × 1 image is no scan");
+ let why: Vec<(usize, &str)> = report.skipped.iter().map(|k| (k.pages[0], k.reason.as_str())).collect();
+ assert_eq!(why.len(), 5, "{why:?}");
+ assert!(why[0].0 == 0 && why[0].1.contains("80 million"), "{why:?}");
+ assert!(why[1].0 == 1 && why[1].1.contains("could not be decoded"), "{why:?}");
+ assert!(why[2].0 == 2 && why[2].1.contains("shorter"), "{why:?}");
+ assert!(why[3].0 == 4 && why[3].1.contains("colour space"), "{why:?}");
+ assert!(why[4].0 == 5 && why[4].1.contains("colour-key mask"), "{why:?}");
+ assert_eq!(s.get(id).unwrap().can_undo(), None);
+ // Pages that don't exist are an error, not a panic.
+ assert!(matches!(s.enhance_scanned_pages(id, &[99], ScanCleanSettings::default()), Err(EditError::Invalid(_))));
+}
+
+#[test]
+fn a_scan_drawn_inside_a_form_xobject_is_reported_not_called_no_scan() {
+ // Pages 1 and 3 draw the scan through Form Fm0 (as some scanners write it), page 2 is text.
+ let page = |contents: usize| {
+ format!(
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> /Font << /F1 9 0 R >> >> /Contents {contents} 0 R >>"
+ )
+ };
+ let bytes = Pdf::new()
+ .obj("<< /Type /Catalog /Pages 2 0 R >>")
+ .obj("<< /Type /Pages /Kids [3 0 R 7 0 R 10 0 R] /Count 3 >>")
+ .obj(page(6))
+ .stream(
+ &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"),
+ format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"),
+ )
+ .stream(&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"), scan_pixels(1))
+ .stream("", "/Fm0 Do")
+ .obj(page(8))
+ .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET")
+ .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
+ .obj(page(11))
+ .stream("", "q /Fm0 Do Q")
+ .build();
+ let (mut s, id) = open(bytes);
+ let before = object(&s, id, 5);
+ let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
+ assert!(report.pages.is_empty() && report.images == 0, "{report:?}");
+ assert_eq!(report.not_scanned, vec![1], "only the text page is no scan");
+ assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}");
+ let k = &report.skipped[0];
+ assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 2][..], "Fm0"));
+ assert!(k.reason.contains("form XObject"), "{k:?}");
+ assert_eq!(object(&s, id, 5), before, "the picture is left as it was");
+ assert_eq!(s.get(id).unwrap().can_undo(), None);
+
+ // A page of vector artwork in a Form, holding only a small logo, is no scan.
+ let logo = Pdf::new()
+ .obj("<< /Type /Catalog /Pages 2 0 R >>")
+ .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
+ .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> >> /Contents 6 0 R >>"))
+ .stream(
+ &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"),
+ format!("0 0 {W} {H} re f q 40 0 0 20 10 370 cm /Im0 Do Q"),
+ )
+ .stream("/Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceGray /BitsPerComponent 8", [128; 800])
+ .stream("", "/Fm0 Do")
+ .build();
+ let (mut s, id) = open(logo);
+ let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
+ assert_eq!((report.not_scanned.as_slice(), report.skipped.len()), (&[0][..], 0), "{report:?}");
+}
+
+#[test]
+fn a_page_mixing_its_own_image_and_a_scan_in_a_form_cleans_the_one_and_reports_the_other() {
+ // Page 1: its own image covers 40% and a Form holding a scan 20%: a scan only together.
+ // Page 2: its own image covers the page and the Form 20% more. Both pages clean Im1 and
+ // report Fm0.
+ let page = |contents: usize| {
+ format!(
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R /Im1 6 0 R >> >> /Contents {contents} 0 R >>"
+ )
+ };
+ let gray = format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8");
+ let bytes = Pdf::new()
+ .obj("<< /Type /Catalog /Pages 2 0 R >>")
+ .obj("<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 >>")
+ .obj(page(7))
+ .stream(
+ &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"),
+ format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"),
+ )
+ .stream(&gray, scan_pixels(1))
+ .stream(&gray, scan_pixels(1))
+ .stream("", format!("q {W} 0 0 160 0 240 cm /Im1 Do Q q 1 0 0 0.2 0 0 cm /Fm0 Do Q"))
+ .obj(page(9))
+ .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im1 Do Q q 1 0 0 0.2 0 0 cm /Fm0 Do Q"))
+ .build();
+ let (mut s, id) = open(bytes);
+ let (form_picture, own) = (object(&s, id, 5), object(&s, id, 6));
+ let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
+ assert_eq!((report.pages.as_slice(), report.images), (&[0, 1][..], 1), "Im1 is cleaned on both pages: {report:?}");
+ assert!(report.not_scanned.is_empty(), "{report:?}");
+ assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}");
+ let k = &report.skipped[0];
+ assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 1][..], "Fm0"));
+ assert!(k.reason.contains("form XObject"), "{k:?}");
+ assert_ne!(object(&s, id, 6), own, "the page's own image is cleaned");
+ assert_eq!(object(&s, id, 5), form_picture, "the picture in the form is left as it was");
+}
+
+#[test]
+fn protected_documents_keep_their_encryption_and_restrictions() {
+ let protect = |user: &str, permissions: i32| {
+ let mut doc = pdfcraft_cos::Document::open(Arc::new(grey_scan())).unwrap();
+ doc.set_encryption(&pdfcraft_cos::NewEncryption {
+ algorithm: pdfcraft_cos::Algorithm::Aes256,
+ user_password: user,
+ owner_password: "owner",
+ permissions,
+ encrypt_metadata: true,
+ seed: [7; 32],
+ })
+ .unwrap();
+ Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap())
+ };
+ // Opened with the password: cleaned, saved still encrypted, and the clean scan reads back.
+ let mut s = Session::new();
+ let id = s.open("x.pdf", None, protect("pw", -1), Some("pw")).unwrap();
+ assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap().images, 1);
+ let saved = s.save_bytes(id).unwrap();
+ assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "still protected");
+ let id2 = s.open("y.pdf", None, saved, Some("pw")).unwrap();
+ assert!(grey_at(&samples(&s, id2), 3, 40) >= 235);
+ // Changes withheld: refused before any work.
+ let id = s.open("r.pdf", None, protect("", 0b0100), None).unwrap();
+ assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::default()).unwrap_err(), EditError::NotPermitted("changes to the document"));
+ assert!(!s.get(id).unwrap().dirty);
+}
+
+#[test]
+fn a_result_for_an_older_state_is_refused() {
+ let (mut s, id) = open(grey_scan());
+ let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap();
+ assert_eq!(job.total(), 1);
+ let cleaned = job.run(|_, _| true).unwrap();
+ s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
+ assert!(matches!(s.apply_scan_clean(id, cleaned), Err(EditError::Invalid(_))));
+ assert_eq!(s.get(id).unwrap().can_undo(), Some("Rotate page"));
+ // Cancelled from the progress callback.
+ let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap();
+ assert!(matches!(job.run(|_, _| false), Err(EditError::Cancelled)));
+}
+
+#[test]
+fn edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves() {
+ let gutter = |edge_shadows: bool| {
+ let (mut s, id) = open(grey_scan());
+ s.enhance_scanned_pages(id, &[0], ScanCleanSettings { edge_shadows, ..ScanCleanSettings::at(100) }).unwrap();
+ let after = samples(&s, id);
+ [(3, 40), (3, 380), (10, 200)].map(|(x, y)| grey_at(&after, x, y))
+ };
+ assert!(gutter(false).iter().any(|&v| v < 200), "{:?}", gutter(false));
+ assert!(gutter(true).iter().all(|&v| v >= 235), "{:?}", gutter(true));
+ // On its own, at level 0, it lightens the gutter's paper to the paper beside it.
+ let (mut s, id) = open(grey_scan());
+ let before = samples(&s, id);
+ s.enhance_scanned_pages(id, &[0], ScanCleanSettings { level: 0, edge_shadows: true, despeckle: false }).unwrap();
+ let after = samples(&s, id);
+ assert!(grey_at(&after, 3, 40).abs_diff(grey_at(&before, 150, 40)) <= 4, "{} vs {}", grey_at(&after, 3, 40), grey_at(&before, 150, 40));
+ assert_eq!(grey_at(&after, 150, 40), grey_at(&before, 150, 40), "the middle is left alone");
+}
+
+/// Two pages, each with its own scan image (objects 4 and 7), so cleaning one page can be told
+/// apart from cleaning the other.
+fn two_scans_pdf() -> Vec {
+ let dict = format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8");
+ let draw = format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q");
+ Pdf::new()
+ .obj("<< /Type /Catalog /Pages 2 0 R >>")
+ .obj("<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>")
+ .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>"))
+ .stream(&dict, scan_pixels(1))
+ .stream("", &draw)
+ .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 7 0 R >> >> /Contents 8 0 R >>"))
+ .stream(&dict, scan_pixels(1))
+ .stream("", &draw)
+ .build()
+}
+
+#[test]
+fn only_the_chosen_pages_scan_is_cleaned() {
+ // Clean page 1, then page 2 of a fresh copy: the other page's image stays byte-identical.
+ for (chosen, image, other) in [(0usize, 4u32, 7u32), (1, 7, 4)] {
+ let (mut s, id) = open(two_scans_pdf());
+ let (image_before, other_before) = (object(&s, id, image), object(&s, id, other));
+ assert_eq!(s.get(id).unwrap().page_images(chosen)[0].object, Some(ObjRef::new(image, 0)), "the fixture");
+ let report = s.enhance_scanned_pages(id, &[chosen], ScanCleanSettings::at(100)).unwrap();
+ assert_eq!((report.pages.as_slice(), report.images), (&[chosen][..], 1), "page {}", chosen + 1);
+ assert_ne!(object(&s, id, image), image_before, "page {}'s image was not cleaned", chosen + 1);
+ assert_eq!(object(&s, id, other), other_before, "cleaning page {} changed the other page's image", chosen + 1);
+ let (Object::Stream(was), Object::Stream(now)) = (&*other_before, &*object(&s, id, other)) else { panic!("the images") };
+ assert_eq!(was.raw, now.raw, "the other image's stream bytes");
+ assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file"));
+ }
+}
diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs
index 8287743fc5..a1574e9463 100644
--- a/crates/engine/src/scan_fields.rs
+++ b/crates/engine/src/scan_fields.rs
@@ -19,30 +19,94 @@ const SCALE: f32 = 2.0;
const MAX_SIDE: f32 = 3000.0;
/// A page is a scan when its images cover at least this share of it.
const COVERED: f64 = 0.5;
+/// A picture inside Form artwork is taken for a scan when it has at least this resolution over
+/// the area the artwork covers (a quarter of the pixels a 72 dpi scan would have).
+const MIN_FORM_DPI: f64 = 36.0;
/// Recognised "words" less tall than this (points) are specks read as letters, and would make
/// the box they sit on look filled in.
const MIN_WORD_HEIGHT: f64 = 4.0;
/// Whether `page` is a picture of a page: images cover most of it.
pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool {
- let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from);
- let page_area = (cx1 - cx0) * (cy1 - cy0);
- if !page_area.is_finite() || page_area <= 0.0 {
- return false;
- }
- let covered: f64 = doc
+ scan_images(doc, page, info).is_some()
+}
+
+/// The raster images (larger than one pixel) `page` draws, each with the share of the page (its
+/// crop box) it covers, when together they cover most of it ([`is_scan`]); `None` otherwise.
+/// Enhance ▸ Scanned document cleans these up ([`crate::scan_clean`]).
+pub(crate) fn scan_images(doc: &Document, page: usize, info: &PageInfo) -> Option> {
+ let area = page_area(info)?;
+ let images: Vec<(pdfcraft_edit::PageImage, f64)> = doc
.page_images(page)
- .iter()
+ .into_iter()
.filter(|i| !i.is_form && i.width > 1 && i.height > 1)
- .map(|i| {
- let r = i.visible_rect.unwrap_or(i.rect);
- let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0);
- let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0);
- w * h
- })
- .filter(|a| a.is_finite())
- .sum();
- covered >= COVERED * page_area
+ .filter_map(|i| Some((share(info, area, &i)?, i)))
+ .map(|(share, i)| (i, share))
+ .collect();
+ let covered: f64 = images.iter().map(|(_, share)| share).sum();
+ (covered >= COVERED).then_some(images)
+}
+
+/// A page that is a scan, classified in one pass: its raster images (larger than one pixel) and
+/// the grouped Form artwork that holds a raster picture, each with the share of the page it
+/// covers, when together they cover most of the page; `None` otherwise. The flag says whether the
+/// raster images alone do ([`scan_images`]). Enhance ▸ Scanned document cleans the images and
+/// reports the Forms, which it can't clean yet, so a page mixing both gets both.
+#[allow(clippy::type_complexity)]
+pub(crate) fn scan_parts(
+ doc: &Document,
+ page: usize,
+ info: &PageInfo,
+) -> Option<(Vec<(pdfcraft_edit::PageImage, f64)>, Vec<(pdfcraft_edit::PageImage, f64)>, bool)> {
+ let area = page_area(info)?;
+ let editor = doc.editor.as_ref();
+ let (mut images, mut forms) = (Vec::new(), Vec::new());
+ let (mut own, mut covered) = (0.0, 0.0);
+ for i in doc.page_images(page) {
+ let Some(share) = share(info, area, &i) else { continue };
+ if !i.is_form {
+ if i.width > 1 && i.height > 1 {
+ own += share;
+ covered += share;
+ images.push((i, share));
+ }
+ } else if let (Some(form), Some(editor)) = (i.object, editor)
+ && holds_scan(&editor.cos, form, share * area)
+ {
+ covered += share;
+ forms.push((i, share));
+ }
+ }
+ (covered >= COVERED).then_some((images, forms, own >= COVERED))
+}
+
+/// Whether Form `form`, covering `points` square points of the page, draws a picture that could
+/// be a scan of it: one with at least [`MIN_FORM_DPI`] over that area, not a logo inside a page
+/// of vector artwork.
+fn holds_scan(cos: &pdfcraft_cos::Document, form: pdfcraft_cos::ObjRef, points: f64) -> bool {
+ let Some(picture) = crate::copy_image::picture_in_form(cos, form) else { return false };
+ let obj = cos.get(picture);
+ let pdfcraft_cos::Object::Stream(s) = &*obj else { return false };
+ let px = |k: &[u8]| s.dict.int(k).unwrap_or(0).max(0) as f64;
+ let per_point = MIN_FORM_DPI / 72.0;
+ px(b"Width") * px(b"Height") >= points * per_point * per_point
+}
+
+/// The area of `info`'s crop box, when it is a usable one.
+fn page_area(info: &PageInfo) -> Option {
+ let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from);
+ let area = (cx1 - cx0) * (cy1 - cy0);
+ (area.is_finite() && area > 0.0).then_some(area)
+}
+
+/// The share of the crop box (of area `area`) that `image` covers, when it is a number.
+fn share(info: &PageInfo, area: f64, image: &pdfcraft_edit::PageImage) -> Option {
+ let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from);
+ let r = image.visible_rect.unwrap_or(image.rect);
+ let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0);
+ let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0);
+ let share = w * h / area;
+ share.is_finite().then_some(share)
}
/// The page as displayed, in points with y up: where detection on a scan works.
diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs
index c0e7417c8e..5ef135d6c8 100644
--- a/crates/engine/src/session/view.rs
+++ b/crates/engine/src/session/view.rs
@@ -168,6 +168,14 @@ impl Session {
Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more })
}
+ /// The bookmark at `path` with everything under it, or the whole outline for `[]`, as a tree
+ /// another document can take ([`crate::Edit::ImportBookmarks`]). `None` when no document has
+ /// `id`, or its structure could not be read for editing.
+ pub fn outline_tree(&self, id: DocId, path: &[usize]) -> Option> {
+ let editor = self.get(id)?.editor.as_ref()?;
+ Some(pdfcraft_organize::outline_tree(&editor.cos, path))
+ }
+
/// Top-level bookmarks as split points: (first page of each part, its bookmark's title).
pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> {
let Some(doc) = self.get(id) else { return Vec::new() };
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 5d95486c32..6c54131d02 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -549,6 +549,85 @@ fn pdf_page_stamps_fit_inside_an_explicit_box() {
assert!(content.contains("/Pic Do") && !content.contains("BI"), "{content}");
}
+/// #1297: a 3D model on a page keeps the whole box as its rectangle and draws a PDF poster as a
+/// form XObject fitted inside it, keeping its shape and centred.
+#[test]
+fn a_3d_model_gets_a_fitted_vector_poster_and_a_file_that_is_not_one_is_refused() {
+ let mut session = Session::new().with_clock(|| 1_700_000_000);
+ let blank = session.create_blank(595.0, 842.0, 1).unwrap();
+ let id = session.open_new("model.pdf", blank).unwrap();
+ let mut prc = b"PRC".to_vec();
+ prc.extend_from_slice(&[1; 64]);
+ let add = |model: Vec| Edit::AddModel3d {
+ page: 0,
+ rect: [56.0, 400.0, 539.0, 700.0],
+ model: Arc::new(model),
+ poster: Some(MarkFile { name: "poster.pdf".into(), bytes: Arc::new(half_red_page()), page: 0 }),
+ activation: Activation3d::default(),
+ name: "Bracket".into(),
+ };
+ assert!(session.apply(id, add(b"%PDF-1.7 not a model".to_vec())).is_err());
+ session.apply(id, add(prc)).unwrap();
+
+ let doc = pdfcraft_cos::Document::open(session.save_bytes(id).unwrap()).unwrap();
+ let page = &pdfcraft_model::pages(&doc)[0];
+ let annots = doc.resolve(page.dict.get(b"Annots").unwrap());
+ let [only] = annots.as_array().unwrap().as_slice() else { panic!("the refused model added nothing: {annots:?}") };
+ let annot = doc.get(only.as_ref().unwrap());
+ let annot = annot.as_dict().unwrap();
+ assert_eq!(annot.name(b"Subtype"), Some(&b"3D"[..]));
+ let rect: Vec = annot.get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(pdfcraft_cos::Object::as_f64).collect();
+ assert_eq!(rect, [56.0, 400.0, 539.0, 700.0], "the whole box, not the fitted picture");
+ assert_eq!(doc.resolve(annot.get(b"3DA").unwrap()).as_dict().unwrap().name(b"A"), Some(&b"XA"[..]));
+ let ap = doc.resolve(annot.get(b"AP").unwrap());
+ let pdfcraft_cos::Object::Stream(normal) = doc.get(ap.as_dict().unwrap().reference(b"N").unwrap()).as_ref().clone() else {
+ panic!("an appearance stream")
+ };
+ let pic = doc.resolve(normal.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
+ let pic = doc.resolve(pic.get(b"XObject").unwrap()).as_dict().unwrap().reference(b"Pic").unwrap();
+ let pdfcraft_cos::Object::Stream(picture) = doc.get(pic).as_ref().clone() else { panic!("the picture is a stream") };
+ assert_eq!(picture.dict.name(b"Subtype"), Some(&b"Form"[..]), "the page is kept as a form XObject");
+ // The 80 × 40 page in the 483 × 300 box: 483 × 241.5 from y 429.25.
+ let content = String::from_utf8(normal.decoded().unwrap().to_vec()).unwrap();
+ assert!(content.contains(" 56 429.25 cm /Pic Do"), "{content}");
+}
+
+/// A PDF poster whose page has no area is refused rather than drawn as nothing.
+#[test]
+fn a_3d_poster_page_without_area_is_refused() {
+ let mut session = Session::new().with_clock(|| 1_700_000_000);
+ let blank = session.create_blank(595.0, 842.0, 1).unwrap();
+ let id = session.open_new("model.pdf", blank).unwrap();
+ let flat = String::from_utf8(half_red_page()).unwrap().replace("[0 0 80 40]", "[0 0 80 0 ]");
+ let add = Edit::AddModel3d {
+ page: 0,
+ rect: [56.0, 400.0, 539.0, 700.0],
+ model: Arc::new(b"PRC rest".to_vec()),
+ poster: Some(MarkFile { name: "flat.pdf".into(), bytes: Arc::new(flat.into_bytes()), page: 0 }),
+ activation: Activation3d::default(),
+ name: "Bracket".into(),
+ };
+ assert!(matches!(session.apply(id, add), Err(EditError::Source(_))));
+ assert!(session.get(id).unwrap().info.annotations.is_empty(), "nothing was added");
+}
+
+/// Adding a 3D model is a comment change: refused without the comments permission, and only the
+/// comments are rebuilt after it.
+#[test]
+fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() {
+ let edit = Edit::AddModel3d {
+ page: 0,
+ rect: [0.0, 0.0, 100.0, 100.0],
+ model: Arc::new(b"PRC rest".to_vec()),
+ poster: None,
+ activation: Activation3d::default(),
+ name: String::new(),
+ };
+ let p = pdfcraft_cos::Permissions { bits: 0, owner: false };
+ assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments")));
+ assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments);
+}
+
fn page_texts(s: &Session, id: DocId) -> Vec {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
@@ -10369,3 +10448,35 @@ fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() {
assert_eq!(reopened.get(again).unwrap().info.annotations[0].contents.as_deref(), Some(text), "reopening preserves all blank lines");
assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical");
}
+
+/// A whole tree goes in as one undoable step, and a refused tree changes nothing.
+#[test]
+fn imported_bookmarks_undo_in_one_step() {
+ let (mut s, id) = session_with(2);
+ let node = |title: &str, page, children| pdfcraft_organize::OutlineNode {
+ title: title.into(),
+ page,
+ view: None,
+ color: None,
+ italic: false,
+ bold: false,
+ open: true,
+ children,
+ };
+ let bookmarks = vec![node("Intro", Some(0), vec![node("Detail", Some(5), vec![])]), node("End", Some(1), vec![])];
+ s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: bookmarks.clone(), replace: false }).unwrap();
+ assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Intro→1[Detail→0]", "End→2"]);
+ assert_eq!(s.get(id).unwrap().can_undo(), Some("Import bookmarks"));
+ assert_eq!(s.outline_tree(id, &[]).unwrap().unwrap().nodes[0].title, "Intro");
+ s.undo(id).unwrap();
+ assert!(s.get(id).unwrap().info.outline.is_empty());
+ s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: bookmarks.clone(), replace: false }).unwrap();
+ s.apply(id, Edit::ImportBookmarks { parent: vec![], index: 0, bookmarks: vec![node("New", Some(1), vec![])], replace: true }).unwrap();
+ assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["New→2"]);
+ s.undo(id).unwrap();
+ assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Intro→1[Detail→0]", "End→2"]);
+ s.undo(id).unwrap();
+ let err = s.apply(id, Edit::ImportBookmarks { parent: vec![4], index: 0, bookmarks, replace: false }).unwrap_err();
+ assert!(err.to_string().contains("no bookmark"), "{err}");
+ assert!(s.get(id).unwrap().info.outline.is_empty());
+}
diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs
index 8009ef1bbf..53ca309326 100644
--- a/crates/engine/tests/it/ocr_edit.rs
+++ b/crates/engine/tests/it/ocr_edit.rs
@@ -207,3 +207,192 @@ fn visible_stretched_text_rewrites_unchanged() {
let i = ((y * out.width + x) * 4) as usize;
assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]);
}
+
+/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools
+/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image.
+fn ocr_under_scan(text: &str) -> std::sync::Arc> {
+ under_scan(&format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET"))
+}
+
+/// A page whose `text` content is followed by the opaque 200 × 100 scan image.
+fn under_scan(text: &str) -> std::sync::Arc> {
+ under_scan_with(text, |_| pdfcraft_cos::Dict::new())
+}
+
+/// As [`under_scan`], with the page's ExtGState resources made by `ext_gstates`.
+fn under_scan_with(text: &str, ext_gstates: impl FnOnce(&mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict) -> std::sync::Arc> {
+ use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
+ let (w, h) = (200u32, 100u32);
+ let mut rgb = Vec::new();
+ for y in 0..h {
+ for x in 0..w {
+ let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y);
+ rgb.extend(if ink { INK } else { PAPER });
+ }
+ }
+ let mut d = Document::new_empty();
+ let pages = d.add(Object::Dict(Dict::new()));
+ let content = format!("{text} q 200 0 0 100 0 0 cm /Im Do Q");
+ let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
+ let mut image = Dict::new();
+ image.set(b"Type".to_vec(), Object::name("XObject"));
+ image.set(b"Subtype".to_vec(), Object::name("Image"));
+ image.set(b"Width".to_vec(), Object::Int(i64::from(w)));
+ image.set(b"Height".to_vec(), Object::Int(i64::from(h)));
+ image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
+ image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
+ let image = d.add(Object::Stream(Stream::flate(image, &rgb)));
+ let mut font = Dict::new();
+ font.set(b"Type".to_vec(), Object::name("Font"));
+ font.set(b"Subtype".to_vec(), Object::name("Type1"));
+ font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
+ let mut fonts = Dict::new();
+ fonts.set(b"F1".to_vec(), Object::Dict(font));
+ let mut xobjects = Dict::new();
+ xobjects.set(b"Im".to_vec(), Object::Ref(image));
+ let mut res = Dict::new();
+ res.set(b"Font".to_vec(), Object::Dict(fonts));
+ res.set(b"XObject".to_vec(), Object::Dict(xobjects));
+ res.set(b"ExtGState".to_vec(), Object::Dict(ext_gstates(&mut d)));
+ let mut p = Dict::new();
+ p.set(b"Type".to_vec(), Object::name("Page"));
+ p.set(b"Parent".to_vec(), Object::Ref(pages));
+ p.set(b"Contents".to_vec(), Object::Ref(contents));
+ p.set(b"Resources".to_vec(), Object::Dict(res));
+ p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec()));
+ let page = d.add(Object::Dict(p));
+ d.update_dict(pages, |p| {
+ p.set(b"Type".to_vec(), Object::name("Pages"));
+ p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
+ p.set(b"Count".to_vec(), Object::Int(1));
+ })
+ .unwrap();
+ let mut root = Dict::new();
+ root.set(b"Type".to_vec(), Object::name("Catalog"));
+ root.set(b"Pages".to_vec(), Object::Ref(pages));
+ let root = d.add(Object::Dict(root));
+ d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root));
+ std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap())
+}
+
+/// The scanned word's box shows the cover and new ink, not the scan.
+fn assert_edit_shows(s: &Session, id: pdfcraft_engine::DocId, label: &str) {
+ let px = word_pixels(s, id);
+ let paper = px.iter().filter(|p| near(**p, PAPER)).count();
+ let ink = px.iter().filter(|p| near(**p, INK)).count();
+ assert!(paper * 10 > px.len() * 4, "{label}: the cover is drawn over the scan: {paper} of {} paper", px.len());
+ assert!(ink * 100 > px.len() * 3, "{label}: the new text shows: {ink} of {} ink", px.len());
+}
+
+fn texts(s: &Session, id: pdfcraft_engine::DocId) -> Vec {
+ s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.replace('\n', " ")).collect()
+}
+
+fn edit_block(s: &mut Session, id: pdfcraft_engine::DocId, block: usize, text: &str) {
+ s.apply(id, Edit::EditTextBlock { page: 0, block, text: text.into(), style: Default::default() }).unwrap();
+}
+
+/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image,
+/// not in the OCR text's place where the image would hide it (#3973). One line takes the
+/// single-style rewrite, a typed line break the styled one. Undo restores the hidden layer, and
+/// the edit survives redo, a save and reopen, and being edited again.
+#[test]
+fn an_ocr_layer_under_the_scan_image_shows_its_edit() {
+ for edit in ["Jane", "Jane\nDoe"] {
+ let mut s = Session::new();
+ let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap();
+ let blocks = s.get(id).unwrap().text_blocks(0);
+ assert_eq!(blocks.len(), 1);
+ assert!(blocks[0].invisible, "the OCR layer is invisible text");
+ assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box");
+
+ edit_block(&mut s, id, 0, edit);
+ assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}");
+ assert_edit_shows(&s, id, edit);
+
+ s.undo(id).unwrap();
+ assert_eq!(texts(&s, id), ["John"], "{edit:?}: undo");
+ assert!(s.get(id).unwrap().text_blocks(0)[0].invisible, "{edit:?}: undo hides the layer again");
+ assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "{edit:?}: undo shows the scan again");
+ s.redo(id).unwrap();
+ assert_edit_shows(&s, id, &format!("{edit:?} redo"));
+
+ let saved = s.save_bytes(id).unwrap();
+ let mut reopened = Session::new();
+ let id = reopened.open("saved.pdf", None, saved, None).unwrap();
+ assert_eq!(texts(&reopened, id), [edit.replace('\n', " ")], "{edit:?}: reopened");
+ assert_edit_shows(&reopened, id, &format!("{edit:?} reopened"));
+
+ edit_block(&mut reopened, id, 0, "Ann");
+ assert_eq!(texts(&reopened, id), ["Ann"], "{edit:?}: edited again");
+ assert_edit_shows(&reopened, id, &format!("{edit:?} edited again"));
+ }
+}
+
+/// Visible text earlier in the same text object makes the rewrite split it; the OCR phrase still
+/// goes on top of the scan, and the visible text stays.
+#[test]
+fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() {
+ for edit in ["Jane", "Jane\nDoe"] {
+ let mut s = Session::new();
+ let page = under_scan("BT /F1 20 Tf 10 80 Td (Head) Tj 3 Tr 32 -36 Td (John) Tj ET");
+ let id = s.open("split.pdf", None, page, None).unwrap();
+ let blocks = s.get(id).unwrap().text_blocks(0);
+ let john = blocks.iter().position(|b| b.text == "John").unwrap();
+ assert!(blocks[john].invisible && blocks.iter().any(|b| b.text == "Head" && !b.invisible), "{blocks:?}");
+ edit_block(&mut s, id, john, edit);
+ let mut after = texts(&s, id);
+ after.sort();
+ let mut want = vec!["Head".to_string(), edit.replace('\n', " ")];
+ want.sort();
+ assert_eq!(after, want, "{edit:?}");
+ assert_edit_shows(&s, id, &format!("{edit:?} split"));
+ }
+}
+
+/// `/SM`: a luminosity soft mask that is white over 30..110 × 30..70 in the space it is selected
+/// in, black elsewhere.
+fn soft_mask_state(d: &mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict {
+ use pdfcraft_cos::{Dict, Object, Stream};
+ let mut group = Dict::new();
+ group.set(b"S".to_vec(), Object::name("Transparency"));
+ group.set(b"CS".to_vec(), Object::name("DeviceRGB"));
+ let mut form = Dict::new();
+ form.set(b"Type".to_vec(), Object::name("XObject"));
+ form.set(b"Subtype".to_vec(), Object::name("Form"));
+ form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec()));
+ form.set(b"Group".to_vec(), Object::Dict(group));
+ let mut mask = Dict::new();
+ mask.set(b"Type".to_vec(), Object::name("Mask"));
+ mask.set(b"S".to_vec(), Object::name("Luminosity"));
+ mask.set(b"G".to_vec(), Object::Ref(d.add(Object::Stream(Stream::flate(form, b"1 g 30 30 80 40 re f")))));
+ let mut state = Dict::new();
+ state.set(b"Type".to_vec(), Object::name("ExtGState"));
+ state.set(b"SMask".to_vec(), Object::Dict(mask));
+ let mut states = Dict::new();
+ states.set(b"SM".to_vec(), Object::Dict(state));
+ states
+}
+
+/// The soft mask is selected before a translation, so it is placed by the CTM before it. The
+/// paragraph lifted over the scan selects it again after its own `cm`: there it must be placed
+/// as it was, not 100 pt to the right where it would hide the new text, so the edit renders
+/// exactly as the same edit on the page without the mask. Both rewrite paths.
+#[test]
+fn a_soft_mask_selected_before_a_cm_stays_put_when_the_edit_is_lifted() {
+ for edit in ["Jane", "Jane\nDoe"] {
+ let mut plain = Session::new();
+ let page = under_scan("q 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q");
+ let plain_id = plain.open("plain.pdf", None, page, None).unwrap();
+ edit_block(&mut plain, plain_id, 0, edit);
+ let mut s = Session::new();
+ let page = under_scan_with("q /SM gs 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q", soft_mask_state);
+ let id = s.open("masked.pdf", None, page, None).unwrap();
+ assert_eq!(texts(&s, id), ["John"]);
+ edit_block(&mut s, id, 0, edit);
+ assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}");
+ assert_edit_shows(&s, id, &format!("{edit:?} masked"));
+ let ink = |s: &Session, id| word_pixels(s, id).iter().filter(|p| near(**p, INK)).count();
+ assert_eq!(ink(&s, id), ink(&plain, plain_id), "{edit:?}: the mask does not hide the new text");
+ }
+}
diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs
index 6bb62cfb73..14659c4f52 100644
--- a/crates/forms/src/author/edit.rs
+++ b/crates/forms/src/author/edit.rs
@@ -234,6 +234,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result d.set(b"MaxLen".to_vec(), Object::Int(m as i64)),
+ // An absent key would inherit the parent's limit; zero is "no limit" (`read.rs`).
+ None if parent_limit => d.set(b"MaxLen".to_vec(), Object::Int(0)),
None => {
d.remove(b"MaxLen");
}
diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs
index 38b5f5059f..3948e06e83 100644
--- a/crates/forms/src/tests.rs
+++ b/crates/forms/src/tests.rs
@@ -5487,6 +5487,29 @@ fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() {
assert_eq!(selection_indices(&doc, &list), Some(vec![2]));
}
+#[test]
+fn clearing_a_kids_length_limit_does_not_bring_back_its_parents() {
+ let mut doc = fixture();
+ let city = field(&fields(&doc), "address.city").clone();
+ let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap();
+ doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap();
+ set_props(&mut doc, "address.city", &FieldProps { max_len: Some(Some(8)), ..FieldProps::default() }).unwrap();
+ assert_eq!(field(&fields(&doc), "address.city").max_len, Some(8));
+ set_props(&mut doc, "address.city", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap();
+ let mut doc = reopen(&doc);
+ let f = field(&fields(&doc), "address.city").clone();
+ assert_eq!(f.max_len, None, "no limit, not the parent's 3");
+ let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"MaxLen")).and_then(Object::as_int);
+ assert_eq!(own, Some(0), "the kid holds an explicit unlimited of its own");
+ set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap();
+ assert_eq!(field(&fields(&reopen(&doc)), "address.city").value, ["Absalom"], "not cut to 3 characters");
+ // Without a parent limit, clearing still removes the key.
+ set_props(&mut doc, "name", &FieldProps { max_len: Some(Some(4)), ..FieldProps::default() }).unwrap();
+ set_props(&mut doc, "name", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap();
+ let name = field(&fields(&doc), "name").clone();
+ assert!(doc.get(name.obj).as_dict().unwrap().get(b"MaxLen").is_none());
+}
+
#[test]
fn clearing_a_kids_default_does_not_bring_back_its_parents() {
let mut doc = fixture();
diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs
index 41f893c71b..159ba04e8c 100644
--- a/crates/optimize/src/images.rs
+++ b/crates/optimize/src/images.rs
@@ -9,7 +9,7 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, St
use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage};
/// Images with more pixels than this are left alone (memory).
-const MAX_PIXELS: u64 = 80_000_000;
+pub(crate) const MAX_PIXELS: u64 = 80_000_000;
/// The resources of a page, inherited from the page tree if the page has none.
pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option {
@@ -419,7 +419,7 @@ pub(crate) fn components(doc: &Document, cs: &Object) -> Option {
}
}
-fn is_dct(s: &Stream) -> Option {
+pub(crate) fn is_dct(s: &Stream) -> Option {
match s.dict.get(b"Filter") {
None => Some(false),
Some(Object::Name(n)) => match n.as_slice() {
@@ -506,7 +506,7 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize, budget: &mut ImageBud
/// to three bytes for each of them and of a PNG predictor's bytes a row (ASCIIHex text under
/// Flate, its two digits a byte broken into lines or spaced). More can only be a decompression
/// bomb, and the image is left alone.
-fn allowance(bytes: usize, rows: u32) -> Option {
+pub(crate) fn allowance(bytes: usize, rows: u32) -> Option {
bytes.checked_add(usize::try_from(rows).ok()?)?.checked_mul(3)?.checked_add(4096)
}
@@ -518,7 +518,7 @@ fn allowance(bytes: usize, rows: u32) -> Option {
/// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where
/// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters
/// or the JPEG's segments can't be read.
-fn ycc_encoded(doc: &Document, s: &Stream) -> Option {
+pub(crate) fn ycc_encoded(doc: &Document, s: &Stream) -> Option {
let entry = color_transform(doc, s)?;
match adobe_transform(&s.raw)? {
Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1),
@@ -658,7 +658,7 @@ fn scaled + 'static>(img: &image::ImageBuffer Option> {
+pub(crate) fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> {
let mut out = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 };
@@ -681,7 +681,7 @@ pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option {
/// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes
/// nothing. Scanners write it on page images (#490).
-fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool {
+pub(crate) fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool {
let decode = doc.resolve(decode);
let Some(a) = decode.as_array() else { return false };
a.len() == 2 * n && a.chunks(2).all(|p| matches!(p, [lo, hi] if doc.resolve(lo).as_f64() == Some(0.0) && doc.resolve(hi).as_f64() == Some(1.0)))
diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs
index e70aa5e71d..ef6423ab7d 100644
--- a/crates/optimize/src/lib.rs
+++ b/crates/optimize/src/lib.rs
@@ -32,6 +32,7 @@ mod audit;
pub mod fragments;
mod images;
mod links;
+mod samples;
mod unused;
pub use audit::{SpaceCategory, SpaceUse, audit_space};
@@ -40,6 +41,7 @@ use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object
use pdfcraft_filters::{Filter, Params};
pub use images::effective_resolutions;
+pub use samples::{NotDecoded, Samples, decode_samples, encode_samples};
#[derive(Debug, thiserror::Error)]
pub enum OptimizeError {
diff --git a/crates/optimize/src/samples.rs b/crates/optimize/src/samples.rs
new file mode 100644
index 0000000000..4800da6fd7
--- /dev/null
+++ b/crates/optimize/src/samples.rs
@@ -0,0 +1,312 @@
+//! An image's samples read out and written back at the same size, for tools that change the
+//! pixels of an image in place (Scan & OCR ▸ Enhance ▸ Scanned document). The image dictionary
+//! stays as it was apart from the encoding: its size, colour space, masks, `/Interpolate`,
+//! `/Intent`, `/Metadata` and every other entry are kept.
+
+use pdfcraft_cos::{Document, ImageBudget, Object, Stream};
+
+use crate::images::{MAX_PIXELS, allowance, components, is_dct, is_identity_decode, jpeg, pixels, ycc_encoded};
+
+/// An image's samples, one byte each, row-major.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Samples {
+ pub width: u32,
+ pub height: u32,
+ /// 1 (grey) or 3 (RGB).
+ pub components: usize,
+ /// The samples. A bi-level image's are 0 (black) or 255 (white) as drawn, whatever its
+ /// decode array.
+ pub data: Vec,
+ /// Stored as a JPEG (written back as one); otherwise written back losslessly (Flate).
+ pub jpeg: bool,
+ /// One bit per pixel (written back as one).
+ pub bilevel: bool,
+ /// A bi-level image whose 1 bits are black (decode array `[1 0]`): written back the same way,
+ /// so its decode array stays true.
+ pub inverted: bool,
+}
+
+/// Why an image's samples were not read. Each says what the image is, so a report can name it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
+pub enum NotDecoded {
+ #[error("it is a stencil mask, not a picture")]
+ StencilMask,
+ #[error("its colour space is not grey or RGB (CMYK, indexed and special colours are not supported yet)")]
+ ColourSpace,
+ #[error("it has {0} bits per sample (8, and 1 for black and white, are supported)")]
+ BitDepth(i64),
+ #[error("its decode array remaps the samples")]
+ Decode,
+ #[error("it is compressed in a format PdfCraft cannot read back yet (JPEG 2000, JBIG2 or CCITT fax)")]
+ Filter,
+ #[error("its JPEG colour transform is ambiguous, so readers may disagree about its colours")]
+ Ambiguous,
+ #[error("it is larger than 80 million pixels")]
+ TooLarge,
+ #[error("its data could not be decoded, or is shorter than its size says")]
+ Corrupt,
+}
+
+/// Read the samples of image stream `s` (a dictionary with `/Subtype /Image`), decoding it within
+/// `budget` (an operation over many images shares one).
+pub fn decode_samples(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result {
+ let d = &s.dict;
+ if matches!(d.get(b"ImageMask").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))) {
+ return Err(NotDecoded::StencilMask);
+ }
+ // The filter first: a JPEG 2000 image may leave its colour space and bit depth to the data,
+ // and the report should name the format, not the missing entries.
+ let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?;
+ let n = d.get(b"ColorSpace").and_then(|cs| components(doc, cs)).ok_or(NotDecoded::ColourSpace)?;
+ let bpc = d.get(b"BitsPerComponent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(0);
+ let bilevel = match bpc {
+ 8 => false,
+ 1 if n == 1 => true,
+ other => return Err(NotDecoded::BitDepth(other)),
+ };
+ let width = d.get(b"Width").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
+ let height = d.get(b"Height").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
+ if width == 0 || height == 0 {
+ return Err(NotDecoded::Corrupt);
+ }
+ if u64::from(width) * u64::from(height) > MAX_PIXELS {
+ return Err(NotDecoded::TooLarge);
+ }
+ if bilevel {
+ if jpeg {
+ // A JPEG has 8 bits per sample: the dictionary contradicts the data.
+ return Err(NotDecoded::Corrupt);
+ }
+ let inverted = match d.get(b"Decode") {
+ None => false,
+ Some(a) if is_identity_decode(doc, a, 1) => false,
+ Some(a) if is_inverted_decode(doc, a) => true,
+ Some(_) => return Err(NotDecoded::Decode),
+ };
+ let data = unpack_bits(doc, s, width, height, inverted, budget).ok_or(NotDecoded::Corrupt)?;
+ return Ok(Samples { width, height, components: 1, data, jpeg: false, bilevel: true, inverted });
+ }
+ if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) {
+ return Err(NotDecoded::Decode);
+ }
+ if jpeg && n == 3 && ycc_encoded(doc, s).is_none() {
+ return Err(NotDecoded::Ambiguous);
+ }
+ let data = pixels(doc, s, n, budget).ok_or(NotDecoded::Corrupt)?;
+ Ok(Samples { width, height, components: n, data, jpeg, bilevel: false, inverted: false })
+}
+
+/// `[1 0]`: a bi-level image whose 1 bits are black.
+fn is_inverted_decode(doc: &Document, decode: &Object) -> bool {
+ let decode = doc.resolve(decode);
+ matches!(decode.as_array().map(Vec::as_slice), Some([a, b]) if doc.resolve(a).as_f64() == Some(1.0) && doc.resolve(b).as_f64() == Some(0.0))
+}
+
+/// The bytes a `w` × `h` bi-level image takes: each row padded to whole bytes.
+fn packed_len(w: u32, h: u32) -> Option {
+ usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?)
+}
+
+/// A bi-level image's pixels, 0 (black) or 255 (white) as drawn, decoded within `budget` with
+/// its `/DecodeParms` resolved, as the 8-bit images are.
+fn unpack_bits(doc: &Document, s: &Stream, w: u32, h: u32, inverted: bool, budget: &mut ImageBudget) -> Option> {
+ let stride = usize::try_from(w).ok()?.div_ceil(8);
+ let need = packed_len(w, h)?;
+ let bits = budget.decode_filters(doc, s, allowance(need, h)?).ok()?;
+ let bits = bits.get(..need)?;
+ let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
+ let mut out = Vec::with_capacity(w.checked_mul(h)?);
+ for row in bits.chunks(stride).take(h) {
+ for x in 0..w {
+ let byte = row.get(x / 8).copied().unwrap_or(0);
+ let one = byte & (0x80 >> (x % 8)) != 0;
+ // Without a decode array 0 is black and 1 white; [1 0] swaps them.
+ out.push(if one != inverted { 255 } else { 0 });
+ }
+ }
+ Some(out)
+}
+
+/// The pixels (0 black … 255 white, threshold at half) packed back to bits.
+fn pack_bits(px: &[u8], w: u32, h: u32, inverted: bool) -> Option> {
+ let stride = usize::try_from(w).ok()?.div_ceil(8);
+ let mut out = vec![0u8; packed_len(w, h)?];
+ let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
+ if px.len() != w.checked_mul(h)? {
+ return None;
+ }
+ for (y, row) in px.chunks(w.max(1)).enumerate() {
+ for (x, &v) in row.iter().enumerate() {
+ let white = v >= 128;
+ if white != inverted
+ && let Some(b) = out.get_mut(y * stride + x / 8)
+ {
+ *b |= 0x80 >> (x % 8);
+ }
+ }
+ }
+ Some(out)
+}
+
+/// Image stream `old` with its samples replaced by `samples` (same size and components): a JPEG
+/// stays a JPEG (at `quality`, 1–100), anything else becomes Flate, and a bi-level image stays
+/// one bit per pixel with its decode array. Filter parameters go (the new data needs none); every
+/// other entry stays. `None` if the samples don't fit the image.
+pub fn encode_samples(old: &Stream, samples: &Samples, quality: u8) -> Option {
+ let Samples { width, height, components: n, data, jpeg: as_jpeg, bilevel, inverted } = samples;
+ let (w, h, n) = (*width, *height, *n);
+ if !(n == 1 || n == 3) || data.len() != usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)? {
+ return None;
+ }
+ let mut d = old.dict.clone();
+ for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] {
+ d.remove(k);
+ }
+ if *bilevel {
+ if n != 1 {
+ return None;
+ }
+ d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
+ return Some(Stream::flate(d, &pack_bits(data, w, h, *inverted)?));
+ }
+ d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
+ if *as_jpeg {
+ let bytes = jpeg(data, n, w, h, quality)?;
+ d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
+ Some(Stream::from_raw(d, bytes))
+ } else {
+ Some(Stream::flate(d, data))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pdfcraft_cos::Dict;
+
+ fn image(dict: &[(&str, Object)], raw: Vec) -> Stream {
+ let mut d = Dict::new();
+ d.set(b"Type".to_vec(), Object::name("XObject"));
+ d.set(b"Subtype".to_vec(), Object::name("Image"));
+ for (k, v) in dict {
+ d.set(k.as_bytes().to_vec(), v.clone());
+ }
+ Stream::from_raw(d, raw)
+ }
+
+ fn doc() -> Document {
+ let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
+ Document::open(std::sync::Arc::new(pdf.to_vec())).unwrap()
+ }
+
+ #[test]
+ fn grey_samples_round_trip_losslessly_and_keep_the_other_entries() {
+ let px: Vec = (0..12 * 5).map(|i| (i * 4) as u8).collect();
+ let s = image(
+ &[
+ ("Width", Object::Int(12)),
+ ("Height", Object::Int(5)),
+ ("ColorSpace", Object::name("DeviceGray")),
+ ("BitsPerComponent", Object::Int(8)),
+ ("Interpolate", Object::Bool(true)),
+ ],
+ px.clone(),
+ );
+ let got = decode_samples(&doc(), &s, &mut ImageBudget::with_work(u64::MAX)).unwrap();
+ assert_eq!((got.width, got.height, got.components, got.jpeg, got.bilevel), (12, 5, 1, false, false));
+ assert_eq!(got.data, px);
+ let back = encode_samples(&s, &got, 90).unwrap();
+ assert_eq!(back.dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
+ assert_eq!(back.dict.get(b"Interpolate"), Some(&Object::Bool(true)));
+ assert_eq!(decode_samples(&doc(), &back, &mut ImageBudget::with_work(u64::MAX)).unwrap().data, px);
+ }
+
+ #[test]
+ fn bilevel_samples_keep_their_decode_array_and_bits() {
+ // 10 × 2: first row black on the left half, second row all white; [1 0] makes 1 black.
+ let raw = vec![0b1111_1000, 0b0000_0000, 0, 0];
+ let s = image(
+ &[
+ ("Width", Object::Int(10)),
+ ("Height", Object::Int(2)),
+ ("ColorSpace", Object::name("DeviceGray")),
+ ("BitsPerComponent", Object::Int(1)),
+ ("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)])),
+ ],
+ raw.clone(),
+ );
+ let got = decode_samples(&doc(), &s, &mut ImageBudget::with_work(u64::MAX)).unwrap();
+ assert!(got.bilevel);
+ assert_eq!(&got.data[..10], &[0, 0, 0, 0, 0, 255, 255, 255, 255, 255]);
+ assert!(got.data[10..].iter().all(|&v| v == 255));
+ assert!(got.inverted);
+ let back = encode_samples(&s, &got, 90).unwrap();
+ assert_eq!(back.dict.int(b"BitsPerComponent"), Some(1));
+ assert!(back.dict.get(b"Decode").is_some());
+ assert_eq!(back.decoded().unwrap(), raw);
+ }
+
+ #[test]
+ fn bilevel_decode_parms_by_reference_decode_like_direct_ones() {
+ // 10 × 2 with a TIFF predictor: each 1 bit flips the pixel from the one to its left.
+ let packed = vec![0b1000_0000, 0b0000_0000, 0b0100_0000, 0b0000_0000];
+ let parms = || {
+ let mut p = pdfcraft_cos::Dict::new();
+ for (k, v) in [("Predictor", 2), ("Colors", 1), ("BitsPerComponent", 1), ("Columns", 10)] {
+ p.set(k.as_bytes().to_vec(), Object::Int(v));
+ }
+ Object::Dict(p)
+ };
+ let dict = [
+ ("Width", Object::Int(10)),
+ ("Height", Object::Int(2)),
+ ("ColorSpace", Object::name("DeviceGray")),
+ ("BitsPerComponent", Object::Int(1)),
+ ];
+ let mut d = doc();
+ let mut direct = Stream::flate(image(&dict, vec![]).dict, &packed);
+ direct.dict.set(b"DecodeParms".to_vec(), parms());
+ let mut indirect = direct.clone();
+ let r = d.add(parms());
+ indirect.dict.set(b"DecodeParms".to_vec(), Object::Ref(r));
+ let a = decode_samples(&d, &direct, &mut ImageBudget::with_work(u64::MAX)).unwrap();
+ let b = decode_samples(&d, &indirect, &mut ImageBudget::with_work(u64::MAX)).unwrap();
+ // Row 1: white from x = 0 on; row 2: black at x = 0, white from x = 1 on.
+ assert_eq!(&a.data[..10], &[255; 10]);
+ assert_eq!(&a.data[10..], &[0, 255, 255, 255, 255, 255, 255, 255, 255, 255]);
+ assert_eq!(b, a);
+ }
+
+ #[test]
+ fn what_is_not_supported_says_why_and_never_panics() {
+ let base = |extra: &[(&str, Object)], raw: Vec| {
+ let mut d: Vec<(&str, Object)> = vec![
+ ("Width", Object::Int(4)),
+ ("Height", Object::Int(4)),
+ ("ColorSpace", Object::name("DeviceGray")),
+ ("BitsPerComponent", Object::Int(8)),
+ ];
+ d.extend(extra.iter().cloned());
+ decode_samples(&doc(), &image(&d, raw), &mut ImageBudget::with_work(u64::MAX))
+ };
+ assert_eq!(base(&[("ColorSpace", Object::name("DeviceCMYK"))], vec![0; 64]), Err(NotDecoded::ColourSpace));
+ assert_eq!(base(&[("BitsPerComponent", Object::Int(4))], vec![0; 8]), Err(NotDecoded::BitDepth(4)));
+ assert_eq!(base(&[("Filter", Object::name("JBIG2Decode"))], vec![0; 8]), Err(NotDecoded::Filter));
+ // JPEG 2000 may leave the colour space and bit depth to its data: the format is the reason.
+ let jpx = |dict: &[(&str, Object)]| decode_samples(&doc(), &image(dict, vec![0; 8]), &mut ImageBudget::with_work(u64::MAX));
+ let size = [("Width", Object::Int(4)), ("Height", Object::Int(4)), ("Filter", Object::name("JPXDecode"))];
+ assert_eq!(jpx(&size), Err(NotDecoded::Filter));
+ assert_eq!(jpx(&[size[0].clone(), size[1].clone(), size[2].clone(), ("ColorSpace", Object::name("DeviceRGB"))]), Err(NotDecoded::Filter));
+ assert_eq!(base(&[("ImageMask", Object::Bool(true))], vec![0; 8]), Err(NotDecoded::StencilMask));
+ assert_eq!(base(&[("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)]))], vec![0; 16]), Err(NotDecoded::Decode));
+ // Too short, a huge size, a broken JPEG, no size at all.
+ assert_eq!(base(&[], vec![0; 5]), Err(NotDecoded::Corrupt));
+ assert_eq!(base(&[("Width", Object::Int(1 << 30)), ("Height", Object::Int(1 << 30))], vec![0; 16]), Err(NotDecoded::TooLarge));
+ assert_eq!(base(&[("Filter", Object::name("DCTDecode"))], vec![0xFF, 0xD8, 1, 2, 3]), Err(NotDecoded::Corrupt));
+ assert_eq!(base(&[("Width", Object::Int(-3))], vec![0; 16]), Err(NotDecoded::Corrupt));
+ // Samples that don't fit the image are refused when written back.
+ let s = image(&[("Width", Object::Int(4)), ("Height", Object::Int(4))], vec![0; 16]);
+ let wrong = Samples { width: 4, height: 4, components: 1, data: vec![0; 15], jpeg: false, bilevel: false, inverted: false };
+ assert!(encode_samples(&s, &wrong, 90).is_none());
+ }
+}
diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs
index b890965d0c..3b3d56cd83 100644
--- a/crates/organize/src/lib.rs
+++ b/crates/organize/src/lib.rs
@@ -20,6 +20,7 @@ mod dedupe;
mod import;
mod labels;
mod outline;
+mod outline_io;
mod pdfx;
mod prune;
mod transition;
@@ -40,6 +41,7 @@ pub use outline::{
Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_at, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark,
move_bookmark, rename_bookmark, set_bookmark_color, set_bookmark_open, set_bookmark_page, set_bookmark_style, top_level_bookmarks,
};
+pub use outline_io::{MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, import_outline, outline_tree, unmapped, view_arity};
pub use transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition};
pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view};
diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs
index ff7d3d8da7..13c02eb662 100644
--- a/crates/organize/src/outline.rs
+++ b/crates/organize/src/outline.rs
@@ -19,7 +19,7 @@ use crate::{OrganizeError, walk};
/// Deepest level a listing enters (the top level is 0). Bookmarks deeper than this are not
/// listed; edits still reach them by path.
-const LIST_DEPTH: usize = 32;
+pub(crate) const LIST_DEPTH: usize = 32;
/// Deepest name-tree node read when looking up named destinations.
const NAME_TREE_DEPTH: usize = 32;
@@ -72,6 +72,14 @@ pub enum OutlineError {
BadColor,
#[error("this document has no tagged headings to make bookmarks from")]
NoEntries,
+ #[error("there are no bookmarks to add")]
+ NothingToImport,
+ #[error("the bookmark at {0:?} has a view that is not a known destination type with its values")]
+ BadView(Vec),
+ #[error("bookmarks may nest at most {0} levels deep")]
+ TooDeep(usize),
+ #[error("at most {0} bookmarks can be added at once")]
+ TooManyBookmarks(usize),
#[error("{0}")]
Organize(#[from] OrganizeError),
#[error("{0}")]
@@ -449,7 +457,7 @@ impl From for OrganizeError {
/// Make `kids` the children of `parent`, in order: rewires `/Parent`, `/Prev`, `/Next`,
/// `/First` and `/Last`.
-fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> {
+pub(crate) fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> {
for (i, k) in kids.iter().enumerate() {
put(doc, *k, b"Parent", Some(Object::Ref(parent)))?;
put(doc, *k, b"Prev", i.checked_sub(1).map(|p| Object::Ref(kids[p])))?;
@@ -473,7 +481,7 @@ struct Counting {
/// Recompute every `/Count` (§12.3.3: open items count their visible descendants, closed items
/// the negative of what opening them would show; the root counts all visible items).
-fn recount(doc: &mut Document) -> Result<()> {
+pub(crate) fn recount(doc: &mut Document) -> Result<()> {
let Some(root) = outline_root(doc) else { return Ok(()) };
let mut seen = HashSet::from([root]);
let kids = children_of(doc, root, &mut seen);
@@ -714,7 +722,7 @@ pub fn move_bookmark(doc: &mut Document, from: &[usize], to_parent: &[usize], in
/// Open `parent` so a bookmark just added or moved into it shows (the root is always open).
/// Only the sign of `/Count` matters here: [`recount`] sets its value.
-fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> {
+pub(crate) fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> {
if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) {
put(doc, parent, b"Count", Some(Object::Int(1)))?;
}
diff --git a/crates/organize/src/outline_io.rs b/crates/organize/src/outline_io.rs
new file mode 100644
index 0000000000..1fc3afc4f3
--- /dev/null
+++ b/crates/organize/src/outline_io.rs
@@ -0,0 +1,261 @@
+//! Moving a whole outline (or one bookmark's subtree) out of a document and into another
+//! (ISO 32000-2 §12.3.3 outlines, §12.3.2.2 explicit destinations).
+//!
+//! [`outline_tree`] reads bookmarks as a portable tree of [`OutlineNode`]s: titles, nesting, open
+//! state, colour, style and destinations as page index plus view in the file's own user space,
+//! so writing a tree back with [`import_outline`] gives the same structure. A node whose page the
+//! target lacks keeps its place in the tree without a destination, and [`unmapped`] names it, so
+//! nothing is dropped silently. Not carried: actions other than a GoTo, named destinations (they
+//! travel resolved to their page), and structure links (`/SE`).
+
+use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
+
+use crate::outline::{LIST_DEPTH, children, container, recount, relink, show_children};
+use crate::{OrganizeError, OutlineError, walk};
+
+/// Most bookmarks one import writes.
+pub const MAX_IMPORT_BOOKMARKS: usize = 100_000;
+
+/// The destination types (Table 149) and how many numbers follow each.
+const VIEWS: [(&[u8], usize); 8] = [(b"XYZ", 3), (b"Fit", 0), (b"FitH", 1), (b"FitV", 1), (b"FitR", 4), (b"FitB", 0), (b"FitBH", 1), (b"FitBV", 1)];
+
+/// One bookmark and the bookmarks under it, independent of any document.
+#[derive(Clone, Debug, PartialEq)]
+pub struct OutlineNode {
+ pub title: String,
+ /// The 0-based page it goes to; `None` for a bookmark that goes nowhere in its document.
+ pub page: Option,
+ /// Where on the page: a destination type from Table 149 and exactly as many values as that
+ /// type takes, in PDF user space (`None` keeps the reader's value). `None` goes to the page at
+ /// the reader's position and zoom.
+ pub view: Option<(Vec, Vec>)>,
+ /// The title's colour (`/C`), each component from 0 to 1; `None` is the default black.
+ pub color: Option<[f32; 3]>,
+ pub italic: bool,
+ pub bold: bool,
+ /// Shown expanded, when it has children.
+ pub open: bool,
+ pub children: Vec,
+}
+
+/// The tree [`outline_tree`] read.
+#[derive(Clone, Debug, PartialEq)]
+pub struct OutlineTree {
+ pub nodes: Vec,
+ /// Bookmarks nested deeper than the listing reaches (32 levels) were left out.
+ pub truncated: bool,
+}
+
+/// The bookmark at `path` with everything under it, or the whole outline for `[]`.
+pub fn outline_tree(doc: &Document, path: &[usize]) -> Result {
+ // A whole outline's top level sits at depth 0; a subtree's root is the bookmark at `path`.
+ let base = path.len().max(1);
+ let mut truncated = false;
+ // The open nodes, outermost first, each with its depth.
+ let mut open: Vec<(usize, OutlineNode)> = Vec::new();
+ let mut roots = Vec::new();
+ for b in crate::bookmarks(doc).into_iter().filter(|b| b.path.starts_with(path) && !b.path.is_empty()) {
+ let depth = b.path.len().saturating_sub(base);
+ if b.path.len() > LIST_DEPTH && has_children(doc, b.obj) {
+ truncated = true;
+ }
+ while open.last().is_some_and(|(d, _)| *d >= depth) {
+ close(&mut open, &mut roots);
+ }
+ let view = b.view.and_then(|(kind, values)| portable_view(kind, values));
+ let node =
+ OutlineNode { title: b.title, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold, open: b.open, children: Vec::new() };
+ open.push((depth, node));
+ }
+ while !open.is_empty() {
+ close(&mut open, &mut roots);
+ }
+ if !path.is_empty() && roots.is_empty() {
+ return Err(OutlineError::NoSuchBookmark(path.to_vec()));
+ }
+ Ok(OutlineTree { nodes: roots, truncated })
+}
+
+fn close(open: &mut Vec<(usize, OutlineNode)>, roots: &mut Vec) {
+ if let Some((_, node)) = open.pop() {
+ match open.last_mut() {
+ Some((_, parent)) => parent.children.push(node),
+ None => roots.push(node),
+ }
+ }
+}
+
+fn has_children(doc: &Document, r: ObjRef) -> bool {
+ doc.get(r).as_dict().is_some_and(|d| d.reference(b"First").is_some())
+}
+
+/// A destination's view as [`import_outline`] takes it: a known type with exactly its number of
+/// values (extra ones dropped, missing ones `None`). An unknown type, or a FitR missing one of
+/// its four values (the rectangle can't be kept), gives `None`.
+fn portable_view(kind: Vec, mut values: Vec>) -> Option<(Vec, Vec>)> {
+ let (_, arity) = VIEWS.iter().find(|(k, _)| *k == kind.as_slice())?;
+ values.resize(*arity, None);
+ if kind.as_slice() == b"FitR" && values.iter().any(Option::is_none) {
+ return None;
+ }
+ Some((kind, values))
+}
+
+/// The nodes of `nodes` whose page `page_count` pages don't have, as paths into `nodes`
+/// (0-based child indices), in tree order. [`import_outline`] keeps these without a destination.
+pub fn unmapped(nodes: &[OutlineNode], page_count: usize) -> Vec> {
+ let mut out = Vec::new();
+ visit(nodes, |path, node| {
+ if node.page.is_some_and(|p| p >= page_count) {
+ out.push(path.to_vec());
+ }
+ });
+ out
+}
+
+/// How many values a view of destination type `kind` takes (Table 149), or `None` for a type
+/// that is not one of them.
+pub fn view_arity(kind: &[u8]) -> Option {
+ VIEWS.iter().find(|(k, _)| *k == kind).map(|(_, arity)| *arity)
+}
+
+/// Call `f` with each node's path and the node, parents first. Iterative.
+fn visit<'a>(nodes: &'a [OutlineNode], mut f: impl FnMut(&[usize], &'a OutlineNode)) {
+ // One entry per open level: its nodes and how many of them have been visited.
+ let mut stack: Vec<(&'a [OutlineNode], usize)> = vec![(nodes, 0)];
+ // The visited node's path, kept in step with the stack rather than rebuilt per node.
+ let mut path: Vec = Vec::new();
+ while let Some(level) = stack.last_mut() {
+ let list = level.0;
+ let index = level.1;
+ let Some(node) = list.get(index) else {
+ stack.pop();
+ continue;
+ };
+ level.1 = index.saturating_add(1);
+ path.truncate(stack.len().saturating_sub(1));
+ path.push(index);
+ f(&path, node);
+ if !node.children.is_empty() {
+ stack.push((&node.children, 0));
+ }
+ }
+}
+
+/// Check every node before anything is written: depth (counting the `base` levels above the
+/// tree), count, colour and view.
+fn check(nodes: &[OutlineNode], base: usize) -> Result<(), OutlineError> {
+ let mut problem = None;
+ let mut count = 0usize;
+ visit(nodes, |path, node| {
+ count = count.saturating_add(1);
+ if problem.is_some() {
+ return;
+ }
+ if base.saturating_add(path.len()) > LIST_DEPTH.saturating_add(1) {
+ problem = Some(OutlineError::TooDeep(LIST_DEPTH.saturating_add(1)));
+ } else if node.color.is_some_and(|c| !c.iter().all(|v| (0.0..=1.0).contains(v))) {
+ problem = Some(OutlineError::BadColor);
+ } else if node.view.as_ref().is_some_and(|v| !view_is_valid(v)) {
+ problem = Some(OutlineError::BadView(path.to_vec()));
+ }
+ });
+ if count > MAX_IMPORT_BOOKMARKS {
+ return Err(OutlineError::TooManyBookmarks(MAX_IMPORT_BOOKMARKS));
+ }
+ problem.map_or(Ok(()), Err)
+}
+
+/// A known type with its number of values, each finite; `/FitR` needs all four.
+fn view_is_valid((kind, values): &(Vec, Vec>)) -> bool {
+ VIEWS.iter().any(|(k, arity)| *k == kind.as_slice() && *arity == values.len())
+ && values.iter().flatten().all(|v| v.is_finite())
+ && (kind.as_slice() != b"FitR" || values.iter().all(Option::is_some))
+}
+
+/// Write `nodes` as children of the bookmark at `parent_path` (`[]` = top level), starting at
+/// child `index` (past the end appends), or, with `replace`, in place of the outline's
+/// top-level bookmarks (then `parent_path` must be `[]`). Nodes whose page this document lacks
+/// are written without a destination (see [`unmapped`]). Everything is checked before anything
+/// is written. Returns the path of the first node written.
+pub fn import_outline(
+ doc: &mut Document,
+ parent_path: &[usize],
+ index: usize,
+ nodes: &[OutlineNode],
+ replace: bool,
+) -> Result, OutlineError> {
+ if nodes.is_empty() {
+ return Err(OutlineError::NothingToImport);
+ }
+ if replace && !parent_path.is_empty() {
+ return Err(OrganizeError::Invalid("replace applies to the whole outline: leave the parent out".into()).into());
+ }
+ check(nodes, parent_path.len())?;
+ let pages: Vec = walk(doc)?.into_iter().map(|(r, _)| r).collect();
+ let parent = container(doc, parent_path)?;
+ let top = write_nodes(doc, &pages, nodes)?;
+ let mut kids = if replace { Vec::new() } else { children(doc, parent) };
+ let at = index.min(kids.len());
+ kids.splice(at..at, top);
+ relink(doc, parent, &kids)?;
+ show_children(doc, parent, parent_path)?;
+ recount(doc)?;
+ let mut path = parent_path.to_vec();
+ path.push(at);
+ Ok(path)
+}
+
+/// Add one item per node, linked under each other, and return the top-level items in order.
+fn write_nodes(doc: &mut Document, pages: &[ObjRef], nodes: &[OutlineNode]) -> Result, OutlineError> {
+ // Each written item with its node, then its children's items once they are written.
+ let mut written: Vec<(ObjRef, Vec)> = Vec::new();
+ let mut top = Vec::new();
+ // (the nodes of one level, the next to write, the index in `written` of their parent)
+ let mut stack: Vec<(&[OutlineNode], usize, Option