mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 18:56:25 +00:00
Merge remote-tracking branch 'upstream/main' into fix/checkbox-kid-on-states-l4
# Conflicts: # crates/xfdf/src/tests.rs
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
+26
-1
@@ -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,11 @@ 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, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation 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-24 (M5, part opacity):** Comment Properties sets a text box's text, border and fill, and a shape's line and fill, each at an opacity of its own (a see-through fill with an opaque line on a plan); stored as /CA, /ca (PDF 2.0) and /PCTextOpacity and drawn with separate graphics states, so other viewers show it. Make Properties Default and the settings carry it; comment_add / comment_edit `fill_opacity`, `text_opacity`. Four new labels in every catalog. Synthetic annot, UI and automation regressions. 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.
|
||||
|
||||
@@ -333,9 +350,13 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
|
||||
- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
|
||||
- **2026-10-20 (M5, several comments):** Comment Properties opened on one of several selected comments, the context menu's Colour and the quick bar's colour change all of them as one undo step, each where the property applies (a font size to text boxes, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Copied Fill & Sign ticks, crosses, dots and lines hang on the pointer as themselves rather than as empty boxes, and copied text boxes show their text (`FillMark::strokes` is shared by the appearance and the outline). Estimates unchanged.
|
||||
- **2026-10-19 (M5, comments between documents):** Comments copy and paste, within and between documents: ⌘C with comments selected copies them as XFDF (each with its replies) to the app's and the system clipboard, ⌘V / Edit ▸ Paste comments / the page menu pastes them onto the current page as new comments under names of their own (`Edit::PasteComments`, `pdfcraft_xfdf::import_comments` with a target page and fresh names, so nothing is replaced and pasting twice gives two sets; `export_xfdf_comments` writes a selection with its replies). Several comments select at once (⌘-click, ⇧-click, on the page and in the panel); Delete removes them as one step and a drag moves them together. Connected lines, polygons and clouds finish from the right-click menu's Complete (Undo last point, Cancel drawing beside it), as other editors do. Tools `comments_copy` / `comments_paste`. Estimates unchanged.
|
||||
|
||||
- **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 +420,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 +800,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 +811,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. |
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
@@ -183,3 +183,6 @@ sync_github_name = true
|
||||
|
||||
[people.Combiler01]
|
||||
sync_github_name = true
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
+170
-64
@@ -232,6 +232,31 @@ fn rg_stroke(c: Rgb) -> String {
|
||||
format!("{} {} {} RG\n", n(c[0]), n(c[1]), n(c[2]))
|
||||
}
|
||||
|
||||
/// A FreeText's border colour where it has its own: the stroking colour in `/DA` (`RG`, or `G`
|
||||
/// for grey). `None`: the border takes the text colour.
|
||||
pub fn da_border(d: &Dict) -> Option<Rgb> {
|
||||
let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned())?;
|
||||
let toks: Vec<&str> = da.split_whitespace().collect();
|
||||
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|x| x.is_finite());
|
||||
let mut col = None;
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
match *t {
|
||||
"RG" if i >= 3 => {
|
||||
if let (Some(r), Some(g), Some(b)) = (num(i - 3), num(i - 2), num(i - 1)) {
|
||||
col = Some([r, g, b].map(|x| x.clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
"G" if i >= 1 => {
|
||||
if let Some(g) = num(i - 1) {
|
||||
col = Some([g.clamp(0.0, 1.0); 3]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col
|
||||
}
|
||||
|
||||
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt.
|
||||
pub fn parse_da(d: &Dict) -> (Rgb, f64) {
|
||||
let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default();
|
||||
@@ -262,19 +287,47 @@ pub fn parse_da(d: &Dict) -> (Rgb, f64) {
|
||||
(col.map(|x| x.clamp(0.0, 1.0)), size.min(400.0))
|
||||
}
|
||||
|
||||
fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
|
||||
if opacity >= 1.0 && !multiply {
|
||||
/// How opaque a comment's parts are drawn, 0–1: its line or border (`/CA`); its fill or
|
||||
/// background (`/ca`, which PDF 2.0 gives the non-stroking operations; without it, as the line);
|
||||
/// and a text box's text (`/PCTextOpacity`, PdfCraft's own; without it, as the fill, since text
|
||||
/// is filled too). A comment with only `/CA` is drawn at that opacity throughout, as before.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PartOpacity {
|
||||
pub line: f64,
|
||||
pub fill: f64,
|
||||
pub text: f64,
|
||||
}
|
||||
|
||||
/// The parts' opacities of the comment `d` (see [`PartOpacity`]).
|
||||
pub fn part_opacity(d: &Dict) -> PartOpacity {
|
||||
let get = |key: &[u8]| d.get(key).and_then(|o| o.as_f64()).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
|
||||
let line = get(b"CA").unwrap_or(1.0);
|
||||
let fill = get(b"ca").unwrap_or(line);
|
||||
let text = get(b"PCTextOpacity").unwrap_or(fill);
|
||||
PartOpacity { line, fill, text }
|
||||
}
|
||||
|
||||
/// `/GS0`: the line's opacity for strokes and the fill's for everything filled; `/GS1`, where the
|
||||
/// text's differs from the fill's: the text's for its glyphs.
|
||||
fn ext_gstate(parts: PartOpacity, multiply: bool) -> Option<Dict> {
|
||||
if parts.line >= 1.0 && parts.fill >= 1.0 && parts.text >= 1.0 && !multiply {
|
||||
return None;
|
||||
}
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
if multiply {
|
||||
gs.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
}
|
||||
gs.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
gs.set(b"ca".to_vec(), Object::Real(opacity));
|
||||
let gs = |stroke: f64, fill: f64| {
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
if multiply {
|
||||
gs.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
}
|
||||
gs.set(b"CA".to_vec(), Object::Real(stroke));
|
||||
gs.set(b"ca".to_vec(), Object::Real(fill));
|
||||
Object::Dict(gs)
|
||||
};
|
||||
let mut res = Dict::new();
|
||||
res.set(b"GS0".to_vec(), Object::Dict(gs));
|
||||
res.set(b"GS0".to_vec(), gs(parts.line, parts.fill));
|
||||
if parts.text != parts.fill {
|
||||
res.set(b"GS1".to_vec(), gs(parts.line, parts.text));
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
@@ -313,10 +366,18 @@ fn stamp_turn(d: &Dict) -> i64 {
|
||||
|
||||
/// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by
|
||||
/// (`/PCTextRotate`, written when it is added to a turned page).
|
||||
fn text_turn(d: &Dict) -> i64 {
|
||||
pub(crate) fn text_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// A FreeText's `/RD`: how far its text box lies inside `/Rect` from the left, bottom, right and
|
||||
/// top. A distance a hair below 0 (rounding) reads as 0; `None` when one is negative or they can't
|
||||
/// be read.
|
||||
pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> {
|
||||
let rd: [f64; 4] = nums(d, b"RD")?.try_into().ok()?;
|
||||
rd.iter().all(|x| x.is_finite() && *x > -0.001).then(|| rd.map(|x| x.max(0.0)))
|
||||
}
|
||||
|
||||
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
|
||||
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
|
||||
/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a
|
||||
@@ -391,7 +452,9 @@ struct AppearanceContext<'a> {
|
||||
subtype: &'a [u8],
|
||||
rect: [f64; 4],
|
||||
cloudy: bool,
|
||||
/// The line's opacity (`parts.line`).
|
||||
opacity: f64,
|
||||
parts: PartOpacity,
|
||||
stroke: Option<Rgb>,
|
||||
width: f64,
|
||||
}
|
||||
@@ -408,7 +471,7 @@ impl AppearanceDrawing {
|
||||
fn new(context: &AppearanceContext<'_>) -> Self {
|
||||
let mut drawing = Self::default();
|
||||
if let Some(gs) = ext_gstate(
|
||||
context.opacity,
|
||||
context.parts,
|
||||
context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")),
|
||||
) {
|
||||
drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
@@ -455,7 +518,8 @@ fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
|
||||
subtype,
|
||||
rect,
|
||||
cloudy,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
opacity: part_opacity(d).line,
|
||||
parts: part_opacity(d),
|
||||
stroke: color(d, b"C")?,
|
||||
width: border_width(d),
|
||||
})
|
||||
@@ -845,46 +909,25 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
let [x0, y0, x1, y1] = draw;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let lw = w.min(h) * 0.12;
|
||||
// The strokes come from `FillMark::strokes`, which a copy's outline draws too.
|
||||
let path = |mark: crate::FillMark| {
|
||||
let ops: Vec<String> = mark
|
||||
.strokes(draw)
|
||||
.iter()
|
||||
.flat_map(|s| s.iter().enumerate().map(|(i, p)| format!("{} {} {}", n(p[0]), n(p[1]), if i == 0 { "m" } else { "l" })))
|
||||
.collect();
|
||||
format!("{} S\n", ops.join(" "))
|
||||
};
|
||||
match name {
|
||||
b"PCCheck" => c.push_str(&format!(
|
||||
"{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + w * 0.15),
|
||||
n(y0 + h * 0.5),
|
||||
n(x0 + w * 0.4),
|
||||
n(y0 + h * 0.2),
|
||||
n(x0 + w * 0.88),
|
||||
n(y0 + h * 0.85)
|
||||
)),
|
||||
b"PCCross" => c.push_str(&format!(
|
||||
"{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + w * 0.18),
|
||||
n(y0 + h * 0.18),
|
||||
n(x1 - w * 0.18),
|
||||
n(y1 - h * 0.18),
|
||||
n(x0 + w * 0.18),
|
||||
n(y1 - h * 0.18),
|
||||
n(x1 - w * 0.18),
|
||||
n(y0 + h * 0.18)
|
||||
)),
|
||||
b"PCCheck" => c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Check))),
|
||||
b"PCCross" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Cross))),
|
||||
b"PCDot" => {
|
||||
let r = w.min(h) * 0.3;
|
||||
c.push_str(&rg(col));
|
||||
c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r));
|
||||
c.push_str("f\n");
|
||||
}
|
||||
b"PCLine" => c.push_str(&format!(
|
||||
"{}{} w 1 J\n{} {} m {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(h.clamp(0.5, 2.0)),
|
||||
n(x0),
|
||||
n(y0 + h / 2.0),
|
||||
n(x1),
|
||||
n(y0 + h / 2.0)
|
||||
)),
|
||||
b"PCLine" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(h.clamp(0.5, 2.0)), path(crate::FillMark::Line))),
|
||||
other => {
|
||||
// Only stamps PdfCraft made: others keep their own artwork.
|
||||
if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) {
|
||||
@@ -902,14 +945,59 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
Some(form(rect, c.as_bytes(), res))
|
||||
}
|
||||
|
||||
/// The oval or circle (`shape`) around a text box whose whole box is `full`: its background
|
||||
/// and border go into `c`; returns the text box inside it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shaped_border(
|
||||
d: &Dict,
|
||||
shape: crate::BorderShape,
|
||||
full: [f64; 4],
|
||||
turn: i64,
|
||||
bw: f64,
|
||||
bg: Option<Rgb>,
|
||||
line_color: Rgb,
|
||||
c: &mut String,
|
||||
) -> Option<[f64; 4]> {
|
||||
// Laid out as displayed: a quarter turn swaps the sides' differences.
|
||||
let rect = match inset(d) {
|
||||
Some(rd) => {
|
||||
let (dx, dy) = if matches!(turn, 90 | 270) { (rd[1], rd[0]) } else { (rd[0], rd[1]) };
|
||||
[full[0] + dx, full[1] + dy, full[2] - dx, full[3] - dy]
|
||||
}
|
||||
None => shape.inside(full, bw, full),
|
||||
};
|
||||
if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let h = bw / 2.0;
|
||||
let [x0, y0, x1, y1] = [full[0] + h, full[1] + h, full[2] - h, full[3] - h];
|
||||
let path = match shape {
|
||||
crate::BorderShape::Circle => {
|
||||
let (cx, cy, r) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0).min(y1 - y0) / 2.0);
|
||||
ellipse(cx - r, cy - r, cx + r, cy + r)
|
||||
}
|
||||
_ => ellipse(x0, y0, x1, y1),
|
||||
};
|
||||
if let Some(bg) = bg {
|
||||
c.push_str(&format!("{}{path}f\n", rg(bg)));
|
||||
}
|
||||
if bw > 0.0 {
|
||||
c.push_str(&format!("{}{} w\n{}{path}S\n[] 0 d\n", rg_stroke(line_color), n(bw), dash(d)));
|
||||
}
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
|
||||
let d = context.dict;
|
||||
let parts = context.parts;
|
||||
let rect = context.rect;
|
||||
let stroke = context.stroke;
|
||||
let mut c = std::mem::take(&mut drawing.content);
|
||||
let mut res = std::mem::take(&mut drawing.resources);
|
||||
let _tail = std::mem::take(&mut drawing.tail);
|
||||
let (text_color, size) = parse_da(d);
|
||||
// The box's border and a callout's leader line: their own colour, else the text's.
|
||||
let line_color = da_border(d).unwrap_or(text_color);
|
||||
let bg = stroke;
|
||||
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
|
||||
// A callout: the leader line (arrowhead at its first point), and the text box inside
|
||||
@@ -948,7 +1036,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
|
||||
c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
|
||||
}
|
||||
@@ -957,26 +1045,40 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some(bg) = bg {
|
||||
c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));
|
||||
}
|
||||
if bw > 0.0 {
|
||||
let h = bw / 2.0;
|
||||
c.push_str(&format!(
|
||||
"{}{} w\n{}{} {} {} {} re S\n[] 0 d\n",
|
||||
rg_stroke(text_color),
|
||||
n(bw),
|
||||
dash(d),
|
||||
n(rect[0] + h),
|
||||
n(rect[1] + h),
|
||||
n(rect[2] - rect[0] - bw),
|
||||
n(rect[3] - rect[1] - bw)
|
||||
));
|
||||
// An oval or a circle (`/PCBorderShape`) around the text box, which is `/Rect` inset by
|
||||
// `/RD` and keeps its place: background and border take the shape, the text is laid out
|
||||
// in the text box as in a rectangle.
|
||||
let shape = crate::BorderShape::of(d);
|
||||
if shape != crate::BorderShape::Rectangle {
|
||||
rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?;
|
||||
} else {
|
||||
if let Some(bg) = bg {
|
||||
c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));
|
||||
}
|
||||
if bw > 0.0 {
|
||||
let h = bw / 2.0;
|
||||
c.push_str(&format!(
|
||||
"{}{} w\n{}{} {} {} {} re S\n[] 0 d\n",
|
||||
rg_stroke(line_color),
|
||||
n(bw),
|
||||
dash(d),
|
||||
n(rect[0] + h),
|
||||
n(rect[1] + h),
|
||||
n(rect[2] - rect[0] - bw),
|
||||
n(rect[3] - rect[1] - bw)
|
||||
));
|
||||
}
|
||||
}
|
||||
let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default();
|
||||
let pad = 2.0 + bw;
|
||||
let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0);
|
||||
let q = d.int(b"Q").unwrap_or(0);
|
||||
// In an oval or a circle the lines are centred in the text box, up and down too.
|
||||
let round = shape != crate::BorderShape::Rectangle;
|
||||
let q = if round { 1 } else { d.int(b"Q").unwrap_or(0) };
|
||||
// The text at its own opacity, where it has one.
|
||||
if parts.text != parts.fill {
|
||||
c.push_str("/GS1 gs\n");
|
||||
}
|
||||
c.push_str(&format!(
|
||||
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
|
||||
n(rect[0]),
|
||||
@@ -987,8 +1089,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
rg(text_color)
|
||||
));
|
||||
let mut out = c.into_bytes();
|
||||
let lines = wrap(&text, size, width);
|
||||
let mut y = rect[3] - pad - size * 0.9;
|
||||
for line in wrap(&text, size, width) {
|
||||
if round {
|
||||
y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0);
|
||||
}
|
||||
for line in lines {
|
||||
if y < rect[1] - size {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
use super::{Dict, Object, PartOpacity, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
|
||||
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
|
||||
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
|
||||
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
|
||||
let stroke = color(d, b"C").flatten();
|
||||
let mut res = Dict::new();
|
||||
if let Some(gs) = ext_gstate(opacity, false) {
|
||||
if let Some(gs) = ext_gstate(PartOpacity { line: opacity, fill: opacity, text: opacity }, false) {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
}
|
||||
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
|
||||
|
||||
+14
-13
@@ -1,6 +1,6 @@
|
||||
use super::draw::draw_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
||||
use super::modify::{n, rich_text};
|
||||
use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box};
|
||||
use super::types::{
|
||||
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
|
||||
};
|
||||
@@ -214,8 +214,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
let rect = rect_for(&new.shape, style)?;
|
||||
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
|
||||
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
|
||||
let opacity = style.opacity.clamp(0.0, 1.0);
|
||||
set_non_opaque_style(&mut d, opacity);
|
||||
if style.fill_opacity.into_iter().chain(style.text_opacity).any(|o| !o.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
let line = style.opacity.clamp(0.0, 1.0);
|
||||
let fill = style.fill_opacity.map_or(line, |o| o.clamp(0.0, 1.0));
|
||||
let text = style.text_opacity.map_or(fill, |o| o.clamp(0.0, 1.0));
|
||||
set_part_keys(&mut d, appearance::PartOpacity { line, fill, text });
|
||||
let border = |d: &mut Dict| {
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
|
||||
@@ -380,20 +385,22 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter"));
|
||||
}
|
||||
let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 };
|
||||
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
|
||||
d.set(b"DA".to_vec(), free_text_da(style.color, style.border_color, size));
|
||||
d.set(b"Q".to_vec(), Object::Int(0));
|
||||
if let Some(f) = style.fill {
|
||||
d.set(b"C".to_vec(), rgb(f));
|
||||
}
|
||||
border(&mut d);
|
||||
rich_text(&mut d);
|
||||
// The text is laid out for the page as displayed, like a stamp's label.
|
||||
if !matches!(new.shape, Shape::Callout { .. }) {
|
||||
// The text is laid out for the page as displayed, like a stamp's label.
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if matches!(turn, 90 | 180 | 270) {
|
||||
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
// An oval or a circle goes around the text box, its text wrapped as displayed:
|
||||
// after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page.
|
||||
shape_text_box(&mut d, rect, style.border_shape, style.width);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -421,7 +428,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
|
||||
fn validate_style(style: &Style) -> Result<(), AnnotError> {
|
||||
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
|
||||
if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
Ok(())
|
||||
@@ -443,12 +450,6 @@ fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]],
|
||||
}
|
||||
}
|
||||
|
||||
fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
|
||||
if opacity < 1.0 {
|
||||
dict.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec<Object>) -> Result<(), AnnotError> {
|
||||
let Some(rect) = rect else { return Ok(()) };
|
||||
let mut popup = Dict::new();
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::create::finite;
|
||||
use super::draw::set_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
|
||||
use super::modify::{fillable, touch, unlocked};
|
||||
use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
|
||||
use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
|
||||
|
||||
// ── reading ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -217,6 +217,15 @@ pub struct Props {
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
/// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's).
|
||||
pub border_color: Option<Rgb>,
|
||||
/// Text boxes and typewriter text: the border's shape (callouts have none to choose).
|
||||
pub border_shape: Option<BorderShape>,
|
||||
/// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its
|
||||
/// line's or border's).
|
||||
pub fill_opacity: Option<f64>,
|
||||
/// How opaque a text box's, typewriter text's or callout's text is drawn.
|
||||
pub text_opacity: Option<f64>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
@@ -239,7 +248,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||||
color,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0),
|
||||
opacity: appearance::part_opacity(d).line,
|
||||
width,
|
||||
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
@@ -248,6 +257,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||||
border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(),
|
||||
border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)),
|
||||
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
|
||||
text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text),
|
||||
subtype,
|
||||
endings: line_endings_of(d),
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ mod create;
|
||||
mod draw;
|
||||
mod info;
|
||||
mod lookup;
|
||||
mod model3d;
|
||||
mod modify;
|
||||
mod threed;
|
||||
mod types;
|
||||
@@ -37,15 +38,17 @@ use lookup::{annots, page_ref};
|
||||
pub use lookup::{
|
||||
has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image,
|
||||
};
|
||||
pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
|
||||
pub(crate) use modify::n;
|
||||
pub use modify::{
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
|
||||
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation,
|
||||
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect,
|
||||
set_review_state, set_style, with_replies_and_popups,
|
||||
};
|
||||
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
|
||||
pub use types::{
|
||||
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
|
||||
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
|
||||
AttachIcon, BorderShape, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb,
|
||||
Shape, StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
|
||||
};
|
||||
|
||||
pub mod appearance;
|
||||
|
||||
@@ -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;
|
||||
@@ -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)));
|
||||
}
|
||||
+264
-22
@@ -5,8 +5,8 @@ use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
|
||||
use super::info::{line_endings_of, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
|
||||
use super::types::{
|
||||
FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style,
|
||||
StyleChange, TextReplacement,
|
||||
BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb,
|
||||
Shape, Style, StyleChange, TextReplacement,
|
||||
};
|
||||
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
|
||||
|
||||
@@ -159,10 +159,13 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
|
||||
}
|
||||
|
||||
/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the
|
||||
/// new text: the wrap width and top edge stay, the height fits the wrapped lines.
|
||||
/// new text: the wrap width and top edge stay, the height fits the wrapped lines. A circle snug
|
||||
/// around its text stays snug around the new text, centred where it was.
|
||||
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText");
|
||||
let d = annot_dict(doc, r);
|
||||
let free_text = d.name(b"Subtype") == Some(b"FreeText");
|
||||
let snug = free_text && is_snug(&d);
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"Contents".to_vec(), PdfString::text(text));
|
||||
// The rich-text version follows the new plain text (text boxes), or would contradict it.
|
||||
@@ -177,8 +180,16 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
|
||||
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
|
||||
// text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even
|
||||
// where the standard fonts can't draw some of it (marked and reported, see
|
||||
// `draw_appearance`).
|
||||
match fitted_box(doc, r, text) {
|
||||
// `draw_appearance`). A circle snug around its text stays snug around the new text; one
|
||||
// made larger by hand keeps its size, only growing for more lines.
|
||||
let fit = if snug {
|
||||
snug_refit(doc, r)
|
||||
} else {
|
||||
let d = annot_dict(doc, r);
|
||||
let circle = text_box_of(&d).filter(|_| BorderShape::of(&d) == BorderShape::Circle);
|
||||
fitted_box(doc, r, text).map(|fit| circle.map_or(fit, |old| [fit[0], fit[1].min(old[1]), fit[2], fit[3]]))
|
||||
};
|
||||
match fit {
|
||||
Some(rect) => apply_text_box(doc, r, rect, meta, true)?,
|
||||
None => draw_appearance(doc, r)?,
|
||||
}
|
||||
@@ -197,11 +208,12 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
|
||||
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
|
||||
let rect = [rect[0], rect[1], rect[2], rect[3]];
|
||||
// A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place.
|
||||
// So does a text box inside an oval or a circle.
|
||||
let tb = if d.contains(b"CL") {
|
||||
let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?;
|
||||
[rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]
|
||||
} else {
|
||||
rect
|
||||
text_box_of(&d).unwrap_or(rect)
|
||||
};
|
||||
let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3]));
|
||||
let w = tb[2] - tb[0];
|
||||
@@ -388,9 +400,134 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
|
||||
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
|
||||
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
|
||||
}
|
||||
// An oval's or a circle's frame is resized: its text box follows inside.
|
||||
let shape = if subtype == "FreeText" { BorderShape::of(&d) } else { BorderShape::Rectangle };
|
||||
let rect = match (shape, text_box_of(&d)) {
|
||||
(BorderShape::Rectangle, _) | (_, None) => rect,
|
||||
// A circle's text box keeps its proportions and reaches the circle with its longer sides;
|
||||
// the circle then goes around it again, never closer than its text allows.
|
||||
(BorderShape::Circle, Some(was)) => {
|
||||
let (o, w) = (normalize(rect), normalize(was));
|
||||
let (cx, cy, r) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]).min(o[3] - o[1]) / 2.0);
|
||||
let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0);
|
||||
let k = r / wx.max(wy).max(1e-6);
|
||||
let (ix, iy) = ((wx * k).max(0.5), (wy * k).max(0.5));
|
||||
[cx - ix, cy - iy, cx + ix, cy + iy]
|
||||
}
|
||||
(shape, Some(was)) => shape.inside(rect, border_width_of(&d), was),
|
||||
};
|
||||
apply_text_box(doc, r, rect, meta, false)
|
||||
}
|
||||
|
||||
/// A FreeText's text box: `/Rect` inset by `/RD` for a callout or a text box inside an oval or a
|
||||
/// circle, else `/Rect`. `None` when they can't be read.
|
||||
pub(crate) fn text_box_of(d: &Dict) -> Option<[f64; 4]> {
|
||||
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
|
||||
let rect = normalize(four(&nums(b"Rect")?)?);
|
||||
if !d.contains(b"CL") && BorderShape::of(d) == BorderShape::Rectangle {
|
||||
return Some(rect);
|
||||
}
|
||||
let rd = appearance::inset(d)?;
|
||||
let tb = [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]];
|
||||
(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb)
|
||||
}
|
||||
|
||||
/// Give text box or typewriter text `d`, whose text box is `tb`, the border `shape` (`width`
|
||||
/// wide): `/Rect` around it, `/RD` and `/PCBorderShape`, or for a rectangle `/Rect` the text
|
||||
/// box itself again.
|
||||
pub(crate) fn shape_text_box(d: &mut Dict, tb: [f64; 4], shape: BorderShape, width: f64) {
|
||||
// A circle goes around the text as the box lays it out.
|
||||
let text = (shape == BorderShape::Circle).then(|| text_extent(d, displayed_sides(d, tb).0 - 2.0 * (2.0 + width.max(0.0))));
|
||||
let outer = shape.around(tb, width, text);
|
||||
d.set(b"Rect".to_vec(), num_array(&outer));
|
||||
if shape == BorderShape::Rectangle {
|
||||
d.remove(b"PCBorderShape");
|
||||
d.remove(b"RD");
|
||||
} else {
|
||||
d.set(b"PCBorderShape".to_vec(), Object::name(shape.name()));
|
||||
// A circle touching the text box's sides would put them a rounding error outside it.
|
||||
let tb = normalize(tb);
|
||||
let rd = [tb[0] - outer[0], tb[1] - outer[1], outer[2] - tb[2], outer[3] - tb[3]].map(|x| x.max(0.0));
|
||||
d.set(b"RD".to_vec(), num_array(&rd));
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of the widest line and the height of the lines of FreeText `d`'s text, as its
|
||||
/// appearance lays the text out `wrap` wide.
|
||||
fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] {
|
||||
let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default();
|
||||
let (_, size) = appearance::parse_da(d);
|
||||
let lines = appearance::wrap(&text, size, wrap.max(1.0));
|
||||
let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
|
||||
[widest, lines.len().max(1) as f64 * size * 1.2]
|
||||
}
|
||||
|
||||
/// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by
|
||||
/// a quarter, where its text is laid out upright.
|
||||
fn displayed_sides(d: &Dict, tb: [f64; 4]) -> (f64, f64) {
|
||||
let tb = normalize(tb);
|
||||
let (w, h) = (tb[2] - tb[0], tb[3] - tb[1]);
|
||||
if matches!(appearance::text_turn(d), 90 | 270) { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// FreeText `d`'s text box `tb`, with a border `width` wide, made as large as its text wrapped
|
||||
/// `wrap` wide needs (its widest line and its lines, inside the padding a box sized to its text
|
||||
/// has) and centred where it was: a circle goes around this, not around a box wider than its text.
|
||||
fn snug_text_box(d: &Dict, tb: [f64; 4], width: f64, wrap: f64) -> [f64; 4] {
|
||||
let tb = normalize(tb);
|
||||
let pad = 2.0 + width.max(0.0);
|
||||
let [w, h] = text_extent(d, wrap);
|
||||
let (hx, hy) = (w / 2.0 + pad + 2.0, h / 2.0 + pad + 1.0);
|
||||
let (hx, hy) = if matches!(appearance::text_turn(d), 90 | 270) { (hy, hx) } else { (hx, hy) };
|
||||
let (cx, cy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0);
|
||||
[cx - hx, cy - hy, cx + hx, cy + hy]
|
||||
}
|
||||
|
||||
/// Whether FreeText `d` is a circle whose text box is snug around its text ([`snug_text_box`]):
|
||||
/// it then follows its text as that changes, where one made larger by hand keeps its size.
|
||||
fn is_snug(d: &Dict) -> bool {
|
||||
let Some(tb) = text_box_of(d).filter(|_| BorderShape::of(d) == BorderShape::Circle) else { return false };
|
||||
let width = border_width_of(d);
|
||||
let s = snug_text_box(d, tb, width, displayed_sides(d, tb).0 - 2.0 * (2.0 + width));
|
||||
let tb = normalize(tb);
|
||||
((s[2] - s[0]) - (tb[2] - tb[0])).abs() < 0.5 && ((s[3] - s[1]) - (tb[3] - tb[1])).abs() < 0.5
|
||||
}
|
||||
|
||||
/// The text box of a circle snug around its text for the text and size it has now: its lines up to
|
||||
/// 300 pt wide, centred where it was.
|
||||
fn snug_refit(doc: &Document, r: ObjRef) -> Option<[f64; 4]> {
|
||||
let d = annot_dict(doc, r);
|
||||
let width = border_width_of(&d);
|
||||
Some(snug_text_box(&d, text_box_of(&d)?, width, 300.0 - 2.0 * (2.0 + width)))
|
||||
}
|
||||
|
||||
/// Give a text box or typewriter text an oval, a circle or a rectangle for its border, and redraw
|
||||
/// it. Its text box keeps its place; the oval or circle goes around it. For a circle the text box
|
||||
/// first shrinks to its text, its lines wrapped as they are.
|
||||
pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: BorderShape, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"FreeText") || d.contains(b"CL") {
|
||||
return Err(AnnotError::Invalid("only text boxes and typewriter text have a border shape".into()));
|
||||
}
|
||||
let tb = text_box_of(&d).ok_or_else(|| AnnotError::Invalid("the text box can't be read".into()))?;
|
||||
let tb = match shape {
|
||||
BorderShape::Circle => {
|
||||
let width = border_width_of(&d);
|
||||
snug_text_box(&d, tb, width, displayed_sides(&d, tb).0 - 2.0 * (2.0 + width))
|
||||
}
|
||||
_ => tb,
|
||||
};
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
shape_text_box(d, tb, shape, border_width_of(d));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// At most this many points reshape a comment (a drawn polygon has a handful).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
@@ -503,6 +640,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
}
|
||||
None => (rect, None),
|
||||
};
|
||||
let (shape, width) = (BorderShape::of(&d), border_width_of(&d));
|
||||
let free_text = d.name(b"Subtype") == Some(b"FreeText");
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"Rect".to_vec(), num_array(&outer));
|
||||
match &cl {
|
||||
@@ -510,6 +649,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
d.set(b"CL".to_vec(), num_array(l));
|
||||
d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]]));
|
||||
}
|
||||
// An oval or a circle goes around the text box again.
|
||||
None if free_text => shape_text_box(d, rect, shape, width),
|
||||
None => {
|
||||
d.remove(b"RD");
|
||||
}
|
||||
@@ -528,6 +669,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
}
|
||||
}
|
||||
|
||||
/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border`
|
||||
/// (another app's), else 1.
|
||||
fn border_width_of(d: &Dict) -> f64 {
|
||||
appearance::border_width(d)
|
||||
}
|
||||
@@ -558,7 +701,8 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
|
||||
Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half])
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and
|
||||
/// redraw it.
|
||||
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let StyleChange { color, opacity, width, endings } = change;
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -581,25 +725,34 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let free_text = subtype == "FreeText";
|
||||
// Typewriter text has no box of its own: a border goes around its text, so the box grows
|
||||
// (or shrinks) by the change on every side and the text keeps its place and its lines.
|
||||
// So does a text box inside an oval or a circle, which goes around it again.
|
||||
let shape = if free_text { BorderShape::of(&d) } else { BorderShape::Rectangle };
|
||||
let typewriter = free_text && (d.name(b"IT") == Some(b"FreeTextTypeWriter") || shape != BorderShape::Rectangle) && !d.contains(b"CL");
|
||||
let regrow = width.filter(|_| typewriter).and_then(|w| {
|
||||
let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 };
|
||||
let rect = grow(text_box_of(&d)?, by);
|
||||
(by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some((rect, w.max(0.0)))
|
||||
});
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
if let Some((rect, w)) = regrow {
|
||||
shape_text_box(d, rect, shape, w);
|
||||
}
|
||||
if let Some(c) = color {
|
||||
if free_text {
|
||||
let (_, size) = appearance::parse_da(d);
|
||||
let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0));
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
|
||||
d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size));
|
||||
rich_text(d);
|
||||
} else {
|
||||
d.set(b"C".to_vec(), rgb(c));
|
||||
}
|
||||
}
|
||||
// The whole comment: its fill and text drop opacities of their own.
|
||||
if let Some(o) = opacity {
|
||||
let o = o.clamp(0.0, 1.0);
|
||||
if o < 1.0 {
|
||||
d.set(b"CA".to_vec(), Object::Real(o));
|
||||
} else {
|
||||
d.remove(b"CA");
|
||||
}
|
||||
set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o });
|
||||
}
|
||||
if let Some(w) = width {
|
||||
let old_w = border_width_of(d);
|
||||
@@ -629,6 +782,37 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// A FreeText's `/DA`: the text colour, the border's own colour when it has one (the stroking
|
||||
/// colour, `RG`), Helvetica at `size`.
|
||||
pub(crate) fn free_text_da(text: Rgb, border: Option<Rgb>, size: f64) -> PdfString {
|
||||
let [r, g, b] = text.map(|x| x.clamp(0.0, 1.0));
|
||||
let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default();
|
||||
PdfString::literal(format!("{} {} {} rg {stroke}/Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())
|
||||
}
|
||||
|
||||
/// Give a text box, typewriter text or callout a border colour of its own (`None`: the text's
|
||||
/// again), and redraw it.
|
||||
pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"FreeText") {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a border colour".into()));
|
||||
}
|
||||
if color.is_some_and(|c| !finite(&c)) {
|
||||
return Err(AnnotError::Invalid("invalid colour".into()));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (text, size) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), free_text_da(text, color, size));
|
||||
rich_text(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the
|
||||
/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new
|
||||
/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt.
|
||||
@@ -643,26 +827,27 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
|
||||
return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into()));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let snug = is_snug(&d);
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (col, _) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes()));
|
||||
d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size));
|
||||
rich_text(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
|
||||
let result = match fitted_box(doc, r, &text) {
|
||||
Some(fit) => {
|
||||
let result = match (snug, if snug { snug_refit(doc, r) } else { fitted_box(doc, r, &text) }) {
|
||||
// A circle snug around its text stays snug around it at the new size.
|
||||
(true, Some(fit)) => apply_text_box(doc, r, fit, meta, false),
|
||||
(false, Some(fit)) => {
|
||||
let cur = annot_dict(doc, r);
|
||||
let nums: Vec<f64> =
|
||||
cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
|
||||
// Keep the top and sides; only the bottom edge moves, and only downwards.
|
||||
match four(&nums).map(normalize) {
|
||||
match text_box_of(&cur) {
|
||||
Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false),
|
||||
_ => apply_text_box(doc, r, fit, meta, false),
|
||||
}
|
||||
}
|
||||
None => redraw_or_restore(doc, r, before.clone()),
|
||||
(_, None) => redraw_or_restore(doc, r, before.clone()),
|
||||
};
|
||||
if result.is_err() {
|
||||
doc.set(r, Object::Dict(before));
|
||||
@@ -707,6 +892,63 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an
|
||||
/// opacity of its own (0–1), its line or border keeping the comment's (`/CA`), and redraw it.
|
||||
/// `None` leaves that part as it is drawn now. The fill goes in `/ca` (PDF 2.0's opacity for
|
||||
/// what is filled), the text in `/PCTextOpacity`, each only where it differs from the one it
|
||||
/// would otherwise follow.
|
||||
pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Option<f64>, text: Option<f64>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if fill.is_some() && !fillable(&subtype) {
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes have a fill of their own".into()));
|
||||
}
|
||||
if text.is_some() && subtype != "FreeText" {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have text of their own".into()));
|
||||
}
|
||||
if fill.into_iter().chain(text).any(|o| !o.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new opacity.
|
||||
if appearance::build(&d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let mut parts = appearance::part_opacity(d);
|
||||
if let Some(f) = fill {
|
||||
parts.fill = f.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(t) = text {
|
||||
parts.text = t.clamp(0.0, 1.0);
|
||||
}
|
||||
set_part_keys(d, parts);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Write a comment's parts' opacities (see [`appearance::PartOpacity`]): the line's as `/CA`, the
|
||||
/// fill's as `/ca` and the text's as `/PCTextOpacity`, each only where it differs from the one
|
||||
/// it would otherwise follow (the fill the line, the text the fill), so a comment drawn at one
|
||||
/// opacity keeps just `/CA`.
|
||||
pub(crate) fn set_part_keys(d: &mut Dict, parts: appearance::PartOpacity) {
|
||||
let appearance::PartOpacity { line, fill, text } = parts;
|
||||
let mut put = |key: &[u8], value: f64, unless: f64| {
|
||||
if value == unless {
|
||||
d.remove(key);
|
||||
} else {
|
||||
d.set(key.to_vec(), Object::Real(value));
|
||||
}
|
||||
};
|
||||
put(b"CA", line, 1.0);
|
||||
put(b"ca", fill, line);
|
||||
put(b"PCTextOpacity", text, fill);
|
||||
}
|
||||
|
||||
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
|
||||
fn ensure_ending_room(d: &mut Dict) {
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
|
||||
+373
-3
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental};
|
||||
|
||||
use super::modify::text_box_of;
|
||||
use super::*;
|
||||
|
||||
/// Two pages. Page 1 has an inline Square annotation in a direct /Annots array; page 2 has an
|
||||
@@ -40,7 +41,7 @@ fn new(page: usize, shape: Shape) -> NewAnnotation {
|
||||
NewAnnotation {
|
||||
page,
|
||||
shape,
|
||||
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None },
|
||||
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None, ..Style::default() },
|
||||
contents: "hello".into(),
|
||||
author: "Ada".into(),
|
||||
}
|
||||
@@ -356,6 +357,296 @@ fn restyle_and_resize_redraw_the_appearance() {
|
||||
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
/// Typed text or a text box takes an oval or a circle for its border: the text box keeps its
|
||||
/// place and the shape goes around it (`/Rect` grows, `/RD` holds the text box, `/PCBorderShape`
|
||||
/// the shape); back to a rectangle, it is the text box it was. A resized frame takes its text box
|
||||
/// along, a thicker border leaves it where it is.
|
||||
#[test]
|
||||
fn text_boxes_take_an_oval_or_a_circle_around_their_text() {
|
||||
let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01);
|
||||
let nums = |d: &Dict, k: &[u8]| -> Vec<f64> {
|
||||
d.get(k).and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default()
|
||||
};
|
||||
// Where the text starts: its first line's text matrix.
|
||||
let text_at = |doc: &Document, d: &Dict| ap_content(doc, d).lines().find(|l| l.ends_with(" Tm") || l.contains(" Tm ")).map(str::to_owned);
|
||||
let mut doc = fixture();
|
||||
let mut tw = new(0, Shape::Typewriter { rect: [100.0, 100.0, 200.0, 120.0], font_size: 10.0 });
|
||||
tw.style.width = 1.0;
|
||||
let t = add_annotation(&mut doc, &tw, &meta("t")).unwrap();
|
||||
// In an oval or a circle the text is centred in its text box (left-aligned in a box): one
|
||||
// line of "hello" at 10 pt in the 100 × 20 text box centred on (150, 110), 3 pt from its top.
|
||||
let centred = format!("1 0 0 1 {} {} Tm (hello) Tj", n(150.0 - appearance::text_width("hello", 10.0) / 2.0), n(107.0));
|
||||
assert!(text_at(&doc, &list(&doc, 0)[t]).is_some_and(|l| l.starts_with("1 0 0 1 103 ")), "a box: left-aligned");
|
||||
set_border_shape(&mut doc, 0, t, BorderShape::Oval, &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[t];
|
||||
// Around the 100 × 20 text box, centred: √2 its size, two points more for the 1 pt border.
|
||||
let (ax, ay) = (50.0 * std::f64::consts::SQRT_2 + 2.0, 10.0 * std::f64::consts::SQRT_2 + 2.0);
|
||||
assert!(close(&rect(d), &[150.0 - ax, 110.0 - ay, 150.0 + ax, 110.0 + ay]), "{:?}", rect(d));
|
||||
assert!(close(&nums(d, b"RD"), &[ax - 50.0, ay - 10.0, ax - 50.0, ay - 10.0]), "{:?}", nums(d, b"RD"));
|
||||
assert_eq!(d.name(b"PCBorderShape"), Some(&b"Oval"[..]));
|
||||
let ap = ap_content(&doc2, d);
|
||||
assert!(ap.contains(" c\n") && ap.contains("S\n") && !ap.contains("re S"), "an oval, not a box: {ap}");
|
||||
assert_eq!(text_at(&doc2, d).as_deref(), Some(centred.as_str()), "the text keeps its place");
|
||||
assert_eq!(props(&doc2, 0, t).unwrap().border_shape, Some(BorderShape::Oval));
|
||||
// A circle around the text: the text box shrinks to "hello" with its padding, centred where it
|
||||
// was, and the circle touches its sides (the text a point clear of the border inside).
|
||||
set_border_shape(&mut doc, 0, t, BorderShape::Circle, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
let hw = appearance::text_width("hello", 10.0) / 2.0;
|
||||
let r = (hw + 5.0).max(hw.hypot(6.0) + 2.0);
|
||||
assert!(close(&rect(d), &[150.0 - r, 110.0 - r, 150.0 + r, 110.0 + r]), "{:?}", rect(d));
|
||||
assert!(close(&nums(d, b"RD"), &[r - hw - 5.0, r - 10.0, r - hw - 5.0, r - 10.0]), "{:?}", nums(d, b"RD"));
|
||||
assert_eq!(text_at(&doc, d).as_deref(), Some(centred.as_str()));
|
||||
// A thicker border: the typed text's box grows by the change on every side, so its text keeps
|
||||
// its place inside the thicker line (as with a rectangle), and the circle goes around that.
|
||||
set_style(&mut doc, 0, t, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!(text_at(&doc, d).as_deref(), Some(centred.as_str()));
|
||||
let r3 = (hw + 7.0).max(hw.hypot(6.0) + 4.0);
|
||||
assert!(close(&rect(d), &[150.0 - r3, 110.0 - r3, 150.0 + r3, 110.0 + r3]), "{:?}", rect(d));
|
||||
// Its frame resized: the text box follows inside, keeping its proportions, its longer sides
|
||||
// reaching the circle.
|
||||
set_rect(&mut doc, 0, t, [150.0 - 2.0 * r3, 110.0 - 2.0 * r3, 150.0 + 2.0 * r3, 110.0 + 2.0 * r3], &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
let rd = nums(d, b"RD");
|
||||
let tb = [rect(d)[0] + rd[0], rect(d)[1] + rd[1], rect(d)[2] - rd[2], rect(d)[3] - rd[3]];
|
||||
assert!(((tb[2] - tb[0]) / (tb[3] - tb[1]) - (hw + 7.0) / 12.0).abs() < 0.01, "the text box keeps its proportions: {tb:?}");
|
||||
assert!(close(&rect(d), &[150.0 - 2.0 * r3, 110.0 - 2.0 * r3, 150.0 + 2.0 * r3, 110.0 + 2.0 * r3]), "{:?}", rect(d));
|
||||
// A rectangle again: the text box it has, nothing around it.
|
||||
set_border_shape(&mut doc, 0, t, BorderShape::Rectangle, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert!(close(&rect(d), &tb), "{:?} vs {tb:?}", rect(d));
|
||||
assert!(!d.contains(b"RD") && !d.contains(b"PCBorderShape"));
|
||||
assert!(ap_content(&doc, d).contains("re S"));
|
||||
|
||||
// A filled text box made with an oval: the background takes the shape too.
|
||||
let mut tb = new(0, Shape::TextBox { rect: [300.0, 500.0, 400.0, 540.0], font_size: 12.0 });
|
||||
(tb.style.fill, tb.style.width, tb.style.border_shape) = (Some([1.0, 1.0, 0.6]), 1.0, BorderShape::Oval);
|
||||
let b = add_annotation(&mut doc, &tb, &meta("b")).unwrap();
|
||||
let d = &list(&doc, 0)[b];
|
||||
assert_eq!(d.name(b"PCBorderShape"), Some(&b"Oval"[..]));
|
||||
let ap = ap_content(&doc, d);
|
||||
assert!(ap.contains("1 1 0.6 rg") && ap.contains(" c\nh\nf\n") && !ap.contains(" re f"), "{ap}");
|
||||
// A callout's border stays a rectangle.
|
||||
let c = add_annotation(
|
||||
&mut doc,
|
||||
&new(
|
||||
0,
|
||||
Shape::Callout {
|
||||
rect: [300.0, 300.0, 400.0, 340.0],
|
||||
knee: [280.0, 320.0],
|
||||
point: [250.0, 280.0],
|
||||
ending: LineEnding::OpenArrow,
|
||||
font_size: 10.0,
|
||||
},
|
||||
),
|
||||
&meta("c"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(set_border_shape(&mut doc, 0, c, BorderShape::Oval, &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert_eq!(props(&doc, 0, c).unwrap().border_shape, None);
|
||||
assert_eq!(BorderShape::parse("CIRCLE"), Some(BorderShape::Circle));
|
||||
}
|
||||
|
||||
/// A circle goes snug around short text, as a number on a drawing is circled: the text box is no
|
||||
/// wider than its text needs and the circle touches its sides, where one through the box's corners
|
||||
/// stood well clear. It follows the text and its size as they change; one made larger by hand
|
||||
/// keeps its size.
|
||||
#[test]
|
||||
fn a_circle_goes_snug_around_its_text_and_follows_it() {
|
||||
let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01;
|
||||
let mut doc = fixture();
|
||||
// "3" at 10 pt with a 1 pt border, in the box Fill & Sign sizes to it: the digit with 2 pt of
|
||||
// padding and 4 pt to spare across, a line and 6 pt down, the border around that.
|
||||
let w3 = appearance::text_width("3", 10.0);
|
||||
let mut typed = new(0, Shape::Typewriter { rect: [100.0, 680.0, 110.0 + w3, 700.0], font_size: 10.0 });
|
||||
(typed.contents, typed.style.width, typed.style.border_shape) = ("3".into(), 1.0, BorderShape::Circle);
|
||||
let t = add_annotation(&mut doc, &typed, &meta("t")).unwrap();
|
||||
let frame = |doc: &Document| {
|
||||
let r = rect(&list(doc, 0)[t]);
|
||||
((r[2] - r[0], r[3] - r[1]), ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0))
|
||||
};
|
||||
let centre = (105.0 + w3 / 2.0, 690.0);
|
||||
// As tall as the box, 20 pt across: through the corners it was 29.
|
||||
assert!(close(frame(&doc).0, (20.0, 20.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
// The text box Fill & Sign gives a box to type into, 40 pt wide: a circle goes snug around its
|
||||
// text there too.
|
||||
let mut wide = typed.clone();
|
||||
wide.shape = Shape::Typewriter { rect: [300.0, 680.0, 342.0, 700.0], font_size: 10.0 };
|
||||
wide.style.border_shape = BorderShape::Rectangle;
|
||||
let b = add_annotation(&mut doc, &wide, &meta("b")).unwrap();
|
||||
set_border_shape(&mut doc, 0, b, BorderShape::Circle, &meta("")).unwrap();
|
||||
let r = rect(&list(&doc, 0)[b]);
|
||||
assert!(close((r[2] - r[0], r[3] - r[1]), (20.0, 20.0)), "{r:?}");
|
||||
// More text: the circle grows with it, about the same centre; less, and it shrinks back.
|
||||
set_contents(&mut doc, 0, t, "123", &meta("")).unwrap();
|
||||
let w = appearance::text_width("123", 10.0) / 2.0;
|
||||
let d = 2.0 * (w + 5.0).max(10.0).max(w.hypot(6.0) + 2.0);
|
||||
assert!(close(frame(&doc).0, (d, d)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
set_contents(&mut doc, 0, t, "3", &meta("")).unwrap();
|
||||
assert!(close(frame(&doc).0, (20.0, 20.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
// A larger font: a larger circle, still snug.
|
||||
set_font_size(&mut doc, 0, t, 20.0, &meta("")).unwrap();
|
||||
let w = appearance::text_width("3", 20.0) / 2.0;
|
||||
let d = 2.0 * (w + 5.0).max(16.0).max(w.hypot(12.0) + 2.0);
|
||||
assert!(close(frame(&doc).0, (d, d)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
set_font_size(&mut doc, 0, t, 10.0, &meta("")).unwrap();
|
||||
assert!(close(frame(&doc).0, (20.0, 20.0)), "{:?}", frame(&doc));
|
||||
// Made larger by hand, it keeps its size as its text changes.
|
||||
set_rect(&mut doc, 0, t, [centre.0 - 20.0, centre.1 - 20.0, centre.0 + 20.0, centre.1 + 20.0], &meta("")).unwrap();
|
||||
assert!(close(frame(&doc).0, (40.0, 40.0)), "{:?}", frame(&doc));
|
||||
set_contents(&mut doc, 0, t, "4", &meta("")).unwrap();
|
||||
assert!(close(frame(&doc).0, (40.0, 40.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
}
|
||||
|
||||
/// A circle around a line wider than it is tall touches its text box's sides: `/RD` is 0 there,
|
||||
/// not a rounding error below it, and a hair below 0 still reads. (A text box that couldn't be
|
||||
/// read stopped following its text, and its text wrapped in a narrower one.) The circle keeps its
|
||||
/// centre as its text changes.
|
||||
#[test]
|
||||
fn a_circle_touching_its_text_box_keeps_following_its_text() {
|
||||
let mut doc = fixture();
|
||||
// Sized to its text: 2 pt of padding and half a point to spare across, a line down.
|
||||
let (w, h) = (appearance::text_width("Testtext", 12.0) + 4.5, 12.0 * 1.11 + 4.0);
|
||||
let mut b = new(0, Shape::TextBox { rect: [210.79, 700.0 - h, 210.79 + w, 700.0], font_size: 12.0 });
|
||||
(b.contents, b.style.width) = ("Testtext".into(), 0.0);
|
||||
let t = add_annotation(&mut doc, &b, &meta("t")).unwrap();
|
||||
// Circle, then a 1 pt border, which the circle grows by.
|
||||
set_border_shape(&mut doc, 0, t, BorderShape::Circle, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, StyleChange::new(None, None, Some(1.0), None), &meta("")).unwrap();
|
||||
let lines = |doc: &Document| ap_content(doc, &list(doc, 0)[t]).matches(" Tj").count();
|
||||
let frame = |doc: &Document| {
|
||||
let r = rect(&list(doc, 0)[t]);
|
||||
((r[2] - r[0], r[3] - r[1]), ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0))
|
||||
};
|
||||
let rd = |doc: &Document| -> Vec<f64> { list(doc, 0)[t].get(b"RD").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() };
|
||||
assert!(rd(&doc).iter().all(|x| *x >= 0.0) && rd(&doc)[0] < 0.01, "touching the sides: {:?}", rd(&doc));
|
||||
assert_eq!(lines(&doc), 1, "Testtext on one line");
|
||||
let (wide, centre) = frame(&doc);
|
||||
set_contents(&mut doc, 0, t, "KRS", &meta("")).unwrap();
|
||||
let (small, at) = frame(&doc);
|
||||
assert!(small.0 < wide.0 - 10.0 && (small.0 - small.1).abs() < 0.01, "a smaller circle: {small:?} from {wide:?}");
|
||||
assert!((at.0 - centre.0).abs() < 0.01 && (at.1 - centre.1).abs() < 0.01, "about its centre: {at:?} {centre:?}");
|
||||
assert_eq!(lines(&doc), 1);
|
||||
// A hair below 0, as rounding left it: still read, so the circle follows its text again.
|
||||
let (_, r) = annot_ref(&mut doc, 0, t).unwrap();
|
||||
let mut was = rd(&doc);
|
||||
was[0] = -2.8e-14;
|
||||
doc.update_dict(r, |d| d.set(b"RD".to_vec(), num_array(&was))).unwrap();
|
||||
assert!(text_box_of(&list(&doc, 0)[t]).is_some());
|
||||
set_contents(&mut doc, 0, t, "Testtext", &meta("")).unwrap();
|
||||
assert!((frame(&doc).0.0 - wide.0).abs() < 0.01 && lines(&doc) == 1, "{:?}", frame(&doc));
|
||||
assert!(rd(&doc).iter().all(|x| *x >= 0.0), "{:?}", rd(&doc));
|
||||
}
|
||||
|
||||
/// On a page turned a quarter, typed text is laid out as the page is shown (`/PCTextRotate`), and a
|
||||
/// circle made with it goes around the text as shown: as large as around the same text on an
|
||||
/// upright page. (Made before the turn was set, its text wrapped at the box's displayed height and
|
||||
/// the circle went around several lines.)
|
||||
#[test]
|
||||
fn a_circle_made_on_a_turned_page_is_sized_as_on_an_upright_one() {
|
||||
let w = appearance::text_width("12345", 10.0);
|
||||
let typed = |rect: [f64; 4]| {
|
||||
let mut t = new(0, Shape::Typewriter { rect, font_size: 10.0 });
|
||||
(t.contents, t.style.width, t.style.border_shape) = ("12345".into(), 1.0, BorderShape::Circle);
|
||||
t
|
||||
};
|
||||
let size = |doc: &Document, i: usize| {
|
||||
let r = rect(&list(doc, 0)[i]);
|
||||
(r[2] - r[0], r[3] - r[1])
|
||||
};
|
||||
let mut upright = fixture();
|
||||
let u = add_annotation(&mut upright, &typed([100.0, 680.0, 110.0 + w, 700.0]), &meta("u")).unwrap();
|
||||
for turn in [90, 270] {
|
||||
let mut doc = fixture();
|
||||
let page = page_refs(&doc).unwrap()[0];
|
||||
doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Int(turn))).unwrap();
|
||||
// The same box as shown: its sides swapped in user space.
|
||||
let t = add_annotation(&mut doc, &typed([100.0, 680.0, 120.0, 690.0 + w]), &meta("t")).unwrap();
|
||||
assert_eq!(list(&doc, 0)[t].get(b"PCTextRotate").and_then(Object::as_int), Some(turn));
|
||||
let (turned, straight) = (size(&doc, t), size(&upright, u));
|
||||
assert!((turned.0 - straight.0).abs() < 0.01 && (turned.1 - straight.1).abs() < 0.01, "{turn}°: {turned:?}, upright {straight:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle's fill, and a text box's border, background and text, each drawn at an opacity of
|
||||
/// its own: the line's in `/CA`, the fill's in `/ca` (PDF 2.0), the text's in `/PCTextOpacity`, and
|
||||
/// in the appearance `/GS0` (strokes at the line's, fills at the fill's) and `/GS1` for the text.
|
||||
#[test]
|
||||
fn parts_are_drawn_at_opacities_of_their_own() {
|
||||
let num = |d: &Dict, k: &[u8]| d.get(k).and_then(|o| o.as_f64());
|
||||
let gstate = |doc: &Document, d: &Dict, name: &[u8]| -> Option<(f64, f64)> {
|
||||
let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N")))?;
|
||||
let obj = doc.get(n);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let res = s.dict.get(b"Resources").map(|r| doc.resolve(r))?;
|
||||
let gs = res.as_dict()?.get(b"ExtGState").map(|g| doc.resolve(g))?;
|
||||
let gs = gs.as_dict()?.get(name).map(|g| doc.resolve(g))?;
|
||||
let gs = gs.as_dict()?;
|
||||
Some((gs.get(b"CA")?.as_f64()?, gs.get(b"ca")?.as_f64()?))
|
||||
};
|
||||
let mut doc = fixture();
|
||||
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
|
||||
set_fill(&mut doc, 0, r, Some([1.0, 1.0, 0.0]), &meta("")).unwrap();
|
||||
set_part_opacity(&mut doc, 0, r, Some(0.3), None, &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[r];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca")), (None, Some(0.3)), "an opaque line, a fill of its own");
|
||||
assert_eq!(gstate(&doc2, d, b"GS0"), Some((1.0, 0.3)));
|
||||
let ap = ap_content(&doc2, d);
|
||||
assert!(ap.starts_with("/GS0 gs") && ap.contains("\nB\n"), "filled and stroked in one go: {ap}");
|
||||
let p = props(&doc2, 0, r).unwrap();
|
||||
assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.3), None));
|
||||
// The whole comment's opacity (StyleAnnotation, the quick bar): the fill goes along again.
|
||||
set_style(&mut doc, 0, r, StyleChange::new(None, Some(0.6), None, None), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[r];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca")), (Some(0.6), None));
|
||||
assert_eq!(props(&doc, 0, r).unwrap().fill_opacity, Some(0.6));
|
||||
|
||||
// A text box with its border opaque, its background at 20 % and its text opaque, as made.
|
||||
let mut tb = new(0, Shape::TextBox { rect: [200.0, 600.0, 400.0, 650.0], font_size: 12.0 });
|
||||
tb.style.fill = Some([1.0, 1.0, 0.6]);
|
||||
tb.style.width = 1.0;
|
||||
(tb.style.fill_opacity, tb.style.text_opacity) = (Some(0.2), Some(1.0));
|
||||
let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[t];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, Some(0.2), Some(1.0)));
|
||||
assert_eq!((gstate(&doc2, d, b"GS0"), gstate(&doc2, d, b"GS1")), (Some((1.0, 0.2)), Some((1.0, 1.0))));
|
||||
let ap = ap_content(&doc2, d);
|
||||
let (gs1, text) = (ap.find("/GS1 gs\n").expect("the text's own state, an operator of its own"), ap.find("BT").expect("text"));
|
||||
assert!(ap.find(" re f").is_some_and(|fill| fill < gs1) && gs1 < text, "background at 20 %, then the text at its own: {ap}");
|
||||
// A new fill opacity leaves the text as it is drawn.
|
||||
set_part_opacity(&mut doc, 0, t, Some(0.5), None, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!((num(d, b"ca"), num(d, b"PCTextOpacity")), (Some(0.5), Some(1.0)));
|
||||
let p = props(&doc, 0, t).unwrap();
|
||||
assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.5), Some(1.0)));
|
||||
// All at one opacity again: only /CA stays.
|
||||
set_part_opacity(&mut doc, 0, t, Some(1.0), Some(1.0), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, None, None));
|
||||
|
||||
// Only the parts a comment has, and finite values; nothing changes when refused.
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, l, Some(0.5), None, &meta("")), Err(AnnotError::Invalid(_))), "a line has no fill");
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, r, None, Some(0.5), &meta("")), Err(AnnotError::Invalid(_))), "a rectangle has no text");
|
||||
let before = list(&doc, 0)[r].clone();
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, r, Some(f64::NAN), None, &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert_eq!(list(&doc, 0)[r], before);
|
||||
// Another app's comment with /CA alone is drawn at it throughout, as before.
|
||||
assert_eq!(
|
||||
appearance::part_opacity(&Dict::from_iter([(b"CA".to_vec(), Object::Real(0.4))])),
|
||||
appearance::PartOpacity { line: 0.4, fill: 0.4, text: 0.4 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_can_be_filled_and_unfilled() {
|
||||
let mut doc = fixture();
|
||||
@@ -591,6 +882,85 @@ fn fill_and_sign_items_are_drawn() {
|
||||
assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err());
|
||||
}
|
||||
|
||||
/// A border chosen for typewriter text goes around it in the text's colour. Changing it grows or
|
||||
/// shrinks the box on every side, so the text stays where it was typed; a text box keeps its box.
|
||||
#[test]
|
||||
fn a_typewriter_border_goes_around_its_text_which_stays_put() {
|
||||
let mut doc = fixture();
|
||||
let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 };
|
||||
let style = Style { width: 1.0, ..Style::default_for(&shape) };
|
||||
let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap();
|
||||
// The text starts 2 pt inside the border: 100 + 1 + 2 across, 716 - 1 - 2 - 0.9 em down.
|
||||
let at = "1 0 0 1 103 703.1 Tm";
|
||||
let d = &list(&doc, 1)[t];
|
||||
let ap = ap_content(&doc, d);
|
||||
assert!(ap.contains("1 w\n") && ap.contains(" re S"), "a 1 pt border: {ap}");
|
||||
assert!(ap.contains(at), "{ap}");
|
||||
for (width, expected) in [(2.0, [99.0, 699.0, 201.0, 717.0]), (0.0, [101.0, 701.0, 199.0, 715.0])] {
|
||||
set_style(&mut doc, 1, t, StyleChange::new(None, None, Some(width), None), &meta("")).unwrap();
|
||||
let d = &list(&doc, 1)[t];
|
||||
assert_eq!(rect(d), expected, "{width} pt border");
|
||||
let ap = ap_content(&doc, d);
|
||||
assert!(ap.contains(at), "{width} pt: the text keeps its place: {ap}");
|
||||
assert_eq!(ap.contains(" re S"), width > 0.0, "{width} pt: {ap}");
|
||||
}
|
||||
let shape = Shape::TextBox { rect: [100.0, 600.0, 200.0, 650.0], font_size: 11.0 };
|
||||
let b = add_annotation(&mut doc, &new(1, shape), &meta("b")).unwrap();
|
||||
set_style(&mut doc, 1, b, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap();
|
||||
assert_eq!(rect(&list(&doc, 1)[b]), [100.0, 600.0, 200.0, 650.0], "a text box keeps the box it was drawn with");
|
||||
// Another app's typed text with its width in /Border (no /BS): 4 pt, so 2 pt shrinks the box
|
||||
// by 2 on every side, as it is drawn.
|
||||
let shape = Shape::Typewriter { rect: [100.0, 500.0, 200.0, 530.0], font_size: 11.0 };
|
||||
let o = add_annotation(
|
||||
&mut doc,
|
||||
&NewAnnotation { page: 1, style: Style::default_for(&shape), shape: shape.clone(), contents: "Bo".into(), author: "Ada".into() },
|
||||
&meta("o"),
|
||||
)
|
||||
.unwrap();
|
||||
let (_, r) = annot_ref(&mut doc, 1, o).unwrap();
|
||||
doc.update_dict(r, |d| {
|
||||
d.remove(b"BS");
|
||||
d.set(b"Border".to_vec(), num_array(&[0.0, 0.0, 4.0]));
|
||||
})
|
||||
.unwrap();
|
||||
set_style(&mut doc, 1, o, StyleChange::new(None, None, Some(2.0), None), &meta("")).unwrap();
|
||||
assert_eq!(rect(&list(&doc, 1)[o]), [102.0, 502.0, 198.0, 528.0], "the imported 4 pt border is read from /Border");
|
||||
// A border colour that isn't a colour is refused before anything is written.
|
||||
let mut bad = NewAnnotation { page: 1, style: Style::default_for(&shape), shape, contents: "x".into(), author: "Ada".into() };
|
||||
bad.style.border_color = Some([f64::NAN, 0.0, 0.0]);
|
||||
let before = list(&doc, 1).len();
|
||||
assert!(matches!(add_annotation(&mut doc, &bad, &meta("n")), Err(AnnotError::Invalid(_))));
|
||||
assert_eq!(list(&doc, 1).len(), before);
|
||||
}
|
||||
|
||||
/// Text keeps its colour and the border gets its own: the stroking colour in `/DA`, which a new
|
||||
/// text colour or font size keeps, and which `None` gives back to the text's.
|
||||
#[test]
|
||||
fn a_text_border_can_have_its_own_colour() {
|
||||
let mut doc = fixture();
|
||||
let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 };
|
||||
let style = Style { color: [0.0, 0.5, 0.0], width: 1.0, border_color: Some([1.0, 0.0, 0.0]), ..Style::default_for(&shape) };
|
||||
let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap();
|
||||
let da = |doc: &Document| text(&list(doc, 1)[t], b"DA");
|
||||
assert_eq!(da(&doc), "0 0.5 0 rg 1 0 0 RG /Helv 11 Tf");
|
||||
let ap = ap_content(&doc, &list(&doc, 1)[t]);
|
||||
assert!(ap.contains("1 0 0 RG\n1 w"), "the border in red: {ap}");
|
||||
assert!(ap.contains("0 0.5 0 rg"), "the text in green: {ap}");
|
||||
assert_eq!(props(&doc, 1, t).unwrap().border_color, Some([1.0, 0.0, 0.0]));
|
||||
set_style(&mut doc, 1, t, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
|
||||
assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 11 Tf", "a new text colour keeps the border's");
|
||||
set_font_size(&mut doc, 1, t, 14.0, &meta("")).unwrap();
|
||||
assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 14 Tf", "so does a new size");
|
||||
set_border_color(&mut doc, 1, t, Some([0.5, 0.5, 0.5]), &meta("")).unwrap();
|
||||
assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0.5 0.5 0.5 RG\n1 w"));
|
||||
set_border_color(&mut doc, 1, t, None, &meta("")).unwrap();
|
||||
assert_eq!(da(&doc), "0 0 1 rg /Helv 14 Tf");
|
||||
assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0 0 1 RG\n1 w"), "the text's colour again");
|
||||
assert_eq!(props(&doc, 1, t).unwrap().border_color, None);
|
||||
let m = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("m")).unwrap();
|
||||
assert!(set_border_color(&mut doc, 0, m, Some([1.0, 0.0, 0.0]), &meta("")).is_err(), "a rectangle's colour is its border");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_subject_and_note_icon_change() {
|
||||
let mut doc = fixture();
|
||||
@@ -873,8 +1243,8 @@ fn cloud_ys(path: &str) -> Vec<f64> {
|
||||
fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let mut doc = fixture();
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let i = add_text_replacement(
|
||||
&mut doc,
|
||||
TextReplacement {
|
||||
|
||||
+132
-5
@@ -1,5 +1,5 @@
|
||||
use super::create::normalize;
|
||||
use super::{ObjRef, appearance};
|
||||
use super::{Dict, ObjRef, appearance};
|
||||
|
||||
pub type Rgb = [f64; 3];
|
||||
|
||||
@@ -124,6 +124,28 @@ impl FillMark {
|
||||
let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) };
|
||||
[at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]
|
||||
}
|
||||
|
||||
/// The mark whose `/Name` this is.
|
||||
pub fn from_name(n: &[u8]) -> Option<Self> {
|
||||
[FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line].into_iter().find(|m| m.name().as_bytes() == n)
|
||||
}
|
||||
|
||||
/// The pen strokes the mark is drawn with in `rect` (user space): what its appearance strokes,
|
||||
/// and what a copy of it shows while it is being placed. The dot is a filled circle (radius
|
||||
/// 0.3 × the shorter side, centred) and has none.
|
||||
pub fn strokes(self, rect: [f64; 4]) -> Vec<Vec<[f64; 2]>> {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
match self {
|
||||
FillMark::Check => vec![vec![[x0 + w * 0.15, y0 + h * 0.5], [x0 + w * 0.4, y0 + h * 0.2], [x0 + w * 0.88, y0 + h * 0.85]]],
|
||||
FillMark::Cross => vec![
|
||||
vec![[x0 + w * 0.18, y0 + h * 0.18], [x1 - w * 0.18, y1 - h * 0.18]],
|
||||
vec![[x0 + w * 0.18, y1 - h * 0.18], [x1 - w * 0.18, y0 + h * 0.18]],
|
||||
],
|
||||
FillMark::Dot => Vec::new(),
|
||||
FillMark::Line => vec![vec![[x0, y0 + h / 2.0], [x1, y0 + h / 2.0]]],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
|
||||
@@ -566,6 +588,16 @@ pub struct Style {
|
||||
pub width: f64,
|
||||
/// Interior fill for rectangles and ovals (`/IC`), background for text boxes.
|
||||
pub fill: Option<Rgb>,
|
||||
/// A text box's, typewriter text's or callout's border colour where it differs from the
|
||||
/// text's (`None`: the text colour), the stroking colour in its `/DA`.
|
||||
pub border_color: Option<Rgb>,
|
||||
/// A text box's or typewriter text's border shape (a callout's is always a rectangle).
|
||||
pub border_shape: BorderShape,
|
||||
/// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the
|
||||
/// line's or border's); `None`: as `opacity`.
|
||||
pub fill_opacity: Option<f64>,
|
||||
/// A text box's text's own opacity, 0–1 (`/PCTextOpacity`); `None`: as the fill's.
|
||||
pub text_opacity: Option<f64>,
|
||||
}
|
||||
|
||||
/// The style properties changed by one annotation edit. `None` leaves a property unchanged.
|
||||
@@ -597,7 +629,102 @@ pub struct TextReplacement<'a> {
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
|
||||
Self {
|
||||
color: [1.0, 0.82, 0.0],
|
||||
opacity: 1.0,
|
||||
width: 1.0,
|
||||
fill: None,
|
||||
fill_opacity: None,
|
||||
text_opacity: None,
|
||||
border_color: None,
|
||||
border_shape: BorderShape::Rectangle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape of a text box's or typewriter text's border (PdfCraft's `/PCBorderShape`, absent for
|
||||
/// a rectangle). An oval or a circle goes around the text box, which keeps its place: `/Rect`
|
||||
/// grows to hold it, centred on the text box, and `/RD` says where the text box is inside
|
||||
/// (§12.5.6.6), as for a callout. Other apps that redraw the comment draw a rectangle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum BorderShape {
|
||||
#[default]
|
||||
Rectangle,
|
||||
Oval,
|
||||
Circle,
|
||||
}
|
||||
|
||||
impl BorderShape {
|
||||
pub const ALL: [BorderShape; 3] = [BorderShape::Rectangle, BorderShape::Oval, BorderShape::Circle];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
BorderShape::Rectangle => "Rectangle",
|
||||
BorderShape::Oval => "Oval",
|
||||
BorderShape::Circle => "Circle",
|
||||
}
|
||||
}
|
||||
|
||||
/// `rectangle`, `oval` or `circle`, in any case.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|b| b.name().eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// The border shape of FreeText `d`: always a rectangle for a callout.
|
||||
pub fn of(d: &Dict) -> Self {
|
||||
if d.contains(b"CL") {
|
||||
return BorderShape::Rectangle;
|
||||
}
|
||||
match d.name(b"PCBorderShape") {
|
||||
Some(b"Oval") => BorderShape::Oval,
|
||||
Some(b"Circle") => BorderShape::Circle,
|
||||
_ => BorderShape::Rectangle,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rectangle this shape, with a border `width` wide, takes around text box `tb`, centred on
|
||||
/// it: an oval through the text box's corners (`√2` its size), a point clear of the border; a
|
||||
/// circle around the text's lines (`text`: the widest line's width and the lines' height, when
|
||||
/// known), a point clear of the border and touching the text box's longer sides at least
|
||||
/// (its corners, padding only, may lie outside), else through the box's corners; the text box
|
||||
/// itself for a rectangle.
|
||||
pub fn around(self, tb: [f64; 4], width: f64, text: Option<[f64; 2]>) -> [f64; 4] {
|
||||
let tb = normalize(tb);
|
||||
let (cx, cy, hx, hy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0, (tb[2] - tb[0]) / 2.0, (tb[3] - tb[1]) / 2.0);
|
||||
let gap = width.max(0.0) + 1.0;
|
||||
let (ax, ay) = match self {
|
||||
BorderShape::Rectangle => return tb,
|
||||
BorderShape::Oval => (hx * std::f64::consts::SQRT_2 + gap, hy * std::f64::consts::SQRT_2 + gap),
|
||||
BorderShape::Circle => {
|
||||
let r = match text {
|
||||
Some([w, h]) => ((w / 2.0).hypot(h / 2.0) + gap).max(hx).max(hy),
|
||||
None => hx.hypot(hy) + gap,
|
||||
};
|
||||
(r, r)
|
||||
}
|
||||
};
|
||||
[cx - ax, cy - ay, cx + ax, cy + ay]
|
||||
}
|
||||
|
||||
/// The text box inside `outer` for this shape when none is recorded: the inverse of
|
||||
/// [`Self::around`] for an oval, a box through a circle's corners for a circle, keeping the
|
||||
/// proportions of `was` (the one it had).
|
||||
pub(crate) fn inside(self, outer: [f64; 4], width: f64, was: [f64; 4]) -> [f64; 4] {
|
||||
let o = normalize(outer);
|
||||
let (cx, cy, hx, hy) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]) / 2.0, (o[3] - o[1]) / 2.0);
|
||||
let gap = width.max(0.0) + 1.0;
|
||||
let (ix, iy) = match self {
|
||||
BorderShape::Rectangle => return o,
|
||||
BorderShape::Oval => ((hx - gap) / std::f64::consts::SQRT_2, (hy - gap) / std::f64::consts::SQRT_2),
|
||||
BorderShape::Circle => {
|
||||
let w = normalize(was);
|
||||
let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0);
|
||||
let k = (hx.min(hy) - gap) / wx.hypot(wy).max(1e-6);
|
||||
(wx * k, wy * k)
|
||||
}
|
||||
};
|
||||
let (ix, iy) = (ix.max(0.5), iy.max(0.5));
|
||||
[cx - ix, cy - iy, cx + ix, cy + iy]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +742,7 @@ impl Style {
|
||||
}
|
||||
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
|
||||
Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0),
|
||||
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None },
|
||||
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, ..Self::default() },
|
||||
Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0),
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5),
|
||||
@@ -624,9 +751,9 @@ impl Style {
|
||||
Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
// Red outline while marked; a black box once applied.
|
||||
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) },
|
||||
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], fill: Some([0.0, 0.0, 0.0]), ..Self::default() },
|
||||
};
|
||||
Self { color, opacity: 1.0, width, fill: None }
|
||||
Self { color, width, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
|
||||
use pdfcraft_engine::{
|
||||
BorderShape, Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort,
|
||||
};
|
||||
use pdfcraft_render::{Annotation, PageInfo};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -190,6 +192,25 @@ impl Automation {
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
|
||||
"color": c.color.map(hex),
|
||||
});
|
||||
// A text box's or typed text's oval or circle (a rectangle is not reported).
|
||||
if c.subtype == "FreeText"
|
||||
&& let Some(shape) = doc.comment_props(c.page, c.index).and_then(|p| p.border_shape).filter(|s| *s != BorderShape::Rectangle)
|
||||
{
|
||||
v["border_shape"] = json!(shape.name().to_ascii_lowercase());
|
||||
}
|
||||
// How opaque it is drawn: as a whole, or its fill and text each at their own.
|
||||
// Not for replies, which are drawn with the comment they answer.
|
||||
if !c.is_reply()
|
||||
&& let Some(p) = doc.comment_props(c.page, c.index)
|
||||
{
|
||||
v["opacity"] = json!(p.opacity);
|
||||
if let Some(f) = p.fill_opacity {
|
||||
v["fill_opacity"] = json!(f);
|
||||
}
|
||||
if let Some(t) = p.text_opacity {
|
||||
v["text_opacity"] = json!(t);
|
||||
}
|
||||
}
|
||||
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
|
||||
// comment_edit's `points` takes them.
|
||||
// Unchanged on a page the viewer could not read, as `rect` is.
|
||||
@@ -253,6 +274,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 +352,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 +380,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<_>>>()?;
|
||||
@@ -425,9 +458,28 @@ impl Automation {
|
||||
if let Some(o) = a.opt_num("opacity")? {
|
||||
style.opacity = o.clamp(0.0, 1.0);
|
||||
}
|
||||
// The fill's and a text box's text's own opacities (the line keeps `opacity`).
|
||||
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
|
||||
let texts = matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. });
|
||||
if fill_opacity.is_some() && !(texts || matches!(shape, Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Polygon { .. })) {
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"fill_opacity applies to a rectangle, oval, polygon, cloud, text box, typewriter text or callout".into(),
|
||||
));
|
||||
}
|
||||
if text_opacity.is_some() && !texts {
|
||||
return Err(ToolError::InvalidArgs("text_opacity applies to a text box, typewriter text or callout".into()));
|
||||
}
|
||||
style.fill_opacity = fill_opacity.map(|o| o.clamp(0.0, 1.0));
|
||||
style.text_opacity = text_opacity.map(|o| o.clamp(0.0, 1.0));
|
||||
if let Some(w) = a.opt_num("width")? {
|
||||
style.width = w.max(0.0);
|
||||
}
|
||||
if let Some(b) = border_shape(a)? {
|
||||
if !matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. }) {
|
||||
return Err(ToolError::InvalidArgs("border_shape is for a text box or typewriter text".into()));
|
||||
}
|
||||
style.border_shape = b;
|
||||
}
|
||||
let contents = a.opt_str("contents")?.unwrap_or_default().to_string();
|
||||
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
|
||||
// Replace Text: the struck-out text and a grouped caret holding `contents`.
|
||||
@@ -488,6 +540,7 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result<Value> {
|
||||
let border_shape = border_shape(a)?;
|
||||
use pdfcraft_engine::FillMark;
|
||||
let page = self.page(a)?;
|
||||
let info = self.doc(a)?.info.pages[page].clone();
|
||||
@@ -515,6 +568,25 @@ impl Automation {
|
||||
if !(4.0..=300.0).contains(&size) {
|
||||
return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into()));
|
||||
}
|
||||
let requested_border = a.opt_num("border")?;
|
||||
if requested_border.is_some() && !matches!(kind, "text" | "date") {
|
||||
return Err(ToolError::InvalidArgs("border is only for type text or date".into()));
|
||||
}
|
||||
let border = requested_border.unwrap_or(0.0);
|
||||
if !(0.0..=12.0).contains(&border) {
|
||||
return Err(ToolError::InvalidArgs("border must be 0–12 pt".into()));
|
||||
}
|
||||
let border_color = a.opt_str("border_color")?.map(parse_color).transpose()?;
|
||||
if border_color.is_some() && border <= 0.0 {
|
||||
return Err(ToolError::InvalidArgs("border_color needs a border".into()));
|
||||
}
|
||||
if border_shape.is_some() && !matches!(kind, "text" | "date") {
|
||||
return Err(ToolError::InvalidArgs("border_shape is only for type text or date".into()));
|
||||
}
|
||||
// Without a border an oval or a circle would grow the box around nothing drawn.
|
||||
if border_shape.is_some_and(|s| s != BorderShape::Rectangle) && border <= 0.0 {
|
||||
return Err(ToolError::InvalidArgs("border_shape needs a border".into()));
|
||||
}
|
||||
if let Some(path) = a.opt_str("path")? {
|
||||
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
|
||||
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
|
||||
@@ -538,13 +610,16 @@ impl Automation {
|
||||
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
|
||||
return self.apply(a, edit);
|
||||
}
|
||||
// A circle goes around the text, so its box is no wider than the text needs.
|
||||
let min_width = if border_shape == Some(BorderShape::Circle) { 0.0 } else { 20.0 };
|
||||
let text_at = |t: &str| {
|
||||
let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max);
|
||||
let w = (longest + 8.0).clamp(20.0, 600.0);
|
||||
let w = (longest + 8.0).clamp(min_width, 600.0);
|
||||
let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1);
|
||||
let h = rows as f64 * size * 1.2 + 6.0;
|
||||
let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
|
||||
Shape::Typewriter { rect, font_size: size }
|
||||
let [x0, y0, x1, y1] = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
|
||||
// A border goes around the box, so the text sits where it would without one.
|
||||
Shape::Typewriter { rect: [x0 - border, y0 - border, x1 + border, y1 + border], font_size: size }
|
||||
};
|
||||
let (shape, contents) = match kind {
|
||||
"text" => {
|
||||
@@ -590,6 +665,11 @@ impl Automation {
|
||||
if let Some(color) = requested_color {
|
||||
style.color = color;
|
||||
}
|
||||
if matches!(shape, Shape::Typewriter { .. }) {
|
||||
style.width = border;
|
||||
style.border_color = border_color;
|
||||
style.border_shape = border_shape.unwrap_or_default();
|
||||
}
|
||||
self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author }))
|
||||
}
|
||||
|
||||
@@ -656,6 +736,21 @@ impl Automation {
|
||||
let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) };
|
||||
edits.push(Edit::FillAnnotation { page, index, fill });
|
||||
}
|
||||
if let Some(c) = a.opt_str("border_color")? {
|
||||
let color = if c.eq_ignore_ascii_case("text") { None } else { Some(parse_color(c)?) };
|
||||
edits.push(Edit::BorderColorAnnotation { page, index, color });
|
||||
}
|
||||
if let Some(shape) = border_shape(a)? {
|
||||
edits.push(Edit::BorderShapeAnnotation { page, index, shape });
|
||||
}
|
||||
// After `opacity`, which sets the whole comment's.
|
||||
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
|
||||
if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) {
|
||||
return Err(ToolError::InvalidArgs("fill_opacity and text_opacity are 0–1".into()));
|
||||
}
|
||||
if fill_opacity.is_some() || text_opacity.is_some() {
|
||||
edits.push(Edit::PartOpacity { page, index, fill: fill_opacity, text: text_opacity });
|
||||
}
|
||||
if let Some(size) = a.opt_num("font_size")? {
|
||||
if !(1.0..=400.0).contains(&size) {
|
||||
return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into()));
|
||||
@@ -689,7 +784,7 @@ impl Automation {
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
|
||||
"nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, border_shape, font_size, rect, move or points".into(),
|
||||
));
|
||||
}
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
|
||||
@@ -700,6 +795,49 @@ impl Automation {
|
||||
let (page, index) = self.comment_target(a)?;
|
||||
self.apply(a, Edit::DeleteAnnotation { page, index })
|
||||
}
|
||||
|
||||
/// The XFDF of the listed comments and their replies (`comments`: 1-based [page, index] pairs).
|
||||
pub(crate) fn comments_copy(&self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let all = self.comments(a)?;
|
||||
let shape = || ToolError::InvalidArgs("comments: [[page, index], …] (1-based, as comment_list reports)".into());
|
||||
let list = a.get("comments").and_then(Value::as_array).ok_or_else(shape)?;
|
||||
let mut which = Vec::with_capacity(list.len());
|
||||
for item in list {
|
||||
let pair = item.as_array().filter(|p| p.len() == 2).and_then(|p| Some((p.first()?.as_u64()?, p.get(1)?.as_u64()?)));
|
||||
let Some((page, index)) = pair.filter(|(p, i)| *p >= 1 && *i >= 1) else { return Err(shape()) };
|
||||
let (page, index) = (usize::try_from(page - 1).map_err(|_| shape())?, usize::try_from(index - 1).map_err(|_| shape())?);
|
||||
if !all.iter().any(|c| c.page == page && c.index == index) {
|
||||
return Err(failed(format!("there is no comment {} on page {} (see comment_list)", index + 1, page + 1)));
|
||||
}
|
||||
which.push((page, index));
|
||||
}
|
||||
let xfdf = self.session.export_comments(id, &which).map_err(failed)?;
|
||||
Ok(json!({ "xfdf": xfdf, "comments": which.len() }))
|
||||
}
|
||||
|
||||
/// Add the comments of `xfdf` (from `comments_copy`) to `page` as new comments.
|
||||
pub(crate) fn comments_paste(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let xfdf = a.str("xfdf")?.to_owned();
|
||||
if !pdfcraft_engine::is_xfdf_comments(&xfdf) {
|
||||
return Err(ToolError::InvalidArgs("xfdf: the text comments_copy returned (XFDF with <annots>)".into()));
|
||||
}
|
||||
let offset = match a.nums::<2>("at")? {
|
||||
Some([x, y]) => {
|
||||
let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| failed(format!("no page {}", page + 1)))?;
|
||||
let [cx, cy] = to_user(&info, x, y);
|
||||
let b =
|
||||
pdfcraft_engine::comments_bounds(&xfdf).ok_or_else(|| ToolError::InvalidArgs("xfdf: no comment with a rect to place".into()))?;
|
||||
Some([cx - (b[0] + b[2]) / 2.0, cy - (b[1] + b[3]) / 2.0])
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let before = self.comments(a)?.len();
|
||||
let mut out = self.apply(a, Edit::PasteComments { xfdf, page, offset })?;
|
||||
out["comments"] = json!(self.comments(a)?.len().saturating_sub(before));
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
@@ -744,6 +882,13 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
|
||||
/// `border_shape`: `rectangle`, `oval` or `circle`.
|
||||
fn border_shape(a: &Args) -> Result<Option<BorderShape>> {
|
||||
a.opt_str("border_shape")?
|
||||
.map(|s| BorderShape::parse(s).ok_or_else(|| ToolError::InvalidArgs(format!("unknown border_shape {s:?}: rectangle, oval or circle"))))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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 })?
|
||||
@@ -865,6 +867,8 @@ impl Automation {
|
||||
"comments_summarize" => self.comments_summarize(&a)?,
|
||||
"comment_edit" => self.comment_edit(&a)?,
|
||||
"comment_delete" => self.comment_delete(&a)?,
|
||||
"comments_copy" => self.comments_copy(&a)?,
|
||||
"comments_paste" => self.comments_paste(&a)?,
|
||||
other => return Err(ToolError::UnknownTool(other.into())),
|
||||
};
|
||||
Ok(vec![Content::Json(out)])
|
||||
@@ -918,3 +922,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ mod links;
|
||||
pub mod mcp;
|
||||
mod measure;
|
||||
mod model3d;
|
||||
mod model_render;
|
||||
mod printing;
|
||||
mod reading;
|
||||
mod redact;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -839,9 +858,13 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph (the standard names), Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow; attachment: PushPin, Paperclip, Graph, Tag." },
|
||||
"color": color(),
|
||||
"fill": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity (its line or border's, where its fill or text has its own)." },
|
||||
"fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity: rectangle, oval, polygon, cloud, text box, typewriter text or callout." },
|
||||
"text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity: text box, typewriter text or callout." },
|
||||
"width": { "type": "number", "minimum": 0, "description": "Line width in points." },
|
||||
"border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "A text box's or typewriter text's border shape: an oval or a circle goes around the text box." },
|
||||
"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"],
|
||||
@@ -972,13 +995,17 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
comment_ref(json!({
|
||||
"contents": { "type": "string" },
|
||||
"color": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity: its fill and text drop opacities of their own." },
|
||||
"fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity (rectangle, oval, polygon, cloud, text box, typewriter text, callout); the line or border keeps the comment's." },
|
||||
"text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity (text box, typewriter text, callout)." },
|
||||
"width": { "type": "number", "minimum": 0 },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"border_color": { "type": "string", "description": "A text box's, typewriter text's or callout's border colour: #RRGGBB, a colour name, or \"text\" for the text's colour." },
|
||||
"border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "A text box's or typewriter text's border shape: an oval or a circle goes around the text, which keeps its place." },
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"move": point(),
|
||||
@@ -989,6 +1016,25 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
|
||||
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
|
||||
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
|
||||
t(
|
||||
"comments_copy",
|
||||
"Copy comments",
|
||||
"The XFDF of these comments, each with its replies: what Copy puts on the clipboard. Paste it into this or another document with comments_paste. `comments`: [[page, index], …], 1-based as comment_list reports them.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"comments": { "type": "array", "items": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 2, "maxItems": 2 }, "minItems": 1 }
|
||||
}),
|
||||
&["doc", "comments"],
|
||||
)),
|
||||
t(
|
||||
"comments_paste",
|
||||
"Paste comments",
|
||||
"Add the comments of an XFDF text (from comments_copy) to `page` as new comments: copies under names of their own, so nothing in the document is replaced and pasting twice gives two sets; replies keep their copied parent. `at` [x, y] (points, origin at the top-left of the displayed page, y down, as comment_add takes them) puts the centre of the copies' bounding box there; without it they keep their positions. Undoable.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "xfdf": { "type": "string" }, "at": point() }), &["doc", "page", "xfdf"])),
|
||||
t(
|
||||
"doc_protect",
|
||||
"Protect with passwords",
|
||||
@@ -1364,7 +1410,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"fill_sign_add",
|
||||
"Fill & Sign: type text or place a mark",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
|
||||
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
|
||||
)
|
||||
.cmd("sign.fill.text")
|
||||
.with(schema(
|
||||
@@ -1376,6 +1422,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"text": { "type": "string", "minLength": 1 },
|
||||
"font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." },
|
||||
"color": color(),
|
||||
"border": { "type": "number", "minimum": 0, "maximum": 12, "description": "For type text or date: a border around it in points, in the text's colour (default 0, none)." },
|
||||
"border_color": { "type": "string", "description": "With border: its own colour, #RRGGBB or a colour name." },
|
||||
"border_shape": { "type": "string", "enum": ["rectangle", "oval", "circle"], "description": "With border, for type text or date: the border's shape (default rectangle); an oval or a circle goes around the text." },
|
||||
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
|
||||
"scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." },
|
||||
"sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." },
|
||||
|
||||
@@ -2135,6 +2135,104 @@ fn numbering_pages_through_tools() {
|
||||
assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// Typed text and text boxes take an oval or a circle for their border through the tools;
|
||||
/// comment_list reports it (a rectangle is not reported). Only typed text, dates and text boxes
|
||||
/// have one, and only the three shapes.
|
||||
#[test]
|
||||
fn text_borders_take_an_oval_or_a_circle_through_the_tools() {
|
||||
let dir = workdir("border-shape");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(
|
||||
&mut a,
|
||||
"fill_sign_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "12", "border": 1, "border_shape": "circle" }),
|
||||
);
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "width": 1, "border_shape": "Oval" }),
|
||||
);
|
||||
let list = |a: &mut Automation| ok(a, "comment_list", json!({ "doc": doc, "page": 1 }))["comments"].as_array().unwrap().clone();
|
||||
let shapes: Vec<Value> = list(&mut a).iter().map(|c| c["border_shape"].clone()).collect();
|
||||
assert_eq!(shapes, [json!("circle"), json!("oval")]);
|
||||
let typed = list(&mut a)[0].clone();
|
||||
// Snug around "12": the box no wider than the text needs, the circle as wide as that box.
|
||||
let r: Vec<f64> = typed["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let w = pdfcraft_engine::annot_text::text_width("12", 10.0) + 10.0;
|
||||
assert!((r[2] - r[0] - w).abs() < 0.05 && (r[3] - r[1] - w).abs() < 0.05, "{r:?}");
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "oval" }));
|
||||
assert_eq!(list(&mut a)[0]["border_shape"], "oval");
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "rectangle" }));
|
||||
assert!(list(&mut a)[0].get("border_shape").is_none(), "a rectangle is not reported");
|
||||
for (tool, args) in [
|
||||
("fill_sign_add", json!({ "doc": doc, "page": 1, "type": "check", "at": [60, 60], "border_shape": "oval" })),
|
||||
("fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "text": "7", "at": [60, 60], "border_shape": "circle" })),
|
||||
("comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [80, 10, 120, 40], "border_shape": "oval" })),
|
||||
("comment_edit", json!({ "doc": doc, "page": typed["page"], "index": typed["index"], "border_shape": "star" })),
|
||||
] {
|
||||
assert!(matches!(a.call(tool, &args), Err(ToolError::InvalidArgs(_))), "{args}");
|
||||
}
|
||||
let callout = ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 100, 190, 140], "to": [40, 60], "contents": "Look" }),
|
||||
);
|
||||
assert!(matches!(
|
||||
a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": callout["comment"]["index"], "border_shape": "oval" })),
|
||||
Err(ToolError::Failed(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// A rectangle's fill and a text box's text each take an opacity of their own through the tools
|
||||
/// (the line or border keeps the comment's `opacity`); `opacity` sets the whole comment's again.
|
||||
#[test]
|
||||
fn comment_parts_take_opacities_of_their_own_through_the_tools() {
|
||||
let dir = workdir("part-opacity");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "fill": "yellow", "fill_opacity": 0.25 }),
|
||||
);
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "fill": "#FFFF99", "width": 1, "fill_opacity": 0.2, "text_opacity": 1 }),
|
||||
);
|
||||
let find = |a: &mut Automation, kind: &str| {
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
list["comments"].as_array().unwrap().iter().find(|c| c["type"] == kind).unwrap().clone()
|
||||
};
|
||||
let rect = find(&mut a, "Square");
|
||||
assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64(), rect.get("text_opacity")), (Some(1.0), Some(0.25), None));
|
||||
let tb = find(&mut a, "FreeText");
|
||||
assert_eq!((tb["opacity"].as_f64(), tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(1.0), Some(0.2), Some(1.0)));
|
||||
// The fill changes, the text stays; an undo step.
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "fill_opacity": 0.5 }));
|
||||
let tb = find(&mut a, "FreeText");
|
||||
assert_eq!((tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(0.5), Some(1.0)));
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change comment properties");
|
||||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||||
// The whole comment's opacity: everything at it again.
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "opacity": 0.6 }));
|
||||
let rect = find(&mut a, "Square");
|
||||
assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64()), (Some(0.6), Some(0.6)));
|
||||
// Only the parts a comment has, at 0–1.
|
||||
for (tool, args) in [
|
||||
("comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [0, 0], "to": [50, 50], "fill_opacity": 0.5 })),
|
||||
("comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [80, 10, 120, 40], "text_opacity": 0.5 })),
|
||||
("comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "fill_opacity": 2 })),
|
||||
] {
|
||||
assert!(matches!(a.call(tool, &args), Err(ToolError::InvalidArgs(_))), "{args}");
|
||||
}
|
||||
assert!(matches!(
|
||||
a.call("comment_edit", &json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "text_opacity": 0.5 })),
|
||||
Err(ToolError::Failed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_through_tools() {
|
||||
let dir = workdir("comments");
|
||||
@@ -3847,6 +3945,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");
|
||||
@@ -4075,6 +4229,48 @@ fn fill_sign_text_and_date_colours_are_saved_and_validated() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_sign_text_border_is_saved_and_validated() {
|
||||
let dir = workdir("fill-sign-text-border");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": "Plain" }));
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 80], "text": "Plain", "border": 1.5 }));
|
||||
let comments = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
let width = |k: usize| {
|
||||
let rect = comments["comments"][k]["rect"].as_array().unwrap();
|
||||
rect[2].as_f64().unwrap() - rect[0].as_f64().unwrap()
|
||||
};
|
||||
assert!((width(1) - width(0) - 3.0).abs() < 1e-6, "1.5 pt on each side: {comments}");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "framed.pdf" }));
|
||||
let saved = std::fs::read(dir.join("framed.pdf")).unwrap();
|
||||
let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(saved)).unwrap();
|
||||
let page = pdfcraft_model::pages(&pdf).remove(0);
|
||||
let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap());
|
||||
let annotation = pdf.resolve(annotations.as_array().unwrap().get(1).unwrap());
|
||||
let bs = annotation.as_dict().unwrap().get(b"BS").unwrap().as_dict().unwrap().get(b"W").unwrap().as_f64().unwrap();
|
||||
assert_eq!(bs, 1.5, "saved annotation has the requested border");
|
||||
// Its own colour, then the text's again through comment_edit.
|
||||
ok(
|
||||
&mut a,
|
||||
"fill_sign_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 160], "text": "Red", "border": 1, "border_color": "#ff0000" }),
|
||||
);
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
let red = list["comments"].as_array().unwrap().iter().find(|c| c["contents"] == "Red").unwrap()["index"].clone();
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": red, "border_color": "text" }));
|
||||
assert!(
|
||||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 200], "text": "No", "border_color": "red" })).is_err(),
|
||||
"no border"
|
||||
);
|
||||
for (kind, border) in [("text", -1.0), ("text", 13.0), ("check", 1.0)] {
|
||||
assert!(matches!(
|
||||
a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 120], "text": "No", "border": border })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
|
||||
let dir = workdir("image-signatures");
|
||||
@@ -6543,6 +6739,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");
|
||||
@@ -10938,3 +11172,49 @@ fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() {
|
||||
assert!(!out.exists());
|
||||
assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([]));
|
||||
}
|
||||
|
||||
/// Copy and paste comments between documents through the tools: the copies carry their replies,
|
||||
/// land on the asked-for page under their own names (a second paste gives a second set), and a
|
||||
/// plain text is refused.
|
||||
#[test]
|
||||
fn comments_copy_and_paste_between_documents_through_tools() {
|
||||
let dir = workdir("comments-clipboard");
|
||||
let mut a = auto(&dir);
|
||||
let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "note", "at": [30, 30], "contents": "Please review", "author": "Ada" }));
|
||||
ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "rectangle", "rect": [20, 20, 80, 60], "contents": "Box" }));
|
||||
ok(&mut a, "comment_reply", json!({ "doc": src, "page": 2, "index": 1, "text": "Done", "author": "Bob" }));
|
||||
// Positions as comment_list reports them (a note's pop-up takes a position of its own).
|
||||
let listed = ok(&mut a, "comment_list", json!({ "doc": src, "page": 2 }));
|
||||
let pairs: Vec<Value> = listed["comments"].as_array().unwrap().iter().map(|c| json!([c["page"], c["index"]])).collect();
|
||||
assert_eq!(pairs.len(), 2, "{listed}");
|
||||
let copied = ok(&mut a, "comments_copy", json!({ "doc": src, "comments": pairs }));
|
||||
assert_eq!(copied["comments"], 2);
|
||||
let xfdf = copied["xfdf"].as_str().unwrap().to_owned();
|
||||
assert!(xfdf.contains("inreplyto=") && xfdf.contains("<square"), "{xfdf}");
|
||||
let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 1, "xfdf": xfdf }));
|
||||
assert_eq!(r["comments"], 3, "two comments and the reply: {r}");
|
||||
assert_eq!(r["undo"], "Paste comments");
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": dst }));
|
||||
let comments = list["comments"].as_array().unwrap();
|
||||
assert_eq!(comments.len(), 2, "the reply is in its thread, not a comment of its own: {list}");
|
||||
assert!(comments.iter().all(|c| c["page"] == 1), "{list}");
|
||||
assert_eq!(comments.iter().filter(|c| c["contents"] == "Please review").count(), 1);
|
||||
// The same again: a second set, not a replacement of the first.
|
||||
ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 1, "xfdf": copied["xfdf"] }));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": dst }))["comments"].as_array().unwrap().len(), 4);
|
||||
// Placed: the copies' box is centred at `at` (top-left page coordinates, like comment_add).
|
||||
let r = ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 2, "xfdf": copied["xfdf"], "at": [100, 150] }));
|
||||
assert_eq!(r["comments"], 3);
|
||||
let placed = ok(&mut a, "comment_list", json!({ "doc": dst, "page": 2 }));
|
||||
let rects: Vec<Vec<f64>> =
|
||||
placed["comments"].as_array().unwrap().iter().map(|c| c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()).collect();
|
||||
let (x0, y0, x1, y1) =
|
||||
rects.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |(a, b, c, d), r| (a.min(r[0]), b.min(r[1]), c.max(r[2]), d.max(r[3])));
|
||||
assert!(((x0 + x1) / 2.0 - 100.0).abs() < 1.0 && ((y0 + y1) / 2.0 - 150.0).abs() < 1.0, "centred at [100, 150]: {rects:?}");
|
||||
assert!(matches!(a.call("comments_paste", &json!({ "doc": dst, "page": 1, "xfdf": "hello" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(a.call("comments_copy", &json!({ "doc": src, "comments": [[9, 9]] })).is_err(), "no such comment");
|
||||
assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 § {
|
||||
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))
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤t).unwrap();
|
||||
assert_eq!(*saved.get(reference), Object::Int(99), "generation {generation}");
|
||||
let back = Document::open(Arc::new(write_incremental(&saved, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert!(back.repair_log().is_empty());
|
||||
assert_eq!(*back.get(reference), Object::Int(99), "generation {generation}");
|
||||
let catalog = back.get(back.root().unwrap());
|
||||
assert_eq!(*back.resolve(catalog.as_dict().unwrap().get(b"Current").unwrap()), Object::Int(99));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_save_does_not_turn_stale_references_into_live_ones() {
|
||||
let doc = Document::open(reused_object()).unwrap();
|
||||
for object_streams in [false, true] {
|
||||
let saved = write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap();
|
||||
let saved = Arc::new(saved);
|
||||
let oracle = hayro_syntax::Pdf::new(saved.clone()).unwrap();
|
||||
let catalog = oracle.xref().get::<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(¤t).unwrap();
|
||||
for generation in [0, 1] {
|
||||
assert_eq!(*saved.get(ObjRef::new(3, generation)), Object::Int(42), "edited={edited}, value={value}");
|
||||
}
|
||||
assert!(saved.full_save_required());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repaired_page_tree_aliases_keep_one_identity_in_full_saves() {
|
||||
let source = object_file_with_entry(
|
||||
&[
|
||||
(1, 0, "<< /Type /Catalog /Pages 2 1 R /Stale 2 2 R >>"),
|
||||
(2, 1, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"),
|
||||
(3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>"),
|
||||
],
|
||||
Some((2, 0, false)),
|
||||
);
|
||||
for edited in [false, true] {
|
||||
let mut doc = Document::open(source.clone()).unwrap();
|
||||
if edited {
|
||||
doc.update_dict(ObjRef::new(2, 0), |dict| dict.set(b"Edited".to_vec(), Object::Bool(true))).unwrap();
|
||||
}
|
||||
let mut restored = Document::open(source.clone()).unwrap();
|
||||
restored.overlay_changes_from(&doc).unwrap();
|
||||
for current in [&doc, &restored] {
|
||||
for object_streams in [false, true] {
|
||||
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
|
||||
let mut saves = vec![write_full(current, &opts).unwrap()];
|
||||
if edited {
|
||||
saves.push(write_incremental(current, &opts).unwrap());
|
||||
}
|
||||
for bytes in saves {
|
||||
let back = Document::open(Arc::new(bytes)).unwrap();
|
||||
let catalog = back.get(back.root().unwrap());
|
||||
let catalog = catalog.as_dict().unwrap();
|
||||
let pages_ref = catalog.reference(b"Pages").unwrap();
|
||||
let pages = back.get(pages_ref);
|
||||
let child_ref = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].as_ref().unwrap();
|
||||
let child = back.get(child_ref);
|
||||
assert_eq!(child.as_dict().unwrap().reference(b"Parent"), Some(pages_ref), "edited={edited}, packed={object_streams}");
|
||||
assert!(matches!(catalog.get(b"Stale"), Some(Object::Null)), "unrelated generations stay null");
|
||||
assert!(back.repair_log().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoring_explicit_sets_removes_old_generation_aliases() {
|
||||
for value in [42, 77] {
|
||||
let mut current = Document::open(contradictory_entry(false)).unwrap();
|
||||
current.set(ObjRef::new(3, 0), Object::Int(42));
|
||||
for repaired_overlay in [false, true] {
|
||||
let mut saved = Document::open(contradictory_entry(false)).unwrap();
|
||||
if repaired_overlay {
|
||||
saved.set_existing(ObjRef::new(3, 0), Object::Int(value)).unwrap();
|
||||
}
|
||||
saved.overlay_changes_from(¤t).unwrap();
|
||||
assert_eq!(*saved.get(ObjRef::new(3, 0)), Object::Int(42));
|
||||
assert_eq!(*saved.get(ObjRef::new(3, 1)), Object::Null, "value={value}, overlay={repaired_overlay}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod bounded_writer;
|
||||
mod corpus;
|
||||
mod encryption;
|
||||
mod generations;
|
||||
mod proptests;
|
||||
mod shared_streams;
|
||||
mod writer;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t.ext_gstate).take_while(|(a, b)| a == b).count();
|
||||
for name in self.ext_gstate.iter().skip(shared) {
|
||||
v.push(Op::new("gs", vec![Object::Name(name.clone())]));
|
||||
for sel in self.ext_gstate.iter().skip(shared) {
|
||||
sel.push_ops(&mut v, ctm);
|
||||
}
|
||||
// A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it.
|
||||
let reselected = !v.is_empty();
|
||||
@@ -222,7 +246,7 @@ pub(super) struct Ts {
|
||||
/// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0).
|
||||
pub(super) line_width: Option<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).
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
],
|
||||
|
||||
@@ -220,6 +220,10 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
// Alt-drag for moving windows (several Linux window managers).
|
||||
// Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership.
|
||||
c("edit.copy", "Copy", EDIT, None, Copying, "copy"),
|
||||
// Comments travel between documents as XFDF (M14): ⌘C with comments selected copies them,
|
||||
// ⌘V pastes the copies onto the current page; the strip's Copy/Paste in the menus too.
|
||||
c("comment.copy", "Copy comments", EDIT, None, Document, "copy"),
|
||||
c("comment.paste", "Paste comments", EDIT, None, Annotate, "clipboard-paste"),
|
||||
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
|
||||
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
|
||||
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
|
||||
@@ -355,6 +359,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"),
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -582,6 +582,18 @@ pub fn date_language(code: &str) -> Option<&'static DateLanguage> {
|
||||
DATE_LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code.trim()))
|
||||
}
|
||||
|
||||
/// Form date fields (`AFDate_FormatEx`, `util.printd`, `util.scand`) write month and weekday
|
||||
/// names in date language `code` from now on, as a localized Acrobat writes its own language's:
|
||||
/// `dd-mmm-yy` is `18-ott-26` in Italian (#5303). An unknown code is English.
|
||||
pub fn use_form_date_language(code: &str) {
|
||||
let l = date_language(code).unwrap_or(&DATE_LANGUAGES[0]);
|
||||
let names = pdfcraft_forms::af::DateNames { months: l.months, months_short: l.months_short, days: l.days, days_short: l.days_short };
|
||||
if pdfcraft_forms::af::date_names() != names {
|
||||
pdfcraft_forms::af::set_date_names(names);
|
||||
pdfcraft_js::set_date_names(l.months, l.months_short, l.days, l.days_short);
|
||||
}
|
||||
}
|
||||
|
||||
/// The characters of `text` that Fill & Sign can't write into a PDF yet, each once. Its text is
|
||||
/// drawn in Helvetica with WinAnsi encoding (Western European letters), so anything else (`ř`,
|
||||
/// Japanese, Chinese) would become `?` in the file.
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label,
|
||||
LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState,
|
||||
Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture,
|
||||
image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
|
||||
AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit,
|
||||
InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox,
|
||||
Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image,
|
||||
field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted,
|
||||
xfa, xfa_after_data_change,
|
||||
};
|
||||
|
||||
mod labels;
|
||||
@@ -263,12 +264,35 @@ pub enum Edit {
|
||||
index: usize,
|
||||
fill: Option<Rgb>,
|
||||
},
|
||||
/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an
|
||||
/// opacity of its own (0–1), the line or border keeping the comment's; `None` leaves that
|
||||
/// part as it is. `StyleAnnotation`'s opacity sets the whole comment's.
|
||||
PartOpacity {
|
||||
page: usize,
|
||||
index: usize,
|
||||
fill: Option<f64>,
|
||||
text: Option<f64>,
|
||||
},
|
||||
/// Change the font size (pt) of a text box, typewriter text or callout.
|
||||
FontSizeAnnotation {
|
||||
page: usize,
|
||||
index: usize,
|
||||
size: f64,
|
||||
},
|
||||
/// Give a text box, typewriter text or callout a border colour of its own (`None`: the
|
||||
/// text's again).
|
||||
BorderColorAnnotation {
|
||||
page: usize,
|
||||
index: usize,
|
||||
color: Option<Rgb>,
|
||||
},
|
||||
/// Give a text box or typewriter text an oval, a circle or a rectangle for its border: the
|
||||
/// text box keeps its place, the oval or circle goes around it.
|
||||
BorderShapeAnnotation {
|
||||
page: usize,
|
||||
index: usize,
|
||||
shape: BorderShape,
|
||||
},
|
||||
/// Comment properties ▸ General / note icon.
|
||||
SetAnnotationInfo {
|
||||
page: usize,
|
||||
@@ -406,6 +430,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 {
|
||||
@@ -554,6 +585,15 @@ pub enum Edit {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
},
|
||||
/// Paste copied comments (XFDF from [`Session::export_comments`]) onto `page` as new
|
||||
/// comments: copies under names of their own, so nothing is replaced and pasting twice
|
||||
/// gives two sets; replies keep their copied parent.
|
||||
PasteComments {
|
||||
xfdf: String,
|
||||
page: usize,
|
||||
/// Where the copies go: moved by (dx, dy) in user space, or at their own positions.
|
||||
offset: Option<[f64; 2]>,
|
||||
},
|
||||
/// Remove Hidden Information: the chosen categories.
|
||||
RemoveHidden {
|
||||
which: Vec<Hidden>,
|
||||
|
||||
@@ -71,6 +71,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Add stamp",
|
||||
"Add 3D model",
|
||||
"Delete comment",
|
||||
"Paste comments",
|
||||
"Edit comment",
|
||||
"Reply",
|
||||
"Set status None",
|
||||
@@ -101,6 +102,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}",
|
||||
@@ -210,6 +212,7 @@ impl Edit {
|
||||
Edit::AddCustomStamp { .. } => Label::of("Add stamp"),
|
||||
Edit::AddModel3d { .. } => Label::of("Add 3D model"),
|
||||
Edit::DeleteAnnotation { .. } => Label::of("Delete comment"),
|
||||
Edit::PasteComments { .. } => Label::of("Paste comments"),
|
||||
Edit::SetAnnotationContents { .. } => Label::of("Edit comment"),
|
||||
Edit::ReplyToAnnotation { .. } => Label::of("Reply"),
|
||||
// The state's name is part of the wording: a language may need to agree it with the rest.
|
||||
@@ -229,9 +232,13 @@ impl Edit {
|
||||
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
|
||||
Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"),
|
||||
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
|
||||
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => {
|
||||
Label::of("Change comment properties")
|
||||
}
|
||||
Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::PartOpacity { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::BorderColorAnnotation { .. }
|
||||
| Edit::BorderShapeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"),
|
||||
Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()),
|
||||
Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()),
|
||||
Edit::ResetForm { .. } => Label::of("Clear form"),
|
||||
@@ -252,6 +259,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.
|
||||
|
||||
@@ -49,6 +49,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
}
|
||||
Edit::AddMeasurement(_)
|
||||
| Edit::AddAnnotation(_)
|
||||
| Edit::PasteComments { .. }
|
||||
| Edit::AddCustomStamp { .. }
|
||||
| Edit::AddModel3d { .. }
|
||||
| Edit::DeleteAnnotation { .. }
|
||||
@@ -64,7 +65,10 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::PartOpacity { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::BorderColorAnnotation { .. }
|
||||
| Edit::BorderShapeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. }
|
||||
| Edit::SetMeasurementScale { .. } => {
|
||||
if p.annotate() {
|
||||
@@ -138,6 +142,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 { .. }
|
||||
@@ -494,6 +499,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::AddAnnotation(a) => {
|
||||
pdfcraft_annot::add_annotation(doc, a, &cx.meta())?;
|
||||
}
|
||||
Edit::PasteComments { xfdf, page, offset } => {
|
||||
// Names from the edit's own id generator: unique to this paste within the document.
|
||||
let prefix = cx.meta().id;
|
||||
let report = pdfcraft_xfdf::import_comments(doc, xfdf, Some(*page), Some(&prefix), *offset)?;
|
||||
if report.comments == 0 {
|
||||
return Err(EditError::Invalid("the clipboard holds no comments".into()));
|
||||
}
|
||||
}
|
||||
Edit::AddCustomStamp { page, rect, name, file, author } => {
|
||||
let src = mark_source(doc, file)?;
|
||||
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
|
||||
@@ -574,7 +587,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
)?;
|
||||
}
|
||||
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
|
||||
Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?,
|
||||
Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?,
|
||||
Edit::BorderColorAnnotation { page, index, color } => pdfcraft_annot::set_border_color(doc, *page, *index, *color, &cx.meta())?,
|
||||
Edit::BorderShapeAnnotation { page, index, shape } => pdfcraft_annot::set_border_shape(doc, *page, *index, *shape, &cx.meta())?,
|
||||
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
|
||||
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
|
||||
}
|
||||
@@ -704,6 +720,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 } => {
|
||||
|
||||
@@ -53,6 +53,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
|
||||
Edit::AddMeasurement(_)
|
||||
| Edit::AddAnnotation(_)
|
||||
| Edit::PasteComments { .. }
|
||||
| Edit::AddCustomStamp { .. }
|
||||
| Edit::AddModel3d { .. }
|
||||
| Edit::DeleteAnnotation { .. }
|
||||
@@ -68,7 +69,10 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::PartOpacity { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::BorderColorAnnotation { .. }
|
||||
| Edit::BorderShapeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
|
||||
Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links,
|
||||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
|
||||
@@ -78,6 +82,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
| Edit::EnhanceScan { .. }
|
||||
| Edit::AddText { .. }
|
||||
| Edit::AddImage { .. }
|
||||
| Edit::UpdateContent { .. }
|
||||
|
||||
+25
-6
@@ -199,6 +199,24 @@ impl Scripts for JsRunner {
|
||||
/// Run field `target`'s script for `event` over the form's fields, keeping what it showed and
|
||||
/// asked for; a refusal carries its last message.
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
|
||||
// As in Acrobat, a failing script leaves the value alone (the error goes to the console).
|
||||
self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult {
|
||||
rc: true,
|
||||
value: value.to_string(),
|
||||
changes: Vec::new(),
|
||||
message: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option<ScriptResult> {
|
||||
pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsRunner {
|
||||
/// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None`
|
||||
/// when the script failed.
|
||||
fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option<ScriptResult> {
|
||||
let states: Vec<_> = fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
@@ -212,17 +230,18 @@ impl Scripts for JsRunner {
|
||||
let states = std::sync::Arc::new(states);
|
||||
let ev = pdfcraft_js::Event::field(event.name(), &target.name, value);
|
||||
let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default());
|
||||
self.output.absorb(&o);
|
||||
if o.error.is_some() {
|
||||
// As in Acrobat, a failing script leaves the value alone (the error goes to the console).
|
||||
return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None };
|
||||
if log {
|
||||
self.output.absorb(&o);
|
||||
}
|
||||
ScriptResult {
|
||||
if o.error.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(ScriptResult {
|
||||
rc: o.rc,
|
||||
value: o.value.clone(),
|
||||
changes: changes(&states, &o.changed),
|
||||
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -111,8 +113,8 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr
|
||||
pub use pdfcraft_annot::appearance as annot_text;
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
pub use pdfcraft_annot::{
|
||||
Activation3d, AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook,
|
||||
Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
|
||||
Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont,
|
||||
OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
|
||||
};
|
||||
pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat};
|
||||
pub use pdfcraft_forms::detect;
|
||||
@@ -127,6 +129,7 @@ pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
|
||||
pub use pdfcraft_sign as sign;
|
||||
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore};
|
||||
pub use pdfcraft_xfdf::Format as DataFormat;
|
||||
pub use pdfcraft_xfdf::{Outline as CommentOutline, comment_count, comment_outlines, comments_bounds, is_xfdf_comments};
|
||||
|
||||
use bookmarks::outline_rows;
|
||||
pub use bookmarks::{BookmarkPage, BookmarkRow};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -114,6 +114,19 @@ impl Session {
|
||||
.map_err(EditError::Write)
|
||||
}
|
||||
|
||||
/// XFDF with these comments (0-based page, position in the page's `/Annots`) and their
|
||||
/// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in.
|
||||
pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result<String, EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let cos = match doc.editor.as_ref() {
|
||||
Some(e) => e.cos.clone(),
|
||||
None => {
|
||||
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
|
||||
}
|
||||
};
|
||||
guard(|| pdfcraft_xfdf::export_xfdf_comments(&cos, which, &doc.name)).map_err(EditError::Write)
|
||||
}
|
||||
|
||||
/// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`).
|
||||
pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result<Vec<u8>, EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
|
||||
@@ -2211,7 +2211,7 @@ fn rect_comment(page: usize, rect: [f64; 4]) -> Edit {
|
||||
Edit::AddAnnotation(NewAnnotation {
|
||||
page,
|
||||
shape: Shape::Rectangle { rect },
|
||||
style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) },
|
||||
style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]), ..Style::default() },
|
||||
contents: "Look here".into(),
|
||||
author: "Reviewer".into(),
|
||||
})
|
||||
@@ -10185,6 +10185,42 @@ fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy and paste: the copies of a comment and its reply land on the asked-for page of another
|
||||
/// document under names of their own (nothing replaced; a second paste gives a second set),
|
||||
/// undo takes a paste away as one step, and a clipboard without comments is refused.
|
||||
#[test]
|
||||
fn pasting_copied_comments_adds_copies_that_undo_as_one_step() {
|
||||
let (mut s, id) = session_with(2);
|
||||
s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap();
|
||||
s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Done".into(), author: "Ada".into() }).unwrap();
|
||||
let xfdf = s.export_comments(id, &[(1, 0)]).unwrap();
|
||||
assert!(xfdf.contains("<square") && xfdf.contains("inreplyto="), "{xfdf}");
|
||||
let other = s.open("other.pdf", None, Arc::new(fixture(2)), None).expect("opens");
|
||||
s.apply(other, Edit::PasteComments { xfdf: xfdf.clone(), page: 0, offset: None }).unwrap();
|
||||
let a = s.get(other).unwrap().info.annotations.clone();
|
||||
assert_eq!(a.len(), 2, "{a:?}");
|
||||
assert!(a.iter().all(|x| x.page == 0), "on the asked-for page: {a:?}");
|
||||
let parent = a.iter().find(|x| x.in_reply_to.is_none()).unwrap();
|
||||
let reply = a.iter().find(|x| x.in_reply_to.is_some()).unwrap();
|
||||
assert_eq!(reply.in_reply_to, parent.name, "the reply follows its copied parent");
|
||||
assert_ne!(parent.name, s.get(id).unwrap().info.annotations[0].name, "a name of its own");
|
||||
assert_eq!(parent.contents.as_deref(), Some("Look here"));
|
||||
assert_eq!(s.get(other).unwrap().can_undo(), Some("Paste comments"));
|
||||
s.apply(other, Edit::PasteComments { xfdf: xfdf.clone(), page: 1, offset: Some([30.0, -10.0]) }).unwrap();
|
||||
let a = s.get(other).unwrap().info.annotations.clone();
|
||||
assert_eq!(a.len(), 4, "a second set");
|
||||
let placed = a.iter().find(|x| x.page == 1 && x.in_reply_to.is_none()).unwrap();
|
||||
assert!((placed.rect[0] - 110.0).abs() < 0.5 && (placed.rect[1] - 70.0).abs() < 0.5, "moved by the offset: {:?}", placed.rect);
|
||||
assert_eq!(comment_count(&xfdf), 1);
|
||||
assert_eq!(comments_bounds(&xfdf).map(|b| b.map(|v| v.round())), Some([80.0, 80.0, 120.0, 120.0]));
|
||||
s.undo(other).unwrap();
|
||||
assert_eq!(s.get(other).unwrap().info.annotations.len(), 2);
|
||||
assert!(s.apply(other, Edit::PasteComments { xfdf: "hello".into(), page: 0, offset: None }).is_err(), "no comments in it");
|
||||
assert!(s.apply(other, Edit::PasteComments { xfdf, page: 9, offset: None }).is_err(), "no such page");
|
||||
assert_eq!(s.get(other).unwrap().info.annotations.len(), 2, "a refused paste changes nothing");
|
||||
assert_eq!(s.get(id).unwrap().info.annotations.len(), 2, "the source is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() {
|
||||
let white = "1 g";
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Form date fields name months in the app's date language (#5303). Its own test binary: the
|
||||
//! names are process-wide, so other tests must not see them change, and its tests take turns.
|
||||
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
use pdfcraft_engine::dates::use_form_date_language;
|
||||
use pdfcraft_engine::js::JsRunner;
|
||||
use pdfcraft_forms::FieldValue;
|
||||
use pdfcraft_forms::af::{DateTime, Format, format_date, format_value, keystroke, parse_date};
|
||||
use pdfcraft_js::{DocInfo, Event, Limits, run};
|
||||
|
||||
/// Held by each test while it changes the process-wide date names, which start as English (a
|
||||
/// failed test may have left others).
|
||||
fn names_lock() -> MutexGuard<'static, ()> {
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
let lock = LOCK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
use_form_date_language("en");
|
||||
lock
|
||||
}
|
||||
|
||||
/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads.
|
||||
fn printd(fmt: &str, typed: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
|
||||
let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
o.value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_dates_follow_the_date_language() {
|
||||
let _names = names_lock();
|
||||
let oct18 = DateTime { y: 2026, m: 10, d: 18, hh: 0, mm: 0, ss: 0 };
|
||||
let dd_mmm_yy = Format::Date("dd-mmm-yy".into());
|
||||
// English until the app says otherwise.
|
||||
assert_eq!(format_date(oct18, "dd-mmm-yy dddd ddd mmmm"), "18-Oct-26 Sunday Sun October");
|
||||
use_form_date_language("it");
|
||||
// AFDate_FormatEx("dd-mmm-yy"), what the issue's form used.
|
||||
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-ott-26");
|
||||
assert_eq!(format_date(oct18, "dddd d mmmm yyyy, ddd"), "domenica 18 ottobre 2026, dom");
|
||||
// What it writes reads back, and so does English typing.
|
||||
assert_eq!(parse_date("18-ott-26", "dd-mmm-yy"), Some(oct18));
|
||||
assert_eq!(parse_date("18 ottobre 2026", "dd mmmm yyyy"), Some(oct18));
|
||||
assert_eq!(parse_date("18-Oct-26", "dd-mmm-yy"), Some(oct18));
|
||||
assert!(keystroke(&dd_mmm_yy, "date", "18-ott-26").is_ok());
|
||||
// util.printd writes Italian names; util.scand reads them.
|
||||
assert_eq!(printd("dd-mmm-yy", "18-ott-26"), "18-ott-26|18-ott-26");
|
||||
assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-ott-26|18-ott-26", "English typing reads too");
|
||||
assert_eq!(printd("dddd d mmmm yyyy", "domenica 18 ottobre 2026"), "domenica 18 ottobre 2026|domenica 18 ottobre 2026");
|
||||
// German short names end in a dot; they are written and read whole.
|
||||
use_form_date_language("de");
|
||||
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Okt.-26");
|
||||
assert_eq!(parse_date("18-Okt.-26", "dd-mmm-yy"), Some(oct18));
|
||||
// An unknown language is English again.
|
||||
use_form_date_language("xx");
|
||||
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Oct-26");
|
||||
assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-Oct-26|18-Oct-26");
|
||||
every_date_language_reads_back_what_it_writes();
|
||||
// Hostile typing never panics and stays quick: long, odd and mixed-script text.
|
||||
use_form_date_language("th");
|
||||
let junk = ["", " ", "ม.", "ม.ค.ม.ค.ม.ค.", "\u{0}", "1月1月1月", "tháng tháng", "Ottobre", "🙂ott🙂", "İstanbul ott 26"];
|
||||
for t in junk.iter().map(|s| s.to_string()).chain(["ม.ค.1".repeat(5000), "9".repeat(100_000)]) {
|
||||
let started = std::time::Instant::now();
|
||||
let _ = parse_date(&t, "dd-mmm-yy");
|
||||
let _ = keystroke(&dd_mmm_yy, "date", &t);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2), "{} chars took {:?}", t.len(), started.elapsed());
|
||||
}
|
||||
use_form_date_language("en");
|
||||
}
|
||||
|
||||
/// An English weekday typed before the month fills the format's weekday, so a month name that
|
||||
/// the date language shares with a weekday (Italian, Spanish, Romanian and Albanian "mar") is
|
||||
/// still read as the month: "Tue 17 Mar 2026" read as "Tue 17 Tuesday 2026" (#5303).
|
||||
#[test]
|
||||
fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
|
||||
let _names = names_lock();
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
|
||||
let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 };
|
||||
let mut bad = Vec::new();
|
||||
for language in ["it", "es", "ro", "sq"] {
|
||||
use_form_date_language(language);
|
||||
let ddd_d_mmm = "ddd d mmm yyyy";
|
||||
let typed = [
|
||||
// What the language writes, then English, then English and localized mixed.
|
||||
(ddd_d_mmm, format_date(mar17, ddd_d_mmm)),
|
||||
(ddd_d_mmm, "Tue 17 Mar 2026".into()),
|
||||
(ddd_d_mmm, "Tuesday 17 Mar 2026".into()),
|
||||
(ddd_d_mmm, "tue 17 mar 2026".into()),
|
||||
(ddd_d_mmm, format!("Tue 17 {}", format_date(mar17, "mmm yyyy"))),
|
||||
(ddd_d_mmm, format!("{} 17 Mar 2026", format_date(mar17, "ddd"))),
|
||||
("mmm d yyyy ddd", "Mar 17 2026 Tue".into()),
|
||||
("dddd d mmmm yyyy", "Tuesday 17 March 2026".into()),
|
||||
];
|
||||
for (fmt, text) in typed {
|
||||
if parse_date(&text, fmt) != Some(mar17) {
|
||||
bad.push(format!("{language} forms {fmt}: {text:?} -> {:?}", parse_date(&text, fmt)));
|
||||
}
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
if o.value != "2026-03-17" {
|
||||
bad.push(format!("{language} scand {fmt}: {text:?} -> {:?} {:?}", o.value, o.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
use_form_date_language("en");
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
|
||||
/// Every date language: what a date field writes reads back as the same date, for each date
|
||||
/// letter that writes a name, through the form path and through `util.scand`. Called from the
|
||||
/// one test, which owns the process-wide names.
|
||||
fn every_date_language_reads_back_what_it_writes() {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
|
||||
let mut bad = Vec::new();
|
||||
for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() {
|
||||
use_form_date_language(l.code);
|
||||
for m in 1..=12u32 {
|
||||
let dt = DateTime { y: 2026, m, d: 18, hh: 0, mm: 0, ss: 0 };
|
||||
for fmt in ["dd-mmm-yy", "d mmmm yyyy", "dddd, mmmm d, yyyy", "ddd d mmm yyyy", "mmm d, yyyy ddd", "yyyymmmdd"] {
|
||||
let text = format_date(dt, fmt);
|
||||
// A short month ending in a number ("Thg 1") glued to the day is ambiguous
|
||||
// ("Thg 118": January 18 or November 8): nothing can read it back.
|
||||
if fmt == "yyyymmmdd" && l.months_short.iter().any(|n| n.ends_with(|c: char| c.is_ascii_digit())) {
|
||||
continue;
|
||||
}
|
||||
if parse_date(&text, fmt).map(|p| (p.y, p.m, p.d)) != Some((2026, m, 18)) {
|
||||
bad.push(format!("{} forms {fmt}: {text:?}", l.code));
|
||||
}
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
if o.value != format!("2026-{m:02}-18") {
|
||||
bad.push(format!("{} scand {fmt}: {text:?} -> {:?} {:?}", l.code, o.value, o.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use_form_date_language("en");
|
||||
assert!(bad.is_empty(), "{} failures:\n{}", bad.len(), bad.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
|
||||
/// A form whose text fields are each `(name, font, format script)`: `/Helv` is Helvetica with
|
||||
/// WinAnsi, `/Song` a Chinese font with a Unicode CMap, which also holds Cyrillic.
|
||||
fn form(fields: &[(&str, &str, &str)]) -> Document {
|
||||
let n = fields.len();
|
||||
let first = 6;
|
||||
let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ");
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs(first)),
|
||||
format!("<< /Fields [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Song {} 0 R >> >> >>", refs(first), first + n),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
];
|
||||
for (i, (name, font, js)) in fields.iter().enumerate() {
|
||||
let js = js.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
|
||||
let y = 700 - 30 * i;
|
||||
objs.push(format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/{font} 10 Tf 0 g) /Rect [50 {y} 300 {}] /P 3 0 R /AA << /F << /S /JavaScript /JS ({js}) >> >> >>",
|
||||
y + 20
|
||||
));
|
||||
}
|
||||
objs.push("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into());
|
||||
let mut out = b"%PDF-1.7
|
||||
"
|
||||
.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
|
||||
{o}
|
||||
endobj
|
||||
",
|
||||
i + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"xref
|
||||
0 {}
|
||||
0000000000 65535 f
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
for o in offsets {
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"{o:010} 00000 n
|
||||
"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"trailer
|
||||
<< /Size {} /Root 1 0 R >>
|
||||
startxref
|
||||
{xref}
|
||||
%%EOF
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
/// The content of field `name`'s normal appearance.
|
||||
fn appearance(doc: &Document, name: &str) -> Vec<u8> {
|
||||
let all = pdfcraft_forms::fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).expect("field");
|
||||
let widget = doc.get(f.widgets[0].obj);
|
||||
let normal = widget.as_dict().and_then(|d| d.get(b"AP")).and_then(|a| doc.resolve(a).as_dict().and_then(|a| a.reference(b"N"))).expect("/AP /N");
|
||||
match &*doc.get(normal) {
|
||||
Object::Stream(s) => s.decoded().expect("decodes"),
|
||||
other => panic!("not a stream: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Unicode-CMap (UCS-2) font is given for `text`.
|
||||
fn ucs2(text: &str) -> Vec<u8> {
|
||||
text.encode_utf16().flat_map(u16::to_be_bytes).collect()
|
||||
}
|
||||
|
||||
fn contains(hay: &[u8], needle: &[u8]) -> bool {
|
||||
hay.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Fills `form`'s field `name` with 18 October 2026 through the JavaScript runner.
|
||||
fn fill(doc: &mut Document, name: &str) {
|
||||
fill_with(doc, name, "18-10-26");
|
||||
}
|
||||
|
||||
/// Fills `form`'s field `name` with `value` through the JavaScript runner.
|
||||
fn fill_with(doc: &mut Document, name: &str, value: &str) {
|
||||
let mut js = JsRunner::new(doc, "form.pdf");
|
||||
pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills");
|
||||
}
|
||||
|
||||
/// Month names in the date language that no font of the field can draw are drawn in English,
|
||||
/// never as `?` ("18-????.-26" in Russian); a font that holds them keeps them (#5303).
|
||||
#[test]
|
||||
fn date_names_no_field_font_can_draw_are_drawn_in_english() {
|
||||
let _names = names_lock();
|
||||
let date = "AFDate_FormatEx(\"dd-mmm-yy\");";
|
||||
let fields = [("helv", "Helv", date), ("song", "Song", date)];
|
||||
for (language, local) in [("ru", "окт."), ("cs", "říj"), ("ja", "10月"), ("el", "Οκτ")] {
|
||||
use_form_date_language(language);
|
||||
let mut doc = form(&fields);
|
||||
fill(&mut doc, "helv");
|
||||
fill(&mut doc, "song");
|
||||
let helv = appearance(&doc, "helv");
|
||||
assert!(contains(&helv, b"(18-Oct-26) Tj"), "{language}: {}", String::from_utf8_lossy(&helv));
|
||||
assert!(!helv.contains(&b'?'), "{language}: {}", String::from_utf8_lossy(&helv));
|
||||
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty(), "{language}");
|
||||
let song = appearance(&doc, "song");
|
||||
assert!(contains(&song, &ucs2(&format!("18-{local}-26"))), "{language} keeps its name: {song:?}");
|
||||
}
|
||||
// Names Helvetica holds are kept: Italian "ott".
|
||||
use_form_date_language("it");
|
||||
let mut doc = form(&fields);
|
||||
fill(&mut doc, "helv");
|
||||
assert!(contains(&appearance(&doc, "helv"), b"(18-ott-26) Tj"));
|
||||
use_form_date_language("en");
|
||||
}
|
||||
|
||||
/// A Format script's `util.printd` is run again with English names for a field whose fonts
|
||||
/// can't draw the date language's, and kept where they can (#5303).
|
||||
#[test]
|
||||
fn format_scripts_print_dates_in_english_where_the_field_cannot_draw_them() {
|
||||
let _names = names_lock();
|
||||
let script = "event.value = event.value ? util.printd(\"dd-mmm-yy\", util.scand(\"dd-mm-yy\", event.value)) + \" !\" : \"\";";
|
||||
let fields = [("helv", "Helv", script), ("song", "Song", script)];
|
||||
use_form_date_language("ru");
|
||||
let mut doc = form(&fields);
|
||||
fill(&mut doc, "helv");
|
||||
fill(&mut doc, "song");
|
||||
let helv = appearance(&doc, "helv");
|
||||
assert!(contains(&helv, b"(18-Oct-26 !) Tj"), "{}", String::from_utf8_lossy(&helv));
|
||||
assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv));
|
||||
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty());
|
||||
assert!(contains(&appearance(&doc, "song"), &ucs2("18-окт.-26 !")));
|
||||
// A script whose text has no English either keeps it, reported as undrawn.
|
||||
let greeting = [("hi", "Helv", "event.value = \"Привет \" + util.printd(\"mmm\", new Date(2026, 9, 18));")];
|
||||
let mut doc = form(&greeting);
|
||||
fill(&mut doc, "hi");
|
||||
assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("hi".to_string(), 'П')]);
|
||||
// The English run is the appearance's alone: scripts go on printing the date language.
|
||||
assert_eq!(printd("dd-mmm-yy", "18-окт.-26"), "18-окт.-26|18-окт.-26");
|
||||
use_form_date_language("en");
|
||||
}
|
||||
|
||||
/// A Format script's English run is kept when it ran, even where it prints the stored value
|
||||
/// again: a Helvetica field storing "18-Oct-26", printed again as `dd-mmm-yy`, showed
|
||||
/// "18-????.-26" in Russian. A script that fails keeps what it showed, reported undrawn (#5303).
|
||||
#[test]
|
||||
fn an_english_run_that_prints_the_stored_value_is_drawn() {
|
||||
let _names = names_lock();
|
||||
let script = "event.value = util.printd(\"dd-mmm-yy\", util.scand(\"dd-mmm-yy\", event.value));";
|
||||
use_form_date_language("ru");
|
||||
let mut doc = form(&[("helv", "Helv", script), ("fails", "Helv", "throw new Error(\"no\");")]);
|
||||
fill_with(&mut doc, "helv", "18-Oct-26");
|
||||
let helv = appearance(&doc, "helv");
|
||||
assert!(contains(&helv, b"(18-Oct-26) Tj"), "{}", String::from_utf8_lossy(&helv));
|
||||
assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv));
|
||||
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty());
|
||||
fill_with(&mut doc, "fails", "18-окт.-26");
|
||||
assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("fails".to_string(), 'о')]);
|
||||
use_form_date_language("en");
|
||||
}
|
||||
|
||||
/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`.
|
||||
fn scanned(fmt: &str, text: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
o.value
|
||||
}
|
||||
|
||||
/// A short weekday typed without its dot ("mar" for French "mar.", Tuesday) is the weekday where
|
||||
/// the format has one, not the English "Mar" (March) that is the only other name of its length:
|
||||
/// "mar 6 janv 2026" read as no date in French and Romanian (#5303).
|
||||
#[test]
|
||||
fn a_short_weekday_typed_without_its_dot_is_the_weekday() {
|
||||
let _names = names_lock();
|
||||
let mut bad = Vec::new();
|
||||
let cases = [
|
||||
("fr", "ddd d mmm yyyy", "Mar 6 janv 2026", "2026-01-06"),
|
||||
("fr", "ddd d mmm yyyy", "mar 17 mars 2026", "2026-03-17"),
|
||||
("fr", "mmm d yyyy ddd", "janv 6 2026 mar", "2026-01-06"),
|
||||
("fr", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"),
|
||||
("ro", "ddd d mmm yyyy", "mar 6 ian 2026", "2026-01-06"),
|
||||
("ro", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"),
|
||||
];
|
||||
for (language, fmt, text, want) in cases {
|
||||
use_form_date_language(language);
|
||||
let got = scanned(fmt, text);
|
||||
if got != want {
|
||||
bad.push(format!("{language} scand {fmt}: {text:?} -> {got}"));
|
||||
}
|
||||
let read = parse_date(text, fmt).map(|d| format!("{:04}-{:02}-{:02}", d.y, d.m, d.d));
|
||||
if read.as_deref() != Some(want) {
|
||||
bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}"));
|
||||
}
|
||||
}
|
||||
use_form_date_language("en");
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
|
||||
/// A weekday left out of a format with one ("17 mar 2026" for "ddd d mmm yyyy") lets a name the
|
||||
/// date language shares between a weekday and a month be the month: Italian, Spanish and
|
||||
/// Albanian "mar" (Tuesday, March) and Albanian "sht" (Saturday, September) were read as the
|
||||
/// weekday, leaving no month (#5303). Typed weekdays still go first.
|
||||
#[test]
|
||||
fn a_shared_name_is_the_month_when_the_weekday_is_left_out() {
|
||||
let _names = names_lock();
|
||||
let mut bad = Vec::new();
|
||||
let cases = [
|
||||
("it", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
|
||||
("es", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
|
||||
("sq", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
|
||||
("it", "ddd d mmm yyyy", "17 mar 2026", (2026, 3, 17)),
|
||||
("it", "dddd, mmmm d, yyyy", "mar 17, 2026", (2026, 3, 17)),
|
||||
("it", "dddd, mmm d, yyyy", "mar 17 2026", (2026, 3, 17)),
|
||||
("sq", "ddd d mmm yyyy", "5 sht 2026", (2026, 9, 5)),
|
||||
("it", "ddd d mmm yyyy", "mar 17 Mar 2026", (2026, 3, 17)),
|
||||
("it", "ddd d mmm yyyy", "mar 17 mar 2026", (2026, 3, 17)),
|
||||
];
|
||||
for (language, fmt, text, want) in cases {
|
||||
use_form_date_language(language);
|
||||
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
|
||||
if read != Some(want) {
|
||||
bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}"));
|
||||
}
|
||||
if keystroke(&Format::Date(fmt.into()), "date", text).is_err() {
|
||||
bad.push(format!("{language} keystroke {fmt}: {text:?} refused"));
|
||||
}
|
||||
}
|
||||
use_form_date_language("en");
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
|
||||
/// Turkish names typed in capitals are read: lower-casing letter by letter made "MAYIS" "mayis"
|
||||
/// (not "mayıs"), "SALI" "sali" and "EKİM" "eki̇m" (an i with a dot above), so no name matched.
|
||||
/// Dotted, dotless and capital i are one letter when names are read (#5303).
|
||||
#[test]
|
||||
fn turkish_names_typed_in_capitals_are_read() {
|
||||
let _names = names_lock();
|
||||
use_form_date_language("tr");
|
||||
let mut bad = Vec::new();
|
||||
for (fmt, text, want) in [
|
||||
("d mmmm yyyy", "1 MAYIS 2026", (2026, 5, 1)),
|
||||
("d mmmm yyyy", "1 EKİM 2026", (2026, 10, 1)),
|
||||
("d mmmm yyyy", "1 Ekim 2026", (2026, 10, 1)),
|
||||
("d mmm yyyy", "1 EKİ 2026", (2026, 10, 1)),
|
||||
("dddd d mmmm yyyy", "SALI 6 OCAK 2026", (2026, 1, 6)),
|
||||
("dddd d mmmm yyyy", "Salı 6 Ocak 2026", (2026, 1, 6)),
|
||||
] {
|
||||
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
|
||||
if read != Some(want) {
|
||||
bad.push(format!("forms {fmt}: {text:?} -> {read:?}"));
|
||||
}
|
||||
let (y, m, d) = want;
|
||||
let got = scanned(fmt, text);
|
||||
if got != format!("{y:04}-{m:02}-{d:02}") {
|
||||
bad.push(format!("scand {fmt}: {text:?} -> {got}"));
|
||||
}
|
||||
}
|
||||
// English is read as before, capitals and all.
|
||||
use_form_date_language("en");
|
||||
for (fmt, text) in [("d mmmm yyyy", "1 APRIL 2026"), ("dddd d mmmm yyyy", "FRIDAY 1 MAY 2026"), ("d mmm yyyy", "1 MAY 2026")] {
|
||||
if parse_date(text, fmt).is_none() || scanned(fmt, text) == "null" {
|
||||
bad.push(format!("en {fmt}: {text:?}"));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
|
||||
/// #5464 review: Greek names typed in capitals end in a capital Σ, which is the final ς of the
|
||||
/// name ("ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος"); they read as they did before names were folded.
|
||||
#[test]
|
||||
fn greek_names_typed_in_capitals_are_read() {
|
||||
let _names = names_lock();
|
||||
use_form_date_language("el");
|
||||
let mut bad = Vec::new();
|
||||
for (fmt, text, want) in [
|
||||
("d mmmm yyyy", "1 ΙΑΝΟΥΆΡΙΟΣ 2026", (2026, 1, 1)),
|
||||
("d mmmm yyyy", "1 Ιανουάριος 2026", (2026, 1, 1)),
|
||||
("d mmmm yyyy", "1 ιανουάριοσ 2026", (2026, 1, 1)),
|
||||
("mmmm d, yyyy", "ΜΆΙΟΣ 5, 2026", (2026, 5, 5)),
|
||||
("dddd, mmmm d, yyyy", "FRIDAY, ΙΑΝΟΥΆΡΙΟΣ 2, 2026", (2026, 1, 2)),
|
||||
] {
|
||||
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
|
||||
if read != Some(want) {
|
||||
bad.push(format!("forms {fmt}: {text:?} -> {read:?}"));
|
||||
}
|
||||
let (y, m, d) = want;
|
||||
let got = scanned(fmt, text);
|
||||
if got != format!("{y:04}-{m:02}-{d:02}") {
|
||||
bad.push(format!("scand {fmt}: {text:?} -> {got}"));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ mod script;
|
||||
#[cfg(test)]
|
||||
use calc::{Notation, eval_notation, notation_name, notation_tokens, unique};
|
||||
pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation};
|
||||
pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate};
|
||||
pub use format::{
|
||||
DATE_PRESETS, DateNames, DateTime, ENGLISH_DATE_NAMES, TIME_PRESETS, date_names, format_date, format_date_in, format_value, format_value_in,
|
||||
js_number, keystroke, make_number, parse_date, set_date_names, validate,
|
||||
};
|
||||
use script::{Arg, call};
|
||||
pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js};
|
||||
|
||||
|
||||
+191
-11
@@ -89,6 +89,165 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun
|
||||
|
||||
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/// The month and weekday names `mmmm`, `mmm`, `dddd` and `ddd` write (weekdays from Sunday), as
|
||||
/// a localized Acrobat writes them in its own language (#5303).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DateNames {
|
||||
pub months: [&'static str; 12],
|
||||
pub months_short: [&'static str; 12],
|
||||
pub days: [&'static str; 7],
|
||||
pub days_short: [&'static str; 7],
|
||||
}
|
||||
|
||||
/// English, the names until the app sets others.
|
||||
pub const ENGLISH_DATE_NAMES: DateNames = DateNames {
|
||||
months: MONTHS,
|
||||
months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
days: DAYS,
|
||||
days_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
};
|
||||
|
||||
static DATE_NAMES: std::sync::RwLock<DateNames> = std::sync::RwLock::new(ENGLISH_DATE_NAMES);
|
||||
|
||||
/// Sets the names date formats write from now on (the app's date language); typed dates are read
|
||||
/// in them and in English.
|
||||
pub fn set_date_names(names: DateNames) {
|
||||
*DATE_NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = names;
|
||||
}
|
||||
|
||||
/// The names date formats write.
|
||||
pub fn date_names() -> DateNames {
|
||||
*DATE_NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// `text` with the month and weekday names of `lists` (in the order months, short months,
|
||||
/// weekdays, short weekdays) written as their English names, so a parser that reads English
|
||||
/// reads them. The longest name wins at each place, case ignored, and only a whole name: one
|
||||
/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold
|
||||
/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a
|
||||
/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian
|
||||
/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are
|
||||
/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a
|
||||
/// localized one does.
|
||||
fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String {
|
||||
const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS];
|
||||
// No date is this long: hostile text is left as it is rather than scanned name by name.
|
||||
if text.len() > MAX_DATE_TEXT_BYTES {
|
||||
return text.to_string();
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let typed_english: [&[&str]; 4] = [&MONTHS, &ENGLISH_DATE_NAMES.months_short, &DAYS, &ENGLISH_DATE_NAMES.days_short];
|
||||
// Each name as folded chars, whether it is a month, and its English name.
|
||||
let mut names: Vec<(Vec<String>, bool, &str)> = Vec::new();
|
||||
for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() {
|
||||
for (name, english) in list.iter().zip(en.iter()) {
|
||||
// A short name ending in a dot ("mar.") is also read typed without it, as the same
|
||||
// kind of name, so that where it equals another ("Mar", March) the format decides.
|
||||
let bare = name.trim_end_matches('.');
|
||||
for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) {
|
||||
let n: Vec<String> = name.chars().map(fold).collect();
|
||||
if !n.is_empty() && n.len() <= MAX_NAME_CHARS {
|
||||
names.push((n, k % 4 < 2, english));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len()));
|
||||
// The format's name fields in order: true for a month, false for a weekday.
|
||||
let f: Vec<char> = fmt.chars().collect();
|
||||
let mut expected = Vec::new();
|
||||
let mut j = 0;
|
||||
while let Some(&c) = f.get(j) {
|
||||
let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1);
|
||||
if run >= 3 && (c == 'm' || c == 'd') {
|
||||
expected.push(c == 'm');
|
||||
}
|
||||
j = j.saturating_add(run);
|
||||
}
|
||||
let mut slot = 0;
|
||||
// A name's first letter may not continue a word; its last may not run on into a letter, nor
|
||||
// (a digit) into a number.
|
||||
let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic());
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let fits = |n: &Vec<String>| {
|
||||
let end = i.saturating_add(n.len());
|
||||
let first = n.first().and_then(|e| e.chars().next());
|
||||
let last = n.last().and_then(|e| e.chars().next());
|
||||
chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned()))
|
||||
&& !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p))))
|
||||
&& !match last {
|
||||
Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit),
|
||||
Some(l) if l.is_alphabetic() => letter(chars.get(end)),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len());
|
||||
let hit = longest.and_then(|len| {
|
||||
let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n));
|
||||
let want = expected.get(slot).copied();
|
||||
let all: Vec<_> = same.by_ref().collect();
|
||||
all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied()
|
||||
});
|
||||
match hit {
|
||||
Some((n, month, english)) => {
|
||||
if expected.get(slot) == Some(month) {
|
||||
slot += 1;
|
||||
}
|
||||
// Apart from what touches it ("10月18"), but no space added at either end.
|
||||
if out.ends_with(char::is_alphanumeric) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(english);
|
||||
i = i.saturating_add(n.len());
|
||||
if chars.get(i).is_some_and(|c| c.is_alphanumeric()) {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless
|
||||
/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are
|
||||
/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i,
|
||||
/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which
|
||||
/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so
|
||||
/// positions in the text stay aligned.
|
||||
fn fold(c: char) -> String {
|
||||
match c {
|
||||
'I' | 'ı' | 'İ' => "i".to_string(),
|
||||
'ς' => "σ".to_string(),
|
||||
_ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` [`fold`]ed char by char.
|
||||
fn folded(s: &str) -> String {
|
||||
s.chars().map(fold).collect()
|
||||
}
|
||||
|
||||
/// Typed dates longer than this are not searched for names.
|
||||
const MAX_DATE_TEXT_BYTES: usize = 1024;
|
||||
|
||||
/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap).
|
||||
const MAX_NAME_CHARS: usize = 32;
|
||||
|
||||
/// The month (1 to 12) a typed word names: a start of at least three letters of a month's name,
|
||||
/// or its whole short name, in `names` or in English.
|
||||
fn month_named(word: &str, names: &DateNames) -> Option<u32> {
|
||||
let w = folded(word);
|
||||
let starts = |list: &[&str; 12]| list.iter().position(|m| w.chars().count() >= 3 && folded(m).starts_with(&w));
|
||||
let whole = |list: &[&str; 12]| list.iter().position(|m| folded(m).trim_end_matches('.') == w);
|
||||
starts(&names.months).or_else(|| whole(&names.months_short)).or_else(|| starts(&MONTHS)).map(|i| i as u32 + 1)
|
||||
}
|
||||
|
||||
/// A date and time read from text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DateTime {
|
||||
@@ -202,7 +361,20 @@ fn order(fmt: &str) -> Vec<Part> {
|
||||
/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any
|
||||
/// separators, month names, two-digit years). `None` when it isn't a valid date.
|
||||
pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
let t = text.trim();
|
||||
let names = date_names();
|
||||
let lists: [&[&str]; 4] = [&names.months, &names.months_short, &names.days, &names.days_short];
|
||||
// Weekdays are shown, not typed (see `order`): where the format has one but none was typed, a
|
||||
// name the language shares between a weekday and a month (Italian "mar") is the month, as
|
||||
// the names are read again with no weekday expected (#5303).
|
||||
read_date(&english_names(text, fmt, lists), fmt, &names).or_else(|| {
|
||||
let no_weekday = fmt.replace("dddd", " ").replace("ddd", " ");
|
||||
(no_weekday != fmt).then(|| read_date(&english_names(text, &no_weekday, lists), fmt, &names)).flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// [`parse_date`] of `english`, typed text whose month and weekday names are English.
|
||||
fn read_date(english: &str, fmt: &str, names: &DateNames) -> Option<DateTime> {
|
||||
let t = english.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -223,10 +395,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
}
|
||||
if cur.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
nums.push_back(std::mem::take(cur));
|
||||
} else if cur.len() >= 3
|
||||
&& let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase()))
|
||||
{
|
||||
*month = Some(i as u32 + 1);
|
||||
} else if let Some(m) = month_named(cur, names) {
|
||||
*month = Some(m);
|
||||
}
|
||||
cur.clear();
|
||||
};
|
||||
@@ -337,6 +507,11 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
|
||||
/// Format a date/time with Acrobat's date format letters.
|
||||
pub fn format_date(dt: DateTime, fmt: &str) -> String {
|
||||
format_date_in(dt, fmt, &date_names())
|
||||
}
|
||||
|
||||
/// [`format_date`] with month and weekday names from `names`.
|
||||
pub fn format_date_in(dt: DateTime, fmt: &str, names: &DateNames) -> String {
|
||||
let chars: Vec<char> = fmt.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
@@ -354,9 +529,9 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String {
|
||||
('y', _) => {
|
||||
let _ = write!(out, "{:02}", dt.y.rem_euclid(100));
|
||||
}
|
||||
('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) {
|
||||
Some(name) if n >= 4 => out.push_str(name),
|
||||
Some(name) => out.push_str(name.get(..3).unwrap_or(name)),
|
||||
('m', 3..) => match (dt.m as usize).checked_sub(1).and_then(|i| names.months.get(i).zip(names.months_short.get(i))) {
|
||||
Some((name, _)) if n >= 4 => out.push_str(name),
|
||||
Some((_, short)) => out.push_str(short),
|
||||
None => {
|
||||
let _ = write!(out, "{}", dt.m);
|
||||
}
|
||||
@@ -368,8 +543,8 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String {
|
||||
let _ = write!(out, "{}", dt.m);
|
||||
}
|
||||
('d', 3..) => {
|
||||
let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default();
|
||||
out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) });
|
||||
let list = if n >= 4 { &names.days } else { &names.days_short };
|
||||
out.push_str(list.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default());
|
||||
}
|
||||
('d', 2) => {
|
||||
let _ = write!(out, "{:02}", dt.d);
|
||||
@@ -451,6 +626,11 @@ fn apply_mask(mask: &str, text: &str) -> Option<String> {
|
||||
|
||||
/// The text shown for a value (the Format event).
|
||||
pub fn format_value(f: &Format, value: &str) -> String {
|
||||
format_value_in(f, value, &date_names())
|
||||
}
|
||||
|
||||
/// [`format_value`] with month and weekday names from `names`.
|
||||
pub fn format_value_in(f: &Format, value: &str, names: &DateNames) -> String {
|
||||
if value.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
@@ -479,7 +659,7 @@ pub fn format_value(f: &Format, value: &str) -> String {
|
||||
),
|
||||
None => value.to_string(),
|
||||
},
|
||||
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)),
|
||||
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date_in(dt, fmt, names)),
|
||||
Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()),
|
||||
Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()),
|
||||
}
|
||||
|
||||
@@ -739,8 +739,18 @@ pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option<Overflow>
|
||||
field_appearance_fit(doc, f, w, &f.value, true).2
|
||||
}
|
||||
|
||||
/// Whether `text` draws in widget `wd` of `f` with no `?`: in the field's `/DR` font when that
|
||||
/// holds it, as [`field_appearance_fit`] picks, or else in the Helvetica (WinAnsi) stand-in.
|
||||
fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool {
|
||||
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
|
||||
let enc = dr_font(doc, &da.font).map(|(_, enc)| enc).filter(|enc| enc.can_encode(text)).unwrap_or(FontText::WinAnsi);
|
||||
enc.first_unencodable(text).is_none()
|
||||
}
|
||||
|
||||
/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box.
|
||||
fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>, Option<Overflow>) {
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
// The Format event: what is shown, not what is stored.
|
||||
let formatted: Vec<String>;
|
||||
let values = if format
|
||||
@@ -748,13 +758,16 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
|
||||
&& values.len() == 1
|
||||
&& f.actions.format != crate::af::Format::None
|
||||
{
|
||||
formatted = vec![crate::af::format_value(&f.actions.format, &values[0])];
|
||||
let shown = crate::af::format_value(&f.actions.format, &values[0]);
|
||||
// Month and weekday names in the date language that no font here can draw ("окт." in
|
||||
// Helvetica) are drawn in English rather than as `?` (#5303).
|
||||
let english = || crate::af::format_value_in(&f.actions.format, &values[0], &crate::af::ENGLISH_DATE_NAMES);
|
||||
let dated = matches!(f.actions.format, crate::af::Format::Date(_) | crate::af::Format::Time(_));
|
||||
formatted = vec![if !dated || draws(doc, f, &wd, &shown) { shown } else { english() }];
|
||||
&formatted[..]
|
||||
} else {
|
||||
values
|
||||
};
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
|
||||
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
|
||||
let (width, height) = (place.layout_w, place.layout_h);
|
||||
|
||||
@@ -60,6 +60,21 @@ pub struct ScriptResult {
|
||||
pub trait Scripts {
|
||||
/// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form.
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult;
|
||||
|
||||
/// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`)
|
||||
/// because no font of the field can draw the date language's (#5303); what it logs was
|
||||
/// already logged by the first run. `None` when it can't: no English dates here, or the
|
||||
/// script failed. What it returns is what the script made, even when that is the value.
|
||||
fn rerun_with_english_dates(
|
||||
&mut self,
|
||||
_event: FieldEvent,
|
||||
_script: &str,
|
||||
_target: &Field,
|
||||
_value: &str,
|
||||
_fields: &[Field],
|
||||
) -> Option<ScriptResult> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// No script engine (or JavaScript turned off): scripts change nothing.
|
||||
@@ -98,6 +113,13 @@ pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dy
|
||||
Some(r.value)
|
||||
}
|
||||
|
||||
/// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date
|
||||
/// language's; `None` when the field has no Format script or the English run didn't happen.
|
||||
pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option<String> {
|
||||
let js = f.actions.scripts.format.as_ref()?;
|
||||
scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc)).map(|r| r.value)
|
||||
}
|
||||
|
||||
/// Apply script changes to other fields (values written without re-running their scripts;
|
||||
/// read-only, required and visibility as flags). `except` is the event's own field, whose value
|
||||
/// the event sets.
|
||||
|
||||
@@ -433,9 +433,24 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
|
||||
};
|
||||
let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD);
|
||||
let mut undrawn = false;
|
||||
// The Format script again with English month and weekday names, once, for widgets whose
|
||||
// fonts can't draw the date language's (#5303).
|
||||
let mut english: Option<Option<String>> = None;
|
||||
for w in &f.widgets {
|
||||
let (stream, missing) = match &shown {
|
||||
Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false),
|
||||
Some(s) => {
|
||||
let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false);
|
||||
// Taken when the English run happened and draws whole, even where it is the
|
||||
// stored value ("18-Oct-26" printed again from itself).
|
||||
let again = match (&drawn.1, values) {
|
||||
(Some(_), [one]) => english.get_or_insert_with(|| scripting::formatted_in_english(doc, f, one, scripts)).as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
match again.map(|e| appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(e), false)) {
|
||||
Some(english) if english.1.is_none() => english,
|
||||
_ => drawn,
|
||||
}
|
||||
}
|
||||
None => appearance::field_appearance_drawn(doc, f, w, values, true),
|
||||
};
|
||||
undrawn |= missing.is_some();
|
||||
|
||||
+186
-9
@@ -134,6 +134,177 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun
|
||||
|
||||
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/// Month and weekday names (weekdays from Sunday): full, then short.
|
||||
type Names = ([&'static str; 12], [&'static str; 12], [&'static str; 7], [&'static str; 7]);
|
||||
|
||||
const ENGLISH: Names = (
|
||||
MONTHS,
|
||||
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
DAYS,
|
||||
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
);
|
||||
|
||||
static NAMES: std::sync::RwLock<Names> = std::sync::RwLock::new(ENGLISH);
|
||||
|
||||
/// Sets the month and weekday names `util.printd` writes for `mmmm`, `mmm`, `dddd` and `ddd`
|
||||
/// (weekdays from Sunday), as a localized Acrobat writes its own language's (#5303).
|
||||
/// `util.scand` reads them as well as English.
|
||||
pub fn set_date_names(months: [&'static str; 12], months_short: [&'static str; 12], days: [&'static str; 7], days_short: [&'static str; 7]) {
|
||||
*NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = (months, months_short, days, days_short);
|
||||
}
|
||||
|
||||
/// `text` with the month and weekday names of `lists` (in the order months, short months,
|
||||
/// weekdays, short weekdays) written as their English names, so a parser that reads English
|
||||
/// reads them. The longest name wins at each place, case ignored, and only a whole name: one
|
||||
/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold
|
||||
/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a
|
||||
/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian
|
||||
/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are
|
||||
/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a
|
||||
/// localized one does.
|
||||
fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String {
|
||||
const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS];
|
||||
// No date is this long: hostile text is left as it is rather than scanned name by name.
|
||||
if text.len() > MAX_DATE_TEXT_BYTES {
|
||||
return text.to_string();
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let typed_english: [&[&str]; 4] = [&ENGLISH.0, &ENGLISH.1, &ENGLISH.2, &ENGLISH.3];
|
||||
// Each name as folded chars, whether it is a month, and its English name.
|
||||
let mut names: Vec<(Vec<String>, bool, &str)> = Vec::new();
|
||||
for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() {
|
||||
for (name, english) in list.iter().zip(en.iter()) {
|
||||
// A short name ending in a dot ("mar.") is also read typed without it, as the same
|
||||
// kind of name, so that where it equals another ("Mar", March) the format decides.
|
||||
let bare = name.trim_end_matches('.');
|
||||
for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) {
|
||||
let n: Vec<String> = name.chars().map(fold).collect();
|
||||
if !n.is_empty() && n.len() <= MAX_NAME_CHARS {
|
||||
names.push((n, k % 4 < 2, english));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len()));
|
||||
// The format's name fields in order: true for a month, false for a weekday.
|
||||
let f: Vec<char> = fmt.chars().collect();
|
||||
let mut expected = Vec::new();
|
||||
let mut j = 0;
|
||||
while let Some(&c) = f.get(j) {
|
||||
let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1);
|
||||
if run >= 3 && (c == 'm' || c == 'd') {
|
||||
expected.push(c == 'm');
|
||||
}
|
||||
j = j.saturating_add(run);
|
||||
}
|
||||
let mut slot = 0;
|
||||
// A name's first letter may not continue a word; its last may not run on into a letter, nor
|
||||
// (a digit) into a number.
|
||||
let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic());
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let fits = |n: &Vec<String>| {
|
||||
let end = i.saturating_add(n.len());
|
||||
let first = n.first().and_then(|e| e.chars().next());
|
||||
let last = n.last().and_then(|e| e.chars().next());
|
||||
chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned()))
|
||||
&& !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p))))
|
||||
&& !match last {
|
||||
Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit),
|
||||
Some(l) if l.is_alphabetic() => letter(chars.get(end)),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len());
|
||||
let hit = longest.and_then(|len| {
|
||||
let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n));
|
||||
let want = expected.get(slot).copied();
|
||||
let all: Vec<_> = same.by_ref().collect();
|
||||
all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied()
|
||||
});
|
||||
match hit {
|
||||
Some((n, month, english)) => {
|
||||
if expected.get(slot) == Some(month) {
|
||||
slot += 1;
|
||||
}
|
||||
// Apart from what touches it ("10月18"), but no space added at either end.
|
||||
if out.ends_with(char::is_alphanumeric) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(english);
|
||||
i = i.saturating_add(n.len());
|
||||
if chars.get(i).is_some_and(|c| c.is_alphanumeric()) {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless
|
||||
/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are
|
||||
/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i,
|
||||
/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which
|
||||
/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so
|
||||
/// positions in the text stay aligned.
|
||||
fn fold(c: char) -> String {
|
||||
match c {
|
||||
'I' | 'ı' | 'İ' => "i".to_string(),
|
||||
'ς' => "σ".to_string(),
|
||||
_ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` [`fold`]ed char by char.
|
||||
fn folded(s: &str) -> String {
|
||||
s.chars().map(fold).collect()
|
||||
}
|
||||
|
||||
/// Typed dates longer than this are not searched for names.
|
||||
const MAX_DATE_TEXT_BYTES: usize = 1024;
|
||||
|
||||
/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap).
|
||||
const MAX_NAME_CHARS: usize = 32;
|
||||
|
||||
fn names() -> Names {
|
||||
*NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// Whether `util.printd` on this thread writes English names, whatever the date language.
|
||||
static PRINTD_ENGLISH: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Runs `run` with `util.printd` writing English month and weekday names when `english` (scripts
|
||||
/// it starts carry this to their own thread): for an appearance whose fonts can't draw the date
|
||||
/// language's names, which would come out as `?` (#5303). `util.scand` still reads both.
|
||||
pub fn with_english_printd<R>(english: bool, run: impl FnOnce() -> R) -> R {
|
||||
struct Restore(bool);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
PRINTD_ENGLISH.with(|e| e.set(self.0));
|
||||
}
|
||||
}
|
||||
let _restore = Restore(PRINTD_ENGLISH.with(|e| e.replace(english)));
|
||||
run()
|
||||
}
|
||||
|
||||
/// Whether [`with_english_printd`] is on for this thread.
|
||||
pub(crate) fn printd_english() -> bool {
|
||||
PRINTD_ENGLISH.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The names `util.printd` writes.
|
||||
fn printd_names() -> Names {
|
||||
if printd_english() { ENGLISH } else { names() }
|
||||
}
|
||||
|
||||
/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt,
|
||||
/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt).
|
||||
pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
@@ -161,6 +332,7 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu
|
||||
let (y, mo, day, wd, h, mi, sec) =
|
||||
(part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?);
|
||||
let mut out = String::new();
|
||||
let (months, months_short, days, days_short) = printd_names();
|
||||
let b = fmt.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
@@ -186,13 +358,13 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu
|
||||
}
|
||||
}
|
||||
b'm' => match run {
|
||||
r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]),
|
||||
3 => out.push_str(&MONTHS[mo as usize % 12][..3]),
|
||||
r if r >= 4 => out.push_str(months.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()),
|
||||
3 => out.push_str(months_short.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()),
|
||||
n => push_num(&mut out, mo + 1, n),
|
||||
},
|
||||
b'd' => match run {
|
||||
r if r >= 4 => out.push_str(DAYS[wd as usize % 7]),
|
||||
3 => out.push_str(&DAYS[wd as usize % 7][..3]),
|
||||
r if r >= 4 => out.push_str(days.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()),
|
||||
3 => out.push_str(days_short.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()),
|
||||
n => push_num(&mut out, day, n),
|
||||
},
|
||||
b'H' => push_num(&mut out, h, run),
|
||||
@@ -435,11 +607,13 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option<Scanned> {
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => {
|
||||
v.mo = Some(named(&MONTHS, l)?.saturating_add(1));
|
||||
let (months, months_short, ..) = names();
|
||||
v.mo = Some(named(&months, l).or_else(|| named(&months_short, l)).or_else(|| named(&MONTHS, l))?.saturating_add(1));
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => {
|
||||
named(&DAYS, l)?;
|
||||
let (.., days, days_short) = names();
|
||||
named(&days, l).or_else(|| named(&days_short, l)).or_else(|| named(&DAYS, l))?;
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => {
|
||||
@@ -509,10 +683,11 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option<Scanned> {
|
||||
|
||||
/// The index of the name `word` is, in full or as its first three letters, in any case.
|
||||
fn named(names: &[&str], word: &str) -> Option<u32> {
|
||||
let word = word.to_lowercase();
|
||||
let word = folded(word);
|
||||
let i = names.iter().position(|n| {
|
||||
let n = n.to_lowercase();
|
||||
word == n || n.get(..3) == Some(word.as_str())
|
||||
let n = folded(n);
|
||||
// A short name may end in a dot ("Okt."); three letters may be cut from any name.
|
||||
word == n || word == n.trim_end_matches('.') || n.chars().take(3).collect::<String>() == word
|
||||
})?;
|
||||
u32::try_from(i).ok()
|
||||
}
|
||||
@@ -524,6 +699,8 @@ pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResul
|
||||
return Ok(JsValue::null());
|
||||
};
|
||||
let fmt = text(&arg(args, 0), ctx)?;
|
||||
let (months, months_short, days, days_short) = names();
|
||||
let typed = english_names(&typed, &fmt, [&months, &months_short, &days, &days_short]);
|
||||
let date = JsDate::new(ctx);
|
||||
let this_year = date.get_full_year(ctx)?.to_number(ctx)?;
|
||||
let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 };
|
||||
|
||||
@@ -44,6 +44,7 @@ mod run;
|
||||
#[cfg(test)]
|
||||
pub(crate) use format::PRINTD_SCANNED;
|
||||
use format::{printd, printf, printx, scand};
|
||||
pub use format::{set_date_names, with_english_printd};
|
||||
pub(crate) use globals::menu_item;
|
||||
use globals::platform;
|
||||
use host::{arg, as_number, error, function, s, text};
|
||||
|
||||
@@ -129,11 +129,13 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<Fi
|
||||
let (kept, skipped) = (&kept, &skipped);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The script's thread writes dates as this one asked (`with_english_printd`).
|
||||
let english = crate::format::printd_english();
|
||||
std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfcraft-js".into())
|
||||
.stack_size(SCRIPT_STACK)
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits));
|
||||
.spawn_scoped(scope, || crate::format::with_english_printd(english, || run_here(script, event, doc, fields, kept, skipped, limits)));
|
||||
match spawned {
|
||||
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
|
||||
Err(e) => failed(format!("the script engine could not start: {e}")),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -108,7 +108,12 @@ 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],
|
||||
fill_text_border: 0.0,
|
||||
fill_text_border_color: None,
|
||||
fill_text_border_shape: pdfcraft_engine::BorderShape::Rectangle,
|
||||
fill_text_default: fill_sign::TextBorder::NONE,
|
||||
dialog: None,
|
||||
updates: updates::Updates::default(),
|
||||
printer_source: None,
|
||||
@@ -128,6 +133,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,8 +233,11 @@ 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,
|
||||
comment_clipboard: None,
|
||||
grid_drop: None,
|
||||
last_snapshot: None,
|
||||
last_copied_image: None,
|
||||
@@ -448,6 +457,9 @@ impl eframe::App for PdfCraftApp {
|
||||
// Notices raised outside `ui` (opened files, OS events, the control channel) translate too.
|
||||
let lang = i18n::Lang::from_pref(&self.language);
|
||||
i18n::set_current(lang);
|
||||
// Form date fields name months in the date language (Preferences ▸ Date format), which
|
||||
// follows the interface language until one is picked (#5303).
|
||||
pdfcraft_engine::dates::use_form_date_language(self.session.date_language().unwrap_or(lang.code()));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(worker) = &self.open_worker {
|
||||
@@ -610,6 +622,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"))]
|
||||
|
||||
@@ -11,7 +11,7 @@ pub(crate) fn print_refused() -> &'static str {
|
||||
}
|
||||
|
||||
enum ClipboardPaste {
|
||||
Text,
|
||||
Text(String),
|
||||
Image(std::sync::Arc<egui::ColorImage>),
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ impl PdfCraftApp {
|
||||
false
|
||||
}
|
||||
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field
|
||||
/// has focus). With no text selected, ⌘C copies the selected comments, each with its replies.
|
||||
pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let pastes: Vec<_> = ctx.input(|input| {
|
||||
@@ -35,7 +36,7 @@ impl PdfCraftApp {
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
egui::Event::Paste(_) => Some(ClipboardPaste::Text),
|
||||
egui::Event::Paste(text) => Some(ClipboardPaste::Text(text.clone())),
|
||||
egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())),
|
||||
_ => None,
|
||||
})
|
||||
@@ -52,7 +53,9 @@ impl PdfCraftApp {
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
|
||||
let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
|
||||
|
||||
let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty());
|
||||
if copy
|
||||
&& (text_selected || !self.copy_selected_comments(i))
|
||||
&& !self.copy_selected_text(i, ctx)
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
@@ -89,16 +92,21 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, images_allowed: bool) {
|
||||
/// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so
|
||||
/// pictures and copied comments land on the page.
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, on_canvas: bool) {
|
||||
let page = self.views.get(view).map_or(0, |view| view.current);
|
||||
for paste in pastes {
|
||||
match paste {
|
||||
ClipboardPaste::Text => {
|
||||
ClipboardPaste::Text(text) => {
|
||||
// Text fields may own the paste; the canvas cache still observes its contents.
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = None;
|
||||
if on_canvas {
|
||||
self.paste_comment_text(view, text);
|
||||
}
|
||||
}
|
||||
ClipboardPaste::Image(image) if images_allowed => {
|
||||
ClipboardPaste::Image(image) if on_canvas => {
|
||||
self.paste_received_image(page, &image);
|
||||
}
|
||||
ClipboardPaste::Image(_) => {}
|
||||
@@ -106,6 +114,82 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A text pasted onto the canvas: XFDF with comments is pasted as comments. Copied comments
|
||||
/// come from the system clipboard when it holds them, from the app's own only when it holds
|
||||
/// nothing legible (a word copied in another app since is that word, not the comments copied
|
||||
/// before it).
|
||||
fn paste_comment_text(&mut self, i: usize, text: String) {
|
||||
if pdfcraft_engine::is_xfdf_comments(&text) {
|
||||
self.paste_comments(i, Some(text), None);
|
||||
} else if text.trim().is_empty() && self.comment_clipboard.is_some() {
|
||||
self.paste_comments(i, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the selected comments of tab `i` as XFDF, each with its replies: into the app's
|
||||
/// clipboard (for ⌘V in any tab or window) and onto the system clipboard as text. False
|
||||
/// when nothing is selected.
|
||||
pub fn copy_selected_comments(&mut self, i: usize) -> bool {
|
||||
let Some(view) = self.views.get(i) else { return false };
|
||||
let keys = view.comments.selection();
|
||||
if keys.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let id = view.id;
|
||||
match self.session.export_comments(id, &keys) {
|
||||
Ok(xfdf) => {
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.copy_text(xfdf.clone());
|
||||
}
|
||||
self.comment_clipboard = Some(CommentClip { xfdf, count: keys.len() });
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.notify_error(e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste copied comments (`text`, XFDF from the system clipboard, or else the app's
|
||||
/// clipboard) into tab `i`. With `at` (a page and a user-space point, from the page's menu)
|
||||
/// the bundle is placed at once, centred there; otherwise it hangs on the pointer as one
|
||||
/// bundle until a click places it (`Gesture::Place`), so a copy never lands on its original
|
||||
/// and a bundle from a large page goes where it fits on a small one. The copies are selected
|
||||
/// once placed (see `apply_edit`).
|
||||
pub fn paste_comments(&mut self, i: usize, text: Option<String>, at: Option<(usize, [f64; 2])>) -> bool {
|
||||
let Some(xfdf) = text.or_else(|| self.comment_clipboard.as_ref().map(|c| c.xfdf.clone())) else {
|
||||
self.notify_tr("Copy comments first");
|
||||
return false;
|
||||
};
|
||||
let Some(view) = self.views.get(i) else { return false };
|
||||
let id = view.id;
|
||||
let Some(doc) = self.session.get(id) else { return false };
|
||||
if !doc.allows_annotation() {
|
||||
self.notify_tr("The document's security settings don't allow this change");
|
||||
return false;
|
||||
}
|
||||
let count = pdfcraft_engine::comment_count(&xfdf);
|
||||
let Some(bounds) = pdfcraft_engine::comments_bounds(&xfdf).filter(|_| count > 0) else {
|
||||
self.notify_tr("The clipboard holds no comments");
|
||||
return false;
|
||||
};
|
||||
match at {
|
||||
Some((page, [x, y])) => {
|
||||
let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0];
|
||||
self.apply_edit(pdfcraft_engine::Edit::PasteComments { xfdf, page, offset: Some(offset) })
|
||||
}
|
||||
None => {
|
||||
let Some(view) = self.views.get_mut(i) else { return false };
|
||||
view.comments.clear_selection();
|
||||
let outlines = pdfcraft_engine::comment_outlines(&xfdf);
|
||||
view.comments.gesture = Some(comments::Gesture::Place { xfdf, bounds, count, outlines });
|
||||
self.notify_tr("Click where the copied comments go; Escape lets them go");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared permission gate for copying selected page text from menus and keyboard events.
|
||||
/// After Select All of the document (#2639), pages whose text wasn't read for display are read
|
||||
/// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done.
|
||||
|
||||
@@ -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;
|
||||
@@ -386,7 +393,7 @@ impl PdfCraftApp {
|
||||
let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?;
|
||||
let (p, i): (usize, usize) =
|
||||
(p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?);
|
||||
v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.select_only((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.reveal = true;
|
||||
}
|
||||
// `--combine-view list`: Combine files as thumbnails (grid) or as the table (list).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,9 @@ impl PdfCraftApp {
|
||||
"ui_scale": self.ui_scale,
|
||||
"flatten_fill_sign": self.flatten_fill_sign_on_save,
|
||||
"fill_text_size": self.fill_text_size,
|
||||
"fill_text_border": self.fill_text_default.width,
|
||||
"fill_text_border_color": self.fill_text_default.color,
|
||||
"fill_text_border_shape": self.fill_text_default.shape.name(),
|
||||
"date_format": self.session.date_format(),
|
||||
// Null follows the interface language.
|
||||
"date_language": self.session.date_language(),
|
||||
@@ -116,8 +119,12 @@ impl PdfCraftApp {
|
||||
"tool": t.command(),
|
||||
"color": s.color,
|
||||
"opacity": s.opacity,
|
||||
"fill_opacity": s.fill_opacity,
|
||||
"text_opacity": s.text_opacity,
|
||||
"width": s.width,
|
||||
"fill": s.fill,
|
||||
"border_color": s.border_color,
|
||||
"border_shape": s.border_shape.name(),
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
// Drawn signatures keep their original form (older settings read the same).
|
||||
@@ -147,6 +154,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 +229,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;
|
||||
}
|
||||
@@ -302,6 +315,26 @@ impl PdfCraftApp {
|
||||
if let Some(show) = v["show_page_edge_shadows"].as_bool() {
|
||||
self.show_page_edge_shadows = show;
|
||||
}
|
||||
if let Some(width) = v["fill_text_border"].as_f64().and_then(fill_sign::valid_text_border) {
|
||||
self.fill_text_default.width = width;
|
||||
}
|
||||
if let Some(shape) = v["fill_text_border_shape"].as_str().and_then(pdfcraft_engine::BorderShape::parse) {
|
||||
self.fill_text_default.shape = shape;
|
||||
}
|
||||
// `null` is a saved "the text's colour"; a missing or malformed one keeps the setting.
|
||||
match v.get("fill_text_border_color") {
|
||||
Some(serde_json::Value::Null) => self.fill_text_default.color = None,
|
||||
Some(c) => {
|
||||
if let Some(c) = rgb_triple(c) {
|
||||
self.fill_text_default.color = Some(c.map(|x| x.clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// Fill & Sign text starts from the saved border.
|
||||
self.fill_text_border = self.fill_text_default.width;
|
||||
self.fill_text_border_color = self.fill_text_default.color;
|
||||
self.fill_text_border_shape = self.fill_text_default.shape;
|
||||
if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) {
|
||||
self.redact_prefs = prefs.clone();
|
||||
self.redact_defaults = prefs;
|
||||
@@ -350,6 +383,8 @@ impl PdfCraftApp {
|
||||
if let Some(o) = e["opacity"].as_f64() {
|
||||
self.comment_prefs.set_opacity(tool, o);
|
||||
}
|
||||
// The fill's and text's own opacities, where saved (older settings have none).
|
||||
self.comment_prefs.set_part_opacity(tool, e["fill_opacity"].as_f64(), e["text_opacity"].as_f64());
|
||||
if let Some(w) = e["width"].as_f64() {
|
||||
self.comment_prefs.set_width(tool, w);
|
||||
}
|
||||
@@ -365,6 +400,20 @@ impl PdfCraftApp {
|
||||
s.fill = fill;
|
||||
self.comment_prefs.set_style(tool, s);
|
||||
}
|
||||
// `"border_color": null` is a saved "Same as text", which clears a tool's own border
|
||||
// colour; a missing or malformed one keeps it.
|
||||
match e.get("border_color") {
|
||||
Some(serde_json::Value::Null) => self.comment_prefs.set_border_color(tool, None),
|
||||
Some(c) => {
|
||||
if let Some(c) = rgb_triple(c) {
|
||||
self.comment_prefs.set_border_color(tool, Some(c));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(shape) = e["border_shape"].as_str().and_then(pdfcraft_engine::BorderShape::parse) {
|
||||
self.comment_prefs.set_border_shape(tool, shape);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user