Merge origin/main into perf/failed-object-cache

Conflicts with the generation-aware references (#1143): stream lengths are
kept by full reference, with whether resolving them read unsaved edits; a
repaired slot counts as an edit read like any other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76
2026-11-05 07:50:48 -05:00
co-authored by Claude Opus 5.5
175 changed files with 19057 additions and 573 deletions
+1
View File
@@ -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 }}
+3 -1
View File
@@ -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)
+22
View File
@@ -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\""
Generated
+20
View File
@@ -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"
+2
View File
@@ -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"
+7
View File
@@ -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"]
+1 -1
View File
@@ -94,7 +94,7 @@ Real-world typography: world scripts, vertical Japanese, colour emoji, gradients
<td width="33%" valign="top">
### 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.
</td>
<td width="33%" valign="top">
+20 -1
View File
@@ -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. |
+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-a-large-small"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
<path d="M15.697 14h5.606" />
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
<path d="M3.304 13h6.392" />
</svg>

After

Width:  |  Height:  |  Size: 475 B

+19 -10
View File
@@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
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<String> {
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));
+10
View File
@@ -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),
}
+77
View File
@@ -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<u8>,
}
/// 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<Vec<usize>, 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<Model3d, AnnotError> {
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<std::sync::Arc<Object>> {
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;
+88
View File
@@ -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<u8>]) -> Document {
let mut objs: Vec<Vec<u8>> = 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<u8> {
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<u8> {
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)));
}
+135
View File
@@ -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"U3Drest");
assert_eq!(adbe(&reopen(&doc)), None, "U3D is in ISO 32000-1");
let mut doc = fixture();
doc.set_version("2.0");
add(&mut doc, b"PRC rest");
assert_eq!(adbe(&reopen(&doc)), None, "PRC is in ISO 32000-2");
let mut doc = fixture();
let root = doc.root().unwrap();
let mut level3 = Dict::new();
level3.set(b"BaseVersion".to_vec(), Object::name("1.7"));
level3.set(b"ExtensionLevel".to_vec(), Object::Int(3));
let mut ext = Dict::new();
ext.set(b"ADBE".to_vec(), Object::Dict(level3));
ext.set(b"XYZ".to_vec(), Object::Dict(Dict::new()));
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext.clone()))).unwrap();
add(&mut doc, b"PRC rest");
assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(3))))), "a later level is kept");
ext.remove(b"ADBE");
let mut doc = fixture();
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext))).unwrap();
add(&mut doc, b"PRC rest");
assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(1))))), "other extensions are kept");
let mut doc = fixture();
let mut ext = Dict::new();
ext.set(b"ADBE".to_vec(), Object::Array(vec![Object::Dict(Dict::new())]));
let ext = doc.add(Object::Dict(ext));
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Ref(ext))).unwrap();
add(&mut doc, b"PRC rest");
let doc = reopen(&doc);
let ext = doc.resolve(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Extensions").unwrap());
assert!(ext.as_dict().unwrap().get(b"ADBE").unwrap().as_array().is_some(), "an array of ADBE extensions is left alone");
}
#[test]
fn a_picture_is_placed_by_its_box_and_a_form_needs_a_positive_size() {
let at = [10.0, 20.0, 110.0, 70.0];
assert_eq!(appearance::picture_place(at, true, None).as_deref(), Some("100 0 0 50 10 20 cm"), "an image fills its unit square");
assert_eq!(appearance::picture_place(at, false, Some((200.0, 25.0))).as_deref(), Some("0.5 0 0 2 10 20 cm"));
for size in [None, Some((0.0, 25.0)), Some((200.0, -1.0))] {
assert_eq!(appearance::picture_place(at, false, size), None, "{size:?}");
}
}
/// The poster's box must lie in the rectangle, and a form poster needs a size to scale by.
#[test]
fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() {
let picture = |at: [f64; 4], size: (f64, f64)| PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size, at };
for (poster, why) in [
(picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"),
(picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"),
(picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"),
(picture([100.0 - 2e-7, 150.0, 100.0 - 1e-7, 200.0], (10.0, 10.0)), "lies outside within the rounding slack"),
] {
let mut doc = fixture();
let new = NewModel3d {
page: 0,
rect: [100.0, 100.0, 300.0, 300.0],
model: b"PRC rest",
activation: Activation3d::default(),
poster: Some(poster),
contents: "",
};
assert!(matches!(add_3d_model(&mut doc, &new, &meta("m")), Err(AnnotError::Invalid(_))), "{why}");
assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added");
}
}
/// A poster box fitted to the rectangle in floating point ends an ulp outside it: a 7×3 picture
/// centred in [0 100 3.6 110.8] spans -2.2e-16 to 3.6000000000000005. It is taken, inside the
/// rectangle.
#[test]
fn a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken() {
let mut doc = fixture();
let at = [-2.220446049250313e-16, 104.62857142857143, 3.6000000000000005, 106.17142857142858];
let poster = PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size: (7.0, 3.0), at };
let new = NewModel3d {
page: 0,
rect: [0.0, 100.0, 3.6, 110.8],
model: b"PRC rest",
activation: Activation3d::default(),
poster: Some(poster),
contents: "",
};
assert_eq!(add_3d_model(&mut doc, &new, &meta("m")), Ok(1));
}
#[test]
fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
let mut doc = fixture();
+292
View File
@@ -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<Self, AnnotError> {
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<PosterPicture>,
/// `/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<ModelFormat, AnnotError> {
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<usize, AnnotError> {
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<Stream, AnnotError> {
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()));
}
}
+2
View File
@@ -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"
+9
View File
@@ -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
+209
View File
@@ -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<Value> {
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::<Vec<_>>(), "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<Value> {
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<Value> {
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<pdfcraft_engine::OutlineTree> {
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<OutlineNode>) -> Result<Value> {
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::<Vec<_>>());
Ok(out)
}
}
fn one_based(path: &[usize]) -> Vec<usize> {
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::<Vec<_>>());
}
v
}
fn nodes_of(list: &Value) -> Result<Vec<OutlineNode>> {
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<Vec<OutlineNode>> {
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<OutlineNode> {
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<Vec<f32>> = 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<u8>, Vec<Option<f64>>)> {
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::<Result<_>>()?
}
};
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}"))
}
+1 -1
View File
@@ -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])]
}
+38
View File
@@ -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<Value> {
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::<Vec<_>>();
let skipped: Vec<Value> = 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)
}
}
+12 -2
View File
@@ -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() {
+3
View File
@@ -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;
+1 -1
View File
@@ -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 },
})
}
+114
View File
@@ -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<Value> {
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<Vec<u8>> {
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}");
}
}
}
+104
View File
@@ -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<Vec<Content>> {
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<u32> {
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<u8>) -> Vec<u8> {
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]);
}
}
+77
View File
@@ -181,6 +181,13 @@ pub fn tools() -> Vec<ToolDef> {
"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<ToolDef> {
}),
&["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<ToolDef> {
}),
&["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<ToolDef> {
}),
&["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(
+246
View File
@@ -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<u8> {
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::<Vec<_>>())
}
/// 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!([]));
}
@@ -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})),
+2
View File
@@ -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;
+120
View File
@@ -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<u8> {
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<Dict>) {
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<f64> = 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!(&not_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());
}
+112
View File
@@ -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<u8> {
let stream = format!("<< /Type /3D /Subtype /{subtype} /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1);
let objs: Vec<String> = 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());
}
+19 -5
View File
@@ -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.
+161 -20
View File
@@ -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<Object> {
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<Arc<Object>, 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<ObjRef> {
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::<u32>().ok()?;
lexer.skip_ws();
let generation = std::str::from_utf8(lexer.token()).ok()?.parse::<u16>().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<u32>, log: &mut Vec<String>) -> 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<String>) {
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<Object>) -> 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<u16> {
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<Object>) -> 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<u32> = 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<Item = (u32, u16, Option<Arc<Object>>)> + '_ {
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"))
}
}
+6 -2
View File
@@ -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).
+23 -8
View File
@@ -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<String>) -> Result<Option<(XrefIndex, Dict, Vec<Revision>)>, CosError> {
@@ -323,7 +323,7 @@ impl Document {
pub(super) fn read_xref_chain_materialized(
&self,
log: &mut Vec<String>,
) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>), CosError> {
) -> Result<(BTreeMap<u32, XrefEntry>, Dict, Vec<Revision>, Vec<(u64, u32)>, Vec<String>), 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<i64, Option<BTreeMap<u32, XrefEntry>>> = 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 &sect {
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))
};
+6 -2
View File
@@ -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<HashMap<u32, (Option<i64>, bool)>> = std::cell::RefCell::new(HashMap::new());
pub(super) static LENGTHS: std::cell::RefCell<HashMap<ObjRef, (Option<i64>, 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<u64> = const { std::cell::Cell::new(0) };
}
@@ -85,6 +85,9 @@ pub(super) fn objstm_member(num: i64, offset: i64, first: Option<usize>, len: us
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Slot {
Set(u16, Arc<Object>),
/// 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<Object>),
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<Arc<Object>, 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() {
+40 -11
View File
@@ -601,7 +601,7 @@ pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec<u8> {
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]
+16 -18
View File
@@ -41,7 +41,7 @@ pub(super) struct XrefRevisionSection {
#[derive(Clone, Debug)]
pub(super) enum XrefIndex {
Compact { revisions: Vec<XrefRevisionSection>, max_num: u32, has_entries: bool },
Materialized(Arc<BTreeMap<u32, XrefEntry>>),
Materialized(Arc<BTreeMap<u32, XrefEntry>>, Arc<Vec<String>>),
}
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<u32>) {
for range in &self.ranges {
pub(super) fn numbers(&self) -> impl Iterator<Item = u32> + '_ {
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<u32>) {
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<XrefEntry> {
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<u32, XrefEntry> {
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<u32> {
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, .. } => {
+18 -6
View File
@@ -307,8 +307,17 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, 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::<String>();
@@ -319,13 +328,15 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, 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<ObjRef> = std::collections::HashSet::new();
let mut encryption_objects: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut pending: Vec<ObjRef> = 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<Vec<u8>, 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<u32, Row> = 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 {
+475
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
object_file_with_entry(objects, None)
}
fn object_file_with_entry(objects: &[(u32, u16, &str)], changed: Option<(u32, u16, bool)>) -> Arc<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8>, value: i32) {
let bytes = Arc::new(bytes);
let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let catalog = oracle.xref().get::<HayroDict<'_>>(ObjectIdentifier::new(1, 0)).unwrap();
assert_eq!(catalog.get::<HayroDict<'_>>(b"Current").unwrap().get::<i32>(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::<HayroDict<'_>>(ObjectIdentifier::new(1, 0)).unwrap();
assert_eq!(catalog.get::<i32>(b"Current"), Some(77));
assert_eq!(catalog.get::<i32>(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::<i32>(ObjectIdentifier::new(3, 0)), None);
assert_eq!(oracle.xref().get::<i32>(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(&current).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::<HayroDict<'_>>(ObjectIdentifier::new(1, 0)).unwrap();
assert_eq!(catalog.get::<i32>(b"Current"), Some(42));
assert_eq!(catalog.get::<i32>(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<Vec<u8>> {
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::<HayroDict<'_>>(ObjectIdentifier::new(1, 0)).unwrap();
assert_eq!(catalog.get::<i32>(b"Current"), Some(77));
assert_eq!(catalog.get::<i32>(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(&current).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(&current).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}");
}
}
}
+1
View File
@@ -3,6 +3,7 @@
mod bounded_writer;
mod corpus;
mod encryption;
mod generations;
mod proptests;
mod shared_streams;
mod writer;
+26 -5
View File
@@ -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]
+6
View File
@@ -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<bool, EditError> {
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,
+1 -1
View File
@@ -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)]
+70
View File
@@ -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<pdfcraft_content::Op>) -> Vec<pdfcraft_content::Op> {
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())
}
+120
View File
@@ -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::<Vec<_>>()))
};
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)));
+2 -1
View File
@@ -31,7 +31,8 @@ pub struct TextBlock {
/// Byte ranges in `editor_text` and their source line indexes.
pub spans: Vec<(std::ops::Range<usize>, 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,
}
+62 -11
View File
@@ -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<Op>,
pub(super) line_width: Option<f64>,
ext_gstate: Vec<Vec<u8>>,
ext_gstate: Vec<GsSelection>,
/// 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<Op> {
///
/// `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<Op> {
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(&current.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<f64>,
/// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]).
pub(super) ext_gstate: Vec<Vec<u8>>,
pub(super) ext_gstate: Vec<GsSelection>,
/// 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<Vec<u8>>, name: &[u8]) {
list.retain(|n| n != name);
fn select_gs(list: &mut Vec<GsSelection>, 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<u8>,
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<Op>, 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).
+32 -5
View File
@@ -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<Vec<Op>, EditError> {
if !block.invisible {
return Ok(Vec::new());
+30 -7
View File
@@ -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<usize>, Vec<bool>, Vec<usize>);
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::<Result<_, _>>()?;
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 })
}
+196 -39
View File
@@ -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<u8> = 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<Vec<u8>> {
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<Vec<u8>> {
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<Option<[f32; 3]>> = 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<Option<[f32; 3]>> = (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<f32> = (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<u8> {
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<Vec<u8>>
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<u8> = 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<bool> = 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<bool> = 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);
}
}
+1 -1
View File
@@ -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")),
],
+1
View File
@@ -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"),
+1 -1
View File
@@ -112,7 +112,7 @@ pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option<ObjRef> {
}
/// The largest raster image Form `form` draws through its resources (nested Forms included).
fn picture_in_form(cos: &Cos, form: ObjRef) -> Option<ObjRef> {
pub(crate) fn picture_in_form(cos: &Cos, form: ObjRef) -> Option<ObjRef> {
let mut seen = HashSet::from([form]);
let mut best: Option<(u64, ObjRef)> = None;
let res = resources(cos, form)?;
+28 -1
View File
@@ -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<bool>,
italic: Option<bool>,
},
/// 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<usize>,
index: usize,
bookmarks: Vec<pdfcraft_organize::OutlineNode>,
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<Vec<u8>>,
poster: Option<MarkFile>,
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<pdfcraft_ocr::PlacedWord>,
},
/// 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 {
+6
View File
@@ -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.
+18 -1
View File
@@ -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 } => {
+2
View File
@@ -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 { .. }
+10 -4
View File
@@ -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;
+19
View File
@@ -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<Vec<usize>, 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<Model3d, String> {
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())
}
}
+12
View File
@@ -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<pdfcraft_annot::PosterPicture, EditError> {
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()?;
+308
View File
@@ -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<usize>,
/// 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<usize>,
/// Scan images left as they were, and why.
pub skipped: Vec<SkippedImage>,
}
/// 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<usize>,
/// 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<usize>,
}
/// What cleaning scanned pages needs, independent of the session.
pub struct ScanCleanJob {
cos: pdfcraft_cos::Document,
targets: Vec<Target>,
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<ScanCleanJob, EditError> {
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<usize> = 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<Target> = 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<ScanCleanReport, EditError> {
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<ScanCleanReport, EditError> {
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<ScanCleaned, EditError> {
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<ScanCleaned, EditError> {
let ScanCleanJob { cos, targets, settings, generation, mut report } = self;
let total = targets.len();
let mut images = Vec::new();
let mut changed_pages: Vec<usize> = 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<Option<Stream>, 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;
+463
View File
@@ -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<u8> {
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<u8> {
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<u8> {
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<u8> {
scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true"), &scan_pixels(1))
}
fn open(bytes: Vec<u8>) -> (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<Object> {
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<Arc<Object>> = [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<Arc<Object>> = [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<usize> = before.data.iter().zip(&after.data).enumerate().filter(|(_, (a, b))| a != b).map(|(i, _)| i).collect();
let mut specks: Vec<usize> = 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<u8>); 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<u8> {
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"));
}
}
+80 -16
View File
@@ -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<Vec<(pdfcraft_edit::PageImage, f64)>> {
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<f64> {
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<f64> {
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.
+8
View File
@@ -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<Result<pdfcraft_organize::OutlineTree, pdfcraft_organize::OutlineError>> {
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() };
+111
View File
@@ -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<u8>| 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<f64> = 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<String> {
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());
}
+189
View File
@@ -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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<String> {
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");
}
}
+3
View File
@@ -234,6 +234,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
let mut list_picked = None;
let (sort_items, old_selection) = items_to_sort(doc, &f, props);
let parent_default = ancestor_sets(doc, f.obj, b"DV");
let parent_limit = ancestor_sets(doc, f.obj, b"MaxLen");
doc.update_dict(f.obj, |d| {
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
if let Some(t) = &props.tooltip {
@@ -246,6 +247,8 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some(m) = props.max_len {
match m {
Some(m) => 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");
}
+23
View File
@@ -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();
+6 -6
View File
@@ -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<Dict> {
@@ -419,7 +419,7 @@ pub(crate) fn components(doc: &Document, cs: &Object) -> Option<usize> {
}
}
fn is_dct(s: &Stream) -> Option<bool> {
pub(crate) fn is_dct(s: &Stream) -> Option<bool> {
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<usize> {
pub(crate) fn allowance(bytes: usize, rows: u32) -> Option<usize> {
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<usize> {
/// /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<bool> {
pub(crate) fn ycc_encoded(doc: &Document, s: &Stream) -> Option<bool> {
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<P: image::Pixel<Subpixel = u8> + 'static>(img: &image::ImageBuffer<P,
}
}
fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
pub(crate) fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
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<bool> {
/// 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)))
+2
View File
@@ -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 {
+312
View File
@@ -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<u8>,
/// 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<Samples, NotDecoded> {
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> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Stream> {
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<u8>) -> 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<u8> = (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<u8>| {
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());
}
}
+2
View File
@@ -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};
+12 -4
View File
@@ -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<usize>),
#[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<OutlineError> 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)))?;
}
+261
View File
@@ -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<usize>,
/// 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<u8>, Vec<Option<f64>>)>,
/// 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<OutlineNode>,
}
/// The tree [`outline_tree`] read.
#[derive(Clone, Debug, PartialEq)]
pub struct OutlineTree {
pub nodes: Vec<OutlineNode>,
/// 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<OutlineTree, OutlineError> {
// 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<OutlineNode>) {
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<u8>, mut values: Vec<Option<f64>>) -> Option<(Vec<u8>, Vec<Option<f64>>)> {
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<Vec<usize>> {
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<usize> {
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<usize> = 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<u8>, Vec<Option<f64>>)) -> 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<Vec<usize>, 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<ObjRef> = 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<Vec<ObjRef>, OutlineError> {
// Each written item with its node, then its children's items once they are written.
let mut written: Vec<(ObjRef, Vec<ObjRef>)> = 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<usize>)> = vec![(nodes, 0, None)];
while let Some(level) = stack.last_mut() {
let (list, parent) = (level.0, level.2);
let Some(node) = list.get(level.1) else {
stack.pop();
continue;
};
level.1 = level.1.saturating_add(1);
let item = doc.add(Object::Dict(item_dict(node, pages)));
match parent.and_then(|p| written.get_mut(p)) {
Some((_, kids)) => kids.push(item),
None => top.push(item),
}
written.push((item, Vec::new()));
if !node.children.is_empty() {
stack.push((&node.children, 0, Some(written.len().saturating_sub(1))));
}
}
for (item, kids) in &written {
relink(doc, *item, kids)?;
}
Ok(top)
}
/// The item's own entries; the links and `/Count`'s value come from [`relink`] and [`recount`]
/// (only the sign of the `/Count` written here matters: it keeps the open state).
fn item_dict(node: &OutlineNode, pages: &[ObjRef]) -> Dict {
let mut d = Dict::new();
d.set(b"Title".to_vec(), Object::String(PdfString::text(&node.title)));
if let Some(page) = node.page.and_then(|p| pages.get(p)) {
let (kind, values) = node.view.clone().unwrap_or_else(|| (b"XYZ".to_vec(), vec![None; 3]));
let mut dest = vec![Object::Ref(*page), Object::Name(kind)];
dest.extend(values.into_iter().map(|v| v.map_or(Object::Null, Object::Real)));
d.set(b"Dest".to_vec(), Object::Array(dest));
}
if let Some(c) = node.color.filter(|c| *c != [0.0; 3]) {
d.set(b"C".to_vec(), Object::Array(c.iter().map(|v| Object::Real(f64::from(*v))).collect()));
}
let flags = i64::from(node.italic) | i64::from(node.bold) << 1;
if flags != 0 {
d.set(b"F".to_vec(), Object::Int(flags));
}
if !node.children.is_empty() {
d.set(b"Count".to_vec(), Object::Int(if node.open { 1 } else { -1 }));
}
d
}
+172
View File
@@ -3813,3 +3813,175 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() {
ctrl_names.sort();
assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]);
}
/// Intro (closed, red bold, at a point) > [Part (italic, FitH), Figure (FitR)], then Results.
fn outline_source() -> Document {
let mut d = doc_a();
crate::add_bookmark_at(&mut d, &[], 0, "Intro", 0, Some(20.0), Some(380.0)).unwrap();
crate::add_bookmark(&mut d, &[0], 0, "Part", 1).unwrap();
crate::add_bookmark(&mut d, &[0], 1, "Figure", 2).unwrap();
crate::add_bookmark(&mut d, &[], 1, "Results", 2).unwrap();
crate::set_bookmark_color(&mut d, &[0], Some([1.0, 0.0, 0.0])).unwrap();
crate::set_bookmark_style(&mut d, &[0], Some(true), None).unwrap();
crate::set_bookmark_style(&mut d, &[0, 0], None, Some(true)).unwrap();
let pages = crate::walk(&d).unwrap();
let (part, figure) = (crate::bookmarks(&d)[1].obj, crate::bookmarks(&d)[2].obj);
let dest = |p: usize, rest: Vec<Object>| Object::Array([vec![Object::Ref(pages[p].0)], rest].concat());
d.update_dict(part, |x| x.set(b"Dest".to_vec(), dest(1, vec![Object::name("FitH"), Object::Real(300.0)]))).unwrap();
let fit_r = vec![Object::name("FitR"), Object::Int(10), Object::Int(20), Object::Int(110), Object::Int(220)];
d.update_dict(figure, |x| x.set(b"Dest".to_vec(), dest(2, fit_r))).unwrap();
crate::set_bookmark_open(&mut d, &[0], false).unwrap();
d
}
fn node(title: &str, page: Option<usize>, children: Vec<crate::OutlineNode>) -> crate::OutlineNode {
crate::OutlineNode { title: title.into(), page, view: None, color: None, italic: false, bold: false, open: true, children }
}
/// The `/Prev`, `/Last` and `/Parent` links agree with `/First`–`/Next` at every level.
fn assert_back_links(doc: &Document) {
let root = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap();
let mut stack = vec![root];
while let Some(parent) = stack.pop() {
let kids = crate::outline::children(doc, parent);
let d = doc.get(parent).as_dict().cloned().unwrap();
assert_eq!(d.reference(b"Last"), kids.last().copied());
for (i, k) in kids.iter().enumerate() {
let kd = doc.get(*k).as_dict().cloned().unwrap();
assert_eq!(kd.reference(b"Prev"), i.checked_sub(1).map(|p| kids[p]));
assert_eq!(kd.reference(b"Parent"), Some(parent));
}
stack.extend(kids);
}
}
#[test]
fn an_exported_outline_imports_into_another_document_unchanged() {
let source = outline_source();
let tree = crate::outline_tree(&source, &[]).unwrap();
assert!(!tree.truncated);
let intro = &tree.nodes[0];
assert_eq!((intro.title.as_str(), intro.page, intro.open, intro.bold, intro.color), ("Intro", Some(0), false, true, Some([1.0, 0.0, 0.0])));
assert_eq!(intro.view, Some((b"XYZ".to_vec(), vec![Some(20.0), Some(380.0), None])));
assert_eq!(intro.children[0].view, Some((b"FitH".to_vec(), vec![Some(300.0)])));
assert!(intro.children[0].italic);
assert_eq!(intro.children[1].view, Some((b"FitR".to_vec(), vec![Some(10.0), Some(20.0), Some(110.0), Some(220.0)])));
let mut target = doc_a();
assert_eq!(crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(), vec![0]);
assert_back_links(&target);
let back = full_roundtrip(&target);
assert_eq!(crate::outline_tree(&back, &[]).unwrap(), tree);
// The closed parent stays closed: its count is negative and the root counts only what shows.
let first = crate::bookmarks(&back)[0].obj;
let root = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap();
assert_eq!((count_of(&back, first), count_of(&back, root)), (Some(-2), Some(2)));
}
#[test]
fn a_subtree_exports_alone_and_a_missing_path_is_refused() {
let source = outline_source();
let tree = crate::outline_tree(&source, &[0]).unwrap();
assert_eq!(tree.nodes.len(), 1);
assert_eq!(tree.nodes[0].title, "Intro");
assert_eq!(tree.nodes[0].children.iter().map(|n| n.title.as_str()).collect::<Vec<_>>(), ["Part", "Figure"]);
let leaf = crate::outline_tree(&source, &[0, 1]).unwrap();
assert_eq!((leaf.nodes.len(), leaf.nodes[0].title.as_str()), (1, "Figure"));
assert_eq!(crate::outline_tree(&source, &[5]), Err(crate::OutlineError::NoSuchBookmark(vec![5])));
assert_eq!(crate::outline_tree(&doc_a(), &[]).unwrap().nodes, []);
}
#[test]
fn a_page_the_target_lacks_keeps_its_bookmark_without_a_destination() {
let tree = crate::outline_tree(&outline_source(), &[]).unwrap();
// doc_b has two pages: Figure and Results go to the third.
assert_eq!(crate::unmapped(&tree.nodes, 2), [vec![0, 1], vec![1]]);
let mut target = doc_b();
crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap();
let listed = crate::bookmarks(&target);
assert_eq!(titles(&listed), ["Intro[Part,Figure]", "Results"]);
assert_eq!(listed.iter().map(|b| b.page).collect::<Vec<_>>(), [Some(0), Some(1), None, None]);
assert!(target.get(listed[2].obj).as_dict().unwrap().get(b"Dest").is_none());
}
#[test]
fn a_fit_r_missing_a_value_copies_with_its_page_and_no_view() {
let mut src = outline_source();
let pages = crate::walk(&src).unwrap();
let figure = crate::bookmarks(&src)[2].obj;
let short = vec![Object::Ref(pages[2].0), Object::name("FitR"), Object::Int(10), Object::Int(20), Object::Int(110)];
src.update_dict(figure, |x| x.set(b"Dest".to_vec(), Object::Array(short))).unwrap();
let tree = crate::outline_tree(&src, &[]).unwrap();
let copied = &tree.nodes[0].children[1];
assert_eq!((copied.page, copied.view.clone()), (Some(2), None));
let mut target = doc_a();
crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap();
assert_eq!(titles(&crate::bookmarks(&target)), ["Intro[Part,Figure]", "Results"]);
}
#[test]
fn an_import_appends_inserts_or_replaces() {
let mut d = doc_a();
crate::add_bookmark(&mut d, &[], 0, "Old A", 0).unwrap();
crate::add_bookmark(&mut d, &[], 1, "Old B", 1).unwrap();
let new = [node("New", Some(2), vec![node("Child", Some(1), vec![])])];
assert_eq!(crate::import_outline(&mut d, &[], 1, &new, false).unwrap(), vec![1]);
assert_eq!(titles(&crate::bookmarks(&d)), ["Old A", "New[Child]", "Old B"]);
assert_eq!(crate::import_outline(&mut d, &[2], 9, &new, false).unwrap(), vec![2, 0]);
assert_eq!(titles(&crate::bookmarks(&d)), ["Old A", "New[Child]", "Old B[New[Child]]"]);
assert_back_links(&d);
assert_eq!(crate::import_outline(&mut d, &[], 5, &new, true).unwrap(), vec![0]);
assert_eq!(titles(&crate::bookmarks(&d)), ["New[Child]"]);
assert_back_links(&d);
let refused = crate::import_outline(&mut d, &[0], 0, &new, true);
assert!(matches!(refused, Err(crate::OutlineError::Organize(crate::OrganizeError::Invalid(_)))));
assert_eq!(crate::import_outline(&mut d, &[], 0, &[], false), Err(crate::OutlineError::NothingToImport));
assert_eq!(crate::import_outline(&mut d, &[7], 0, &new, false), Err(crate::OutlineError::NoSuchBookmark(vec![7])));
}
#[test]
fn a_bad_node_anywhere_refuses_the_whole_import() {
let mut d = doc_a();
crate::add_bookmark(&mut d, &[], 0, "Kept", 0).unwrap();
let before = crate::bookmarks(&d);
let with = |f: &dyn Fn(&mut crate::OutlineNode)| {
let mut child = node("Child", Some(0), vec![]);
f(&mut child);
vec![node("Fine", Some(0), vec![]), node("Parent", Some(0), vec![child])]
};
let bad_views: [(&[u8], Vec<Option<f64>>); 4] =
[(b"XYZ", vec![Some(1.0)]), (b"Zoom", vec![]), (b"FitH", vec![Some(f64::NAN)]), (b"FitR", vec![Some(0.0), None, Some(1.0), Some(1.0)])];
for (kind, values) in bad_views {
let nodes = with(&|n| n.view = Some((kind.to_vec(), values.clone())));
assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadView(vec![1, 0])), "{kind:?}");
}
let nodes = with(&|n| n.color = Some([0.5, 1.5, 0.0]));
assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadColor));
let nodes = with(&|n| n.color = Some([f32::NAN, 0.0, 0.0]));
assert_eq!(crate::import_outline(&mut d, &[], 0, &nodes, false), Err(crate::OutlineError::BadColor));
let mut deep = node("Leaf", None, vec![]);
for _ in 0..33 {
deep = node("Level", None, vec![deep]);
}
assert_eq!(crate::import_outline(&mut d, &[], 0, &[deep], false), Err(crate::OutlineError::TooDeep(33)));
// 33 levels fit at the top, but not under a bookmark: the deepest would be past the listing.
let mut chain = node("Leaf", None, vec![]);
for _ in 0..32 {
chain = node("Level", None, vec![chain]);
}
assert_eq!(crate::import_outline(&mut d, &[0], 0, std::slice::from_ref(&chain), false), Err(crate::OutlineError::TooDeep(33)));
let many = vec![node("Item", None, vec![]); crate::MAX_IMPORT_BOOKMARKS + 1];
assert_eq!(crate::import_outline(&mut d, &[], 0, &many, false), Err(crate::OutlineError::TooManyBookmarks(crate::MAX_IMPORT_BOOKMARKS)));
assert_eq!(crate::bookmarks(&d), before);
}
#[test]
fn an_outline_deeper_than_the_listing_is_exported_as_truncated() {
let tree = crate::outline_tree(&open(deep_outline(40)), &[]).unwrap();
assert!(tree.truncated);
assert!(!crate::outline_tree(&open(deep_outline(33)), &[]).unwrap().truncated);
// What was exported imports back: 33 levels is the deepest an import takes.
let mut target = doc_a();
crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap();
assert_eq!(crate::bookmarks(&target).len(), 33);
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "pdfcraft-prc-render"
description = "Headless CPU renderer for PRC 3D models: camera, depth-buffered triangle fill, headlight shading"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
pdfcraft-prc.workspace = true
thiserror.workspace = true
[dev-dependencies]
flate2.workspace = true
+29
View File
@@ -0,0 +1,29 @@
# pdfcraft-prc-render
A headless CPU renderer for PRC 3D models read by `pdfcraft-prc`: placed meshes in world space, a fitted camera, a
depth-buffered triangle fill and headlight shading, into a straight-RGBA image.
- **Layer:** L3. Depends on `pdfcraft-prc` only.
- **Licence:** MIT OR Apache-2.0.
- `#![forbid(unsafe_code)]`. Never panics on input: image size, triangle count and every index are checked, and
damage returns `RenderError`.
## API
```rust
assemble(&PrcFile) -> Result<Vec<WorldMesh>, RenderError> // every placement's mesh moved into the world and coloured
place(&TriangleMesh, &Matrix, colour, triangle_colours) -> WorldMesh
Camera::fit(&Bounds, direction, up, perspective, aspect) / Camera::fit_meshes(&[WorldMesh], ...)
render(&[WorldMesh], &Camera, &RenderOptions) -> Result<Image, RenderError>
```
- With no product tree, each tessellated mesh is drawn where it lies, in the default colour.
- A triangle's own colour wins over its mesh's, which wins over `RenderOptions::colour`.
- Opaque triangles are drawn first, then translucent ones blended over them; alpha 0 is not drawn.
- `Lighting::Headlight` (default) shades by the angle to the view direction; `Lighting::Unlit` draws flat colour.
- Perspective triangles are clipped at a near plane 1/10,000 of the eye-to-target distance from the eye; orthographic views are not clipped.
- `MAX_RENDER_PIXELS` (2^24, about 16 MP) bounds the image: at most 64 MB of RGBA plus 128 MB of depth.
`MAX_ASSEMBLED_TRIANGLES` and `MAX_ASSEMBLED_VERTICES` (2^24 each) bound one `assemble` call. `MAX_RENDER_WORK` (2^31) bounds
the pixel centres one `render` tests, summed over each triangle's clipped bounding box; past it the render returns
`RenderError::TooMuchWork`. That is still seconds of CPU at the top, so callers on a UI thread should render small or off-thread.
- Not yet: shadows, ambient occlusion, specular light, scene lights, textures, wires and markups.
+164
View File
@@ -0,0 +1,164 @@
//! A parsed PRC file's tessellations placed in the world, ready for [`crate::render`].
use crate::RenderError;
use crate::raster::channel;
use pdfcraft_prc::{Matrix, PrcFile, Rgba, Tessellation, TriangleMesh};
/// The most triangles [`assemble`] places, over every placement: a tessellation many
/// occurrences draw is copied once per occurrence.
pub const MAX_ASSEMBLED_TRIANGLES: usize = 1 << 24;
/// The most positions, normals and texture coordinates [`assemble`] copies, over every placement.
pub const MAX_ASSEMBLED_VERTICES: usize = 1 << 24;
/// A triangle mesh in world coordinates with the colours it draws in.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct WorldMesh {
/// The triangles, already placed.
pub mesh: TriangleMesh,
/// The mesh's straight-RGBA colour; `None` takes [`crate::RenderOptions::colour`], opaque.
pub colour: Option<[u8; 4]>,
/// Per triangle, its own colour when its face is styled; `None` (the whole list or an entry)
/// takes [`Self::colour`].
pub triangle_colours: Option<Vec<Option<[u8; 4]>>>,
}
impl WorldMesh {
/// `mesh`, uncoloured.
#[must_use]
pub fn new(mesh: TriangleMesh) -> Self {
WorldMesh { mesh, colour: None, triangle_colours: None }
}
/// The colour triangle `t` draws in, before the render options' default.
pub(crate) fn colour_of(&self, t: usize) -> Option<[u8; 4]> {
self.triangle_colours.as_ref().and_then(|c| c.get(t).copied().flatten()).or(self.colour)
}
}
/// `mesh` moved into the world by `matrix`, drawn in `colour` and, per triangle, `triangle_colours`.
#[must_use]
pub fn place(mesh: &TriangleMesh, matrix: &Matrix, colour: Option<[u8; 4]>, triangle_colours: Option<Vec<Option<[u8; 4]>>>) -> WorldMesh {
WorldMesh { mesh: mesh.transformed(matrix), colour, triangle_colours }
}
/// Every triangle mesh `prc` draws, in world coordinates and coloured as its product tree says
/// ([`PrcFile::placements`]). A file with no tree draws each file structure's meshes where they
/// are, uncoloured. Wires, markups and compressed tessellations are not drawn.
///
/// # Errors
/// [`RenderError::Model`] when a tessellation section or the tree cannot be read, or the tree
/// places a tessellation its file structure does not have; [`RenderError::TooManyTriangles`] past
/// [`MAX_ASSEMBLED_TRIANGLES`] and [`RenderError::TooManyVertices`] past [`MAX_ASSEMBLED_VERTICES`],
/// counting both what is decoded and what is placed.
pub fn assemble(prc: &PrcFile) -> Result<Vec<WorldMesh>, RenderError> {
let tessellations = decode(prc.file_structures.iter().map(|fs| fs.tessellations()), Budget::default())?;
let mut budget = Budget::default();
let mut out = Vec::new();
if !prc.stores_tree() {
for mesh in tessellations.iter().flatten().filter_map(as_mesh) {
budget.spend(mesh)?;
out.push(WorldMesh::new(mesh.clone()));
}
return Ok(out);
}
let items = prc.placements()?.into_iter().map(|p| Item {
file_structure: p.file_structure,
tessellation: p.tessellation,
matrix: p.matrix,
colour: p.colour,
triangle_colours: Box::new(move |mesh: &TriangleMesh| p.triangle_colours(mesh)),
});
place_items(&tessellations, items, &mut budget, &mut out)?;
Ok(out)
}
/// Each file structure's tessellations, decoded one structure at a time and charged to `budget`
/// as they are, so decoding stops at the structure that passes a ceiling.
pub(crate) fn decode(
structures: impl IntoIterator<Item = Result<Vec<Tessellation>, pdfcraft_prc::PrcError>>,
mut budget: Budget,
) -> Result<Vec<Vec<Tessellation>>, RenderError> {
let mut out = Vec::new();
for tessellations in structures {
let tessellations = tessellations?;
for mesh in tessellations.iter().filter_map(as_mesh) {
budget.spend(mesh)?;
}
out.push(tessellations);
}
Ok(out)
}
/// One placement as [`place_items`] reads it: a tessellation by file structure and index, where
/// it goes and how it is coloured.
pub(crate) struct Item<'a> {
pub(crate) file_structure: usize,
pub(crate) tessellation: usize,
pub(crate) matrix: Matrix,
pub(crate) colour: Option<Rgba>,
pub(crate) triangle_colours: Box<FaceColours<'a>>,
}
/// Per triangle of a mesh, the colour its face's style resolves to.
type FaceColours<'a> = dyn Fn(&TriangleMesh) -> Option<Vec<Option<Rgba>>> + 'a;
/// Each item's mesh moved into the world and coloured, appended to `out`. Items naming a wire,
/// markup or compressed tessellation are skipped; an item naming no tessellation at all is
/// [`PrcError::Malformed`](pdfcraft_prc::PrcError::Malformed).
pub(crate) fn place_items<'a>(
tessellations: &[Vec<Tessellation>],
items: impl IntoIterator<Item = Item<'a>>,
budget: &mut Budget,
out: &mut Vec<WorldMesh>,
) -> Result<(), RenderError> {
for p in items {
let Some(tessellation) = tessellations.get(p.file_structure).and_then(|t| t.get(p.tessellation)) else {
let what = format!("tessellation {} of file structure {}, which has none by that index", p.tessellation, p.file_structure);
return Err(pdfcraft_prc::PrcError::Malformed(what).into());
};
let Some(mesh) = as_mesh(tessellation) else {
continue;
};
budget.spend(mesh)?;
let triangle_colours = (p.triangle_colours)(mesh).map(|c| c.into_iter().map(|c| c.map(bytes)).collect());
out.push(place(mesh, &p.matrix, p.colour.map(bytes), triangle_colours));
}
Ok(())
}
/// Triangles and vertex entries (positions, normals, texture coordinates) placed so far.
#[derive(Debug, Default)]
pub(crate) struct Budget {
pub(crate) triangles: usize,
pub(crate) vertices: usize,
}
impl Budget {
fn spend(&mut self, mesh: &TriangleMesh) -> Result<(), RenderError> {
let triangles =
self.triangles.checked_add(mesh.triangles.len()).filter(|&n| n <= MAX_ASSEMBLED_TRIANGLES).ok_or(RenderError::TooManyTriangles)?;
let vertices = [mesh.positions.len(), mesh.normals.len(), mesh.uvs.len()]
.into_iter()
.try_fold(self.vertices, usize::checked_add)
.filter(|&n| n <= MAX_ASSEMBLED_VERTICES)
.ok_or(RenderError::TooManyVertices)?;
(self.triangles, self.vertices) = (triangles, vertices);
Ok(())
}
}
fn as_mesh(t: &Tessellation) -> Option<&TriangleMesh> {
match t {
Tessellation::Mesh(m) => Some(m),
_ => None,
}
}
/// A 0–1 colour as straight-RGBA bytes, each channel clamped.
fn bytes(c: Rgba) -> [u8; 4] {
[c.r, c.g, c.b, c.a].map(|v| channel(v.clamp(0.0, 1.0) * 255.0))
}
#[cfg(test)]
mod tests;
+127
View File
@@ -0,0 +1,127 @@
#![allow(clippy::unwrap_used)]
use super::*;
fn triangle() -> TriangleMesh {
let mut m = TriangleMesh::default();
m.positions = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
m.triangles = vec![[0, 1, 2]];
m
}
#[test]
fn a_placement_moves_its_mesh_into_the_world() {
let mut shift = Matrix::IDENTITY;
shift.0[0][3] = 5.0;
shift.0[2][3] = -2.0;
let placed = place(&triangle(), &shift, Some([1, 2, 3, 255]), None);
assert_eq!(placed.mesh.positions, [[5.0, 0.0, -2.0], [6.0, 0.0, -2.0], [5.0, 1.0, -2.0]]);
assert_eq!(placed.colour, Some([1, 2, 3, 255]));
// A mirror keeps the triangle facing the same way round.
let mut mirror = Matrix::IDENTITY;
mirror.0[0][0] = -1.0;
assert_eq!(place(&triangle(), &mirror, None, None).mesh.triangles, [[0, 2, 1]]);
}
#[test]
fn a_triangle_colour_wins_over_the_mesh_colour() {
let mut m = place(&triangle(), &Matrix::IDENTITY, Some([9, 9, 9, 255]), Some(vec![Some([1, 1, 1, 255]), None]));
m.mesh.triangles.push([0, 2, 1]);
m.mesh.triangles.push([1, 2, 0]);
assert_eq!(m.colour_of(0), Some([1, 1, 1, 255]));
assert_eq!(m.colour_of(1), Some([9, 9, 9, 255]), "an unstyled face takes the mesh's colour");
assert_eq!(m.colour_of(2), Some([9, 9, 9, 255]), "past the list too");
assert_eq!(WorldMesh::new(triangle()).colour_of(0), None);
}
#[test]
fn colours_are_clamped_into_bytes() {
let [r, g, b, a] = [0.0, 1.0, 0.5, 2.0];
let mut c = Rgba::default();
(c.r, c.g, c.b, c.a) = (r, g, b, a);
assert_eq!(bytes(c), [0, 255, 128, 255]);
c.r = f64::NAN;
c.g = -3.0;
assert_eq!(bytes(c)[..2], [0, 0]);
}
#[test]
fn the_triangle_budget_is_shared_and_refuses_past_the_ceiling() {
let mut b = Budget { triangles: MAX_ASSEMBLED_TRIANGLES - 1, vertices: 0 };
assert!(b.spend(&triangle()).is_ok());
assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles));
let mut b = Budget { triangles: usize::MAX, vertices: 0 };
assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyTriangles));
}
#[test]
fn the_vertex_budget_counts_positions_normals_and_uvs() {
let mut heavy = triangle();
heavy.normals = vec![[0.0, 0.0, 1.0]; 2];
heavy.uvs = vec![[0.0; 2]; 4];
let mut b = Budget::default();
b.spend(&heavy).unwrap();
assert_eq!((b.triangles, b.vertices), (1, 9));
// One triangle over many vertices: the triangle ceiling alone would let it through.
let mut b = Budget { triangles: 0, vertices: MAX_ASSEMBLED_VERTICES - 8 };
assert_eq!(b.spend(&heavy), Err(RenderError::TooManyVertices));
assert_eq!((b.triangles, b.vertices), (0, MAX_ASSEMBLED_VERTICES - 8), "a refused mesh spends nothing");
let mut b = Budget { triangles: 0, vertices: usize::MAX };
assert_eq!(b.spend(&triangle()), Err(RenderError::TooManyVertices));
}
fn item<'a>(fs: usize, tess: usize, x: f64, colour: Option<Rgba>) -> Item<'a> {
let mut matrix = Matrix::IDENTITY;
matrix.0[0][3] = x;
Item { file_structure: fs, tessellation: tess, matrix, colour, triangle_colours: Box::new(|_| None) }
}
#[test]
fn each_placement_draws_its_tessellation_where_and_in_the_colour_the_tree_says() {
let tessellations = vec![vec![Tessellation::Mesh(triangle())]];
let mut red = Rgba::default();
red.r = 1.0;
red.a = 1.0;
// Two occurrences of one tessellation at different places, one coloured.
let items = [item(0, 0, 3.0, Some(red)), item(0, 0, -4.0, None)];
let (mut budget, mut out) = (Budget::default(), Vec::new());
place_items(&tessellations, items, &mut budget, &mut out).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].mesh.positions, [[3.0, 0.0, 0.0], [4.0, 0.0, 0.0], [3.0, 1.0, 0.0]]);
assert_eq!(out[0].colour, Some([255, 0, 0, 255]));
assert_eq!(out[1].mesh.positions, [[-4.0, 0.0, 0.0], [-3.0, 0.0, 0.0], [-4.0, 1.0, 0.0]]);
assert_eq!(out[1].colour, None);
assert_eq!(budget.triangles, 2, "each occurrence spends its triangles");
}
#[test]
fn a_placement_naming_a_missing_tessellation_is_malformed() {
let tessellations = vec![vec![Tessellation::Mesh(triangle())]];
for (fs, tess) in [(1, 0), (0, 7)] {
let (mut budget, mut out) = (Budget::default(), Vec::new());
let r = place_items(&tessellations, [item(fs, tess, 0.0, None)], &mut budget, &mut out);
assert!(matches!(r, Err(RenderError::Model(pdfcraft_prc::PrcError::Malformed(_)))), "{fs}/{tess}: {r:?}");
}
}
#[test]
fn a_placement_past_the_triangle_budget_is_an_error() {
let tessellations = vec![vec![Tessellation::Mesh(triangle())]];
let (mut budget, mut out) = (Budget { triangles: MAX_ASSEMBLED_TRIANGLES, vertices: 0 }, Vec::new());
let r = place_items(&tessellations, [item(0, 0, 0.0, None)], &mut budget, &mut out);
assert!(matches!(r, Err(RenderError::TooManyTriangles)));
assert!(out.is_empty());
}
#[test]
fn decoding_stops_at_the_structure_that_passes_the_ceiling() {
let pulled = std::cell::Cell::new(0);
let structures = std::iter::repeat_with(|| {
pulled.set(pulled.get() + 1);
Ok(vec![Tessellation::Mesh(triangle())])
})
.take(3);
let nearly_full = Budget { triangles: MAX_ASSEMBLED_TRIANGLES - 1, vertices: 0 };
assert_eq!(decode(structures, nearly_full).err(), Some(RenderError::TooManyTriangles));
assert_eq!(pulled.get(), 2, "the third structure is never decoded");
}
+18
View File
@@ -0,0 +1,18 @@
//! Headless CPU rendering of PRC 3D models (ISO 14739-1) to an RGBA image.
//!
//! Layer L3: depends only on `pdfcraft-prc`. [`assemble`] turns a parsed [`pdfcraft_prc::PrcFile`]
//! into world-space meshes; [`render`] draws them from a [`Camera`] with one depth-tested sample per
//! pixel centre and a light at the camera. Single-threaded and GUI-free, so it runs unchanged on wasm32.
//! The image size is capped by [`MAX_RENDER_PIXELS`], the drawing work by [`MAX_RENDER_WORK`] and the
//! assembled model by [`MAX_ASSEMBLED_TRIANGLES`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an
//! error, never a panic.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod assemble;
mod raster;
mod render;
mod vec3;
pub use assemble::{MAX_ASSEMBLED_TRIANGLES, MAX_ASSEMBLED_VERTICES, WorldMesh, assemble, place};
pub use render::{Bounds, Camera, Image, Lighting, MAX_RENDER_PIXELS, MAX_RENDER_WORK, Projection, RenderError, RenderOptions, render};
+321
View File
@@ -0,0 +1,321 @@
//! Projection, near-plane clipping and the depth-tested triangle fill behind [`crate::render`].
use crate::render::{Camera, Projection, RenderError};
use crate::vec3::{length, sub};
/// One view-space corner: x, y, z (z along the view direction), then its red, green and blue shade.
pub(crate) type ViewCorner = [f64; 6];
/// One screen-space corner: pixel x, pixel y and depth key, and its shade.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Corner {
pub(crate) at: [f64; 3],
pub(crate) shade: [f64; 3],
}
/// The perspective near plane, as a fraction of the eye-to-target distance.
pub(crate) const NEAR_FRACTION: f64 = 1e-4;
pub(crate) enum View {
/// `focal` = 1 / tan(fov_y / 2); `near` = the clip distance.
Perspective {
focal: f64,
aspect: f64,
near: f64,
},
Orthographic {
half_height: f64,
aspect: f64,
},
}
impl View {
/// The projection for `camera` at width/height `aspect`; refuses a field of view outside
/// (0, 180) degrees or a non-positive orthographic height.
pub(crate) fn new(camera: &Camera, aspect: f64) -> Result<View, RenderError> {
match camera.projection {
Projection::Perspective { fov_y } => {
if !(fov_y > 0.0 && fov_y < 180.0) {
return Err(RenderError::Camera("the field of view is not between 0 and 180 degrees"));
}
let focal = 1.0 / (fov_y.to_radians() / 2.0).tan();
// A field of view too small to measure leaves no finite focal length.
if !focal.is_finite() {
return Err(RenderError::Camera("the field of view is too small"));
}
let distance = length(sub(camera.target, camera.eye));
Ok(View::Perspective { focal, aspect, near: (distance * NEAR_FRACTION).max(f64::MIN_POSITIVE) })
}
Projection::Orthographic { height } => {
if !(height.is_finite() && height > 0.0) {
return Err(RenderError::Camera("the orthographic height is not positive"));
}
Ok(View::Orthographic { half_height: height / 2.0, aspect })
}
}
}
/// The part of `tri` in front of the near plane (Sutherland–Hodgman against one plane):
/// 0, 3 or 4 corners, the shade interpolated along each cut edge.
pub(crate) fn clip(&self, tri: [ViewCorner; 3]) -> Vec<ViewCorner> {
let View::Perspective { near, .. } = *self else {
return tri.to_vec();
};
let mut out = Vec::with_capacity(4);
for (k, &p) in tri.iter().enumerate() {
let q = tri.get((k + 1) % 3).copied().unwrap_or(p);
let z = |v: ViewCorner| v.get(2).copied().unwrap_or(0.0);
let (p_in, q_in) = (z(p) >= near, z(q) >= near);
if p_in {
out.push(p);
}
if p_in != q_in {
// Cut from the same end whichever way round the edge is met, so two triangles
// sharing it get the same cut point.
let (s, e) = if first(&p, &q) { (p, q) } else { (q, p) };
let t = (near - z(s)) / (z(e) - z(s));
let mut cut = s;
for (c, d) in cut.iter_mut().zip(e) {
*c += t * (d - *c);
}
out.push(cut);
}
}
out
}
/// The screen corner of view corner `v`: pixel x, pixel y and a depth key that is linear
/// in screen space and larger for nearer points.
pub(crate) fn project(&self, [x, y, z, r, g, b]: ViewCorner, width: u32, height: u32) -> Corner {
let (nx, ny, key) = match *self {
// Divided before multiplied, so an on-screen point's intermediate products stay finite.
View::Perspective { focal, aspect, .. } => (x / z * focal / aspect, y / z * focal, 1.0 / z),
View::Orthographic { half_height, aspect } => (x / half_height / aspect, y / half_height, -z),
};
Corner { at: [(nx + 1.0) / 2.0 * f64::from(width), (1.0 - ny) / 2.0 * f64::from(height), key], shade: [r, g, b] }
}
}
/// Whether view corner `a`'s position comes before `b`'s, ordering x, then y, then z.
fn first(a: &ViewCorner, b: &ViewCorner) -> bool {
a.iter().zip(b).take(3).map(|(p, q)| p.total_cmp(q)).find(|o| o.is_ne()).is_none_or(std::cmp::Ordering::is_lt)
}
/// The image being drawn: `rgba` holds `width * height` straight-RGBA pixels and `depth` their
/// depth keys, row by row from the top.
pub(crate) struct Target<'b> {
/// Depth keys are 1/z (a perspective view), so the shade is interpolated perspective-correct.
pub(crate) perspective: bool,
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) rgba: &'b mut [u8],
pub(crate) depth: &'b mut [f64],
/// Pixel centres the fills may still test.
pub(crate) work: u64,
}
impl Target<'_> {
/// Fill the screen triangle of `corners` with straight-RGBA `colour`, depth-tested at pixel
/// centres, the shade interpolated across it. A pixel centre on an edge two triangles share
/// belongs to one of them only. An opaque pixel replaces the pixel and its depth; a
/// translucent one is blended over it, leaving the depth. The clipped bounding box's pixel
/// centres are charged to [`Self::work`] first: [`RenderError::TooMuchWork`], with nothing
/// drawn, when too few are left.
pub(crate) fn fill(&mut self, corners: [Corner; 3], colour: [u8; 4]) -> Result<(), RenderError> {
let [a, b, c] = corners;
let area = edge(a.at, b.at, c.at);
let edges = [(b.at, c.at), (c.at, a.at), (a.at, b.at)];
if !area.is_finite() || area.abs() < 1e-12 {
return Ok(());
}
let Some((x0, x1, y0, y1)) = self.bounds(&corners) else {
return Ok(());
};
let span = |lo: u32, hi: u32| u64::from(hi.saturating_sub(lo)).saturating_add(1);
self.work = self.work.checked_sub(span(x0, x1).saturating_mul(span(y0, y1))).ok_or(RenderError::TooMuchWork)?;
for y in y0..=y1 {
for x in x0..=x1 {
let p = [f64::from(x) + 0.5, f64::from(y) + 0.5, 0.0];
let w = edges.map(|(from, to)| edge(from, to, p) / area);
if w.iter().zip(edges).any(|(&k, (from, to))| k < 0.0 || (k == 0.0 && !owns(from, to, area))) {
continue;
}
let mix = |f: &dyn Fn(&Corner) -> f64| w.iter().zip(&corners).map(|(k, c)| k * f(c)).sum::<f64>();
let key = mix(&|c| at(c.at, 2));
let Some(i) = pixel_index(self.width, x, y) else {
continue;
};
if self.depth.get(i).is_none_or(|&d| key <= d) {
continue;
}
let shade = if a.shade == b.shade && b.shade == c.shade {
a.shade
} else if self.perspective && key > 0.0 {
// Screen weights times 1/z, renormalised, are the surface's own weights.
[0, 1, 2].map(|j| mix(&|c| at(c.shade, j) * at(c.at, 2)) / key)
} else {
[0, 1, 2].map(|j| mix(&|c| at(c.shade, j)))
};
self.blend(i, key, shaded(colour, shade));
}
}
Ok(())
}
/// The pixel rows and columns `corners` may cover, clipped to the image; `None` when off it.
fn bounds(&self, corners: &[Corner; 3]) -> Option<(u32, u32, u32, u32)> {
let lo = |i: usize| corners.iter().map(|c| at(c.at, i)).fold(f64::INFINITY, f64::min).floor().max(0.0);
let hi = |i: usize, limit: u32| corners.iter().map(|c| at(c.at, i)).fold(f64::NEG_INFINITY, f64::max).ceil().min(f64::from(limit) - 1.0);
let (x0, x1) = (lo(0), hi(0, self.width));
let (y0, y1) = (lo(1), hi(1, self.height));
// Each bound is finite and within 0..limit here, so the casts are exact.
(x0 <= x1 && y0 <= y1).then_some((x0 as u32, x1 as u32, y0 as u32, y1 as u32))
}
/// Write straight-RGBA `rgba` at pixel `i`: replacing it and its depth when opaque, blended
/// over it otherwise.
fn blend(&mut self, i: usize, key: f64, rgba: [u8; 4]) {
let opaque = rgba[3] == 255;
if opaque && let Some(d) = self.depth.get_mut(i) {
*d = key;
}
let Some(px) = i.checked_mul(4).and_then(|s| self.rgba.get_mut(s..s.checked_add(4)?)) else {
return;
};
if opaque {
px.copy_from_slice(&rgba);
} else if let [pr, pg, pb, pa] = px {
let [sr, sg, sb, sa] = rgba;
let src = f64::from(sa) / 255.0;
let dst = f64::from(*pa) / 255.0 * (1.0 - src);
let out = src + dst;
if out <= 0.0 {
return;
}
for (p, s) in [pr, pg, pb].into_iter().zip([sr, sg, sb]) {
*p = channel((f64::from(s) * src + f64::from(*p) * dst) / out);
}
*pa = channel(out * 255.0);
}
}
}
/// Pixel `x, y`'s index in an image `width` wide; `None` on overflow.
pub(crate) fn pixel_index(width: u32, x: u32, y: u32) -> Option<usize> {
usize::try_from(y).ok()?.checked_mul(usize::try_from(width).ok()?)?.checked_add(usize::try_from(x).ok()?)
}
/// `v` rounded and clamped to a colour channel; NaN is 0.
pub(crate) fn channel(v: f64) -> u8 {
// Clamped to 0..=255 first, so the cast is exact (NaN casts to 0).
v.round().clamp(0.0, 255.0) as u8
}
/// `colour`'s RGB scaled channel by channel by `shade`, saturating at 255; alpha kept.
fn shaded([r, g, b, a]: [u8; 4], [sr, sg, sb]: [f64; 3]) -> [u8; 4] {
let [r, g, b] = [(r, sr), (g, sg), (b, sb)].map(|(v, s)| channel(f64::from(v) * s));
[r, g, b, a]
}
/// Twice the signed area of (a, b, p) in the xy plane.
pub(crate) fn edge(a: [f64; 3], b: [f64; 3], p: [f64; 3]) -> f64 {
// Two triangles sharing an edge meet it in opposite orders; evaluating it in one canonical
// order makes their values exact negatives, so a pixel centre on the edge cannot round out
// of both and leave a crack.
if (at(a, 0), at(a, 1)) > (at(b, 0), at(b, 1)) {
return -edge(b, a, p);
}
(at(b, 0) - at(a, 0)) * (at(p, 1) - at(a, 1)) - (at(b, 1) - at(a, 1)) * (at(p, 0) - at(a, 0))
}
/// Whether a pixel centre exactly on the edge `from`-`to` of a triangle of signed doubled area
/// `area` is the triangle's: the side the triangle grows towards, +x first, then +y. Two
/// triangles sharing the edge lie on opposite sides of it, so exactly one owns it.
fn owns(from: [f64; 3], to: [f64; 3], area: f64) -> bool {
let s = area.signum();
let (gx, gy) = (-(at(to, 1) - at(from, 1)) * s, (at(to, 0) - at(from, 0)) * s);
gx > 0.0 || (gx == 0.0 && gy > 0.0)
}
/// Component `i` of `v`, 0.0 out of range.
pub(crate) fn at(v: [f64; 3], i: usize) -> f64 {
v.get(i).copied().unwrap_or(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_edge_seen_from_either_triangle_is_exactly_negated() {
// So no pixel centre on a shared edge is rejected by both.
let vals = [0.1, 0.7, 1.3, 2.9, 3.3, 5.5];
for (a, b, p) in [([0.1, 0.1], [0.7, 0.7], [0.1, 2.9]), ([5.5, 0.7], [1.3, 3.3], [2.9, 0.1])]
.into_iter()
.chain(vals.iter().map(|&v| ([v, 0.1], [0.7, v], [v, v])))
{
let [a, b, p] = [a, b, p].map(|[x, y]| [x, y, 0.0]);
assert_eq!(edge(a, b, p), -edge(b, a, p), "{a:?} {b:?} {p:?}");
}
}
#[test]
fn pixel_indices_and_channels_never_overflow() {
assert_eq!(pixel_index(10, 3, 2), Some(23));
assert_eq!(channel(f64::NAN), 0);
assert_eq!(channel(1e300), 255);
assert_eq!(channel(-4.0), 0);
assert_eq!(channel(127.5), 128);
}
#[test]
fn a_shared_edge_is_cut_at_the_same_point_from_either_triangle() {
// Cutting this edge from P towards Q and from Q towards P rounds differently.
let view = View::Perspective { focal: 1.0, aspect: 1.0, near: 0.5 };
let (p, q) = ([0.1, 0.1, 0.2, 1.0, 1.0, 1.0], [0.7, 0.2, 1.6, 1.0, 1.0, 1.0]);
let one = view.clip([p, q, [0.0, 3.0, 2.0, 1.0, 1.0, 1.0]]);
let other = view.clip([q, p, [3.0, 0.0, 2.0, 1.0, 1.0, 1.0]]);
let shared = one.iter().filter(|a| other.iter().any(|b| a[..3] == b[..3])).count();
assert_eq!(shared, 2, "Q and the cut on PQ: {one:?} {other:?}");
}
fn corner(x: f64, y: f64, key: f64, shade: f64) -> Corner {
Corner { at: [x, y, key], shade: [shade; 3] }
}
#[test]
fn a_pixel_on_an_edge_two_triangles_share_is_blended_once() {
let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]);
let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: u64::MAX };
let [a, b, c, d] = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)].map(|(x, y)| corner(x, y, 1.0, 1.0));
t.fill([a, b, c], [255, 0, 0, 128]).unwrap();
t.fill([a, c, d], [255, 0, 0, 128]).unwrap();
// The diagonal runs through four pixel centres; each must look like the rest.
for px in rgba.chunks(4) {
assert_eq!(px, [255, 0, 0, 128]);
}
}
#[test]
fn a_perspective_shade_is_interpolated_on_the_surface_not_the_screen() {
let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]);
let mut t = Target { perspective: true, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: u64::MAX };
// The x = 4 corner is three times as far away as the others, and the only lit one.
t.fill([corner(0.0, 0.0, 1.0, 0.0), corner(4.0, 0.0, 1.0 / 3.0, 1.0), corner(0.0, 4.0, 1.0, 0.0)], [240, 240, 240, 255]).unwrap();
// At screen (1.5, 0.5) the weights are 0.5, 0.375, 0.125: on the surface the far corner
// counts 0.125 / 0.75 = 1/6, where on the screen it would count 0.375.
assert_eq!(rgba.get(4..8), Some(&[40, 40, 40, 255][..]));
}
#[test]
fn fills_stop_when_the_work_allowance_is_spent() {
let (mut rgba, mut depth) = (vec![0; 64], vec![f64::NEG_INFINITY; 16]);
// Each fill's box is the whole 4x4 image: 16 pixel centres.
let mut t = Target { perspective: false, width: 4, height: 4, rgba: &mut rgba, depth: &mut depth, work: 40 };
let tri = [corner(0.0, 0.0, 1.0, 1.0), corner(4.0, 0.0, 1.0, 1.0), corner(0.0, 4.0, 1.0, 1.0)];
assert_eq!(t.fill(tri, [255, 0, 0, 255]), Ok(()));
assert_eq!(t.fill(tri, [255, 0, 0, 255]), Ok(()));
assert_eq!(t.fill(tri, [255, 0, 0, 255]), Err(RenderError::TooMuchWork));
assert_eq!(t.work, 8);
}
}
+388
View File
@@ -0,0 +1,388 @@
//! The camera, the render options and [`render`]: world meshes to an RGBA image.
//!
//! One sample per pixel centre, no antialiasing. Faces are lit on both sides because a
//! producer's winding is not trusted to face outward.
use crate::WorldMesh;
use crate::raster::{Corner, Target, View, ViewCorner, at};
use crate::vec3::{cross, dot, length, normalize, scale, span, sub, unit_cross};
use pdfcraft_prc::TriangleMesh;
/// The largest image [`render`] draws, in pixels (width × height).
pub const MAX_RENDER_PIXELS: u64 = 16 * 1024 * 1024;
/// The most pixel centres one [`render`] tests, summed over each drawn triangle's bounding box
/// clipped to the image: 128 full layers of the largest image.
pub const MAX_RENDER_WORK: u64 = 1 << 31;
/// [`Camera::fit`]'s framed extent over the model's: 5% clear on each side.
const FIT_MARGIN: f64 = 1.1;
/// How the camera projects the scene onto the image.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Projection {
/// A pinhole camera with this vertical field of view, in degrees (exclusive range 0–180).
Perspective {
/// Vertical field of view, degrees.
fov_y: f64,
},
/// A parallel projection showing this much of the scene vertically, in model units.
Orthographic {
/// Visible height, model units.
height: f64,
},
}
/// Where the camera is and where it looks, in world coordinates.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Camera {
/// The camera position.
pub eye: [f64; 3],
/// The point at the image centre.
pub target: [f64; 3],
/// The direction that appears upward; must not be parallel to the view direction.
pub up: [f64; 3],
/// The projection.
pub projection: Projection,
}
/// An axis-aligned box around every point a set of meshes draws.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bounds {
/// The smallest x, y and z.
pub min: [f64; 3],
/// The largest x, y and z.
pub max: [f64; 3],
}
impl Bounds {
/// The box around every finite vertex the meshes' drawn triangles use, or `None` when there
/// is none.
#[must_use]
pub fn of(meshes: &[WorldMesh]) -> Option<Bounds> {
let mut out: Option<Bounds> = None;
for p in vertices(meshes) {
let b = out.get_or_insert(Bounds { min: p, max: p });
for ((lo, hi), c) in b.min.iter_mut().zip(b.max.iter_mut()).zip(p) {
*lo = lo.min(c);
*hi = hi.max(c);
}
}
out
}
/// The box's centre, finite for any finite box.
#[must_use]
pub fn centre(&self) -> [f64; 3] {
[0, 1, 2].map(|i| at(self.min, i) / 2.0 + at(self.max, i) / 2.0)
}
/// Half the box's diagonal: the radius of a sphere holding it.
#[must_use]
pub fn radius(&self) -> f64 {
length(sub(self.max, self.min)) / 2.0
}
}
/// Every corner of the triangles [`render`] draws: those not fully transparent whose three
/// corners exist and are finite.
fn vertices(meshes: &[WorldMesh]) -> impl Iterator<Item = [f64; 3]> + Clone + '_ {
meshes
.iter()
.flat_map(|m| m.mesh.triangles.iter().enumerate().filter(|&(t, _)| !invisible(m, t)).filter_map(|(_, tri)| corners(&m.mesh, tri)).flatten())
}
/// Whether triangle `t` of `m` is fully transparent, so [`render`] does not draw it.
fn invisible(m: &WorldMesh, t: usize) -> bool {
m.colour_of(t).is_some_and(|c| c[3] == 0)
}
impl Camera {
/// A camera looking along `direction` that fits `bounds` in an image of the given aspect
/// ratio (width / height), with a 30° perspective or an orthographic projection: the box's
/// eight corners framed from this direction, about 5% clear on each side of the tighter axis.
///
/// # Errors
/// [`RenderError::Camera`] when `direction` is zero or parallel to `up`, or `aspect` is not positive.
pub fn fit(bounds: &Bounds, direction: [f64; 3], up: [f64; 3], perspective: bool, aspect: f64) -> Result<Camera, RenderError> {
let corners = (0..8).map(|k| [0, 1, 2].map(|i| if k >> i & 1 == 0 { at(bounds.min, i) } else { at(bounds.max, i) }));
Camera::frame(bounds, corners, direction, up, (perspective, aspect))
}
/// As [`Camera::fit`], framing every vertex the meshes' drawn triangles use rather than their box,
/// so an oblique view is not padded by empty box corners.
///
/// # Errors
/// As [`Camera::fit`], and when the meshes have no finite vertex.
pub fn fit_meshes(meshes: &[WorldMesh], direction: [f64; 3], up: [f64; 3], perspective: bool, aspect: f64) -> Result<Camera, RenderError> {
let bounds = Bounds::of(meshes).ok_or(RenderError::Camera("the model has no vertex"))?;
Camera::frame(&bounds, vertices(meshes), direction, up, (perspective, aspect))
}
/// Frames `points` (which `bounds` holds) from `direction`, aimed at the middle of their projection.
fn frame(
bounds: &Bounds,
points: impl Iterator<Item = [f64; 3]> + Clone,
direction: [f64; 3],
up: [f64; 3],
(perspective, aspect): (bool, f64),
) -> Result<Camera, RenderError> {
if !(aspect.is_finite() && aspect > 0.0) {
return Err(RenderError::Camera("the aspect ratio is not positive"));
}
let dir = normalize(direction).ok_or(RenderError::Camera("the view direction is zero"))?;
let centre = bounds.centre();
let radius = match bounds.radius() {
r if r.is_finite() && r > 0.0 => r,
_ => 1.0,
};
let [right, upward, forward] = axes(dir, up)?;
let view = |p: [f64; 3]| {
let r = sub(p, centre);
[dot(r, right), dot(r, upward), dot(r, forward)]
};
let ((x0, x1), (y0, y1)) = points
.clone()
.map(view)
.fold(((f64::INFINITY, f64::NEG_INFINITY), (f64::INFINITY, f64::NEG_INFINITY)), |((a0, a1), (b0, b1)), [x, y, _]| {
((a0.min(x), a1.max(x)), (b0.min(y), b1.max(y)))
});
if !(x0 <= x1 && y0 <= y1) {
return Err(RenderError::Camera("the model has no vertex"));
}
let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let target = [0, 1, 2].map(|i| at(centre, i) + mx * at(right, i) + my * at(upward, i));
let floor = radius * 1e-3;
let (projection, distance) = if perspective {
let fov_y: f64 = 30.0;
let ty = (fov_y.to_radians() / 2.0).tan() / FIT_MARGIN;
let tx = ty * aspect;
// A point at lateral (x, y) from the target and depth f past it is in view when
// |x| <= (d + f)·tx and |y| <= (d + f)·ty.
let d = points.map(view).fold(floor, |d, [x, y, f]| {
let need = ((x - mx).abs() / tx).max((y - my).abs() / ty);
d.max(need - f).max(floor - f)
});
(Projection::Perspective { fov_y }, d)
} else {
let height = ((y1 - y0).max((x1 - x0) / aspect) * FIT_MARGIN).max(floor);
(Projection::Orthographic { height }, 2.0 * radius)
};
let camera = Camera { eye: sub(target, scale(dir, distance)), target, up, projection };
camera.basis()?;
Ok(camera)
}
/// Right, up and forward unit vectors.
pub(crate) fn basis(&self) -> Result<[[f64; 3]; 3], RenderError> {
let forward = normalize(sub(self.target, self.eye)).ok_or(RenderError::Camera("the eye and the target coincide"))?;
axes(forward, self.up)
}
}
/// Right, up and `forward` (a unit vector) for a camera whose up direction is `up`.
fn axes(forward: [f64; 3], up: [f64; 3]) -> Result<[[f64; 3]; 3], RenderError> {
let right = normalize(cross(forward, up)).ok_or(RenderError::Camera("the up direction is zero or parallel to the view direction"))?;
Ok([right, cross(right, forward), forward])
}
/// How surfaces are lit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Lighting {
/// One light at the camera: a surface's colour times `0.3 + 0.7·|n·v|`, where `v` points from
/// the surface to the eye (orthographic: against the view direction).
#[default]
Headlight,
/// No shading: every surface in its own colour.
Unlit,
}
/// What [`render`] draws.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderOptions {
/// Image width, pixels.
pub width: u32,
/// Image height, pixels.
pub height: u32,
/// Background colour, straight RGBA.
pub background: [u8; 4],
/// The colour of a surface with none of its own.
pub colour: [u8; 3],
/// How surfaces are lit.
pub lighting: Lighting,
}
impl Default for RenderOptions {
/// 1024 × 768, opaque white background, light grey surface, the headlight.
fn default() -> Self {
RenderOptions { width: 1024, height: 768, background: [255, 255, 255, 255], colour: [190, 192, 200], lighting: Lighting::Headlight }
}
}
/// A rendered image: `rgba` holds `width * height` straight-alpha pixels, row by row from the top.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
/// Width, pixels.
pub width: u32,
/// Height, pixels.
pub height: u32,
/// The pixels.
pub rgba: Vec<u8>,
}
/// Why [`render`], [`Camera::fit`] or [`crate::assemble`] refused.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum RenderError {
/// The image is empty or larger than [`MAX_RENDER_PIXELS`].
#[error("a {width}x{height} image is empty or larger than the render ceiling")]
Size {
/// Requested width.
width: u32,
/// Requested height.
height: u32,
},
/// The camera cannot form a view.
#[error("invalid camera: {0}")]
Camera(&'static str),
/// The model could not be read.
#[error("unreadable model: {0}")]
Model(#[from] pdfcraft_prc::PrcError),
/// The placed model has more than [`crate::MAX_ASSEMBLED_TRIANGLES`] triangles.
#[error("the placed model has more triangles than the assembly ceiling")]
TooManyTriangles,
/// The placed model has more than [`crate::MAX_ASSEMBLED_VERTICES`] positions, normals and
/// texture coordinates.
#[error("the placed model has more vertices than the assembly ceiling")]
TooManyVertices,
/// Drawing would test more than [`MAX_RENDER_WORK`] pixel centres.
#[error("the render would test more pixels than the work ceiling")]
TooMuchWork,
}
/// Draw `meshes` as seen by `camera`.
///
/// Each triangle is filled in its own colour ([`WorldMesh::triangle_colours`]), else its mesh's
/// ([`WorldMesh::colour`]), else [`RenderOptions::colour`], and lit per [`RenderOptions::lighting`];
/// nearer surfaces hide farther ones. Opaque triangles are drawn first; translucent ones are then
/// blended over them, hidden by nearer opaque surfaces but not by each other, in mesh order; fully
/// transparent ones are not drawn. Triangles with a non-finite or out-of-range vertex are skipped.
/// A perspective camera clips at a near plane 1/10,000 of the way from the eye to the target, so
/// what lies behind the eye, or in front of it but nearer than that, is not drawn; an orthographic
/// camera does not clip.
///
/// # Errors
/// [`RenderError::Size`] for an empty or over-ceiling image; [`RenderError::Camera`] for a camera
/// that cannot form a view (eye on the target, `up` parallel to the view, a field of view outside
/// 0–180° or too small for a finite focal length, a non-positive orthographic height);
/// [`RenderError::TooMuchWork`] past [`MAX_RENDER_WORK`].
pub fn render(meshes: &[WorldMesh], camera: &Camera, options: &RenderOptions) -> Result<Image, RenderError> {
let (width, height) = (options.width, options.height);
let size = RenderError::Size { width, height };
let pixels = u64::from(width) * u64::from(height);
if pixels == 0 || pixels > MAX_RENDER_PIXELS {
return Err(size);
}
let pixels = usize::try_from(pixels).map_err(|_| size)?;
let view = View::new(camera, f64::from(width) / f64::from(height))?;
let perspective = matches!(view, View::Perspective { .. });
let scene = Scene { eye: camera.eye, basis: camera.basis()?, view, lighting: options.lighting };
let mut rgba = options.background.repeat(pixels);
let mut depth = vec![f64::NEG_INFINITY; pixels];
let mut target = Target { perspective, width, height, rgba: &mut rgba, depth: &mut depth, work: MAX_RENDER_WORK };
let [r, g, b] = options.colour;
for opaque in [true, false] {
for m in meshes {
for t in 0..m.mesh.triangles.len() {
let colour = m.colour_of(t).unwrap_or([r, g, b, 255]);
if (colour[3] == 255) == opaque && colour[3] != 0 {
scene.draw(&mut target, &m.mesh, t, colour)?;
}
}
}
}
Ok(Image { width, height, rgba })
}
/// The camera a frame is drawn from, with its basis and projection.
struct Scene {
eye: [f64; 3],
/// Right, up and forward.
basis: [[f64; 3]; 3],
view: View,
lighting: Lighting,
}
impl Scene {
/// The shade at world point `p` with normal `n`.
fn shade(&self, p: [f64; 3], n: [f64; 3]) -> [f64; 3] {
if self.lighting == Lighting::Unlit {
return [1.0; 3];
}
let towards = match self.view {
View::Perspective { .. } => normalize(sub(self.eye, p)),
View::Orthographic { .. } => Some(scale(at3(&self.basis, 2), -1.0)),
};
[0.3 + 0.7 * towards.map_or(1.0, |v| dot(n, v).abs().min(1.0)); 3]
}
/// Draw triangle `t` of `mesh` into `target` in straight-RGBA `colour`; refused once the
/// target's work allowance is spent.
fn draw(&self, target: &mut Target, mesh: &TriangleMesh, t: usize, colour: [u8; 4]) -> Result<(), RenderError> {
let Some(world) = mesh.triangles.get(t).and_then(|tri| corners(mesh, tri)) else {
return Ok(());
};
let Some(face) = unit_cross(span(at3(&world, 0), at3(&world, 1)), span(at3(&world, 0), at3(&world, 2))) else {
return Ok(());
};
let stored = stored_normals(mesh, t);
let normal = |k: usize| stored.and_then(|n| n.get(k).copied().flatten()).unwrap_or(face);
// Without stored normals the triangle is flat: one shade, lit along the view ray to its first corner.
let flat = stored.is_none().then(|| self.shade(at3(&world, 0), normal(0)));
let [right, up, forward] = self.basis;
let view_corners = [0, 1, 2].map(|k| -> ViewCorner {
let p = at3(&world, k);
let [r, g, b] = flat.unwrap_or_else(|| self.shade(p, normal(k)));
let d = sub(p, self.eye);
[dot(d, right), dot(d, up), dot(d, forward), r, g, b]
});
let screen: Vec<Corner> = self.view.clip(view_corners).into_iter().map(|v| self.view.project(v, target.width, target.height)).collect();
if let Some((&a, rest)) = screen.split_first() {
for pair in rest.windows(2) {
if let [b, c] = pair {
target.fill([a, *b, *c], colour)?;
}
}
}
Ok(())
}
}
/// Triangle `t`'s stored corner normals, unit length; a corner whose normal is zero or not finite
/// is `None`, and so is the whole when the mesh stores none.
fn stored_normals(mesh: &TriangleMesh, t: usize) -> Option<[Option<[f64; 3]>; 3]> {
let slots = mesh.triangle_normals.get(t)?;
Some(slots.map(|i| mesh.normals.get(usize::try_from(i).ok()?).copied().and_then(normalize)))
}
/// The three corners of a triangle, or `None` when one is missing or not finite.
fn corners(mesh: &TriangleMesh, tri: &[u32; 3]) -> Option<[[f64; 3]; 3]> {
let mut out = [[0.0; 3]; 3];
for (slot, &i) in out.iter_mut().zip(tri) {
let p = *mesh.positions.get(usize::try_from(i).ok()?)?;
if !p.iter().all(|c| c.is_finite()) {
return None;
}
*slot = p;
}
Some(out)
}
/// Corner `i` of a triangle, the origin when out of range.
fn at3(v: &[[f64; 3]; 3], i: usize) -> [f64; 3] {
v.get(i).copied().unwrap_or([0.0; 3])
}
#[cfg(test)]
mod tests;
+347
View File
@@ -0,0 +1,347 @@
#![allow(clippy::unwrap_used, clippy::panic, clippy::unreachable)]
use super::*;
fn mesh(positions: Vec<[f64; 3]>, triangles: Vec<[u32; 3]>) -> TriangleMesh {
let mut m = TriangleMesh::default();
m.positions = positions;
m.triangles = triangles;
m
}
fn quad_mesh(z: f64, half: f64, tilt: f64) -> TriangleMesh {
mesh(vec![[-half, -half, z - tilt], [half, -half, z + tilt], [half, half, z + tilt], [-half, half, z - tilt]], vec![[0, 1, 2], [0, 2, 3]])
}
fn quad(z: f64, half: f64, tilt: f64) -> WorldMesh {
WorldMesh::new(quad_mesh(z, half, tilt))
}
fn painted(m: WorldMesh, colour: Option<[u8; 4]>) -> WorldMesh {
WorldMesh { colour, ..m }
}
fn ortho(height: f64) -> Camera {
Camera { eye: [0.0, 0.0, 10.0], target: [0.0, 0.0, 0.0], up: [0.0, 1.0, 0.0], projection: Projection::Orthographic { height } }
}
fn small() -> RenderOptions {
RenderOptions { width: 40, height: 40, ..RenderOptions::default() }
}
fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] {
let i = (y * image.width + x) as usize * 4;
image.rgba[i..i + 4].try_into().unwrap()
}
#[test]
fn a_facing_square_fills_the_middle_and_leaves_the_corners() {
let image = render(&[quad(0.0, 1.0, 0.0)], &ortho(4.0), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]);
// Half the view height is covered: pixels 10..30.
assert_eq!(pixel(&image, 10, 20)[0], 190);
assert_eq!(pixel(&image, 9, 20)[0], 255);
}
#[test]
fn stored_normals_shade_smoothly_across_a_flat_square() {
let mut m = quad_mesh(0.0, 1.0, 0.0);
let s = 3f64.sqrt() / 2.0;
// The left corners lean 60° away from the viewer; the right face it.
m.normals = vec![[s, 0.0, 0.5], [0.0, 0.0, 2.0]];
m.triangle_normals = vec![[0, 1, 1], [0, 1, 0]];
let image = render(&[WorldMesh::new(m.clone())], &ortho(4.0), &small()).unwrap();
let red = |x| pixel(&image, x, 20)[0];
assert!(red(11) < red(20) && red(20) < red(29), "{} {} {}", red(11), red(20), red(29));
// Shade 0.3 + 0.7 cos θ at the edges: 0.65 left, 1.0 right.
assert!((i32::from(red(10)) - 128).abs() <= 4, "{}", red(10));
assert!(red(29) >= 186);
m.normals.clear();
m.triangle_normals.clear();
let flat = render(&[WorldMesh::new(m)], &ortho(4.0), &small()).unwrap();
assert_eq!(pixel(&flat, 11, 20), pixel(&flat, 29, 20));
}
#[test]
fn the_nearer_surface_hides_the_farther_whatever_the_order() {
let far = quad(-1.0, 2.0, 1.0);
let near = quad(1.0, 0.5, 0.0);
for meshes in [[far.clone(), near.clone()], [near, far]] {
let image = render(&meshes, &ortho(8.0), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
assert!(pixel(&image, 20, 12)[0] < 190, "the tilted far square is darker");
}
}
const RED: Option<[u8; 4]> = Some([255, 0, 0, 255]);
const GLASS: Option<[u8; 4]> = Some([0, 0, 255, 102]);
/// Glass (40% blue) in front of red blends whichever is listed first, glass behind red is hidden,
/// a clear mesh draws nothing, and a mesh with no colour takes the options' colour.
#[test]
fn meshes_draw_in_their_own_colours_and_glass_blends() {
let unlit = RenderOptions { lighting: Lighting::Unlit, ..small() };
let at_middle = |meshes: &[(f64, Option<[u8; 4]>)]| {
let meshes: Vec<WorldMesh> = meshes.iter().map(|&(z, c)| painted(quad(z, 1.0, 0.0), c)).collect();
pixel(&render(&meshes, &ortho(4.0), &unlit).unwrap(), 20, 20)
};
assert_eq!(at_middle(&[(1.0, GLASS), (-1.0, RED)]), [153, 0, 102, 255]);
assert_eq!(at_middle(&[(-1.0, RED), (1.0, GLASS)]), [153, 0, 102, 255]);
assert_eq!(at_middle(&[(-1.0, GLASS), (1.0, RED)]), [255, 0, 0, 255]);
assert_eq!(at_middle(&[(1.0, Some([0, 0, 255, 0])), (-1.0, RED)]), [255, 0, 0, 255]);
assert_eq!(at_middle(&[(1.0, None)]), [190, 192, 200, 255]);
assert_eq!(at_middle(&[(2.0, GLASS), (1.0, GLASS), (-1.0, RED)]), [92, 0, 163, 255], "glass does not hide glass");
let clear = RenderOptions { background: [0, 0, 0, 0], ..unlit };
let image = render(&[painted(quad(1.0, 1.0, 0.0), GLASS)], &ortho(4.0), &clear).unwrap();
assert_eq!(pixel(&image, 20, 20), [0, 0, 255, 102], "glass over nothing");
}
#[test]
fn a_triangle_colour_overrides_its_mesh_colour() {
let mut m = painted(quad(0.0, 1.0, 0.0), RED);
// The lower-right triangle is green; the upper-left keeps the mesh's red.
m.triangle_colours = Some(vec![Some([0, 255, 0, 255]), None]);
let unlit = RenderOptions { lighting: Lighting::Unlit, ..small() };
let image = render(&[m], &ortho(4.0), &unlit).unwrap();
assert_eq!(pixel(&image, 25, 25), [0, 255, 0, 255]);
assert_eq!(pixel(&image, 14, 14), [255, 0, 0, 255]);
}
#[test]
fn each_edge_bounds_a_triangle() {
let tri = WorldMesh::new(mesh(vec![[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2]]));
let image = render(&[tri], &ortho(4.0), &small()).unwrap();
assert_ne!(pixel(&image, 20, 25)[0], 255);
// Just past each edge: below the base, left and right of the apex.
for (x, y) in [(20, 31), (13, 15), (27, 15)] {
assert_eq!(pixel(&image, x, y)[0], 255, "({x},{y})");
}
}
#[test]
fn a_face_turned_away_is_still_drawn() {
let mut back = quad_mesh(0.0, 1.0, 0.0);
for t in &mut back.triangles {
t.swap(1, 2);
}
let image = render(&[WorldMesh::new(back)], &ortho(4.0), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
}
#[test]
fn perspective_clips_what_is_behind_the_camera() {
let camera = Camera { projection: Projection::Perspective { fov_y: 60.0 }, ..ortho(1.0) };
let image = render(&[quad(20.0, 1.0, 0.0)], &camera, &small()).unwrap();
assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255]));
// A floor running from behind the camera to beyond the target.
let floor = WorldMesh::new(mesh(vec![[-5.0, -1.0, 20.0], [5.0, -1.0, 20.0], [0.0, -1.0, -20.0]], vec![[0, 1, 2]]));
let image = render(&[floor], &camera, &small()).unwrap();
assert_ne!(pixel(&image, 20, 38), [255, 255, 255, 255]);
assert_eq!(pixel(&image, 20, 2), [255, 255, 255, 255]);
}
#[test]
fn a_fitted_camera_frames_the_model() {
// Depth matters in perspective: the nearer square looks larger.
let meshes = [quad(3.0, 3.0, 0.0), quad(-3.0, 3.0, 0.0)];
let bounds = Bounds::of(&meshes).unwrap();
for perspective in [true, false] {
let camera = Camera::fit(&bounds, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], perspective, 2.0).unwrap();
let image = render(&meshes, &camera, &RenderOptions { width: 80, height: 40, ..RenderOptions::default() }).unwrap();
assert_ne!(pixel(&image, 40, 20)[0], 255);
// Nothing drawn touches the image border.
for x in 0..80 {
assert_eq!(pixel(&image, x, 0)[0], 255);
assert_eq!(pixel(&image, x, 39)[0], 255);
}
}
}
#[test]
fn a_mesh_fit_ignores_empty_box_corners() {
// A thin strip along the box's diagonal: seen across the diagonal, the box is as tall as it
// is wide, the strip is not.
let meshes = [WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [10.0, 10.0, 0.0], [10.0, 10.01, 0.0]], vec![[0, 1, 2]]))];
let (dir, up) = ([0.0, 0.0, -1.0], [1.0, -1.0, 0.0]);
let height = |c: Camera| match c.projection {
Projection::Orthographic { height } => height,
Projection::Perspective { .. } => unreachable!(),
};
let boxed = height(Camera::fit(&Bounds::of(&meshes).unwrap(), dir, up, false, 2.0).unwrap());
let tight = height(Camera::fit_meshes(&meshes, dir, up, false, 2.0).unwrap());
assert!((boxed / tight - 2.0).abs() < 0.01, "{boxed} vs {tight}");
let near = Camera::fit_meshes(&meshes, dir, up, true, 2.0).unwrap();
let far = Camera::fit(&Bounds::of(&meshes).unwrap(), dir, up, true, 2.0).unwrap();
assert!(length(sub(near.eye, near.target)) < 0.6 * length(sub(far.eye, far.target)));
// Lopsided in its box: the limiting vertex sits exactly on the margin, measured from the
// re-centred target. Narrow (width limits) and wide (height limits).
let tri = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 2.0, 1.0]], vec![[0, 1, 2]]));
for aspect in [0.5, 3.0] {
let c = Camera::fit_meshes(std::slice::from_ref(&tri), [0.0, 0.3, -1.0], [1.0, 1.0, 0.0], true, aspect).unwrap();
let [right, upward, forward] = c.basis().unwrap();
let ty = 15f64.to_radians().tan() / FIT_MARGIN;
let fill = tri
.mesh
.positions
.iter()
.map(|&p| {
let r = sub(p, c.eye);
(dot(r, right).abs() / (ty * aspect)).max(dot(r, upward).abs() / ty) / dot(r, forward)
})
.fold(0.0, f64::max);
assert!((fill - 1.0).abs() < 1e-6, "aspect {aspect}: {fill}");
}
}
#[test]
fn a_fitted_camera_fills_the_tighter_axis() {
let drawn = |image: &Image, horizontal: bool, line: u32| {
let n = if horizontal { image.width } else { image.height };
(0..n)
.filter(|&i| {
let (x, y) = if horizontal { (i, line) } else { (line, i) };
pixel(image, x, y)[0] != 255
})
.count()
};
// Height-limited: a 6 x 6 square in an 80 x 40 image.
let square = [quad(0.0, 3.0, 0.0)];
// Width-limited and off-origin: a 10 x 1 strip in a 40 x 40 image.
let strip = [WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 1.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 3]]))];
for perspective in [true, false] {
let fit =
|meshes: &[WorldMesh], aspect| Camera::fit(&Bounds::of(meshes).unwrap(), [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], perspective, aspect).unwrap();
let wide = RenderOptions { width: 80, height: 40, ..RenderOptions::default() };
let rows = drawn(&render(&square, &fit(&square, 2.0), &wide).unwrap(), false, 40);
assert!((34..=37).contains(&rows), "{perspective}: {rows} of 40 rows");
let image = render(&strip, &fit(&strip, 1.0), &small()).unwrap();
let cols = drawn(&image, true, 20);
assert!((34..=37).contains(&cols), "{perspective}: {cols} of 40 columns");
// Centred: the margins differ by at most a pixel.
let left = (0..40).take_while(|&x| pixel(&image, x, 20)[0] == 255).count();
assert!(left.abs_diff(40 - cols - left) <= 1, "{perspective}: left {left}");
}
}
#[test]
fn bad_sizes_and_cameras_are_refused() {
let m = [quad(0.0, 1.0, 0.0)];
let size = |width, height| RenderOptions { width, height, ..RenderOptions::default() };
assert!(matches!(render(&m, &ortho(1.0), &size(0, 10)), Err(RenderError::Size { .. })));
assert!(matches!(render(&m, &ortho(1.0), &size(10_000, 10_000)), Err(RenderError::Size { .. })));
let bad = [
Camera { eye: [0.0; 3], target: [0.0; 3], ..ortho(1.0) },
Camera { up: [0.0, 0.0, 1.0], ..ortho(1.0) },
Camera { projection: Projection::Perspective { fov_y: 180.0 }, ..ortho(1.0) },
Camera { projection: Projection::Perspective { fov_y: f64::NAN }, ..ortho(1.0) },
Camera { projection: Projection::Perspective { fov_y: 1e-310 }, ..ortho(1.0) },
ortho(0.0),
ortho(f64::INFINITY),
];
for camera in bad {
assert!(matches!(render(&m, &camera, &small()), Err(RenderError::Camera(_))), "{camera:?}");
}
let b = Bounds::of(&m).unwrap();
assert!(Camera::fit(&b, [0.0; 3], [0.0, 1.0, 0.0], true, 1.0).is_err());
assert!(Camera::fit(&b, [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], true, 0.0).is_err());
assert!(Camera::fit_meshes(&[], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0], true, 1.0).is_err());
}
#[test]
fn non_finite_and_missing_vertices_are_skipped() {
let m = WorldMesh::new(mesh(vec![[0.0, 0.0, 0.0], [f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0]], vec![[0, 1, 2], [0, 2, 9], [0, 2, u32::MAX]]));
let image = render(std::slice::from_ref(&m), &ortho(4.0), &small()).unwrap();
assert!(image.rgba.chunks(4).all(|p| p == [255, 255, 255, 255]));
// No triangle can be drawn, so none frames the model.
assert_eq!(Bounds::of(&[m]), None);
}
#[test]
fn unlit_draws_the_plain_colour_and_the_headlight_shades_by_angle() {
let lit = |lighting, tilt| {
let image = render(&[quad(0.0, 1.0, tilt)], &ortho(4.0), &RenderOptions { lighting, ..small() }).unwrap();
pixel(&image, 20, 20)
};
assert_eq!(lit(Lighting::Headlight, 0.0), [190, 192, 200, 255]);
// Tilted 45°: 0.3 + 0.7 cos 45° ≈ 0.795 of the colour.
assert_eq!(lit(Lighting::Headlight, 1.0), [151, 153, 159, 255]);
assert_eq!(lit(Lighting::Unlit, 1.0), [190, 192, 200, 255]);
}
#[test]
fn a_huge_square_is_drawn() {
// Its edges' cross product (about 1e320) overflows unless they are scaled first.
let image = render(&[quad(0.0, 1e160, 0.0)], &ortho(4e160), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255]);
}
#[test]
fn a_camera_very_far_away_still_draws() {
let camera = Camera { eye: [0.0, 0.0, 1e200], target: [0.0; 3], up: [0.0, 1.0, 0.0], projection: Projection::Perspective { fov_y: 30.0 } };
let image = render(&[quad(0.0, 1e199, 0.0)], &camera, &small()).unwrap();
// The headlight meets the square almost head-on at the centre pixel.
let [r, _, _, a] = pixel(&image, 20, 20);
assert!((185..=190).contains(&r) && a == 255, "{:?}", pixel(&image, 20, 20));
}
#[test]
fn the_centre_of_a_box_near_the_largest_float_is_finite() {
let b = Bounds { min: [1e308; 3], max: [1.5e308; 3] };
assert!(b.centre().iter().all(|c| c.is_finite()), "{:?}", b.centre());
}
#[test]
fn a_triangle_that_is_not_drawn_does_not_widen_the_box() {
// The second triangle has a non-finite corner, so its finite far corner is not drawn either.
let m = mesh(vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [f64::NAN, 0.0, 0.0], [1e6, 0.0, 0.0]], vec![[0, 1, 2], [3, 4, 0]]);
let b = Bounds::of(&[WorldMesh::new(m)]).unwrap();
assert_eq!(b.max, [1.0, 1.0, 0.0]);
}
#[test]
fn a_fully_transparent_triangle_does_not_widen_the_box_or_the_fit() {
// The far triangle is clear, so the camera frames the near one as if it were alone.
let m = mesh(vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1e6, 0.0, 0.0], [1e6, 1.0, 0.0]], vec![[0, 1, 2], [3, 4, 1]]);
let clear = WorldMesh { triangle_colours: Some(vec![None, Some([0, 0, 0, 0])]), ..WorldMesh::new(m.clone()) };
assert_eq!(Bounds::of(std::slice::from_ref(&clear)).unwrap().max, [1.0, 1.0, 0.0]);
let alone = WorldMesh::new(mesh(m.positions, vec![[0, 1, 2]]));
let fit = |w: &WorldMesh| Camera::fit_meshes(std::slice::from_ref(w), [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], true, 1.0).unwrap();
assert_eq!(fit(&clear), fit(&alone));
// A whole mesh painted clear is not framed either.
assert_eq!(
Bounds::of(&[painted(WorldMesh::new(mesh(vec![[0.0; 3], [9.0, 0.0, 0.0], [0.0, 9.0, 0.0]], vec![[0, 1, 2]])), Some([1, 2, 3, 0]))]),
None
);
}
#[test]
fn a_small_model_far_from_the_origin_can_be_fitted() {
let (o, d) = (1e16, 4.0);
let m = mesh(vec![[o, o, o], [o + d, o, o], [o, o + d, o]], vec![[0, 1, 2]]);
let camera = Camera::fit_meshes(&[WorldMesh::new(m)], [1.0, 1.0, 1.0], [0.0, 1.0, 0.0], false, 1.0);
assert!(camera.is_ok(), "{camera:?}");
}
#[test]
fn a_triangle_whose_edges_overflow_is_drawn() {
// Its corners are finite but 2e308 apart, so the plain edge differences are infinite.
let m = mesh(vec![[-1e308, -1e308, 0.0], [1e308, -1e308, 0.0], [0.0, 1e308, 0.0]], vec![[0, 1, 2]]);
let image = render(&[WorldMesh::new(m)], &ortho(1e308), &small()).unwrap();
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255]);
}
#[test]
fn an_on_screen_triangle_is_drawn_when_projecting_it_would_overflow() {
let wide = RenderOptions { width: 30, height: 10, ..RenderOptions::default() };
let drawn = |image: &Image| image.rgba.chunks(4).filter(|px| px != &[255, 255, 255, 255]).count();
// A tiny field of view: x times the focal length (about 1.1e300) overflows, though x over z does not.
let camera = Camera { eye: [0.0; 3], target: [0.0, 0.0, 1e308], up: [0.0, 1.0, 0.0], projection: Projection::Perspective { fov_y: 1e-298 } };
let far = mesh(vec![[1.5e8, -3e7, 1e308], [2.5e8, -3e7, 1e308], [2e8, 3e7, 1e308]], vec![[0, 1, 2]]);
assert!(drawn(&render(&[WorldMesh::new(far)], &camera, &wide).unwrap()) > 0);
// Half the orthographic height times the aspect ratio overflows.
let big = mesh(vec![[-5e307, -5e307, 0.0], [5e307, -5e307, 0.0], [0.0, 5e307, 0.0]], vec![[0, 1, 2]]);
assert_eq!(pixel(&render(&[WorldMesh::new(big)], &ortho(1.5e308), &wide).unwrap(), 15, 5), [190, 192, 200, 255]);
}
+119
View File
@@ -0,0 +1,119 @@
//! Three-component vector arithmetic for the camera and the rasterizer.
/// A point or direction, `[x, y, z]`.
pub(crate) type Vec3 = [f64; 3];
/// `a - b`.
pub(crate) fn sub([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 {
[ax - bx, ay - by, az - bz]
}
/// The direction from `from` to `to`: `to - from`, halved when the full difference overflows.
pub(crate) fn span(from: Vec3, to: Vec3) -> Vec3 {
let d = sub(to, from);
if d.iter().all(|c| c.is_finite()) { d } else { sub(scale(to, 0.5), scale(from, 0.5)) }
}
/// `a` times `s`.
pub(crate) fn scale(a: Vec3, s: f64) -> Vec3 {
a.map(|c| c * s)
}
/// The dot product `a · b`.
pub(crate) fn dot([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> f64 {
ax * bx + ay * by + az * bz
}
/// The right-handed cross product `a × b`.
pub(crate) fn cross([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 {
[ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx]
}
/// The Euclidean length of `a`, taken with `a` divided by its largest component so squaring
/// cannot overflow or underflow; infinite or NaN when a component is.
pub(crate) fn length(a: Vec3) -> f64 {
let big = largest(a);
if !(big.is_finite() && big > 0.0) {
return dot(a, a).sqrt();
}
let a = a.map(|c| c / big);
big * dot(a, a).sqrt()
}
/// The largest component of `a` by magnitude; NaN components are passed over.
fn largest(a: Vec3) -> f64 {
a.iter().fold(0.0_f64, |m, c| m.max(c.abs()))
}
/// `a` scaled to unit length; `None` when its length is zero or not finite. `a` is first divided
/// by its largest component, so squaring cannot overflow or underflow.
pub(crate) fn normalize(a: Vec3) -> Option<Vec3> {
let big = largest(a);
if !(big.is_finite() && big > 0.0) {
return None;
}
let a = a.map(|c| c / big);
let l = dot(a, a).sqrt();
(l.is_finite() && l > 0.0).then(|| scale(a, 1.0 / l))
}
/// The unit normal of the plane spanned by `a` and `b` (`a × b`, normalized); `None` when they are
/// parallel, zero or not finite. Each is normalized first, so neither huge edges nor edges of
/// very different sizes overflow or underflow the cross product.
pub(crate) fn unit_cross(a: Vec3, b: Vec3) -> Option<Vec3> {
normalize(cross(normalize(a)?, normalize(b)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cross_follows_the_right_hand_rule() {
assert_eq!(cross([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), [0.0, 0.0, 1.0]);
assert_eq!(cross([0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), [0.0, 0.0, -1.0]);
}
#[test]
fn normalize_rejects_degenerate_vectors() {
assert_eq!(normalize([0.0, 4.0, 0.0]), Some([0.0, 1.0, 0.0]));
assert_eq!(normalize([0.0; 3]), None);
assert_eq!(normalize([f64::NAN, 0.0, 0.0]), None);
assert_eq!(normalize([f64::INFINITY, 0.0, 0.0]), None);
}
#[test]
fn normalize_keeps_huge_and_tiny_vectors() {
assert_eq!(normalize([1e200, 0.0, 0.0]), Some([1.0, 0.0, 0.0]));
assert_eq!(normalize([0.0, -3e-320, 0.0]), Some([0.0, -1.0, 0.0]));
}
#[test]
fn unit_cross_survives_edges_whose_product_overflows() {
assert_eq!(unit_cross([1e160, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0]));
assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e-170, 0.0]), Some([0.0, 0.0, 1.0]));
assert_eq!(unit_cross([2.0, 0.0, 0.0], [4.0, 0.0, 0.0]), None);
assert_eq!(unit_cross([f64::INFINITY, 0.0, 0.0], [0.0, 1.0, 0.0]), None);
}
#[test]
fn unit_cross_survives_edges_of_very_different_sizes() {
assert_eq!(unit_cross([1e-170, 0.0, 0.0], [0.0, 1e160, 0.0]), Some([0.0, 0.0, 1.0]));
}
#[test]
fn span_keeps_the_direction_of_a_difference_that_overflows() {
assert_eq!(span([1.0, 2.0, 3.0], [4.0, 0.0, 3.0]), [3.0, -2.0, 0.0]);
assert_eq!(span([-1e308, 0.0, 0.0], [1e308, 1e308, 0.0]), [1e308, 5e307, 0.0]);
}
#[test]
fn length_survives_huge_and_tiny_vectors() {
let near = |l: f64, want: f64| (l - want).abs() <= want * 1e-15;
assert!(near(length([3e200, 4e200, 0.0]), 5e200));
assert!(near(length([0.0, 3e-200, 4e-200]), 5e-200));
assert_eq!(length([0.0; 3]), 0.0);
assert!(length([f64::NAN, 1.0, 0.0]).is_nan());
assert_eq!(length([f64::INFINITY, 1.0, 0.0]), f64::INFINITY);
}
}
+145
View File
@@ -0,0 +1,145 @@
//! End to end: a synthetic PRC stream parsed, assembled and rendered to pixels.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use pdfcraft_prc::PrcFile;
use pdfcraft_prc_render::{Camera, Image, RenderOptions, assemble, render};
use std::io::Write as _;
/// An MSB-first bit sink: the inverse of the PRC bit reader, for the few values these tests write.
#[derive(Default)]
struct W {
bits: Vec<bool>,
}
impl W {
fn put(&mut self, v: u64, n: u32) -> &mut Self {
for i in (0..n).rev() {
self.bits.push((v >> i) & 1 == 1);
}
self
}
fn bit(&mut self, b: bool) -> &mut Self {
self.bits.push(b);
self
}
/// An `UnsignedInteger`: bytes low first, each after a 1 bit, then a 0 bit.
fn uint(&mut self, mut v: u32) -> &mut Self {
while v != 0 {
self.bit(true).put(u64::from(v & 0xff), 8);
v >>= 8;
}
self.bit(false)
}
fn uints(&mut self, v: &[u32]) -> &mut Self {
self.uint(u32::try_from(v.len()).unwrap());
for &x in v {
self.uint(x);
}
self
}
/// A `Double` from the few the code table stores whole: 0, ±0.5, ±1, ±2.
fn double(&mut self, v: f64) -> &mut Self {
let negative = v < 0.0;
match v.abs() {
0.0 => self.put(0b01, 2),
0.5 => self.put(0x777, 11).bit(negative),
1.0 => self.put(0b0000, 4).bit(negative),
// The exponent row of [2, 4) with no mantissa bytes.
2.0 => self.put(0x1b, 5).bit(negative).bit(false),
_ => panic!("{v} has no short code here"),
}
}
fn bytes(&self) -> Vec<u8> {
self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect()
}
}
/// A tessellation section of one `TESS_3D` (authoring version 8137) with explicit normals and
/// triangles of `(normal, point)` coordinate indices.
fn tess_section(points: &[[f64; 3]], normals: &[[f64; 3]], corners: &[(u32, u32)]) -> Vec<u8> {
let mut w = W::default();
w.uint(305).uint(0).bit(true).uint(1);
w.uint(172).bit(false).uint(u32::try_from(points.len() * 3).unwrap());
points.iter().flatten().for_each(|&c| _ = w.double(c));
// Faces present; no loop flag; normals not recalculated.
w.bit(true).bit(false).bit(false);
w.uint(u32::try_from(normals.len() * 3).unwrap());
normals.iter().flatten().for_each(|&c| _ = w.double(c));
w.uint(0).uints(&corners.iter().flat_map(|&(n, p)| [n, p]).collect::<Vec<_>>());
// One face of triangles with a normal per corner.
w.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0).uints(&[u32::try_from(corners.len() / 3).unwrap()]);
w.uint(0).bit(false);
w.uint(0).uint(0);
w.bytes()
}
/// A one-file-structure PRC stream whose tessellation section is `tess`, with no product tree.
fn prc_file(tess: &[u8]) -> Vec<u8> {
const HEADER_LEN: usize = 107;
let zlib = |data: &[u8]| {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
};
let u32s = |out: &mut Vec<u8>, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes()));
let mut globals = W::default();
globals.uint(0);
let sections: [&[u8]; 5] = [&globals.bytes(), b"", tess, b"", b""];
let mut body = b"PRC".to_vec();
u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]);
let mut offs = vec![HEADER_LEN];
for s in sections {
offs.push(HEADER_LEN + body.len());
body.extend(zlib(s));
}
let mf_start = HEADER_LEN + body.len();
body.extend(zlib(b""));
let mf_end = HEADER_LEN + body.len();
let mut out = b"PRC".to_vec();
u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]);
u32s(&mut out, &[5, 6, 7, 8, 0, 6]);
u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::<Vec<_>>());
u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]);
out.extend(body);
out
}
fn pixel(image: &Image, x: u32, y: u32) -> [u8; 4] {
let i = (y * image.width + x) as usize * 4;
image.rgba[i..i + 4].try_into().unwrap()
}
#[test]
fn a_synthetic_prc_renders_its_nearer_triangle_over_the_farther() {
// Listed first, a small triangle at z = 1 facing the viewer; behind it at z = -1, a larger
// one whose stored normal lies across the view, so the headlight shades it darkest.
let points = [[-1., -1., 1.], [1., -1., 1.], [0., 1., 1.], [-2., -2., -1.], [2., -2., -1.], [0., 2., -1.]];
let normals = [[0., 0., 1.], [1., 0., 0.]];
let corners = [(0, 0), (0, 3), (0, 6), (3, 9), (3, 12), (3, 15)];
let prc = PrcFile::parse(&prc_file(&tess_section(&points, &normals, &corners))).unwrap();
let meshes = assemble(&prc).unwrap();
assert_eq!(meshes.len(), 1);
assert_eq!(meshes[0].mesh.triangles.len(), 2);
let camera = Camera::fit_meshes(&meshes, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], false, 1.0).unwrap();
let image = render(&meshes, &camera, &RenderOptions { width: 40, height: 40, ..RenderOptions::default() }).unwrap();
assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255], "background corner");
assert_eq!(pixel(&image, 20, 20), [190, 192, 200, 255], "the nearer triangle, lit head on");
// Below the near triangle only the far one: 0.3 of the surface colour.
assert_eq!(pixel(&image, 20, 34), [57, 58, 60, 255], "the farther triangle, lit edge on");
}
#[test]
fn damaged_streams_are_errors_not_panics() {
let points = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]];
let bytes = prc_file(&tess_section(&points, &[[0., 0., 1.]], &[(0, 0), (0, 3), (0, 6)]));
for end in 0..bytes.len() {
if let Ok(prc) = PrcFile::parse(&bytes[..end]) {
let _ = assemble(&prc);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "pdfcraft-prc"
description = "PRC 3D model reader (ISO 14739-1): container, bit-level primitives, schema"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
flate2.workspace = true
thiserror.workspace = true
[dev-dependencies]
proptest.workspace = true
+43
View File
@@ -0,0 +1,43 @@
# pdfcraft-prc
A reader for PRC (ISO 14739-1), the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries (ISO 32000-2 §13.6.3).
- **Layer:** L0, standalone. No dependency on any other PdfCraft crate.
- **Licence:** MIT OR Apache-2.0. Inflation through `flate2` with the pure-Rust `miniz_oxide` backend.
- `#![forbid(unsafe_code)]`. Never panics on input: inflated bytes, decoded elements, schema steps and walk depth are capped, and damage returns `PrcError`.
## API
```rust
PrcFile::parse(data) -> Result<PrcFile, PrcError> // header, file structures, model file
PrcFile::parse_with_limit(data, max_inflated) -> Result<PrcFile, PrcError>
FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry
FileStructure::schema() -> Result<Schema, PrcError> // the globals section's leading schema
FileStructure::tessellations() -> Result<Vec<Tessellation>, PrcError> // meshes, wires, markups, compressed meshes
PrcFile::placements() / placements_with(StyleAlpha, EntityOverrides) -> Result<Vec<Placement>, PrcError>
PrcFile::model_tree() -> Result<Vec<ModelNode>, PrcError> // the assembly tree as a model-tree panel lists it
Placement::triangle_colours(&TriangleMesh) -> Option<Vec<Option<Rgba>>>
TriangleMesh::transformed(&Matrix) -> TriangleMesh
Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader)
BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double
```
- `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit.
- `MAX_DECODED_ELEMENTS` (2^24) bounds the array elements one pass decodes (a tree walk, or one `tessellations` call), since a single bit can decode to a 4-8 byte element; `TooManyElements` reports it.
- Schema programs (how a newer producer describes fields it added) run under a nesting ceiling (64) and `MAX_SCHEMA_STEPS` (2^22), shared like `MAX_DECODED_ELEMENTS` by every program one pass runs.
- `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`).
- `Tessellation::Mesh` is a `TriangleMesh`: positions, triangles, one triangle range per face, the stored normals and
texture coordinates with each triangle's corner indices into them, and per-triangle line-attribute styles.
`Wire` is polylines; `Markup` is read past.
- `Tessellation::Compressed` is read to its end so later entities decode; its triangles are not rebuilt yet.
- A face may carry at most 8 texture-coordinate sets; more is `Malformed`. Optimised vertex colours are `Unsupported`.
- The product tree: each drawable tessellation placed by its occurrence path's locations and the item's coordinate
systems (a 4x4 `Matrix`), coloured by the style inheritance rules, with occurrences' entity references applied
(`EntityOverrides`: subtree, everywhere or ignored) and style transparency combined with material alpha by `StyleAlpha`.
A prototype's part is coloured by its own file structure's styles. Each `ModelNode` lists its name, where the name
came from, its visibility and the range of placements it drew.
- Occurrence and prototype cycles are `Malformed`; the walk is capped at depth 64, 2^20 visits and `MAX_PLACEMENTS`.
- A tree's markups (PMI), views, scene lights and clipping planes are read past, not modelled; so are textures.
- Not yet in the crate: rebuilding compressed meshes, textures.
- The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`.
A few reading choices also follow prc-rs; each is marked `// prc-rs:` in the source.
+93
View File
@@ -0,0 +1,93 @@
//! The prefix-code table behind the PRC `Double` [WD 11.17.2].
//!
//! The table has 2077 rows: 29 frequent doubles and one row per IEEE binary64 exponent
//! (0..=2047). Each row is a code of 1 to 22 bits and the value it decodes to.
//! The rows come from prc-rs under its MIT licence (see [`table`]).
use std::collections::HashMap;
use std::sync::OnceLock;
mod table;
/// Rows a complete table has.
const ROWS: usize = 2077;
/// The longest code, in bits.
pub(crate) const MAX_CODE_BITS: u32 = 22;
/// What a code decodes to.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Entry {
/// A frequent value, returned with the sign bit applied.
Double(f64),
/// The upper 32 bits of a binary64 (exponent set, mantissa zero); the mantissa follows
/// in the stream.
Exponent(u32),
}
/// One row: code length, code, and what it decodes to.
type Row = (u32, u32, Entry);
fn key(nbits: u32, code: u32) -> u32 {
(nbits << 24) | code
}
/// The rows keyed by `(nbits, code)`, or `None` when the table is incomplete or not prefix
/// free (then no `Double` decodes, rather than some decoding wrongly).
fn build(rows: &[Row]) -> Option<HashMap<u32, Entry>> {
let mut map = HashMap::with_capacity(rows.len());
for &(nbits, code, entry) in rows {
if nbits == 0 || nbits > MAX_CODE_BITS || code >> nbits != 0 || map.insert(key(nbits, code), entry).is_some() {
return None;
}
}
let prefix_free = map.keys().all(|&k| {
let (n, c) = (k >> 24, k & 0xff_ffff);
(1..n).all(|m| !map.contains_key(&key(m, c >> (n - m))))
});
(map.len() == ROWS && prefix_free).then_some(map)
}
fn table() -> Option<&'static HashMap<u32, Entry>> {
static MAP: OnceLock<Option<HashMap<u32, Entry>>> = OnceLock::new();
MAP.get_or_init(|| build(table::TABLE)).as_ref()
}
/// The entry for an `nbits`-long code, if one exists.
pub(crate) fn lookup(nbits: u32, code: u32) -> Option<Entry> {
table()?.get(&key(nbits, code)).copied()
}
/// The `(nbits, code)` of IEEE exponent `e`'s row, the test writer's half of [`lookup`].
#[cfg(test)]
pub(crate) fn exponent_code(e: u32) -> Option<(u32, u32)> {
table()?.iter().find(|(_, v)| matches!(v, Entry::Exponent(hi) if (hi >> 20) & 0x7ff == e)).map(|(&k, _)| (k >> 24, k & 0xff_ffff))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn an_incomplete_or_ambiguous_table_decodes_nothing() {
assert!(build(&[(2, 0b01, Entry::Double(0.0))]).is_none(), "one row is not the table");
let rows: Vec<Row> = (0..ROWS as u32).map(|i| (22, i, Entry::Exponent(i))).collect();
assert!(build(&rows).is_some(), "2077 distinct 22-bit codes are prefix free");
let mut ambiguous = rows.clone();
ambiguous[0] = (1, 0, Entry::Double(1.0)); // a prefix of every code starting 0
assert!(build(&ambiguous).is_none());
let mut too_long = rows;
too_long[0] = (2, 0b111, Entry::Double(1.0)); // a code wider than its length
assert!(build(&too_long).is_none());
}
#[test]
fn the_table_is_complete() {
let t = table().unwrap();
assert_eq!(t.len(), ROWS);
assert_eq!(t.values().filter(|e| matches!(e, Entry::Double(_))).count(), 29);
assert!((0..2048).all(|e| exponent_code(e).is_some()));
assert_eq!(lookup(2, 0b01), Some(Entry::Double(0.0)));
}
}
File diff suppressed because it is too large Load Diff
+420
View File
@@ -0,0 +1,420 @@
//! The array primitives of compressed tessellation [WD 11.6-11.9] and the Huffman payload
//! they may carry [WD 12.2].
//!
//! The WD gives only the Huffman writer. The reader decodes what producers write: each
//! leaf's code length is read in the width the payload's 8-bit field states (the WD writes a
//! fixed 8 bits), stored codes carry a leading root bit, and the payload is read LSB-first
//! although the stream around it is MSB-first.
use std::collections::HashMap;
use crate::PrcError;
use crate::bits::BitReader;
/// Longest Huffman code [WD 12.2].
const MAX_CODE_BITS: u32 = 32;
/// Widest Huffman symbol a caller asks for: the arrays hold 2- to 16-bit values.
const MAX_SYMBOL_BITS: u32 = 16;
fn malformed(what: &str) -> PrcError {
PrcError::Malformed(what.to_owned())
}
/// An LSB-first cursor over a Huffman payload.
struct Lsb<'a> {
data: &'a [u8],
pos: usize,
end: usize,
}
impl Lsb<'_> {
fn bit(&mut self) -> Result<u32, PrcError> {
if self.pos >= self.end {
return Err(PrcError::Truncated("a Huffman payload"));
}
let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a Huffman payload"))?;
let b = u32::from((byte >> (self.pos % 8)) & 1);
self.pos += 1;
Ok(b)
}
/// `n` ≤ 32 bits, the first read being the least significant.
fn bits(&mut self, n: u32) -> Result<u32, PrcError> {
let mut v = 0u32;
for i in 0..n.min(32) {
v |= self.bit()? << i;
}
Ok(v)
}
fn remaining(&self) -> usize {
self.end.saturating_sub(self.pos)
}
}
/// The low `n` (1..=16) bits of `v` as a symbol, two's complement when `signed`.
fn symbol(v: u32, n: u32, signed: bool) -> i32 {
let v = i64::from(v);
let wide = if signed && n > 0 && v & (1 << (n - 1)) != 0 { v - (1i64 << n) } else { v };
// n <= 16, so `wide` is within ±2^16.
i32::try_from(wide).unwrap_or(0)
}
/// The Huffman container of a compressed Character/Short array [WD 11.6, 11.7, 12.2]:
/// symbols are `nbits` wide, sign-extended when `signed`.
fn huffman(r: &mut BitReader<'_>, nbits: u32, signed: bool) -> Result<Vec<i32>, PrcError> {
if nbits == 0 || nbits > MAX_SYMBOL_BITS {
return Err(malformed("Huffman symbol width"));
}
let words = r.unsigned_integer()? as usize;
if words == 0 {
return Ok(Vec::new());
}
if words.saturating_mul(32) > r.remaining() {
return Err(PrcError::Truncated("a Huffman payload"));
}
// words * 32 bits remain, so words * 4 bytes cannot overflow.
let blob = (0..words * 4).map(|_| r.character()).collect::<Result<Vec<u8>, _>>()?;
let last = r.unsigned_integer()?;
if last > 32 {
return Err(malformed("Huffman last-word bit count above 32"));
}
let end = (words - 1) * 32 + last as usize;
let mut b = Lsb { data: &blob, pos: 0, end };
let codes = huffman_leaves(&mut b, nbits, signed)?;
if codes.is_empty() {
return Ok(Vec::new());
}
let count = b.bits(32)? as usize;
// Every element is at least one bit.
if count > b.remaining() {
return Err(PrcError::Truncated("a Huffman element count"));
}
r.take_elements(count)?;
let mut out = Vec::with_capacity(count.min(1024));
for _ in 0..count {
out.push(huffman_symbol(&mut b, &codes)?);
}
Ok(out)
}
/// The leaf table: `(code length, stored code)` to symbol.
fn huffman_leaves(b: &mut Lsb<'_>, nbits: u32, signed: bool) -> Result<HashMap<(u32, u32), i32>, PrcError> {
let leaves = b.bits(nbits + 1)?;
if leaves == 0 {
return Ok(HashMap::new());
}
// prc-rs: the code-length width is read from the payload, not fixed at 8 bits.
let len_width = b.bits(8)?;
if len_width == 0 || len_width > MAX_CODE_BITS {
return Err(malformed("Huffman code-length width"));
}
// Every leaf is at least nbits + 2 bits, so the payload bounds the table.
if (leaves as usize).saturating_mul((nbits + 2) as usize) > b.remaining() {
return Err(PrcError::Truncated("a Huffman payload"));
}
let mut codes = HashMap::new();
for _ in 0..leaves {
let s = symbol(b.bits(nbits)?, nbits, signed);
let len = b.bits(len_width)?;
if len == 0 || len > MAX_CODE_BITS {
return Err(malformed("Huffman code length"));
}
let code = b.bits(len)?;
if codes.insert((len, code), s).is_some() {
return Err(malformed("duplicate Huffman code"));
}
}
// A leaf whose code begins another's would end that code early, leaving its unread bits
// in front of the next element.
let shadowed = codes.keys().any(|&(len, code)| (1..len).any(|l| codes.contains_key(&(l, code >> (len - l)))));
if shadowed {
return Err(malformed("a Huffman code that is a prefix of another"));
}
Ok(codes)
}
/// One element: code bits, most significant first, until a leaf matches.
fn huffman_symbol(b: &mut Lsb<'_>, codes: &HashMap<(u32, u32), i32>) -> Result<i32, PrcError> {
// prc-rs: the stored code's first bit read is its most significant (the root bit).
let (mut code, mut len) = (0u32, 0u32);
while len < MAX_CODE_BITS {
code = (code << 1) | b.bit()?;
len += 1;
if let Some(&s) = codes.get(&(len, code)) {
return Ok(s);
}
}
Err(malformed("a Huffman code matching no leaf"))
}
/// A `CharacterArray(nbits)` [WD 11.6]. `compressed` is `None` when the flag is written,
/// else the caller's implicit value. Uncompressed elements are whole bytes, sign-extended
/// from 8 bits when `signed`.
pub(crate) fn character_array(r: &mut BitReader<'_>, nbits: u32, compressed: Option<bool>, signed: bool) -> Result<Vec<i32>, PrcError> {
let compressed = match compressed {
Some(c) => c,
None => r.bit()?,
};
if compressed {
return huffman(r, nbits, signed);
}
let n = r.unsigned_integer()? as usize;
if n.saturating_mul(8) > r.remaining() {
return Err(PrcError::Truncated("a character array"));
}
r.take_elements(n)?;
(0..n)
.map(|_| {
let c = r.character()?;
Ok(if signed { i32::from(c.cast_signed()) } else { i32::from(c) })
})
.collect()
}
/// A `ShortArray(nbits)` [WD 11.7]: uncompressed elements are a low then a high byte, read
/// unsigned.
pub(crate) fn short_array(r: &mut BitReader<'_>, nbits: u32) -> Result<Vec<i32>, PrcError> {
if r.bit()? {
return huffman(r, nbits, false);
}
let n = r.unsigned_integer()? as usize;
if n.saturating_mul(16) > r.remaining() {
return Err(PrcError::Truncated("a short array"));
}
r.take_elements(n)?;
(0..n)
.map(|_| {
let lo = r.character()?;
let hi = r.character()?;
Ok(i32::from(u16::from_le_bytes([lo, hi])))
})
.collect()
}
/// A `CompressedIntegerArray` [WD 11.8]: a CharacterArray(6) of widths, then one
/// `IntegerWithVariableBitNumber` per width.
pub(crate) fn compressed_integer_array(r: &mut BitReader<'_>) -> Result<Vec<i64>, PrcError> {
let widths = character_array(r, 6, None, false)?;
widths
.into_iter()
.map(|w| {
let w = u32::try_from(w).map_err(|_| malformed("integer width"))?;
r.integer_vbn(w)
})
.collect()
}
/// A `CompressedIndiceArray` [WD 11.9], read as producers write it: the stored signed 6-bit
/// values are width differences summed into each element's width, and each element is a
/// delta from the previous (the WD's own reading desyncs from the third element). Indices
/// are never negative.
pub(crate) fn compressed_indice_array(r: &mut BitReader<'_>, compressed: Option<bool>) -> Result<Vec<u32>, PrcError> {
let diffs = character_array(r, 6, compressed, true)?;
let mut out = Vec::with_capacity(diffs.len());
let (mut width, mut prev) = (0i64, 0i64);
for d in diffs {
// prc-rs: widths are running sums of the stored differences, values running deltas.
width = width.checked_add(i64::from(d)).ok_or_else(|| malformed("indice width"))?;
let w = u32::try_from(width).map_err(|_| malformed("indice width"))?;
let v = prev.checked_add(r.integer_vbn(w)?).ok_or_else(|| malformed("indice delta"))?;
out.push(u32::try_from(v).map_err(|_| malformed("an index outside 0..2^32"))?);
prev = v;
}
Ok(out)
}
/// An `UncompressedBoolArray` of `n` raw bits (no length prefix).
pub(crate) fn bool_array(r: &mut BitReader<'_>, n: usize) -> Result<Vec<bool>, PrcError> {
if n > r.remaining() {
return Err(PrcError::Truncated("a boolean array"));
}
r.take_elements(n)?;
(0..n).map(|_| r.bit()).collect()
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::testw::W;
fn read<T>(w: &W, f: impl FnOnce(&mut BitReader<'_>) -> Result<T, PrcError>) -> T {
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let v = f(&mut r).unwrap();
assert_eq!(r.position(), w.len(), "bits consumed");
v
}
#[test]
fn huffman_decodes_the_wd_example_with_root_bits() {
// WD 12.2's input {1,3,5,7,7,9,5,3,3}; codes carry the root bit.
let leaves = [(3, 0b100, 3), (7, 0b101, 3), (5, 0b110, 3), (1, 0b1110, 4), (9, 0b1111, 4)];
let values = [1, 3, 5, 7, 7, 9, 5, 3, 3];
let mut w = W::default();
w.bit(true).huffman(4, 3, &leaves, &values);
assert_eq!(read(&w, |r| character_array(r, 4, None, false)), values);
}
#[test]
fn huffman_symbols_sign_extend_only_when_signed() {
let leaves = [(0b111110, 0b10, 2), (1, 0b11, 2)];
let mut w = W::default();
w.huffman(6, 2, &leaves, &[62, 1, 62]);
assert_eq!(read(&w, |r| huffman(r, 6, true)), [-2, 1, -2]);
assert_eq!(read(&w, |r| huffman(r, 6, false)), [62, 1, 62]);
}
#[test]
fn huffman_rejects_codes_that_match_no_leaf_and_empty_payloads_are_empty() {
let mut bad = W::default();
bad.huffman_raw(2, 2, &[(1, 0b10, 2)], &[(0b11, 2)]);
let bytes = bad.bytes();
assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Malformed(_) | PrcError::Truncated(_))));
let mut empty = W::default();
empty.uint(0);
assert!(read(&empty, |r| huffman(r, 2, false)).is_empty());
}
#[test]
fn a_huffman_element_count_past_the_payload_is_truncated_not_allocated() {
// One leaf, then an element count of 2^32 - 1 with no elements behind it.
let mut w = W::default();
w.huffman_count(2, 2, &[(1, 0b10, 2)], u32::MAX);
let bytes = w.bytes();
assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Truncated("a Huffman element count"))));
}
#[test]
fn a_huffman_code_that_begins_another_is_malformed() {
// `10` would end every `100` one bit early.
let mut w = W::default();
w.huffman(2, 2, &[(1, 0b10, 2), (2, 0b100, 3)], &[2, 1]);
let bytes = w.bytes();
let r = huffman(&mut BitReader::new(&bytes), 2, false);
assert!(malformed_by(r, "a Huffman code that is a prefix of another"));
}
#[test]
fn huffman_widths_out_of_range_are_errors() {
// Well-formed payloads at each width, which only the width check refuses.
for nbits in [0, 17] {
let mut w = W::default();
w.huffman(nbits, 2, &[(1, 0b10, 2)], &[1]);
let bytes = w.bytes();
assert!(matches!(huffman(&mut BitReader::new(&bytes), nbits, false), Err(PrcError::Malformed(_))), "{nbits}");
}
}
#[test]
fn uncompressed_arrays() {
let mut w = W::default();
w.bit(false).uint(2).put(0xfe, 8).put(3, 8);
assert_eq!(read(&w, |r| character_array(r, 6, None, true)), [-2, 3]);
let mut s = W::default();
s.bit(false).uint(1).put(0x34, 8).put(0x12, 8);
assert_eq!(read(&s, |r| short_array(r, 16)), [0x1234]);
}
#[test]
fn integer_array_reads_one_width_per_value() {
let mut w = W::default();
w.bit(false).uint(3).put(3, 8).put(0, 8).put(4, 8);
w.bit(true).put(3, 2); // -3 in 3 bits
w.bit(false).put(5, 3); // 5 in 4 bits
assert_eq!(read(&w, compressed_integer_array), [-3, 0, 5]);
}
#[test]
fn indice_array_sums_widths_and_deltas() {
// Values 5, 6, 4: widths 4, 2, 3 stored as diffs 4, -2, +1.
let mut w = W::default();
w.bit(false).uint(3).put(4, 8).put(0xfe, 8).put(1, 8);
w.bit(false).put(5, 3); // 5
w.bit(false).put(1, 1); // +1
w.bit(true).put(2, 2); // -2
assert_eq!(read(&w, |r| compressed_indice_array(r, None)), [5, 6, 4]);
// The implicit-flag form reads no flag bit.
let mut i = W::default();
i.uint(1).put(2, 8).bit(false).put(1, 1);
assert_eq!(read(&i, |r| compressed_indice_array(r, Some(false))), [1]);
// A negative running index is damage.
let mut n = W::default();
n.bit(false).uint(1).put(2, 8).bit(true).put(1, 1);
let bytes = n.bytes();
assert!(compressed_indice_array(&mut BitReader::new(&bytes), None).is_err());
}
#[test]
fn a_bool_array_longer_than_the_data_is_truncated() {
assert!(matches!(bool_array(&mut BitReader::new(&[0]), 9), Err(PrcError::Truncated(_))));
assert_eq!(bool_array(&mut BitReader::new(&[0x80]), 2).unwrap(), [true, false]);
}
#[test]
fn decoded_elements_are_charged_against_one_allowance() {
// One bit can decode to a 4-byte element, so the byte cap alone does not bound memory.
let leaves = [(0b111110, 0b10, 2), (1, 0b11, 2)];
let mut w = W::default();
w.huffman(6, 2, &leaves, &[62, 1, 62]);
let bytes = w.bytes();
let mut r = BitReader::with_elements(&bytes, 2);
assert!(matches!(huffman(&mut r, 6, false), Err(PrcError::TooManyElements { .. })));
let mut r = BitReader::with_elements(&bytes, 4);
assert_eq!(huffman(&mut r, 6, false).unwrap(), [62, 1, 62]);
assert_eq!(r.elements_left(), 1);
assert!(matches!(bool_array(&mut BitReader::with_elements(&[0], 1), 2), Err(PrcError::TooManyElements { .. })));
}
/// Uncompressed character and short arrays are charged against the allowance too, and a
/// character count past the data is refused by name before anything is read.
#[test]
fn uncompressed_arrays_are_charged_and_their_counts_checked() {
let mut w = W::default();
w.uint(3).put(0, 8).put(1, 8).put(2, 8);
let chars = w.bytes();
let read = |n| character_array(&mut BitReader::with_elements(&chars, n), 8, Some(false), false);
assert!(matches!(read(2), Err(PrcError::TooManyElements { .. })));
assert_eq!(read(3).unwrap(), [0, 1, 2]);
let mut w = W::default();
w.bit(false).uint(2).put(1, 8).put(0, 8).put(2, 8).put(0, 8);
let shorts = w.bytes();
assert!(matches!(short_array(&mut BitReader::with_elements(&shorts, 1), 8), Err(PrcError::TooManyElements { .. })));
assert_eq!(short_array(&mut BitReader::with_elements(&shorts, 2), 8).unwrap(), [1, 2]);
let mut w = W::default();
w.uint(1000).put(7, 8);
let short_of_data = w.bytes();
let res = character_array(&mut BitReader::new(&short_of_data), 8, Some(false), false);
assert!(matches!(res, Err(PrcError::Truncated("a character array"))), "{res:?}");
}
fn malformed_by(res: Result<Vec<i32>, PrcError>, what: &str) -> bool {
matches!(res, Err(PrcError::Malformed(m)) if m == what)
}
#[test]
fn a_zero_code_length_width_or_length_is_refused_by_name() {
let mut w = W::default();
w.huffman_raw(2, 0, &[(1, 0, 0)], &[]);
let bytes = w.bytes();
assert!(malformed_by(huffman(&mut BitReader::new(&bytes), 2, false), "Huffman code-length width"));
let mut w = W::default();
w.huffman_raw(2, 2, &[(1, 0, 0)], &[]);
let bytes = w.bytes();
assert!(malformed_by(huffman(&mut BitReader::new(&bytes), 2, false), "Huffman code length"));
}
#[test]
fn a_boolean_array_past_the_data_is_refused_before_it_is_read() {
let mut w = W::default();
w.put(0b1011, 4);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let n = r.remaining() + 1;
assert_eq!(bool_array(&mut r, n), Err(PrcError::Truncated("a boolean array")));
assert_eq!(r.position(), 0, "nothing read");
}
}
+491
View File
@@ -0,0 +1,491 @@
//! The bit-level reader for PRC compressed sections [WD 10.3, 11.1-11.17].
//!
//! Values are consumed MSB-first from each inflated byte and straddle byte boundaries freely
//! [WD 10.3.1, 11.3]. Multi-byte integers are groups of eight bits, low group first. Two sign
//! conventions coexist: `Integer` is two's complement, the `*WithVariableBitNumber` types are
//! sign-magnitude.
use crate::PrcError;
use crate::acof::{self, Entry};
/// The most array elements the readers of one PRC stream decode together (2^24): at most 8
/// bytes each, so 128 MiB.
pub const MAX_DECODED_ELEMENTS: usize = 1 << 24;
/// The most schema-program steps (tokens executed, loop iterations included) the readers of
/// one PRC stream run together.
pub const MAX_SCHEMA_STEPS: u64 = 1 << 22;
/// What the readers of one stream may still spend, carried from each reader to the next.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Budget {
/// Array elements still to decode.
pub(crate) elements: usize,
/// Schema-program steps still to run.
pub(crate) steps: u64,
}
impl Budget {
/// A whole stream's allowance.
pub(crate) const FULL: Self = Self { elements: MAX_DECODED_ELEMENTS, steps: MAX_SCHEMA_STEPS };
}
/// A cursor over one inflated section.
#[derive(Debug, Clone)]
pub struct BitReader<'a> {
data: &'a [u8],
pos: usize,
budget: Budget,
}
impl<'a> BitReader<'a> {
/// A reader positioned at the first bit of `data`, allowed [`MAX_DECODED_ELEMENTS`] and
/// [`MAX_SCHEMA_STEPS`].
pub fn new(data: &'a [u8]) -> Self {
Self::with_budget(data, Budget::FULL)
}
/// A reader of `data` allowed `budget`: what an earlier reader of the same stream left.
pub(crate) fn with_budget(data: &'a [u8], budget: Budget) -> Self {
Self { data, pos: 0, budget }
}
/// A reader of `data` allowed `elements` array elements and a full step allowance.
#[cfg(test)]
pub(crate) fn with_elements(data: &'a [u8], elements: usize) -> Self {
Self::with_budget(data, Budget { elements, ..Budget::FULL })
}
/// This reader, at its position, allowed `budget` instead: what readers of the same
/// stream left since it stopped.
pub(crate) fn with_remaining(mut self, budget: Budget) -> Self {
self.budget = budget;
self
}
/// What this reader may still spend.
pub(crate) fn budget(&self) -> Budget {
self.budget
}
/// Array elements this reader may still decode.
#[cfg(test)]
pub(crate) fn elements_left(&self) -> usize {
self.budget.elements
}
/// Charge `n` decoded array elements against the allowance.
///
/// # Errors
/// [`PrcError::TooManyElements`] when fewer than `n` are left.
pub(crate) fn take_elements(&mut self, n: usize) -> Result<(), PrcError> {
self.budget.elements = self.budget.elements.checked_sub(n).ok_or(PrcError::TooManyElements { limit: MAX_DECODED_ELEMENTS })?;
Ok(())
}
/// Charge one schema-program step; false when none is left.
pub(crate) fn take_step(&mut self) -> bool {
match self.budget.steps.checked_sub(1) {
Some(left) => {
self.budget.steps = left;
true
}
None => false,
}
}
/// Bits consumed so far.
pub fn position(&self) -> usize {
self.pos
}
/// Bits left before the end of the data.
pub fn remaining(&self) -> usize {
self.data.len().saturating_mul(8).saturating_sub(self.pos)
}
/// Step over `n` bits of opaque data (`UserData` [WD 8.6]).
///
/// # Errors
/// [`PrcError::Truncated`] when fewer than `n` bits remain; the cursor does not move.
pub fn skip_bits(&mut self, n: usize) -> Result<(), PrcError> {
if n > self.remaining() {
return Err(PrcError::Truncated("user data"));
}
self.pos = self.pos.saturating_add(n);
Ok(())
}
/// One bit; a PRC `Boolean` [WD 10.3.3].
///
/// # Errors
/// [`PrcError::Truncated`] at the end of the data.
pub fn bit(&mut self) -> Result<bool, PrcError> {
let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a bit"))?;
let b = (byte >> (7 - (self.pos % 8))) & 1 == 1;
// `pos / 8` indexed the data, so `pos + 1` is at most `8 * len`.
self.pos = self.pos.saturating_add(1);
Ok(b)
}
/// `n` bits (at most 32), MSB-first; `UnsignedIntegerWithVariableBitNumber` [WD 11.13].
///
/// # Errors
/// [`PrcError::Truncated`] at the end of the data; [`PrcError::Malformed`] for `n > 32`.
pub fn bits(&mut self, n: u32) -> Result<u32, PrcError> {
if n > 32 {
return Err(PrcError::Malformed(format!("{n}-bit field")));
}
if self.remaining() < n as usize {
return Err(PrcError::Truncated("a bit field"));
}
let mut v: u64 = 0;
for _ in 0..n {
v = (v << 1) | u64::from(self.bit()?);
}
// n <= 32, so v fits.
Ok(u32::try_from(v).unwrap_or(u32::MAX))
}
fn byte(&mut self) -> Result<u8, PrcError> {
Ok(u8::try_from(self.bits(8)?).unwrap_or(u8::MAX))
}
/// A `Character`: one byte [WD 10.3.4].
///
/// # Errors
/// [`PrcError::Truncated`].
pub fn character(&mut self) -> Result<u8, PrcError> {
self.byte()
}
/// An `UnsignedInteger` [WD 11.10]: while a 1 bit precedes it, one more eight-bit group,
/// low group first.
///
/// # Errors
/// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups.
pub fn unsigned_integer(&mut self) -> Result<u32, PrcError> {
let mut v: u32 = 0;
let mut shift = 0;
while self.bit()? {
if shift >= 32 {
return Err(PrcError::Malformed("UnsignedInteger over 32 bits".into()));
}
v |= u32::from(self.byte()?) << shift;
shift += 8;
}
Ok(v)
}
/// An `Integer` [WD 11.11]: a 0 bit is zero; otherwise eight-bit groups, low group first,
/// each followed by a continue bit, sign-extended from the last group's top bit.
///
/// # Errors
/// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups.
pub fn integer(&mut self) -> Result<i32, PrcError> {
if !self.bit()? {
return Ok(0);
}
let mut v: u32 = 0;
let mut shift = 0;
loop {
if shift >= 32 {
return Err(PrcError::Malformed("Integer over 32 bits".into()));
}
let group = self.byte()?;
v |= u32::from(group) << shift;
shift += 8;
if !self.bit()? {
if group & 0x80 != 0 && shift < 32 {
v |= u32::MAX << shift;
}
return Ok(v.cast_signed());
}
}
}
/// An `IntegerWithVariableBitNumber(n)` [WD 11.12]: a negative flag (1 = negative; the WD
/// prose says the opposite, its sample code and real files agree on this), then the
/// magnitude in `n - 1` bits.
///
/// # Errors
/// [`PrcError::Truncated`]; [`PrcError::Malformed`] for `n > 33`.
pub fn integer_vbn(&mut self, n: u32) -> Result<i64, PrcError> {
let Some(magnitude_bits) = n.checked_sub(1) else {
return Ok(0);
};
let negative = self.bit()?;
let m = i64::from(self.bits(magnitude_bits)?);
Ok(if negative { -m } else { m })
}
/// A `DoubleWithVariableBitNumber(tolerance, n)` [WD 11.14]: a sign-magnitude quantum
/// count times `tolerance`.
///
/// # Errors
/// As [`Self::integer_vbn`].
pub fn double_vbn(&mut self, tolerance: f64, n: u32) -> Result<f64, PrcError> {
Ok(self.integer_vbn(n)? as f64 * tolerance)
}
/// `NumberOfBitsThenUnsignedInteger` [WD 11.15]: a 5-bit width, then the value in that
/// many bits.
///
/// # Errors
/// [`PrcError::Truncated`].
pub fn nbits_then_unsigned(&mut self) -> Result<u32, PrcError> {
let n = self.bits(5)?;
self.bits(n)
}
/// A `String` [WD 10.3.7, 11.4]: `None` for the null string, else UTF-8 (invalid
/// sequences replaced).
///
/// # Errors
/// [`PrcError::Truncated`] when the byte count runs past the data.
pub fn string(&mut self) -> Result<Option<String>, PrcError> {
if !self.bit()? {
return Ok(None);
}
let len = self.unsigned_integer()? as usize;
if len.saturating_mul(8) > self.remaining() {
return Err(PrcError::Truncated("a String"));
}
let bytes = (0..len).map(|_| self.byte()).collect::<Result<Vec<u8>, _>>()?;
Ok(Some(String::from_utf8_lossy(&bytes).into_owned()))
}
/// `FloatAsBytes` [WD 11.5]: an IEEE binary32, least-significant byte first.
///
/// # Errors
/// [`PrcError::Truncated`].
pub fn float_as_bytes(&mut self) -> Result<f32, PrcError> {
let b = [self.byte()?, self.byte()?, self.byte()?, self.byte()?];
Ok(f32::from_le_bytes(b))
}
/// A `Double` [WD 11.17]: a prefix code from the Double table, then a sign bit and, for
/// an exponent code, a byte-wise mantissa with back-references.
///
/// The two-bit zero code has no sign bit. Back-reference offsets 1 to 5 are all accepted,
/// distance 1 included (PDF Association pdf-issues #769).
///
/// # Errors
/// [`PrcError::UnknownDoubleCode`], [`PrcError::Truncated`], or [`PrcError::Malformed`]
/// for a back-reference outside the value.
pub fn double(&mut self) -> Result<f64, PrcError> {
// prc-rs: the decoding steps (code lookup, sign, exponent, byte-wise mantissa with
// back-references) follow prc-rs's.
let mut code = 0u32;
let mut entry = None;
for n in 1..=acof::MAX_CODE_BITS {
code = (code << 1) | u32::from(self.bit()?);
if let Some(e) = acof::lookup(n, code) {
if n == 2 && code == 0b01 {
return Ok(0.0);
}
entry = Some(e);
break;
}
}
let entry = entry.ok_or(PrcError::UnknownDoubleCode)?;
let negative = self.bit()?;
let upper = match entry {
Entry::Double(v) => return Ok(if negative { -v } else { v }),
Entry::Exponent(upper) => upper,
};
let mut b = (u64::from(upper) << 32).to_le_bytes();
if self.bit()? {
self.mantissa(&mut b)?;
}
let sign = if negative { 1u64 << 63 } else { 0 };
Ok(f64::from_bits(u64::from_le_bytes(b) | sign))
}
/// Mantissa bits 51..0 into `b` (little-endian byte order): a nibble, then bytes 5 down
/// to 0, each a literal or a back-reference to a byte already set.
fn mantissa(&mut self, b: &mut [u8; 8]) -> Result<(), PrcError> {
let nibble = u8::try_from(self.bits(4)?).unwrap_or(0);
let [.., b6, _] = b;
*b6 |= nibble;
let at = |b: &[u8; 8], i: usize| b.get(i).copied().ok_or_else(|| PrcError::Malformed("Double back-reference past the value".into()));
for cbi in (0..=5usize).rev() {
let prev = at(b, cbi + 1)?;
let value = if self.bit()? {
self.byte()?
} else {
match self.bits(3)? {
// Fill this byte and every lower one with the previous byte.
0 => {
b.iter_mut().take(cbi + 1).for_each(|x| *x = prev);
return Ok(());
}
// As 0, but the lowest byte is a literal.
6 => {
b.iter_mut().take(cbi + 1).skip(1).for_each(|x| *x = prev);
let low = self.byte()?;
let [b0, ..] = b;
*b0 = low;
return Ok(());
}
off @ 1..=5 => at(b, cbi + off as usize)?,
_ => return Err(PrcError::Malformed("Double mantissa offset 7".into())),
}
};
if let Some(x) = b.get_mut(cbi) {
*x = value;
}
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::testw::W;
/// The code for IEEE exponent `e`.
fn exponent_code(e: u32) -> (u32, u32) {
acof::exponent_code(e).expect("the table has a row per exponent")
}
fn read_double(w: &W) -> f64 {
BitReader::new(&w.bytes()).double().unwrap()
}
#[test]
fn zero_is_the_two_bit_code_with_no_sign() {
let mut w = W::default();
w.put(0b01, 2).put(0b1111_1111, 8);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
assert_eq!(r.double().unwrap(), 0.0);
assert_eq!(r.position(), 2);
}
#[test]
fn exponent_with_literal_mantissa() {
let v = -1234.5678f64;
let le = v.to_bits().to_le_bytes();
let e = u32::try_from((v.to_bits() >> 52) & 0x7ff).unwrap();
let (n, c) = exponent_code(e);
let mut w = W::default();
w.put(u64::from(c), n).put(1, 1).put(1, 1).put(u64::from(le[6] & 0x0f), 4);
for byte in le[..6].iter().rev() {
w.put(1, 1).put(u64::from(*byte), 8);
}
assert_eq!(read_double(&w), v);
}
#[test]
fn exponent_without_mantissa_is_a_power_of_two() {
let (n, c) = exponent_code(1023 + 3);
let mut w = W::default();
w.put(u64::from(c), n).put(0, 1).put(0, 1);
assert_eq!(read_double(&w), 8.0);
}
#[test]
fn fill_back_reference_and_literal_low_byte() {
// Mantissa bytes 6..0 = 0x05, 0xAA, 0xAA (offset 1), 0xAA (offset 2), fill 0xAA to byte 1, byte 0 = 0x11.
let (n, c) = exponent_code(1023);
let mut w = W::default();
w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4);
w.put(1, 1).put(0xAA, 8);
w.put(0, 1).put(1, 3);
w.put(0, 1).put(2, 3);
w.put(0, 1).put(6, 3).put(0x11, 8);
assert_eq!(read_double(&w), f64::from_le_bytes([0x11, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xF5, 0x3F]));
// Byte 5 literal, then fill 4..0.
let mut w = W::default();
w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4);
w.put(1, 1).put(0x77, 8).put(0, 1).put(0, 3);
assert_eq!(read_double(&w), f64::from_le_bytes([0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0xF5, 0x3F]));
}
#[test]
fn a_back_reference_past_the_value_is_refused() {
let (n, c) = exponent_code(1023);
let mut w = W::default();
w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0, 4);
w.put(0, 1).put(5, 3).put(0, 16); // byte 5 + offset 5 = byte 10
assert!(matches!(BitReader::new(&w.bytes()).double(), Err(PrcError::Malformed(_))));
}
#[test]
fn a_truncated_double_is_an_error_not_a_panic() {
// No code is 8 bits or shorter and a prefix of 0x17, so one byte ends inside a code.
assert!(matches!(BitReader::new(&[0x17]).double(), Err(PrcError::Truncated(_))));
assert!(matches!(BitReader::new(&[]).double(), Err(PrcError::Truncated(_))));
}
#[test]
fn integers_and_strings() {
let mut w = W::default();
// UnsignedInteger 300 = groups 0x2C, 0x01.
w.put(1, 1).put(0x2C, 8).put(1, 1).put(0x01, 8).put(0, 1);
// Integer -2 = group 0xFE, stop.
w.put(1, 1).put(0xFE, 8).put(0, 1);
// Integer 200 = 0xC8 then 0x00 (top bit clear), stop.
w.put(1, 1).put(0xC8, 8).put(1, 1).put(0x00, 8).put(0, 1);
// IntegerWithVariableBitNumber(4) = -5.
w.put(1, 1).put(5, 3);
// String "hé".
w.put(1, 1).put(1, 1).put(3, 8).put(0, 1).put(0x68, 8).put(0xC3, 8).put(0xA9, 8);
// Null string.
w.put(0, 1);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
assert_eq!(r.unsigned_integer().unwrap(), 300);
assert_eq!(r.integer().unwrap(), -2);
assert_eq!(r.integer().unwrap(), 200);
assert_eq!(r.integer_vbn(4).unwrap(), -5);
assert_eq!(r.string().unwrap().as_deref(), Some("hé"));
assert_eq!(r.string().unwrap(), None);
}
#[test]
fn the_writer_round_trips_through_the_reader() {
let mut w = W::default();
w.uint(0).uint(u32::MAX).int(-129).int(i32::MIN).int(i32::MAX).string(Some("glass")).string(None);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
assert_eq!(r.unsigned_integer().unwrap(), 0);
assert_eq!(r.unsigned_integer().unwrap(), u32::MAX);
assert_eq!(r.integer().unwrap(), -129);
assert_eq!(r.integer().unwrap(), i32::MIN);
assert_eq!(r.integer().unwrap(), i32::MAX);
assert_eq!(r.string().unwrap().as_deref(), Some("glass"));
assert_eq!(r.string().unwrap(), None);
}
#[test]
fn oversized_groups_and_widths_are_malformed() {
// Five UnsignedInteger groups.
let mut w = W::default();
(0..5).for_each(|_| {
w.put(1, 1).put(0xFF, 8);
});
w.put(0, 1);
assert!(matches!(BitReader::new(&w.bytes()).unsigned_integer(), Err(PrcError::Malformed(_))));
assert!(matches!(BitReader::new(&[0; 8]).bits(33), Err(PrcError::Malformed(_))));
assert!(matches!(BitReader::new(&[0; 8]).integer_vbn(34), Err(PrcError::Malformed(_))));
assert_eq!(BitReader::new(&[]).integer_vbn(0), Ok(0));
}
#[test]
fn truncation_is_an_error_not_a_panic() {
let mut r = BitReader::new(&[0xFF]);
assert!(r.unsigned_integer().is_err());
let mut r = BitReader::new(&[0xC0, 0x40]);
assert!(matches!(r.string(), Err(PrcError::Truncated(_))));
let mut r = BitReader::new(&[0xAB]);
assert!(matches!(r.skip_bits(9), Err(PrcError::Truncated(_))));
assert_eq!(r.position(), 0, "a refused skip does not move the cursor");
r.skip_bits(8).unwrap();
assert_eq!(r.remaining(), 0);
}
}
+340
View File
@@ -0,0 +1,340 @@
//! The PRC container: file header, file-structure descriptions, section extents and
//! inflation [WD 6.1-6.2, 10.2].
//!
//! Section extents are not stored: each section runs to the next one's start offset, and the
//! last one to the model file's start. Inflation stops at the zlib stream's end, so padding
//! between sections is tolerated.
use std::io::Read;
use crate::PrcError;
use crate::bits::BitReader;
use crate::schema::Schema;
/// The PRC version this reader implements.
pub const PRC_READER_VERSION: u32 = 8137;
/// The most file structures [`PrcFile::parse`] accepts.
pub const MAX_FILE_STRUCTURES: usize = 65_536;
/// Ceiling on the total inflated size of every section in one PRC stream, together with the
/// picture and uncompressed blocks it copies.
pub const MAX_INFLATED_BYTES: usize = 512 * 1024 * 1024;
/// Sections per file structure [WD 6.1.2]: its header, then the five in [`SectionKind`]
/// order.
const SECTIONS: usize = 6;
/// A 128-bit PRC identifier [WD 10.2.4]: four little-endian `u32`s.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct UniqueId(pub [u32; 4]);
/// The compressed sections of one file structure, in file order [WD 6.1.2, 6.2.1].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SectionKind {
/// Starts with the file structure's schema, then its globals entity.
Globals,
/// The product-structure tree.
Tree,
/// Tessellated (mesh) data.
Tessellation,
/// Exact (B-rep) geometry.
Geometry,
/// Supplementary geometry.
ExtraGeometry,
}
impl SectionKind {
/// Every kind, in file order.
pub const ALL: [SectionKind; 5] =
[SectionKind::Globals, SectionKind::Tree, SectionKind::Tessellation, SectionKind::Geometry, SectionKind::ExtraGeometry];
/// The section's name, as used in errors.
pub fn name(self) -> &'static str {
match self {
SectionKind::Globals => "globals",
SectionKind::Tree => "tree",
SectionKind::Tessellation => "tessellation",
SectionKind::Geometry => "geometry",
SectionKind::ExtraGeometry => "extra geometry",
}
}
fn index(self) -> usize {
match self {
SectionKind::Globals => 0,
SectionKind::Tree => 1,
SectionKind::Tessellation => 2,
SectionKind::Geometry => 3,
SectionKind::ExtraGeometry => 4,
}
}
}
/// The uncompressed file header [WD 6.1.1].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrcHeader {
/// The oldest reader version the producer says can read the file.
pub min_version_for_read: u32,
/// The producer's PRC version.
pub authoring_version: u32,
/// The file's identifier.
pub file_id: UniqueId,
/// The producing application's identifier (all zero = unregistered).
pub application_id: UniqueId,
}
/// One file structure: its uncompressed header fields and its five inflated sections
/// [WD 6.2].
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct FileStructure {
/// The identifier; equals the file header's description of it.
pub id: UniqueId,
/// Per-structure reader version floor; may differ from the file's.
pub min_version_for_read: u32,
/// Per-structure producer version.
pub authoring_version: u32,
/// Picture payloads (JPEG, PNG, ...) carried uncompressed in the header, referenced by
/// index from picture entities [WD 6.2.2].
pub pictures: Vec<Vec<u8>>,
sections: [Vec<u8>; 5],
}
impl FileStructure {
/// The inflated bytes of one section.
pub fn section(&self, kind: SectionKind) -> &[u8] {
self.sections.get(kind.index()).map_or(&[], Vec::as_slice)
}
/// The globals section's leading schema: the fields producers newer than this reader
/// added to each entity type [WD 6.3].
///
/// # Errors
/// As [`Schema::read`].
pub fn schema(&self) -> Result<Schema, PrcError> {
Schema::read(&mut BitReader::new(self.section(SectionKind::Globals)))
}
}
/// A parsed PRC stream: header, file structures and model-file section.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PrcFile {
/// The file header.
pub header: PrcHeader,
/// File structures in physical order (referenced before referencing).
pub file_structures: Vec<FileStructure>,
/// The inflated model-file section: its schema, then the model file entity.
pub model_file: Vec<u8>,
/// The `UncompressedFiles` blocks after the file header [WD 6.1.1], which picture
/// entities may also reference.
pub uncompressed_files: Vec<Vec<u8>>,
}
/// A little-endian byte cursor over the uncompressed parts of the stream.
struct Bytes<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Bytes<'a> {
fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], PrcError> {
let end = self.pos.checked_add(n).ok_or(PrcError::Truncated(what))?;
let s = self.data.get(self.pos..end).ok_or(PrcError::Truncated(what))?;
self.pos = end;
Ok(s)
}
fn u32(&mut self, what: &'static str) -> Result<u32, PrcError> {
let s = self.take(4, what)?;
let b: [u8; 4] = s.try_into().map_err(|_| PrcError::Truncated(what))?;
Ok(u32::from_le_bytes(b))
}
fn id(&mut self, what: &'static str) -> Result<UniqueId, PrcError> {
Ok(UniqueId([self.u32(what)?, self.u32(what)?, self.u32(what)?, self.u32(what)?]))
}
fn magic(&mut self) -> Result<(), PrcError> {
match self.take(3, "magic") {
Ok(b"PRC") => Ok(()),
_ => Err(PrcError::NotPrc),
}
}
/// `UncompressedBlock`s: a count, then size-prefixed blocks [WD 10.2.2-3], each charged to
/// `budget` before it is copied.
fn blocks(&mut self, what: &'static str, budget: &mut Budget) -> Result<Vec<Vec<u8>>, PrcError> {
let n = offset(self.u32(what)?);
// Each block needs at least its 4-byte size, so `n` is bounded by the data left.
if n > self.data.len().saturating_sub(self.pos) / 4 {
return Err(PrcError::Truncated(what));
}
(0..n)
.map(|_| {
let len = offset(self.u32(what)?);
let block = self.take(len, what)?;
budget.charge(block.len())?;
Ok(block.to_vec())
})
.collect()
}
}
/// A stored `u32` offset or count as a `usize` (saturating on a 16-bit target, where no data
/// that large could exist).
pub(crate) fn offset(v: u32) -> usize {
usize::try_from(v).unwrap_or(usize::MAX)
}
/// One file structure description of the file header [WD 6.2]: its id and the offsets of
/// its sections.
fn file_structure_description(r: &mut Bytes) -> Result<(UniqueId, Vec<usize>), PrcError> {
let id = r.id("file structure description")?;
if r.u32("file structure description")? != 0 {
return Err(PrcError::Malformed("file structure reserved field is not 0".into()));
}
let count = offset(r.u32("file structure description")?);
if count < SECTIONS {
return Err(PrcError::Malformed(format!("{count} sections in a file structure")));
}
let mut offs = Vec::with_capacity(SECTIONS);
// Offsets past the six this reader uses are read and dropped; each read is bounded by
// the data, so a huge `count` ends in `Truncated`.
for i in 0..count {
let o = r.u32("section offset")?;
if i < SECTIONS {
offs.push(offset(o));
}
}
Ok((id, offs))
}
/// Where one file structure's parts lie: its section offsets and the end of its last section.
struct Extent<'a> {
offs: &'a [usize],
last: usize,
}
impl Extent<'_> {
fn bound(&self, i: usize) -> usize {
self.offs.get(i).copied().unwrap_or(self.last)
}
}
/// The file structure `id` lying at `extent`: its header and its inflated sections, charged
/// to `budget`.
fn file_structure(data: &[u8], id: UniqueId, extent: &Extent, budget: &mut Budget) -> Result<FileStructure, PrcError> {
if extent.offs.windows(2).any(|w| w.first() > w.get(1)) || extent.bound(SECTIONS - 1) > extent.last {
return Err(PrcError::Malformed("file structure section offsets are not ascending".into()));
}
let header = data.get(..extent.bound(1)).ok_or(PrcError::Truncated("file structure header"))?;
let mut h = Bytes { data: header, pos: extent.bound(0) };
h.magic()?;
let min_version_for_read = h.u32("file structure header")?;
let authoring_version = h.u32("file structure header")?;
if h.id("file structure header")? != id {
return Err(PrcError::Malformed("file structure id differs from its description".into()));
}
let _application = h.id("file structure header")?;
// Headers may overlap, so each structure's copy of its pictures is charged.
let pictures = h.blocks("file structure pictures", budget)?;
let mut sections: [Vec<u8>; 5] = Default::default();
for (i, (slot, kind)) in sections.iter_mut().zip(SectionKind::ALL).enumerate() {
let end = if i + 2 < SECTIONS { extent.bound(i + 2) } else { extent.last };
*slot = budget.inflate(data, (extent.bound(i + 1), end), kind.name())?;
}
Ok(FileStructure { id, min_version_for_read, authoring_version, pictures, sections })
}
/// What is left of the inflation ceiling, and the ceiling itself for the error.
struct Budget {
left: usize,
limit: usize,
}
impl Budget {
/// The zlib stream in `data[range]`, its size charged to the budget.
fn inflate(&mut self, data: &[u8], range: (usize, usize), section: &'static str) -> Result<Vec<u8>, PrcError> {
let (start, end) = range;
if start == end {
return Ok(Vec::new());
}
let src = data.get(start..end).ok_or_else(|| PrcError::Malformed(format!("{section} section {start}..{end} is outside the data")))?;
let mut out = Vec::new();
// One byte past the budget is enough to know it was exceeded.
let cap = u64::try_from(self.left).unwrap_or(u64::MAX).saturating_add(1);
flate2::read::ZlibDecoder::new(src).take(cap).read_to_end(&mut out).map_err(|e| PrcError::Inflate { section, reason: e.to_string() })?;
self.charge(out.len())?;
Ok(out)
}
/// Spend `n` bytes of the ceiling.
fn charge(&mut self, n: usize) -> Result<(), PrcError> {
self.left = self.left.checked_sub(n).ok_or(PrcError::TooLarge { limit: self.limit })?;
Ok(())
}
}
impl PrcFile {
/// Parse a PRC stream (the decoded data of a `/3D` stream with `/Subtype /PRC`).
///
/// Newer `min_version_for_read` values are not refused: the fields read here are
/// unchanged across published versions, and the caller sees the versions in
/// [`PrcHeader`].
///
/// # Errors
/// [`PrcError::NotPrc`] without the magic; [`PrcError::Truncated`] or
/// [`PrcError::Malformed`] for a damaged header or offset table; [`PrcError::Inflate`]
/// for a section that is not zlib; [`PrcError::TooLarge`] past [`MAX_INFLATED_BYTES`]
/// in total, inflated sections and copied blocks together.
///
/// # Examples
/// ```
/// use pdfcraft_prc::{PrcError, PrcFile};
/// assert_eq!(PrcFile::parse(b"%PDF"), Err(PrcError::NotPrc));
/// ```
pub fn parse(data: &[u8]) -> Result<Self, PrcError> {
Self::parse_with_limit(data, MAX_INFLATED_BYTES)
}
/// [`Self::parse`] with a caller-chosen ceiling on the total inflated size, for callers
/// with less memory to spend than [`MAX_INFLATED_BYTES`].
///
/// # Errors
/// As [`Self::parse`]; [`PrcError::TooLarge`] carries `limit`.
pub fn parse_with_limit(data: &[u8], limit: usize) -> Result<Self, PrcError> {
let mut r = Bytes { data, pos: 0 };
r.magic()?;
let header = PrcHeader {
min_version_for_read: r.u32("file header")?,
authoring_version: r.u32("file header")?,
file_id: r.id("file header")?,
application_id: r.id("file header")?,
};
let n_fs = offset(r.u32("file structure count")?);
if n_fs == 0 || n_fs > MAX_FILE_STRUCTURES {
return Err(PrcError::Malformed(format!("{n_fs} file structures")));
}
let descriptions = (0..n_fs).map(|_| file_structure_description(&mut r)).collect::<Result<Vec<_>, _>>()?;
let mf_start = offset(r.u32("model file offsets")?);
let mf_end = offset(r.u32("model file offsets")?);
let mut budget = Budget { left: limit, limit };
let uncompressed_files = r.blocks("uncompressed files", &mut budget)?;
if mf_end < mf_start {
return Err(PrcError::Malformed("model file ends before it starts".into()));
}
if mf_end > data.len() {
return Err(PrcError::Malformed(format!("model file ends at {mf_end}, past the {}-byte stream", data.len())));
}
// prc-rs: the last section runs to the model file's start.
let last = mf_start.min(data.len());
let file_structures =
descriptions.iter().map(|(id, offs)| file_structure(data, *id, &Extent { offs, last }, &mut budget)).collect::<Result<Vec<_>, _>>()?;
let model_file = budget.inflate(data, (mf_start, mf_end), "model file")?;
Ok(PrcFile { header, file_structures, model_file, uncompressed_files })
}
}
+127
View File
@@ -0,0 +1,127 @@
//! What every section reader shares: the cursor, the schema, the authoring version, and the
//! base fields every PRC entity starts with [WD 7.2.3, 7.4.3, 8.6].
use crate::PrcError;
use crate::bits::BitReader;
use crate::schema::Schema;
use crate::tree::Graphics;
const ATTRIBUTE: u32 = 201;
/// A [`PrcError::Malformed`] naming `what`.
pub(crate) fn malformed(what: String) -> PrcError {
PrcError::Malformed(what)
}
/// A section reader.
pub(crate) struct Ctx<'a, 's> {
pub(crate) r: BitReader<'a>,
pub(crate) schema: &'s Schema,
/// The file structure's authoring version; gates version-added fields.
pub(crate) version: u32,
/// The current name: what a `same_name` entity reuses; every entity's own name replaces
/// it [WD 7.2.3.4]. Each section starts with none.
pub(crate) name: Option<String>,
/// The PRC unique id of the last `ContentPRCRefBase` read [WD 7.2.3.2].
pub(crate) uid: u32,
/// The current graphics: what a `same_graphics` entity reuses [WD 7.2.4].
pub(crate) graphics: Graphics,
}
impl<'a, 's> Ctx<'a, 's> {
pub(crate) fn new(r: BitReader<'a>, schema: &'s Schema, version: u32) -> Self {
Ctx { r, schema, version, name: None, uid: 0, graphics: Graphics::default() }
}
/// Reads an entity type code; any value but `want` is malformed.
pub(crate) fn expect_type(&mut self, want: u32) -> Result<(), PrcError> {
let t = self.r.unsigned_integer()?;
if t != want {
return Err(malformed(format!("entity type {t} where {want} belongs")));
}
Ok(())
}
/// A count that must fit in the remaining data at `min_bits` per item, which bounds any
/// allocation sized by it.
pub(crate) fn count(&mut self, min_bits: usize, what: &'static str) -> Result<usize, PrcError> {
let n = self.r.unsigned_integer()? as usize;
if n.saturating_mul(min_bits) > self.r.remaining() {
return Err(PrcError::Truncated(what));
}
self.r.take_elements(n)?;
Ok(n)
}
/// A counted array of `UnsignedInteger`s.
pub(crate) fn uints(&mut self, what: &'static str) -> Result<Vec<u32>, PrcError> {
let n = self.count(1, what)?;
(0..n).map(|_| self.r.unsigned_integer()).collect()
}
/// A counted array of `Double`s.
pub(crate) fn doubles(&mut self, what: &'static str) -> Result<Vec<f64>, PrcError> {
let n = self.count(2, what)?;
(0..n).map(|_| self.r.double()).collect()
}
/// `AttributeEntry` [WD 7.4.3]: a predefined title code or a string.
fn attribute_entry(&mut self) -> Result<(), PrcError> {
if self.r.bit()? {
self.r.unsigned_integer()?;
} else {
self.r.string()?;
}
Ok(())
}
/// One attribute value [WD 7.4.3], read past.
fn attribute_value(&mut self) -> Result<(), PrcError> {
match self.r.unsigned_integer()? {
0 => {}
1 | 3 => {
self.r.integer()?;
}
2 => {
self.r.double()?;
}
4 => {
self.r.string()?;
}
// prc-rs: a 64-bit integer is its high part, then its low part.
5 => {
self.r.integer()?;
self.r.unsigned_integer()?;
}
k => return Err(malformed(format!("attribute value kind {k}"))),
}
Ok(())
}
/// `ContentPRCBase` [WD 7.2.3]: attributes, then the name; returns the entity's name,
/// which is also the current name afterwards.
pub(crate) fn content_prc_base(&mut self) -> Result<Option<String>, PrcError> {
let n = self.count(1, "attributes")?;
for _ in 0..n {
self.expect_type(ATTRIBUTE)?;
self.attribute_entry()?;
let pairs = self.count(2, "attribute pairs")?;
for _ in 0..pairs {
self.attribute_entry()?;
self.attribute_value()?;
}
self.schema.skip_added_fields(ATTRIBUTE, &mut self.r)?;
}
// `same_name` TRUE reuses the current name [WD 7.2.3.4].
if !self.r.bit()? {
self.name = self.r.string()?;
}
Ok(self.name.clone())
}
/// `UserData` [WD 8.6]: a bit count, then that many opaque bits.
pub(crate) fn user_data(&mut self) -> Result<(), PrcError> {
let n = self.r.unsigned_integer()? as usize;
self.r.skip_bits(n)
}
}
+46
View File
@@ -0,0 +1,46 @@
//! The crate's error type.
/// Why a PRC stream could not be read.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PrcError {
/// The data does not start with the `PRC` magic [WD 6.1.1].
#[error("not a PRC stream: missing the \"PRC\" magic")]
NotPrc,
/// A value ran past the end of its byte or bit stream.
#[error("PRC data truncated while reading {0}")]
Truncated(&'static str),
/// A structural field holds a value the format forbids.
#[error("malformed PRC: {0}")]
Malformed(String),
/// No `Double` code of up to 22 bits matched [WD 11.17].
#[error("PRC Double: no code matches the next 22 bits")]
UnknownDoubleCode,
/// A section is not a valid zlib stream.
#[error("PRC {section} section does not inflate: {reason}")]
Inflate {
/// The section's name.
section: &'static str,
/// The decoder's message.
reason: String,
},
/// The inflated sections and copied blocks together exceed the ceiling
/// ([`crate::MAX_INFLATED_BYTES`] or the caller's limit).
#[error("PRC sections inflate past the {limit}-byte ceiling")]
TooLarge {
/// The ceiling that was hit.
limit: usize,
},
/// The arrays read from one stream hold more elements than [`crate::MAX_DECODED_ELEMENTS`].
/// One bit can decode to a 4- or 8-byte element, so the inflation ceiling alone does not
/// bound the memory the arrays take.
#[error("PRC arrays decode past the {limit}-element ceiling")]
TooManyElements {
/// The ceiling that was hit.
limit: usize,
},
/// A construct this reader does not decode; the part of the file holding it cannot be
/// read past.
#[error("PRC {0} is not supported")]
Unsupported(&'static str),
}
+276
View File
@@ -0,0 +1,276 @@
//! Model-space vectors and matrices, and their application to meshes.
use crate::TriangleMesh;
/// A point or direction in model space.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub(crate) struct Vec3(pub(crate) [f64; 3]);
impl Vec3 {
/// The right-handed cross product `self × b`.
pub(crate) fn cross(self, b: Vec3) -> Vec3 {
let ([ax, ay, az], [bx, by, bz]) = (self.0, b.0);
Vec3([ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx])
}
}
/// A 4×4 matrix, `m.0[row][col]`, acting on column vectors.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Matrix(pub [[f64; 4]; 4]);
impl Default for Matrix {
fn default() -> Self {
Matrix::IDENTITY
}
}
impl Matrix {
/// The identity matrix.
pub const IDENTITY: Matrix = Matrix([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]);
/// The entry at `row`, `col`; 0 outside the matrix.
#[must_use]
pub fn at(&self, row: usize, col: usize) -> f64 {
self.0.get(row).and_then(|r| r.get(col)).copied().unwrap_or(0.0)
}
/// `self × b`: `b` applied first.
#[must_use]
pub fn multiply(&self, b: &Matrix) -> Matrix {
let mut m = [[0.0; 4]; 4];
for (i, row) in m.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell = (0..4).map(|k| self.at(i, k) * b.at(k, j)).sum();
}
}
Matrix(m)
}
/// `self × (p, 1)`, divided by the homogeneous coordinate when it is neither 0 nor 1.
#[must_use]
pub fn transform_point(&self, p: [f64; 3]) -> [f64; 3] {
let [x, y, z] = p;
let row = |i: usize| self.at(i, 0) * x + self.at(i, 1) * y + self.at(i, 2) * z + self.at(i, 3);
let w = row(3);
let s = if w == 0.0 || w == 1.0 { 1.0 } else { 1.0 / w };
[row(0) * s, row(1) * s, row(2) * s]
}
/// The determinant of the upper-left 3×3 block.
fn det3(&self) -> f64 {
let a = |i, j| self.at(i, j);
a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0))
+ a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0))
}
/// The determinant of the whole matrix, by expansion along the last row.
fn det4(&self) -> f64 {
(0..4)
.map(|j| {
let cols: Vec<usize> = (0..4).filter(|&c| c != j).collect();
let a = |i: usize, k: usize| cols.get(k).map_or(0.0, |&c| self.at(i, c));
let minor = a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0))
+ a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0));
let sign = if (3 + j) % 2 == 0 { 1.0 } else { -1.0 };
sign * self.at(3, j) * minor
})
.sum()
}
/// Whether mapping points through this matrix turns them inside out. Where the point is
/// divided by `w`, the Jacobian's sign is the sign of the 4×4 determinant; a last row of
/// zeros (no division) falls back to the 3×3 block. Each row is first divided by its
/// largest entry, which keeps both signs while the products stay in range.
fn mirrors(&self) -> bool {
let scaled = Matrix(self.0.map(|row| {
let big = row.iter().fold(0.0_f64, |m, c| m.max(c.abs()));
if big.is_finite() && big > 0.0 { row.map(|c| c / big) } else { row }
}));
let det = scaled.det4();
if det == 0.0 { scaled.det3() < 0.0 } else { det < 0.0 }
}
/// Cofactor `(i, j)` of the upper-left 3×3 block (`i`, `j` < 3).
fn cofactor(&self, i: usize, j: usize) -> f64 {
let (r0, r1) = ((i + 1) % 3, (i + 2) % 3);
let (c0, c1) = ((j + 1) % 3, (j + 2) % 3);
self.at(r0, c0) * self.at(r1, c1) - self.at(r0, c1) * self.at(r1, c0)
}
}
impl TriangleMesh {
/// This mesh with every position mapped through `m` and every stored normal through its
/// inverse transpose (renormalised); a mirroring `m` (negative 4×4 determinant, which a
/// negative `w` can cause too) reverses each triangle so the outside stays
/// counter-clockwise. A projective `m` (a last row other than
/// `[0, 0, 0, w]`) bends the surface per point, so the stored normals are dropped.
///
/// ```
/// use pdfcraft_prc::{Matrix, TriangleMesh};
/// let mut mirror = Matrix::IDENTITY;
/// mirror.0[0][0] = -1.0;
/// let mut mesh = TriangleMesh::default();
/// mesh.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
/// mesh.triangles = vec![[0, 1, 2]];
/// let placed = mesh.transformed(&mirror);
/// assert_eq!(placed.positions, [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
/// assert_eq!(placed.triangles, [[0, 2, 1]]);
/// ```
#[must_use]
pub fn transformed(&self, m: &Matrix) -> Self {
let mut out = self.clone();
for p in &mut out.positions {
*p = m.transform_point(*p);
}
// The cofactor matrix is det times the inverse transpose, so its sign is corrected
// and the result renormalised.
let mirrors = m.mirrors();
let sign = if mirrors { -1.0 } else { 1.0 };
if m.at(3, 0) != 0.0 || m.at(3, 1) != 0.0 || m.at(3, 2) != 0.0 {
out.normals.clear();
out.triangle_normals.clear();
}
for n in &mut out.normals {
let [x, y, z] = *n;
let v = [0, 1, 2].map(|i| sign * (m.cofactor(i, 0) * x + m.cofactor(i, 1) * y + m.cofactor(i, 2) * z));
let len = v.iter().map(|c| c * c).sum::<f64>().sqrt();
*n = if len > 0.0 && len.is_finite() { v.map(|c| c / len) } else { v };
}
if mirrors {
for t in &mut out.triangles {
t.swap(1, 2);
}
for t in &mut out.triangle_normals {
t.swap(1, 2);
}
for t in out.triangle_uvs.iter_mut().flatten().flatten() {
t.swap(1, 2);
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cross_follows_the_right_hand_rule() {
assert_eq!(Vec3([1.0, 0.0, 0.0]).cross(Vec3([0.0, 1.0, 0.0])), Vec3([0.0, 0.0, 1.0]));
assert_eq!(Vec3([0.0, 1.0, 0.0]).cross(Vec3([1.0, 0.0, 0.0])), Vec3([0.0, 0.0, -1.0]));
}
#[test]
fn multiply_applies_the_right_operand_first() {
let mut t = Matrix::IDENTITY;
t.0[0][3] = 5.0;
let mut s = Matrix::IDENTITY;
s.0[0][0] = 2.0;
assert_eq!(t.multiply(&s).transform_point([1.0, 0.0, 0.0]), [7.0, 0.0, 0.0]);
assert_eq!(s.multiply(&t).transform_point([1.0, 0.0, 0.0]), [12.0, 0.0, 0.0]);
}
#[test]
fn a_projective_point_is_divided_by_w() {
let mut m = Matrix::IDENTITY;
m.0[3][3] = 2.0;
assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [1.0, 2.0, 3.0]);
m.0[3][3] = 0.0;
assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [2.0, 4.0, 6.0]);
}
#[test]
fn normals_take_the_inverse_transpose_and_mirror_with_their_triangle() {
// A shear in x by y turns the plane x = 0 into x = y; its x normal tilts to stay perpendicular.
let mut shear = Matrix::IDENTITY;
shear.0[0][1] = 1.0;
let mesh = TriangleMesh {
positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
normals: vec![[1.0, 0.0, 0.0]],
triangle_normals: vec![[0, 0, 0]],
..TriangleMesh::default()
};
let n = shear.transformed_normal(&mesh);
let s = std::f64::consts::FRAC_1_SQRT_2;
assert!((n[0] - s).abs() < 1e-12 && (n[1] + s).abs() < 1e-12 && n[2] == 0.0, "{n:?}");
let mut mirror = Matrix::IDENTITY;
mirror.0[2][2] = -1.0;
let placed = mesh.transformed(&mirror);
assert_eq!(placed.triangles, [[0, 2, 1]]);
assert_eq!(placed.triangle_normals, [[0, 0, 0]]);
assert_eq!(placed.normals, [[1.0, 0.0, 0.0]]);
}
#[test]
fn a_projective_transform_drops_the_stored_normals() {
let mesh = TriangleMesh {
positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
normals: vec![[1.0, 0.0, 1.0]],
triangle_normals: vec![[0, 0, 0]],
..TriangleMesh::default()
};
let mut projective = Matrix::IDENTITY;
projective.0[3][0] = 1.0;
let placed = mesh.transformed(&projective);
assert!(placed.normals.is_empty() && placed.triangle_normals.is_empty());
// A uniform w keeps them.
let mut uniform = Matrix::IDENTITY;
uniform.0[3][3] = 2.0;
assert_eq!(mesh.transformed(&uniform).triangle_normals, [[0, 0, 0]]);
}
/// A negative `w` turns the mapped triangles inside out though the 3×3 block does not
/// mirror, so the winding is reversed by the sign of the whole determinant.
#[test]
fn a_negative_w_reverses_the_winding() {
let mesh = TriangleMesh {
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
normals: vec![[1.0, 0.0, 0.0]],
triangle_normals: vec![[0, 0, 0]],
..TriangleMesh::default()
};
let mut negated = Matrix::IDENTITY;
negated.0[3][3] = -1.0;
let placed = mesh.transformed(&negated);
assert_eq!(placed.positions, [[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0]]);
assert_eq!(placed.triangles, [[0, 2, 1]]);
assert_eq!(placed.normals, [[-1.0, 0.0, 0.0]]);
// A projective w that is negative over the mesh: w = z - 3.
let mut projective = Matrix::IDENTITY;
projective.0[3][2] = 1.0;
projective.0[3][3] = -3.0;
assert_eq!(mesh.transformed(&projective).triangles, [[0, 2, 1]]);
// A positive w, uniform or projective, keeps the winding.
let mut positive = Matrix::IDENTITY;
positive.0[3][2] = 1.0;
positive.0[3][3] = 3.0;
assert_eq!(mesh.transformed(&positive).triangles, [[0, 1, 2]]);
}
#[test]
fn a_mirror_whose_determinant_underflows_still_reverses_the_winding() {
let mesh = TriangleMesh {
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
triangles: vec![[0, 1, 2]],
..TriangleMesh::default()
};
let diagonal = |x: f64| {
let mut m = Matrix::IDENTITY;
(m.0[0][0], m.0[1][1], m.0[2][2]) = (x, 1e-200, 1e-200);
m
};
assert_eq!(mesh.transformed(&diagonal(-1e200)).triangles, [[0, 2, 1]]);
assert_eq!(mesh.transformed(&diagonal(1e200)).triangles, [[0, 1, 2]]);
}
impl Matrix {
fn transformed_normal(&self, mesh: &TriangleMesh) -> [f64; 3] {
mesh.transformed(self).normals.first().copied().unwrap_or_default()
}
}
}
+37
View File
@@ -0,0 +1,37 @@
//! A reader for PRC, the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries
//! (ISO 32000-2 §13.6.3, ISO 14739-1).
//!
//! Layer L0, standalone: this crate depends on no other PdfCraft crate. It reads the
//! uncompressed container, inflates each section, and decodes the bit-level primitives and
//! the schema a newer producer uses to describe fields this reader does not know; it decodes
//! tessellations and walks the product tree into placed, coloured meshes
//! ([`PrcFile::placements`], [`PrcFile::model_tree`]).
//!
//! Clause numbers in `[WD x.y]` refer to the PRC working draft, ISO/TC 171/SC 2 N570.
//!
//! Every read is bounded: inflation by [`MAX_INFLATED_BYTES`] (or a caller's limit), decoded
//! array elements by [`MAX_DECODED_ELEMENTS`] and schema-program steps by [`MAX_SCHEMA_STEPS`]
//! per reading pass, schema programs by a depth ceiling. Damaged data is an error, never a panic.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod acof;
mod arrays;
mod bits;
mod container;
mod entity;
mod error;
mod geometry;
mod schema;
mod tess;
#[cfg(test)]
mod testw;
mod tree;
pub use bits::{BitReader, MAX_DECODED_ELEMENTS, MAX_SCHEMA_STEPS};
pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId};
pub use error::PrcError;
pub use geometry::Matrix;
pub use schema::Schema;
pub use tess::{Tessellation, TriangleMesh, TriangleStyle};
pub use tree::{EntityOverrides, MAX_PLACEMENTS, ModelNode, NameSource, Placement, Rgba, StyleAlpha};
+565
View File
@@ -0,0 +1,565 @@
//! `FileStructureSchema` and its token interpreter [WD 6.3, 9].
//!
//! A producer newer than the reader describes, per entity type, the fields it appended; the
//! reader runs that program after the fields it knows, to step over the rest [WD 9.1]. Token
//! values follow the WD 9.2 table as corrected by PDF Association pdf-issues #407
//! (`Block_Start` = 19; 39 and 40 swallow one token).
//!
//! Choices the sources leave open, made here:
//! - `SimpleFor` reads its count as an `UnsignedInteger` [WD 9.3.17 pseudocode; pdf-issues
//! #575]: that read also decodes an `Integer`-written non-negative count, while a signed
//! read does not decode an unsigned one whose top group is 0x80 or higher.
//! - `Block_Version V` runs its block only when `V` is newer than
//! [`crate::PRC_READER_VERSION`]: an older block holds fields this reader already reads
//! natively [WD 9.3.20].
//! - `Extent_1D` and `Extent_2D` read 2 and 4 `Double`s: `Interval` and `Domain`
//! [WD 8.2.2, 8.2.4].
//! - Expression values are `i64`; a `Double` read as an operand truncates (NaN reads as 0).
//! - Variables are not block-scoped: a program that reuses an index after its block ends
//! reads the stale value instead of an error.
//! - Pointer tokens (12-14) and `Value_CurveIs3D` (28) need the exact-geometry reader and are
//! refused with [`PrcError::Unsupported`].
use std::collections::HashMap;
use crate::PrcError;
use crate::bits::BitReader;
/// Nesting depth of blocks, loops, expressions and `Father_Type` calls.
const MAX_DEPTH: u32 = 64;
/// The per-entity-type programs at the start of a globals or model-file
/// bitstream [WD 6.3.1].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Schema {
programs: HashMap<u32, Vec<u32>>,
}
impl Schema {
/// Read a schema: `UnsignedInteger` count, then per entry the entity
/// type, a token count and the tokens [WD 6.3.2].
///
/// # Errors
/// [`PrcError::Truncated`] when a count runs past the data;
/// [`PrcError::Malformed`] for an entity type listed twice; [`PrcError::TooManyElements`]
/// past the reader's allowance, one element per entry and per token.
pub fn read(r: &mut BitReader<'_>) -> Result<Self, PrcError> {
let n = r.unsigned_integer()? as usize;
// Every entry takes at least two bits.
if n > r.remaining() / 2 {
return Err(PrcError::Truncated("the schema"));
}
let mut programs = HashMap::new();
for _ in 0..n {
// The entry itself costs one element, so empty programs are not free.
r.take_elements(1)?;
let entity_type = r.unsigned_integer()?;
let len = r.unsigned_integer()? as usize;
if len > r.remaining() {
return Err(PrcError::Truncated("a schema program"));
}
r.take_elements(len)?;
let tokens = (0..len).map(|_| r.unsigned_integer()).collect::<Result<Vec<_>, _>>()?;
if programs.insert(entity_type, tokens).is_some() {
return Err(PrcError::Malformed(format!("schema lists entity type {entity_type} twice")));
}
}
Ok(Self { programs })
}
/// True when no entity type has added fields (the common case: the file
/// was written at the version this reader implements).
pub fn is_empty(&self) -> bool {
self.programs.is_empty()
}
/// The entity types the schema describes, unordered.
pub fn entity_types(&self) -> impl Iterator<Item = u32> + '_ {
self.programs.keys().copied()
}
/// Consume the fields a newer producer appended to `entity_type`, if the
/// schema describes it; a no-op otherwise. Each step is charged to `r`, so every
/// program run on one stream shares [`crate::MAX_SCHEMA_STEPS`].
///
/// # Errors
/// [`PrcError::Unsupported`] for a token that needs exact geometry;
/// [`PrcError::Malformed`] for an ill-formed program, one past the depth ceiling, or
/// one that runs out of the stream's steps; any read error from `r`.
pub fn skip_added_fields(&self, entity_type: u32, r: &mut BitReader<'_>) -> Result<(), PrcError> {
let Some(program) = self.programs.get(&entity_type) else {
return Ok(());
};
let mut exec = Exec { schema: self, r, vars: HashMap::new(), loop_counter: 0 };
exec.program(program, 0)
}
}
struct Exec<'s, 'r, 'd> {
schema: &'s Schema,
r: &'r mut BitReader<'d>,
vars: HashMap<u32, i64>,
loop_counter: i64,
}
fn malformed(what: &str) -> PrcError {
PrcError::Malformed(format!("schema program: {what}"))
}
/// The token at `*pos`, advancing past it.
fn next(prog: &[u32], pos: &mut usize) -> Result<u32, PrcError> {
let t = prog.get(*pos).copied().ok_or_else(|| malformed("ends mid-statement"))?;
*pos += 1;
Ok(t)
}
impl Exec<'_, '_, '_> {
fn tick(&mut self, depth: u32) -> Result<(), PrcError> {
if !self.r.take_step() {
return Err(malformed("step ceiling reached"));
}
if depth > MAX_DEPTH {
return Err(malformed("nesting ceiling reached"));
}
Ok(())
}
fn program(&mut self, prog: &[u32], depth: u32) -> Result<(), PrcError> {
let mut pos = 0;
while pos < prog.len() {
self.stmt(prog, &mut pos, true, depth)?;
}
Ok(())
}
/// One data read for tokens 0-5 and 7-11, returning its value as an
/// operand.
fn read(&mut self, token: u32) -> Result<i64, PrcError> {
let doubles = match token {
0 => return Ok(i64::from(self.r.bit()?)),
1 => return Ok(self.r.double()? as i64),
2 => return Ok(i64::from(self.r.character()?)),
3 => return Ok(i64::from(self.r.unsigned_integer()?)),
4 => return Ok(i64::from(self.r.integer()?)),
5 => {
self.r.string()?;
return Ok(0);
}
7 => 2,
8 => 3,
9 => 2,
10 => 4,
11 => 6,
_ => return Err(malformed("not a data token")),
};
for _ in 0..doubles {
self.r.double()?;
}
Ok(0)
}
/// Statements until `Block_End`, which is consumed.
fn block(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> {
loop {
if prog.get(*pos) == Some(&21) {
*pos += 1;
return Ok(());
}
self.stmt(prog, pos, run, depth + 1)?;
}
}
/// Run `body` (one statement at `*pos`) `count` times; `*pos` ends past it.
fn repeat(&mut self, prog: &[u32], pos: &mut usize, count: i64, run: bool, depth: u32) -> Result<(), PrcError> {
let start = *pos;
self.stmt(prog, pos, false, depth + 1)?;
if run {
let saved = self.loop_counter;
for i in 0..count.max(0) {
self.tick(depth)?;
self.loop_counter = i;
let mut p = start;
self.stmt(prog, &mut p, true, depth + 1)?;
}
self.loop_counter = saved;
}
Ok(())
}
/// One statement; with `run` false, only step over its tokens.
fn stmt(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> {
self.tick(depth)?;
match next(prog, pos)? {
t @ (0..=5 | 7..=11) => {
if run {
self.read(t)?;
}
}
6 => self.parent_program(prog, pos, run, depth)?,
12 => {
next(prog, pos)?;
if run {
return Err(PrcError::Unsupported("a schema pointer field"));
}
}
13 | 14 => {
if run {
return Err(PrcError::Unsupported("a schema pointer field"));
}
}
15 => {
let count = self.expr(prog, pos, run, depth + 1)?;
self.repeat(prog, pos, count, run, depth)?;
}
16 => {
// prc-rs: reads SimpleFor's count as an Integer; this reader deliberately reads an
// UnsignedInteger (module doc).
let count = if run { i64::from(self.r.unsigned_integer()?) } else { 0 };
self.repeat(prog, pos, count, run, depth)?;
}
17 => self.conditional(prog, pos, run, depth)?,
19 => self.block(prog, pos, run, depth)?,
20 => {
let version = next(prog, pos)?;
let newer = version > crate::PRC_READER_VERSION;
self.block(prog, pos, run && newer, depth)?;
}
22 => {
let v = next(prog, pos)?;
if run {
self.vars.insert(v, 0);
}
}
23 | 24 => {
let v = next(prog, pos)?;
let value = self.expr(prog, pos, run, depth + 1)?;
if run {
self.vars.insert(v, value);
}
}
39 | 40 => {
next(prog, pos)?;
}
t => return Err(malformed(&format!("token {t} where a statement belongs"))),
}
Ok(())
}
/// Token 6: run the named type's own program, the fields it inherits.
fn parent_program(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> {
let parent = next(prog, pos)?;
if run && let Some(p) = self.schema.programs.get(&parent) {
self.tick(depth + 1)?;
let mut q = 0;
while q < p.len() {
self.stmt(p, &mut q, true, depth + 1)?;
}
}
Ok(())
}
/// Token 17: `if` with an optional token-18 `else`.
fn conditional(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> {
let cond = self.expr(prog, pos, run, depth + 1)? != 0;
self.stmt(prog, pos, run && cond, depth + 1)?;
if prog.get(*pos) == Some(&18) {
*pos += 1;
self.stmt(prog, pos, run && !cond, depth + 1)?;
}
Ok(())
}
/// One expression; with `run` false its value is 0 and nothing is read.
fn expr(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<i64, PrcError> {
self.tick(depth)?;
let t = next(prog, pos)?;
match t {
0..=4 => {
if run {
self.read(t)
} else {
Ok(0)
}
}
25 => {
let v = next(prog, pos)?;
if !run {
return Ok(0);
}
self.vars.get(&v).copied().ok_or_else(|| malformed(&format!("variable {v} read before it is set")))
}
26 => Ok(i64::from(next(prog, pos)?)),
27 => Ok(self.loop_counter),
28 => {
if run {
Err(PrcError::Unsupported("Value_CurveIs3D"))
} else {
Ok(0)
}
}
29..=38 => {
let a = self.expr(prog, pos, run, depth + 1)?;
let b = self.expr(prog, pos, run, depth + 1)?;
if !run {
return Ok(0);
}
let overflow = || malformed("a schema expression overflows");
Ok(match t {
29 => a.checked_mul(b).ok_or_else(overflow)?,
30 => a.checked_div(b).ok_or_else(|| malformed("division by zero"))?,
31 => a.checked_add(b).ok_or_else(overflow)?,
32 => a.checked_sub(b).ok_or_else(overflow)?,
33 => i64::from(a < b),
34 => i64::from(a <= b),
35 => i64::from(a > b),
36 => i64::from(a >= b),
37 => i64::from(a == b),
_ => i64::from(a != b),
})
}
t => Err(malformed(&format!("token {t} where a value belongs"))),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
mod tests {
use super::*;
use crate::testw::W;
fn schema(entries: &[(u32, &[u32])]) -> W {
let mut w = W::default();
w.uint(entries.len() as u32);
for (t, toks) in entries {
w.uint(*t).uint(toks.len() as u32);
for k in *toks {
w.uint(*k);
}
}
w
}
/// Each schema entry and its program's tokens are charged against the reader's element
/// allowance.
#[test]
fn program_tokens_are_charged_against_the_allowance() {
let bytes = schema(&[(172, &[1, 2, 3])]).bytes();
assert!(matches!(Schema::read(&mut BitReader::with_elements(&bytes, 3)), Err(PrcError::TooManyElements { .. })));
assert!(Schema::read(&mut BitReader::with_elements(&bytes, 4)).is_ok());
let empty = schema(&[(172, &[]), (173, &[]), (174, &[])]).bytes();
assert!(matches!(Schema::read(&mut BitReader::with_elements(&empty, 2)), Err(PrcError::TooManyElements { .. })));
assert!(Schema::read(&mut BitReader::with_elements(&empty, 3)).is_ok());
}
fn run(entries: &[(u32, &[u32])], data: &W) -> (Result<(), PrcError>, usize) {
let mut w = schema(entries);
w.append(data);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let s = Schema::read(&mut r).unwrap();
let start = r.position();
let res = s.skip_added_fields(172, &mut r);
(res, r.position() - start)
}
#[test]
fn integer_string_and_boolean_tokens_consume_their_fields() {
let mut d = W::default();
d.bit(true).uint(300).int(-7).string(Some("ab")).put(0x41, 8);
let want = d.len();
d.uint(99); // not part of the added fields
let (res, used) = run(&[(172, &[0, 3, 4, 5, 2])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn data_tokens_consume_their_fields() {
let mut d = W::default();
d.bit(true).double(1.5).uint(300).int(-7).string(Some("ab")).double(2.0).double(3.0);
let want = d.len();
d.uint(99); // not part of the added fields
let (res, used) = run(&[(172, &[0, 1, 3, 4, 5, 7])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn vector_extent_and_box_tokens_read_their_doubles() {
// Vec3, Extent1D, Extent2D, BBox: 3 + 2 + 4 + 6 Doubles.
let mut d = W::default();
for i in 0..15 {
d.double(f64::from(i) + 0.5);
}
let want = d.len();
d.uint(99);
let (res, used) = run(&[(172, &[8, 9, 10, 11])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn arithmetic_and_comparison_operators() {
// For (5 - 3) { UInt }, then If (2 < 2) { Bool } Else { Char }.
let mut d = W::default();
d.uint(7).uint(8).put(0x41, 8);
let want = d.len();
d.uint(99);
let prog = [15, 32, 26, 5, 26, 3, 3, 17, 33, 26, 2, 26, 2, 0, 18, 2];
let (res, used) = run(&[(172, &prog)], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn an_absent_type_is_a_no_op() {
let mut w = schema(&[(300, &[3])]);
w.uint(5);
let bytes = w.bytes();
let mut r = BitReader::new(&bytes);
let s = Schema::read(&mut r).unwrap();
let at = r.position();
s.skip_added_fields(172, &mut r).unwrap();
assert_eq!(r.position(), at);
assert!(!s.is_empty());
}
#[test]
fn simple_for_reads_its_count_then_repeats_the_body() {
let mut d = W::default();
d.int(3).uint(1).uint(2).uint(3);
let want = d.len();
d.uint(77);
let (res, used) = run(&[(172, &[16, 3])], &d);
res.unwrap();
assert_eq!(used, want);
// A block body.
let mut d = W::default();
d.int(2).bit(true).uint(9).bit(false).uint(8);
let want = d.len();
let (res, used) = run(&[(172, &[16, 19, 0, 3, 21])], &d);
res.unwrap();
assert_eq!(used, want);
// A count whose top byte is 0x80 or higher, written either way.
for signed in [false, true] {
let mut d = W::default();
if signed {
d.int(200);
} else {
d.uint(200);
}
(0..200).for_each(|_| {
d.bit(true);
});
let want = d.len();
d.uint(77);
let (res, used) = run(&[(172, &[16, 0])], &d);
res.unwrap();
assert_eq!(used, want, "signed={signed}");
}
}
#[test]
fn if_else_and_variables_choose_the_branch_by_file_data() {
// DeclareAndSet v0 = <UInt from file>; if v0 > 1 { UInt } else { Character }.
let prog: &[u32] = &[24, 0, 3, 17, 35, 25, 0, 26, 1, 3, 18, 2];
let mut d = W::default();
d.uint(5).uint(4250);
let want = d.len();
let (res, used) = run(&[(172, prog)], &d);
res.unwrap();
assert_eq!(used, want);
let mut d = W::default();
d.uint(0).put(0xAB, 8);
let want = d.len();
let (res, used) = run(&[(172, prog)], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn for_counts_by_expression_and_exposes_the_counter() {
// For (2 * 2) { if counter == 2 { UInt } } => one UInt read.
let prog: &[u32] = &[15, 29, 26, 2, 26, 2, 17, 37, 27, 26, 2, 3];
let mut d = W::default();
d.uint(1234);
let want = d.len();
let (res, used) = run(&[(172, prog)], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn an_expression_that_overflows_is_malformed() {
// For ((2^31 * 2^31) * 4) { UInt }: 2^64 would wrap to 0 and read nothing.
let prog: &[u32] = &[15, 29, 29, 26, 1 << 31, 26, 1 << 31, 26, 4, 3];
let (res, _) = run(&[(172, prog)], &W::default());
assert!(matches!(res, Err(PrcError::Malformed(m)) if m.contains("overflows")));
}
#[test]
fn version_blocks_run_only_when_newer_than_the_reader() {
let mut d = W::default();
d.uint(42);
let want = d.len();
let (res, used) = run(&[(172, &[20, 8137, 3, 21, 20, 9000, 3, 21])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn father_type_runs_the_parent_program_and_obsolete_tokens_swallow_one() {
let mut d = W::default();
d.uint(1).uint(2);
let want = d.len();
let (res, used) = run(&[(172, &[39, 3, 6, 50, 40, 8]), (50, &[3, 3])], &d);
res.unwrap();
assert_eq!(used, want);
}
#[test]
fn hostile_programs_are_refused_not_run_forever() {
let d = W::default();
// For (1000000 * 1000000) { empty block }.
let prog: &[u32] = &[15, 29, 26, 1_000_000, 26, 1_000_000, 19, 21];
assert!(matches!(run(&[(172, prog)], &d).0, Err(PrcError::Malformed(_))));
// Father_Type recursing into itself.
assert!(matches!(run(&[(172, &[6, 172])], &d).0, Err(PrcError::Malformed(_))));
// A pointer field.
assert!(matches!(run(&[(172, &[13])], &d).0, Err(PrcError::Unsupported(_))));
// A stray Block_End, an unterminated block, a division by zero.
assert!(matches!(run(&[(172, &[21])], &d).0, Err(PrcError::Malformed(_))));
assert!(matches!(run(&[(172, &[19, 0])], &d).0, Err(PrcError::Malformed(_))));
assert!(matches!(run(&[(172, &[23, 0, 30, 26, 1, 26, 0])], &d).0, Err(PrcError::Malformed(_))));
}
/// Every program run on one reader draws on one step allowance.
#[test]
fn programs_on_one_stream_share_the_step_allowance() {
// For (2^19) { empty block }: about 2^20 steps, so the 2^22 allowance fits three runs.
let bytes = schema(&[(172, &[15, 26, 1 << 19, 19, 21])]).bytes();
let mut r = BitReader::new(&bytes);
let s = Schema::read(&mut r).unwrap();
let runs = (0..5).take_while(|_| s.skip_added_fields(172, &mut r).is_ok()).count();
assert_eq!(runs, 3);
assert!(matches!(s.skip_added_fields(172, &mut r), Err(PrcError::Malformed(m)) if m.contains("step ceiling")));
}
#[test]
fn a_duplicated_entity_type_is_malformed() {
let bytes = schema(&[(172, &[3]), (172, &[3])]).bytes();
assert!(matches!(Schema::read(&mut BitReader::new(&bytes)), Err(PrcError::Malformed(_))));
}
#[test]
fn an_entry_count_past_the_data_is_refused_before_any_entry_is_read() {
let mut w = W::default();
w.uint(1_000).uint(172).uint(0);
let bytes = w.bytes();
assert_eq!(Schema::read(&mut BitReader::new(&bytes)).unwrap_err(), PrcError::Truncated("the schema"));
}
}
+440
View File
@@ -0,0 +1,440 @@
//! The tessellation section: `FileStructureTessellation` (305) and its entities, `TESS_3D`
//! (172) with its `TESS_Face`s (174), `TESS_3D_Wire` (175), `TESS_Markup` (176) and
//! `TESS_3D_Compressed` (173) [WD 7.3.7, 7.8].
//!
//! `TESS_3D_Compressed` is read field by field to its end [WD 7.8.9.7] so the entities after
//! it decode; its triangles are not rebuilt.
//!
//! The schema runs after the fields of each concrete type read here (201, 172, 173, 174, 175,
//! 176, 305), not for the abstract levels (`PRCBase`, `TESS`).
mod triangulate;
use crate::arrays;
use crate::bits::{BitReader, Budget};
use crate::entity::{Ctx, malformed};
use crate::{FileStructure, PrcError, SectionKind};
use triangulate::{Corners, Face, triangle_elements};
const FILE_STRUCTURE_TESSELLATION: u32 = 305;
const TESS_3D: u32 = 172;
const TESS_3D_COMPRESSED: u32 = 173;
const TESS_FACE: u32 = 174;
const TESS_3D_WIRE: u32 = 175;
const TESS_MARKUP: u32 = 176;
/// prc-rs: `origin` exists in a compressed tessellation from this version (pdf-issues #705).
const ORIGIN_FROM: u32 = 7031;
/// prc-rs: `has_loops` exists from this file-structure authoring version.
const HAS_LOOPS_FROM: u32 = 7039;
/// prc-rs: `must_recalculate_normals` exists from this version.
const RECALCULATE_FROM: u32 = 7047;
/// A wire header's count bits; the top four are flags [WD 7.8.7.5].
const WIRE_COUNT_MASK: u32 = 0x0FFF_FFFF;
const WIRE_IS_CLOSING: u32 = 0x1000_0000;
const WIRE_IS_CONTINUOUS: u32 = 0x2000_0000;
/// Normal angles wider than this are not a `ShortArray` [WD 7.8.9.7].
const MAX_ANGLE_BITS: u32 = 16;
/// One entry of the tessellation array; a representation item's `index_tessellation` selects
/// one by position.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Tessellation {
/// A `TESS_3D` triangle mesh.
Mesh(TriangleMesh),
/// A `TESS_3D_Wire`: polylines.
Wire(Vec<Vec<[f64; 3]>>),
/// A `TESS_Markup` (PMI drawing), read past but not decoded.
Markup,
/// A `TESS_3D_Compressed` mesh, read past but not rebuilt.
#[non_exhaustive]
Compressed {
/// The entity's triangle count.
triangles: usize,
},
}
/// A `TESS_3D` decoded to indexed triangles, in the entity's own frame (the representation
/// item's placement is not applied).
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct TriangleMesh {
/// The coordinate array as points.
pub positions: Vec<[f64; 3]>,
/// Counter-clockwise triangles seen from the outside, as indices into
/// [`Self::positions`] [WD 7.8.5.2].
pub triangles: Vec<[u32; 3]>,
/// `triangles` split by `TESS_Face`: face `i` is `triangles[faces[i].clone()]`.
pub faces: Vec<std::ops::Range<usize>>,
/// The producer asks the reader to compute normals (none are stored).
pub normals_recalculated: bool,
/// The stored vertex normals [WD 7.8.5.1], as written (not renormalised); empty when the
/// mesh stores none or any of its normal indices is unusable.
pub normals: Vec<[f64; 3]>,
/// Per triangle, the index into [`Self::normals`] of each corner's normal, in the corner
/// order of [`Self::triangles`]; empty exactly when [`Self::normals`] is.
pub triangle_normals: Vec<[u32; 3]>,
/// Per triangle, the style its face's line attributes give it [WD 7.8.6]; empty when no
/// face carries any.
pub triangle_styles: Vec<TriangleStyle>,
/// The stored texture coordinates as (u, v) pairs [WD 7.8.5.1]; empty when the mesh
/// stores none.
pub uvs: Vec<[f64; 2]>,
/// Per texture-coordinate set, per triangle, the index into [`Self::uvs`] of each
/// corner's coordinate, in the corner order of [`Self::triangles`]; each set is as long
/// as [`Self::triangles`], `None` for a triangle whose face stores no coordinate for the
/// set. Empty when [`Self::uvs`] is.
pub triangle_uvs: Vec<Vec<Option<[u32; 3]>>>,
}
/// The graphics a face's `line_attributes` give one triangle [WD 7.8.6].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct TriangleStyle {
/// An index into the globals' style array plus one; 0 keeps the owner's graphics.
pub style: u32,
/// The face's behaviour bits [WD 5.4].
pub behaviour: u16,
}
impl FileStructure {
/// The tessellation section's entities, in the order a representation item's
/// `index_tessellation` counts them [WD 7.3.7].
///
/// # Errors
/// As [`crate::Schema::read`] for the globals' schema; [`PrcError::Truncated`] or
/// [`PrcError::Malformed`] for a damaged section; [`PrcError::Unsupported`] for
/// optimised vertex colours.
pub fn tessellations(&self) -> Result<Vec<Tessellation>, PrcError> {
self.tessellations_within(Budget::FULL)
}
/// As [`Self::tessellations`], the globals' schema and the section sharing `budget`.
pub(crate) fn tessellations_within(&self, budget: Budget) -> Result<Vec<Tessellation>, PrcError> {
let mut globals = BitReader::with_budget(self.section(SectionKind::Globals), budget);
let schema = crate::Schema::read(&mut globals)?;
let r = BitReader::with_budget(self.section(SectionKind::Tessellation), globals.budget());
Ctx::new(r, &schema, self.authoring_version).file_structure_tessellation()
}
}
impl Ctx<'_, '_> {
/// `FileStructureTessellation` (305) [WD 7.3.7].
pub(crate) fn file_structure_tessellation(&mut self) -> Result<Vec<Tessellation>, PrcError> {
self.expect_type(FILE_STRUCTURE_TESSELLATION)?;
self.content_prc_base()?;
let n = self.count(1, "tessellations")?;
let mut out = Vec::with_capacity(n.min(1024));
for _ in 0..n {
out.push(match self.r.unsigned_integer()? {
TESS_3D => Tessellation::Mesh(self.tess_3d()?),
TESS_3D_WIRE => Tessellation::Wire(self.wire()?),
TESS_MARKUP => {
self.markup()?;
Tessellation::Markup
}
TESS_3D_COMPRESSED => Tessellation::Compressed { triangles: self.tess_3d_compressed()? },
t => return Err(malformed(format!("entity type {t} in the tessellation array"))),
});
}
self.schema.skip_added_fields(FILE_STRUCTURE_TESSELLATION, &mut self.r)?;
self.user_data()?;
Ok(out)
}
/// `ContentBaseTessData` [WD 7.8.4]: `is_calculated`, then coordinates.
fn base_tess_data(&mut self) -> Result<Vec<f64>, PrcError> {
self.r.bit()?;
self.doubles("tessellation coordinates")
}
/// `VertexColors` [WD 7.8.7.2] for `count` points, read past.
fn vertex_colors(&mut self, count: usize, in_wire: bool) -> Result<(), PrcError> {
let rgba = self.r.bit()?;
// prc-rs: `is_segment_color` exists only inside a wire.
let per_segment = in_wire && self.r.bit()?;
if self.r.bit()? {
return Err(PrcError::Unsupported("optimised vertex colours"));
}
let count = if per_segment { count / 2 } else { count };
let bytes = if rgba { 4 } else { 3 };
// The first colour is `8 * bytes` bits; every later one is at least its reuse bit.
if count > 0 && (count - 1).saturating_add(8 * bytes) > self.r.remaining() {
return Err(PrcError::Truncated("vertex colours"));
}
for i in 0..count {
if i == 0 || !self.r.bit()? {
for _ in 0..bytes {
self.r.character()?;
}
}
}
Ok(())
}
/// `TESS_3D` (172) after its type code [WD 7.8.5].
fn tess_3d(&mut self) -> Result<TriangleMesh, PrcError> {
let coords = self.base_tess_data()?;
let _has_faces = self.r.bit()?;
if self.version >= HAS_LOOPS_FROM {
let _has_loops = self.r.bit()?;
}
let recalc = self.version >= RECALCULATE_FROM && self.r.bit()?;
if recalc {
let _flags = self.r.character()?;
let _crease_angle = self.r.double()?;
}
let normal_coords = self.doubles("normal coordinates")?;
let _wire_indices = self.uints("wire indices")?;
let indices = self.uints("triangulated indices")?;
let n_faces = self.count(1, "faces")?;
let mut faces = Vec::with_capacity(n_faces.min(1024));
for _ in 0..n_faces {
self.expect_type(TESS_FACE)?;
faces.push(self.face()?);
}
let texture = self.doubles("texture coordinates")?;
self.schema.skip_added_fields(TESS_3D, &mut self.r)?;
let mut mesh = TriangleMesh { positions: points(&coords)?, normals_recalculated: recalc, ..TriangleMesh::default() };
let styled = faces.iter().any(|f| !f.styles.is_empty());
let mut corners = Corners::default();
// Every triangle carries a corner triple for each texture set any face uses.
let sets = faces.iter().map(Face::texture_sets).max().unwrap_or(0);
let per_triangle = triangle_elements(!recalc, sets, styled);
for f in &faces {
// Faces may reuse one index range, so each face's triangles, with what they
// carry, are charged before they are made.
self.r.take_elements(f.triangle_count()?.saturating_mul(per_triangle))?;
let start = mesh.triangles.len();
let entities = f.triangulate(&indices, !recalc, mesh.positions.len(), &mut mesh.triangles, &mut corners)?;
mesh.faces.push(start..mesh.triangles.len());
if styled {
f.styles_into(&entities, &mut mesh.triangle_styles);
}
}
(mesh.normals, mesh.triangle_normals) = stored_normals(&normal_coords, &corners.normals, mesh.triangles.len());
(mesh.uvs, mesh.triangle_uvs) = stored_uvs(&texture, corners.textures);
Ok(mesh)
}
/// `TESS_Face` (174) after its type code [WD 7.8.6].
fn face(&mut self) -> Result<Face, PrcError> {
let styles = self.uints("face line attributes")?;
let _start_of_wire = self.r.unsigned_integer()?;
let _sizes_wire = self.uints("face wire sizes")?;
let flags = self.r.unsigned_integer()?;
let start = self.r.unsigned_integer()? as usize;
let data = self.uints("triangulated data")?;
let textures = self.r.unsigned_integer()? as usize;
let mut face = Face { flags, start, data, textures, styles, behaviour: 0 };
if self.r.bit()? {
let n = face.point_count()?;
self.vertex_colors(n, false)?;
}
if !face.styles.is_empty() {
let bits = self.r.unsigned_integer()?;
face.behaviour = u16::try_from(bits).map_err(|_| malformed(format!("face behaviour {bits} is wider than 16 bits")))?;
}
self.schema.skip_added_fields(TESS_FACE, &mut self.r)?;
Ok(face)
}
/// `TESS_3D_Wire` (175) after its type code [WD 7.8.7].
fn wire(&mut self) -> Result<Vec<Vec<[f64; 3]>>, PrcError> {
let pts = points(&self.base_tess_data()?)?;
let words = self.uints("wire indices")?;
let mut wires: Vec<Vec<[f64; 3]>> = Vec::new();
let mut colours = 0usize;
if words.is_empty() {
colours = pts.len();
if !pts.is_empty() {
wires.push(pts.clone());
}
}
let mut it = words.iter().copied();
while let Some(header) = it.next() {
let n = (header & WIRE_COUNT_MASK) as usize;
let mut wire = Vec::with_capacity(n.min(words.len()));
for _ in 0..n {
let idx = it.next().ok_or_else(|| malformed("wire runs past its indices".into()))?;
wire.push(point_at(&pts, idx)?);
}
colours = colours.saturating_add(n);
if header & WIRE_IS_CLOSING != 0 {
colours = colours.saturating_add(1);
if let Some(&first) = wire.first() {
wire.push(first);
}
}
match wires.last_mut() {
Some(prev) if header & WIRE_IS_CONTINUOUS != 0 => prev.extend(wire),
_ => wires.push(wire),
}
}
if self.r.bit()? {
self.vertex_colors(colours, true)?;
}
self.schema.skip_added_fields(TESS_3D_WIRE, &mut self.r)?;
Ok(wires)
}
/// `TESS_Markup` (176) after its type code [WD 7.8.8], read past.
fn markup(&mut self) -> Result<(), PrcError> {
self.base_tess_data()?;
self.uints("markup codes")?;
let n = self.count(1, "markup strings")?;
for _ in 0..n {
self.r.string()?;
}
self.r.string()?;
self.r.character()?;
self.schema.skip_added_fields(TESS_MARKUP, &mut self.r)
}
/// `TESS_3D_Compressed` (173) after its type code [WD 7.8.9.7], read past; returns its
/// triangle count. prc-rs: the field order and version gates follow its schema.
///
/// Sizes the WD leaves unstated: the face count is the largest face index + 1,
/// `normal_is_reversed` is one bit per triangle, and the point-reference array's
/// compressed flag is implicit (more than three references).
fn tess_3d_compressed(&mut self) -> Result<usize, PrcError> {
let r = &mut self.r;
r.bit()?; // is_calculated
r.bit()?; // has_faces
r.double()?; // tolerance
if self.version >= ORIGIN_FROM {
for _ in 0..3 {
r.float_as_bytes()?;
}
}
arrays::compressed_integer_array(r)?; // point_array
let edge_status = arrays::character_array(r, 2, None, false)?;
let face_of = arrays::compressed_indice_array(r, None)?;
let t = face_of.len();
if edge_status.len() != t && edge_status.len() != t.saturating_mul(3) {
return Err(malformed(format!("{} edge statuses for {t} triangles", edge_status.len())));
}
let faces = face_of.iter().max().map_or(0, |&m| (m as usize).saturating_add(1));
let n = r.unsigned_integer()? as usize;
let references = arrays::bool_array(r, n)?.iter().filter(|&&b| b).count();
let refs = arrays::compressed_indice_array(r, Some(references > 3))?;
if refs.len() != references {
return Err(malformed(format!("{} point references for {references} flagged points", refs.len())));
}
self.compressed_normals(t, faces)?;
let r = &mut self.r;
if r.bit()? {
arrays::bool_array(r, faces)?; // is_point_color_on_face
arrays::character_array(r, 8, None, false)?; // point_color_array
}
if r.bit()? {
arrays::bool_array(r, faces)?; // is_multiple_line_attribute_on_face
}
arrays::short_array(r, 16)?; // line_attributes
if !r.bit()? {
self.compressed_texture_parameter()?;
if !self.r.bit()? {
arrays::bool_array(&mut self.r, faces)?; // face_has_texture
}
}
if self.r.bit()? {
arrays::character_array(&mut self.r, 8, None, false)?; // behaviours
}
self.schema.skip_added_fields(TESS_3D_COMPRESSED, &mut self.r)?;
Ok(t)
}
/// A compressed tessellation's normals, from `must_recalculate_normals` on, read past.
fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result<(), PrcError> {
let r = &mut self.r;
if r.bit()? {
arrays::bool_array(r, triangles)?; // normal_is_reversed
r.double()?; // crease_angle
r.character()?; // normal_recalculation_flags
return Ok(());
}
let angle_bits = u32::from(r.character()?);
if angle_bits > MAX_ANGLE_BITS {
return Err(malformed(format!("{angle_bits}-bit normal angles")));
}
let n = r.unsigned_integer()? as usize;
arrays::bool_array(r, n)?; // is_binary_normal
arrays::short_array(r, angle_bits)?; // normal angles
arrays::bool_array(r, faces)?; // is_face_planar
Ok(())
}
/// `CompressedTextureParameter` [WD 7.8.9.8-7.8.9.9; pdf-issues #729, #749], read past.
fn compressed_texture_parameter(&mut self) -> Result<(), PrcError> {
let words = self.count(32, "texture data")?;
// count() bounded words * 32 by the remaining bits.
self.r.skip_bits(words.saturating_mul(32))?;
self.r.unsigned_integer()?; // last_integer_used_bit_number
let n = self.count(5, "texture references")?;
for _ in 0..n {
self.r.nbits_then_unsigned()?;
}
self.r.double()?; // tolerance
let n = self.count(32, "texture parameters")?;
for _ in 0..n {
self.r.float_as_bytes()?;
}
Ok(())
}
}
/// The normal array and each triangle's corner normals, or both empty when any slot fails to
/// name a normal: a mesh shades from its stored normals wholly or not at all.
fn stored_normals(coords: &[f64], slots: &[[u32; 3]], triangles: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
// Like a slot naming no normal, a partial last normal drops the stored normals.
let Ok(normals) = points(coords) else {
return (Vec::new(), Vec::new());
};
let named = |k: u32| k.is_multiple_of(3) && (k as usize / 3) < normals.len();
if normals.is_empty() || slots.len() != triangles || !slots.iter().flatten().all(|&k| named(k)) {
return (Vec::new(), Vec::new());
}
let corners = slots.iter().map(|t| t.map(|k| k / 3)).collect();
(normals, corners)
}
/// Per texture-coordinate set, each triangle's corner pair indices.
type UvSets = Vec<Vec<Option<[u32; 3]>>>;
/// The texture coordinates as (u, v) pairs, and per set the corner slots turned into pair
/// indices (a slot is a `Double` offset, so even); a triangle with a slot naming no pair is
/// `None`.
fn stored_uvs(coords: &[f64], sets: UvSets) -> (Vec<[f64; 2]>, UvSets) {
let uvs: Vec<[f64; 2]> = coords.as_chunks::<2>().0.to_vec();
if uvs.is_empty() {
return (Vec::new(), Vec::new());
}
let pair = |k: u32| (k.is_multiple_of(2) && (k as usize / 2) < uvs.len()).then_some(k / 2);
let sets =
sets.into_iter().map(|set| set.into_iter().map(|tri| tri.and_then(|[a, b, c]| Some([pair(a)?, pair(b)?, pair(c)?]))).collect()).collect();
(uvs, sets)
}
/// `coords` as points; an array that does not divide into whole points is malformed.
fn points(coords: &[f64]) -> Result<Vec<[f64; 3]>, PrcError> {
let (whole, rest) = coords.as_chunks::<3>();
if !rest.is_empty() {
return Err(malformed(format!("{} coordinates are not whole points", coords.len())));
}
Ok(whole.to_vec())
}
/// The point a `Double`-offset index names [WD 7.8.5.2: multiples of 3].
fn point_at(pts: &[[f64; 3]], idx: u32) -> Result<[f64; 3], PrcError> {
if !idx.is_multiple_of(3) {
return Err(malformed(format!("point index {idx} is not a multiple of 3")));
}
pts.get(idx as usize / 3).copied().ok_or_else(|| malformed(format!("point index {idx} past the coordinates")))
}
#[cfg(test)]
pub(crate) mod tests;
+738
View File
@@ -0,0 +1,738 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::triangulate::MAX_TEXTURE_SETS;
use super::*;
use crate::Schema;
use crate::testw::W;
const SQUARE: [f64; 12] = [0., 0., 0., 1., 0., 0., 1., 1., 0., 0., 1., 0.];
const ATTRIBUTE: u32 = 201;
const NORMAL_SINGLE: u32 = 0x4000_0000;
/// A 305 section holding `body` (already-written entities, `n` of them).
fn section(n: u32, body: &W) -> W {
let mut w = W::default();
w.uint(305).uint(0).bit(true).uint(n).append(body).uint(0);
w
}
fn uints(w: &mut W, v: &[u32]) {
w.uint(u32::try_from(v.len()).unwrap());
for &x in v {
w.uint(x);
}
}
fn square(w: &mut W, entity: u32) {
coordinates(w, entity, &SQUARE);
}
fn coordinates(w: &mut W, entity: u32, coords: &[f64]) {
w.uint(entity).bit(false).uint(u32::try_from(coords.len()).unwrap());
for &c in coords {
w.double(c);
}
}
#[derive(Default)]
struct Mesh<'a> {
recalc: bool,
indices: &'a [u32],
faces: &'a [(u32, u32, &'a [u32])],
textures: u32,
uvs: &'a [f64],
/// The coordinates; [`SQUARE`] when `None`.
coords: Option<&'a [f64]>,
}
/// A TESS_3D over [`SQUARE`] at authoring version `v`.
fn tess_3d(w: &mut W, v: u32, m: &Mesh<'_>) {
coordinates(w, 172, m.coords.unwrap_or(&SQUARE));
w.bit(true);
if v >= 7039 {
w.bit(false);
}
if v >= 7047 {
w.bit(m.recalc);
if m.recalc {
w.put(0, 8).double(0.5);
}
}
if m.recalc {
w.uint(0);
} else {
// Two normals: slot 0 is +z, slot 3 is -z.
w.uint(6);
for c in [0., 0., 1., 0., 0., -1.] {
w.double(c);
}
}
w.uint(0);
uints(w, m.indices);
w.uint(u32::try_from(m.faces.len()).unwrap());
for &(flags, start, data) in m.faces {
w.uint(174).uint(0).uint(0).uint(0).uint(flags).uint(start);
uints(w, data);
w.uint(m.textures).bit(false);
}
w.uint(u32::try_from(m.uvs.len()).unwrap());
for &c in m.uvs {
w.double(c);
}
}
fn decode(w: &W, schema: &Schema, version: u32) -> Result<Vec<Tessellation>, PrcError> {
let bytes = w.bytes();
Ctx::new(BitReader::new(&bytes), schema, version).file_structure_tessellation()
}
fn mesh(t: &Tessellation) -> &TriangleMesh {
match t {
Tessellation::Mesh(m) => m,
other => panic!("not a mesh: {other:?}"),
}
}
fn one(indices: &[u32], faces: &[(u32, u32, &[u32])], textures: u32) -> TriangleMesh {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices, faces, textures, ..Mesh::default() });
mesh(&decode(&section(1, &b), &Schema::default(), 8137).unwrap()[0]).clone()
}
#[test]
fn triangle_and_fan_with_normals() {
// One triangle (n,p x3), then a fan of 4 vertices (n,p each).
let m = one(&[3, 0, 0, 3, 3, 6, 0, 0, 3, 3, 0, 6, 3, 9], &[(0x2 | 0x4, 0, &[1, 1, 4])], 0);
assert_eq!(m.positions.len(), 4);
assert_eq!(m.positions[2], [1., 1., 0.]);
assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [0, 2, 3]]);
assert_eq!(m.faces, vec![0..3]);
assert!(!m.normals_recalculated);
assert_eq!(m.normals, [[0., 0., 1.], [0., 0., -1.]]);
// Each corner keeps the normal stored beside its point.
assert_eq!(m.triangle_normals, [[1, 0, 1], [0, 1, 0], [0, 0, 1]]);
}
#[test]
fn strip_normals_follow_the_alternating_winding() {
let m = one(&[3, 0, 0, 3, 3, 6, 0, 9], &[(0x8, 0, &[1, 4])], 0);
assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]);
assert_eq!(m.triangle_normals, [[1, 0, 1], [1, 0, 0]]);
}
#[test]
fn a_normal_index_past_the_array_drops_every_normal_but_no_triangle() {
let m = one(&[0, 0, 6, 3, 0, 6], &[(0x2, 0, &[1])], 0);
assert_eq!(m.triangles, [[0, 1, 2]]);
assert!(m.normals.is_empty());
assert!(m.triangle_normals.is_empty());
}
#[test]
fn recalculated_normals_drop_the_normal_slots_and_strips_alternate() {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { recalc: true, indices: &[0, 3, 6, 9], faces: &[(0x8, 0, &[1, 4])], ..Mesh::default() });
let t = decode(&section(1, &b), &Schema::default(), 8137).unwrap();
let m = mesh(&t[0]);
assert!(m.normals_recalculated);
assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]);
assert!(m.normals.is_empty() && m.triangle_normals.is_empty());
}
#[test]
fn version_gates_the_loop_and_recalculation_flags() {
for v in [7038, 7039, 7046, 7047, 8137] {
let mut b = W::default();
tess_3d(&mut b, v, &Mesh { indices: &[0, 0, 0, 3, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() });
// A trailing wire: a misread flag desyncs it.
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), v).unwrap();
assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]], "version {v}");
assert!(matches!(t[1], Tessellation::Wire(_)), "version {v}");
}
}
#[test]
fn one_normal_blocks_and_textures() {
// 0x20: n,p,p,p. 0x40 with NORMAL_Single: n then p per vertex.
// 0x2000 with one texture set: n,t,p,t,p,t,p.
let idx = [
0, 0, 3, 6, // 0x20
3, 0, 6, 9, // 0x40 single, 3 vertices
0, 0, 9, 2, 0, 4, 3, // 0x2000
];
let m = one(&idx, &[(0x20 | 0x40 | 0x2000, 0, &[1, 1, 3 | NORMAL_SINGLE, 1])], 1);
assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3], [3, 0, 1]]);
// A one-normal entity gives every corner its single normal.
assert_eq!(m.triangle_normals, [[0, 0, 0], [1, 1, 1], [0, 0, 0]]);
}
#[test]
fn one_normal_fan_without_single_keeps_a_normal_per_vertex() {
let m = one(&[0, 0, 0, 3, 0, 6, 0, 9], &[(0x40, 0, &[1, 4])], 0);
assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]);
}
#[test]
fn textured_fan_and_strip_carry_texture_slots_per_vertex() {
// 0x400 then 0x800, two texture sets: n,t,t,p per vertex.
let mut idx = Vec::new();
for p in [0, 3, 6, 0, 3, 6, 9] {
idx.extend([0, 0, 2, p]);
}
let m = one(&idx, &[(0x400 | 0x800, 0, &[1, 3, 1, 4])], 2);
assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [2, 1, 3]]);
}
/// The unit square as two textured triangles (one set; per vertex a normal, a texture slot,
/// a point), its corners at the picture's corners.
fn textured_square() -> W {
let mut body = W::default();
let indices = [0, 0, 0, 0, 2, 3, 0, 4, 6, 0, 0, 0, 0, 4, 6, 0, 6, 9];
tess_3d(
&mut body,
8137,
&Mesh { indices: &indices, faces: &[(0x200, 0, &[2])], textures: 1, uvs: &[0., 0., 1., 0., 1., 1., 0., 1.], ..Mesh::default() },
);
section(1, &body)
}
#[test]
fn a_textured_mesh_keeps_each_corners_coordinates() {
let t = decode(&textured_square(), &Schema::default(), 8137).unwrap();
let m = mesh(&t[0]);
assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]);
assert_eq!(m.uvs, [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
assert_eq!(m.triangle_uvs, [vec![Some([0, 1, 2]), Some([0, 2, 3])]]);
// An odd slot names no pair: that triangle has no coordinates.
let mut b = W::default();
tess_3d(
&mut b,
8137,
&Mesh { indices: &[0, 1, 0, 0, 2, 3, 0, 4, 6], faces: &[(0x200, 0, &[1])], textures: 1, uvs: &[0.0; 6], ..Mesh::default() },
);
let t = decode(&section(1, &b), &Schema::default(), 8137).unwrap();
let m = mesh(&t[0]);
assert_eq!(m.triangles, [[0, 1, 2]]);
assert_eq!(m.triangle_uvs, [vec![None]]);
}
#[test]
fn too_many_texture_sets_are_refused_before_anything_is_allocated() {
// An empty fan on a face claiming 2^32 - 1 texture sets: no slot bounds the count.
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: u32::MAX, ..Mesh::default() });
assert!(
matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(m)) if m == format!("{} texture-coordinate sets on a face", u32::MAX))
);
let sets = u32::try_from(MAX_TEXTURE_SETS).unwrap();
let mut ok = W::default();
tess_3d(&mut ok, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: sets, ..Mesh::default() });
assert!(decode(&section(1, &ok), &Schema::default(), 8137).is_ok());
}
/// A tessellation section holding one TESS_3D: [`SQUARE`] as two faces of one triangle each.
pub(crate) fn two_face_square() -> W {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], faces: &[(0x2, 0, &[1]), (0x2, 6, &[1])], ..Mesh::default() });
section(1, &b)
}
#[test]
fn two_faces_partition_the_triangles() {
let m = one(&[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], &[(0x2, 0, &[1]), (0x2, 6, &[1])], 0);
assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]);
assert_eq!(m.faces, [0..1, 1..2]);
}
/// A wire over [`SQUARE`]: an open two-point wire continued by a closing two-point wire,
/// then optionally per-segment RGB colours.
fn wire(w: &mut W, colours: bool) {
square(w, 175);
uints(w, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]);
w.bit(colours);
if colours {
// 5 points (4 + the implicit closing one) -> 2 segment colours.
w.bit(false).bit(true).bit(false).put(0xff0000, 24);
w.bit(true);
}
}
#[test]
fn wires_close_and_continue_and_colours_are_counted() {
for colours in [false, true] {
let mut b = W::default();
wire(&mut b, colours);
// A second entity: a wrong colour count would desync it.
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), 8137).unwrap();
for e in &t {
let Tessellation::Wire(w) = e else { panic!("not a wire") };
assert_eq!(w, &[vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [1., 1., 0.]]]);
}
}
}
#[test]
fn a_closing_wire_colours_its_implicit_point() {
// Per-point RGB: 4 indexed points + 1 implicit = 5 colours.
let mut b = W::default();
square(&mut b, 175);
uints(&mut b, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]);
b.bit(true).bit(false).bit(false).bit(false).put(1, 24);
for _ in 1..5 {
b.bit(true);
}
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), 8137).unwrap();
assert!(matches!(t[1], Tessellation::Wire(_)));
assert_eq!(t[0], t[1]);
}
#[test]
fn a_wire_index_must_name_a_point() {
let mut b = W::default();
square(&mut b, 175);
uints(&mut b, &[2, 0, 12]);
b.bit(false);
assert!(matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_))));
}
#[test]
fn a_wire_without_indices_is_the_coordinate_polyline() {
let mut b = W::default();
square(&mut b, 175);
b.uint(0).bit(true);
// 4 implicit points -> 4 RGB colours, all "same" after the first.
b.bit(false).bit(false).bit(false).put(1, 24);
for _ in 1..4 {
b.bit(true);
}
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), 8137).unwrap();
let Tessellation::Wire(w) = &t[0] else { panic!("not a wire") };
assert_eq!(w.len(), 1);
assert_eq!(w[0].len(), 4);
assert!(matches!(t[1], Tessellation::Wire(_)));
}
/// Reused colours cost one bit each, so a long run of them may end the section.
#[test]
fn reused_vertex_colours_may_be_the_last_data() {
let n = 40;
let mut b = W::default();
b.uint(175).bit(false).uint(3 * n);
for i in 0..3 * n {
b.double(f64::from(i));
}
// No indices: the polyline of all `n` points, one RGB colour then `n - 1` reuses.
b.uint(0).bit(true).bit(false).bit(false).bit(false).put(1, 24);
for _ in 1..n {
b.bit(true);
}
let t = decode(&section(1, &b), &Schema::default(), 8137).unwrap();
let Tessellation::Wire(w) = &t[0] else { panic!("not a wire") };
assert_eq!(w[0].len(), n as usize);
}
/// A square as one triangle plus one 4-vertex fan (two entities, three triangles), the face
/// carrying `attrs` and, when it has any, behaviour 0x30; then `colours` per-point colours
/// and a trailing wire.
fn styled_face(attrs: &[u32], colours: usize) -> Vec<Tessellation> {
decode(&styled_face_section(attrs, colours, 0x30), &Schema::default(), 8137).unwrap()
}
/// [`styled_face`]'s section, the face's behaviour `behaviour`.
fn styled_face_section(attrs: &[u32], colours: usize, behaviour: u32) -> W {
let mut b = W::default();
square(&mut b, 172);
b.bit(true).bit(false).bit(false).uint(0).uint(0);
uints(&mut b, &[0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9]);
b.uint(1).uint(174);
uints(&mut b, attrs);
b.uint(0).uint(0).uint(0x2 | 0x4).uint(0);
uints(&mut b, &[1, 1, 4]);
b.uint(0).bit(colours > 0);
if colours > 0 {
b.bit(false).bit(false).put(0x10_2030, 24);
for _ in 1..colours {
b.bit(true);
}
}
if !attrs.is_empty() {
b.uint(behaviour);
}
b.uint(0);
wire(&mut b, false);
section(2, &b)
}
#[test]
fn face_vertex_colours_count_points_not_data() {
// A triangle plus a fan of 4 vertices = 7 points -> 7 colours.
let t = styled_face(&[], 7);
assert_eq!(mesh(&t[0]).triangles.len(), 3);
assert!(matches!(t[1], Tessellation::Wire(_)));
}
fn styles(attrs: &[u32]) -> Vec<(u32, u16)> {
let t = styled_face(attrs, 0);
assert!(matches!(t[1], Tessellation::Wire(_)));
mesh(&t[0]).triangle_styles.iter().map(|g| (g.style, g.behaviour)).collect()
}
#[test]
fn face_line_attributes_style_its_triangles() {
assert!(styles(&[]).is_empty());
assert_eq!(styles(&[5]), [(5, 0x30); 3]);
// One per entity: the triangle, then both triangles of the fan.
assert_eq!(styles(&[4, 7]), [(4, 0x30), (7, 0x30), (7, 0x30)]);
// An entity styled 0 inherits, as do entities past the list.
assert_eq!(styles(&[4, 0, 9]), [(4, 0x30), (0, 0x30), (0, 0x30)]);
}
#[test]
fn markup_is_read_past() {
let mut b = W::default();
b.uint(176).bit(false).uint(3);
for c in [1., 2., 3.] {
b.double(c);
}
uints(&mut b, &[6, 0]);
b.uint(1).string(Some("R1")).string(Some("label")).put(7, 8);
wire(&mut b, false);
let t = decode(&section(2, &b), &Schema::default(), 8137).unwrap();
assert_eq!(t[0], Tessellation::Markup);
assert!(matches!(t[1], Tessellation::Wire(_)));
}
#[test]
fn schema_fields_are_skipped_after_each_entity() {
// A newer producer added one UnsignedInteger to TESS_3D and one to TESS_Face.
let mut s = W::default();
s.uint(2).uint(172).uint(1).uint(3).uint(174).uint(1).uint(3);
let sb = s.bytes();
let schema = Schema::read(&mut BitReader::new(&sb)).unwrap();
let mut b = W::default();
square(&mut b, 172);
b.bit(true).bit(false).bit(false).uint(0).uint(0);
uints(&mut b, &[0, 0, 0, 3, 0, 6]);
b.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0);
uints(&mut b, &[1]);
b.uint(0).bit(false).uint(99); // the face's added field
b.uint(0).uint(77); // texture count, then TESS_3D's added field
wire(&mut b, false);
let t = decode(&section(2, &b), &schema, 8137).unwrap();
assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]]);
assert!(matches!(t[1], Tessellation::Wire(_)));
// Without the schema the same bytes do not decode to the same thing.
assert_ne!(decode(&section(2, &b), &Schema::default(), 8137).ok(), Some(t));
}
#[test]
fn attributes_names_and_user_data_are_read_past() {
let mut w = W::default();
w.uint(305).uint(1).uint(ATTRIBUTE);
w.bit(true).uint(2); // predefined title
w.uint(2);
w.bit(false).string(Some("k")).uint(4).string(Some("v"));
w.bit(true).uint(1).uint(5).int(-1).uint(9);
w.uint(42); // the attribute's schema-added field
w.bit(false).string(Some("tess")).uint(1);
wire(&mut w, false);
w.uint(5).put(0b10110, 5);
let mut sw = W::default();
sw.uint(1).uint(ATTRIBUTE).uint(1).uint(3);
let sb = sw.bytes();
let schema = Schema::read(&mut BitReader::new(&sb)).unwrap();
let bytes = w.bytes();
let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137);
let t = ctx.file_structure_tessellation().unwrap();
assert!(matches!(t[..], [Tessellation::Wire(_)]));
assert_eq!(ctx.name.as_deref(), Some("tess"));
assert_eq!(ctx.r.position(), w.len());
}
/// A one-triangle TESS_3D_Compressed. `full` takes every optional branch: stored normals,
/// colours, line attributes, texture, behaviours. Otherwise `three_t` picks the 3T edge form.
fn compressed(w: &mut W, v: u32, full: bool, three_t: bool) {
compressed_with_angles(w, v, full, three_t, 10);
}
/// [`compressed`] whose `full` form stores `angle_bits`-bit normal angles.
fn compressed_with_angles(w: &mut W, v: u32, full: bool, three_t: bool, angle_bits: u8) {
w.uint(173).bit(false).bit(true).double(0.001);
if v >= 7031 {
w.put(0, 32).put(0, 32).put(0, 32);
}
// point_array: nine 2-bit values.
w.bit(false).uint(9);
for _ in 0..9 {
w.put(2, 8);
}
for _ in 0..9 {
w.bit(false).bit(true);
}
// edge_status_array: the 3T form (Huffman) or the T form.
if full {
w.bit(true).huffman(2, 2, &[(0, 0b10, 2)], &[0, 0, 0]);
} else if three_t {
w.bit(false).uint(3).put(0, 8).put(0, 8).put(0, 8);
} else {
w.bit(false).uint(1).put(0, 8);
}
// triangle_face_array [0]. `full`: four references, so the flagless reference array is
// Huffman; else none.
w.bit(false).uint(1).put(1, 8).bit(false);
if full {
w.uint(4).put(0b1111, 4);
w.huffman(6, 2, &[(1, 0b10, 2), (0, 0b11, 2)], &[1, 0, 0, 0]);
w.put(0, 4);
} else {
w.uint(3).put(0, 3).uint(0);
}
if full {
w.bit(false).put(u64::from(angle_bits), 8).uint(3).put(0b101, 3);
w.bit(true).huffman(10, 2, &[(5, 0b10, 2)], &[5, 5]);
w.bit(true); // is_face_planar
w.bit(true).bit(true).bit(false).uint(5);
for c in [1, 255, 0, 0, 128] {
w.put(c, 8);
}
w.bit(true).bit(false);
} else {
w.bit(true).bit(false).double(0.5).put(0, 8);
w.bit(false).bit(false);
}
w.bit(false).uint(1).put(1, 8).put(0, 8); // line_attribute_array
if full {
w.bit(false).uint(1).put(0, 32).uint(32);
w.uint(1).put(1, 5).bit(true).double(0.5).uint(2);
w.put(0, 32).put(0, 32).bit(false).bit(true);
w.bit(true).bit(false).uint(1).put(1, 8);
} else {
w.bit(true).bit(false);
}
}
#[test]
fn a_compressed_mesh_is_read_to_its_end() {
for (full, three_t) in [(false, false), (false, true), (true, true)] {
for v in [7030, 7031, 8137] {
let mut b = W::default();
compressed(&mut b, v, full, three_t);
let indices = [0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9];
tess_3d(&mut b, v, &Mesh { indices: &indices, faces: &[(0x2 | 0x4, 0, &[1, 1, 4])], ..Mesh::default() });
let w = section(2, &b);
let bytes = w.bytes();
let schema = Schema::default();
let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, v);
let t = ctx.file_structure_tessellation().unwrap();
assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }, Tessellation::Mesh(_)]), "full={full} v={v}: {t:?}");
assert_eq!(ctx.r.position(), w.len(), "full={full} v={v}");
}
}
}
#[test]
fn refusals_and_damage_are_errors() {
// Edge statuses neither T nor 3T long.
let mut c = W::default();
c.uint(173).bit(false).bit(true).double(0.001);
c.put(0, 32).put(0, 32).put(0, 32);
c.bit(false).uint(0).bit(false).uint(2).put(0, 16);
c.bit(false).uint(1).put(1, 8).bit(false);
assert!(matches!(decode(&section(1, &c), &Schema::default(), 8137), Err(PrcError::Malformed(_))));
// Optimised vertex colours.
let mut o = W::default();
square(&mut o, 175);
o.uint(0).bit(true).bit(false).bit(false).bit(true);
assert!(matches!(decode(&section(1, &o), &Schema::default(), 8137), Err(PrcError::Unsupported(_))));
// A point index that is not a multiple of 3, and one past the end.
for bad in [4, 12] {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, bad, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() });
assert!(matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_))));
}
// A fan whose count claims a billion points.
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0], faces: &[(0x4, 0, &[1, 0x3FFF_FFFF])], ..Mesh::default() });
assert!(decode(&section(1, &b), &Schema::default(), 8137).is_err());
// A tessellation count far past the data.
let mut w = W::default();
w.uint(305).uint(0).bit(true).uint(u32::MAX);
assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Truncated(_))));
// A wrong entity type where the section's own belongs.
let mut w = W::default();
w.uint(306);
assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Malformed(_))));
}
#[test]
fn every_truncation_of_a_section_is_an_error_not_a_panic() {
let mut b = W::default();
compressed(&mut b, 8137, true, true);
wire(&mut b, true);
let bytes = section(2, &b).bytes();
for end in 0..bytes.len() {
let cut = bytes.get(..end).unwrap();
// The zero padding of the last byte may still decode; anything shorter must not.
let _ = Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation();
}
let cut = bytes.get(..bytes.len() / 2).unwrap();
assert!(Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation().is_err());
}
/// A one-file-structure PRC stream whose tessellation section is `tess`, with an empty
/// globals section schema.
fn prc_file(tess: &[u8]) -> Vec<u8> {
let mut globals = W::default();
globals.uint(0);
prc_file_with(&globals.bytes(), tess)
}
/// As [`prc_file`] with the globals section `globals`.
fn prc_file_with(globals: &[u8], tess: &[u8]) -> Vec<u8> {
use std::io::Write as _;
const HEADER_LEN: usize = 107;
let zlib = |data: &[u8]| {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
};
let u32s = |out: &mut Vec<u8>, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes()));
let sections: [&[u8]; 5] = [globals, b"", tess, b"", b""];
let mut body = b"PRC".to_vec();
u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]);
let mut offs = vec![HEADER_LEN];
for s in sections {
offs.push(HEADER_LEN + body.len());
body.extend(zlib(s));
}
let mf_start = HEADER_LEN + body.len();
body.extend(zlib(b""));
let mf_end = HEADER_LEN + body.len();
let mut out = b"PRC".to_vec();
u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]);
u32s(&mut out, &[5, 6, 7, 8, 0, 6]);
u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::<Vec<_>>());
u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]);
out.extend(body);
out
}
#[test]
fn a_prc_file_yields_its_tessellations() {
let f = crate::PrcFile::parse(&prc_file(&textured_square().bytes())).unwrap();
let t = f.file_structures[0].tessellations().unwrap();
assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2], [0, 2, 3]]);
}
#[test]
fn normal_angles_wider_than_16_bits_are_refused_by_name() {
let mut b = W::default();
compressed_with_angles(&mut b, 8137, true, true, 17);
assert!(matches!(decode(&section(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(m)) if m == "17-bit normal angles"));
}
/// An entity's counted array is charged against the reader's element allowance.
#[test]
fn counted_entity_arrays_are_charged_against_the_allowance() {
let mut w = W::default();
w.uint(3).uint(1).uint(2).uint(3);
let bytes = w.bytes();
let schema = Schema::default();
let read = |n| Ctx::new(BitReader::with_elements(&bytes, n), &schema, 8137).uints("indices");
assert!(matches!(read(2), Err(PrcError::TooManyElements { .. })));
assert_eq!(read(3).unwrap(), [1, 2, 3]);
}
#[test]
fn a_face_behaviour_wider_than_16_bits_is_malformed() {
let r = decode(&styled_face_section(&[1], 0, 0x1_0030), &Schema::default(), 8137);
assert!(matches!(r, Err(PrcError::Malformed(m)) if m == "face behaviour 65584 is wider than 16 bits"));
}
#[test]
fn coordinates_that_are_not_whole_points_are_malformed() {
let mut partial = SQUARE.to_vec();
partial.push(7.0);
let mut b = W::default();
let faces: &[(u32, u32, &[u32])] = &[(0x2, 0, &[1])];
tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, 3, 0, 6], faces, coords: Some(&partial), ..Mesh::default() });
let r = decode(&section(1, &b), &Schema::default(), 8137);
assert!(matches!(r, Err(PrcError::Malformed(m)) if m == "13 coordinates are not whole points"));
// A partial last normal drops the stored normals, as a slot naming none does.
assert_eq!(stored_normals(&[0.0, 0.0, 1.0, 0.0], &[[0, 0, 0]], 1), (Vec::new(), Vec::new()));
}
/// Elements a tessellation section spends from a full allowance.
fn spent(section: &W) -> usize {
let bytes = section.bytes();
let schema = Schema::default();
let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137);
ctx.file_structure_tessellation().unwrap();
crate::MAX_DECODED_ELEMENTS - ctx.r.elements_left()
}
#[test]
fn faces_reusing_one_index_range_are_charged_for_each_triangle_they_make() {
const INDICES: [u32; 12] = [0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9];
let square = |faces: &[(u32, u32, &[u32])]| {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces, ..Mesh::default() });
spent(&section(1, &b))
};
let one = square(&[(0x2, 0, &[2])]);
let three = square(&[(0x2, 0, &[2]), (0x2, 0, &[2]), (0x2, 0, &[2])]);
// Per extra face: its count entry, its one data element and its two triangles, each
// 12 bytes of corners and 12 of normal slots.
assert_eq!(three - one, 2 * (2 + 2 * 4));
}
#[test]
fn reused_textured_faces_are_charged_for_every_corner_array() {
// The textured square, then one untextured triangle's normal and point slots.
const INDICES: [u32; 24] = [0, 0, 0, 0, 2, 3, 0, 4, 6, 0, 0, 0, 0, 4, 6, 0, 6, 9, 0, 0, 0, 3, 0, 6];
let square = |faces: &[(u32, u32, &[u32])], sets: u32| {
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces, textures: sets, uvs: &[0.0; 8], ..Mesh::default() });
spent(&section(1, &b))
};
let face: (u32, u32, &[u32]) = (0x200, 0, &[2]);
let untextured: (u32, u32, &[u32]) = (0x2, 18, &[1]);
let one = square(&[face], 1);
let three = square(&[face, face, face], 1);
// Per extra face: its count, its data element and two triangles of corners, normal slots
// and one texture set, 16 bytes a corner triple.
assert_eq!(three - one, 2 * (2 + 2 * 6));
// One textured face gives every triangle of the mesh a texture triple.
let mixed = square(&[face, untextured], 1);
assert_eq!(mixed - one, 2 + 6);
}
#[test]
fn a_face_is_charged_before_it_is_triangulated() {
// One face claiming a million triangles over a 12-entry index array.
const INDICES: [u32; 12] = [0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9];
let mut b = W::default();
tess_3d(&mut b, 8137, &Mesh { indices: &INDICES, faces: &[(0x2, 0, &[1_000_000])], ..Mesh::default() });
let bytes = section(1, &b).bytes();
let schema = Schema::default();
let r = Ctx::new(BitReader::with_elements(&bytes, 1000), &schema, 8137).file_structure_tessellation();
assert!(matches!(r, Err(PrcError::TooManyElements { .. })), "{r:?}");
}
#[test]
fn the_globals_schema_and_the_tessellations_share_one_allowance() {
// A schema of one entry with three tokens: four elements.
let mut globals = W::default();
globals.uint(1).uint(999).uint(3).uint(0).uint(0).uint(0);
let tess = textured_square();
let f = crate::PrcFile::parse(&prc_file_with(&globals.bytes(), &tess.bytes())).unwrap();
let need = spent(&tess) + 4;
let within = |elements| f.file_structures[0].tessellations_within(crate::bits::Budget { elements, ..crate::bits::Budget::FULL });
assert!(within(need).is_ok());
assert!(matches!(within(need - 1), Err(PrcError::TooManyElements { .. })));
}

Some files were not shown because too many files have changed in this diff Show More