Merge remote-tracking branch 'origin/main' into refactor/page-press-edit-text

# Conflicts:
#	crates/ui-egui/src/canvas.rs
This commit is contained in:
alehel
2026-11-05 14:30:43 +01:00
118 changed files with 7859 additions and 553 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 }}
+2 -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 (256)
## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -263,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)
+11
View File
@@ -3200,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
+21
View File
@@ -1303,6 +1303,15 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
@@ -4277,6 +4286,8 @@ dependencies = [
"pdfcraft-model",
"pdfcraft-organize",
"pdfcraft-platform",
"pdfcraft-prc",
"pdfcraft-prc-render",
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
@@ -4560,6 +4571,15 @@ dependencies = [
"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"
@@ -4718,6 +4738,7 @@ dependencies = [
name = "pdfcraft-web"
version = "0.18.1"
dependencies = [
"craft-web-host",
"eframe",
"egui",
"js-sys",
+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"
+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">
+22 -1
View File
@@ -21,10 +21,17 @@
> **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 (web: same-document host mode on the canvas, `data-host="page"`; 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)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
@@ -223,6 +230,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.
@@ -235,6 +243,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **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.
@@ -261,8 +271,10 @@ 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-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
@@ -291,6 +303,9 @@ 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-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
- **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. 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.
@@ -336,6 +351,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.
@@ -399,6 +416,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).
@@ -777,6 +796,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. |
@@ -787,6 +807,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. |
+3 -1
View File
@@ -11,10 +11,12 @@ workspace = true
[dependencies]
pdfcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "CustomEvent", "CustomEventInit"] }
js-sys = "0.3"
+81 -110
View File
@@ -5,18 +5,11 @@
#[cfg(target_arch = "wasm32")]
fn main() {
use eframe::wasm_bindgen::JsCast;
use pdfcraft_ui_egui::PdfCraftApp;
let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() };
wasm_bindgen_futures::spawn_local(async move {
let Some(canvas) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("pdfcraft"))
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
return;
};
let Some(canvas) = app_canvas() else { return };
let started = eframe::WebRunner::new()
.start(
canvas,
@@ -54,11 +47,11 @@ fn main() {
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
warn_before_unload(app.unsaved_flag.clone());
// `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such
// as the Nextcloud app) passes files in and takes saves back (`host` below).
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
// Host modes (`host` below, over `craft-web-host`): `?host=parent`, a
// same-origin page embedding PdfCraft in an iframe (such as the Nextcloud
// app), or `data-host="page"` on the canvas, a host that runs the module in
// its own document.
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
// `?author=`: the host's name for the user signs their comments,
// unless they chose a name in PdfCraft (the web default is "Guest").
if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty())
@@ -81,9 +74,7 @@ fn main() {
.await;
if let Err(e) = started {
eframe::web_sys::console::error_1(&e);
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
}
}
@@ -123,136 +114,116 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
Ok(js_sys::Uint8Array::new(&buf).to_vec())
}
/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a
/// `type`:
/// The app's canvas: the one element `index.html` and every embedding page must provide, with
/// the id the app looks up.
#[cfg(target_arch = "wasm32")]
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
use eframe::wasm_bindgen::JsCast;
web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
}
/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. The messages
/// and the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
/// other Craft apps' bridges, with the `pdfcraft:` prefix. This module is PdfCraft's part of it.
///
/// - `pdfcraft:ready` (to the parent): the app is listening; send files now.
/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or
/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL.
/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is
/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors).
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
///
/// Only the parent window on the app's own origin is heard or answered. With a parent on
/// another origin there is no bridge: the app runs as if unframed and saves download.
/// - `?host=parent` is the `postMessage` bridge for a same-origin page that embeds PdfCraft in an
/// iframe (such as the Nextcloud app). With a parent on another origin there is no bridge: the
/// app runs as if unframed and saves download (#2167). `data-host="page"` on the app's canvas
/// is for a host that loads the module into its own document, with no iframe: the same messages
/// travel as DOM `CustomEvent`s on that canvas. `?host=parent` wins when both are present.
/// - `pdfcraft:ready` once the app is listening; send files now. PdfCraft reads bytes, so it
/// says `file: false` and a host sends `bytes`.
/// - `pdfcraft:open` (from the host): `{ name, bytes }` holding a PDF. It opens in a tab, like a
/// `?file=` URL; a file over [`host::MAX_OPEN_BYTES`] is refused before it is copied.
/// - `pdfcraft:save` (to the host): `{ name, saveAs, bytes }` when the user saves. The host stores
/// the file (and reports its own errors).
/// - `pdfcraft:dirty` whenever unsaved work appears or goes away, `pdfcraft:failed` when the app
/// couldn't start.
#[cfg(target_arch = "wasm32")]
mod host {
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
/// The app id in the host messages (`pdfcraft:ready`).
const APP: &str = "pdfcraft";
/// The name a file gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "document.pdf";
/// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits).
const MAX_OPEN_BYTES: u64 = 512 << 20;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
host: Rc<Host>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent
/// is on another origin: messages only go to this origin, so the browser would drop
/// every save without an error while the document showed as saved (#2167).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
}
fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> {
let sent = match transfer {
Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)),
None => self.parent.post_message(message, &self.origin),
};
sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
/// The bridge for this page, or `None` when PdfCraft isn't hosted: no `?host=parent` and
/// no `data-host="page"` on the canvas, an unframed `?host=parent` page, or a parent on
/// another origin (messages only go to this origin, so the browser would drop every save
/// without an error while the document showed as saved, #2167).
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
pub fn post_ready(&self) {
if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) {
let _ = self.post(&m, None);
}
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGL, for example): let the parent say so.
/// The app couldn't start (no WebGL, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) {
let _ = self.post(&m, None);
}
let _ = self.host.post_failed(error, None);
}
/// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download.
/// Hear `pdfcraft:open` and queue the file like a `?file=` download.
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
queue_open(&name, &data, &inbox, &ctx);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("pdfcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string());
let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return };
let bytes = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
array.to_vec()
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
js_sys::Uint8Array::new(&bytes).to_vec()
} else {
return;
};
if let Ok(mut q) = inbox.lock() {
q.push((name, bytes));
}
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
let bridge = self.clone();
Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let m =
message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
bridge.post(&m, Some(&buffer))
})
let host = self.host.clone();
Box::new(move |name: &str, bytes: &[u8], save_as: bool| host.post_save(name, bytes, None, save_as))
}
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
let bridge = self.clone();
let host = self.host.clone();
// A host starts out clean: the first report is a change only when it is dirty.
let mut last = false;
Box::new(move |dirty: bool| {
if dirty != last {
last = dirty;
if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) {
let _ = bridge.post(&m, None);
}
host.post_dirty(dirty);
}
})
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
/// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` holds a PDF: queue it like a
/// `?file=` download and wake the app. The crate has cleaned the name (and cut it to 255
/// characters); a blank one comes as `document` and becomes `document.pdf`.
fn queue_open(name: &str, data: &FileData, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(view) = data else {
eframe::web_sys::console::warn_1(&format!("PdfCraft: host file {name} came as a File; PdfCraft reads bytes only; not opened").into());
return;
};
// A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory.
if data.size() > MAX_OPEN_BYTES as f64 {
eframe::web_sys::console::warn_1(
&format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20).into(),
);
return;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
if let Ok(mut q) = inbox.lock() {
q.push((name, view.to_vec()));
}
ctx.request_repaint();
}
}
+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

+3
View File
@@ -183,3 +183,6 @@ sync_github_name = true
[people.Combiler01]
sync_github_name = true
[people.helenkwok]
real_name = "Helen Kwok"
+2
View File
@@ -22,6 +22,7 @@ mod create;
mod draw;
mod info;
mod lookup;
mod model3d;
mod modify;
mod threed;
mod types;
@@ -37,6 +38,7 @@ 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,
+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)));
}
+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
+14 -2
View File
@@ -253,6 +253,12 @@ impl Automation {
if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") {
return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into()));
}
// Shift while drawing: each end or corner held level or plumb with the one before.
let ortho = a.opt_bool("ortho")?.unwrap_or(false);
if ortho && !matches!(kind, "line" | "arrow" | "polygon" | "cloud" | "polyline") {
return Err(ToolError::InvalidArgs("ortho applies to a line, arrow, polygon, cloud or polyline".into()));
}
let held = |last: [f64; 2], p: [f64; 2]| if ortho { pdfcraft_engine::measure::snap::ortho(last, p) } else { p };
let markup = match kind {
"highlight" => Some(Markup::Highlight),
"underline" => Some(Markup::Underline),
@@ -325,7 +331,8 @@ impl Automation {
Shape::TextBox { rect: rect_to_user(&info, a.need::<4>("rect", "a text box")?), font_size }
}
"line" | "arrow" => {
let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?);
let f = a.need::<2>("from", "a line")?;
let t = held(f, a.need::<2>("to", "a line")?);
let [start, end] = ending_pair(kind, endings.as_deref())?;
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end }
}
@@ -352,10 +359,15 @@ impl Automation {
"polygon" | "cloud" | "polyline" => {
let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points"));
let pts = a.get("points").and_then(|p| p.as_array()).ok_or_else(wrong)?;
let mut last = None;
let vertices = pts
.iter()
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
Some([Some(x), Some(y)]) => {
let p = last.map_or([*x, *y], |l| held(l, [*x, *y]));
last = Some(p);
Ok(to_user(&info, p[0], p[1]))
}
_ => Err(wrong()),
})
.collect::<Result<Vec<_>>>()?;
+34
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 })?
@@ -918,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)
}
}
+1
View File
@@ -28,6 +28,7 @@ mod links;
pub mod mcp;
mod measure;
mod model3d;
mod model_render;
mod printing;
mod reading;
mod redact;
+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]);
}
}
+20
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")
@@ -842,6 +861,7 @@ pub fn tools() -> Vec<ToolDef> {
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"width": { "type": "number", "minimum": 0, "description": "Line width in points." },
"endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." },
"ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." },
"font_size": { "type": "number", "exclusiveMinimum": 0 },
}),
&["doc", "page", "type"],
+94
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");
@@ -6437,6 +6493,44 @@ fn drawing_comments_through_tools() {
assert!(matches!(png[0], Content::Png { .. }));
}
/// comment_add `ortho`, Shift's hold while drawing: a line's end is held level or plumb with its
/// start, and each corner of connected lines or a polygon with the corner before.
#[test]
fn lines_and_corners_are_held_level_or_plumb_through_the_tool() {
let dir = workdir("ortho");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for args in [
json!({ "type": "line", "from": [20, 40], "to": [120, 52], "ortho": true }),
json!({ "type": "arrow", "from": [180, 40], "to": [170, 140], "ortho": true }),
json!({ "type": "polyline", "points": [[20, 200], [90, 210], [95, 260], [130, 250]], "ortho": true }),
json!({ "type": "line", "from": [20, 280], "to": [120, 290] }),
] {
let mut args = args;
args.as_object_mut().unwrap().extend([("doc".to_string(), json!(doc)), ("page".to_string(), json!(1))]);
ok(&mut a, "comment_add", args);
}
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
// In the order added: the list isn't sorted by index.
let mut listed = list["comments"].as_array().unwrap().clone();
listed.sort_by_key(|c| c["index"].as_u64());
let points: Vec<Vec<[f64; 2]>> =
listed.iter().map(|c| c["points"].as_array().unwrap().iter().map(|p| [p[0].as_f64().unwrap(), p[1].as_f64().unwrap()]).collect()).collect();
let want: [&[[f64; 2]]; 4] = [
&[[20.0, 40.0], [120.0, 40.0]],
&[[180.0, 40.0], [180.0, 140.0]],
&[[20.0, 200.0], [90.0, 200.0], [90.0, 260.0], [130.0, 260.0]],
&[[20.0, 280.0], [120.0, 290.0]],
];
assert_eq!(points.len(), want.len(), "{points:?}");
for (got, want) in points.iter().zip(want) {
assert_eq!(got.len(), want.len(), "{got:?}");
assert!(got.iter().zip(want).all(|(g, w)| (g[0] - w[0]).abs() < 0.01 && (g[1] - w[1]).abs() < 0.01), "{got:?} for {want:?}");
}
let refused = a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 50, 50], "ortho": true }));
assert!(matches!(refused, Err(ToolError::InvalidArgs(_))), "ortho is for lines and corners: {refused:?}");
}
#[test]
fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
let dir = workdir("signing-graphic");
+1
View File
@@ -9,6 +9,7 @@ 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;
+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.
+142 -18
View File
@@ -1,6 +1,8 @@
use std::collections::HashSet;
use super::reader::find;
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, held_bytes, objstm_member};
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,
};
@@ -129,8 +131,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))))
}
@@ -151,9 +153,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
@@ -388,7 +474,7 @@ impl Document {
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
match self.overlay.get(&num) {
Some(Slot::Set(_, o)) => return Ok(o.clone()),
Some(Slot::Set(_, o) | Slot::RepairedSet(_, _, o)) => return Ok(o.clone()),
Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)),
None => {}
}
@@ -519,11 +605,11 @@ impl Document {
None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
Some(XrefEntry::InFile { offset, .. }) => {
let resolve = |r: ObjRef| {
if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) {
if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
return known;
}
let length = self.try_get(r.num).ok().and_then(|o| o.as_int());
LENGTHS.with(|m| m.borrow_mut().insert(r.num, length));
let length = self.try_get_reference(r).ok().and_then(|o| o.as_int());
LENGTHS.with(|m| m.borrow_mut().insert(r, length));
length
};
let off = offset as usize;
@@ -640,6 +726,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);
@@ -699,7 +817,8 @@ 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);
}
@@ -728,8 +847,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(())
@@ -740,14 +865,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,
@@ -759,7 +883,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),
})
}
@@ -782,7 +906,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);
}
}
@@ -800,7 +924,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
@@ -7,7 +7,7 @@ thread_local! {
/// Indirect `/Length` values resolved during the current outermost load: a damaged file
/// 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).
pub(super) static LENGTHS: std::cell::RefCell<HashMap<u32, Option<i64>>> = std::cell::RefCell::new(HashMap::new());
pub(super) static LENGTHS: std::cell::RefCell<HashMap<ObjRef, Option<i64>>> = std::cell::RefCell::new(HashMap::new());
}
/// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream
@@ -65,6 +65,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),
}
@@ -103,6 +106,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,
}
@@ -133,7 +137,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() {
+14 -11
View File
@@ -599,7 +599,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);
@@ -687,7 +687,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]);
}
@@ -1181,15 +1181,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
+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)?;
+7
View File
@@ -406,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 {
+2
View File
@@ -101,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}",
@@ -252,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.
+2
View File
@@ -138,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 { .. }
@@ -704,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 } => {
+1
View File
@@ -78,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 { .. }
+2
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;
+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())
}
}
+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.
+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");
}
}
+6
View File
@@ -189,6 +189,12 @@ pub fn constrain(last: Point, point: Point) -> Point {
let angle = (v[1].atan2(v[0]) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
[last[0] + length * angle.cos(), last[1] + length * angle.sin()]
}
/// Hold a proposed vertex level or plumb with the last vertex, whichever is nearer (a CAD
/// drawing's ortho mode): the vertex slides onto that axis rather than turning onto it.
pub fn ortho(last: Point, point: Point) -> Point {
let v = sub(point, last);
if v[0].abs() >= v[1].abs() { [point[0], last[1]] } else { [last[0], point[1]] }
}
struct Walker<'a> {
doc: &'a Document,
geometry: Geometry,
+10
View File
@@ -449,6 +449,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() {
assert!(!listing.measurements.is_empty());
}
#[test]
fn ortho_holds_a_vertex_level_or_plumb_with_the_last() {
let last = [10.0, 20.0];
assert_eq!(snap::ortho(last, [50.0, 27.0]), [50.0, 20.0], "mostly across: level");
assert_eq!(snap::ortho(last, [-30.0, 13.0]), [-30.0, 20.0], "to the left too");
assert_eq!(snap::ortho(last, [14.0, 80.0]), [10.0, 80.0], "mostly up: plumb");
assert_eq!(snap::ortho(last, [8.0, -40.0]), [10.0, -40.0], "and down");
assert_eq!(snap::ortho(last, [30.0, 40.0]), [30.0, 20.0], "a tie stays level");
assert_eq!(snap::ortho(last, last), last);
}
#[test]
fn a_stream_over_the_byte_budget_uses_it_up_once() {
// 50 references to one Flate stream that inflates past the 16 MiB budget decode once.
let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]);
+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());
}
}
+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);
}
}
}
+5 -5
View File
@@ -894,7 +894,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
}
}
if changed {
doc.set(r, Object::Dict(nd));
doc.set_existing(r, Object::Dict(nd))?;
}
continue;
}
@@ -910,7 +910,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
changed |= ns.dict.remove(b"Alternates").is_some();
changed |= ns.dict.remove(b"OPI").is_some();
if changed {
doc.set(r, Object::Stream(ns));
doc.set_existing(r, Object::Stream(ns))?;
images += 1;
}
continue;
@@ -918,7 +918,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
if level == Level::A3b && ty.as_deref() == Some(b"Filespec") && d.get(b"EF").is_some() && d.get(b"AFRelationship").is_none() {
let mut nd = d.clone();
nd.set(b"AFRelationship".to_vec(), Object::name("Unspecified"));
doc.set(r, Object::Dict(nd));
doc.set_existing(r, Object::Dict(nd))?;
files += 1;
}
}
@@ -927,14 +927,14 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
for (r, o) in objects(doc) {
let Object::Dict(d) = &*o else { continue };
if d.get(b"S").is_some() && is_action(d) && chain_forbidden(doc, d).is_some() {
doc.set(r, Object::Null);
doc.set_existing(r, Object::Null)?;
actions += 1;
} else if let Some(Object::Dict(a)) = d.get(b"A")
&& chain_forbidden(doc, a).is_some()
{
let mut nd = d.clone();
nd.remove(b"A");
doc.set(r, Object::Dict(nd));
doc.set_existing(r, Object::Dict(nd))?;
actions += 1;
}
}
@@ -0,0 +1,81 @@
//! Preflight must preserve references accepted by the COS generation-repair policy.
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_preflight::{Level, convert};
use std::sync::Arc;
fn image_file(damaged: bool) -> Arc<Vec<u8>> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let bodies = [
"<< /Type /Catalog /Pages 2 0 R /HeaderImage 5 1 R /RowImage 5 0 R /StaleImage 5 2 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Resources << /XObject << /Im 5 1 R >> >> >>",
"null",
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /Interpolate true /Length 1 >>\nstream\nX\nendstream",
];
let mut offsets = Vec::new();
for (i, body) in bodies.iter().enumerate() {
offsets.push(bytes.len());
let generation = u16::from(i == 4);
bytes.extend_from_slice(format!("{} {generation} obj\n{body}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
for (i, offset) in offsets.into_iter().enumerate() {
let generation = u16::from(i == 4 && !damaged);
bytes.extend_from_slice(format!("{offset:010} {generation:05} n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Arc::new(bytes)
}
fn assert_images(doc: &Document, converted: bool, dual: bool) {
let root = doc.get(doc.root().unwrap());
let catalog = root.as_dict().unwrap();
for key in [b"HeaderImage".as_slice(), b"RowImage".as_slice()] {
let value = doc.resolve(catalog.get(key).unwrap());
if key == b"RowImage" && !dual {
assert_eq!(*value, Object::Null);
} else {
let Object::Stream(image) = &*value else { panic!("{key:?} became {value:?}") };
assert_eq!(image.dict.get(b"Interpolate"), Some(&Object::Bool(!converted)));
assert_eq!(&image.raw[..], b"X");
}
}
assert_eq!(*doc.resolve(catalog.get(b"StaleImage").unwrap()), Object::Null);
}
#[test]
fn preflight_preserves_repaired_image_references_in_snapshots_and_saves() {
for damaged in [false, true] {
let original = image_file(damaged);
let mut doc = Document::open(original.clone()).unwrap();
assert_images(&doc, false, damaged);
let snapshot = doc.clone();
let report = convert(&mut doc, Level::A2b).unwrap();
assert!(report.fixed.iter().any(|line| line.contains("cleared image interpolation")));
assert_images(&doc, true, damaged);
assert_eq!(doc.full_save_required(), damaged);
assert_images(&snapshot, false, damaged);
let mut restored = snapshot.clone();
restored.overlay_changes_from(&doc).unwrap();
assert_images(&restored, true, damaged);
for changed in [&doc, &restored] {
for object_streams in [false, true] {
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
let incremental = write_incremental(changed, &opts).unwrap();
if !damaged {
assert!(incremental.starts_with(&original));
}
assert_images(&Document::open(Arc::new(incremental)).unwrap(), true, damaged);
let full = write_full(changed, &opts).unwrap();
let reopened = Document::open(Arc::new(full)).unwrap();
assert_images(&reopened, true, damaged);
if damaged {
let catalog = reopened.get(reopened.root().unwrap());
let catalog = catalog.as_dict().unwrap();
assert_eq!(catalog.reference(b"HeaderImage"), catalog.reference(b"RowImage"), "saved aliases must share one object");
}
}
}
}
}
+260 -36
View File
@@ -6436,8 +6436,8 @@ fn draw_minified_image(
/// making scans, screenshots and rasterized text look soft.
#[test]
fn non_interpolated_images_are_not_smoothed_when_minified() {
// Top band K W R G, bottom band B Y M C. Nearest 8×4 → 4×2 sampling picks each block's
// pixel, so every device pixel is one palette colour; Catmull-Rom blends across blocks.
// Top band K W R G, bottom band B Y M C. Shrinking 8×4 → 4×2 averages each 2×2 block,
// so every device pixel is one palette colour; Catmull-Rom blends across blocks.
let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]];
let rgb: Vec<u8> = block_source(palette).concat();
let exact: Vec<[u8; 4]> = palette.iter().map(|c| [c[0], c[1], c[2], 255]).collect();
@@ -6455,38 +6455,76 @@ fn non_interpolated_images_are_not_smoothed_when_minified() {
assert!(pixels(&enabled).iter().any(|px| px[..3].iter().any(|&c| (32..=223).contains(&c))), "/Interpolate true made no mid-tone");
}
/// A shrunk, interpolated image whose edges fall between device pixels is snapped to whole
/// pixels (shrunk once to that size, drawn 1:1). Before, the shrunk raster was sampled a second
/// time at the fractional offset, so every edge of a scan was a blended, soft pixel.
/// A shrunk image whose edges fall between device pixels is snapped to whole pixels (shrunk
/// once to that size, drawn 1:1), interpolated or not. Before, the shrunk raster was sampled a
/// second time at the fractional offset, so every edge of a scan was a blended, soft pixel.
#[test]
fn interpolated_minified_image_edges_snap_to_whole_pixels() {
let black = vec![0u8; 40 * 40];
let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q";
let image = hex(&black);
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
fn minified_image_edges_snap_to_whole_pixels() {
for interpolate in ["/Interpolate true ", ""] {
let black = vec![0u8; 40 * 40];
let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q";
let image = hex(&black);
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 {interpolate}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
image.len() + 1
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (11, 11));
let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4];
// Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11.
for y in 1..11 {
for x in 0..10 {
assert_eq!(gray(x, y), 0, "pixel ({x}, {y}) inside the snapped image");
content.len(),
image.len() + 1
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (11, 11));
let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4];
// Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11.
for y in 1..11 {
for x in 0..10 {
assert_eq!(gray(x, y), 0, "{interpolate:?}: pixel ({x}, {y}) inside the snapped image");
}
assert_eq!(gray(10, y), 255, "{interpolate:?}: column 10 is outside the snapped image (row {y})");
}
for x in 0..11 {
assert_eq!(gray(x, 0), 255, "{interpolate:?}: row 0 is outside the snapped image (column {x})");
}
assert_eq!(gray(10, y), 255, "column 10 is outside the snapped image (row {y})");
}
for x in 0..11 {
assert_eq!(gray(x, 0), 255, "row 0 is outside the snapped image (column {x})");
}
/// A tile draws exactly the pixels of the whole page under it, also where a shrunk image's
/// edge falls on a half pixel. The edges were snapped by rounding halves away from zero, so
/// the tile at x = 5, which moves the left edge from 2.5 to -2.5, snapped it one pixel left of
/// where the whole page did and shifted every column of the image.
#[test]
fn a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does() {
for interpolate in ["/Interpolate true ", ""] {
let ramp: Vec<u8> = (0..40 * 40).map(|i| (i % 40 * 6) as u8).collect();
let content = "q 10 0 0 10 2.5 2.5 cm /Im0 Do Q";
let image = hex(&ramp);
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 {interpolate}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
image.len() + 1
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let req = RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() };
let full = renderer.render(req);
let tile = Tile { x: 5, y: 5, w: 10, h: 10 };
let part = renderer.render(RenderRequest { tile: Some(tile), ..req });
assert!(full.error.is_none() && part.error.is_none(), "{:?} {:?}", full.error, part.error);
assert_eq!((full.width, part.width, part.height), (20, 10, 10));
for y in 0..10usize {
for x in 0..10usize {
let whole = full.rgba[((y + 5) * 20 + x + 5) * 4];
assert_eq!(part.rgba[(y * 10 + x) * 4], whole, "{interpolate:?}: pixel ({x}, {y}) of the tile");
}
}
}
}
@@ -6570,28 +6608,161 @@ fn non_interpolated_gray_images_are_not_smoothed_when_minified() {
}
#[test]
fn non_integer_minification_samples_the_same_columns() {
// Ten columns drawn six pixels wide: centre sampling reads columns 0, 2, 4, 5, 7 and 9.
fn non_integer_minification_averages_the_columns_each_pixel_covers() {
// Ten columns drawn six pixels wide: each pixel covers 10/6 columns. Pixel 0 is 60% of
// column 0 and 40% of column 1 (0.6 × 10 + 0.4 × 20 = 14), pixel 1 is 20% of column 1,
// all of column 2's 60% and 20% of column 3 (30), and so on. Nearest-neighbour read whole
// columns 0, 2, 4, 5, 7 and 9 instead, dropping the four others.
let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100];
let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, None, (6, 1));
let grays: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
assert_eq!(grays, [10, 30, 50, 60, 80, 100]);
assert_eq!(grays, [14, 30, 46, 64, 80, 96]);
}
#[test]
fn non_integer_minification_samples_colour_and_mask_from_the_same_columns() {
// Ten gray columns with a soft mask, drawn six pixels wide: columns 0, 2, 4, 5, 7 and 9 are
// sampled, so the mask must pick the same columns as the colours.
fn non_integer_minification_averages_colour_and_mask_together() {
// Ten gray columns with a soft mask, drawn six pixels wide. Colours are averaged
// premultiplied, over the same columns as the mask, and composited over the white page.
// Pixel 0: 60% of column 0 (gray 10, opaque) and 40% of column 1 (transparent) is
// premultiplied gray 6 at alpha 153, so 6 + 255 × (1 − 153/255) = 108. Pixels 1 and 4
// cover only transparent columns and show the page.
let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100];
let mask = [255u8, 0, 0, 0, 255, 255, 0, 0, 0, 255];
let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, Some(&mask), (6, 1));
// Opaque columns keep their gray; transparent columns show the white page.
assert_eq!(
page.rgba.as_chunks::<4>().0.to_vec(),
vec![[10, 10, 10, 255], [255, 255, 255, 255], [50, 50, 50, 255], [60, 60, 60, 255], [255, 255, 255, 255], [100, 100, 100, 255]]
vec![[108, 108, 108, 255], [255, 255, 255, 255], [132, 132, 132, 255], [138, 138, 138, 255], [255, 255, 255, 255], [162, 162, 162, 255]]
);
}
/// A scan shown at screen size: a stroke one sample wide in an image drawn at a third of its
/// resolution. Nearest-neighbour read columns 1, 4 and 7 and lost the stroke in column 3, which
/// is how the text of 300 dpi scans broke up at 100% zoom. Area averaging keeps it as the
/// third of pixel 1 it covers.
#[test]
fn non_interpolated_minification_keeps_thin_strokes() {
let mut stroke = [255u8; 9];
stroke[3] = 0;
let page = draw_minified_image("DeviceGray", (9, 1), None, &stroke, None, (3, 1));
let grays: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
assert_eq!(grays, [255, 170, 255]);
}
/// A zoomed scan: an image of page-scan size drawn three times its resolution without
/// `/Interpolate`. Nearest-neighbour turned each sample into a 3 × 3 block, so the edges of
/// scanned text were staircases; it is smoothed (bilinearly) instead, as browsers draw it.
#[test]
fn large_non_interpolated_images_are_smoothed_when_enlarged() {
let side = 512usize;
let half: Vec<u8> = (0..side * side).map(|i| if i % side < side / 2 { 0 } else { 255 }).collect();
let page = draw_minified_image("DeviceGray", (512, 512), None, &half, None, (1536, 1536));
let row = 768 * 1536;
let edge: Vec<u8> = (760..776).map(|x| page.rgba[(row + x) * 4]).collect();
assert!(edge.iter().any(|v| (32..=223).contains(v)), "a hard edge, not a smoothed one: {edge:?}");
assert_eq!((page.rgba[(row + 700) * 4], page.rgba[(row + 836) * 4]), (0, 255), "away from the edge");
}
/// Small images (icons, QR codes, pixel art) keep their hard pixels when enlarged.
#[test]
fn small_non_interpolated_images_keep_hard_pixels_when_enlarged() {
let checker: Vec<u8> = (0..64).map(|i| if (i / 8 + i % 8) % 2 == 0 { 0 } else { 255 }).collect();
let page = draw_minified_image("DeviceGray", (8, 8), None, &checker, None, (80, 80));
assert!(page.rgba.as_chunks::<4>().0.iter().all(|px| px[0] == 0 || px[0] == 255), "a small image was smoothed");
}
/// A scan shrunk across and stretched down (a fax shown at screen size, zoomed in): the shrunk
/// side is still averaged, and the stretched side is smoothed as a scan enlarged both ways
/// is. Shrinking one side kept the nearest sampler for the other, so its edges were staircases.
#[test]
fn a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched() {
// Top half black; bottom half columns alternating black and white, which averages to gray.
let side = 512usize;
let source: Vec<u8> = (0..side * side).map(|i| if i / side < side / 2 || i % 2 == 0 { 0 } else { 255 }).collect();
let page = draw_minified_image("DeviceGray", (512, 512), None, &source, None, (256, 1536));
let gray = |x: usize, y: usize| page.rgba[(y * 256 + x) * 4];
let edge: Vec<u8> = (760..776).map(|y| gray(128, y)).collect();
assert!(edge.iter().any(|v| (16..=112).contains(v)), "a hard edge down the stretched side: {edge:?}");
assert_eq!(gray(128, 700), 0, "above the edge");
assert!((0..256).all(|x| gray(x, 836).abs_diff(128) <= 1), "the shrunk side is averaged at 1:1");
}
/// An image shrunk across and stretched down (a 204 × 98 dpi fax shown at screen size): the
/// shrunk side is still averaged. The area shrink was asked to enlarge the other side too,
/// refused, and the image fell back to its source resolution and the nearest sampler, which
/// read single columns.
#[test]
fn an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged() {
let ramp: Vec<u8> = [10u8, 20, 30, 40, 50, 60, 70, 80].repeat(2);
let page = draw_minified_image("DeviceGray", (8, 2), None, &ramp, None, (4, 4));
let grays: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
assert_eq!(grays, [15, 35, 55, 75].repeat(4), "each pixel is the mean of the two columns it covers");
}
/// Averaging straight colours would tint an opaque sample with the colour of a transparent
/// neighbour: white beside transparent black, shrunk to one pixel, came out gray over the white
/// page. Premultiplied, the transparent sample adds nothing but its transparency.
#[test]
fn non_interpolated_minification_does_not_bleed_transparent_colour() {
let page = draw_minified_image("DeviceGray", (2, 1), None, &[255, 0], Some(&[255, 0]), (1, 1));
assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), vec![[255, 255, 255, 255]]);
}
/// The same with a soft mask smaller than its image: red, red, blue, blue under a mask that is
/// opaque, then transparent, shrunk to one pixel, is half-covered red over the white page.
/// Colour and mask were averaged apart and multiplied, so the transparent blue came through
/// as purple (191, 127, 191).
#[test]
fn a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified() {
let content = "q 1 0 0 1 0 0 cm /Im0 Do Q";
let (image, mask) = ("FF0000FF00000000FF0000FF", "FF00");
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 1 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{mask}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
image.len() + 1,
mask.len() + 1
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
let [r, g, b, _] = page.rgba.as_chunks::<4>().0.first().copied().unwrap_or_default();
assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got ({r}, {g}, {b})");
}
/// A smaller soft mask that asks for `/Interpolate` stays smooth when it is sampled onto the grid
/// of an area-shrunk image. Here a 64 × 64 black image is drawn at 32 × 32 under a 2 × 2 mask,
/// opaque on the left and clear on the right: the mask is enlarged while the image shrinks, and
/// sampled nearest-neighbour onto the image's grid the edge became a hard black and white step.
#[test]
fn an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified() {
let content = "q 32 0 0 32 0 0 cm /Im0 Do Q";
let (image, mask) = ("00".repeat(64 * 64), "FF00FF00");
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 32 32] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{mask}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
image.len() + 1,
mask.len() + 1
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (32, 32));
let row: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().skip(16 * 32).take(32).map(|[r, ..]| *r).collect();
let ramp = row.iter().filter(|v| (1..255).contains(*v)).count();
assert!(ramp >= 8 && row.is_sorted() && row.first() == Some(&0) && row.last() == Some(&255), "a smooth black to white edge, got {row:?}");
}
#[test]
fn non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified() {
let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]];
@@ -6643,6 +6814,59 @@ fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() {
assert_eq!(grays[3], 255, "{grays:?}");
}
/// Issue #4800: scanners save the text of a page as a 1-bit `/ImageMask` stencil, painted in the
/// fill colour, and a stencil shrunk by nearest-neighbour lost the strokes thinner than the shrink
/// factor. Here a one-column red stroke of an 8-column stencil is drawn 4 pixels wide: nearest
/// reads columns 1, 3, 5 and 7 and misses it, while the area average makes it a half-covered red.
#[test]
fn a_shrunk_stencil_mask_keeps_thin_strokes() {
// Sample 0 paints (the default `/Decode [0 1]`), so 0xDF is a stroke in column 2.
let content = "q 1 0 0 rg 4 0 0 1 0 0 cm /Im0 Do Q";
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ImageMask true /Filter /ASCIIHexDecode /Length 3 >> stream\nDF>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len()
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (4, 1));
let px = page.rgba.as_chunks::<4>().0.to_vec();
let [r, g, b, _] = px.get(1).copied().unwrap_or_default();
assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got {px:?}");
assert_eq!(px.get(3), Some(&[255, 255, 255, 255]), "{px:?}");
}
/// A scan drawn at its own resolution keeps its hard pixels when its scale passes 1 only by
/// rounding: `x_scale` is an `f32`, and a 192 dpi scan on a 2× display at 100% can come out a
/// few ulps above 1. Smoothed bilinearly at a half-pixel offset, one-pixel stripes became gray.
/// Here `600.0001` (an `f32` of 600.00012) draws 600 samples 2·10⁻⁷ wider than 1:1.
#[test]
fn a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp() {
let content = "q 600.0001 0 0 600 0.5 0 cm /Im0 Do Q";
let image: String = (0..600 * 600).map(|i| if i % 2 == 0 { "00" } else { "FF" }).collect();
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 601 600] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 600 /Height 600 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
content.len(),
image.len() + 1
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
let row: Vec<u8> = page.rgba.as_chunks::<4>().0.iter().skip(300 * page.width as usize).take(page.width as usize).map(|[r, ..]| *r).collect();
let gray = row.iter().skip(10).take(580).filter(|v| (16..240).contains(*v)).count();
assert_eq!(gray, 0, "one-pixel stripes were blurred: {:?}", row.get(10..30));
}
/// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their
/// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels
/// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic
@@ -7716,7 +7940,7 @@ fn stitched_render_partitions_match_a_whole_render() {
/// and square caps, a sharp miter, a rotated and a stretched matrix, a hairline, dashes, a
/// curve, text, and a page shown rotated.
///
/// Images are skipped the same way (vendored patch (10)): an image on a tile, one crossing
/// Images are skipped the same way (vendored patch (11)): an image on a tile, one crossing
/// tile lines, and one fully off the page.
#[test]
fn skipping_paths_off_the_canvas_changes_no_pixel() {
+5
View File
@@ -108,6 +108,7 @@ impl PdfCraftApp {
ui_scale_applied: None,
flatten_fill_sign_on_save: false,
fill_text_size: fill_sign::TEXT_SIZE,
fill_tool: fill_sign::FillTool::Signature,
fill_text_color: [0.0; 3],
dialog: None,
updates: updates::Updates::default(),
@@ -128,6 +129,7 @@ impl PdfCraftApp {
last_session: Default::default(),
quit_session: None,
reload_changed_files: true,
update_check_at_start: true,
show_secured_notice: true,
always_default_view: false,
#[cfg(not(target_arch = "wasm32"))]
@@ -227,6 +229,8 @@ impl PdfCraftApp {
stamp_draft: Default::default(),
optimize_draft: OptimizeDraft::default(),
optimize_run: None,
scan_clean_draft: scan_clean_ui::ScanCleanDraft::default(),
scan_clean_run: None,
progress_notice: None,
page_clipboard: None,
grid_drop: None,
@@ -616,6 +620,7 @@ impl eframe::App for PdfCraftApp {
self.auto_ocr_scanned_page();
self.poll_ocr();
self.poll_optimize();
self.poll_scan_clean();
self.poll_action();
self.process_file_requests();
#[cfg(not(target_arch = "wasm32"))]
+10 -3
View File
@@ -47,6 +47,9 @@ impl PdfCraftApp {
("show_page_edge_shadows", _) => {
self.show_page_edge_shadows = value.parse::<bool>().map_err(|_| "show_page_edge_shadows must be true or false")?;
}
("update_check_at_start", _) => {
self.update_check_at_start = value.parse::<bool>().map_err(|_| "update_check_at_start must be true or false")?;
}
("scroll_speed", _) => {
self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?;
}
@@ -119,6 +122,7 @@ impl PdfCraftApp {
"export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)),
"accessibility-options" => Some(Dialog::AccessibilityOptions),
"recognize-text" => Some(Dialog::RecognizeText),
"enhance-scan" => Some(Dialog::EnhanceScan),
"js-console" => Some(Dialog::JsConsole),
"document-js" => Some(Dialog::DocumentJs),
"preferences" => Some(Dialog::Preferences),
@@ -333,9 +337,12 @@ impl PdfCraftApp {
field if field.starts_with("field-") => QuickTool::Field(
prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?,
),
fill if fill.starts_with("fill-") => QuickTool::Fill(
fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?,
),
fill if fill.starts_with("fill-") => {
let tool =
fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?;
self.fill_tool = tool;
QuickTool::Fill(tool)
}
other => {
let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?;
self.comment_prefs.group_tool[t.group()] = t;
+1
View File
@@ -5,6 +5,7 @@ impl PdfCraftApp {
pub(super) fn show_progress(&mut self, ctx: &egui::Context) {
if widgets::progress_notice(self, ctx) {
self.cancel_optimize();
self.cancel_scan_clean();
}
}
+6
View File
@@ -147,6 +147,9 @@ impl PdfCraftApp {
// Set outside json! above, which is at serde_json's macro recursion limit.
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
v["show_page_edge_shadows"] = self.show_page_edge_shadows.into();
// Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and
// settings written then would turn this off unasked.
v["update_check_at_start"] = self.update_check_at_start.into();
v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default();
v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned);
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
@@ -219,6 +222,9 @@ impl PdfCraftApp {
if let Some(on) = v["reload_changed_files"].as_bool() {
self.reload_changed_files = on;
}
if let Some(on) = v["update_check_at_start"].as_bool() {
self.update_check_at_start = on;
}
if let Some(on) = v["show_secured_notice"].as_bool() {
self.show_secured_notice = on;
}
+63 -6
View File
@@ -476,6 +476,10 @@ pub struct DocView {
pub forward: Vec<usize>,
/// Pending navigation: page and fraction down the page to align with the viewport top.
pub goto: Option<(usize, f32)>,
/// With `goto`: → / ← turned the page at an edge, so a page or spread wider than the window
/// starts at the edge the reader comes from (`false`: its left, after →; `true`: its right,
/// after ←) instead of keeping the column.
pub(crate) goto_side: Option<bool>,
/// Pending position within that page from a destination ([`Self::go_to_dest`]): the point
/// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points
/// from the viewport's top-left corner.
@@ -483,6 +487,12 @@ pub struct DocView {
/// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down /
/// Page Up where the pages scroll.
pub key_scroll: f32,
/// Pending keyboard scrolling sideways, in points right (negative: left): → / ← on a page
/// wider than the window.
pub key_side: f32,
/// As last drawn: whether the view was at its left and at its right edge (both when the
/// pages fit across), so → / ← turn the page there and scroll sideways elsewhere.
side_edges: Option<(bool, bool)>,
/// Single-page view, as last drawn: the page shown and whether the view was at its top and
/// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273).
single_edges: Option<(usize, bool, bool)>,
@@ -924,8 +934,11 @@ impl DocView {
back: Vec::new(),
forward: Vec::new(),
goto: None,
goto_side: None,
goto_point: None,
key_scroll: 0.0,
key_side: 0.0,
side_edges: None,
single_edges: None,
flash: None,
compare_marks: Vec::new(),
@@ -1159,6 +1172,7 @@ struct PageEnv<'a> {
info: &'a DocInfo,
initials: &'a Option<crate::fill_sign::SavedSig>,
key_scroll: f32,
key_side: f32,
middle_gesture: bool,
modal: bool,
navigation_enabled: bool,
@@ -1208,6 +1222,9 @@ struct PageOut {
page_action: Option<PageMenuAction>,
primary_down: bool,
refused: Option<String>,
/// The current page or spread where it is drawn, every page of it: one off screen (the second
/// page of a zoomed spread) has no screen transform, but still decides where the row ends.
row_on_screen: Option<Rect>,
stamp_placed: bool,
}
@@ -1600,6 +1617,7 @@ fn draw_pages(
hand,
info,
key_scroll,
key_side,
middle_gesture,
modal,
navigation_enabled,
@@ -1627,7 +1645,7 @@ fn draw_pages(
// ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution
// until after the text widgets when a selection owns that drag.
let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + coarse_scroll };
let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide;
let delta = auto_delta + edge_delta - vec2(key_side, key_scroll) + wheel_delta + glide;
if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
@@ -1740,6 +1758,7 @@ fn draw_pages(
page_action: _,
primary_down: _,
refused,
row_on_screen: _,
stamp_placed,
} = page_out;
let mut page_action: Option<PageMenuAction> = None;
@@ -1804,6 +1823,9 @@ fn draw_pages(
Some(Owner::PrepareCorner { .. }) => true,
_ => false,
};
// The row as it is now: in continuous view the current page may have changed above.
let row_on_screen =
shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union);
resp.context_menu(|ui| {
// Crop mode has no menu: a right-click on the picture being cropped, its box or its grips is
// the crop's (#5835).
@@ -1965,6 +1987,7 @@ fn draw_pages(
page_action,
primary_down: ptr.primary_down,
refused,
row_on_screen,
stamp_placed,
};
(wanted, visible_now)
@@ -2427,6 +2450,7 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size()));
} else if let Some((page, frac)) = view.goto.take() {
let side = view.goto_side.take();
let page = page.min(rects.len() - 1);
let r = rects[page];
// Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap()
@@ -2442,7 +2466,27 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry
// quick-action bar as the layout does.
let (x, w) = (view.last_scroll.x, avail.width());
let (left, right) = (r.left() - SIDE, r.right() + SIDE);
let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) };
// Turned by → / ← at an edge (`goto_side`): a page or spread wider than the window starts at
// the edge the reader comes from.
let row = shown_row(view, rects).iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union).unwrap_or(r);
let (row_left, row_right) = (row.left() - SIDE, row.right() + SIDE);
let target = match side {
Some(from_right) if row_right - row_left > w => {
if from_right {
row_right - w
} else {
row_left
}
}
_ if right - left <= w => {
if left >= x && right <= x + w {
x
} else {
r.center().x - w / 2.0
}
}
_ => x.clamp(left, right - w),
};
if (target - x).abs() > 0.5 {
scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0)));
}
@@ -2692,6 +2736,7 @@ struct GlideCx {
now: f64,
may_glide: bool,
key_scroll: f32,
key_side: f32,
auto_delta: Vec2,
zoom_delta: f32,
edge_delta: Vec2,
@@ -2705,11 +2750,11 @@ struct GlideCx {
/// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for
/// a reader action. Returns this frame's glide.
fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 {
let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx;
let GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx;
let over_page = ui.rect_contains_pointer(avail);
ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page));
let interrupted = glide_interrupted(ui);
if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {
if !may_glide || interrupted || key_scroll != 0.0 || key_side != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {
view.fling.stop();
}
let glide = view.fling.step(now);
@@ -2791,7 +2836,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
};
// Keyboard scrolling applies to this frame's page view only, never later.
let key_scroll = std::mem::take(&mut app.views[index].key_scroll);
if key_scroll != 0.0 {
let key_side = std::mem::take(&mut app.views[index].key_side);
if key_scroll != 0.0 || key_side != 0.0 {
app.views[index].end_find_following();
}
// The Search panel closed: its search moves to the find bar.
@@ -2891,7 +2937,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
read_wheel(ui, view, avail, single_paging, edge_turned, wheel_enabled, animates);
let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates;
let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || coarse_scroll != Vec2::ZERO || has_page;
let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled };
let motion = GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled };
let glide = step_glide(ui, view, avail, &motion);
// The wheel may have turned the page above: draw the row it turned to. Measured from the old
// row, the jump to the new page scrolled both out of view, a blank frame on every turn forward.
@@ -2998,6 +3044,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
info,
initials: &initials,
key_scroll,
key_side,
middle_gesture,
modal,
navigation_enabled,
@@ -3048,6 +3095,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
page_action,
primary_down,
mut refused,
row_on_screen,
stamp_placed,
} = page_out;
view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| {
@@ -3057,6 +3105,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0);
(page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0)
});
// Within a point, as above: at an edge of the scrolled content, or of the current page or
// spread shown whole that way (in continuous view the content is as wide as the widest page,
// and a narrower current page shows whole inside it).
let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0);
let inner = out.inner_rect;
view.side_edges = Some((
out.state.clamped_offset().x <= 1.0 || row_on_screen.is_some_and(|r| r.left() >= inner.left() - 1.0),
out.state.clamped_offset().x >= max_x - 1.0 || row_on_screen.is_some_and(|r| r.right() <= inner.right() + 1.0),
));
view.viewport_screen = out.inner_rect;
view.auto_scroll.paint(ui, avail);
+1 -1
View File
@@ -315,7 +315,7 @@ mod tests {
#[test]
fn a_wider_quick_bar_centres_by_its_own_width_and_needs_a_wider_margin() {
// Fill & Sign's Text tool widens the bar to 70 pt: it centres by that, and 72 pt no longer holds it.
// A 70 pt bar centres by that, and 72 pt no longer holds it.
assert_eq!(quick_bar_offset(120.0, 70.0), 15.0);
assert_eq!(quick_bar_offset(72.0, 70.0), 14.0);
}
+28 -4
View File
@@ -424,14 +424,39 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
}
}
// As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do
// ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view.
// ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. On a page wider
// than the window (a plan zoomed in) → / ← scroll sideways a line first, and turn the page
// once there is nothing left to scroll that way, as ↓ / ↑ do in single-page view; ⌘→ / ⌘←
// turn it at once.
let command = ctx.input(|i| i.modifiers.command);
let scrolls = view.continuous;
if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) {
let line = KEY_SCROLL_LINE * speed.factor();
// Only an up-to-date view counts: no jump pending.
let sideways = |view: &DocView, right: bool| {
!command && view.goto.is_none() && view.side_edges.is_some_and(|(left_edge, right_edge)| !if right { right_edge } else { left_edge })
};
// Turned at an edge, the next page starts at its left and the previous one at its right, as
// reading on across: on two wide pages → doesn't turn twice without showing the next one's left.
let at_edge = |view: &mut DocView, right: bool| {
if !command && view.goto.is_some() {
view.goto_side = Some(!right);
}
};
if key(Key::ArrowRight) && sideways(view, true) {
view.key_side += line;
} else if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) {
view.step_page(true);
if key(Key::ArrowRight) {
at_edge(view, true);
}
}
if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) {
if key(Key::ArrowLeft) && sideways(view, false) {
view.key_side -= line;
} else if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) {
view.step_page(false);
if key(Key::ArrowLeft) {
at_edge(view, false);
}
}
// ↓ / ↑ scroll a line, and Page Down / Page Up a screen where the pages scroll. In
// single-page view, ↓ / ↑ turn the page once there is nothing left to scroll that way, as
@@ -439,7 +464,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
// The Scroll speed preference sets how far ↓ / ↑ go as well as the wheel (#985); Page Down /
// Page Up keep a screen.
if !command {
let line = KEY_SCROLL_LINE * speed.factor();
let screen = (view.viewport_h - KEY_SCROLL_LINE).max(KEY_SCROLL_LINE);
let steps = [(Key::ArrowDown, line), (Key::ArrowUp, -line), (Key::PageDown, screen), (Key::PageUp, -screen)];
for (k, by) in steps {
+37
View File
@@ -2380,3 +2380,40 @@ fn a_camera_corner_at_the_page_edge_is_hit_on_the_page_only() {
assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(14.0, 24.0)).is_some(), "on the page");
assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(6.0, 16.0)).is_none(), "just off it");
}
/// #5125: after Enhance scanned file, the page shown is re-rendered, not the raster of the
/// scan before it was cleaned (same scale, so nothing else would replace it).
#[test]
fn enhancing_a_scan_re_renders_the_page_shown() {
let (w, h) = (64usize, 64usize);
let px: Vec<u8> = (0..w * h).map(|i| if i % w < 4 { 30 } else { 198 }).collect();
let mut pdf = format!(
"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {} >>\nstream\n",
px.len()
)
.into_bytes();
pdf.extend_from_slice(&px);
let content = format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q");
pdf.extend_from_slice(
format!(
"\nendstream\nendobj\n5 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n",
content.len()
)
.as_bytes(),
);
let mut app = PdfCraftApp::new();
app.open_bytes("scan.pdf", None, pdf).unwrap();
let (i, doc) = app.active_ids().unwrap();
let ctx = egui::Context::default();
let tex = ctx.load_texture("before", egui::ColorImage::filled([1, 1], Color32::WHITE), TextureOptions::LINEAR);
app.views[i].pages.insert(0, PageTex { tag: 1000, tex });
let job = app.session.scan_clean_job(doc, &[], pdfcraft_engine::scan_clean::ScanCleanSettings::default()).unwrap();
let cleaned = job.run(|_, _| true).unwrap();
assert_eq!(cleaned.report.pages, vec![0], "the page is a scan and gets cleaned");
let progress =
std::sync::Arc::new(std::sync::Mutex::new(crate::scan_clean_ui::ScanCleanProgress { result: Some(Ok(cleaned)), ..Default::default() }));
app.scan_clean_run = Some(crate::scan_clean_ui::ScanCleanRun { doc, progress });
app.poll_scan_clean();
assert!(app.scan_clean_run.is_none());
assert_eq!(app.views[i].pages.get(&0).unwrap().tag, STALE_TAG, "the old raster is re-rendered");
}
+2
View File
@@ -522,6 +522,7 @@ impl PdfCraftApp {
"export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)),
"export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)),
"ocr.recognize" => self.dialog = Some(Dialog::RecognizeText),
"ocr.enhance" => self.dialog = Some(Dialog::EnhanceScan),
"ocr.recognize_batch" => self.ocr_files_dialog(),
"edit.edit_text" => {
self.quick_tool = crate::QuickTool::EditText;
@@ -697,6 +698,7 @@ impl PdfCraftApp {
}
fill if crate::fill_sign::FillTool::from_command(fill).is_some() => {
let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) };
self.fill_tool = tool;
self.quick_tool = crate::QuickTool::Fill(tool);
let initials = tool == crate::fill_sign::FillTool::Initials;
if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) {
+12 -1
View File
@@ -62,6 +62,7 @@ struct Pending {
open_revision: Option<usize>,
a11y_now: bool,
ocr_now: bool,
scan_clean_now: bool,
compare_now: bool,
images_now: bool,
stamp_now: bool,
@@ -132,7 +133,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::Preferences => 610.0,
Dialog::About => 780.0,
Dialog::About => 820.0,
Dialog::PinTool => 540.0,
_ => 520.0,
});
@@ -393,6 +394,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
req.close = go || cancel;
return false;
}
Dialog::EnhanceScan => {
let (go, cancel) = crate::scan_clean_ui::body(ui, app, &t);
req.scan_clean_now = go;
req.close = go || cancel;
return false;
}
Dialog::AccessibilityOptions => {
let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t);
req.a11y_now = start;
@@ -1352,6 +1359,7 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending)
app.signature = d.saved();
crate::fill_sign::FillTool::Signature
};
app.fill_tool = tool;
app.quick_tool = crate::QuickTool::Fill(tool);
app.toast = None;
}
@@ -1995,6 +2003,9 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
if req.ocr_now {
app.start_ocr();
}
if req.scan_clean_now {
app.start_scan_clean();
}
if req.compare_now {
app.run_compare();
}
+326 -54
View File
@@ -266,12 +266,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let counts = crate::i18n::fmt(crate::i18n::t("{n} contributors · {c} commits"), &[("n", &list.len().to_string()), ("c", &group(TOTAL_COMMITS))]);
ui.label(RichText::new(counts).small().color(t.text_muted));
ui.separator();
egui::ScrollArea::both().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label(crate::i18n::t("No contributor data was built into this copy."));
} else if v.table {
table(ui, &list, &mut v);
} else {
if list.is_empty() {
ui.label(crate::i18n::t("No contributor data was built into this copy."));
} else if v.table {
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
// once its columns are dragged wider than the dialog.
ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| {
ui.set_max_height(LIST_HEIGHT);
egui::ScrollArea::horizontal().id_salt("credits_list").auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
});
} else {
egui::ScrollArea::vertical().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -280,43 +285,154 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
}
});
});
}
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui
.button(RichText::new(format!("{}{arrow}", crate::i18n::t(k.label().1))).strong())
.on_hover_text(crate::i18n::t(k.label().0))
.clicked()
{
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
/// How the contributor table's spare width is shared out: all of it to Name.
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
/// How the model table's spare width is shared out: to Company and Model, the text columns.
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
/// The narrowest a column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// The width a column needs for its header and its widest cell.
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
widest + 2.0 * ui.spacing().button_padding.x
}
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
})
.collect()
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
ui.with_layout(layout, add).inner
}
fn row_height(ui: &egui::Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Body) + 6.0
}
/// One contributor's table cells after the name, in `SortKey::ALL` order.
fn cells(c: &Contributor) -> [String; 9] {
[
group(c.prs),
group(c.commits),
group(c.lines_added),
group(c.lines_deleted),
signed(c.lines_delta()),
group(c.binary_added),
group(c.binary_deleted),
day(c.first_commit).to_string(),
day(c.last_commit).to_string(),
]
}
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
/// toolbar's sort-order button, so no arrow widens the column.
fn header_text(k: SortKey) -> RichText {
RichText::new(crate::i18n::t(k.label().1)).strong()
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
let h = row_height(ui);
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
let widths: Vec<f32> = SortKey::ALL
.iter()
.enumerate()
.map(|(i, &k)| match i.checked_sub(1) {
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
})
.collect();
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
.header(h + 4.0, |mut header| {
for k in SortKey::ALL {
header.col(|ui| {
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k)))
.on_hover_text(crate::i18n::t(k.label().0));
if r.clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
}
})
.body(|mut body| {
for (c, cells) in list.iter().zip(rows) {
body.row(h, |mut row| {
row.col(|ui| {
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
.on_hover_text(c.summary());
});
for text in cells {
row.col(|ui| {
aligned(ui, true, |ui| ui.label(text));
});
}
});
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
@@ -326,21 +442,50 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().id_salt("credits_models").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in MODEL_COLUMNS {
ui.label(RichText::new(crate::i18n::t(h)).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
let h = row_height(ui);
let titles = MODEL_COLUMNS;
// Company, Model and Version read as text; the counts line up on the right.
let right = [false, false, false, true, true, true];
let rows: Vec<[String; 6]> = MODELS
.iter()
.map(|m| {
[
m.company.to_string(),
m.model.to_string(),
m.version.to_string(),
group(m.commits),
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
]
})
.collect();
let widths: Vec<f32> = titles
.iter()
.enumerate()
.map(|(i, title)| natural(ui, RichText::new(crate::i18n::t(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
.collect();
ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| {
ui.set_max_height(LIST_HEIGHT);
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
.header(h + 4.0, |mut header| {
for (title, r) in titles.into_iter().zip(right) {
header.col(|ui| {
aligned(ui, r, |ui| ui.label(RichText::new(crate::i18n::t(title)).strong()));
});
}
})
.body(|mut body| {
for cells in rows {
body.row(h, |mut row| {
for (text, r) in cells.into_iter().zip(right) {
row.col(|ui| {
aligned(ui, r, |ui| ui.label(text));
});
}
});
}
});
});
});
}
@@ -421,4 +566,131 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
#[test]
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
}
#[test]
fn too_little_room_shrinks_only_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
@@ -659,6 +659,8 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut
ctx.checkbox(ui, &mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"), t);
#[cfg(not(target_arch = "wasm32"))]
ctx.checkbox(ui, &mut app.reload_changed_files, tl!("Reload documents when their files change on disk"), t);
#[cfg(not(target_arch = "wasm32"))]
ctx.checkbox(ui, &mut app.update_check_at_start, tl!("Check for updates when PdfCraft starts"), t);
ctx.checkbox(ui, &mut app.show_secured_notice, tl!("Show a notice when a document is secured"), t);
#[cfg(not(target_arch = "wasm32"))]
+73 -40
View File
@@ -239,8 +239,20 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_
QuickTool::Fill(f) => Some(f),
_ => None,
};
let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text);
let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign"));
let shown = current_fill.unwrap_or(app.fill_tool);
let tip = match shown {
crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials if current_fill.is_none() => {
let initials = shown == crate::fill_sign::FillTool::Initials;
let saved = if initials { app.initials.is_some() } else { app.signature.is_some() };
crate::i18n::fmt(
tl!(if saved { "Place saved {what}" } else { "Add {what}" }),
&[("what", tl!(if initials { "initials" } else { "signature" }))],
)
}
_ => tl!(shown.label()).to_string(),
};
let tip = format!("{}: {tip}", tl!("Fill & Sign"));
let resp = icons::button(ui, shown.icon(), cell, current_fill.is_some(), &tip);
let r = resp.rect;
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE));
@@ -287,6 +299,30 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_
});
}
fn controls_divider(ui: &mut egui::Ui, t: &Tokens, vertical: bool) {
let (r, _) = if vertical {
ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover())
} else {
ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover())
};
if vertical {
ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
} else {
ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider));
}
}
fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> {
let (index, id) = app.active_ids()?;
let (page, annotation) = app.views.get(index)?.comments.selected?;
let doc = app.session.get(id).filter(|doc| doc.allows_annotation())?;
doc.info
.annotations
.iter()
.any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
.then_some((page, annotation))
}
/// Extra controls rendered below/beside the buttons when a comment or fill tool is active.
fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) {
let current_fill = match app.quick_tool {
@@ -294,47 +330,44 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve
_ => None,
};
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
if vertical {
ui.label(tl!("Font size:"));
}
let size = ui.add(
egui::DragValue::new(&mut app.fill_text_size)
.range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
.speed(0.25)
.suffix(" pt"),
);
if ((size.changed() && !size.dragged()) || size.drag_stopped())
&& let Some((index, id)) = app.active_ids()
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
&& app.session.get(id).is_some_and(|doc| {
doc.allows_annotation()
&& doc
.info
.annotations
.iter()
.any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
})
{
app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size });
}
ui.menu_button(tl!("Colour"), |ui| {
if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) {
app.fill_text_color = color;
ui.close();
controls_divider(ui, t, vertical);
if let Some(color) = comments::quick_bar_colour(ui, app.fill_text_color) {
app.fill_text_color = color;
if let Some((page, index)) = selected_fill_text(app) {
app.apply_edit(Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None });
}
});
}
let resp = icons::button(ui, "a-large-small", 32.0, false, tl!("Font size"));
let resized = egui::Popup::menu(&resp)
.align(egui::RectAlign::RIGHT_START)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
ui.set_min_width(180.0);
ui.label(egui::RichText::new(tl!("Font size")).font(theme::semibold(12.0)));
let size = ui.add(
egui::Slider::new(&mut app.fill_text_size, crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
.logarithmic(true)
.suffix(" pt"),
);
// #1274: the size also applies to the Fill & Sign text that is selected,
// once per change (a typed value, or the end of a drag), so a drag adds
// one undo step and not one per frame.
(size.changed() && !size.dragged()) || size.drag_stopped()
})
.is_some_and(|popup| popup.inner);
let size = app.fill_text_size;
if resized
&& let Some((page, index)) = selected_fill_text(app)
&& app
.active_ids()
.and_then(|(_, id)| app.session.get(id)?.comment_props(page, index)?.font_size)
.is_none_or(|stored| format!("{stored:.3}") != format!("{size:.3}"))
{
app.apply_edit(Edit::FontSizeAnnotation { page, index, size });
}
}
if let QuickTool::Comment(tool) = app.quick_tool {
let (r, _) = if vertical {
ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover())
} else {
ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover())
};
if vertical {
ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
} else {
ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider));
}
controls_divider(ui, t, vertical);
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
&& let Some((index, id)) = app.active_ids()
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
+8
View File
@@ -182,6 +182,7 @@ use workflows::js_ui;
use workflows::ocr_ui;
use workflows::optimize_ui;
use workflows::print_ui;
use workflows::scan_clean_ui;
use workflows::web_print;
/// Files dropped on a document's page grid or its Pages panel.
@@ -332,6 +333,7 @@ pub struct PdfCraftApp {
pub flatten_fill_sign_on_save: bool,
/// Point size for new Fill & Sign text and dates, kept across launches.
pub fill_text_size: f64,
pub fill_tool: fill_sign::FillTool,
/// Colour for new Fill & Sign text and dates, kept across launches.
pub fill_text_color: [f64; 3],
pub dialog: Option<Dialog>,
@@ -371,6 +373,9 @@ pub struct PdfCraftApp {
quit_session: Option<last_session::LastSession>,
/// Preferences: reload a document when its file changes on disk (#431).
pub reload_changed_files: bool,
/// Preferences: ask for a newer release when the desktop app starts (#5737). Off, the app
/// asks only from Help ▸ Check for updates.
pub update_check_at_start: bool,
/// Preferences: tell the user, above the pages, that a document is secured (its security
/// settings restrict changes). Off only hides that notice (#4035); kept in the settings.
pub show_secured_notice: bool,
@@ -549,6 +554,9 @@ pub struct PdfCraftApp {
pub optimize_draft: OptimizeDraft,
/// The running optimization (Optimize PDF ▸ Advanced optimization).
pub optimize_run: Option<optimize_ui::OptimizeRun>,
/// Scan & OCR ▸ Enhance scanned file: the dialog's choices and the running clean-up.
pub scan_clean_draft: scan_clean_ui::ScanCleanDraft,
pub scan_clean_run: Option<scan_clean_ui::ScanCleanRun>,
/// A background job's progress card (see [`widgets::progress_notice`]).
pub progress_notice: Option<widgets::ProgressNotice>,
/// Pages copied or cut in Organize Pages, ready to paste (into any document).
+8 -4
View File
@@ -433,7 +433,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
buttons_left = ui.min_rect().left();
});
});
egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| {
let body = egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| {
ui.set_width(ui.available_width());
if editing {
if let Some(e) = inline_editor(ui, t, view, key, 2) {
@@ -531,7 +531,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
// The header selects the card, except where its buttons are.
let mut r = head.response.rect;
r.max.x = r.max.x.min(buttons_left - 2.0);
r
(r, body.response.rect)
});
let rect = frame.response.rect;
if selected {
@@ -545,7 +545,8 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
ui.add_space(5.0);
// The header row selects the card (and shows the comment on the page); unselected cards are
// clickable everywhere.
let hit = if selected { frame.inner } else { rect };
let (head, body) = frame.inner;
let hit = if selected { head } else { rect };
let click = ui
.interact(hit, ui.scope_id().with(("card", key)), Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
@@ -555,7 +556,10 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
view.comments.reply.clear();
*nav = Some(Nav::Flash(a.page, a.shown_rect(rotation)));
}
if click.double_clicked() && allowed {
// On the selected card a double-click on the text edits it too (dragging over it selects
// words of it, for copying).
let body = (selected && !editing).then(|| ui.interact(body, ui.scope_id().with(("card-body", key)), Sense::click()));
if (click.double_clicked() || body.is_some_and(|b| b.double_clicked())) && allowed {
view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default());
}
let menu_id = ui.scope_id().with(("card-menu", key));
+5 -2
View File
@@ -21,7 +21,8 @@ pub(crate) struct Updates {
updater: Updater,
/// The Updates dialog is showing.
pub(crate) open: bool,
/// Ask for a newer release at start; the desktop app turns it on, so tests never reach the network.
/// Ask for a newer release at start, unless Preferences says not to; the desktop app turns
/// it on, so tests never reach the network.
#[cfg(not(target_arch = "wasm32"))]
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
@@ -73,10 +74,12 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
{
let Some(ctx) = self.ctx.clone() else { return };
// Settled on the first frame: a preference turned on later waits for the next start.
let at_start = std::mem::take(&mut self.updates.startup_check) && self.update_check_at_start;
let updates = &mut self.updates;
if std::mem::take(&mut updates.check_requested) {
updates.updater.check_now(&ctx);
} else if updates.startup_check {
} else if at_start {
updates.updater.check_at_start(&ctx);
}
updates.updater.tick(&ctx);
+2
View File
@@ -166,6 +166,8 @@ pub enum Dialog {
AccessibilityOptions,
/// Scan & OCR ▸ Recognize text.
RecognizeText,
/// Scan & OCR ▸ Enhance scanned file.
EnhanceScan,
/// The JavaScript console (⌘J).
JsConsole,
/// Document JavaScripts.
+1 -1
View File
@@ -41,7 +41,7 @@ pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
#[cfg(test)]
use menu::{RECENT_CUSTOM, same_colour};
pub(crate) use menu::{attachment_items, context_menu, quick_bar_controls};
pub(crate) use menu::{attachment_items, context_menu, quick_bar_colour, quick_bar_controls};
pub(crate) use paint::paint_page;
#[cfg(test)]
use paint::{changing_points, drawn_shape, edited_in_view};
+12 -6
View File
@@ -32,15 +32,21 @@ pub(super) fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option<u
a.points.iter().position(|q| cx.to_screen([f64::from(q[0]), f64::from(q[1])]).distance(p) <= 7.0)
}
/// `points` with point `i` moved to `to`; with Shift, held to the nearest 45° from the point
/// before it (a line's other end; the first corner turns about the second).
/// `to` held level or plumb with `last` while Shift is down (ortho, as in a CAD drawing): the
/// next end or corner of a line, arrow, connected lines, polygon or cloud.
pub(crate) fn held(last: Option<&[f64; 2]>, to: [f64; 2], shift: bool) -> [f64; 2] {
match last {
Some(l) if shift => pdfcraft_engine::measure::snap::ortho(*l, to),
_ => to,
}
}
/// `points` with point `i` moved to `to`; with Shift, held level or plumb with the point before
/// it (a line's other end; the first corner is held to the second).
pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> {
let mut out = points.to_vec();
let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) };
let to = match anchor {
Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to),
_ => to,
};
let to = held(anchor, to, shift);
if let Some(p) = out.get_mut(i) {
*p = to;
}
+16 -8
View File
@@ -1,8 +1,8 @@
use super::geometry::{
CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable,
resized, snapped_caret,
CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, point_handle, reaches_points, reshapable, reshaped,
resizable, resized, snapped_caret,
};
use super::paint::{drawn_shape, edit_anchor, erase, new_comment};
use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment};
use super::state::{Composer, ComposerKind, Gesture, PageCx};
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
use crate::page_cursor::{Layer, Subject};
@@ -355,6 +355,8 @@ impl Gestures<'_> {
}
fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool {
// Shift holds a line level or plumb (ortho).
let shift = self.resp.ctx.input(|i| i.modifiers.shift);
let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self;
let cv = &mut view.comments;
if !cx.allowed {
@@ -383,7 +385,8 @@ impl Gestures<'_> {
}
} else {
points.truncate(1);
points.push(u);
let line = matches!(*tool, CommentTool::Line | CommentTool::Arrow);
points.push(held(points.first(), u, line && shift));
}
}
if resp.drag_stopped()
@@ -417,6 +420,8 @@ impl Gestures<'_> {
}
fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool {
// Shift holds the next side of connected lines, a polygon or a cloud level or plumb.
let shift = self.resp.ctx.input(|i| i.modifiers.shift);
let Self { ui, resp, cx, pointer, over_page, .. } = *self;
let cv = &mut view.comments;
if !cx.allowed {
@@ -443,8 +448,9 @@ impl Gestures<'_> {
match cv.gesture.as_mut() {
Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => {
// Clicking the first point again closes a polygon.
let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0;
let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0);
let closes = closes_at(cx, tool, points, p);
let u = held(points.last(), u, shift);
let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(cx.to_screen(u)) < 2.0);
if closes {
let pts = std::mem::take(points);
cv.gesture = None;
@@ -615,9 +621,11 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool
}
let cv = &mut view.comments;
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
// One step back per press: cancel the gesture, then deselect, then drop the tool.
// One step back per press: cancel the gesture, then deselect, then drop the tool (a
// comment tool, or a Fill & Sign one: its Text tool would otherwise open a box at every
// click with no way out by the keyboard).
let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some();
if !cancelled && matches!(tool, QuickTool::Comment(_)) {
if !cancelled && matches!(tool, QuickTool::Comment(_) | QuickTool::Fill(_)) {
*tool = QuickTool::Select;
}
}
+18 -11
View File
@@ -286,25 +286,32 @@ pub fn fill_picker(ui: &mut egui::Ui, current: Option<Rgb>) -> Option<Option<Rgb
picked
}
/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness.
pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option<Rgb> {
let style = prefs.style(tool);
let mut selected_color = None;
if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() {
prefs.pinned = !prefs.pinned;
}
pub(crate) fn quick_bar_colour(ui: &mut egui::Ui, current: Rgb) -> Option<Rgb> {
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
ui.painter().circle_filled(r.center(), 9.0, color32(style.color));
ui.painter().circle_filled(r.center(), 9.0, color32(current));
ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour")));
let resp = resp.on_hover_text(tl!("Colour"));
let mut picked = None;
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if let Some(c) = swatch_grid(ui, Some(style.color)) {
prefs.set_color(tool, c);
selected_color = Some(c);
if let Some(c) = swatch_grid(ui, Some(current)) {
picked = Some(c);
ui.close();
}
});
picked
}
/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness.
pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option<Rgb> {
let style = prefs.style(tool);
if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() {
prefs.pinned = !prefs.pinned;
}
let selected_color = quick_bar_colour(ui, style.color);
if let Some(c) = selected_color {
prefs.set_color(tool, c);
}
if tool.has_fill() {
let resp = fill_button(ui, style.fill);
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
+11 -3
View File
@@ -1,4 +1,4 @@
use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable};
use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, reshapable, reshaped, resizable};
use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS};
use super::{
Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape,
@@ -165,10 +165,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke));
}
CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => {
// The vertices so far, and a rubber band to the pointer.
// The vertices so far, and a rubber band to where a click would put the next one: the
// first, where the click closes the shape, Shift or not.
let mut line = pts.clone();
if let Some(p) = cx.ptr.pos {
line.push(p);
let shift = painter.ctx().input(|i| i.modifiers.shift);
line.push(if closes_at(cx, *tool, points, p) { pts[0] } else { cx.to_screen(held(points.last(), cx.to_user(p), shift)) });
}
painter.add(egui::Shape::line(line, stroke));
if let Some(first) = pts.first().filter(|_| *tool != CommentTool::PolyLine && pts.len() >= 3) {
@@ -187,6 +189,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
}
}
/// Whether a click at `p` (on screen) closes the polygon or cloud drawn so far (`points`, user
/// space): on its first point again, once it has three.
pub(super) fn closes_at(cx: &PageCx<'_>, tool: CommentTool, points: &[[f64; 2]], p: Pos2) -> bool {
points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0
}
/// The shape a finished drawing gesture makes (`None` if it is too small to mean anything).
pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
let (first, last) = (*points.first()?, *points.last()?);
+15 -6
View File
@@ -374,20 +374,22 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
}
continue;
};
ui.horizontal(|ui| {
let on = app.quick_tool == crate::QuickTool::Fill(if i == 0 { FillTool::Signature } else { FillTool::Initials });
let wash = ui.painter().add(egui::Shape::Noop);
let row = ui.horizontal(|ui| {
let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click());
let use_label = label("Use {what}");
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone()));
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, use_label.clone()));
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE);
painter.rect_stroke(
rect,
CornerRadius::same(4),
Stroke::new(1.0, if response.hovered() { t.accent } else { t.border }),
Stroke::new(1.0, if on || response.hovered() { t.accent } else { t.border }),
egui::StrokeKind::Inside,
);
paint_saved(ui, rect.shrink(8.0), saved, &mut app.saved_signature_previews[i], &format!("saved-{what}"));
if response.on_hover_text(label("Place saved {what}")).clicked() {
if crate::theme::hand(response).on_hover_text(label("Place saved {what}")).clicked() {
command = Some(use_id);
}
if crate::icons::button(ui, "pencil", 26.0, false, &label("Change {what}")).clicked() {
@@ -397,6 +399,9 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
command = Some(remove_id);
}
});
if on {
ui.painter().set(wash, egui::Shape::rect_filled(row.response.rect.expand(3.0), CornerRadius::same(6), t.accent_soft));
}
ui.add_space(4.0);
}
command
@@ -486,7 +491,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo
match tool {
FillTool::Text => {
// Clicking away from an open box commits it: the page sees this click before the box
// sees its lost focus, so replacing the box first would drop what was typed.
// sees its lost focus, so replacing the box first would drop what was typed. A new box
// deselects, as a comment tool's note or text box does: Escape then closes the box and
// leaves the tool in two presses.
view.comments.selected = None;
let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true });
open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| {
FillAction::Edit(Box::new(typed_with_color(
@@ -582,7 +590,8 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
r.request_focus();
t.focus = false;
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
// Taken here: the same press must not also leave the tool (one step back per press).
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
cancel = true;
} else if r.lost_focus() {
commit = true;
+1
View File
@@ -187,6 +187,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("wrench", include_bytes!("../../../../assets/icons/wrench.svg")),
("move-horizontal", include_bytes!("../../../../assets/icons/move-horizontal.svg")),
("pilcrow", include_bytes!("../../../../assets/icons/pilcrow.svg")),
("a-large-small", include_bytes!("../../../../assets/icons/a-large-small.svg")),
("subscript", include_bytes!("../../../../assets/icons/subscript.svg")),
("superscript", include_bytes!("../../../../assets/icons/superscript.svg")),
("x", include_bytes!("../../../../assets/icons/x.svg")),
+1 -1
View File
@@ -99,7 +99,7 @@ mod tests {
#[test]
fn shared_catalog_preserves_existing_icons_and_fallback_uri() {
assert_eq!(ICONS.len(), 190);
assert_eq!(ICONS.len(), 191);
assert!(exists("minimize-2"));
for (name, original) in ICONS {
let expected =

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