diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8549142f7..9403a1e044 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,3 +42,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c70d84e624..e8fa4adac6 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (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) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 9638749929..f998b6fc3d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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\"" diff --git a/Cargo.lock b/Cargo.lock index fb7e5a759d..1bf064f5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 4b98f47c28..bf6a4e3146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,8 @@ pdfcraft-engine = { path = "crates/engine", version = "0.18.1" } pdfcraft-platform = { path = "crates/platform", version = "0.18.1" } pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" } pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false } +pdfcraft-prc = { path = "crates/prc", version = "0.18.1" } +pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" } # pdf hayro = "0.7.1" diff --git a/README.md b/README.md index 3c1b67936a..f9a41cb3ca 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Real-world typography: world scripts, vertical Japanese, colour emoji, gradients ### Fearless -Every save appends your changes and leaves the original bytes untouched. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake. +Ordinary saves append your changes and leave the original bytes untouched. Edits to damaged objects whose xref and header generations disagree use a full rewrite, keeping both accepted references connected to the same repaired object. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake. diff --git a/ROADMAP.md b/ROADMAP.md index 00dd8fdee0..f104ee0d80 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. | diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index ad8243d594..56e10c0c29 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -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" diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index 73d14f4a6c..ffa5a06068 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -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::().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, 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 { + use eframe::wasm_bindgen::JsCast; + web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::().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, } 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 { - 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 { + 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::::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::() { - array.to_vec() - } else if bytes.is_instance_of::() { - 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 { - 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 { - 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(); } } diff --git a/assets/icons/a-large-small.svg b/assets/icons/a-large-small.svg new file mode 100644 index 0000000000..7716db3cf2 --- /dev/null +++ b/assets/icons/a-large-small.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/contributors/people.toml b/contributors/people.toml index fecfb68c90..8ab196abc9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -183,3 +183,6 @@ sync_github_name = true [people.Combiler01] sync_github_name = true + +[people.helenkwok] +real_name = "Helen Kwok" diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 89c0f70f48..7148e9fc2e 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -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 { + 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::().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 { - 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 { + 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, 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> { 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 = 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, + 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 { 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; } diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs index 3b33662524..3178f78e94 100644 --- a/crates/annot/src/appearance/symbols.rs +++ b/crates/annot/src/appearance/symbols.rs @@ -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 { // 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. diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 1904745e95..6376655ce9 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -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) -> Result<(), AnnotError> { let Some(rect) = rect else { return Ok(()) }; let mut popup = Dict::new(); diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 1a54dbc077..ba181d7504 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -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, /// Text boxes, typewriter text and callouts: the `/DA` font size in points. pub font_size: Option, + /// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's). + pub border_color: Option, + /// Text boxes and typewriter text: the border's shape (callouts have none to choose). + pub border_shape: Option, + /// 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, + /// How opaque a text box's, typewriter text's or callout's text is drawn. + pub text_opacity: Option, } /// The current properties of the comment at `(page, index)`. @@ -239,7 +248,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { 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 { 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), }) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 5b7492bea2..52c0aad74f 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -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; diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs new file mode 100644 index 0000000000..b50342898e --- /dev/null +++ b/crates/annot/src/model3d.rs @@ -0,0 +1,77 @@ +//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a +//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares. + +use super::lookup::{annots, page_ref}; +use super::threed::MAX_MODEL_BYTES; +use super::{AnnotError, Dict, Document, Object}; + +/// The format of a 3D stream, from its `/Subtype`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Model3dKind { + /// Product Representation Compact (ISO 14739-1). + Prc, + /// Universal 3D (ECMA-363). + U3d, +} + +/// A 3D annotation's model: its format and the decoded stream. +#[derive(Debug, Clone, PartialEq)] +pub struct Model3d { + pub kind: Model3dKind, + pub data: Vec, +} + +/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order. +/// +/// # Errors +/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`]. +pub fn model_3d_annotations(doc: &Document, page: usize) -> Result, AnnotError> { + let list = annots(doc, page_ref(doc, page)?); + Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect()) +} + +/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded. +/// +/// # Errors +/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`] +/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or +/// U3D, or decodes past [`MAX_MODEL_BYTES`]. +pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result { + let list = annots(doc, page_ref(doc, page)?); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1)); + if annot.name(b"Subtype") != Some(b"3D") { + return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at()))); + } + let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?; + let Object::Stream(s) = stream.as_ref() else { + return Err(AnnotError::Invalid(format!("{} has no 3D stream", at()))); + }; + let kind = match s.dict.name(b"Subtype") { + Some(b"PRC") => Model3dKind::Prc, + Some(b"U3D") => Model3dKind::U3d, + other => { + let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned()); + return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at()))); + } + }; + let data = doc + .with_direct_parms(s) + .decoded_within(MAX_MODEL_BYTES) + .map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?; + Ok(Model3d { kind, data }) +} + +/// What an annotation's `/3DD` names: the 3D stream itself, or the `/3D` entry of a 3D reference +/// dictionary. A reference is followed once; the caller refuses anything that is not a stream. +fn stream_of(doc: &Document, annot: &Dict) -> Option> { + let obj = doc.resolve(annot.get(b"3DD")?); + match obj.as_ref() { + Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)), + _ => Some(obj), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/annot/src/model3d/tests.rs b/crates/annot/src/model3d/tests.rs new file mode 100644 index 0000000000..90addb1bb6 --- /dev/null +++ b/crates/annot/src/model3d/tests.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use super::*; + +/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on. +fn doc(annots: &str, extra: &[Vec]) -> Document { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(), + ]; + objs.extend(extra.iter().cloned()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") +} + +fn stream(dict: &str, body: &[u8]) -> Vec { + let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes(); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +fn annot(dd: &str) -> Vec { + format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes() +} + +#[test] +fn a_plain_3d_stream_is_read_through_its_filters() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]); + assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() })); +} + +#[test] +fn a_3dref_leads_to_the_stream_it_names() { + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); + assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec())); +} + +#[test] +fn a_u3d_stream_is_read_and_says_so() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]); + assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d)); +} + +#[test] +fn a_3d_annotation_without_a_stream_is_invalid() { + let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); +} + +#[test] +fn an_unknown_format_is_invalid() { + let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)"))); +} + +#[test] +fn a_3dref_is_followed_once_and_only_to_a_stream() { + // A reference naming another reference, or one that names itself, is damage: no stream. + let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); + // The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key. + let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream"))); +} + +#[test] +fn another_annotation_is_not_a_3d_one() { + let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]); + assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation"))); + assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 })); + assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1])); + assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1))); +} diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index a615b8e013..c7d87be090 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -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> { 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, 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, 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 = - 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 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, text: Option, 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(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 2ff781a3a5..ff7f8925f0 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -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 { + 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 { 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 { 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 { diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 522995b9f9..f6bcc68a7e 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -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 { + [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> { + 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, + /// 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, + /// 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, + /// A text box's text's own opacity, 0–1 (`/PCTextOpacity`); `None`: as the fill's. + pub text_opacity: Option, } /// 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::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() } } } diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index a6e43de1f0..a825b1fe52 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -14,6 +14,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-organize = { workspace = true } pdfcraft-platform = { workspace = true } +pdfcraft-prc = { workspace = true } +pdfcraft-prc-render = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" diff --git a/crates/automation/README.md b/crates/automation/README.md index 8ffa2d0b3e..d218fbcda6 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -250,6 +250,15 @@ choices and values over 4096 UTF-8 bytes return an error. Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. +## Rendering a 3D model + +`model_render` draws a page's 3D model (a 3D annotation's PRC stream, directly or through a 3D +reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1); +`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye +(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true. +The model is framed to fill the image and shaded by a headlight on white. U3D models are refused +with an error, and the annotation's own views, lighting and render modes are not applied yet. + ## Viewer navigation history A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 135d0868ee..78f4cefbe0 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -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::>()).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::>>()?; @@ -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 { + 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 { + 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 { + 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 )".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> { + 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::*; diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index d7aa15745a..71a979b637 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -69,6 +69,7 @@ impl Automation { self.apply(&a, edit)? } "page_render" => return self.page_render(&a), + "model_render" => return self.model_render(&a), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, @@ -136,6 +137,7 @@ impl Automation { out["detected"] = json!(given.is_none()); out } + "page_enhance_scan" => self.page_enhance_scan(&a)?, "page_delete" => { let pages = self.pages(&a, "pages")?; self.apply(&a, Edit::DeletePages { pages })? @@ -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 { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => Vec::new(), + }; + let level = match a.opt_int("level")? { + None => pdfcraft_engine::scan_clean::ScanCleanSettings::default().level, + Some(l @ 0..=100) => l as u8, + Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), + }; + let mut settings = pdfcraft_engine::scan_clean::ScanCleanSettings::at(level); + if let Some(on) = a.opt_bool("edge_shadows")? { + settings.edge_shadows = on; + } + if let Some(on) = a.opt_bool("despeckle")? { + settings.despeckle = on; + } + let id = self.doc(a)?.id; + let report = self.session.enhance_scanned_pages(id, &pages, settings).map_err(failed)?; + let numbered = |p: &[usize]| p.iter().map(|p| p + 1).collect::>(); + let skipped: Vec = report.skipped.iter().map(|k| json!({ "pages": numbered(&k.pages), "name": k.name, "reason": k.reason })).collect(); + let mut out = summary(self.doc(a)?); + out["pages_cleaned"] = json!(numbered(&report.pages)); + out["images"] = json!(report.images); + out["not_scanned"] = json!(numbered(&report.not_scanned)); + out["skipped"] = json!(skipped); + Ok(out) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 3b6275d5c8..520d836b01 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -28,6 +28,7 @@ mod links; pub mod mcp; mod measure; mod model3d; +mod model_render; mod printing; mod reading; mod redact; diff --git a/crates/automation/src/model_render.rs b/crates/automation/src/model_render.rs new file mode 100644 index 0000000000..dbfabc6708 --- /dev/null +++ b/crates/automation/src/model_render.rs @@ -0,0 +1,104 @@ +//! `model_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer. + +use pdfcraft_engine::model3d::Model3dKind; +use pdfcraft_prc::PrcFile; +use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render}; + +use super::Automation; +use super::dispatch::Content; +use super::error::{Result, ToolError, failed}; +use super::json::Args; +use super::pages::encode_png; + +/// Default and largest side of the image, in pixels. +pub(crate) const DEFAULT_SIDE: u32 = 512; +pub(crate) const MAX_SIDE: u32 = 4096; + +/// The default view: from the front, right and above, as an isometric view looks. +const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0]; + +impl Automation { + pub(crate) fn model_render(&mut self, a: &Args) -> Result> { + let page = self.page(a)?; + let width = side(a, "width")?; + let height = side(a, "height")?; + let direction = direction(a)?; + let perspective = a.opt_bool("perspective")?.unwrap_or(true); + let doc = self.doc(a)?; + let found = doc.model_3d_annotations(page).map_err(failed)?; + let index = match a.opt_int("model")? { + None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?, + Some(n) => { + let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?; + *found + .get(pos) + .ok_or_else(|| ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())))? + } + }; + let model = doc.model_3d(page, index).map_err(failed)?; + if model.kind == Model3dKind::U3d { + return Err(failed("this 3D model is in U3D, which can't be drawn yet; only PRC models can")); + } + let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?; + let meshes = assemble(&prc).map_err(failed)?; + let aspect = f64::from(width) / f64::from(height); + let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?; + let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?; + let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?; + Ok(vec![Content::Png { data, width: image.width, height: image.height }]) + } +} + +fn side(a: &Args, key: &str) -> Result { + match a.opt_int(key)? { + None => Ok(DEFAULT_SIDE), + Some(n) => u32::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)).ok_or_else(|| Args::wrong(key, &format!("between 1 and {MAX_SIDE}"))), + } +} + +/// `direction`: three finite numbers, not all zero, from the eye towards the model. +fn direction(a: &Args) -> Result<[f64; 3]> { + let Some(v) = a.get("direction") else { return Ok(DEFAULT_DIRECTION) }; + let wrong = || Args::wrong("direction", "three numbers [x, y, z], not all zero"); + let items = v.as_array().filter(|x| x.len() == 3).ok_or_else(wrong)?; + let mut out = [0.0; 3]; + for (o, x) in out.iter_mut().zip(items) { + *o = x.as_f64().filter(|f| f.is_finite()).ok_or_else(wrong)?; + } + if out.iter().all(|c| *c == 0.0) { + return Err(wrong()); + } + Ok(out) +} + +/// Z is up, as CAD models are drawn, unless the view looks along it. +fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] { + if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] } +} + +/// Straight RGBA to the premultiplied RGBA `encode_png` takes. +fn premultiplied(mut rgba: Vec) -> Vec { + for [r, g, b, alpha] in rgba.as_chunks_mut::<4>().0 { + let a = u16::from(*alpha); + for c in [r, g, b] { + *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX); + } + } + rgba +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn premultiplying_scales_colour_by_alpha() { + assert_eq!(premultiplied(vec![200, 100, 0, 255, 200, 100, 0, 128, 9, 9, 9, 0]), vec![200, 100, 0, 255, 100, 50, 0, 128, 0, 0, 0, 0]); + } + + #[test] + fn a_view_along_z_keeps_y_up() { + assert_eq!(up_for([0.0, 0.0, -1.0]), [0.0, 1.0, 0.0]); + assert_eq!(up_for(DEFAULT_DIRECTION), [0.0, 0.0, 1.0]); + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index dd4b0f14c3..1f227e294b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -181,6 +181,13 @@ pub fn tools() -> Vec { "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), + t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") + .ro() + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 }, + "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 }, + "direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }, + "perspective": { "type": "boolean" } + }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") @@ -233,6 +240,18 @@ pub fn tools() -> Vec { }), &["doc", "page"], )), + t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, a scan drawn inside grouped Form artwork, broken data, or nothing to change). One undoable step; none when nothing changed.") + .cmd("ocr.enhance") + .with(schema( + json!({ + "doc": doc(), + "pages": pages("to clean up (default: all)"), + "level": { "type": "integer", "minimum": 0, "maximum": 100 }, + "edge_shadows": { "type": "boolean" }, + "despeckle": { "type": "boolean" }, + }), + &["doc"], + )), t("page_delete", "Delete pages", "Delete pages. Undoable until saved.") .destructive() .cmd("page.delete") @@ -839,9 +858,13 @@ pub fn tools() -> Vec { "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 { 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 { 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 { 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 { "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)." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index c7e4835590..cc32166b8a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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 = 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 = 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> = + 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 = 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("> = + 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"); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index f396601edb..3122fe5825 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -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; diff --git a/crates/automation/tests/it/model_render.rs b/crates/automation/tests/it/model_render.rs new file mode 100644 index 0000000000..615a5346a9 --- /dev/null +++ b/crates/automation/tests/it/model_render.rs @@ -0,0 +1,112 @@ +//! `model_render` draws a page's 3D model headlessly: a synthetic PDF holding a synthetic PRC. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// One triangle at z = 0 facing +z, with corners (-1,-1), (1,-1) and (0,1), as a PRC file of one +/// file structure with no product tree, in hexadecimal for the stream's `/ASCIIHexDecode`. +const TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000cd000000d5000000dd000000e500000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c087758da2422b43084fe3140e038713464cd759381a162800006ed70828789c030000000001789c030000000001789c030000000001"; + +/// A one-page PDF whose page holds a 3D annotation over a stream of `/Subtype /{subtype}`. +fn pdf(subtype: &str, hex: &str) -> Vec { + let stream = format!("<< /Type /3D /Subtype /{subtype} /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 10 10] >> 4 0 R] >>".into(), + "<< /Type /Annot /Subtype /3D /Rect [20 20 180 180] /3DD 5 0 R >>".into(), + stream, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// A fresh folder with `prc.pdf` and `u3d.pdf` in `in`, `outside.pdf` beside it, and an +/// automation rooted at `in`. +fn setup(test: &str) -> (PathBuf, Automation) { + let dir = std::env::temp_dir().join(format!("pdfcraft-model3d-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("in")).unwrap(); + std::fs::write(dir.join("in/prc.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap(); + std::fs::write(dir.join("in/u3d.pdf"), pdf("U3D", "553344")).unwrap(); + std::fs::write(dir.join("outside.pdf"), pdf("PRC", TRIANGLE_PRC_HEX)).unwrap(); + let a = Automation::new().with_root(dir.join("in")).unwrap(); + (dir, a) +} + +fn open(a: &mut Automation, path: &str) -> u64 { + match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) { + Content::Json(v) => v["doc"].as_u64().unwrap(), + other => panic!("expected JSON: {other:?}"), + } +} + +/// The RGB of pixel (x, y) of a PNG. +fn rgb(data: &[u8], x: usize, y: usize) -> [u8; 3] { + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.line_size / info.width as usize; + let at = y * info.line_size + x * channels; + [pixels[at], pixels[at + 1], pixels[at + 2]] +} + +#[test] +fn a_prc_model_is_drawn_over_the_background() { + let (_dir, mut a) = setup("prc"); + let doc = open(&mut a, "prc.pdf"); + let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false }); + let out = a.call("model_render", &args).unwrap(); + let Content::Png { data, width, height } = &out[0] else { panic!("expected a PNG") }; + assert_eq!((*width, *height), (64, 48)); + assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background"); + assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on"); + // In perspective the headlight meets the face at a slight angle, so only not-background holds. + let out = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1] })).unwrap(); + let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; + assert_ne!(rgb(data, 32, 24), [255, 255, 255], "the centre is drawn in perspective too"); + // The same model named explicitly, and the default isometric view, both draw. + assert!(a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); +} + +#[test] +fn a_u3d_model_or_a_missing_one_is_a_clear_error() { + let (_dir, mut a) = setup("u3d"); + let doc = open(&mut a, "u3d.pdf"); + let err = a.call("model_render", &json!({ "doc": doc, "page": 1 })).unwrap_err().to_string(); + assert!(err.contains("U3D"), "{err}"); + let doc = open(&mut a, "prc.pdf"); + let err = a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 2 })).unwrap_err().to_string(); + assert!(err.contains("has 1 3D models, not 2"), "{err}"); + for bad in [json!({ "direction": [0, 0, 0] }), json!({ "direction": [1, 2] }), json!({ "width": 0 }), json!({ "height": 5000 })] { + let mut args = json!({ "doc": doc, "page": 1 }); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(a.call("model_render", &args).is_err(), "{bad}"); + } +} + +#[test] +fn a_model_outside_the_root_is_never_opened_to_be_drawn() { + let (dir, mut a) = setup("root"); + let outside = dir.join("outside.pdf"); + for path in [Value::from("../outside.pdf"), Value::from(outside.to_string_lossy().into_owned())] { + assert!(a.call("doc_open", &json!({ "path": path })).is_err(), "{path} lies outside the root"); + } + // The tool takes no path of its own: only a document opened inside the root reaches it. + assert!(a.call("model_render", &json!({ "doc": 1, "page": 1 })).is_err()); + let doc = open(&mut a, "prc.pdf"); + assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok()); +} diff --git a/crates/cos/README.md b/crates/cos/README.md index 7b576f293f..398b161625 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -53,7 +53,18 @@ another. Edits never touch the original bytes. - **Every cross-reference form:** classic tables with their quirky subsections, cross-reference streams, hybrid-reference files (`/XRefStm`), and object streams. - **Repair.** A file whose xref is wrong or missing is reconstructed by scanning for `obj` - headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged. + headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged. A live + in-file reference may match either its xref generation or a same-number object header at + that indexed offset; a missing, free, or neither-matching slot still resolves to null. + Header probes inspect at most 128 bytes at the indexed and header-relative offsets, without + parsing object bodies. On open, disagreements in retained xref revisions are recorded once + per object, up to 256 objects plus an omitted-details summary. This diagnostic scan does + not expand compact xref ranges; the winning xref entry remains authoritative for free + slots. Compressed objects require generation zero. Explicit `set` overlays accept only the + supplied generation; `set_existing` and `update_dict` retain both accepted generations of a + damaged live slot, including through snapshots, and require full rewrite. Headers beyond + this bounded probe are not additionally recovered. Materialized primary and hybrid rows are + diagnosed before folding, so a newer row does not hide a retained disagreement. - **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields `CosError`, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects. @@ -66,14 +77,17 @@ another. Edits never touch the original bytes. ## Writing -`write_incremental` appends only the edited objects, a cross-reference section **in the same -style as the file's last one**, and a trailer with `/Prev`. The original bytes are never modified, -which is what keeps a signature over an earlier revision valid. +`write_incremental` ordinarily appends only the edited objects, a cross-reference section **in +the same style as the file's last one**, and a trailer with `/Prev`, preserving the original +bytes and signatures over earlier revisions. It delegates to `write_full` when reconstruction, +encryption changes or `full_save_required()` demand a rewrite. Repair-aware edits to a live +xref/header generation disagreement use this fallback rather than append a contradictory row. `write_full` writes only what the trailer can reach, renumbered densely — the Save As and garbage-collecting path. By default (`SaveOptions::object_streams`) it packs non-stream objects into compressed object streams with a cross-reference stream (PDF 1.5+); turning that off writes a -classic table any reader accepts. +classic table any reader accepts. Both forms map accepted xref/header aliases to one output +object, retaining shared identity such as page-tree parent links; unrelated generations stay null. Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay recognisable in a diff and byte-stable when re-serialized. diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 62ef1e74cc..8d9917d749 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -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 { - self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null)) + self.try_get_reference(r).unwrap_or_else(|_| Arc::new(Object::Null)) + } + + pub(super) fn reference_matches(&self, r: ObjRef) -> bool { + match self.overlay.get(&r.num) { + Some(Slot::Set(generation, _)) => r.generation == *generation, + Some(Slot::RepairedSet(generation, alias, _)) => r.generation == *generation || r.generation == *alias, + Some(Slot::Freed(_)) => false, + None => match self.entries.lookup(r.num, &self.data) { + Some(XrefEntry::InFile { offset, generation }) => { + r.generation == generation || self.indexed_header_reference(r.num, offset) == Some(r) + } + Some(XrefEntry::InStream { .. }) => r.generation == 0, + None | Some(XrefEntry::Free { .. }) => false, + }, + } + } + + pub(super) fn try_get_reference(&self, r: ObjRef) -> Result, CosError> { + if !self.reference_matches(r) { + return Ok(Arc::new(Object::Null)); + } + self.try_get(r.num) + } + + /// A repair probe, not a whole-file search: only the indexed header (or the same offset + /// relative to a shifted PDF header) can establish this additional generation. A small + /// window bounds hostile whitespace/tokens; ordinary xref-matching reads are unchanged. + pub(super) fn indexed_header_reference(&self, num: u32, offset: u64) -> Option { + let at = |off: usize| { + let bytes = self.data.get(off..off.saturating_add(128).min(self.data.len()))?; + let mut lexer = Lexer::new(bytes, 0); + lexer.skip_ws(); + let number = std::str::from_utf8(lexer.token()).ok()?.parse::().ok()?; + lexer.skip_ws(); + let generation = std::str::from_utf8(lexer.token()).ok()?.parse::().ok()?; + (number == num && lexer.eat_keyword(b"obj")).then_some(ObjRef::new(number, generation)) + }; + let off = usize::try_from(offset).ok()?; + at(off).or_else(|| if self.header_offset == 0 { None } else { off.checked_add(self.header_offset).and_then(at) }) + } + + /// Add at most one bounded diagnostic per object. False stops diagnostic probing only; + /// parsers must still read the remaining xref rows needed to build their index. + pub(super) fn inspect_xref_generation_repair(&self, num: u32, entry: XrefEntry, recorded: &mut HashSet, log: &mut Vec) -> bool { + let XrefEntry::InFile { offset, generation } = entry else { return true }; + if recorded.contains(&num) { + return true; + } + let Some(header) = self.indexed_header_reference(num, offset) else { return true }; + if header.generation == generation { + return true; + } + if recorded.len() == 256 { + log.push("additional xref/header generation disagreements omitted after 256 objects".into()); + return false; + } + recorded.insert(num); + log.push(format!("object {num}: xref generation {generation} disagrees with object header generation {}", header.generation)); + true + } + + /// Diagnose once at open without expanding compact ranges or decoding an xref twice. + /// Materialized readers retain bounded notes while they read accepted sections, before + /// newer rows hide older ones. Discarded parse attempts/rebuilt indexes carry no notes. + pub(super) fn note_xref_generation_repairs(&self, log: &mut Vec) { + match &*self.entries { + XrefIndex::Materialized(_, notes) => log.extend(notes.iter().cloned()), + XrefIndex::Compact { revisions, .. } => { + let mut recorded = HashSet::new(); + for revision in revisions { + for section in std::iter::once(&revision.primary).chain(revision.hybrid.iter()) { + for num in section.numbers() { + if let Some(entry) = section.lookup(num, &self.data) + && !self.inspect_xref_generation_repair(num, entry, &mut recorded, log) + { + return; + } + } + } + } + } + } } /// Follow a reference if `o` is one; otherwise return `o` itself. An object can be nothing but @@ -388,7 +474,7 @@ impl Document { pub fn try_get(&self, num: u32) -> Result, 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) -> Result<(), CosError> { + if !self.reference_matches(r) { + return Err(CosError::MissingObject(r.num)); + } + let alias = self.alternate_generation(r); + if let Some(alias) = alias { + let obj = self.adopt(obj.into()); + self.overlay.insert(r.num, Slot::RepairedSet(r.generation, alias, Arc::new(obj))); + } else { + self.set(r, obj); + } + Ok(()) + } + + /// Other accepted generation for a known-live reference to a damaged slot. + pub(super) fn alternate_generation(&self, r: ObjRef) -> Option { + match self.overlay.get(&r.num) { + Some(Slot::RepairedSet(generation, alias, _)) => Some(if r.generation == *generation { *alias } else { *generation }), + Some(_) => None, + None => match self.entries.lookup(r.num, &self.data) { + Some(XrefEntry::InFile { offset, generation }) => self.indexed_header_reference(r.num, offset).and_then(|header| { + (header.generation != generation).then_some(if r.generation == generation { header.generation } else { generation }) + }), + _ => None, + }, + } + } + /// Add a new indirect object and return its reference. pub fn add(&mut self, obj: impl Into) -> ObjRef { let r = ObjRef::new(self.allocate_num(), 0); @@ -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>)> + '_ { 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")) } } diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 70bbcc313e..7a4337c8da 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,5 +1,5 @@ use super::reader::find; -use super::state::Credentials; +use super::state::{Credentials, Slot}; use super::xref::XrefIndex; use super::{Arc, BTreeMap, CosError, Dict, Document, HashSet, ObjRef, Object}; @@ -156,6 +156,9 @@ impl Document { } doc.reconstruct(&mut log)?; } + // Header generations are plaintext, including in encrypted files. Inspect only the + // bounded headers, not object bodies, and retain the compact xref representation. + doc.note_xref_generation_repairs(&mut log); doc.repair_log = Arc::new(log); // Reconstruction may have found a different trailer: (re)authenticate against it. if (!ok || !authenticated) @@ -288,8 +291,9 @@ impl Document { } /// The next save must rewrite the whole file (see [`Document::require_full_save`]). + /// Repair-aware edits retaining two accepted generations cannot use one incremental slot. pub fn full_save_required(&self) -> bool { - self.full_save + self.full_save || self.overlay.values().any(|slot| matches!(slot, Slot::RepairedSet(..))) } /// Security was set or removed since the document was opened (the next save applies it). diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index d21bf9403f..e1691ace90 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -17,8 +17,8 @@ impl Document { return Ok((index, trailer, revisions, Vec::new())); } } - let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?; - Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities)) + let (entries, trailer, revisions, oddities, generation_repairs) = self.read_xref_chain_materialized(log)?; + Ok((XrefIndex::Materialized(Arc::new(entries), Arc::new(generation_repairs)), trailer, revisions, oddities)) } fn read_compact_xref_chain(&self, log: &mut Vec) -> Result)>, CosError> { @@ -323,7 +323,7 @@ impl Document { pub(super) fn read_xref_chain_materialized( &self, log: &mut Vec, - ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { + ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>, Vec), CosError> { let data = &self.data; let tail_start = data.len().saturating_sub(4096); let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; @@ -343,6 +343,9 @@ impl Document { // At most MAX_XREF_OBJECTS entries are kept in all; a stream past that is read again. let mut hybrids: HashMap>> = HashMap::new(); let (mut hybrid_rows, mut noted) = (0usize, HashSet::new()); + let mut generation_repairs = Vec::new(); + let mut recorded = HashSet::new(); + let mut diagnose = true; let mut next = Some(first); while let Some(off) = next.take() { if !visited.insert(off) || visited.len() > 10_000 { @@ -371,6 +374,12 @@ impl Document { } }; revisions.push(Revision { xref_offset: off, is_stream, header_relative }); + for (&num, &entry) in § { + if !diagnose { + break; + } + diagnose = self.inspect_xref_generation_repair(num, entry, &mut recorded, &mut generation_repairs); + } // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0); @@ -390,6 +399,12 @@ impl Document { let mut stm = BTreeMap::new(); let read = match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { + for (&num, &entry) in &stm { + if !diagnose { + break; + } + diagnose = self.inspect_xref_generation_repair(num, entry, &mut recorded, &mut generation_repairs); + } for (&num, &e) in &stm { match sect.get(&num) { None | Some(XrefEntry::Free { .. }) => { @@ -427,7 +442,7 @@ impl Document { for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { trailer.remove(k); } - Ok((entries, trailer, revisions, oddities)) + Ok((entries, trailer, revisions, oddities, generation_repairs)) } /// Read one section at `off`, adding entries not already known (newer sections win). `odd` @@ -606,7 +621,7 @@ impl Document { } if found > 0 { log.push(format!("the cross-reference data leaves out {found} objects the file contains; located them by their object headers")); - self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled))); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled), Arc::default())); } self.located = Arc::new(located); } @@ -631,7 +646,7 @@ impl Document { if entries.is_empty() { return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() }); } - self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()))); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()), Arc::default())); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Objects inside object streams. for num in objstms { @@ -648,7 +663,7 @@ impl Document { } } let object_count = entries.len(); - self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries))); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries), Arc::default())); self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a // usable /Root (last in the file, whichever kind), else the catalog itself. One whose @@ -658,7 +673,7 @@ impl Document { // A file whose only trailers are cross-reference streams (and whose `startxref` is gone // or points nowhere) keeps its /Info, /ID and /Encrypt this way. let rank = |at: usize, d: &Dict| { - let root = self.try_get(d.reference(b"Root")?.num).ok()?; + let root = self.try_get_reference(d.reference(b"Root")?).ok()?; let Object::Dict(c) = &*root else { return None }; Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at)) }; diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 96dde01c0f..f68a483290 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -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>> = std::cell::RefCell::new(HashMap::new()); + pub(super) static LENGTHS: std::cell::RefCell>> = 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, len: us #[derive(Clone, Debug, PartialEq)] pub(super) enum Slot { Set(u16, Arc), + /// Both generations accepted from a damaged live source slot survive a repair-aware edit. + /// A full rewrite is required: a new xref slot cannot encode two generations. + RepairedSet(u16, u16, Arc), Freed(u16), } @@ -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, CosError> { - let object = self.document.try_get(reference.num); + let object = self.document.try_get_reference(reference); // A missed eviction here only costs a re-parse, so never fail the read on a poisoned // lock. The editing-cache evictors propagate instead; they guard user edits. if let Ok(mut streams) = self.document.objstms.lock() { diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index f12437baaf..ad8f86fdea 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -599,7 +599,7 @@ pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { fn assert_compact_matches_materialized(doc: &Document) { assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map"); - let (entries, _, revisions, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + let (entries, _, revisions, _, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); assert_eq!(revisions.as_slice(), doc.revisions()); let max = entries.keys().next_back().copied().unwrap_or(0); assert_eq!(doc.entries.max_num(), max); @@ -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 diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs index 02390ff7da..771df28b80 100644 --- a/crates/cos/src/document/xref.rs +++ b/crates/cos/src/document/xref.rs @@ -41,7 +41,7 @@ pub(super) struct XrefRevisionSection { #[derive(Clone, Debug)] pub(super) enum XrefIndex { Compact { revisions: Vec, max_num: u32, has_entries: bool }, - Materialized(Arc>), + Materialized(Arc>, Arc>), } pub(super) const MAX_COMPACT_XREF_ROWS: usize = 2_000_000; @@ -149,8 +149,8 @@ impl XrefSection { } } - pub(super) fn extend_numbers(&self, numbers: &mut Vec) { - for range in &self.ranges { + pub(super) fn numbers(&self) -> impl Iterator + '_ { + self.ranges.iter().flat_map(|range| { let available = match &self.data { XrefSectionData::Table => range.count as usize, XrefSectionData::Stream { bytes, widths } => { @@ -158,14 +158,12 @@ impl XrefSection { bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0) } }; - for i in 0..available.min(range.count as usize) { - if let Ok(i) = u32::try_from(i) - && let Some(num) = range.start.checked_add(i) - { - numbers.push(num); - } - } - } + (0..available.min(range.count as usize)).filter_map(move |i| u32::try_from(i).ok().and_then(|i| range.start.checked_add(i))) + }) + } + + pub(super) fn extend_numbers(&self, numbers: &mut Vec) { + numbers.extend(self.numbers()); } } @@ -189,20 +187,20 @@ impl XrefRevisionSection { impl XrefIndex { pub(super) fn empty() -> Self { - Self::Materialized(Arc::new(BTreeMap::new())) + Self::Materialized(Arc::new(BTreeMap::new()), Arc::default()) } pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option { match self { Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)), - Self::Materialized(entries) => entries.get(&num).copied(), + Self::Materialized(entries, _) => entries.get(&num).copied(), } } pub(super) fn is_empty(&self) -> bool { match self { Self::Compact { has_entries, .. } => !has_entries, - Self::Materialized(entries) => entries.is_empty(), + Self::Materialized(entries, _) => entries.is_empty(), } } @@ -211,14 +209,14 @@ impl XrefIndex { pub(super) fn remove_exhausted(&mut self) -> bool { match self { Self::Compact { .. } => false, - Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(), + Self::Materialized(entries, _) => Arc::make_mut(entries).remove(&u32::MAX).is_some(), } } /// Every entry, newest section first, as the materialized reader keeps them. pub(super) fn to_map(&self, file: &[u8]) -> BTreeMap { match self { - Self::Materialized(entries) => entries.as_ref().clone(), + Self::Materialized(entries, _) => entries.as_ref().clone(), Self::Compact { revisions, .. } => { let mut numbers = Vec::new(); for revision in revisions { @@ -232,13 +230,13 @@ impl XrefIndex { pub(super) fn max_num(&self) -> u32 { match self { Self::Compact { max_num, .. } => *max_num, - Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0), + Self::Materialized(entries, _) => entries.keys().next_back().copied().unwrap_or(0), } } pub(super) fn object_numbers(&self, file: &[u8]) -> Vec { match self { - Self::Materialized(entries) => { + Self::Materialized(entries, _) => { entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect() } Self::Compact { revisions, .. } => { diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index dcfcb97997..6c4c71580a 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -307,8 +307,17 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro if matches!(*o, Object::Null) { continue; // dangling reference: written as null by renumbering below } - map.insert(r, output_number(order.len())?); - order.push((r, matches!(*o, Object::Stream(_)))); + // Accepted xref/header aliases denote one slot, including page-tree cycles. + // Check for null first: an unrelated or freed generation must remain dangling. + let canonical = ObjRef::new(r.num, reader.document.generation(r.num)); + if let Some(&num) = map.get(&canonical) { + map.insert(r, num); + continue; + } + let num = output_number(order.len())?; + map.insert(r, num); + map.insert(canonical, num); + order.push((canonical, matches!(*o, Object::Stream(_)))); visit_refs(&o, &mut queue); } let version = reader.document.version().chars().take(3).collect::(); @@ -319,13 +328,15 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro out.extend_from_slice(&[b'%', 0xE2, 0xE3, 0xCF, 0xD3, b'\n']); // The /Encrypt dictionary and everything it references (e.g. indirect /CF crypt-filter // dictionaries) are needed before anything can be decrypted: never inside object streams. - let mut encryption_objects: std::collections::HashSet = std::collections::HashSet::new(); + let mut encryption_objects: std::collections::HashSet = std::collections::HashSet::new(); let mut pending: Vec = Vec::new(); if let Some(encrypt) = trailer_in.get(b"Encrypt") { collect_refs(encrypt, &mut |r| pending.push(r)); } while let Some(r) = pending.pop() { - if encryption_objects.insert(r) { + if let Some(&num) = map.get(&r) + && encryption_objects.insert(num) + { collect_refs(&reader.get(r), &mut |x| pending.push(x)); } } @@ -333,14 +344,15 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro // damaged and could not be repaired", before it even asks for the password), while other // readers accept it. qpdf keeps the catalog out of object streams too. Verified with // Acrobat DC on macOS (#774). - let root_outside = if reader.document.output_security().0.is_some() { trailer_in.reference(b"Root") } else { None }; + let root_outside = + if reader.document.output_security().0.is_some() { trailer_in.reference(b"Root").and_then(|r| map.get(&r)).copied() } else { None }; let mut rows: BTreeMap = BTreeMap::new(); // Keep only references, not renamed copies of the entire object graph. Standalone // objects still precede object streams, preserving the established output order. let mut packed: Vec<(u32, ObjRef)> = Vec::new(); for (i, (r, is_stream)) in order.iter().enumerate() { let num = output_number(i)?; - let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(r) && root_outside != Some(*r); + let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(&num) && root_outside != Some(num); if in_stream { packed.push((num, *r)); } else { diff --git a/crates/cos/tests/it/generations.rs b/crates/cos/tests/it/generations.rs new file mode 100644 index 0000000000..3d56628c93 --- /dev/null +++ b/crates/cos/tests/it/generations.rs @@ -0,0 +1,475 @@ +//! An indirect reference identifies an object number AND its generation (ISO 32000-2 §7.3.10). + +use std::sync::Arc; + +use hayro_syntax::object::{Dict as HayroDict, ObjectIdentifier}; +use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, XrefEntry, write_full, write_incremental}; + +/// Object 3 was reused: only 3 1 R refers to the current value, never 3 0 R. +fn reused_object() -> Arc> { + object_file(&[ + (1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"), + (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), + (3, 1, "42"), + ]) +} + +fn object_file(objects: &[(u32, u16, &str)]) -> Arc> { + object_file_with_entry(objects, None) +} + +fn object_file_with_entry(objects: &[(u32, u16, &str)], changed: Option<(u32, u16, bool)>) -> Arc> { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, generation, value) in objects { + offsets.push((*num, bytes.len(), *generation)); + bytes.extend_from_slice(format!("{num} {generation} obj\n{value}\nendobj\n").as_bytes()); + } + let xref = bytes.len(); + let size = objects.len() + 1; + bytes.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for (num, offset, generation) in offsets { + let (generation, free) = changed.filter(|(n, _, _)| *n == num).map_or((generation, false), |(_, g, free)| (g, free)); + let (offset, kind) = if free { (0, 'f') } else { (offset, 'n') }; + bytes.extend_from_slice(format!("{offset:010} {generation:05} {kind} \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Arc::new(bytes) +} + +fn contradictory_entry(free: bool) -> Arc> { + object_file_with_entry( + &[(1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "42")], + Some((3, if free { 2 } else { 0 }, free)), + ) +} + +#[test] +fn contradictory_xref_generation_recovers_the_matching_header_and_records_repair() { + for warm in [false, true] { + let doc = Document::open(contradictory_entry(false)).unwrap(); + if warm { + assert_eq!(*doc.try_get(3).unwrap(), Object::Int(42)); + } + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 1))), Object::Int(42)); + // Preserve the existing xref-matching behavior, but never accept an unrelated generation. + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null); + assert!(!doc.xref_entry_is_exact(3), "a contradictory generation is not an exact xref entry"); + assert!( + doc.repair_log() + .iter() + .any(|line| { line.contains("object 3") && line.contains("xref generation 0") && line.contains("header generation 1") }), + "{:?}", + doc.repair_log() + ); + let repairs = doc.repair_log().to_vec(); + for _ in 0..3 { + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + } + assert_eq!(doc.repair_log(), repairs); + + let mut edited = doc.clone(); + edited.free(ObjRef::new(3, 1)); + for generation in [0, 1, 2] { + assert_eq!(*edited.get(ObjRef::new(3, generation)), Object::Null); + } + edited.set(ObjRef::new(3, 2), Object::Int(99)); + assert_eq!(*edited.get(ObjRef::new(3, 1)), Object::Null); + assert_eq!(*edited.get(ObjRef::new(3, 2)), Object::Int(99)); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + } +} + +/// A damaged row may name generation zero while the retained object and catalog use one. +fn contradictory_dictionary() -> Arc> { + object_file_with_entry( + &[(1, 0, "<< /Type /Catalog /Pages 2 0 R /Current 3 1 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "<< /Value 42 >>")], + Some((3, 0, false)), + ) +} + +fn assert_repaired_dictionary(bytes: Vec, value: i32) { + let bytes = Arc::new(bytes); + let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap(); + assert_eq!(catalog.get::>(b"Current").unwrap().get::(b"Value"), Some(value)); + let back = Document::open(bytes).unwrap(); + let catalog = back.get(back.root().unwrap()); + let current = back.resolve(catalog.as_dict().unwrap().get(b"Current").unwrap()); + assert_eq!(current.as_dict().unwrap().int(b"Value"), Some(i64::from(value))); +} + +#[test] +fn repaired_header_references_survive_full_saves_and_dictionary_edits() { + let original = contradictory_dictionary(); + let doc = Document::open(original.clone()).unwrap(); + for object_streams in [false, true] { + assert_repaired_dictionary(write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(), 42); + } + let mut edited = doc.clone(); + edited.update_dict(ObjRef::new(3, 1), |dict| dict.set(b"Value".to_vec(), Object::Int(77))).unwrap(); + assert_eq!(edited.generation(3), 1); + let incremental = write_incremental(&edited, &SaveOptions::default()).unwrap(); + assert!(edited.full_save_required(), "repair-aware dictionary edits normalize on full rewrite"); + assert_repaired_dictionary(incremental, 77); + for object_streams in [false, true] { + assert_repaired_dictionary(write_full(&edited, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(), 77); + } + let mut restored = doc.clone(); + restored.overlay_changes_from(&edited).unwrap(); + assert_repaired_dictionary(write_incremental(&restored, &SaveOptions::default()).unwrap(), 77); +} + +#[test] +fn free_xref_slots_never_revive_retained_object_headers() { + for warm in [false, true] { + let doc = Document::open(contradictory_entry(true)).unwrap(); + assert!(matches!(doc.xref_entry(3), Some(XrefEntry::Free { next_generation: 2 }))); + if warm { + assert_eq!(*doc.try_get(3).unwrap(), Object::Null); + } + for generation in [0, 1, 2] { + assert_eq!(*doc.get(ObjRef::new(3, generation)), Object::Null); + } + assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 1))), Object::Null); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + } + // Control: the retained bytes really are a readable object when its slot is live. + let live = Document::open(contradictory_entry(false)).unwrap(); + assert_eq!(*live.try_get(3).unwrap(), Object::Int(42)); +} + +#[test] +fn incremental_generation_identity_is_visible_to_an_independent_reader() { + let original = reused_object(); + let mut doc = Document::open(original.clone()).unwrap(); + doc.set(ObjRef::new(3, 1), Object::Int(77)); + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(bytes.starts_with(&original)); + let oracle = hayro_syntax::Pdf::new(Arc::new(bytes)).unwrap(); + let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap(); + assert_eq!(catalog.get::(b"Current"), Some(77)); + assert_eq!(catalog.get::(b"Stale"), None); +} + +#[test] +fn generation_repair_diagnostics_are_bounded_without_disabling_recovery() { + let mut objects = vec![(1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>")]; + objects.extend((3..303).map(|num| (num, 1, "42"))); + let bytes = object_file(&objects); + let bytes = String::from_utf8(bytes.as_ref().clone()).unwrap().replace("00001 n \n", "00000 n \n"); + for materialized in [false, true] { + let bytes = if materialized { bytes.replace("/Size 303", "/Size 302") } else { bytes.clone() }; + let doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert_eq!(doc.repair_log().len(), 257); + assert!(doc.repair_log().last().unwrap().contains("omitted after 256 objects")); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(302, 1)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(302, 2)), Object::Null); + assert_eq!(doc.repair_log().len(), 257); + } +} + +#[test] +fn references_require_the_current_generation_even_with_a_warm_cache() { + let bytes = reused_object(); + let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + assert_eq!(oracle.xref().get::(ObjectIdentifier::new(3, 0)), None); + assert_eq!(oracle.xref().get::(ObjectIdentifier::new(3, 1)), Some(42)); + + let doc = Document::open(bytes).unwrap(); + assert!(doc.repair_log().is_empty()); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null); + assert_eq!(*doc.try_get(3).unwrap(), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null); + assert_eq!(*doc.resolve(&Object::Ref(ObjRef::new(3, 0))), Object::Null); + assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null); +} + +#[test] +fn overlay_generation_changes_do_not_retarget_old_references_or_clones() { + let mut doc = Document::open(reused_object()).unwrap(); + let snapshot = doc.clone(); + let old = ObjRef::new(3, 1); + let new = ObjRef::new(3, 2); + doc.free(old); + assert_eq!(*doc.get(old), Object::Null); + doc.set(new, Object::Int(99)); + assert_eq!(*doc.get(old), Object::Null); + assert_eq!(*doc.get(new), Object::Int(99)); + assert_eq!(*snapshot.get(old), Object::Int(42)); + assert_eq!(*snapshot.get(new), Object::Null); + + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert!(back.repair_log().is_empty()); + assert_eq!(*back.get(old), Object::Null); + assert_eq!(*back.get(new), Object::Int(99)); +} + +#[test] +fn restoring_snapshot_preserves_changes_to_nonzero_generation_objects() { + for generation in [1, 2] { + let mut saved = Document::open(reused_object()).unwrap(); + let mut current = saved.clone(); + let reference = ObjRef::new(3, generation); + current.set(reference, Object::Int(99)); + current.update_dict(current.root().unwrap(), |catalog| catalog.set(b"Current".to_vec(), Object::Ref(reference))).unwrap(); + + saved.overlay_changes_from(¤t).unwrap(); + assert_eq!(*saved.get(reference), Object::Int(99), "generation {generation}"); + let back = Document::open(Arc::new(write_incremental(&saved, &SaveOptions::default()).unwrap())).unwrap(); + assert!(back.repair_log().is_empty()); + assert_eq!(*back.get(reference), Object::Int(99), "generation {generation}"); + let catalog = back.get(back.root().unwrap()); + assert_eq!(*back.resolve(catalog.as_dict().unwrap().get(b"Current").unwrap()), Object::Int(99)); + } +} + +#[test] +fn full_save_does_not_turn_stale_references_into_live_ones() { + let doc = Document::open(reused_object()).unwrap(); + for object_streams in [false, true] { + let saved = write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(); + let saved = Arc::new(saved); + let oracle = hayro_syntax::Pdf::new(saved.clone()).unwrap(); + let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap(); + assert_eq!(catalog.get::(b"Current"), Some(42)); + assert_eq!(catalog.get::(b"Stale"), None); + let back = Document::open(saved).unwrap(); + assert!(back.repair_log().is_empty()); + let catalog = back.get(back.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + assert_eq!(catalog.get(b"Stale"), Some(&Object::Null)); + assert_eq!(*back.resolve(catalog.get(b"Current").unwrap()), Object::Int(42)); + } +} + +#[test] +fn compressed_objects_only_accept_generation_zero() { + let doc = Document::open(reused_object()).unwrap(); + let saved = write_full(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let current = catalog.as_dict().unwrap().reference(b"Current").unwrap(); + assert_eq!(current.generation, 0); + assert!(matches!(back.xref_entry(current.num), Some(XrefEntry::InStream { .. }))); + assert_eq!(*back.get(current), Object::Int(42)); + assert_eq!(*back.get(ObjRef::new(current.num, 1)), Object::Null); +} + +#[test] +fn indirect_stream_lengths_require_the_referenced_generation() { + for generation in [0, 1] { + // The embedded endstream is data when /Length resolves, but the existing missing- + // length recovery stops there when the reference is stale. + let stream = format!("<< /Length 4 {generation} R >>\nstream\nA\nendstream\nB\nendstream"); + let doc = Document::open(object_file(&[ + (1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), + (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), + (3, 0, &stream), + (4, 1, "13"), + ])) + .unwrap(); + let object = doc.get(ObjRef::new(3, 0)); + let Object::Stream(stream) = &*object else { panic!("expected stream") }; + assert_eq!(&stream.raw[..], if generation == 1 { b"A\nendstream\nB".as_slice() } else { b"A".as_slice() }); + } +} + +/// A newer valid row must not hide damage in a retained older revision. +fn shadowed_generation_disagreement(materialized: bool, shifted: bool) -> Arc> { + let original = contradictory_dictionary(); + let text = std::str::from_utf8(&original).unwrap(); + let previous: usize = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse().unwrap(); + let mut bytes = (*original).clone(); + let offset = bytes.len(); + bytes.extend_from_slice(b"3 1 obj\n<< /Value 77 >>\nendobj\n"); + let xref = bytes.len(); + let size = if materialized { 3 } else { 4 }; + bytes.extend_from_slice( + format!("xref\n3 1\n{offset:010} 00001 n \ntrailer\n<< /Size {size} /Root 1 0 R /Prev {previous} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); + if shifted { + bytes.splice(..0, b"junk before the header\n".iter().copied()); + } + Arc::new(bytes) +} + +#[test] +fn retained_generation_disagreements_are_reported_in_materialized_mode_too() { + for materialized in [false, true] { + for shifted in [false, true] { + let doc = Document::open(shadowed_generation_disagreement(materialized, shifted)).unwrap(); + assert_eq!(doc.revisions().len(), 2, "the xref chain is retained"); + assert!(!doc.reconstructed()); + assert_eq!(doc.get(ObjRef::new(3, 1)).as_dict().unwrap().int(b"Value"), Some(77)); + assert!( + doc.repair_log().iter().any(|line| line.contains("object 3: xref generation 0 disagrees with object header generation 1")), + "materialized={materialized}, shifted={shifted}: {:?}", + doc.repair_log() + ); + } + } +} + +#[test] +fn repair_aware_edits_keep_both_aliases_and_full_save_normalizes_them() { + let mut doc = Document::open(contradictory_entry(false)).unwrap(); + let snapshot = doc.clone(); + doc.set_existing(ObjRef::new(3, 0), Object::Int(77)).unwrap(); + for generation in [0, 1] { + assert_eq!(*doc.get(ObjRef::new(3, generation)), Object::Int(77)); + assert_eq!(*snapshot.get(ObjRef::new(3, generation)), Object::Int(42)); + } + let state = doc.read_state(); + assert!(doc.set_existing(ObjRef::new(3, 2), Object::Int(99)).is_err()); + assert!(state.matches(&doc)); + assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Null); + let mut restored = snapshot.clone(); + restored.overlay_changes_from(&doc).unwrap(); + for changed in [&doc, &restored] { + assert!(changed.full_save_required()); + for bytes in [ + write_incremental(changed, &SaveOptions::default()).unwrap(), + write_full(changed, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(), + ] { + let bytes = Arc::new(bytes); + let oracle = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let catalog = oracle.xref().get::>(ObjectIdentifier::new(1, 0)).unwrap(); + assert_eq!(catalog.get::(b"Current"), Some(77)); + assert_eq!(catalog.get::(b"Stale"), Some(77), "both source aliases stay live"); + let back = Document::open(bytes).unwrap(); + assert!(back.repair_log().is_empty(), "normalized output has no generation repairs"); + } + } + // Explicit replacement keeps its narrower, caller-supplied generation contract. + doc.set(ObjRef::new(3, 2), Object::Int(99)); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Null); + assert_eq!(*doc.get(ObjRef::new(3, 2)), Object::Int(99)); +} + +#[test] +fn hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them() { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, generation, body) in [(1, 0, "<< /Type /Catalog /Pages 2 0 R >>"), (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), (3, 1, "42")] { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{num} {generation} obj\n{body}\nendobj\n").as_bytes()); + } + let hybrid = bytes.len(); + offsets.push(hybrid); + bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 4 2] /Index [3 1] /Length 7 >>\nstream\n"); + bytes.push(1); + bytes.extend_from_slice(&(offsets[2] as u32).to_be_bytes()); + bytes.extend_from_slice(&0u16.to_be_bytes()); + bytes.extend_from_slice(b"\nendstream\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for (i, offset) in offsets.into_iter().enumerate() { + let generation = u16::from(i == 2); + bytes.extend_from_slice(format!("{offset:010} {generation:05} n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R /XRefStm {hybrid} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(!doc.reconstructed()); + assert!(doc.xref_oddities().iter().any(|(_, num)| *num == 3), "the conflicting hybrid row is set aside"); + assert_eq!(*doc.get(ObjRef::new(3, 1)), Object::Int(42)); + assert_eq!(*doc.get(ObjRef::new(3, 0)), Object::Null, "the table still determines accepted references"); + assert!(doc.repair_log().iter().any(|line| line.contains("object 3: xref generation 0 disagrees with object header generation 1"))); +} + +#[test] +fn discarded_xref_repair_notes_do_not_survive_object_header_reconstruction() { + let bytes = + String::from_utf8((*contradictory_dictionary()).clone()).unwrap().replace("/Root 1 0 R", "/Root 99 0 R").replace("/Size 4", "/Size 3"); + let doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert!(doc.reconstructed()); + assert!(doc.repair_log().iter().all(|line| !line.contains("xref generation")), "{:?}", doc.repair_log()); +} + +#[test] +fn restoring_equal_values_still_restores_the_accepted_generation_aliases() { + for edited in [false, true] { + for value in [42, 77] { + let mut current = Document::open(contradictory_entry(false)).unwrap(); + if edited { + current.set_existing(ObjRef::new(3, 0), Object::Int(42)).unwrap(); + } + let mut saved = Document::open(object_file(&[ + (1, 0, "<< /Type /Catalog /Pages 2 0 R /Stale 3 0 R /Current 3 1 R >>"), + (2, 0, "<< /Type /Pages /Kids [] /Count 0 >>"), + (3, 0, &value.to_string()), + ])) + .unwrap(); + assert_eq!(*saved.get(ObjRef::new(3, 1)), Object::Null); + saved.overlay_changes_from(¤t).unwrap(); + for generation in [0, 1] { + assert_eq!(*saved.get(ObjRef::new(3, generation)), Object::Int(42), "edited={edited}, value={value}"); + } + assert!(saved.full_save_required()); + } + } +} + +#[test] +fn repaired_page_tree_aliases_keep_one_identity_in_full_saves() { + let source = object_file_with_entry( + &[ + (1, 0, "<< /Type /Catalog /Pages 2 1 R /Stale 2 2 R >>"), + (2, 1, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), + (3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>"), + ], + Some((2, 0, false)), + ); + for edited in [false, true] { + let mut doc = Document::open(source.clone()).unwrap(); + if edited { + doc.update_dict(ObjRef::new(2, 0), |dict| dict.set(b"Edited".to_vec(), Object::Bool(true))).unwrap(); + } + let mut restored = Document::open(source.clone()).unwrap(); + restored.overlay_changes_from(&doc).unwrap(); + for current in [&doc, &restored] { + for object_streams in [false, true] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let mut saves = vec![write_full(current, &opts).unwrap()]; + if edited { + saves.push(write_incremental(current, &opts).unwrap()); + } + for bytes in saves { + let back = Document::open(Arc::new(bytes)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let pages_ref = catalog.reference(b"Pages").unwrap(); + let pages = back.get(pages_ref); + let child_ref = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].as_ref().unwrap(); + let child = back.get(child_ref); + assert_eq!(child.as_dict().unwrap().reference(b"Parent"), Some(pages_ref), "edited={edited}, packed={object_streams}"); + assert!(matches!(catalog.get(b"Stale"), Some(Object::Null)), "unrelated generations stay null"); + assert!(back.repair_log().is_empty()); + } + } + } + } +} + +#[test] +fn restoring_explicit_sets_removes_old_generation_aliases() { + for value in [42, 77] { + let mut current = Document::open(contradictory_entry(false)).unwrap(); + current.set(ObjRef::new(3, 0), Object::Int(42)); + for repaired_overlay in [false, true] { + let mut saved = Document::open(contradictory_entry(false)).unwrap(); + if repaired_overlay { + saved.set_existing(ObjRef::new(3, 0), Object::Int(value)).unwrap(); + } + saved.overlay_changes_from(¤t).unwrap(); + assert_eq!(*saved.get(ObjRef::new(3, 0)), Object::Int(42)); + assert_eq!(*saved.get(ObjRef::new(3, 1)), Object::Null, "value={value}, overlay={repaired_overlay}"); + } + } +} diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs index 52cf05d6c2..1bd7ee1fd6 100644 --- a/crates/cos/tests/it/main.rs +++ b/crates/cos/tests/it/main.rs @@ -3,6 +3,7 @@ mod bounded_writer; mod corpus; mod encryption; +mod generations; mod proptests; mod shared_streams; mod writer; diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index eb03523189..2716b9242e 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -358,11 +358,32 @@ fn objects_referenced_by_the_encrypt_dictionary_stay_outside_object_streams() { let cf = doc.get(enc_ref).as_dict().unwrap().get(b"CF").cloned().unwrap(); let cf_ref = doc.add(cf); doc.update_dict(enc_ref, |d| d.set(b"CF".to_vec(), Object::Ref(cf_ref))).unwrap(); - let packed = write_full(&doc, &SaveOptions::default()).unwrap(); - let back = Document::open(Arc::new(packed.clone())).expect("reopens"); - assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); - assert_eq!(title(&back), b"Caf\xe9 (draft)"); - assert_eq!(hayro_pages(&packed), 4); + // Visit this alias from Root before the encryption walk reaches /CF's other alias. + doc.update_dict(doc.root().unwrap(), |d| d.set(b"CFProbe".to_vec(), Object::Ref(cf_ref))).unwrap(); + let classic = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let saved = Document::open(Arc::new(classic.clone())).unwrap(); + let cf_num = saved.get(saved.root().unwrap()).as_dict().unwrap().reference(b"CFProbe").unwrap().num; + for damaged in [false, true] { + let mut bytes = classic.clone(); + if damaged { + // Equal-length edits preserve the xref offsets and its generation-zero row. + for (from, to) in [(format!("\n{cf_num} 0 obj"), format!("\n{cf_num} 1 obj")), (format!("/CF {cf_num} 0 R"), format!("/CF {cf_num} 1 R"))] + { + let start = bytes.windows(from.len()).position(|w| w == from.as_bytes()).unwrap(); + bytes[start..start + from.len()].copy_from_slice(to.as_bytes()); + } + } + let doc = Document::open(Arc::new(bytes)).unwrap(); + let packed = write_full(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(packed.clone())).expect("reopens"); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(title(&back), b"Caf\xe9 (draft)"); + assert_eq!(hayro_pages(&packed), 4); + let probe = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"CFProbe").unwrap(); + let encrypt = back.get(back.trailer().reference(b"Encrypt").unwrap()); + assert_eq!(encrypt.as_dict().unwrap().reference(b"CF"), Some(probe)); + assert!(matches!(back.xref_entry(probe.num), Some(pdfcraft_cos::XrefEntry::InFile { .. })), "crypt filters must stay outside object streams"); + } } #[test] diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index f4680bcc5c..72656fa1bb 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1691,6 +1691,12 @@ pub(crate) fn selectable_images( .collect()) } +/// Whether an XObject drawn at or after operator `from` of the page's joined operators shows +/// inside `rect` (user space). +pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: usize) -> Result { + Ok(page_images(doc, page)?.iter().any(|i| i.op >= from && i.visible_rect.and_then(|v| intersection(v, rect)).is_some())) +} + fn transform_image( doc: &mut Document, page: &pdfcraft_model::Page, diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 44a6730acc..b95c4296f9 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -77,7 +77,7 @@ use marks::{matrix_number, n}; pub(crate) use place::undrawable; #[cfg(test)] use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role}; -use place::{check, check_on_top, contents, page_list, place_tagged}; +use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{stamp, undrawable_message}; #[cfg(test)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index b535d60563..27ed7b3793 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -371,6 +371,76 @@ pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: Ok(mark_ref) } +/// Draw `ops`, written for the space whose CTM is `ctm`, on top of everything on `page`, tagged +/// `Edited`. A rewritten invisible paragraph goes here when [`revealed_under_paint`]: an OCR +/// layer written before the scan image it lies under would keep the revealed text hidden (#3973). +pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: [f64; 6], ops: &[pdfcraft_content::Op]) -> Result<(), EditError> { + if ops.is_empty() { + return Ok(()); + } + use pdfcraft_content::Op; + let mut all = vec![Op::new("q", vec![]), Op::new("cm", ctm.iter().map(|v| pdfcraft_content::num(*v)).collect())]; + all.extend_from_slice(ops); + all.push(Op::new("Q", vec![])); + place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) +} + +/// Where a rewritten paragraph can be drawn, in user space ([`revealed_under_paint`]). +pub(crate) struct Reach { + /// The paragraph's old box, where its cover goes. + pub rect: [f64; 4], + /// The move asked for (`BlockStyle::offset`). + pub offset: [f64; 2], + /// The widest line the rewrite may draw, how many lines it has, and how far apart they are. + pub width: f64, + pub rows: usize, + pub pitch: f64, + pub upright: bool, +} + +impl Reach { + /// The old box and the moved one, grown to `width` either way (alignment may grow a line left + /// or right), down to `rows` lines and up one `pitch` above its top (a larger size on the first line, however many lines the old box had); turned text grows by the lines' diagonal every way. + /// Unbounded when a number is not finite. + pub(crate) fn bounds(&self) -> [f64; 4] { + const UNBOUNDED: [f64; 4] = [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY]; + let ([dx, dy], r) = (self.offset, self.rect); + if !r.iter().chain(&self.offset).chain([&self.width, &self.pitch]).all(|v| v.is_finite()) { + return UNBOUNDED; + } + let moved = [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]; + let rows = f64::from(u32::try_from(self.rows).unwrap_or(u32::MAX)); + let (width, height) = (self.width.abs(), self.pitch.abs() * rows); + let grown = if self.upright { + [moved[0].min(moved[2] - width), moved[1].min(moved[3] - height), moved[2].max(moved[0] + width), moved[3] + self.pitch.abs()] + } else { + let d = width.hypot(height); + [moved[0] - d, moved[1] - d, moved[2] + d, moved[3] + d] + }; + let b = [r[0].min(grown[0]), r[1].min(grown[1]), r[2].max(grown[2]), r[3].max(grown[3])]; + if b.iter().all(|v| v.is_finite()) { b } else { UNBOUNDED } + } +} + +/// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after +/// operator `at` of the page's joined `ops` paints over `reach` (user space, [`Reach::bounds`]), as the scan image +/// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline +/// image or a shading after it counts anywhere, and so does any XObject when the page's images +/// cannot be listed. +pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, reach: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { + let later = ops.get(at..).unwrap_or_default(); + if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) { + return invisible; + } + later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, reach, at).unwrap_or(true) +} + +/// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, +/// none otherwise. The page was checked to take them ([`check_on_top`]) before the edit began. +pub(crate) fn lift_on_top(over: bool, paragraph: &mut Vec) -> Vec { + if over { std::mem::take(paragraph) } else { Vec::new() } +} + fn too_deep_on_top() -> EditError { EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into()) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e78da842b8..cbea5c21d2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6213,6 +6213,126 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } +/// Invisible text moves on top only when something painted after it covers it: an XObject drawn +/// over its box (an image, or a form holding one), or any inline image or shading. An image +/// clipped away entirely, or drawn elsewhere, does not move it. +#[test] +fn revealed_text_moves_on_top_only_when_painted_over() { + let on_top_of = |doc: Document| { + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at) + }; + let on_top = |content: &str| on_top_of(image_page_with(content)); + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it"); + assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere"); + assert!(!on_top(&format!("{scan} BT /F1 12 Tf 3 Tr 110 150 Td (Over) Tj ET")), "the scan is drawn first"); + assert!(!on_top(&format!("BT /F1 12 Tf 0 Tr 110 150 Td (Shown) Tj ET {scan}")), "visible text stays where it is"); + assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); + let under = "BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET"; + let elsewhere = "q 50 0 0 50 400 600 cm /Im0 Do Q"; + assert!(!on_top(&format!("{under} q 0 0 10 10 re W n {scan} Q")), "the scan is clipped away entirely"); + assert!(on_top(&format!("{under} {elsewhere} {scan}")), "two images, the second over it"); + assert!(on_top(&format!("{under} {scan} {elsewhere}")), "two images, the first over it"); + assert!(!on_top(&format!("{under} {elsewhere} {elsewhere}")), "two images, neither over it"); + let in_form = |content: &str| { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 7 0 R >> >> >>"; + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im0 6 0 R >> >>", "/Im0 Do"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + page.into(), + stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + image, + form, + ]; + on_top_of(build(&objs.iter().map(String::as_str).collect::>())) + }; + assert!(in_form(&format!("{under} q 200 0 0 100 100 100 cm /Fm0 Do Q")), "a form holding the scan, over it"); + assert!(!in_form(&format!("{under} q 50 0 0 50 400 600 cm /Fm0 Do Q")), "a form holding an image elsewhere"); +} + +/// Revealed OCR text is checked where the rewrite lands, not only where it was: an image drawn +/// after it over just the moved or grown text still lifts the text on top, on both rewrite paths. +#[test] +fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() }; + let sized = text::BlockStyle { size: Some(24.0), ..Default::default() }; + let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() }; + let range = text::RangeStyle { + range: 0..2, + family: None, + resource: None, + size: Some(120.0), + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let enlarged = text::BlockStyle { ranges: vec![range], ..Default::default() }; + let above = "q 200 0 0 60 100 200 cm /Im0 Do Q"; + let cases = [ + ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved), + ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved), + ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there, this line now runs well past the scan's left edge", &text::BlockStyle::default()), + ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()), + ("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()), + ]; + let low = "q 200 0 0 30 100 300 cm /Im0 Do Q"; + let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([ + ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low), + ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low), + ("BT /F1 12 Tf 3 Tr 110 150 Td (Hi there) Tj ET", "Hi there", &enlarged, above), + ]); + for (content, edit, style, scan) in cases { + let mut doc = image_page_with(&format!("{content} {scan}")); + let before = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + assert!(!revealed_under_paint(&doc, 0, true, before.rect, &ops, at), "{edit:?}: the old box alone is clear of the scan"); + text::rewrite_block(&mut doc, 0, 0, Some(edit), style).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let scan_at = ops.iter().position(|o| o.is("Do")).unwrap(); + let shows = ["Tj", "TJ", "'", "\""]; + assert!(ops.iter().skip(scan_at).any(|o| shows.iter().any(|s| o.is(s))), "{edit:?}: the rewrite is drawn over the scan"); + } +} + +/// [`crate::Reach`] grows the old box to the move, the width either way and the lines below. +#[test] +fn a_rewrites_reach_covers_its_move_width_and_lines() { + let reach = |offset, width, rows, upright| crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset, width, rows, pitch: 10.0, upright }.bounds(); + assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 110.0]); + assert_eq!(reach([5.0, -50.0], 20.0, 1, true), [10.0, 40.0, 35.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 110.0]); + assert_eq!(reach([0.0, 0.0], 30.0, 4, false), [-40.0, 40.0, 80.0, 150.0]); + assert_eq!(reach([f64::NAN, 0.0], 20.0, 1, true)[0], f64::NEG_INFINITY); + let larger = crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset: [0.0, 0.0], width: 20.0, rows: 1, pitch: 24.0, upright: true }; + assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 124.0], "a larger size grows the line up as well as down"); +} + +/// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep +/// for that is refused before either rewrite path changes anything. +#[test] +fn an_ocr_rewrite_refused_on_top_leaves_the_document_as_it_was() { + let content = format!("{} BT /F1 20 Tf 3 Tr 42 44 Td (John) Tj ET /Im Do {}", "/X BMC ".repeat(10_001), "EMC ".repeat(10_001)); + let mut doc = text_page(&content); + assert!(text::text_blocks(&doc, 0).unwrap()[0].invisible); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + for edit in ["Jane", "Jane\nDoe"] { + let error = text::rewrite_block(&mut doc, 0, 0, Some(edit), &text::BlockStyle::default()).unwrap_err(); + assert!(error.to_string().contains("nested too deeply"), "{edit:?}: {error}"); + assert!(write_full(&doc, &SaveOptions::default()).unwrap() == before, "{edit:?}: a refused rewrite changes nothing"); + } +} + #[test] fn group_move_budget_refusals_keep_the_original_page_selectable() { let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995))); diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 9ddf4db4b1..48fe956f4f 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -31,7 +31,8 @@ pub struct TextBlock { /// Byte ranges in `editor_text` and their source line indexes. pub spans: Vec<(std::ops::Range, usize)>, /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose - /// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]). + /// pixels show the words. A rewrite draws the new text visibly, on top of the page when + /// the scan is drawn after it (see [`BlockStyle::cover`]). pub invisible: bool, } diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 2a9f6da61a..5114a76263 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -18,13 +18,33 @@ pub(super) struct TextState { /// graphics state the text was painted with that [`TextState::ops`] doesn't restate. pub(super) stroke: Vec, pub(super) line_width: Option, - ext_gstate: Vec>, + ext_gstate: Vec, /// The dash pattern and line cap in force (see [`Ts`]): compared, not restated. pub(super) dash: Dash, pub(super) line_cap: i64, } impl TextState { + /// The state a page's content starts in (ISO 32000-2 §8.4.1, §9.3.1): what is in force on + /// top of the page, after its content wrapped in `q`/`Q`. + pub(crate) fn initial() -> Self { + TextState { + font: None, + char_spacing: 0.0, + word_spacing: 0.0, + scale: 1.0, + leading: 0.0, + rise: 0.0, + fill: Vec::new(), + render_mode: 0, + stroke: Vec::new(), + line_width: None, + ext_gstate: Vec::new(), + dash: (Vec::new(), 0.0), + line_cap: 0, + } + } + pub(super) fn of(ts: &Ts) -> Self { TextState { font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)), @@ -54,13 +74,17 @@ impl TextState { /// /// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force: /// run these inside a `q`/`Q` that starts from `current`, one per state drawn. - pub(crate) fn paint_ops(&self, current: &TextState) -> Vec { + /// + /// `ctm` is the CTM where they run. A soft mask is placed by the CTM its `gs` ran at + /// (ISO 32000-2 §11.6.5.2), so one selected at another CTM is selected there and the CTM + /// set back. + pub(crate) fn paint_ops(&self, current: &TextState, ctm: [f64; 6]) -> Vec { let n = pdfcraft_content::num; let mut v = Vec::new(); // The selections both share stay in force; the ones after them are selected again in order. let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count(); - for name in self.ext_gstate.iter().skip(shared) { - v.push(Op::new("gs", vec![Object::Name(name.clone())])); + for sel in self.ext_gstate.iter().skip(shared) { + sel.push_ops(&mut v, ctm); } // A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it. let reselected = !v.is_empty(); @@ -222,7 +246,7 @@ pub(super) struct Ts { /// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0). pub(super) line_width: Option, /// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]). - pub(super) ext_gstate: Vec>, + pub(super) ext_gstate: Vec, /// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`). pub(super) dash: Dash, /// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt). @@ -255,12 +279,38 @@ const MAX_GS: usize = 32; /// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute /// value, so a later selection of the same dictionary overrides the earlier one completely: the /// earlier entry goes, which keeps replaying the list equivalent to replaying the stream. -fn select_gs(list: &mut Vec>, name: &[u8]) { - list.retain(|n| n != name); +fn select_gs(list: &mut Vec, name: &[u8], anchor: Option<[f64; 6]>) { + list.retain(|s| s.name != name); if list.len() >= MAX_GS { list.remove(0); } - list.push(name.to_vec()); + list.push(GsSelection { name: name.to_vec(), anchor }); +} + +/// One `gs` selection: the ExtGState's resource name, and the CTM it ran at when the dictionary +/// has a soft mask, which that CTM places (ISO 32000-2 §11.6.5.2). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GsSelection { + name: Vec, + anchor: Option<[f64; 6]>, +} + +impl GsSelection { + /// Select this ExtGState from where the CTM is `ctm`: at its anchor, then back to `ctm`. Both + /// moves need an invertible matrix; without one it is selected where it runs. + fn push_ops(&self, v: &mut Vec, ctm: [f64; 6]) { + let gs = Op::new("gs", vec![Object::Name(self.name.clone())]); + let moves = self.anchor.filter(|a| *a != ctm).and_then(|a| { + let (anchor, here) = (Matrix(a), Matrix(ctm)); + Some((anchor.then(&here.invert()?), here.then(&anchor.invert()?))) + }); + let Some((to, back)) = moves else { + v.push(gs); + return; + }; + let cm = |m: Matrix| Op::new("cm", m.0.iter().map(|x| pdfcraft_content::num(*x)).collect()); + v.extend([cm(to), gs, cm(back)]); + } } /// The text-showing operators of one stream with their text, font, box and baseline. @@ -879,14 +929,15 @@ pub(super) fn interpret( b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), b"gs" => { - if let Some(name) = op.name(0) { - select_gs(&mut ts.ext_gstate, name); - } let mut held = None; let params = match op.name(0).and_then(|name| states.get(name)) { Some(p) => super::dict_in_place(doc, p, &mut held), None => None, }; + if let Some(name) = op.name(0) { + let masked = params.and_then(|p| p.get(b"SMask")).is_some_and(|m| doc.resolve(m).as_dict().is_some()); + select_gs(&mut ts.ext_gstate, name, masked.then_some(ts.ctm.0)); + } if let Some(params) = params { ts.gs.apply(doc, params); // `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5). diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index ec2fac984c..d27d23e0b5 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -702,11 +702,13 @@ fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, let mut ops = vec![Op::new("q", vec![])]; ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); // Moved: a translation inside that state. + let mut ctm = Matrix(plan.emit.ctm); if let Some((ax, ay)) = plan.movement { ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + ctm = Matrix([1.0, 0.0, 0.0, 1.0, ax, ay]).then(&ctm); } // Painted as the paragraph was, including what its text object set before the first show. - ops.extend(plan.state.paint_ops(plan.after)); + ops.extend(plan.state.paint_ops(plan.after, ctm.0)); ops.push(Op::new("BT", vec![])); let mut state = plan.state.clone(); state.word_spacing = 0.0; @@ -816,6 +818,11 @@ pub(crate) fn rewrite_block_from( let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; members.iter().try_for_each(|line| line.ensure_editable())?; let p = page_dict(doc, page)?; + // Revealed OCR text may go on top of the page (see `revealed_under_paint`): a page that can't + // take that is refused here, before either rewrite path changes anything. + if b.invisible { + crate::check_on_top(doc, &p)?; + } let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; @@ -930,9 +937,28 @@ pub(crate) fn rewrite_block_from( char_spacing: o_state.char_spacing, lead, }; - let plan = - BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let at = if split { start } else { emit.bt_op }; + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let widest = wrapped.iter().map(|(l, _, _)| advance.of(l)).fold(width, f64::max); + let reach = crate::Reach { rect: b.rect, offset: [dx, dy], width: widest, rows: wrapped.len(), pitch: lead * k, upright }; + let over = crate::revealed_under_paint(doc, page, b.invisible, reach.bounds(), &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; + let plan = BlockPlan { + block: &b, + lines: &lines, + style, + emit, + after: drawn_from, + movement, + state: o_state, + tm, + size, + underline: style.underline == Some(true), + }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let on_top = crate::lift_on_top(over, &mut block_ops); let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { @@ -940,7 +966,6 @@ pub(crate) fn rewrite_block_from( block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !dropped.contains(&i)) @@ -957,12 +982,14 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; + crate::draw_on_top(doc, &p, emit.ctm, &on_top)?; Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a /// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text. -/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it. +/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one +/// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]). pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { if !block.invisible { return Ok(Vec::new()); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 885a0a7158..7a9204a8c0 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,3 +1,4 @@ +use super::super::interp::TextState; use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, @@ -401,6 +402,16 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) } +/// How far below a row of `size` the next one is written: the source's leading unless a size +/// or line spacing is chosen. +fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 { + if style.line_spacing.is_none() && style.size.is_none() { + source_leading + } else { + style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(size) + } +} + type WrappedStyledRows = (StyledRows, RowRanges, Vec, Vec, Vec); fn wrap_styled_rows( @@ -976,6 +987,7 @@ pub(crate) fn rewrite_styled_block( let o = &lead.origin; // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; + let at = if split { start } else { o.bt_op }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; @@ -989,6 +1001,7 @@ pub(crate) fn rewrite_styled_block( .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + let run_ctm = Matrix([1.0, 0.0, 0.0, 1.0, tx, ty]).then(&Matrix(o.ctm)).0; // A new row can land one leading above the next paragraph and look like the same // paragraph. When every row is in this rewrite, a paragraph marker keeps them in one // scope so the neighbour stays out (#2517). A row left in the source stream cannot share @@ -1007,6 +1020,19 @@ pub(crate) fn rewrite_styled_block( }) .rfind(|v| *v > 0.0) .unwrap_or(first.size * 1.5); + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let largest = + block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max); + // At least one line of the largest glyphs, so a larger range reaches up as far as it draws. + const LINE_EM: f64 = 1.2; + let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * LINE_EM); + let widest = measured.iter().copied().fold(width, f64::max); + // This path lays rows out along user-space baselines, so the paragraph is upright here. + let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; + let over = crate::revealed_under_paint(doc, page, block.invisible, reach.bounds(), &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; let mut baseline = first.origin.baseline; for (row_index, row) in rows.iter().enumerate() { let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); @@ -1073,7 +1099,7 @@ pub(crate) fn rewrite_styled_block( // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); - block_ops.extend(state.paint_ops(after)); + block_ops.extend(state.paint_ops(drawn_from, run_ctm)); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); @@ -1164,21 +1190,17 @@ pub(crate) fn rewrite_styled_block( x += segment_width; pos = end; } - baseline -= if style.line_spacing.is_none() && style.size.is_none() { - source_leading - } else { - style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) - }; + baseline -= row_step(style, anchor.size, source_leading); } push_whole_paragraph_marker(&mut block_ops, marked, "EMC"); block_ops.push(Op::new("Q", vec![])); + let on_top = crate::lift_on_top(over, &mut block_ops); block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; // A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is // safe. The close is not part of `block_ops`: that vector can end inside a text object // when the rewrite splits one, and a kept row's operators come later in the stream. @@ -1190,5 +1212,6 @@ pub(crate) fn rewrite_styled_block( d.set(b"Contents".to_vec(), contents); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + crate::draw_on_top(doc, &p, o.ctm, &on_top)?; Ok(LineEdit { substituted }) } diff --git a/crates/engine/src/camera_clean.rs b/crates/engine/src/camera_clean.rs index 9c4120be56..d1768a2a83 100644 --- a/crates/engine/src/camera_clean.rs +++ b/crates/engine/src/camera_clean.rs @@ -1,6 +1,8 @@ -//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level): even out -//! the lighting so the paper turns white (room-light colour casts, a hand's shadow, darker -//! edges go with it), then raise the contrast and drop isolated specks. Colour ink stays colour. +//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level) or a +//! scanned page's picture (Enhance ▸ Scanned document, [`crate::scan_clean`]): even out the +//! lighting so the paper turns white (room-light colour casts, a hand's shadow, darker edges and +//! book gutters go with it), then raise the contrast and drop isolated specks. Colour ink stays +//! colour. //! //! The paper's illumination is estimated per channel on a coarse grid of blocks, from each //! block's bright end (the paper, not the ink), smoothed into a continuous map and divided out. @@ -8,6 +10,8 @@ /// The strongest a level can be. pub const MAX_LEVEL: u8 = 100; +/// The level from which [`clean`] also drops isolated specks. +pub const DESPECKLE_FROM: u8 = 60; /// How an enhanced camera page looks. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -66,34 +70,158 @@ pub fn apply(rgb: &[u8], width: u32, height: u32, look: Look, level: u8) -> Opti let even = clean(rgb, width, height, 50)?; let t = 0.45 + 0.4 * level.min(MAX_LEVEL) as f32 / MAX_LEVEL as f32; let mut grey: Vec = even.as_chunks::<3>().0.iter().map(|p| if (lum(p) as f32 / 255.0) < t { 0 } else { 255 }).collect(); - despeckle_grey(&mut grey, w, h); + despeckle(&mut grey, 1, w, h); Pixels::Grey(grey) } }) } -/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest). -/// Returns `None` when the buffer doesn't match the size. +/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest), from +/// [`DESPECKLE_FROM`] without isolated specks. Returns `None` when the buffer doesn't match the +/// size. pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option> { + clean_samples(rgb, 3, width, height, CleanUp { level, edge_shadows: false, despeckle: level >= DESPECKLE_FROM }) +} + +/// What [`clean_samples`] does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CleanUp { + /// 0 (unchanged) … 100: even out the paper's illumination and raise the contrast, as + /// [`clean`]'s level. + pub level: u8, + /// Even out shadows along the picture's edges first (a book's gutter, a copier's dark edge): + /// lightened line by line to the paper beside them, within the outer fifth of each side. + pub edge_shadows: bool, + /// Drop isolated dark specks. + pub despeckle: bool, +} + +/// [`clean`] for samples of `n` components (1: grey, 3: RGB), with each step chosen apart (each +/// works at level 0 too). Returns `None` when `n` is neither or the buffer doesn't match the size. +pub fn clean_samples(px: &[u8], n: usize, width: u32, height: u32, how: CleanUp) -> Option> { let (w, h) = (width as usize, height as usize); - if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() { + if !(n == 1 || n == 3) || w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(n)? != px.len() { return None; } - let level = level.min(MAX_LEVEL); - if level == 0 { - return Some(rgb.to_vec()); + let level = how.level.min(MAX_LEVEL); + let mut out = px.to_vec(); + if how.edge_shadows { + even_edges(&mut out, n, w, h); } + if level > 0 { + out = even_out(&out, n, w, h, level); + } + if how.despeckle { + despeckle(&mut out, n, w, h); + } + Some(out) +} + +/// The share of the width (and of the height) at each side where [`even_edges`] works. +const EDGE_BAND: f32 = 0.2; +/// The most [`even_edges`] lightens a line by; a darker line (a scanner's black border, a +/// picture) is not taken for shadowed paper and stays as it is. +const MAX_EDGE_GAIN: f32 = 3.0; + +/// Even out shadows that run along the picture's edges, in place: a book's gutter, a copier's +/// dark border, a page that curved away from the glass. Within the outer [`EDGE_BAND`] of each +/// side, each column (then each row) gets the paper's level along it (per channel, its 90th +/// percentile, so the text across it doesn't count), and is lightened to the brightest paper of +/// that band. The block grid of [`background`] is too coarse for such a narrow shadow: its +/// blocks borrow the brighter paper next to them. +fn even_edges(px: &mut [u8], n: usize, w: usize, h: usize) { + for vertical in [true, false] { + // Columns: lines across the width, each running down the height; rows the other way. + let (lines, along) = if vertical { (w, h) } else { (h, w) }; + let band = ((lines as f32 * EDGE_BAND) as usize).min(lines / 2); + if band == 0 { + continue; + } + let at = |line: usize, i: usize| if vertical { i * w + line } else { line * w + i }; + // The paper's level of every line in the bands and a few beyond them. + let reach = band.saturating_add(3).min(lines); + let measured = |line: usize| line < reach || line >= lines.saturating_sub(reach); + let mut level: Vec> = vec![None; lines]; + let mut hist = [[0u32; 256]; 3]; + for (line, slot) in level.iter_mut().enumerate().filter(|(line, _)| measured(*line)) { + hist.iter_mut().for_each(|hh| hh.fill(0)); + for i in 0..along { + let p = at(line, i) * n; + for (c, hh) in hist.iter_mut().enumerate().take(n) { + if let Some(v) = px.get(p + c) + && let Some(count) = hh.get_mut(*v as usize) + { + *count += 1; + } + } + } + let want = (along as f32 * 0.9) as u32; + *slot = Some(std::array::from_fn(|c| { + let mut seen = 0; + let v = hist.get(c).and_then(|hh| { + hh.iter().position(|&count| { + seen += count; + seen > want + }) + }); + v.unwrap_or(255) as f32 / 255.0 + })); + } + // Smoothed: the median of five neighbouring lines (fewer at the very edge, centred all + // the same, so a gradient isn't shifted), so one odd line (a rule, a fold) doesn't stand + // out. + let smooth: Vec> = (0..lines) + .map(|line| { + level.get(line).copied().flatten()?; + let r = line.min(lines - 1 - line).min(2); + Some(std::array::from_fn(|c| { + let mut near: Vec = (line - r..=line + r).filter_map(|l| level.get(l).copied().flatten()).map(|v| v[c]).collect(); + near.sort_by(f32::total_cmp); + near.get(near.len() / 2).copied().unwrap_or(1.0) + })) + }) + .collect(); + // Each side's band, and the lines its paper is measured on (the band and a few beyond). + for (shadowed, measured) in [(0..band, 0..reach), (lines - band..lines, lines.saturating_sub(reach)..lines)] { + // The brightest paper there: what the shadow is lightened to. + let paper: [f32; 3] = + std::array::from_fn(|c| measured.clone().filter_map(|l| smooth.get(l).copied().flatten()).map(|v| v[c]).fold(0.0, f32::max)); + for line in shadowed { + let Some(own) = smooth.get(line).copied().flatten() else { continue }; + let gain: [f32; 3] = std::array::from_fn(|c| { + let g = paper[c] / own[c].max(1e-3); + if (1.0..=MAX_EDGE_GAIN).contains(&g) { g } else { 1.0 } + }); + if gain.iter().take(n).all(|&g| g == 1.0) { + continue; + } + for i in 0..along { + let p = at(line, i) * n; + for (c, g) in gain.iter().enumerate().take(n) { + if let Some(v) = px.get_mut(p + c) { + *v = (*v as f32 * g).round().clamp(0.0, 255.0) as u8; + } + } + } + } + } + } +} + +/// The lighting evened out and the contrast raised at `level` (1–100); `px` holds `w` × `h` +/// pixels of `n` components. +fn even_out(px: &[u8], n: usize, w: usize, h: usize, level: u8) -> Vec { let k = level as f32 / MAX_LEVEL as f32; - let background = background(rgb, w, h); + let background = background(px, n, w, h); // Lighting is evened out fully from half strength on; contrast keeps growing to the top. let flatten = (2.0 * k).min(1.0); let white = 1.0 - 0.12 * k; // what reaches white let black = 0.30 * k; // what reaches black - let mut out = Vec::with_capacity(rgb.len()); - for (i, px) in rgb.as_chunks::<3>().0.iter().enumerate() { + let mut out = Vec::with_capacity(px.len()); + for (i, p) in px.chunks_exact(n).enumerate() { let (x, y) = (i % w, i / w); let bg = background.at(x, y); - for (c, &v) in px.iter().enumerate() { + for (c, &v) in p.iter().enumerate() { let v = v as f32 / 255.0; let b = bg.get(c).copied().unwrap_or(1.0).max(0.05); let even = (v / b).min(1.0); @@ -102,10 +230,7 @@ pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option> out.push((stretched * 255.0).round() as u8); } } - if level >= 60 { - despeckle(&mut out, w, h); - } - Some(out) + out } /// The paper's illumination: a grid of per-channel values, read back with bilinear @@ -140,9 +265,10 @@ impl Background { } } -/// Estimate the paper's illumination: per block, the 90th percentile of each channel (bright -/// paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then smoothed. -fn background(rgb: &[u8], w: usize, h: usize) -> Background { +/// Estimate the paper's illumination: per block, the 90th percentile of each of the `n` channels +/// (bright paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then +/// smoothed. Channels past `n` stay white. +fn background(px: &[u8], n: usize, w: usize, h: usize) -> Background { let block = (w.min(h) / 16).clamp(8, 128); let (cols, rows) = (w.div_ceil(block), h.div_ceil(block)); let mut cells = Vec::with_capacity(cols * rows); @@ -150,20 +276,20 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background { for by in 0..rows { for bx in 0..cols { hist.iter_mut().for_each(|hh| hh.fill(0)); - let mut n = 0u32; + let mut count = 0u32; for y in by * block..((by + 1) * block).min(h) { for x in bx * block..((bx + 1) * block).min(w) { - if let Some(p) = rgb.get((y * w + x) * 3..(y * w + x) * 3 + 3) { + if let Some(p) = px.get((y * w + x) * n..(y * w + x) * n + n) { for (c, &v) in p.iter().enumerate() { if let Some(slot) = hist.get_mut(c).and_then(|hh| hh.get_mut(v as usize)) { *slot += 1; } } - n += 1; + count += 1; } } } - let want = (n as f32 * 0.9) as u32; + let want = (count as f32 * 0.9) as u32; cells.push(std::array::from_fn(|c| { let mut seen = 0; let v = hist.get(c).and_then(|hh| { @@ -219,19 +345,15 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background { Background { cols, rows, block: block as f32, cells } } -/// [`despeckle`] for one grey byte per pixel. -fn despeckle_grey(grey: &mut [u8], w: usize, h: usize) { - let mut rgb: Vec = grey.iter().flat_map(|&v| [v, v, v]).collect(); - despeckle(&mut rgb, w, h); - for (g, p) in grey.iter_mut().zip(rgb.as_chunks::<3>().0.iter()) { - *g = p[0]; - } -} - -/// Lighten dark pixels with at most one dark neighbour (specks), in place. -fn despeckle(rgb: &mut [u8], w: usize, h: usize) { - let lum = |p: &[u8; 3]| (p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000; - let dark: Vec = rgb.as_chunks::<3>().0.iter().map(|p| lum(p) < 128).collect(); +/// Lighten dark pixels with at most one dark neighbour (specks), in place; `px` holds `w` × `h` +/// pixels of `n` components (1 or 3). +fn despeckle(px: &mut [u8], n: usize, w: usize, h: usize) { + let lum = |p: &[u8]| match p { + [r, g, b] => (*r as u32 * 299 + *g as u32 * 587 + *b as u32 * 114) / 1000, + [v, ..] => *v as u32, + [] => 255, + }; + let dark: Vec = px.chunks_exact(n.max(1)).map(|p| lum(p) < 128).collect(); let is_dark = |x: isize, y: isize| { x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && dark.get(y as usize * w + x as usize).copied().unwrap_or(false) }; @@ -243,7 +365,7 @@ fn despeckle(rgb: &mut [u8], w: usize, h: usize) { let neighbours = (-1..=1).flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))).filter(|&(dx, dy)| (dx, dy) != (0, 0) && is_dark(x + dx, y + dy)).count(); if neighbours <= 1 - && let Some(p) = rgb.get_mut((y as usize * w + x as usize) * 3..(y as usize * w + x as usize) * 3 + 3) + && let Some(p) = px.get_mut((y as usize * w + x as usize) * n..(y as usize * w + x as usize) * n + n) { p.fill(255); } @@ -388,5 +510,40 @@ mod tests { assert_eq!(clean(&[], 0, 0, 50), None); assert_eq!(clean(&[0; 12], 2, 3, 50), None); assert_eq!(clean(&[9; 3], 1, 1, 255).map(|v| v.len()), Some(3)); + let every = CleanUp { level: 100, edge_shadows: true, despeckle: true }; + assert_eq!(clean_samples(&[0; 12], 2, 2, 3, every), None, "two components"); + assert_eq!(clean_samples(&[0; 5], 1, 2, 3, every), None); + for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 2), (5, 3)] { + assert_eq!(clean_samples(&vec![100; w * h], 1, w as u32, h as u32, every).map(|v| v.len()), Some(w * h)); + } + } + + #[test] + fn edge_shadows_lighten_shadowed_paper_but_not_a_black_border_or_the_middle() { + // Grey paper (200) with a shadow darkening the left 20 columns to 120, a black scanner + // border in the right 8 columns, and a dark block in the middle. + let (w, h) = (100usize, 60usize); + let mut grey = vec![200u8; w * h]; + for y in 0..h { + for x in 0..w { + let v = if x >= w - 8 { + 5 + } else if (40..60).contains(&x) && (20..40).contains(&y) { + 30 + } else if x < 20 { + 120 + (80 * x / 20) as u8 + } else { + 200 + }; + grey[y * w + x] = v; + } + } + let out = clean_samples(&grey, 1, w as u32, h as u32, CleanUp { level: 0, edge_shadows: true, despeckle: false }).unwrap(); + for x in [0, 5, 19] { + assert!(out[30 * w + x].abs_diff(200) <= 3, "column {x}: {} → {}", grey[30 * w + x], out[30 * w + x]); + } + assert_eq!(out[30 * w + w - 3], 5, "a black border is no shadow"); + assert_eq!(out[30 * w + 50], 30, "the middle is untouched"); + assert_eq!(out[30 * w + 30], 200); } } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e479232bbb..47394a7bb5 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -265,7 +265,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ item("In this file", "scan-text", "ocr.recognize", Ready), item("In multiple files", "files", "ocr.recognize_batch", Ready), - item("Enhance scanned file", "sparkles", "ocr.enhance", Planned("M10")), + item("Enhance scanned file", "sparkles", "ocr.enhance", Ready), item("Enhance camera image", "camera", "ocr.enhance_camera", Ready), item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0647726452..9301201661 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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"), diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index 53b890f27c..9d5668ccf5 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -112,7 +112,7 @@ pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option { } /// The largest raster image Form `form` draws through its resources (nested Forms included). -fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { +pub(crate) fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { let mut seen = HashSet::from([form]); let mut best: Option<(u64, ObjRef)> = None; let res = resources(cos, form)?; diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index c9382c1b02..5ede67c811 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -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. diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index ea43733401..1bcf24108f 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -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, }, + /// 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, + text: Option, + }, /// 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, + }, + /// 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, }, + /// 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>, }, + /// 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, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 7a51f9078d..615ac0fea5 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -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. diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 8ef6795f96..588a3a847f 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -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 } => { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 8869e5d1a7..ce51df5e61 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -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 { .. } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d1ee5c15b0..cc78785815 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -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 { + 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 { 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(), - } + }) } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8698dfd804..1c113d0d82 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -35,6 +35,7 @@ mod history; pub mod js; mod layers; pub mod links; +pub mod model3d; pub mod ocr; mod open; pub mod optimizer; @@ -43,6 +44,7 @@ mod pictures; pub mod print_raster; pub mod printcheck; mod protection; +pub mod scan_clean; mod scan_colours; mod scan_fields; mod session; @@ -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}; diff --git a/crates/engine/src/model3d.rs b/crates/engine/src/model3d.rs new file mode 100644 index 0000000000..b95a332a22 --- /dev/null +++ b/crates/engine/src/model3d.rs @@ -0,0 +1,19 @@ +//! The 3D models on a page (ISO 32000-2 §13.6), read so a renderer can draw them. + +pub use pdfcraft_annot::{MAX_MODEL_BYTES, Model3d, Model3dKind}; + +use crate::Document; + +impl Document { + /// The `/Annots` positions of the 3D annotations on 0-based `page`, in order. + pub fn model_3d_annotations(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_annot::model_3d_annotations(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// The decoded model of the 3D annotation at `/Annots` position `index` on 0-based `page`. + pub fn model_3d(&self, page: usize, index: usize) -> Result { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_annot::model_3d(&editor.cos, page, index).map_err(|e| e.to_string()) + } +} diff --git a/crates/engine/src/scan_clean.rs b/crates/engine/src/scan_clean.rs new file mode 100644 index 0000000000..cef7538309 --- /dev/null +++ b/crates/engine/src/scan_clean.rs @@ -0,0 +1,308 @@ +//! Scan & OCR ▸ Enhance ▸ Scanned document: clean up the pictures scanned pages are made of. +//! +//! Unlike Enhance ▸ Camera image, the page is not rendered and replaced: each scan image is +//! decoded, cleaned ([`crate::camera_clean::clean_samples`]) and written back **into the same +//! image object**, at the same size and in the same colour space. So everything else on the +//! page stays as it was: an invisible OCR text layer, comments, links, form fields, other +//! content, and the image's own soft mask, `/Interpolate` and other entries. A JPEG stays a JPEG +//! (at quality 90), any other grey or RGB image is stored losslessly (Flate), and a black and +//! white (1-bit) image stays 1-bit. An image several pages draw is cleaned once. +//! +//! A page is a scan when its pictures cover at least half of it (as for Recognize text); the +//! pictures each covering at least a tenth of the page are its scan. What is not cleaned is +//! reported, never skipped silently: pages that are no scan, and scan images in a form PdfCraft +//! can't rewrite yet (CMYK, indexed, JPEG 2000, JBIG2, CCITT fax, a stencil mask, a remapping +//! decode array, a scan drawn inside grouped Form artwork) or whose data is broken. +//! +//! Like Optimize, the work is a job: [`Session::scan_clean_job`] captures the document, +//! [`ScanCleanJob::run`] cleans anywhere (a worker thread), and [`Session::apply_scan_clean`] +//! makes the result one undoable step, "Enhance scanned file" (the name the Scan & OCR tool has). + +use pdfcraft_cos::{ObjRef, Object, Stream}; + +use crate::camera_clean::{CleanUp, MAX_LEVEL, clean_samples}; +use crate::edit::check_permission; +use crate::{DocId, Edit, EditError, Session, guard, optimize}; + +pub use crate::camera_clean::DESPECKLE_FROM; + +/// The JPEG quality a cleaned JPEG scan is written back at (as Enhance camera image). +const QUALITY: u8 = 90; +/// On a scanned page, an image covering at least this share of the page is part of the scan; +/// smaller ones (a logo or a signature pasted on) are left alone. +const PART_OF_SCAN: f64 = 0.1; +/// Why a scan drawn through grouped Form artwork stays as it is. +const IN_FORM: &str = "it is drawn inside grouped artwork (a form XObject), which can't be cleaned up yet"; + +/// How scanned pages are cleaned up. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScanCleanSettings { + /// Background removal, 0 (off) … 100 (strongest), as Enhance camera image's clean-up level: + /// the paper's illumination is evened out (fully from 50), so a grey, yellowed or unevenly lit + /// background turns white; then the contrast is raised so faint strokes darken. Colour ink + /// stays colour. + pub level: u8, + /// Edge shadow removal: shadows along the scan's edges (a book's gutter, a copier's dark + /// border) are lightened to the paper next to them, line by line, before the level applies. + pub edge_shadows: bool, + /// Despeckle: isolated dark specks (dust, scanner noise), dark pixels with at most one dark + /// neighbour, turn white. The only change a black and white (1-bit) scan takes. + pub despeckle: bool, +} + +impl ScanCleanSettings { + /// The level `level` (capped at 100) with edge shadow removal (unless the level is 0), and + /// despeckling from [`DESPECKLE_FROM`] as the camera tool does. + pub fn at(level: u8) -> Self { + let level = level.min(MAX_LEVEL); + Self { level, edge_shadows: level > 0, despeckle: level >= DESPECKLE_FROM } + } + + /// Whether these settings change anything at all. + pub fn changes_something(self) -> bool { + self.level > 0 || self.edge_shadows || self.despeckle + } +} + +impl Default for ScanCleanSettings { + fn default() -> Self { + Self::at(50) + } +} + +/// What a clean-up did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ScanCleanReport { + /// Pages (0-based) whose scan was cleaned, in order. + pub pages: Vec, + /// Images rewritten. One drawn on several pages counts once. + pub images: usize, + /// Pages left alone because no picture covers most of them. + pub not_scanned: Vec, + /// Scan images left as they were, and why. + pub skipped: Vec, +} + +/// A scan image that was not cleaned. +#[derive(Clone, Debug, PartialEq)] +pub struct SkippedImage { + /// The pages (0-based) that draw it, among those asked for. + pub pages: Vec, + /// Its resource name on the first of them. + pub name: String, + /// Why, in a sentence that follows "skipped because". + pub reason: String, +} + +/// One image to clean: its object, its name and the pages that draw it. +struct Target { + object: ObjRef, + name: String, + pages: Vec, +} + +/// What cleaning scanned pages needs, independent of the session. +pub struct ScanCleanJob { + cos: pdfcraft_cos::Document, + targets: Vec, + settings: ScanCleanSettings, + /// The document's edit generation it was taken at: a result is for that state only. + generation: u64, + report: ScanCleanReport, +} + +/// The cleaned images, ready for [`Session::apply_scan_clean`], and the report. +pub struct ScanCleaned { + images: Vec<(ObjRef, Stream)>, + generation: u64, + pub report: ScanCleanReport, +} + +impl Session { + /// Capture what cleaning up the scans on `pages` (0-based; empty = all) of document `id` + /// needs. Errors when the document can't be changed (read-only, or its permissions forbid + /// changes) or a page doesn't exist. + pub fn scan_clean_job(&self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if let Some(why) = &doc.read_only_reason { + return Err(EditError::ReadOnly(why.clone())); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(String::new()))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&Edit::EnhanceScan { images: Vec::new() }, &p)?; + } + let all = doc.info.pages.len(); + let mut wanted: Vec = if pages.is_empty() { (0..all).collect() } else { pages.to_vec() }; + if let Some(p) = wanted.iter().find(|p| **p >= all) { + return Err(EditError::Invalid(format!("there is no page {}: the document has {all}", p.saturating_add(1)))); + } + wanted.sort_unstable(); + wanted.dedup(); + let mut report = ScanCleanReport::default(); + let mut targets: Vec = Vec::new(); + // Form artwork already reported, and where in `report.skipped`. + let mut forms_reported: Vec<(ObjRef, usize)> = Vec::new(); + for page in wanted { + let Some(info) = doc.info.pages.get(page) else { continue }; + // Ordinary images and scan-holding Forms are classified together, so a page that + // mixes them gets its images cleaned and its Forms reported. + let Some((images, forms, own_scan)) = crate::scan_fields::scan_parts(doc, page, info) else { + report.not_scanned.push(page); + continue; + }; + let forms: Vec<_> = forms.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN).collect(); + if !own_scan && forms.is_empty() { + report.not_scanned.push(page); + continue; + } + // A scan drawn through a Form XObject is a scan all the same: say why it stays. + for (form, _) in forms { + let known = form.object.and_then(|o| forms_reported.iter().find(|(r, _)| *r == o)).and_then(|(_, k)| report.skipped.get_mut(*k)); + match known { + Some(s) if !s.pages.contains(&page) => s.pages.push(page), + Some(_) => {} + None => { + if let Some(o) = form.object { + forms_reported.push((o, report.skipped.len())); + } + report.skipped.push(SkippedImage { pages: vec![page], name: form.name, reason: IN_FORM.into() }); + } + } + } + for (image, _) in images.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN) { + let Some(object) = image.object else { + report.skipped.push(SkippedImage { + pages: vec![page], + name: image.name, + reason: "it is stored inside the page's resources rather than as an object of its own".into(), + }); + continue; + }; + match targets.iter_mut().find(|t| t.object == object) { + Some(t) if !t.pages.contains(&page) => t.pages.push(page), + Some(_) => {} + None => targets.push(Target { object, name: image.name, pages: vec![page] }), + } + } + } + Ok(ScanCleanJob { cos: editor.cos.clone(), targets, settings, generation: doc.edit_generation(), report }) + } + + /// Write the images [`ScanCleanJob::run`] cleaned into document `id` as one undoable step, + /// "Enhance scanned file" (none when nothing was cleaned), and return the report. Refused + /// when the document changed since the job was taken. + pub fn apply_scan_clean(&mut self, id: DocId, cleaned: ScanCleaned) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.edit_generation() != cleaned.generation { + return Err(EditError::Invalid("the document changed while its scans were being cleaned up; nothing was changed, try again".into())); + } + if !cleaned.images.is_empty() { + self.apply(id, Edit::EnhanceScan { images: cleaned.images })?; + } + Ok(cleaned.report) + } + + /// Enhance ▸ Scanned document in one call: [`Self::scan_clean_job`], run, then + /// [`Self::apply_scan_clean`] (the CLI and agents use this). + pub fn enhance_scanned_pages(&mut self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result { + let job = self.scan_clean_job(id, pages, settings)?; + let cleaned = job.run(|_, _| true)?; + self.apply_scan_clean(id, cleaned) + } +} + +impl ScanCleanJob { + /// The images to clean (one drawn on several pages counts once). + pub fn total(&self) -> usize { + self.targets.len() + } + + /// Clean every scan image. `progress(done, total)` is called before each image; returning + /// `false` stops with [`EditError::Cancelled`]. A panic is returned as an error, so a worker + /// thread always reports back. + pub fn run(self, mut progress: impl FnMut(usize, usize) -> bool) -> Result { + guard(move || self.work(&mut progress)).map_err(|m| EditError::Invalid(format!("cleaning up the scans failed unexpectedly ({m})")))? + } + + fn work(self, progress: &mut dyn FnMut(usize, usize) -> bool) -> Result { + let ScanCleanJob { cos, targets, settings, generation, mut report } = self; + let total = targets.len(); + let mut images = Vec::new(); + let mut changed_pages: Vec = Vec::new(); + // One decoding allowance for the whole run, sized by the file. + let mut budget = pdfcraft_cos::ImageBudget::new(&cos); + for (done, target) in targets.into_iter().enumerate() { + if !progress(done, total) { + return Err(EditError::Cancelled); + } + let mut skip = |reason: String| report.skipped.push(SkippedImage { pages: target.pages.clone(), name: target.name.clone(), reason }); + match clean_image(&cos, target.object, settings, &mut budget) { + Ok(Some(stream)) => { + images.push((target.object, stream)); + changed_pages.extend(target.pages.iter().copied()); + } + Ok(None) => skip("it is already clean: nothing changes at these settings".into()), + Err(reason) => skip(reason), + } + } + changed_pages.sort_unstable(); + changed_pages.dedup(); + report.images = images.len(); + report.pages = changed_pages; + report.skipped.sort_by_key(|s| s.pages.first().copied()); + Ok(ScanCleaned { images, generation, report }) + } +} + +/// Image `object` cleaned with `settings`: the new stream, `None` when nothing changes, or why it +/// can't be cleaned. +fn clean_image( + cos: &pdfcraft_cos::Document, + object: ObjRef, + settings: ScanCleanSettings, + budget: &mut pdfcraft_cos::ImageBudget, +) -> Result, String> { + let obj = cos.get(object); + let Object::Stream(s) = &*obj else { return Err("it is not an image".into()) }; + if s.dict.name(b"Subtype") != Some(b"Image") { + return Err("it is not an image".into()); + } + // A colour-key mask names sample values to leave unpainted: cleaned samples would match others. + if matches!(s.dict.get(b"Mask").map(|m| cos.resolve(m)).as_deref(), Some(Object::Array(_))) { + return Err("it has a colour-key mask, which cleaning would change".into()); + } + let samples = optimize::decode_samples(cos, s, budget).map_err(|e| e.to_string())?; + let cleaned = if samples.bilevel { + if !settings.despeckle { + return Err("it is black and white, which only Despeckle changes, and Despeckle is off".into()); + } + clean_samples(&samples.data, 1, samples.width, samples.height, CleanUp { level: 0, edge_shadows: false, despeckle: true }) + } else { + if !settings.changes_something() { + return Ok(None); + } + let how = CleanUp { level: settings.level, edge_shadows: settings.edge_shadows, despeckle: settings.despeckle }; + clean_samples(&samples.data, samples.components, samples.width, samples.height, how) + } + .ok_or("its samples don't match its size")?; + if cleaned == samples.data { + return Ok(None); + } + let cleaned = optimize::Samples { data: cleaned, ..samples }; + optimize::encode_samples(s, &cleaned, QUALITY).map(Some).ok_or_else(|| "it could not be written back".into()) +} + +/// [`Edit::EnhanceScan`]: put the cleaned images in place. Each object must still be an image. +pub(crate) fn write_back(doc: &mut pdfcraft_cos::Document, images: &[(ObjRef, Stream)]) -> Result<(), EditError> { + for (object, stream) in images { + let is_image = matches!(&*doc.get(*object), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")); + if !is_image { + return Err(EditError::Invalid(format!("object {} is no longer an image; the scan was not changed", object.num))); + } + doc.set(*object, Object::Stream(stream.clone())); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs new file mode 100644 index 0000000000..e58ab3e5ef --- /dev/null +++ b/crates/engine/src/scan_clean/tests.rs @@ -0,0 +1,463 @@ +use std::sync::Arc; + +use pdfcraft_testkit::Pdf; + +use super::*; + +const W: usize = 300; +const H: usize = 400; +/// Isolated specks on the paper, away from the text rows. +const SPECKS: [(usize, usize); 3] = [(150, 25), (200, 85), (120, 125)]; + +/// The light falling on the paper at `x`: full in the middle, darker towards a shadowed left +/// edge (as a book's gutter) and a little at the right edge. +fn light(x: usize) -> f32 { + let left = (x as f32 / 60.0).min(1.0); + let right = ((W - 1 - x) as f32 / 30.0).min(1.0); + (0.55 + 0.45 * left) * (0.8 + 0.2 * right) +} + +fn is_stroke(x: usize, y: usize) -> bool { + (60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x) +} + +/// A synthetic scan, `n` components (1 grey, 3 RGB): greyish yellowed paper, a shadowed gutter, +/// dark text strokes and a few specks. +fn scan_pixels(n: usize) -> Vec { + let mut out = Vec::with_capacity(W * H * n); + for y in 0..H { + for x in 0..W { + let ink = if is_stroke(x, y) || SPECKS.contains(&(x, y)) { 0.12 } else { 0.8 }; + let v = ink * light(x); + let px = [v, v, v * 0.85]; + for c in px.iter().take(n) { + out.push((c * 255.0).round().clamp(0.0, 255.0) as u8); + } + } + } + out +} + +fn jpeg(px: &[u8], n: usize) -> Vec { + let mut out = Vec::new(); + let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 }; + image::ImageEncoder::write_image(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95), px, W as u32, H as u32, ty).unwrap(); + out +} + +/// Three pages: 1 a scan (the image `image_dict`/`data` as object 4) with an invisible text +/// layer and a comment, 2 a page of real text, 3 the same scan image drawn again. +fn scan_pdf(image_dict: &str, data: &[u8]) -> Vec { + let resources = "/Resources << /XObject << /Im0 4 0 R >> /Font << /F1 5 0 R >> >>"; + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 8 0 R 9 0 R] /Count 3 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 6 0 R /Annots [7 0 R] >>")) + .stream(&format!("/Type /XObject /Subtype /Image {image_dict}"), data) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q BT 3 Tr /F1 12 Tf 20 380 Td (Invisible OCR words) Tj ET")) + .obj("<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /Contents (A comment) >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 10 0 R >>")) + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 11 0 R >>")) + .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET") + .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q")) + .build() +} + +fn grey_scan() -> Vec { + scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true"), &scan_pixels(1)) +} + +fn open(bytes: Vec) -> (Session, DocId) { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scan.pdf", None, Arc::new(bytes), None).unwrap(); + (s, id) +} + +fn object(s: &Session, id: DocId, num: u32) -> Arc { + s.get(id).unwrap().editor.as_ref().unwrap().cos.get(ObjRef::new(num, 0)) +} + +/// The samples of the image page 1 draws, now. +fn samples(s: &Session, id: DocId) -> optimize::Samples { + let doc = s.get(id).unwrap(); + let object = doc.page_images(0)[0].object.unwrap(); + let cos = &doc.editor.as_ref().unwrap().cos; + let Object::Stream(img) = &*cos.get(object) else { panic!("the image") }; + optimize::decode_samples(cos, img, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)).unwrap() +} + +fn grey_at(px: &optimize::Samples, x: usize, y: usize) -> u8 { + px.data[(y * W + x) * px.components] +} + +#[test] +fn a_scan_gets_white_paper_even_edges_dark_strokes_and_no_specks_in_place() { + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + let kept: Vec> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect(); + let text_before = s.get(id).unwrap().text_lines(0); + let original = s.get(id).unwrap().bytes.clone(); + + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.pages, vec![0, 2], "the shared image counts for both its pages"); + assert_eq!(report.images, 1); + assert_eq!(report.not_scanned, vec![1], "the text page is no scan"); + assert!(report.skipped.is_empty(), "{:?}", report.skipped); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file")); + + let after = samples(&s, id); + assert_eq!((after.width, after.height, after.components, after.jpeg), (W as u32, H as u32, 1, false)); + // Paper, also in the shadowed gutter and at the right edge, turns (nearly) white. + for (x, y) in [(150, 40), (150, 380), (3, 40), (3, 380), (30, 200), (W - 2, 40)] { + assert!(grey_at(&before, x, y) < 210, "the fixture's paper is grey at ({x}, {y})"); + assert!(grey_at(&after, x, y) >= 235, "paper at ({x}, {y}): {} → {}", grey_at(&before, x, y), grey_at(&after, x, y)); + } + // The edge is evened out: the gutter is as white as the middle. + assert!(grey_at(&after, 150, 40).abs_diff(grey_at(&after, 3, 40)) <= 12); + // Strokes stay dark (darker, with the contrast), also in the gutter's light. + for (x, y) in [(150, 62), (61, 102), (238, 342)] { + assert!(grey_at(&after, x, y) <= 40, "stroke at ({x}, {y}): {}", grey_at(&after, x, y)); + } + for (x, y) in SPECKS { + assert!(grey_at(&before, x, y) < 60); + assert!(grey_at(&after, x, y) >= 200, "speck at ({x}, {y}): {}", grey_at(&after, x, y)); + } + // Everything but the image is as it was: the page, its invisible text layer, the comment, the + // font, the other pages. The image keeps its entries. + let now: Vec> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect(); + assert_eq!(kept, now); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3); + let text_after = s.get(id).unwrap().text_lines(0); + assert!(text_after.iter().any(|l| l.text.contains("Invisible OCR words")), "{text_after:?}"); + assert_eq!(text_before.len(), text_after.len()); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.get(b"Interpolate"), Some(&Object::Bool(true))); + assert_eq!(img.dict.name(b"ColorSpace"), Some(&b"DeviceGray"[..])); + + // Undo brings the original image, and the original file, back. + s.undo(id).unwrap(); + assert_eq!(samples(&s, id), before); + assert_eq!(s.get(id).unwrap().bytes, original); + assert_eq!(s.get(id).unwrap().can_undo(), None); +} + +#[test] +fn a_jpeg_scan_stays_a_jpeg_in_colour() { + let px = scan_pixels(3); + let (mut s, id) = open(scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), &jpeg(&px, 3))); + let report = s.enhance_scanned_pages(id, &[0], ScanCleanSettings::at(80)).unwrap(); + // Only page 1 was asked for; the image it shares with page 3 is changed for both. + assert_eq!((report.pages.as_slice(), report.images), (&[0][..], 1)); + let after = samples(&s, id); + assert!(after.jpeg && after.components == 3); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.name(b"Filter"), Some(&b"DCTDecode"[..])); + // Yellowed paper becomes white; the strokes stay dark. + let at = |x: usize, y: usize| after.data[(y * W + x) * 3..][..3].to_vec(); + for (x, y) in [(150, 40), (5, 300)] { + assert!(at(x, y).iter().all(|&v| v >= 230), "paper at ({x}, {y}): {:?}", at(x, y)); + } + assert!(at(150, 63).iter().all(|&v| v <= 60), "stroke: {:?}", at(150, 63)); + // The file still renders. + let doc = s.get(id).unwrap(); + let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()).render(pdfcraft_render::RenderRequest { + page: 0, + scale: 1.0, + ..Default::default() + }); + assert!(shot.error.is_none(), "{:?}", shot.error); +} + +#[test] +fn despeckle_is_its_own_switch() { + let speck = |settings: ScanCleanSettings| { + let (mut s, id) = open(grey_scan()); + s.enhance_scanned_pages(id, &[0], settings).unwrap(); + let after = samples(&s, id); + SPECKS.iter().map(|&(x, y)| grey_at(&after, x, y)).max().unwrap_or(0) + }; + assert!(speck(ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(80) }) < 100, "kept without despeckle"); + assert!(speck(ScanCleanSettings::at(80)) >= 200); + // Despeckle alone, at level 0, changes nothing but the specks. + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: true }).unwrap(); + let after = samples(&s, id); + let changed: Vec = before.data.iter().zip(&after.data).enumerate().filter(|(_, (a, b))| a != b).map(|(i, _)| i).collect(); + let mut specks: Vec = SPECKS.iter().map(|&(x, y)| y * W + x).collect(); + specks.sort_unstable(); + assert_eq!(changed, specks); + // Nothing to do: no undo step, and the report says why. + let (mut s, id) = open(grey_scan()); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: false }).unwrap(); + assert_eq!(report.images, 0); + assert!(report.pages.is_empty()); + assert_eq!(report.skipped.len(), 1); + assert!(report.skipped[0].reason.contains("already clean"), "{:?}", report.skipped); + assert_eq!(s.get(id).unwrap().can_undo(), None); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +fn a_black_and_white_scan_stays_one_bit_and_loses_its_specks() { + // 64 × 64, one bit per pixel, 1 = white: a black bar and two specks. + let (w, h) = (64usize, 64usize); + let mut bits = vec![0xFFu8; w / 8 * h]; + let mut black = |x: usize, y: usize| bits[y * w / 8 + x / 8] &= !(0x80 >> (x % 8)); + for y in 20..30 { + for x in 10..50 { + black(x, y); + } + } + black(5, 5); + black(58, 58); + let bytes = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 64 64] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>") + .stream("/Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 1", &bits) + .stream("", "q 64 0 0 64 0 0 cm /Im0 Do Q") + .build(); + let (mut s, id) = open(bytes); + // Only Despeckle changes a black and white scan: without it the image is reported. + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(100) }).unwrap(); + assert_eq!(report.images, 0); + assert!(report.skipped.first().is_some_and(|k| k.reason.contains("black and white")), "{:?}", report.skipped); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.images, 1); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.int(b"BitsPerComponent"), Some(1)); + let px = samples(&s, id); + assert!(px.bilevel); + let at = |x: usize, y: usize| px.data[y * w + x]; + assert_eq!((at(5, 5), at(58, 58)), (255, 255), "specks gone"); + assert_eq!((at(10, 20), at(30, 25), at(49, 29)), (0, 0, 0), "the bar stays"); + assert_eq!(px.data.iter().filter(|&&v| v == 0).count(), 40 * 10); +} + +#[test] +fn hostile_images_are_reported_not_a_panic() { + // Each page draws one image over the whole page. + let images: [(&str, Vec); 6] = [ + ("/Width 100000 /Height 100000 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![0; 64]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /DCTDecode", vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 3, 9]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![200; 1000]), + ("/Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![128]), + ("/Width 8 /Height 8 /ColorSpace /DeviceCMYK /BitsPerComponent 8", vec![0; 256]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Mask [250 255]", vec![200; 300 * 400]), + ]; + let n = images.len(); + let kids: String = (0..n).map(|i| format!("{} 0 R ", 3 + 3 * i)).collect(); + let mut pdf = Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>").obj(format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>")); + for (i, (dict, data)) in images.iter().enumerate() { + let (img, contents) = (4 + 3 * i, 5 + 3 * i); + pdf = pdf + .obj(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /XObject << /Im0 {img} 0 R >> >> /Contents {contents} 0 R >>" + )) + .stream(&format!("/Type /XObject /Subtype /Image {dict}"), data) + .stream("", "q 300 0 0 400 0 0 cm /Im0 Do Q"); + } + let (mut s, id) = open(pdf.build()); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.images, 0); + assert_eq!(report.not_scanned, vec![3], "a 1 × 1 image is no scan"); + let why: Vec<(usize, &str)> = report.skipped.iter().map(|k| (k.pages[0], k.reason.as_str())).collect(); + assert_eq!(why.len(), 5, "{why:?}"); + assert!(why[0].0 == 0 && why[0].1.contains("80 million"), "{why:?}"); + assert!(why[1].0 == 1 && why[1].1.contains("could not be decoded"), "{why:?}"); + assert!(why[2].0 == 2 && why[2].1.contains("shorter"), "{why:?}"); + assert!(why[3].0 == 4 && why[3].1.contains("colour space"), "{why:?}"); + assert!(why[4].0 == 5 && why[4].1.contains("colour-key mask"), "{why:?}"); + assert_eq!(s.get(id).unwrap().can_undo(), None); + // Pages that don't exist are an error, not a panic. + assert!(matches!(s.enhance_scanned_pages(id, &[99], ScanCleanSettings::default()), Err(EditError::Invalid(_)))); +} + +#[test] +fn a_scan_drawn_inside_a_form_xobject_is_reported_not_called_no_scan() { + // Pages 1 and 3 draw the scan through Form Fm0 (as some scanners write it), page 2 is text. + let page = |contents: usize| { + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> /Font << /F1 9 0 R >> >> /Contents {contents} 0 R >>" + ) + }; + let bytes = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 7 0 R 10 0 R] /Count 3 >>") + .obj(page(6)) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"), + ) + .stream(&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"), scan_pixels(1)) + .stream("", "/Fm0 Do") + .obj(page(8)) + .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .obj(page(11)) + .stream("", "q /Fm0 Do Q") + .build(); + let (mut s, id) = open(bytes); + let before = object(&s, id, 5); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert!(report.pages.is_empty() && report.images == 0, "{report:?}"); + assert_eq!(report.not_scanned, vec![1], "only the text page is no scan"); + assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}"); + let k = &report.skipped[0]; + assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 2][..], "Fm0")); + assert!(k.reason.contains("form XObject"), "{k:?}"); + assert_eq!(object(&s, id, 5), before, "the picture is left as it was"); + assert_eq!(s.get(id).unwrap().can_undo(), None); + + // A page of vector artwork in a Form, holding only a small logo, is no scan. + let logo = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> >> /Contents 6 0 R >>")) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("0 0 {W} {H} re f q 40 0 0 20 10 370 cm /Im0 Do Q"), + ) + .stream("/Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceGray /BitsPerComponent 8", [128; 800]) + .stream("", "/Fm0 Do") + .build(); + let (mut s, id) = open(logo); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!((report.not_scanned.as_slice(), report.skipped.len()), (&[0][..], 0), "{report:?}"); +} + +#[test] +fn a_page_mixing_its_own_image_and_a_scan_in_a_form_cleans_the_one_and_reports_the_other() { + // Page 1: its own image covers 40% and a Form holding a scan 20%: a scan only together. + // Page 2: its own image covers the page and the Form 20% more. Both pages clean Im1 and + // report Fm0. + let page = |contents: usize| { + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R /Im1 6 0 R >> >> /Contents {contents} 0 R >>" + ) + }; + let gray = format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"); + let bytes = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 >>") + .obj(page(7)) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"), + ) + .stream(&gray, scan_pixels(1)) + .stream(&gray, scan_pixels(1)) + .stream("", format!("q {W} 0 0 160 0 240 cm /Im1 Do Q q 1 0 0 0.2 0 0 cm /Fm0 Do Q")) + .obj(page(9)) + .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im1 Do Q q 1 0 0 0.2 0 0 cm /Fm0 Do Q")) + .build(); + let (mut s, id) = open(bytes); + let (form_picture, own) = (object(&s, id, 5), object(&s, id, 6)); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!((report.pages.as_slice(), report.images), (&[0, 1][..], 1), "Im1 is cleaned on both pages: {report:?}"); + assert!(report.not_scanned.is_empty(), "{report:?}"); + assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}"); + let k = &report.skipped[0]; + assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 1][..], "Fm0")); + assert!(k.reason.contains("form XObject"), "{k:?}"); + assert_ne!(object(&s, id, 6), own, "the page's own image is cleaned"); + assert_eq!(object(&s, id, 5), form_picture, "the picture in the form is left as it was"); +} + +#[test] +fn protected_documents_keep_their_encryption_and_restrictions() { + let protect = |user: &str, permissions: i32| { + let mut doc = pdfcraft_cos::Document::open(Arc::new(grey_scan())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: user, + owner_password: "owner", + permissions, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) + }; + // Opened with the password: cleaned, saved still encrypted, and the clean scan reads back. + let mut s = Session::new(); + let id = s.open("x.pdf", None, protect("pw", -1), Some("pw")).unwrap(); + assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap().images, 1); + let saved = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "still protected"); + let id2 = s.open("y.pdf", None, saved, Some("pw")).unwrap(); + assert!(grey_at(&samples(&s, id2), 3, 40) >= 235); + // Changes withheld: refused before any work. + let id = s.open("r.pdf", None, protect("", 0b0100), None).unwrap(); + assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::default()).unwrap_err(), EditError::NotPermitted("changes to the document")); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +fn a_result_for_an_older_state_is_refused() { + let (mut s, id) = open(grey_scan()); + let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap(); + assert_eq!(job.total(), 1); + let cleaned = job.run(|_, _| true).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); + assert!(matches!(s.apply_scan_clean(id, cleaned), Err(EditError::Invalid(_)))); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Rotate page")); + // Cancelled from the progress callback. + let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap(); + assert!(matches!(job.run(|_, _| false), Err(EditError::Cancelled))); +} + +#[test] +fn edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves() { + let gutter = |edge_shadows: bool| { + let (mut s, id) = open(grey_scan()); + s.enhance_scanned_pages(id, &[0], ScanCleanSettings { edge_shadows, ..ScanCleanSettings::at(100) }).unwrap(); + let after = samples(&s, id); + [(3, 40), (3, 380), (10, 200)].map(|(x, y)| grey_at(&after, x, y)) + }; + assert!(gutter(false).iter().any(|&v| v < 200), "{:?}", gutter(false)); + assert!(gutter(true).iter().all(|&v| v >= 235), "{:?}", gutter(true)); + // On its own, at level 0, it lightens the gutter's paper to the paper beside it. + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + s.enhance_scanned_pages(id, &[0], ScanCleanSettings { level: 0, edge_shadows: true, despeckle: false }).unwrap(); + let after = samples(&s, id); + assert!(grey_at(&after, 3, 40).abs_diff(grey_at(&before, 150, 40)) <= 4, "{} vs {}", grey_at(&after, 3, 40), grey_at(&before, 150, 40)); + assert_eq!(grey_at(&after, 150, 40), grey_at(&before, 150, 40), "the middle is left alone"); +} + +/// Two pages, each with its own scan image (objects 4 and 7), so cleaning one page can be told +/// apart from cleaning the other. +fn two_scans_pdf() -> Vec { + let dict = format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"); + let draw = format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"); + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>")) + .stream(&dict, scan_pixels(1)) + .stream("", &draw) + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 7 0 R >> >> /Contents 8 0 R >>")) + .stream(&dict, scan_pixels(1)) + .stream("", &draw) + .build() +} + +#[test] +fn only_the_chosen_pages_scan_is_cleaned() { + // Clean page 1, then page 2 of a fresh copy: the other page's image stays byte-identical. + for (chosen, image, other) in [(0usize, 4u32, 7u32), (1, 7, 4)] { + let (mut s, id) = open(two_scans_pdf()); + let (image_before, other_before) = (object(&s, id, image), object(&s, id, other)); + assert_eq!(s.get(id).unwrap().page_images(chosen)[0].object, Some(ObjRef::new(image, 0)), "the fixture"); + let report = s.enhance_scanned_pages(id, &[chosen], ScanCleanSettings::at(100)).unwrap(); + assert_eq!((report.pages.as_slice(), report.images), (&[chosen][..], 1), "page {}", chosen + 1); + assert_ne!(object(&s, id, image), image_before, "page {}'s image was not cleaned", chosen + 1); + assert_eq!(object(&s, id, other), other_before, "cleaning page {} changed the other page's image", chosen + 1); + let (Object::Stream(was), Object::Stream(now)) = (&*other_before, &*object(&s, id, other)) else { panic!("the images") }; + assert_eq!(was.raw, now.raw, "the other image's stream bytes"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file")); + } +} diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs index 8287743fc5..a1574e9463 100644 --- a/crates/engine/src/scan_fields.rs +++ b/crates/engine/src/scan_fields.rs @@ -19,30 +19,94 @@ const SCALE: f32 = 2.0; const MAX_SIDE: f32 = 3000.0; /// A page is a scan when its images cover at least this share of it. const COVERED: f64 = 0.5; +/// A picture inside Form artwork is taken for a scan when it has at least this resolution over +/// the area the artwork covers (a quarter of the pixels a 72 dpi scan would have). +const MIN_FORM_DPI: f64 = 36.0; /// Recognised "words" less tall than this (points) are specks read as letters, and would make /// the box they sit on look filled in. const MIN_WORD_HEIGHT: f64 = 4.0; /// Whether `page` is a picture of a page: images cover most of it. pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { - let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); - let page_area = (cx1 - cx0) * (cy1 - cy0); - if !page_area.is_finite() || page_area <= 0.0 { - return false; - } - let covered: f64 = doc + scan_images(doc, page, info).is_some() +} + +/// The raster images (larger than one pixel) `page` draws, each with the share of the page (its +/// crop box) it covers, when together they cover most of it ([`is_scan`]); `None` otherwise. +/// Enhance ▸ Scanned document cleans these up ([`crate::scan_clean`]). +pub(crate) fn scan_images(doc: &Document, page: usize, info: &PageInfo) -> Option> { + let area = page_area(info)?; + let images: Vec<(pdfcraft_edit::PageImage, f64)> = doc .page_images(page) - .iter() + .into_iter() .filter(|i| !i.is_form && i.width > 1 && i.height > 1) - .map(|i| { - let r = i.visible_rect.unwrap_or(i.rect); - let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); - let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); - w * h - }) - .filter(|a| a.is_finite()) - .sum(); - covered >= COVERED * page_area + .filter_map(|i| Some((share(info, area, &i)?, i))) + .map(|(share, i)| (i, share)) + .collect(); + let covered: f64 = images.iter().map(|(_, share)| share).sum(); + (covered >= COVERED).then_some(images) +} + +/// A page that is a scan, classified in one pass: its raster images (larger than one pixel) and +/// the grouped Form artwork that holds a raster picture, each with the share of the page it +/// covers, when together they cover most of the page; `None` otherwise. The flag says whether the +/// raster images alone do ([`scan_images`]). Enhance ▸ Scanned document cleans the images and +/// reports the Forms, which it can't clean yet, so a page mixing both gets both. +#[allow(clippy::type_complexity)] +pub(crate) fn scan_parts( + doc: &Document, + page: usize, + info: &PageInfo, +) -> Option<(Vec<(pdfcraft_edit::PageImage, f64)>, Vec<(pdfcraft_edit::PageImage, f64)>, bool)> { + let area = page_area(info)?; + let editor = doc.editor.as_ref(); + let (mut images, mut forms) = (Vec::new(), Vec::new()); + let (mut own, mut covered) = (0.0, 0.0); + for i in doc.page_images(page) { + let Some(share) = share(info, area, &i) else { continue }; + if !i.is_form { + if i.width > 1 && i.height > 1 { + own += share; + covered += share; + images.push((i, share)); + } + } else if let (Some(form), Some(editor)) = (i.object, editor) + && holds_scan(&editor.cos, form, share * area) + { + covered += share; + forms.push((i, share)); + } + } + (covered >= COVERED).then_some((images, forms, own >= COVERED)) +} + +/// Whether Form `form`, covering `points` square points of the page, draws a picture that could +/// be a scan of it: one with at least [`MIN_FORM_DPI`] over that area, not a logo inside a page +/// of vector artwork. +fn holds_scan(cos: &pdfcraft_cos::Document, form: pdfcraft_cos::ObjRef, points: f64) -> bool { + let Some(picture) = crate::copy_image::picture_in_form(cos, form) else { return false }; + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { return false }; + let px = |k: &[u8]| s.dict.int(k).unwrap_or(0).max(0) as f64; + let per_point = MIN_FORM_DPI / 72.0; + px(b"Width") * px(b"Height") >= points * per_point * per_point +} + +/// The area of `info`'s crop box, when it is a usable one. +fn page_area(info: &PageInfo) -> Option { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let area = (cx1 - cx0) * (cy1 - cy0); + (area.is_finite() && area > 0.0).then_some(area) +} + +/// The share of the crop box (of area `area`) that `image` covers, when it is a number. +fn share(info: &PageInfo, area: f64, image: &pdfcraft_edit::PageImage) -> Option { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let r = image.visible_rect.unwrap_or(image.rect); + let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); + let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); + let share = w * h / area; + share.is_finite().then_some(share) } /// The page as displayed, in points with y up: where detection on a scan works. diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 553ddd6aa3..06e6acbfd1 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -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 { + 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, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c54131d02..a20314c2e0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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(" 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::>().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::>().join(" "); + let mut objs: Vec = 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 { + 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 { + 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("; ")); +} diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 8009ef1bbf..53ca309326 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -207,3 +207,192 @@ fn visible_stretched_text_rewrites_unchanged() { let i = ((y * out.width + x) * 4) as usize; assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]); } + +/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools +/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image. +fn ocr_under_scan(text: &str) -> std::sync::Arc> { + under_scan(&format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET")) +} + +/// A page whose `text` content is followed by the opaque 200 × 100 scan image. +fn under_scan(text: &str) -> std::sync::Arc> { + under_scan_with(text, |_| pdfcraft_cos::Dict::new()) +} + +/// As [`under_scan`], with the page's ExtGState resources made by `ext_gstates`. +fn under_scan_with(text: &str, ext_gstates: impl FnOnce(&mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict) -> std::sync::Arc> { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; + let (w, h) = (200u32, 100u32); + let mut rgb = Vec::new(); + for y in 0..h { + for x in 0..w { + let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y); + rgb.extend(if ink { INK } else { PAPER }); + } + } + let mut d = Document::new_empty(); + let pages = d.add(Object::Dict(Dict::new())); + let content = format!("{text} q 200 0 0 100 0 0 cm /Im Do Q"); + let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + let mut image = Dict::new(); + image.set(b"Type".to_vec(), Object::name("XObject")); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(i64::from(w))); + image.set(b"Height".to_vec(), Object::Int(i64::from(h))); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let image = d.add(Object::Stream(Stream::flate(image, &rgb))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(font)); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + res.set(b"ExtGState".to_vec(), Object::Dict(ext_gstates(&mut d))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + let page = d.add(Object::Dict(p)); + d.update_dict(pages, |p| { + p.set(b"Type".to_vec(), Object::name("Pages")); + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut root = Dict::new(); + root.set(b"Type".to_vec(), Object::name("Catalog")); + root.set(b"Pages".to_vec(), Object::Ref(pages)); + let root = d.add(Object::Dict(root)); + d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap()) +} + +/// The scanned word's box shows the cover and new ink, not the scan. +fn assert_edit_shows(s: &Session, id: pdfcraft_engine::DocId, label: &str) { + let px = word_pixels(s, id); + let paper = px.iter().filter(|p| near(**p, PAPER)).count(); + let ink = px.iter().filter(|p| near(**p, INK)).count(); + assert!(paper * 10 > px.len() * 4, "{label}: the cover is drawn over the scan: {paper} of {} paper", px.len()); + assert!(ink * 100 > px.len() * 3, "{label}: the new text shows: {ink} of {} ink", px.len()); +} + +fn texts(s: &Session, id: pdfcraft_engine::DocId) -> Vec { + s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.replace('\n', " ")).collect() +} + +fn edit_block(s: &mut Session, id: pdfcraft_engine::DocId, block: usize, text: &str) { + s.apply(id, Edit::EditTextBlock { page: 0, block, text: text.into(), style: Default::default() }).unwrap(); +} + +/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image, +/// not in the OCR text's place where the image would hide it (#3973). One line takes the +/// single-style rewrite, a typed line break the styled one. Undo restores the hidden layer, and +/// the edit survives redo, a save and reopen, and being edited again. +#[test] +fn an_ocr_layer_under_the_scan_image_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap(); + let blocks = s.get(id).unwrap().text_blocks(0); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].invisible, "the OCR layer is invisible text"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box"); + + edit_block(&mut s, id, 0, edit); + assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}"); + assert_edit_shows(&s, id, edit); + + s.undo(id).unwrap(); + assert_eq!(texts(&s, id), ["John"], "{edit:?}: undo"); + assert!(s.get(id).unwrap().text_blocks(0)[0].invisible, "{edit:?}: undo hides the layer again"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "{edit:?}: undo shows the scan again"); + s.redo(id).unwrap(); + assert_edit_shows(&s, id, &format!("{edit:?} redo")); + + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let id = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(texts(&reopened, id), [edit.replace('\n', " ")], "{edit:?}: reopened"); + assert_edit_shows(&reopened, id, &format!("{edit:?} reopened")); + + edit_block(&mut reopened, id, 0, "Ann"); + assert_eq!(texts(&reopened, id), ["Ann"], "{edit:?}: edited again"); + assert_edit_shows(&reopened, id, &format!("{edit:?} edited again")); + } +} + +/// Visible text earlier in the same text object makes the rewrite split it; the OCR phrase still +/// goes on top of the scan, and the visible text stays. +#[test] +fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let page = under_scan("BT /F1 20 Tf 10 80 Td (Head) Tj 3 Tr 32 -36 Td (John) Tj ET"); + let id = s.open("split.pdf", None, page, None).unwrap(); + let blocks = s.get(id).unwrap().text_blocks(0); + let john = blocks.iter().position(|b| b.text == "John").unwrap(); + assert!(blocks[john].invisible && blocks.iter().any(|b| b.text == "Head" && !b.invisible), "{blocks:?}"); + edit_block(&mut s, id, john, edit); + let mut after = texts(&s, id); + after.sort(); + let mut want = vec!["Head".to_string(), edit.replace('\n', " ")]; + want.sort(); + assert_eq!(after, want, "{edit:?}"); + assert_edit_shows(&s, id, &format!("{edit:?} split")); + } +} + +/// `/SM`: a luminosity soft mask that is white over 30..110 × 30..70 in the space it is selected +/// in, black elsewhere. +fn soft_mask_state(d: &mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Object, Stream}; + let mut group = Dict::new(); + group.set(b"S".to_vec(), Object::name("Transparency")); + group.set(b"CS".to_vec(), Object::name("DeviceRGB")); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + form.set(b"Group".to_vec(), Object::Dict(group)); + let mut mask = Dict::new(); + mask.set(b"Type".to_vec(), Object::name("Mask")); + mask.set(b"S".to_vec(), Object::name("Luminosity")); + mask.set(b"G".to_vec(), Object::Ref(d.add(Object::Stream(Stream::flate(form, b"1 g 30 30 80 40 re f"))))); + let mut state = Dict::new(); + state.set(b"Type".to_vec(), Object::name("ExtGState")); + state.set(b"SMask".to_vec(), Object::Dict(mask)); + let mut states = Dict::new(); + states.set(b"SM".to_vec(), Object::Dict(state)); + states +} + +/// The soft mask is selected before a translation, so it is placed by the CTM before it. The +/// paragraph lifted over the scan selects it again after its own `cm`: there it must be placed +/// as it was, not 100 pt to the right where it would hide the new text, so the edit renders +/// exactly as the same edit on the page without the mask. Both rewrite paths. +#[test] +fn a_soft_mask_selected_before_a_cm_stays_put_when_the_edit_is_lifted() { + for edit in ["Jane", "Jane\nDoe"] { + let mut plain = Session::new(); + let page = under_scan("q 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q"); + let plain_id = plain.open("plain.pdf", None, page, None).unwrap(); + edit_block(&mut plain, plain_id, 0, edit); + let mut s = Session::new(); + let page = under_scan_with("q /SM gs 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q", soft_mask_state); + let id = s.open("masked.pdf", None, page, None).unwrap(); + assert_eq!(texts(&s, id), ["John"]); + edit_block(&mut s, id, 0, edit); + assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}"); + assert_edit_shows(&s, id, &format!("{edit:?} masked")); + let ink = |s: &Session, id| word_pixels(s, id).iter().filter(|p| near(**p, INK)).count(); + assert_eq!(ink(&s, id), ink(&plain, plain_id), "{edit:?}: the mask does not hide the new text"); + } +} diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 86c7971c31..f5167374d1 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -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}; diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs index df4720bec7..37d2163cb0 100644 --- a/crates/forms/src/af/format.rs +++ b/crates/forms/src/af/format.rs @@ -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 = 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 = 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, 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 = 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 = 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| { + 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 { + 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 { /// 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 { - 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 { + let t = english.trim(); if t.is_empty() { return None; } @@ -223,10 +395,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { } 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 { /// 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 = 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 { /// 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()), } diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c9ff86d17a..8403b729d9 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -739,8 +739,18 @@ pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option 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, Option) { + 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; 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); diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index c1a5a4fb0a..4b52f1586b 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -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 { + 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 { + 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. diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 76c8f7a9fb..530a1d431e 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -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> = 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(); diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs index 7f207b1916..26e2fad989 100644 --- a/crates/js/src/format.rs +++ b/crates/js/src/format.rs @@ -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 = 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 = 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, 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 = 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 = 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| { + 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 = 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(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 { @@ -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 { 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 { /// 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 { - 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::() == 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 }; diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 93f57e7528..0bf4032efd 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -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}; diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 07c0e984bb..ab7e32ed53 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -129,11 +129,13 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc 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}")), diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 930ffe3053..b26c389e96 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -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, diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 5b277e5546..88906910c7 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -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]); diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 41f893c71b..159ba04e8c 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -9,7 +9,7 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, St use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). -const MAX_PIXELS: u64 = 80_000_000; +pub(crate) const MAX_PIXELS: u64 = 80_000_000; /// The resources of a page, inherited from the page tree if the page has none. pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { @@ -419,7 +419,7 @@ pub(crate) fn components(doc: &Document, cs: &Object) -> Option { } } -fn is_dct(s: &Stream) -> Option { +pub(crate) fn is_dct(s: &Stream) -> Option { match s.dict.get(b"Filter") { None => Some(false), Some(Object::Name(n)) => match n.as_slice() { @@ -506,7 +506,7 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize, budget: &mut ImageBud /// to three bytes for each of them and of a PNG predictor's bytes a row (ASCIIHex text under /// Flate, its two digits a byte broken into lines or spaced). More can only be a decompression /// bomb, and the image is left alone. -fn allowance(bytes: usize, rows: u32) -> Option { +pub(crate) fn allowance(bytes: usize, rows: u32) -> Option { bytes.checked_add(usize::try_from(rows).ok()?)?.checked_mul(3)?.checked_add(4096) } @@ -518,7 +518,7 @@ fn allowance(bytes: usize, rows: u32) -> Option { /// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where /// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters /// or the JPEG's segments can't be read. -fn ycc_encoded(doc: &Document, s: &Stream) -> Option { +pub(crate) fn ycc_encoded(doc: &Document, s: &Stream) -> Option { let entry = color_transform(doc, s)?; match adobe_transform(&s.raw)? { Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1), @@ -658,7 +658,7 @@ fn scaled + 'static>(img: &image::ImageBuffer Option> { +pub(crate) fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { let mut out = Vec::new(); let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 }; @@ -681,7 +681,7 @@ pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option { /// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes /// nothing. Scanners write it on page images (#490). -fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { +pub(crate) fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { let decode = doc.resolve(decode); let Some(a) = decode.as_array() else { return false }; a.len() == 2 * n && a.chunks(2).all(|p| matches!(p, [lo, hi] if doc.resolve(lo).as_f64() == Some(0.0) && doc.resolve(hi).as_f64() == Some(1.0))) diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index e70aa5e71d..ef6423ab7d 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -32,6 +32,7 @@ mod audit; pub mod fragments; mod images; mod links; +mod samples; mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; @@ -40,6 +41,7 @@ use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; +pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; #[derive(Debug, thiserror::Error)] pub enum OptimizeError { diff --git a/crates/optimize/src/samples.rs b/crates/optimize/src/samples.rs new file mode 100644 index 0000000000..4800da6fd7 --- /dev/null +++ b/crates/optimize/src/samples.rs @@ -0,0 +1,312 @@ +//! An image's samples read out and written back at the same size, for tools that change the +//! pixels of an image in place (Scan & OCR ▸ Enhance ▸ Scanned document). The image dictionary +//! stays as it was apart from the encoding: its size, colour space, masks, `/Interpolate`, +//! `/Intent`, `/Metadata` and every other entry are kept. + +use pdfcraft_cos::{Document, ImageBudget, Object, Stream}; + +use crate::images::{MAX_PIXELS, allowance, components, is_dct, is_identity_decode, jpeg, pixels, ycc_encoded}; + +/// An image's samples, one byte each, row-major. +#[derive(Clone, Debug, PartialEq)] +pub struct Samples { + pub width: u32, + pub height: u32, + /// 1 (grey) or 3 (RGB). + pub components: usize, + /// The samples. A bi-level image's are 0 (black) or 255 (white) as drawn, whatever its + /// decode array. + pub data: Vec, + /// Stored as a JPEG (written back as one); otherwise written back losslessly (Flate). + pub jpeg: bool, + /// One bit per pixel (written back as one). + pub bilevel: bool, + /// A bi-level image whose 1 bits are black (decode array `[1 0]`): written back the same way, + /// so its decode array stays true. + pub inverted: bool, +} + +/// Why an image's samples were not read. Each says what the image is, so a report can name it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum NotDecoded { + #[error("it is a stencil mask, not a picture")] + StencilMask, + #[error("its colour space is not grey or RGB (CMYK, indexed and special colours are not supported yet)")] + ColourSpace, + #[error("it has {0} bits per sample (8, and 1 for black and white, are supported)")] + BitDepth(i64), + #[error("its decode array remaps the samples")] + Decode, + #[error("it is compressed in a format PdfCraft cannot read back yet (JPEG 2000, JBIG2 or CCITT fax)")] + Filter, + #[error("its JPEG colour transform is ambiguous, so readers may disagree about its colours")] + Ambiguous, + #[error("it is larger than 80 million pixels")] + TooLarge, + #[error("its data could not be decoded, or is shorter than its size says")] + Corrupt, +} + +/// Read the samples of image stream `s` (a dictionary with `/Subtype /Image`), decoding it within +/// `budget` (an operation over many images shares one). +pub fn decode_samples(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result { + let d = &s.dict; + if matches!(d.get(b"ImageMask").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))) { + return Err(NotDecoded::StencilMask); + } + // The filter first: a JPEG 2000 image may leave its colour space and bit depth to the data, + // and the report should name the format, not the missing entries. + let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; + let n = d.get(b"ColorSpace").and_then(|cs| components(doc, cs)).ok_or(NotDecoded::ColourSpace)?; + let bpc = d.get(b"BitsPerComponent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(0); + let bilevel = match bpc { + 8 => false, + 1 if n == 1 => true, + other => return Err(NotDecoded::BitDepth(other)), + }; + let width = d.get(b"Width").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + let height = d.get(b"Height").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if width == 0 || height == 0 { + return Err(NotDecoded::Corrupt); + } + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return Err(NotDecoded::TooLarge); + } + if bilevel { + if jpeg { + // A JPEG has 8 bits per sample: the dictionary contradicts the data. + return Err(NotDecoded::Corrupt); + } + let inverted = match d.get(b"Decode") { + None => false, + Some(a) if is_identity_decode(doc, a, 1) => false, + Some(a) if is_inverted_decode(doc, a) => true, + Some(_) => return Err(NotDecoded::Decode), + }; + let data = unpack_bits(doc, s, width, height, inverted, budget).ok_or(NotDecoded::Corrupt)?; + return Ok(Samples { width, height, components: 1, data, jpeg: false, bilevel: true, inverted }); + } + if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) { + return Err(NotDecoded::Decode); + } + if jpeg && n == 3 && ycc_encoded(doc, s).is_none() { + return Err(NotDecoded::Ambiguous); + } + let data = pixels(doc, s, n, budget).ok_or(NotDecoded::Corrupt)?; + Ok(Samples { width, height, components: n, data, jpeg, bilevel: false, inverted: false }) +} + +/// `[1 0]`: a bi-level image whose 1 bits are black. +fn is_inverted_decode(doc: &Document, decode: &Object) -> bool { + let decode = doc.resolve(decode); + matches!(decode.as_array().map(Vec::as_slice), Some([a, b]) if doc.resolve(a).as_f64() == Some(1.0) && doc.resolve(b).as_f64() == Some(0.0)) +} + +/// The bytes a `w` × `h` bi-level image takes: each row padded to whole bytes. +fn packed_len(w: u32, h: u32) -> Option { + usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?) +} + +/// A bi-level image's pixels, 0 (black) or 255 (white) as drawn, decoded within `budget` with +/// its `/DecodeParms` resolved, as the 8-bit images are. +fn unpack_bits(doc: &Document, s: &Stream, w: u32, h: u32, inverted: bool, budget: &mut ImageBudget) -> Option> { + let stride = usize::try_from(w).ok()?.div_ceil(8); + let need = packed_len(w, h)?; + let bits = budget.decode_filters(doc, s, allowance(need, h)?).ok()?; + let bits = bits.get(..need)?; + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let mut out = Vec::with_capacity(w.checked_mul(h)?); + for row in bits.chunks(stride).take(h) { + for x in 0..w { + let byte = row.get(x / 8).copied().unwrap_or(0); + let one = byte & (0x80 >> (x % 8)) != 0; + // Without a decode array 0 is black and 1 white; [1 0] swaps them. + out.push(if one != inverted { 255 } else { 0 }); + } + } + Some(out) +} + +/// The pixels (0 black … 255 white, threshold at half) packed back to bits. +fn pack_bits(px: &[u8], w: u32, h: u32, inverted: bool) -> Option> { + let stride = usize::try_from(w).ok()?.div_ceil(8); + let mut out = vec![0u8; packed_len(w, h)?]; + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + if px.len() != w.checked_mul(h)? { + return None; + } + for (y, row) in px.chunks(w.max(1)).enumerate() { + for (x, &v) in row.iter().enumerate() { + let white = v >= 128; + if white != inverted + && let Some(b) = out.get_mut(y * stride + x / 8) + { + *b |= 0x80 >> (x % 8); + } + } + } + Some(out) +} + +/// Image stream `old` with its samples replaced by `samples` (same size and components): a JPEG +/// stays a JPEG (at `quality`, 1–100), anything else becomes Flate, and a bi-level image stays +/// one bit per pixel with its decode array. Filter parameters go (the new data needs none); every +/// other entry stays. `None` if the samples don't fit the image. +pub fn encode_samples(old: &Stream, samples: &Samples, quality: u8) -> Option { + let Samples { width, height, components: n, data, jpeg: as_jpeg, bilevel, inverted } = samples; + let (w, h, n) = (*width, *height, *n); + if !(n == 1 || n == 3) || data.len() != usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)? { + return None; + } + let mut d = old.dict.clone(); + for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + d.remove(k); + } + if *bilevel { + if n != 1 { + return None; + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + return Some(Stream::flate(d, &pack_bits(data, w, h, *inverted)?)); + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + if *as_jpeg { + let bytes = jpeg(data, n, w, h, quality)?; + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Some(Stream::from_raw(d, bytes)) + } else { + Some(Stream::flate(d, data)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_cos::Dict; + + fn image(dict: &[(&str, Object)], raw: Vec) -> Stream { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + for (k, v) in dict { + d.set(k.as_bytes().to_vec(), v.clone()); + } + Stream::from_raw(d, raw) + } + + fn doc() -> Document { + let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + Document::open(std::sync::Arc::new(pdf.to_vec())).unwrap() + } + + #[test] + fn grey_samples_round_trip_losslessly_and_keep_the_other_entries() { + let px: Vec = (0..12 * 5).map(|i| (i * 4) as u8).collect(); + let s = image( + &[ + ("Width", Object::Int(12)), + ("Height", Object::Int(5)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(8)), + ("Interpolate", Object::Bool(true)), + ], + px.clone(), + ); + let got = decode_samples(&doc(), &s, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((got.width, got.height, got.components, got.jpeg, got.bilevel), (12, 5, 1, false, false)); + assert_eq!(got.data, px); + let back = encode_samples(&s, &got, 90).unwrap(); + assert_eq!(back.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(back.dict.get(b"Interpolate"), Some(&Object::Bool(true))); + assert_eq!(decode_samples(&doc(), &back, &mut ImageBudget::with_work(u64::MAX)).unwrap().data, px); + } + + #[test] + fn bilevel_samples_keep_their_decode_array_and_bits() { + // 10 × 2: first row black on the left half, second row all white; [1 0] makes 1 black. + let raw = vec![0b1111_1000, 0b0000_0000, 0, 0]; + let s = image( + &[ + ("Width", Object::Int(10)), + ("Height", Object::Int(2)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(1)), + ("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)])), + ], + raw.clone(), + ); + let got = decode_samples(&doc(), &s, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert!(got.bilevel); + assert_eq!(&got.data[..10], &[0, 0, 0, 0, 0, 255, 255, 255, 255, 255]); + assert!(got.data[10..].iter().all(|&v| v == 255)); + assert!(got.inverted); + let back = encode_samples(&s, &got, 90).unwrap(); + assert_eq!(back.dict.int(b"BitsPerComponent"), Some(1)); + assert!(back.dict.get(b"Decode").is_some()); + assert_eq!(back.decoded().unwrap(), raw); + } + + #[test] + fn bilevel_decode_parms_by_reference_decode_like_direct_ones() { + // 10 × 2 with a TIFF predictor: each 1 bit flips the pixel from the one to its left. + let packed = vec![0b1000_0000, 0b0000_0000, 0b0100_0000, 0b0000_0000]; + let parms = || { + let mut p = pdfcraft_cos::Dict::new(); + for (k, v) in [("Predictor", 2), ("Colors", 1), ("BitsPerComponent", 1), ("Columns", 10)] { + p.set(k.as_bytes().to_vec(), Object::Int(v)); + } + Object::Dict(p) + }; + let dict = [ + ("Width", Object::Int(10)), + ("Height", Object::Int(2)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(1)), + ]; + let mut d = doc(); + let mut direct = Stream::flate(image(&dict, vec![]).dict, &packed); + direct.dict.set(b"DecodeParms".to_vec(), parms()); + let mut indirect = direct.clone(); + let r = d.add(parms()); + indirect.dict.set(b"DecodeParms".to_vec(), Object::Ref(r)); + let a = decode_samples(&d, &direct, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + let b = decode_samples(&d, &indirect, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + // Row 1: white from x = 0 on; row 2: black at x = 0, white from x = 1 on. + assert_eq!(&a.data[..10], &[255; 10]); + assert_eq!(&a.data[10..], &[0, 255, 255, 255, 255, 255, 255, 255, 255, 255]); + assert_eq!(b, a); + } + + #[test] + fn what_is_not_supported_says_why_and_never_panics() { + let base = |extra: &[(&str, Object)], raw: Vec| { + let mut d: Vec<(&str, Object)> = vec![ + ("Width", Object::Int(4)), + ("Height", Object::Int(4)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(8)), + ]; + d.extend(extra.iter().cloned()); + decode_samples(&doc(), &image(&d, raw), &mut ImageBudget::with_work(u64::MAX)) + }; + assert_eq!(base(&[("ColorSpace", Object::name("DeviceCMYK"))], vec![0; 64]), Err(NotDecoded::ColourSpace)); + assert_eq!(base(&[("BitsPerComponent", Object::Int(4))], vec![0; 8]), Err(NotDecoded::BitDepth(4))); + assert_eq!(base(&[("Filter", Object::name("JBIG2Decode"))], vec![0; 8]), Err(NotDecoded::Filter)); + // JPEG 2000 may leave the colour space and bit depth to its data: the format is the reason. + let jpx = |dict: &[(&str, Object)]| decode_samples(&doc(), &image(dict, vec![0; 8]), &mut ImageBudget::with_work(u64::MAX)); + let size = [("Width", Object::Int(4)), ("Height", Object::Int(4)), ("Filter", Object::name("JPXDecode"))]; + assert_eq!(jpx(&size), Err(NotDecoded::Filter)); + assert_eq!(jpx(&[size[0].clone(), size[1].clone(), size[2].clone(), ("ColorSpace", Object::name("DeviceRGB"))]), Err(NotDecoded::Filter)); + assert_eq!(base(&[("ImageMask", Object::Bool(true))], vec![0; 8]), Err(NotDecoded::StencilMask)); + assert_eq!(base(&[("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)]))], vec![0; 16]), Err(NotDecoded::Decode)); + // Too short, a huge size, a broken JPEG, no size at all. + assert_eq!(base(&[], vec![0; 5]), Err(NotDecoded::Corrupt)); + assert_eq!(base(&[("Width", Object::Int(1 << 30)), ("Height", Object::Int(1 << 30))], vec![0; 16]), Err(NotDecoded::TooLarge)); + assert_eq!(base(&[("Filter", Object::name("DCTDecode"))], vec![0xFF, 0xD8, 1, 2, 3]), Err(NotDecoded::Corrupt)); + assert_eq!(base(&[("Width", Object::Int(-3))], vec![0; 16]), Err(NotDecoded::Corrupt)); + // Samples that don't fit the image are refused when written back. + let s = image(&[("Width", Object::Int(4)), ("Height", Object::Int(4))], vec![0; 16]); + let wrong = Samples { width: 4, height: 4, components: 1, data: vec![0; 15], jpeg: false, bilevel: false, inverted: false }; + assert!(encode_samples(&s, &wrong, 90).is_none()); + } +} diff --git a/crates/prc-render/Cargo.toml b/crates/prc-render/Cargo.toml new file mode 100644 index 0000000000..11ccd3346f --- /dev/null +++ b/crates/prc-render/Cargo.toml @@ -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 diff --git a/crates/prc-render/README.md b/crates/prc-render/README.md new file mode 100644 index 0000000000..000d673179 --- /dev/null +++ b/crates/prc-render/README.md @@ -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, 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 +``` + +- 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. diff --git a/crates/prc-render/src/assemble.rs b/crates/prc-render/src/assemble.rs new file mode 100644 index 0000000000..c9264d620e --- /dev/null +++ b/crates/prc-render/src/assemble.rs @@ -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>>, +} + +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>>) -> 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, 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, pdfcraft_prc::PrcError>>, + mut budget: Budget, +) -> Result>, 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, + pub(crate) triangle_colours: Box>, +} + +/// Per triangle of a mesh, the colour its face's style resolves to. +type FaceColours<'a> = dyn Fn(&TriangleMesh) -> Option>> + '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], + items: impl IntoIterator>, + budget: &mut Budget, + out: &mut Vec, +) -> 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; diff --git a/crates/prc-render/src/assemble/tests.rs b/crates/prc-render/src/assemble/tests.rs new file mode 100644 index 0000000000..5018bdbe8c --- /dev/null +++ b/crates/prc-render/src/assemble/tests.rs @@ -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) -> 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"); +} diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs new file mode 100644 index 0000000000..9cfd49efbe --- /dev/null +++ b/crates/prc-render/src/lib.rs @@ -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}; diff --git a/crates/prc-render/src/raster.rs b/crates/prc-render/src/raster.rs new file mode 100644 index 0000000000..4427df5973 --- /dev/null +++ b/crates/prc-render/src/raster.rs @@ -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 { + 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 { + 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::(); + 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::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); + } +} diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs new file mode 100644 index 0000000000..bcaf8acced --- /dev/null +++ b/crates/prc-render/src/render.rs @@ -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 { + let mut out: Option = 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 + 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 { + 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 { + 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 + Clone, + direction: [f64; 3], + up: [f64; 3], + (perspective, aspect): (bool, f64), + ) -> Result { + 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, +} + +/// 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 { + 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 = 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; diff --git a/crates/prc-render/src/render/tests.rs b/crates/prc-render/src/render/tests.rs new file mode 100644 index 0000000000..586292ef80 --- /dev/null +++ b/crates/prc-render/src/render/tests.rs @@ -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 = 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]); +} diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs new file mode 100644 index 0000000000..bdbed70aed --- /dev/null +++ b/crates/prc-render/src/vec3.rs @@ -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 { + 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 { + 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); + } +} diff --git a/crates/prc-render/tests/model.rs b/crates/prc-render/tests/model.rs new file mode 100644 index 0000000000..80e84252c6 --- /dev/null +++ b/crates/prc-render/tests/model.rs @@ -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, +} + +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 { + 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 { + 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::>()); + // 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 { + 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, 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::>()); + 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); + } + } +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1df39ebf05..1fb7272b33 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -894,7 +894,7 @@ pub fn convert(doc: &mut Document, level: Level) -> Result Result Result Result Arc> { + 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"); + } + } + } + } +} diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 9101012f66..be232d80eb 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -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 = 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 = (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 = 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 = 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 = (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 = (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 = (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 = (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 = (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 = [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 = 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 = 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 = 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() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 43a1e5ce1d..61b85cd7f1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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"))] diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 9319368d29..d60789728d 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -11,7 +11,7 @@ pub(crate) fn print_refused() -> &'static str { } enum ClipboardPaste { - Text, + Text(String), Image(std::sync::Arc), } @@ -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, 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, 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, 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. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 0309042bf1..35a214d9aa 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -47,6 +47,9 @@ impl PdfCraftApp { ("show_page_edge_shadows", _) => { self.show_page_edge_shadows = value.parse::().map_err(|_| "show_page_edge_shadows must be true or false")?; } + ("update_check_at_start", _) => { + self.update_check_at_start = value.parse::().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). diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index e0a8370c60..3d6f493e10 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -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(); } } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 08a20afb45..89789d8a4c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -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::>(), // 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); + } } } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index be2dd97d53..9004d88205 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -492,6 +492,10 @@ pub struct DocView { pub forward: Vec, /// Pending navigation: page and fraction down the page to align with the viewport top. pub goto: Option<(usize, f32)>, + /// With `goto`: → / ← turned the page at an edge, so a page or spread wider than the window + /// starts at the edge the reader comes from (`false`: its left, after →; `true`: its right, + /// after ←) instead of keeping the column. + pub(crate) goto_side: Option, /// Pending position within that page from a destination ([`Self::go_to_dest`]): the point /// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points /// from the viewport's top-left corner. @@ -499,6 +503,12 @@ pub struct DocView { /// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down / /// Page Up where the pages scroll. pub key_scroll: f32, + /// Pending keyboard scrolling sideways, in points right (negative: left): → / ← on a page + /// wider than the window. + pub key_side: f32, + /// As last drawn: whether the view was at its left and at its right edge (both when the + /// pages fit across), so → / ← turn the page there and scroll sideways elsewhere. + side_edges: Option<(bool, bool)>, /// Single-page view, as last drawn: the page shown and whether the view was at its top and /// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273). single_edges: Option<(usize, bool, bool)>, @@ -777,6 +787,8 @@ pub(crate) enum PageMenuAction { MarqueeZoom, Copy, Paste(usize, [f64; 2]), + /// Paste the copied comments, centred at this point of the page. + PasteComments(usize, [f64; 2]), AddComment(usize, [f64; 2]), AddBookmark, TakeSnapshot, @@ -798,6 +810,8 @@ pub(crate) struct PageMenuPerms { pub can_copy: bool, pub can_copy_items: bool, pub can_paste: bool, + /// Copied comments wait to be pasted, and comments may be added. + pub can_paste_comments: bool, pub has_annotations: bool, } @@ -839,6 +853,9 @@ pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64 ui, A::Paste(page, at), ); + if perms.can_paste_comments { + pick(ui.button(tl!("Paste comments")), ui, A::PasteComments(page, at)); + } ui.separator(); @@ -928,8 +945,11 @@ impl DocView { back: Vec::new(), forward: Vec::new(), goto: None, + goto_side: None, goto_point: None, key_scroll: 0.0, + key_side: 0.0, + side_edges: None, single_edges: None, flash: None, compare_marks: Vec::new(), @@ -1143,6 +1163,10 @@ struct PageEnv<'a> { by_line: &'a String, calc_order: bool, can_copy: bool, + /// Copied comments wait to be pasted. + can_paste_comments: bool, + /// Copied comments hang on the pointer (`Gesture::Place`): the click is theirs. + placing: bool, can_crop: bool, can_fill: bool, can_modify: bool, @@ -1162,6 +1186,7 @@ struct PageEnv<'a> { info: &'a DocInfo, initials: &'a Option, key_scroll: f32, + key_side: f32, middle_gesture: bool, modal: bool, navigation_enabled: bool, @@ -1189,6 +1214,7 @@ struct PageEnv<'a> { y_shift: f32, fill_text_size: f64, fill_text_color: [f64; 3], + fill_text_border: crate::fill_sign::TextBorder, today: (i64, u32, u32), can_paste_image: bool, } @@ -1198,6 +1224,8 @@ struct PageEnv<'a> { struct PageOut { canvas_action: Option, clicked_link: Option, + comment_notice: Option<&'static str>, + copy_comments: bool, copy_selected: bool, field_menu: Option, field_placed: bool, @@ -1209,8 +1237,12 @@ struct PageOut { open_signature: bool, open_table: Option<(usize, [f64; 4])>, page_action: Option, + paste_comments_at: Option<(usize, [f64; 2])>, primary_down: bool, refused: Option, + /// The current page or spread where it is drawn, every page of it: one off screen (the second + /// page of a zoomed spread) has no screen transform, but still decides where the row ends. + row_on_screen: Option, stamp_placed: bool, } @@ -1586,6 +1618,7 @@ fn draw_pages( coarse_scroll, calc_order, can_copy, + can_paste_comments, can_modify, content_h, content_w, @@ -1597,6 +1630,7 @@ fn draw_pages( hand, info, key_scroll, + key_side, middle_gesture, modal, navigation_enabled, @@ -1626,7 +1660,7 @@ fn draw_pages( // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution // until after the text widgets when a selection owns that drag. let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + coarse_scroll }; - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + let delta = auto_delta + edge_delta - vec2(key_side, key_scroll) + wheel_delta + glide; if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } @@ -1779,7 +1813,9 @@ fn draw_pages( page_action: _, primary_down: _, refused, + row_on_screen: _, stamp_placed, + .. } = page_out; let mut page_action: Option = None; let painter = ui.painter(); @@ -1831,6 +1867,9 @@ fn draw_pages( if view.continuous { view.current = current; } + // The row as it is now: in continuous view the current page may have changed above. + let row_on_screen = + shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union); resp.context_menu(|ui| { let is_crop_over_image = tool == QuickTool::EditText && view.image_selection.clone().is_some_and(|sel| { @@ -1938,8 +1977,18 @@ fn draw_pages( crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation); return; } - if view.comments.selected.is_some() { - canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify); + // A selected comment, or connected lines being drawn (Complete, Undo last point): the + // comment menu. + let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points()); + if view.comments.selected.is_some() || drawing_points { + canvas_action = comments::context_menu( + ui, + view, + info, + &|page, index| doc.annotation_deletable(page, index), + prefs, + comments::MenuRights { allowed, can_paste: can_paste_comments, can_modify }, + ); return; } // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. @@ -1983,6 +2032,7 @@ fn draw_pages( can_copy, can_copy_items, can_paste, + can_paste_comments: allowed && can_paste_comments, has_annotations: !info.annotations.is_empty(), }; default_page_context_menu(ui, page, at, perms, &mut page_action); @@ -1990,9 +2040,15 @@ fn draw_pages( if !resp.context_menu_opened() { ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); } + // Copy comments / Paste comments from the page's menu or a Comments panel card. + let copy_comments = std::mem::take(&mut view.comments.copy_request); + let paste_comments_at = view.comments.paste_request.take(); + let comment_notice = view.comments.notice.take(); *out = PageOut { canvas_action, clicked_link, + comment_notice, + copy_comments, copy_selected, field_menu, field_placed, @@ -2004,8 +2060,10 @@ fn draw_pages( open_signature, open_table, page_action, + paste_comments_at, primary_down: ptr.primary_down, refused, + row_on_screen, stamp_placed, }; (wanted, visible_now) @@ -2468,6 +2526,7 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); } else if let Some((page, frac)) = view.goto.take() { + let side = view.goto_side.take(); let page = page.min(rects.len() - 1); let r = rects[page]; // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() @@ -2483,7 +2542,27 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry // quick-action bar as the layout does. let (x, w) = (view.last_scroll.x, avail.width()); let (left, right) = (r.left() - SIDE, r.right() + SIDE); - let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + // Turned by → / ← at an edge (`goto_side`): a page or spread wider than the window starts at + // the edge the reader comes from. + let row = shown_row(view, rects).iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union).unwrap_or(r); + let (row_left, row_right) = (row.left() - SIDE, row.right() + SIDE); + let target = match side { + Some(from_right) if row_right - row_left > w => { + if from_right { + row_right - w + } else { + row_left + } + } + _ if right - left <= w => { + if left >= x && right <= x + w { + x + } else { + r.center().x - w / 2.0 + } + } + _ => x.clamp(left, right - w), + }; if (target - x).abs() > 0.5 { scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); } @@ -2733,6 +2812,7 @@ struct GlideCx { now: f64, may_glide: bool, key_scroll: f32, + key_side: f32, auto_delta: Vec2, zoom_delta: f32, edge_delta: Vec2, @@ -2746,11 +2826,11 @@ struct GlideCx { /// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for /// a reader action. Returns this frame's glide. fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { - let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; + let GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; let over_page = ui.rect_contains_pointer(avail); ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); let interrupted = glide_interrupted(ui); - if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + if !may_glide || interrupted || key_scroll != 0.0 || key_side != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { view.fling.stop(); } let glide = view.fling.step(now); @@ -2763,6 +2843,15 @@ fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> V glide } +/// The tool the page sees this frame, and whether copied comments hang on the pointer. Those +/// take the next click whatever tool is on: the page sees the Select tool until the bundle is +/// placed or let go, and no form field, added content or field being prepared takes the click +/// either. +fn page_tool(view: &DocView, chosen: QuickTool) -> (QuickTool, bool) { + let placing = matches!(view.comments.gesture, Some(comments::Gesture::Place { .. })); + (if placing { QuickTool::Select } else { chosen }, placing) +} + /// Text selection runs for the Select tool and for the markup tools (highlight…). fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool { match tool { @@ -2832,7 +2921,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // Keyboard scrolling applies to this frame's page view only, never later. let key_scroll = std::mem::take(&mut app.views[index].key_scroll); - if key_scroll != 0.0 { + let key_side = std::mem::take(&mut app.views[index].key_side); + if key_scroll != 0.0 || key_side != 0.0 { app.views[index].end_find_following(); } // The Search panel closed: its search moves to the find bar. @@ -2932,7 +3022,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { read_wheel(ui, view, avail, single_paging, edge_turned, wheel_enabled, animates); let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || coarse_scroll != Vec2::ZERO || has_page; - let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; + let motion = GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; let glide = step_glide(ui, view, avail, &motion); // The wheel may have turned the page above: draw the row it turned to. Measured from the old // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. @@ -2964,7 +3054,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let sharp_held = hold_sharp_renders(ui, view, scale); view.current_scale_tag = tag; let hand = app.quick_tool == QuickTool::Hand; - let tool = app.quick_tool; + let (tool, placing) = page_tool(view, app.quick_tool); // Text selection runs for the Select tool and for the markup tools (highlight…). let selects_text = tool_selects_text(tool, preparing); let prefs = &app.comment_prefs; @@ -2972,6 +3062,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let units = app.units; let allowed = doc.allows_annotation(); let can_copy = doc.allows_copying(); + let can_paste_comments = app.comment_clipboard.is_some(); let comments_hidden = doc.comments_hidden(); let form = doc.form.clone(); let generation = doc.edit_generation(); @@ -2984,11 +3075,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // In Preferences ▸ Date format, or why it can't be stamped into the PDF. let date_text = crate::date_text_for_pdf(&app.session); let by_line = app.session.stamp_by_line(&author); - let custom_stamp = match app.quick_tool { + let custom_stamp = match tool { QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), _ => None, }; let mut open_props: Option<(usize, usize)> = None; + let mut recolour: Option = None; // The table being set up in its window, shown on its own document's page. let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); let can_modify = doc.allows_modification(); @@ -3020,6 +3112,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { by_line: &by_line, calc_order, can_copy, + can_paste_comments, + placing, can_crop, can_fill, can_modify, @@ -3039,6 +3133,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { info, initials: &initials, key_scroll, + key_side, middle_gesture, modal, navigation_enabled, @@ -3066,6 +3161,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { y_shift, fill_text_size: app.fill_text_size, fill_text_color: app.fill_text_color, + fill_text_border: crate::fill_sign::TextBorder { + width: app.fill_text_border, + color: app.fill_text_border_color, + shape: app.fill_text_border_shape, + }, today, can_paste_image, }; @@ -3076,6 +3176,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let PageOut { canvas_action, clicked_link, + comment_notice, + copy_comments, mut copy_selected, field_menu, field_placed, @@ -3087,8 +3189,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_signature, open_table, page_action, + paste_comments_at, primary_down, mut refused, + row_on_screen, stamp_placed, } = page_out; view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { @@ -3098,6 +3202,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) }); + // Within a point, as above: at an edge of the scrolled content, or of the current page or + // spread shown whole that way (in continuous view the content is as wide as the widest page, + // and a narrower current page shows whole inside it). + let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0); + let inner = out.inner_rect; + view.side_edges = Some(( + out.state.clamped_offset().x <= 1.0 || row_on_screen.is_some_and(|r| r.left() >= inner.left() - 1.0), + out.state.clamped_offset().x >= max_x - 1.0 || row_on_screen.is_some_and(|r| r.right() <= inner.right() + 1.0), + )); view.viewport_screen = out.inner_rect; view.auto_scroll.paint(ui, avail); @@ -3161,6 +3274,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { app.comments_panel_closed = false; } Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)), + Some(comments::CanvasAction::Colour(c)) => recolour = Some(c), Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)), Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} @@ -3185,6 +3299,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_table, form_notice, copy_selected, + copy_comments, + paste_comments_at, + comment_notice, + recolour, remote_open, form, generation, @@ -3207,6 +3325,13 @@ struct FrameRequests { open_table: Option<(usize, [f64; 4])>, form_notice: Option, copy_selected: bool, + /// Copy comments / Paste comments from the page's menu or a Comments panel card, and what + /// the comment tools have to say. + copy_comments: bool, + paste_comments_at: Option<(usize, [f64; 2])>, + comment_notice: Option<&'static str>, + /// A colour picked for every selected comment (Colour in the page's menu). + recolour: Option, remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>, form: Arc>, generation: u64, @@ -3229,6 +3354,10 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, open_table, form_notice, copy_selected, + copy_comments, + paste_comments_at, + comment_notice, + recolour, remote_open, form, generation, @@ -3240,6 +3369,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, if let Some((p, i)) = open_props { app.open_comment_props(p, i); } + if let Some(c) = recolour { + app.colour_selected_comments(c); + } if let Some((p, i)) = app.views[index].comments.default_request.take() { app.make_comment_default(p, i); } @@ -3335,6 +3467,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, Some(PageMenuAction::Paste(page, at)) => { app.paste_image_at(page, at); } + Some(PageMenuAction::PasteComments(page, at)) => { + app.paste_comments(index, None, Some((page, at))); + } Some(PageMenuAction::RotateCCW) => { app.views[index].rotate_view(false); } @@ -3423,6 +3558,15 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, if copy_selected { app.execute("edit.copy"); } + if copy_comments { + app.execute("comment.copy"); + } + if let Some(at) = paste_comments_at { + app.paste_comments(index, None, Some(at)); + } + if let Some(notice) = comment_notice { + app.notify_tr(notice); + } // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. if app.mode != crate::Mode::Read && !app.full_screen { diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index dd3fe8c498..fbd4200217 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -315,7 +315,7 @@ mod tests { #[test] fn a_wider_quick_bar_centres_by_its_own_width_and_needs_a_wider_margin() { - // Fill & Sign's Text tool widens the bar to 70 pt: it centres by that, and 72 pt no longer holds it. + // A 70 pt bar centres by that, and 72 pt no longer holds it. assert_eq!(quick_bar_offset(120.0, 70.0), 15.0); assert_eq!(quick_bar_offset(72.0, 70.0), 14.0); } diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index bc98a243fa..1a2a0343a6 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -424,14 +424,39 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } } // As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do - // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. + // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. On a page wider + // than the window (a plan zoomed in) → / ← scroll sideways a line first, and turn the page + // once there is nothing left to scroll that way, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← + // turn it at once. let command = ctx.input(|i| i.modifiers.command); let scrolls = view.continuous; - if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { + let line = KEY_SCROLL_LINE * speed.factor(); + // Only an up-to-date view counts: no jump pending. + let sideways = |view: &DocView, right: bool| { + !command && view.goto.is_none() && view.side_edges.is_some_and(|(left_edge, right_edge)| !if right { right_edge } else { left_edge }) + }; + // Turned at an edge, the next page starts at its left and the previous one at its right, as + // reading on across: on two wide pages → doesn't turn twice without showing the next one's left. + let at_edge = |view: &mut DocView, right: bool| { + if !command && view.goto.is_some() { + view.goto_side = Some(!right); + } + }; + if key(Key::ArrowRight) && sideways(view, true) { + view.key_side += line; + } else if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { view.step_page(true); + if key(Key::ArrowRight) { + at_edge(view, true); + } } - if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) { + if key(Key::ArrowLeft) && sideways(view, false) { + view.key_side -= line; + } else if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) { view.step_page(false); + if key(Key::ArrowLeft) { + at_edge(view, false); + } } // ↓ / ↑ scroll a line, and Page Down / Page Up a screen where the pages scroll. In // single-page view, ↓ / ↑ turn the page once there is nothing left to scroll that way, as @@ -439,7 +464,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // The Scroll speed preference sets how far ↓ / ↑ go as well as the wheel (#985); Page Down / // Page Up keep a screen. if !command { - let line = KEY_SCROLL_LINE * speed.factor(); let screen = (view.viewport_h - KEY_SCROLL_LINE).max(KEY_SCROLL_LINE); let steps = [(Key::ArrowDown, line), (Key::ArrowUp, -line), (Key::PageDown, screen), (Key::PageUp, -screen)]; for (k, by) in steps { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 03bd3e807d..e1c9719668 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -51,6 +51,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut editing_content, form, info, + placing, prefs, preparing, redact_mark, @@ -116,14 +117,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); - let on_field = if preparing { + let on_field = if preparing && !placing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); frame.out.field_props |= o.properties; frame.out.field_placed |= o.placed; frame.out.open_table = frame.out.open_table.or(o.table); o.consumed || field_tool.is_some() } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) + tool == QuickTool::Select && !placing && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; @@ -150,7 +151,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } view.content.selected = None; } - let on_content = !on_objects && editing_content && can_modify && { + let on_content = !on_objects && !placing && editing_content && can_modify && { source_blocked || crate::content_ui::page_input( cx, @@ -265,6 +266,7 @@ fn tool_input( units, fill_text_size, fill_text_color, + fill_text_border, .. } = frame.env; let PageSlot { i, xf, .. } = *slot; @@ -294,6 +296,7 @@ fn tool_input( tool: ft, text_size: fill_text_size, text_color: fill_text_color, + text_border: fill_text_border, signature: signature.as_ref(), initials: initials.as_ref(), preview: frame.signature_preview, diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d5b8312d6c..5260ccc4ed 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2380,3 +2380,40 @@ fn a_camera_corner_at_the_page_edge_is_hit_on_the_page_only() { assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(14.0, 24.0)).is_some(), "on the page"); assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(6.0, 16.0)).is_none(), "just off it"); } + +/// #5125: after Enhance scanned file, the page shown is re-rendered, not the raster of the +/// scan before it was cleaned (same scale, so nothing else would replace it). +#[test] +fn enhancing_a_scan_re_renders_the_page_shown() { + let (w, h) = (64usize, 64usize); + let px: Vec = (0..w * h).map(|i| if i % w < 4 { 30 } else { 198 }).collect(); + let mut pdf = format!( + "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n4 0 obj\n<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Length {} >>\nstream\n", + px.len() + ) + .into_bytes(); + pdf.extend_from_slice(&px); + let content = format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q"); + pdf.extend_from_slice( + format!( + "\nendstream\nendobj\n5 0 obj\n<< /Length {} >>\nstream\n{content}\nendstream\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n", + content.len() + ) + .as_bytes(), + ); + let mut app = PdfCraftApp::new(); + app.open_bytes("scan.pdf", None, pdf).unwrap(); + let (i, doc) = app.active_ids().unwrap(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("before", egui::ColorImage::filled([1, 1], Color32::WHITE), TextureOptions::LINEAR); + app.views[i].pages.insert(0, PageTex { tag: 1000, tex }); + let job = app.session.scan_clean_job(doc, &[], pdfcraft_engine::scan_clean::ScanCleanSettings::default()).unwrap(); + let cleaned = job.run(|_, _| true).unwrap(); + assert_eq!(cleaned.report.pages, vec![0], "the page is a scan and gets cleaned"); + let progress = + std::sync::Arc::new(std::sync::Mutex::new(crate::scan_clean_ui::ScanCleanProgress { result: Some(Ok(cleaned)), ..Default::default() })); + app.scan_clean_run = Some(crate::scan_clean_ui::ScanCleanRun { doc, progress }); + app.poll_scan_clean(); + assert!(app.scan_clean_run.is_none()); + assert_eq!(app.views[i].pages.get(&0).unwrap().tag, STALE_TAG, "the old raster is re-rendered"); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 083ada4253..4601986e9a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -26,6 +26,7 @@ pub(crate) const NEXT_VIEW: Shortcut = Shortcut::cmd("]"); pub(crate) const FIND_NEXT: Shortcut = Shortcut::cmd("G"); pub(crate) const FIND_PREV: Shortcut = Shortcut::cmd_shift("G"); pub(crate) const COPY: Shortcut = Shortcut::cmd("C"); +pub(crate) const PASTE: Shortcut = Shortcut::cmd("V"); pub(crate) const SELECT_ALL: Shortcut = Shortcut::cmd("A"); pub(crate) const PAGE_PREV: Shortcut = Shortcut::cmd("←"); pub(crate) const PAGE_NEXT: Shortcut = Shortcut::cmd("→"); @@ -95,6 +96,8 @@ impl PdfCraftApp { .is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty())))) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) && match spec.id { + "comment.copy" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| v.comments.selected.is_some()), + "comment.paste" => self.comment_clipboard.is_some(), "view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()), "view.next_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.forward.is_empty()), _ => true, @@ -522,6 +525,7 @@ impl PdfCraftApp { "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), + "ocr.enhance" => self.dialog = Some(Dialog::EnhanceScan), "ocr.recognize_batch" => self.ocr_files_dialog(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; @@ -569,6 +573,14 @@ impl PdfCraftApp { } return Some(false); } + "comment.copy" => { + let Some(i) = active else { return Some(false) }; + return Some(self.copy_selected_comments(i)); + } + "comment.paste" => { + let Some(i) = active else { return Some(false) }; + return Some(self.paste_comments(i, None, None)); + } "comment.hide_all" => { if let Some(i) = active { let id = self.views[i].id; @@ -697,6 +709,7 @@ impl PdfCraftApp { } fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) }; + self.fill_tool = tool; self.quick_tool = crate::QuickTool::Fill(tool); let initials = tool == crate::fill_sign::FillTool::Initials; if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) { diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index b803f2738c..c2e82f98e5 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -96,6 +96,8 @@ impl Host for crate::PdfCraftApp { // Where pages are on screen (points), to aim ui.click / ui.drag at page content. "pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::>(), "selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })), + // Every selected comment (⌘-click adds), the one with the handles first. + "selected_comments": v.comments.selection().iter().map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })).collect::>(), "comment_composer_open": v.comments.composer.is_some(), })), // The Combine files tab: how its files show, and each grid thumbnail's state. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 84c36f41aa..7ce76c420e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -62,6 +62,7 @@ struct Pending { open_revision: Option, a11y_now: bool, ocr_now: bool, + scan_clean_now: bool, compare_now: bool, images_now: bool, stamp_now: bool, @@ -132,7 +133,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { Dialog::Print => 820.0, Dialog::FieldProps | Dialog::BulkFieldProps => 600.0, Dialog::Preferences => 610.0, - Dialog::About => 780.0, + Dialog::About => 820.0, Dialog::PinTool => 540.0, _ => 520.0, }); @@ -393,6 +394,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = go || cancel; return false; } + Dialog::EnhanceScan => { + let (go, cancel) = crate::scan_clean_ui::body(ui, app, &t); + req.scan_clean_now = go; + req.close = go || cancel; + return false; + } Dialog::AccessibilityOptions => { let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); req.a11y_now = start; @@ -1352,6 +1359,7 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) app.signature = d.saved(); crate::fill_sign::FillTool::Signature }; + app.fill_tool = tool; app.quick_tool = crate::QuickTool::Fill(tool); app.toast = None; } @@ -1995,6 +2003,9 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: if req.ocr_now { app.start_ocr(); } + if req.scan_clean_now { + app.start_scan_clean(); + } if req.compare_now { app.run_compare(); } diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 35ce510ae7..12752dde3c 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -26,6 +26,9 @@ pub struct PropsDraft { /// Acrobat's Make Properties Default box: on OK, new comments of this kind take the edited /// colour, opacity and line width too. pub make_default: bool, + /// The other selected comments, each with its own properties: OK makes the same changes to + /// them where they apply (a colour to every one, a font size to text boxes, …). + pub others: Vec<(usize, usize, CommentProps)>, } const ICONS: [NoteIcon; 14] = NoteIcon::ALL; @@ -99,6 +102,23 @@ impl PdfCraftApp { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.is_reply()) else { return }; + // Typed text has no comment tool: Fill & Sign gives new text its size, colour and border, + // kept across launches. + if crate::comments::typed_text(a) { + let kind = crate::i18n::in_sentence(tl!(crate::comments_panel::kind_label(a))).to_string(); + if let Some(size) = p.font_size.and_then(crate::fill_sign::valid_text_size) { + self.fill_text_size = size; + } + if let Some(color) = p.color.and_then(crate::fill_sign::valid_text_color) { + self.fill_text_color = color; + } + let width = p.width.and_then(crate::fill_sign::valid_text_border).unwrap_or(0.0); + let border = crate::fill_sign::TextBorder { width, color: p.border_color, shape: drawn_border_shape(p).unwrap_or_default() }; + self.fill_text_default = border; + (self.fill_text_border, self.fill_text_border_color, self.fill_text_border_shape) = (border.width, border.color, border.shape); + self.notify_fmt("New {tool} comments will look like this one", &[("tool", &kind)]); + return; + } let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { @@ -108,56 +128,177 @@ impl PdfCraftApp { style.fill = p.fill; } self.comment_prefs.set_style(tool, style); + // A text box's or callout's own border colour (cleared for tools that have none). + self.comment_prefs.set_border_color(tool, p.border_color); + if let Some(shape) = drawn_border_shape(p) { + self.comment_prefs.set_border_shape(tool, shape); + } + // The fill's and text's own opacities where they show (else the opacity is the whole + // comment's). With neither a border nor a fill the text is the whole comment: its drawn + // opacity, which Comment Properties shows, is the whole comment's default. + let (fill, text) = parts_shown(p); // Keep invisible defaults out of tools where a zero stroke would make them disappear. - self.comment_prefs.set_opacity(tool, p.opacity); + self.comment_prefs.set_opacity(tool, p.text_opacity.filter(|_| !text).unwrap_or(p.opacity)); + self.comment_prefs.set_part_opacity(tool, p.fill_opacity.filter(|_| fill), p.text_opacity.filter(|_| text)); if let Some(w) = p.width { self.comment_prefs.set_width(tool, w); } self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]); } - /// Open Comment Properties for the comment at `(page, index)` of the active document. + /// Give every selected comment of the active document `color`, as one undo step. + pub fn colour_selected_comments(&mut self, color: pdfcraft_engine::Rgb) { + self.colour_selection(color, |_| true); + } + + /// Give the selected comments that `only` picks `color`, as one undo step: those whose look can + /// change and that aren't locked (a locked one would refuse the whole step). + pub(crate) fn colour_selection(&mut self, color: pdfcraft_engine::Rgb, only: impl Fn(&pdfcraft_render::Annotation) -> bool) { + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let keys = self.views.get(i).map(|v| v.comments.selection()).unwrap_or_default(); + let mut edits: Vec = keys + .into_iter() + .filter(|(p, ix)| doc.info.annotations.iter().any(|a| a.page == *p && a.index == *ix && !a.is_reply() && only(a))) + .filter(|(p, ix)| doc.comment_props(*p, *ix).is_some_and(|c| c.restylable && !c.locked)) + .map(|(page, index)| Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None }) + .collect(); + match edits.len() { + 0 => {} + 1 => { + self.apply_edit(edits.remove(0)); + } + _ => { + self.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits }); + } + } + } + + /// Open Comment Properties for the comment at `(page, index)` of the active document. When it + /// is one of several selected comments, the changes go to all of them. pub fn open_comment_props(&mut self, page: usize, index: usize) { - let Some((_, id)) = self.active_ids() else { return }; - let Some(p) = self.session.get(id).and_then(|d| d.comment_props(page, index)) else { return }; - self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false }); + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let Some(p) = doc.comment_props(page, index) else { return }; + let selection = self.views.get(i).map(|v| &v.comments).filter(|c| c.is_selected((page, index))).map(|c| c.selection()).unwrap_or_default(); + let others = + selection.into_iter().filter(|k| *k != (page, index)).filter_map(|(pg, ix)| doc.comment_props(pg, ix).map(|c| (pg, ix, c))).collect(); + self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false, others }); self.dialog = Some(crate::Dialog::CommentProps); } } -/// The edits that make the comment match the draft (empty when nothing changed). +/// The border shape `p` draws: a Thickness of 0 takes an oval or a circle away with the line, as +/// the toolbar's Border ▸ None does. +fn drawn_border_shape(p: &CommentProps) -> Option { + p.border_shape.map(|s| if p.width.is_some_and(|w| w <= 0.0) { pdfcraft_engine::BorderShape::Rectangle } else { s }) +} + +/// Which of the comment's parts show an opacity of their own: (its fill, its text). A text box's +/// text, border and fill each do once it has a border or a fill; a filled shape's line and fill +/// do; anything else has one opacity for the whole comment. +pub fn parts_shown(p: &CommentProps) -> (bool, bool) { + let fill = p.fill_opacity.is_some() && p.fill.is_some(); + let text = p.text_opacity.is_some() && (fill || has_border(p)); + (fill, text) +} + +/// A text box's, typewriter text's or callout's border is drawn. +fn has_border(p: &CommentProps) -> bool { + p.font_size.is_some() && p.width.is_some_and(|w| w > 0.0) +} + +/// The edits that make the comment match the draft (empty when nothing changed), and make the +/// same changes to the other selected comments where they apply. Locks come off first and go on +/// last, as a locked comment refuses other changes; another comment that stays locked is left +/// as it is. pub fn edits(d: &PropsDraft) -> Vec { let (o, e) = (&d.original, &d.edited); + let targets: Vec<(usize, usize, &CommentProps)> = + std::iter::once((d.page, d.index, o)).chain(d.others.iter().map(|(p, i, c)| (*p, *i, c))).collect(); let mut out = Vec::new(); - // A locked comment refuses other changes: unlock first, lock last. - if o.locked && !e.locked { - out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: false }); + // Unticking Locked unlocks every selected comment. + let unlocking = o.locked && !e.locked; + for &(page, index, t) in &targets { + if unlocking && t.locked { + out.push(Edit::LockAnnotation { page, index, locked: false }); + } } + for (n, &(page, index, t)) in targets.iter().enumerate() { + if n > 0 && t.locked && !unlocking { + continue; + } + changes(o, e, page, index, t, &mut out); + } + for &(page, index, t) in &targets { + if !o.locked && e.locked && !t.locked { + out.push(Edit::LockAnnotation { page, index, locked: true }); + } + } + out +} + +/// The changes from `o` to `e` (the draft) for the comment at `(page, index)`, whose own +/// properties are `t`: only those its kind has (a line width for shapes and lines, a fill for +/// shapes, a font size for text, …). +fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &CommentProps, out: &mut Vec) { let color = (e.color != o.color).then_some(e.color).flatten(); let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity); - let width = (e.width != o.width).then_some(e.width).flatten(); - let endings = (e.endings != o.endings).then(|| e.endings.clone()).flatten(); - if e.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) { - out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width, endings }); + let width = (e.width != o.width).then_some(e.width).flatten().filter(|_| t.width.is_some()); + let endings = + (e.endings != o.endings).then(|| e.endings.clone()).flatten().filter(|ends| t.endings.as_ref().is_some_and(|own| own.len() == ends.len())); + if t.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) { + out.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings }); } - if e.restylable && e.fillable && e.fill != o.fill { - out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill }); + if t.restylable && t.fillable && e.fill != o.fill { + out.push(Edit::FillAnnotation { page, index, fill: e.fill }); } - if e.restylable + // After the opacity, which is the whole comment's: the parts shown are set again. Which + // parts show is the comment's own once the draft's fill and line width are on it, so another + // selected comment without a border or a fill keeps one opacity for the whole comment. + let mut after = t.clone(); + if t.fillable && e.fill != o.fill { + after.fill = e.fill; + } + if let Some(w) = width { + after.width = Some(w); + } + let (fill, text) = parts_shown(&after); + let moved = |a: Option, b: Option| a.zip(b).is_some_and(|(a, b)| (a - b).abs() > 1e-6); + if t.restylable + && (fill || text) + && (opacity.is_some() || fill && moved(e.fill_opacity, o.fill_opacity) || text && moved(e.text_opacity, o.text_opacity)) + { + out.push(Edit::PartOpacity { page, index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) }); + } + if t.restylable + && t.font_size.is_some() && let Some(size) = e.font_size.filter(|s| Some(*s) != o.font_size) { - out.push(Edit::FontSizeAnnotation { page: d.page, index: d.index, size }); + out.push(Edit::FontSizeAnnotation { page, index, size }); + } + if t.restylable && t.font_size.is_some() && e.border_color != o.border_color { + out.push(Edit::BorderColorAnnotation { page, index, color: e.border_color }); + } + // After the width (StyleAnnotation), whose border the shape goes around: each selected + // text keeps its own shape unless the draft's changed, and loses it with its border. + let mut shaped = after; + if e.border_shape != o.border_shape { + shaped.border_shape = e.border_shape; + } + if t.restylable + && t.border_shape.is_some() + && (e.border_shape != o.border_shape || width.is_some()) + && let Some(shape) = drawn_border_shape(&shaped).filter(|s| Some(*s) != t.border_shape) + { + out.push(Edit::BorderShapeAnnotation { page, index, shape }); } let author = (e.author != o.author).then(|| e.author.clone()); let subject = (e.subject != o.subject).then(|| e.subject.clone()); - let icon = (e.icon != o.icon).then_some(e.icon).flatten(); + let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some()); if author.is_some() || subject.is_some() || icon.is_some() { - out.push(Edit::SetAnnotationInfo { page: d.page, index: d.index, author, subject, icon }); + out.push(Edit::SetAnnotationInfo { page, index, author, subject, icon }); } - if !o.locked && e.locked { - out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: true }); - } - out } /// Draw the dialog; returns (apply, cancel). @@ -176,15 +317,18 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .collect() }) .unwrap_or_default(); - let default_tool = app.comment_props.as_ref().and_then(|d| { - let (_, id) = app.active_ids()?; - let doc = app.session.get(id)?; - let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())?; - crate::comments::tool_for_default(a, d.edited.endings.as_deref()) + let has_default = app.comment_props.as_ref().is_some_and(|d| { + let doc = app.active_ids().and_then(|(_, id)| app.session.get(id)); + doc.and_then(|doc| doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())) + .is_some_and(|a| crate::comments::tool_for_default(a, d.edited.endings.as_deref()).is_some() || crate::comments::typed_text(a)) }); let Some(d) = app.comment_props.as_mut() else { return (false, true) }; let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string(); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{kind} Properties"), &[("kind", &kind)])).font(theme::semibold(18.0))); + if !d.others.is_empty() { + let n = (d.others.len() + 1).to_string(); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Changes here apply to all {n} selected comments"), &[("n", &n)])).color(t.text_muted)); + } ui.add_space(6.0); ui.horizontal_wrapped(|ui| { for (tab, label) in @@ -219,6 +363,31 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo e.color = Some(c); } ui.end_row(); + // Text boxes, typewriter text and callouts: the border's own colour. + if e.font_size.is_some() { + ui.label(tl!("Border colour")); + let text = e.color.unwrap_or([0.0; 3]); + ui.horizontal(|ui| { + if let Some(c) = crate::comments::border_colour_button(ui, e.border_color, text) { + e.border_color = c; + } + }); + ui.end_row(); + } + // Text boxes and typewriter text: an oval or a circle goes around the text. + if let Some(shape) = e.border_shape.as_mut() { + let label = ui.label(tl!("Border shape")); + craft_ui::ComboBox::from_id_salt("border-shape") + .selected_text(tl!(shape.name())) + .show_ui(ui, |ui| { + for s in pdfcraft_engine::BorderShape::ALL { + ui.selectable_value(shape, s, tl!(s.name())); + } + }) + .response + .labelled_by(label.id); + ui.end_row(); + } if e.fillable { ui.label(tl!("Fill colour")); if let Some(f) = crate::comments::fill_picker(ui, e.fill) { @@ -231,12 +400,51 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.add(egui::DragValue::new(size).range(1.0..=400.0).speed(0.5).suffix(" pt")); ui.end_row(); } - ui.label(tl!("Opacity")); - let mut pct = e.opacity * 100.0; - if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() { - e.opacity = pct / 100.0; + // One opacity for the whole comment, or each part's of its own: a text box's + // text, border and fill, a shape's line and fill. The rows stay put; a part + // not drawn (no fill, no border) is greyed. + let percent = |ui: &mut egui::Ui, label: &str, value: &mut f64, enabled: bool| { + let name = ui.add_enabled(enabled, egui::Label::new(label)); + let mut pct = *value * 100.0; + if ui.add_enabled(enabled, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).labelled_by(name.id).changed() { + *value = pct / 100.0; + } + ui.end_row(); + }; + let (fill, text) = parts_shown(e); + let border = has_border(e); + match (e.text_opacity.is_some(), e.fill_opacity.is_some()) { + (true, _) => { + // With neither a border nor a fill, the text is the whole comment: + // shown at the opacity it is drawn at, a change sets the whole + // comment's (which its parts then take). + match e.text_opacity.as_mut() { + Some(t) if text => percent(ui, tl!("Text opacity"), t, true), + drawn => { + let mut shown = drawn.as_deref().copied().unwrap_or(e.opacity); + let before = shown; + percent(ui, tl!("Text opacity"), &mut shown, true); + if shown != before { + e.opacity = shown; + if let Some(t) = drawn { + *t = shown; + } + } + } + } + if text { + percent(ui, tl!("Border opacity"), &mut e.opacity, border); + } else { + let mut whole = e.opacity; + percent(ui, tl!("Border opacity"), &mut whole, false); + } + } + (false, true) => percent(ui, tl!("Line opacity"), &mut e.opacity, true), + (false, false) => percent(ui, tl!("Opacity"), &mut e.opacity, true), + } + if let Some(f) = e.fill_opacity.as_mut() { + percent(ui, tl!("Fill opacity"), f, fill); } - ui.end_row(); let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 }; if let Some(w) = e.width.as_mut() { ui.label(tl!("Thickness")); @@ -288,8 +496,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.horizontal(|ui| { ui.add(widgets::checkbox(&mut d.edited.locked, tl!("Locked"))); ui.add_space(16.0); - // Acrobat's box beside Locked. Only a comment whose look can change, made by a tool. - let defaultable = d.edited.restylable && default_tool.is_some(); + // Acrobat's box beside Locked. Only a comment whose look can change, made by a tool (or + // typed text, for Fill & Sign). + let defaultable = d.edited.restylable && has_default; ui.add_enabled(defaultable, widgets::checkbox(&mut d.make_default, tl!("Make Properties Default"))); }); let (mut apply, mut cancel) = (false, false); diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index ad45ec77b6..d69d545b3f 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -266,12 +266,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let counts = crate::i18n::fmt(crate::i18n::t("{n} contributors · {c} commits"), &[("n", &list.len().to_string()), ("c", &group(TOTAL_COMMITS))]); ui.label(RichText::new(counts).small().color(t.text_muted)); ui.separator(); - egui::ScrollArea::both().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { - if list.is_empty() { - ui.label(crate::i18n::t("No contributor data was built into this copy.")); - } else if v.table { - table(ui, &list, &mut v); - } else { + if list.is_empty() { + ui.label(crate::i18n::t("No contributor data was built into this copy.")); + } else if v.table { + // The table scrolls vertically itself, under a fixed header; it only scrolls sideways + // once its columns are dragged wider than the dialog. + ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| { + ui.set_max_height(LIST_HEIGHT); + egui::ScrollArea::horizontal().id_salt("credits_list").auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v)); + }); + } else { + egui::ScrollArea::vertical().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { ui.horizontal_wrapped(|ui| { for (i, c) in list.iter().enumerate() { if i > 0 { @@ -280,43 +285,154 @@ pub fn contributors_ui(ui: &mut egui::Ui) { ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); } }); - } - }); + }); + } ui.data_mut(|d| d.insert_temp(id, v)); } -fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { - egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { - for k in SortKey::ALL { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui - .button(RichText::new(format!("{}{arrow}", crate::i18n::t(k.label().1))).strong()) - .on_hover_text(crate::i18n::t(k.label().0)) - .clicked() - { - if v.key == k { - v.ascending = !v.ascending; - } else { - v.key = k; - v.ascending = k.default_ascending(); - } +/// How the contributor table's spare width is shared out: all of it to Name. +const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + +/// How the model table's spare width is shared out: to Company and Model, the text columns. +const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0]; + +/// The narrowest a column can be dragged. +const MIN_COLUMN: f32 = 28.0; + +/// The width a column needs for its header and its widest cell. +fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator) -> f32 { + let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x; + let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max); + widest + 2.0 * ui.spacing().button_padding.x +} + +/// Opening column widths: each column's natural width, with the spare room shared out by `grow`, +/// so the columns fill `room` exactly. When there is too little room, only the growing columns +/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that. +fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec { + let spare = room - natural.iter().sum::(); + let weight = grow.iter().sum::(); + let slack = |w: f32| (w - MIN_COLUMN).max(0.0); + let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum(); + natural + .iter() + .zip(grow) + .map(|(w, g)| { + if *g > 0.0 && spare >= 0.0 { + w + spare * g / weight + } else if *g > 0.0 && flexible > 0.0 { + w - (-spare).min(flexible) * slack(*w) / flexible + } else { + *w } - } - ui.end_row(); - for c in list { - ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); - ui.label(group(c.prs)); - ui.label(group(c.commits)); - ui.label(group(c.lines_added)); - ui.label(group(c.lines_deleted)); - ui.label(signed(c.lines_delta())); - ui.label(group(c.binary_added)); - ui.label(group(c.binary_deleted)); - ui.label(day(c.first_commit)); - ui.label(day(c.last_commit)); - ui.end_row(); - } - }); + }) + .collect() +} + +/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls +/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge: +/// only a table dragged wider than the window scrolls sideways. Text too long for its column is +/// clipped. When a column's natural width or the window changes, the columns go back to a new fit, +/// dropping any dragged widths. +fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> { + // Every cell is followed by the item gap, and the vertical scrollbar keeps its margins. + let s = ui.spacing(); + let gaps = s.item_spacing.x * natural.len() as f32; + let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin; + let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0)); + // The table keeps its own column widths, so a change in what they were fitted to has to clear + // them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available + // width, so this compares the natural widths and the clip rect, which scrolling never moves. + let fit = egui::Id::unique(("credits_fit", id)); + let measured = (natural.to_vec(), ui.clip_rect().width()); + let refit = ui.data_mut(|d| d.get_temp::<(Vec, f32)>(fit)).as_ref() != Some(&measured); + ui.data_mut(|d| d.insert_temp(fit, measured)); + let mut t = + egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY); + if refit { + t.reset(); + } + for w in widths { + t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true)); + } + t +} + +/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred. +fn aligned(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, add).inner +} + +fn row_height(ui: &egui::Ui) -> f32 { + ui.text_style_height(&egui::TextStyle::Body) + 6.0 +} + +/// One contributor's table cells after the name, in `SortKey::ALL` order. +fn cells(c: &Contributor) -> [String; 9] { + [ + group(c.prs), + group(c.commits), + group(c.lines_added), + group(c.lines_deleted), + signed(c.lines_delta()), + group(c.binary_added), + group(c.binary_deleted), + day(c.first_commit).to_string(), + day(c.last_commit).to_string(), + ] +} + +/// A contributor-table header. The sorted column is highlighted; its direction shows on the +/// toolbar's sort-order button, so no arrow widens the column. +fn header_text(k: SortKey) -> RichText { + RichText::new(crate::i18n::t(k.label().1)).strong() +} + +fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { + let h = row_height(ui); + let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect(); + let widths: Vec = SortKey::ALL + .iter() + .enumerate() + .map(|(i, &k)| match i.checked_sub(1) { + None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))), + Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())), + }) + .collect(); + fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW) + .header(h + 4.0, |mut header| { + for k in SortKey::ALL { + header.col(|ui| { + let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))) + .on_hover_text(crate::i18n::t(k.label().0)); + + if r.clicked() { + if v.key == k { + v.ascending = !v.ascending; + } else { + v.key = k; + v.ascending = k.default_ascending(); + } + } + }); + } + }) + .body(|mut body| { + for (c, cells) in list.iter().zip(rows) { + body.row(h, |mut row| { + row.col(|ui| { + aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login))) + .on_hover_text(c.summary()); + }); + for text in cells { + row.col(|ui| { + aligned(ui, true, |ui| ui.label(text)); + }); + } + }); + } + }); } /// About ▸ Models: AI models credited in Co-Authored-By trailers. @@ -326,21 +442,50 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::ScrollArea::both().id_salt("credits_models").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { - egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { - for h in MODEL_COLUMNS { - ui.label(RichText::new(crate::i18n::t(h)).strong()); - } - ui.end_row(); - for m in MODELS { - ui.label(m.company); - ui.label(m.model); - ui.label(m.version); - ui.label(group(m.commits)); - ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); - ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); - ui.end_row(); - } + let h = row_height(ui); + let titles = MODEL_COLUMNS; + // Company, Model and Version read as text; the counts line up on the right. + let right = [false, false, false, true, true, true]; + let rows: Vec<[String; 6]> = MODELS + .iter() + .map(|m| { + [ + m.company.to_string(), + m.model.to_string(), + m.version.to_string(), + group(m.commits), + format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64), + format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)), + ] + }) + .collect(); + let widths: Vec = titles + .iter() + .enumerate() + .map(|(i, title)| natural(ui, RichText::new(crate::i18n::t(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned()))) + .collect(); + ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| { + ui.set_max_height(LIST_HEIGHT); + egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| { + fitted_table(ui, "credits_models", &widths, &MODEL_GROW) + .header(h + 4.0, |mut header| { + for (title, r) in titles.into_iter().zip(right) { + header.col(|ui| { + aligned(ui, r, |ui| ui.label(RichText::new(crate::i18n::t(title)).strong())); + }); + } + }) + .body(|mut body| { + for cells in rows { + body.row(h, |mut row| { + for (text, r) in cells.into_iter().zip(right) { + row.col(|ui| { + aligned(ui, r, |ui| ui.label(text)); + }); + } + }); + } + }); }); }); } @@ -421,4 +566,131 @@ mod tests { assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07"); assert_eq!(day(""), "—"); } + + #[test] + fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]); + let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0); + assert!((w.iter().sum::() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}"); + } + + #[test] + fn too_little_room_shrinks_only_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]); + } + + #[test] + fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() { + let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0); + assert!((w.iter().sum::() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}"); + assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}"); + assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]); + } + + const WINDOW: f32 = 640.5; + const NATURAL: [f32; 3] = [120.0, 80.0, 80.0]; + const GROW: [f32; 3] = [1.0, 0.0, 0.0]; + + /// One frame of a table long enough to scroll; the rects of its header cells. + fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec) -> Vec { + scrolled_frame(ctx, width, natural, 0.0, events) + } + + /// [`table_frame`], scrolled `offset` points sideways. + fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec) -> Vec { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0))); + let raw = egui::RawInput { screen_rect, events, ..Default::default() }; + let mut cells = Vec::new(); + let out = ctx.run_ui(raw, |ui| { + cells.clear(); + egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| { + fitted_table(ui, "test", natural, &GROW) + .header(20.0, |mut header| { + for _ in natural { + header.col(|ui| cells.push(ui.max_rect())); + } + }) + .body(|mut body| { + for _ in 0..40 { + body.row(20.0, |mut row| { + for _ in natural { + row.col(|ui| { + ui.label("x"); + }); + } + }); + } + }); + }); + }); + out.drop_without_applying_deltas(); + cells + } + + /// A new context with the table open and settled, and the rects of its header cells. + fn opened_table() -> (egui::Context, Vec) { + let ctx = egui::Context::default(); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + (ctx, cells) + } + + /// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go. + fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) { + use egui::{Event, Modifiers, PointerButton}; + let start = egui::pos2(cell.right() + 4.0, cell.center().y); + let halfway = start + egui::vec2(by / 2.0, 0.0); + let end = start + egui::vec2(by, 0.0); + let moved = Event::PointerMoved; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] { + table_frame(ctx, WINDOW, &NATURAL, vec![event]); + } + } + + #[test] + fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() { + let (_, opened) = opened_table(); + for (dragged, cell) in opened.iter().enumerate() { + let (ctx, _) = opened_table(); + drag_edge(&ctx, *cell, -30.0); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + for (column, (was, now)) in opened.iter().zip(&after).enumerate() { + let want = if column == dragged { was.width() - 30.0 } else { was.width() }; + assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}"); + } + } + } + + #[test] + fn a_changed_fit_replaces_the_column_widths() { + let (ctx, opened) = opened_table(); + drag_edge(&ctx, opened[0], -30.0); + let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new()); + assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}"); + let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new()); + let widths: Vec = wider.iter().map(|c| c.width().round()).collect(); + assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}"); + assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}"); + let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new()); + assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}"); + } + + #[test] + fn scrolling_sideways_keeps_dragged_widths() { + let (ctx, opened) = opened_table(); + drag_edge(&ctx, opened[0], 100.0); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}"); + for offset in [13.3, 27.77, 41.1, 0.6] { + scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new()); + let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new()); + assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}"); + } + } } diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 9e3182e416..ea985f3207 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -659,6 +659,8 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut ctx.checkbox(ui, &mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"), t); #[cfg(not(target_arch = "wasm32"))] ctx.checkbox(ui, &mut app.reload_changed_files, tl!("Reload documents when their files change on disk"), t); + #[cfg(not(target_arch = "wasm32"))] + ctx.checkbox(ui, &mut app.update_check_at_start, tl!("Check for updates when PdfCraft starts"), t); ctx.checkbox(ui, &mut app.show_secured_notice, tl!("Show a notice when a document is secured"), t); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 879025b01d..4439dfe493 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -28,7 +28,7 @@ fn new_text_typed(v: &crate::DocView) -> bool { } /// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before` -/// is a deleted annotation's page's annotations before the delete (empty for other edits). +/// is the page's annotations before a delete or a paste of comments (empty for other edits). fn select_after_edit( view: &mut crate::DocView, edit: &Edit, @@ -58,11 +58,12 @@ fn select_after_edit( crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight) ) => { - view.comments.selected = None; + view.comments.clear_selection(); } Edit::AddAnnotation(a) => { - // Select the new comment (appended last among the page's comments). + // Select the new comment alone (appended last among the page's comments). let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); + view.comments.clear_selection(); view.comments.selected = newest.map(|n| (a.page, n)); view.comments.reveal = true; // A new highlight opens its note for typing straight away, as in Acrobat, @@ -75,6 +76,25 @@ fn select_after_edit( } } Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)), + // A group moved together stays selected (its positions don't change), so it + // can be moved or copied again; anything else in a batch clears the selection. + Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {} + Edit::Batch { .. } => view.comments.clear_selection(), + // The copies the paste added (and nothing that was there before): select them all. + Edit::PasteComments { page, .. } => { + let mut new: Vec<(usize, usize)> = info + .annotations + .iter() + .filter(|x| x.page == *page && !x.is_reply() && !annots_before.iter().any(|b| b.name == x.name)) + .map(|x| (*page, x.index)) + .collect(); + view.comments.clear_selection(); + if !new.is_empty() { + view.comments.selected = Some(new.remove(0)); + view.comments.also = new; + view.comments.reveal = true; + } + } _ => {} } } @@ -142,7 +162,10 @@ impl PdfCraftApp { // A deletion moves the comments after it on the page: these say where the selected one, // and the one open in the panel's editor, went. let annots_before = match (&edit, self.session.get(id)) { - (Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(), + // A paste selects what it adds: the page's comments before it are the ones it didn't. + (Edit::DeleteAnnotation { page, .. } | Edit::PasteComments { page, .. }, Some(d)) => { + d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect() + } _ => Vec::new(), }; match self.session.apply(id, edit.clone()) { @@ -787,7 +810,8 @@ impl PdfCraftApp { } let author = self.comment_prefs.author.clone(); let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page)); - let edit = crate::fill_sign::typed_with_color(tb.page, tb.at, &text, tb.size, rotation, &author, tb.color); + let look = crate::fill_sign::TypedLook { color: tb.color, border: tb.border }; + let edit = crate::fill_sign::typed_with(tb.page, tb.at, &text, tb.size, rotation, &author, look); if self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { view.fill_text = None; @@ -904,7 +928,10 @@ fn comment_page(edit: &Edit) -> Option { | Edit::ResizeAnnotation { page, .. } | Edit::StyleAnnotation { page, .. } | Edit::FillAnnotation { page, .. } + | Edit::PartOpacity { page, .. } | Edit::FontSizeAnnotation { page, .. } + | Edit::BorderColorAnnotation { page, .. } + | Edit::BorderShapeAnnotation { page, .. } | Edit::SetAnnotationInfo { page, .. } => Some(*page), _ => None, } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 1fd5dbd75c..8080758598 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -239,8 +239,20 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ QuickTool::Fill(f) => Some(f), _ => None, }; - let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text); - let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign")); + let shown = current_fill.unwrap_or(app.fill_tool); + let tip = match shown { + crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials if current_fill.is_none() => { + let initials = shown == crate::fill_sign::FillTool::Initials; + let saved = if initials { app.initials.is_some() } else { app.signature.is_some() }; + crate::i18n::fmt( + tl!(if saved { "Place saved {what}" } else { "Add {what}" }), + &[("what", tl!(if initials { "initials" } else { "signature" }))], + ) + } + _ => tl!(shown.label()).to_string(), + }; + let tip = format!("{}: {tip}", tl!("Fill & Sign")); + let resp = icons::button(ui, shown.icon(), cell, current_fill.is_some(), &tip); let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE)); @@ -287,6 +299,120 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_ }); } +fn controls_divider(ui: &mut egui::Ui, t: &Tokens, vertical: bool) { + let (r, _) = if vertical { + ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover()) + } else { + ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover()) + }; + if vertical { + ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); + } else { + ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider)); + } +} + +fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> { + let (index, id) = app.active_ids()?; + let (page, annotation) = app.views.get(index)?.comments.selected?; + let doc = app.session.get(id).filter(|doc| doc.allows_annotation())?; + doc.info + .annotations + .iter() + .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked) + .then_some((page, annotation)) +} + +/// Fill & Sign's Border controls for typed text: its width, colour and shape, for the selected +/// text (`picked`), or for new text when none is selected. The label shows in a vertical bar, as the +/// font size's does. +fn fill_text_border_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, picked: Option<(usize, usize)>, vertical: bool) { + // The selected text's own border (Comment Properties may have changed it + // since): the controls below show it and change only it. Without one they + // set what new text gets until PdfCraft quits; Make Current Properties + // Default on typed text keeps it. + let selected = picked.and_then(|(page, annotation)| { + let p = app.active_ids().and_then(|(_, id)| app.session.get(id))?.comment_props(page, annotation)?; + let own = crate::fill_sign::TextBorder { width: p.width.unwrap_or(0.0), color: p.border_color, shape: p.border_shape.unwrap_or_default() }; + Some((page, annotation, own)) + }); + let was = selected.map_or( + crate::fill_sign::TextBorder { width: app.fill_text_border, color: app.fill_text_border_color, shape: app.fill_text_border_shape }, + |(_, _, own)| own, + ); + let mut border = was; + // A border around the text, in its colour; the text keeps its place. + let border_label = |w: f64| if w > 0.0 { format!("{w} pt") } else { tl!("None").to_string() }; + let label = vertical.then(|| ui.label(tl!("Border:"))); + let combo = craft_ui::ComboBox::from_id_salt("fill-text-border") + .selected_text(border_label(was.width)) + .width(72.0) + .show_ui(ui, |ui| { + for w in crate::fill_sign::TEXT_BORDERS { + ui.selectable_value(&mut border.width, w, border_label(w)); + } + }) + .response; + match label { + Some(label) => { + combo.labelled_by(label.id); + } + None => { + combo.on_hover_text(tl!("Border:")); + } + } + // The border's own colour (the text's unless picked). + if was.width > 0.0 + && let Some(color) = comments::border_colour_button(ui, was.color, [0.0; 3]) + { + border.color = color; + } + // Its shape: an oval or a circle goes around the text, which keeps its place. + if was.width > 0.0 { + craft_ui::ComboBox::from_id_salt("fill-text-border-shape") + .selected_text(tl!(was.shape.name())) + .width(96.0) + .show_ui(ui, |ui| { + for shape in pdfcraft_engine::BorderShape::ALL { + ui.selectable_value(&mut border.shape, shape, tl!(shape.name())); + } + }) + .response + .on_hover_text(tl!("Border shape")); + } + // No border, no shape: an oval or a circle left without its line would + // keep its box grown around a shape that isn't drawn. + if border.width <= 0.0 { + border.shape = pdfcraft_engine::BorderShape::Rectangle; + } + match selected { + Some((page, index, _)) => { + // One undo step for what one choice changed (Border ▸ None takes a + // circle's line and its shape). + let mut edits = Vec::new(); + if border.width != was.width { + edits.push(Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(border.width), endings: None }); + } + if border.color != was.color { + edits.push(Edit::BorderColorAnnotation { page, index, color: border.color }); + } + if border.shape != was.shape { + edits.push(Edit::BorderShapeAnnotation { page, index, shape: border.shape }); + } + if edits.len() > 1 { + app.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits }); + } else if let Some(edit) = edits.pop() { + app.apply_edit(edit); + } + } + None => { + app.fill_text_border = border.width; + app.fill_text_border_color = border.color; + app.fill_text_border_shape = border.shape; + } + } +} + /// Extra controls rendered below/beside the buttons when a comment or fill tool is active. fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) { let current_fill = match app.quick_tool { @@ -294,60 +420,75 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve _ => None, }; if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) { - if vertical { - ui.label(tl!("Font size:")); - } - let size = ui.add( - egui::DragValue::new(&mut app.fill_text_size) - .range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE) - .speed(0.25) - .suffix(" pt"), - ); - if ((size.changed() && !size.dragged()) || size.drag_stopped()) - && let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc - .info - .annotations - .iter() - .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked) - }) - { - app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size }); - } - ui.menu_button(tl!("Colour"), |ui| { - if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) { - app.fill_text_color = color; - ui.close(); + controls_divider(ui, t, vertical); + if let Some(color) = comments::quick_bar_colour(ui, app.fill_text_color) { + app.fill_text_color = color; + if let Some((page, index)) = selected_fill_text(app) { + app.apply_edit(Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None }); } - }); + } + let resp = icons::button(ui, "a-large-small", 32.0, false, tl!("Font size")); + let resized = egui::Popup::menu(&resp) + .align(egui::RectAlign::RIGHT_START) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.set_min_width(180.0); + ui.label(egui::RichText::new(tl!("Font size")).font(theme::semibold(12.0))); + let size = ui.add( + egui::Slider::new(&mut app.fill_text_size, crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE) + .logarithmic(true) + .suffix(" pt"), + ); + // #1274: the size also applies to the Fill & Sign text that is selected, + // once per change (a typed value, or the end of a drag), so a drag adds + // one undo step and not one per frame. + (size.changed() && !size.dragged()) || size.drag_stopped() + }) + .is_some_and(|popup| popup.inner); + let size = app.fill_text_size; + if resized + && let Some((page, index)) = selected_fill_text(app) + && app + .active_ids() + .and_then(|(_, id)| app.session.get(id)?.comment_props(page, index)?.font_size) + .is_none_or(|stored| format!("{stored:.3}") != format!("{size:.3}")) + { + app.apply_edit(Edit::FontSizeAnnotation { page, index, size }); + } + // After the text's own controls, so they keep their place in a bar that scrolls. + fill_text_border_controls(ui, app, selected_fill_text(app), vertical); } if let QuickTool::Comment(tool) = app.quick_tool { - let (r, _) = if vertical { - ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover()) - } else { - ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover()) - }; - if vertical { - ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); - } else { - ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider)); + controls_divider(ui, t, vertical); + // Text boxes and callouts: the border's own colour, for new ones and the selected one + // this tool made (not a locked one). With one selected the button shows its colours + // (Comment Properties may have changed them), else the tool's. + if tool.has_border_color() { + let style = app.comment_prefs.style(tool); + let selected = app.active_ids().and_then(|(index, id)| { + let (page, annotation) = app.views.get(index)?.comments.selected?; + let doc = app.session.get(id)?; + let made = doc.allows_annotation() + && doc.info.annotations.iter().any(|a| a.page == page && a.index == annotation && !a.locked && comments::made_by(a, tool)); + made.then(|| (page, annotation, doc.comment_props(page, annotation))) + }); + let (shown, text) = match selected.as_ref().and_then(|(.., p)| p.as_ref()) { + Some(p) => (p.border_color, p.color.unwrap_or(style.color)), + None => (style.border_color, style.color), + }; + if let Some(color) = comments::border_colour_button(ui, shown, text) { + app.comment_prefs.set_border_color(tool, color); + if let Some((page, annotation, _)) = selected { + app.apply_edit(Edit::BorderColorAnnotation { page, index: annotation, color }); + } + } } + // The selected comments this tool made take the colour too (locked ones keep theirs). if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) - && let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc - .info - .annotations - .iter() - .any(|a| a.page == page && a.index == annotation && !a.is_reply() && !a.locked && comments::made_by(a, tool)) - }) + && let Some((_, id)) = app.active_ids() + && app.session.get(id).is_some_and(|doc| doc.allows_annotation()) { - app.apply_edit(Edit::StyleAnnotation { page, index: annotation, color: Some(color), opacity: None, width: None, endings: None }); + app.colour_selection(color, |a| comments::made_by(a, tool)); } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c0acc17a30..94e83f451a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -147,8 +147,9 @@ use state::CopyPage; #[cfg(not(target_arch = "wasm32"))] use state::OpenRequest; pub use state::{ - CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt, - PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt, + CommentClip, CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, + PasswordPrompt, PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, + UnlockPrompt, }; use state::{DocumentCopyJob, GridFile}; pub use tools::bulk_fields; @@ -182,6 +183,7 @@ use workflows::js_ui; use workflows::ocr_ui; use workflows::optimize_ui; use workflows::print_ui; +use workflows::scan_clean_ui; use workflows::web_print; /// Files dropped on a document's page grid or its Pages panel. @@ -332,8 +334,19 @@ pub struct PdfCraftApp { pub flatten_fill_sign_on_save: bool, /// Point size for new Fill & Sign text and dates, kept across launches. pub fill_text_size: f64, + pub fill_tool: fill_sign::FillTool, /// Colour for new Fill & Sign text and dates, kept across launches. pub fill_text_color: [f64; 3], + /// Border around new Fill & Sign text and dates, in points (0: none), until PdfCraft quits: + /// each launch starts from [`Self::fill_text_default`]. + pub fill_text_border: f64, + /// That border's own colour (`None`: the text's). + pub fill_text_border_color: Option, + /// That border's shape (an oval or a circle goes around the text). + pub fill_text_border_shape: pdfcraft_engine::BorderShape, + /// The border new Fill & Sign text starts each launch with, kept across launches: what Make + /// Current Properties Default last took from typed text (none at first). + pub fill_text_default: fill_sign::TextBorder, pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). pub(crate) updates: updates::Updates, @@ -371,6 +384,9 @@ pub struct PdfCraftApp { quit_session: Option, /// Preferences: reload a document when its file changes on disk (#431). pub reload_changed_files: bool, + /// Preferences: ask for a newer release when the desktop app starts (#5737). Off, the app + /// asks only from Help ▸ Check for updates. + pub update_check_at_start: bool, /// Preferences: tell the user, above the pages, that a document is secured (its security /// settings restrict changes). Off only hides that notice (#4035); kept in the settings. pub show_secured_notice: bool, @@ -549,10 +565,15 @@ pub struct PdfCraftApp { pub optimize_draft: OptimizeDraft, /// The running optimization (Optimize PDF ▸ Advanced optimization). pub optimize_run: Option, + /// Scan & OCR ▸ Enhance scanned file: the dialog's choices and the running clean-up. + pub scan_clean_draft: scan_clean_ui::ScanCleanDraft, + pub scan_clean_run: Option, /// A background job's progress card (see [`widgets::progress_notice`]). pub progress_notice: Option, /// Pages copied or cut in Organize Pages, ready to paste (into any document). pub page_clipboard: Option, + /// Copied comments (XFDF), to paste into this or another document (M14). + pub comment_clipboard: Option, /// Files dropped on the page grid, waiting for the pointer to say which gap they go to. grid_drop: Option, /// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 49f2aeba1a..369ad5933d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -141,6 +141,9 @@ const EDIT: &[&str] = &[ PASTE, DELETE, SEP, + "comment.copy", + "comment.paste", + SEP, SELECT_ALL, SEP, "edit.find", diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index d54daf5717..430bdbec85 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -239,6 +239,9 @@ pub(crate) fn show( { view.comments.collapsed_pages.retain(|&c| c != p); } + // The cards in the order shown, for a ⇧-click range (folded pages' cards aren't shown). + let order: Vec<(usize, usize)> = + roots.iter().filter(|a| !(sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page))).map(|a| (a.page, a.index)).collect(); let mut page = usize::MAX; let mut group: Option = None; // A card (or page header) out of view that was measured at this width only takes up its @@ -287,7 +290,7 @@ pub(crate) fn show( } let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); let top = ui.cursor().min.y; - if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) { + if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, doc, prefs, allowed, nav }) { edit = Some(e); } if virtual_list { @@ -376,6 +379,8 @@ struct CardContext<'a> { // The clockwise `/Rotate` of the comment's page. rotation: u16, thread: &'a [&'a Annotation], + /// The cards in the order shown, for a ⇧-click range. + order: &'a [(usize, usize)], doc: &'a pdfcraft_engine::Document, prefs: &'a CommentPrefs, allowed: bool, @@ -383,11 +388,14 @@ struct CardContext<'a> { } fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Option { - let CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav } = context; + let CardContext { theme: t, annotation: a, rotation, thread, order, doc, prefs, allowed, nav } = context; let info = &doc.info; let mut edit = None; let key = (a.page, a.index); - let selected = view.comments.selected == Some(key); + let selected = view.comments.is_selected(key); + // Several comments may be selected; the reply field (one draft) belongs to the one with + // the handles. + let primary = view.comments.selected == Some(key); let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent); let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()); let marked = is_marked(thread); @@ -433,7 +441,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti buttons_left = ui.min_rect().left(); }); }); - egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| { + let body = egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 4, bottom: 0 }).show(ui, |ui| { ui.set_width(ui.available_width()); if editing { if let Some(e) = inline_editor(ui, t, view, key, 2) { @@ -504,7 +512,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti } above = me; } - if selected && allowed { + if primary && allowed { ui.add_space(8.0); egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 0, bottom: 0 }).show(ui, |ui| { // The field fills its line and Post sits right-aligned under it: a field sized @@ -531,7 +539,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti // The header selects the card, except where its buttons are. let mut r = head.response.rect; r.max.x = r.max.x.min(buttons_left - 2.0); - r + (r, body.response.rect) }); let rect = frame.response.rect; if selected { @@ -545,17 +553,30 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti ui.add_space(5.0); // The header row selects the card (and shows the comment on the page); unselected cards are // clickable everywhere. - let hit = if selected { frame.inner } else { rect }; + let (head, body) = frame.inner; + let hit = if selected { head } else { rect }; let click = ui .interact(hit, ui.scope_id().with(("card", key)), Sense::click()) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))])); if click.clicked() { - view.comments.selected = Some(key); + // ⌘/Ctrl-click adds the comment to the selection, as on the page; ⇧-click selects the + // cards from the one with the handles to this one, as lists do. + let modifiers = ui.input(|i| i.modifiers); + if modifiers.shift && !modifiers.command { + view.comments.select_range(order, key); + } else if modifiers.command { + view.comments.toggle(key); + } else { + view.comments.select_only(key); + } view.comments.reply.clear(); *nav = Some(Nav::Flash(a.page, a.shown_rect(rotation))); } - if click.double_clicked() && allowed { + // On the selected card a double-click on the text edits it too (dragging over it selects + // words of it, for copying). + let body = (selected && !editing).then(|| ui.interact(body, ui.scope_id().with(("card-body", key)), Sense::click())); + if (click.double_clicked() || body.is_some_and(|b| b.double_clicked())) && allowed { view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default()); } let menu_id = ui.scope_id().with(("card-menu", key)); @@ -595,14 +616,20 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti view.comments.props_request = Some((a.page, a.index)); ui.close(); } - if ui.add_enabled(crate::comments::tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { + if ui.add_enabled(crate::comments::defaultable(a), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((a.page, a.index)); ui.close(); } + if ui.button(tl!("Copy comment")).clicked() { + // The card's comment alone (the page's menu copies the whole selection). + view.comments.select_only(key); + view.comments.copy_request = true; + ui.close(); + } ui.separator(); // A locked reply can't be deleted, so it keeps the comment it answers too. if ui.add_enabled(doc.annotation_deletable(a.page, a.index), egui::Button::new(tl!("Delete"))).clicked() { - view.comments.selected = None; + view.comments.clear_selection(); edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index }); ui.close(); } diff --git a/crates/ui-egui/src/platform/updates.rs b/crates/ui-egui/src/platform/updates.rs index 6b7ea291fa..bcf6783cdb 100644 --- a/crates/ui-egui/src/platform/updates.rs +++ b/crates/ui-egui/src/platform/updates.rs @@ -21,7 +21,8 @@ pub(crate) struct Updates { updater: Updater, /// The Updates dialog is showing. pub(crate) open: bool, - /// Ask for a newer release at start; the desktop app turns it on, so tests never reach the network. + /// Ask for a newer release at start, unless Preferences says not to; the desktop app turns + /// it on, so tests never reach the network. #[cfg(not(target_arch = "wasm32"))] startup_check: bool, #[cfg(not(target_arch = "wasm32"))] @@ -73,10 +74,12 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let Some(ctx) = self.ctx.clone() else { return }; + // Settled on the first frame: a preference turned on later waits for the next start. + let at_start = std::mem::take(&mut self.updates.startup_check) && self.update_check_at_start; let updates = &mut self.updates; if std::mem::take(&mut updates.check_requested) { updates.updater.check_now(&ctx); - } else if updates.startup_check { + } else if at_start { updates.updater.check_at_start(&ctx); } updates.updater.tick(&ctx); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e3a9c540a6..915d40e6ec 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -166,6 +166,8 @@ pub enum Dialog { AccessibilityOptions, /// Scan & OCR ▸ Recognize text. RecognizeText, + /// Scan & OCR ▸ Enhance scanned file. + EnhanceScan, /// The JavaScript console (⌘J). JsConsole, /// Document JavaScripts. @@ -231,6 +233,14 @@ pub struct PagePropsDraft { pub tab_order: Option, } +/// Copied comments: their XFDF (each with its replies), for `Edit::PasteComments`. +#[derive(Clone, Debug, PartialEq)] +pub struct CommentClip { + pub xfdf: String, + /// How many comments (replies not counted) were copied. + pub count: usize, +} + /// Copied pages: their document's name and bytes (as it was when copied) and the pages. #[derive(Clone, Debug)] pub struct PageClip { diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 8d118ac3c9..a906386d73 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -34,14 +34,14 @@ use geometry::handle_pos; #[cfg(test)] pub(crate) use geometry::{caret_snap, snapped_caret}; pub(crate) use geometry::{corner_ratio, page_rotation, resized}; -pub use geometry::{drawn_text_box_rect, text_box_rect}; +pub use geometry::{drawn_text_box_rect, snug_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; +pub(crate) use menu::{MenuRights, attachment_items, border_colour_button, context_menu, quick_bar_colour, quick_bar_controls}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; -pub(crate) use menu::{attachment_items, context_menu, quick_bar_controls}; pub(crate) use paint::paint_page; #[cfg(test)] use paint::{changing_points, drawn_shape, edited_in_view}; @@ -247,6 +247,12 @@ impl CommentTool { if matches!(self, Self::TextBox | Self::Callout) { 0.0 } else { 0.5 } } + /// Whether the border-colour control applies to this tool: text boxes and callouts, whose + /// border takes the text colour unless it has its own. + pub fn has_border_color(self) -> bool { + matches!(self, Self::TextBox | Self::Callout) + } + /// Whether the fill-colour control applies to this tool. pub fn has_fill(self) -> bool { matches!(self, Self::Rectangle | Self::Oval | Self::Polygon | Self::Cloud | Self::TextBox | Self::Callout) @@ -353,6 +359,22 @@ impl CommentPrefs { } } + /// The text box tool's border shape (the other tools keep a rectangle). + pub fn set_border_shape(&mut self, tool: CommentTool, shape: pdfcraft_engine::BorderShape) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.border_shape = if tool == CommentTool::TextBox { shape } else { pdfcraft_engine::BorderShape::Rectangle }; + } + } + + /// The tool's fill's own opacity (0–100 %) and a text box's or callout's text's (10–100 %), + /// for new comments; `None`: as the tool's opacity. + pub fn set_part_opacity(&mut self, tool: CommentTool, fill: Option, text: Option) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.fill_opacity = fill.filter(|o| o.is_finite() && tool.has_fill()).map(|o| o.clamp(0.0, 1.0)); + s.text_opacity = text.filter(|o| o.is_finite() && matches!(tool, CommentTool::TextBox | CommentTool::Callout)).map(|o| o.clamp(0.1, 1.0)); + } + } + /// The tool's fill (`None`: no fill). pub fn set_fill(&mut self, tool: CommentTool, fill: Option) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { @@ -360,6 +382,14 @@ impl CommentPrefs { } } + /// The border's own colour for new text boxes and callouts (`None`: the text colour). + pub fn set_border_color(&mut self, tool: CommentTool, color: Option) { + if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { + s.border_color = + if tool.has_border_color() { color.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0))) } else { None }; + } + } + pub fn set_style(&mut self, tool: CommentTool, style: Style) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { *s = style; @@ -391,6 +421,17 @@ fn arrow_ending(ending: LineEnding) -> bool { matches!(ending, LineEnding::OpenArrow | LineEnding::ClosedArrow | LineEnding::ROpenArrow | LineEnding::RClosedArrow) } +/// Typed text (Fill & Sign's, or a typewriter's): no comment tool makes it, so Make Current +/// Properties Default sets what Fill & Sign gives new text. +pub(crate) fn typed_text(a: &Annotation) -> bool { + a.subtype == "FreeText" && a.intent.as_deref() == Some("FreeTextTypeWriter") +} + +/// Whether Make Current Properties Default has defaults to set from `a`. +pub(crate) fn defaultable(a: &Annotation) -> bool { + tool_for(a).is_some() || typed_text(a) +} + /// The tool that makes comments like `a` (for Make Current Properties Default). pub fn tool_for(a: &Annotation) -> Option { Some(match (a.subtype.as_str(), a.intent.as_deref()) { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index ee67b92bb0..7e291be4db 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -32,15 +32,21 @@ pub(super) fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option, to: [f64; 2], shift: bool) -> [f64; 2] { + match last { + Some(l) if shift => pdfcraft_engine::measure::snap::ortho(*l, to), + _ => to, + } +} + +/// `points` with point `i` moved to `to`; with Shift, held level or plumb with the point before +/// it (a line's other end; the first corner is held to the second). pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> { let mut out = points.to_vec(); let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) }; - let to = match anchor { - Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to), - _ => to, - }; + let to = held(anchor, to, shift); if let Some(p) = out.get_mut(i) { *p = to; } @@ -153,10 +159,19 @@ pub(crate) fn caret_snap(text: &PageText, vx: f32, vy: f32) -> Option<([f32; 2], /// The box for `text` at `size` with its displayed top-left corner at `at`, on a page turned by /// `rotation` (its `/Rotate`): on a quarter-turned page it is taller than wide in user space. pub fn text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] { + sized_text_box_rect(at, text, size, rotation, 40.0) +} + +/// [`text_box_rect`] without its 40 pt minimum width: the box of text a circle goes around. +pub fn snug_text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64) -> [f64; 4] { + sized_text_box_rect(at, text, size, rotation, 0.0) +} + +fn sized_text_box_rect(at: [f64; 2], text: &str, size: f64, rotation: i64, min_width: f64) -> [f64; 4] { use pdfcraft_engine::annot_text::{text_width, wrap}; let pad = 2.0; let longest = text.lines().map(|l| text_width(l, size)).fold(0.0, f64::max); - let w = (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0); + let w = (longest + 2.0 * pad + 4.0).clamp(min_width, 300.0); let lines = wrap(text, size, w - 2.0 * pad).len().max(1); let h = lines as f64 * size * 1.2 + 2.0 * pad + 2.0; pdfcraft_engine::text_box_at(rotation, at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]) diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index ac3c2a6878..fa56f7a231 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,10 +1,10 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable, - resized, snapped_caret, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, point_handle, reaches_points, reshapable, reshaped, + resizable, resized, snapped_caret, }; -use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; +use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; -use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; +use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; use crate::page_router::{CommentPart, Hit, Owner, PagePress}; @@ -31,6 +31,39 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.gesture = None; return true; } + // Copied comments on the pointer: a click places the bundle, centred where it is clicked, + // on this page; a right-click lets it go. Nothing else on the page takes the click. + if let Some(Gesture::Place { xfdf, bounds, .. }) = cv.gesture.clone() { + if over_page { + view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); + } + let cv = &mut view.comments; + if resp.secondary_clicked() { + cv.gesture = None; + return true; + } + if resp.clicked() + && over_page + && cx.allowed + && let Some(p) = pointer + { + let [x, y] = cx.to_user(p); + let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0]; + // Larger than the page it lands on (an A0 bundle on an A4 page): placed as it is, + // and said so. + if let Some(page) = cx.info.pages.get(cx.page) { + let a = page.user_to_view((bounds[0] + offset[0]) as f32, (bounds[1] + offset[1]) as f32); + let b = page.user_to_view((bounds[2] + offset[0]) as f32, (bounds[3] + offset[1]) as f32); + let (w, h) = ((a[0] - b[0]).abs(), (a[1] - b[1]).abs()); + if w > page.width + 1.0 || h > page.height + 1.0 { + cv.notice = Some("The copied comments are larger than this page; they were placed as they are"); + } + } + cv.gesture = None; + view.pending_edit = Some(Edit::PasteComments { xfdf, page: cx.page, offset: Some(offset) }); + } + return true; + } let moving = moving_on(view, cx.page); let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { @@ -216,7 +249,20 @@ fn select_input( consumed = true; } CommentPart::Body if !is_markup(&a.subtype) => { - cv.selected = Some((cx.page, a.index)); + // Dragging one of several selected comments moves them all; any other drag starts + // a selection of its own. + let grabbed = (cx.page, a.index); + if !cv.is_selected(grabbed) { + cv.select_only(grabbed); + } else { + // The grabbed one takes the handles; the one that had them stays selected. + if let Some(previous) = cv.selected.replace(grabbed) + && previous != grabbed + { + cv.also.push(previous); + } + cv.also.retain(|k| *k != grabbed); + } cv.reveal = true; if cx.allowed { cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o }); @@ -232,38 +278,9 @@ fn select_input( if resp.drag_stopped() && let Some(p) = pointer { - match cv.gesture.clone() { - Some(Gesture::Move { page, index, from }) if page == cx.page => { - cv.gesture = None; - let (a, b) = (cx.to_user(from), cx.to_user(p)); - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - if from.distance(p) >= 2.0 { - view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); - } - } - Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => { - cv.gesture = None; - if let Some(a) = cx.get(index) { - let from_rect = cx.screen_rect(a); - let shift = ui.input(|i| i.modifiers.shift); - let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); - let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); - if r.width() >= 4.0 && r.height() >= 4.0 { - view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); - } - } - } - Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => { - cv.gesture = None; - let shift = ui.input(|i| i.modifiers.shift); - let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); - if moved { - view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); - } - } - _ => {} - } + finish_select_gesture(ui, cx, view, p); } + let cv = &mut view.comments; // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { @@ -271,11 +288,16 @@ fn select_input( // form field; added text) leaves comments unselected. match owner { Some((a, _)) => { - cv.selected = Some((cx.page, a.index)); + // ⌘/Ctrl-click (or ⇧-click) adds the comment to the selection, as in Acrobat. + if ui.input(|i| i.modifiers.command || i.modifiers.shift) { + cv.toggle((cx.page, a.index)); + } else { + cv.select_only((cx.page, a.index)); + } cv.reveal = true; consumed = true; } - None if press != PagePress::Elsewhere => cv.selected = None, + None if press != PagePress::Elsewhere => cv.clear_selection(), None => {} } } @@ -284,7 +306,7 @@ fn select_input( && cx.allowed && let Some((a, _)) = owner { - cv.selected = Some((cx.page, a.index)); + cv.select_only((cx.page, a.index)); cv.composer = Some(Composer { page: cx.page, at: edit_anchor(cx.info, a), @@ -297,6 +319,57 @@ fn select_input( consumed } +/// The end of a move, resize or reshape the Select tool began on this page: the edit it makes, +/// for the pointer released at `p`. +fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: Pos2) { + match view.comments.gesture.clone() { + Some(Gesture::Move { page, index, from }) if page == cx.page => { + view.comments.gesture = None; + let (a, b) = (cx.to_user(from), cx.to_user(p)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + if from.distance(p) >= 2.0 { + // The other selected comments on this page (not text markup, which is where + // its text is, nor a locked one, which would refuse the whole step) move with + // the dragged one, as one undo step. + let others: Vec = view + .comments + .also + .iter() + .filter(|(p, i)| *p == page && *i != index && cx.get(*i).is_some_and(|a| !is_markup(&a.subtype) && !a.locked)) + .map(|(_, i)| *i) + .collect(); + view.pending_edit = Some(if others.is_empty() { + Edit::MoveAnnotation { page, index, dx, dy } + } else { + let edits = std::iter::once(index).chain(others).map(|index| Edit::MoveAnnotation { page, index, dx, dy }).collect(); + Edit::Batch { label: tl!("Move comments").to_owned(), edits } + }); + } + } + Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => { + view.comments.gesture = None; + if let Some(a) = cx.get(index) { + let from_rect = cx.screen_rect(a); + let shift = ui.input(|i| i.modifiers.shift); + let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); + let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); + if r.width() >= 4.0 && r.height() >= 4.0 { + view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); + } + } + } + Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => { + view.comments.gesture = None; + let shift = ui.input(|i| i.modifiers.shift); + let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0); + if moved { + view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) }); + } + } + _ => {} + } +} + /// After text selection ran: a markup tool turns a finished selection into a comment; Replace /// Text asks for the replacement. pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) { @@ -346,6 +419,8 @@ impl Gestures<'_> { } fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { + // Shift holds a line level or plumb (ortho). + let shift = self.resp.ctx.input(|i| i.modifiers.shift); let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { @@ -374,7 +449,8 @@ impl Gestures<'_> { } } else { points.truncate(1); - points.push(u); + let line = matches!(*tool, CommentTool::Line | CommentTool::Arrow); + points.push(held(points.first(), u, line && shift)); } } if resp.drag_stopped() @@ -408,6 +484,8 @@ impl Gestures<'_> { } fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool { + // Shift holds the next side of connected lines, a polygon or a cloud level or plumb. + let shift = self.resp.ctx.input(|i| i.modifiers.shift); let Self { ui, resp, cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { @@ -416,7 +494,18 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); + // The menu's Complete is taken by the page the drawing is on: every page on screen + // passes here in a frame, and an earlier one must not swallow it. With no drawing + // left, a request is stale. + let drawing_here = match &cv.gesture { + Some(Gesture::Draw { page, .. }) => *page == cx.page, + _ => { + cv.finish_request = false; + false + } + }; + let finish = + resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) || (drawing_here && std::mem::take(&mut cv.finish_request)); // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) @@ -434,8 +523,9 @@ impl Gestures<'_> { match cv.gesture.as_mut() { Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { // Clicking the first point again closes a polygon. - let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; - let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); + let closes = closes_at(cx, tool, points, p); + let u = held(points.last(), u, shift); + let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(cx.to_screen(u)) < 2.0); if closes { let pts = std::mem::take(points); cv.gesture = None; @@ -606,18 +696,39 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } let cv = &mut view.comments; if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - // One step back per press: cancel the gesture, then deselect, then drop the tool. - let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some(); - if !cancelled && matches!(tool, QuickTool::Comment(_)) { + // One step back per press: cancel the gesture, then deselect, then drop the tool (a + // comment tool, or a Fill & Sign one: its Text tool would otherwise open a box at every + // click with no way out by the keyboard). + let had_selection = cv.selected.is_some(); + cv.clear_selection(); + let cancelled = cv.gesture.take().is_some() || had_selection; + if !cancelled && matches!(tool, QuickTool::Comment(_) | QuickTool::Fill(_)) { *tool = QuickTool::Select; } } if allowed - && let Some((page, index)) = cv.selected + && cv.selected.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + && let Some(edit) = delete_selected(cv) { - cv.selected = None; - view.pending_edit = Some(Edit::DeleteAnnotation { page, index }); + view.pending_edit = Some(edit); + } +} + +/// The edit that deletes every selected comment (one undo step for several), and clears the +/// selection. Later positions go first, so each deletion leaves the earlier ones where they are. +pub(crate) fn delete_selected(cv: &mut CommentView) -> Option { + let mut keys = cv.selection(); + cv.clear_selection(); + keys.sort_unstable_by(|a, b| b.cmp(a)); + keys.dedup(); + match keys.as_slice() { + [] => None, + [(page, index)] => Some(Edit::DeleteAnnotation { page: *page, index: *index }), + _ => Some(Edit::Batch { + label: tl!("Delete comments").to_owned(), + edits: keys.into_iter().map(|(page, index)| Edit::DeleteAnnotation { page, index }).collect(), + }), } } diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index b577fbbd29..a2c38b2628 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -1,8 +1,9 @@ +use super::input::delete_selected; use super::paint::edit_anchor; -use super::state::{Composer, ComposerKind}; +use super::state::{Composer, ComposerKind, Gesture}; use super::{ Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, - icons, theme, tool_for, vec2, + defaultable, icons, theme, vec2, }; /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's @@ -22,8 +23,18 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc ui.separator(); } +/// What the canvas context menu may offer. +#[derive(Clone, Copy, Debug)] +pub(crate) struct MenuRights { + /// Comments may be added and changed. + pub(crate) allowed: bool, + /// Copied comments wait to be pasted. + pub(crate) can_paste: bool, + /// The document's pages may change (Flatten comments draws into them). + pub(crate) can_modify: bool, +} + /// Items of the canvas context menu for the selected comment (or the page). -/// `can_modify`: the document's pages may change (Flatten comments draws into them). /// `deletable`: whether Delete would delete the comment at (page, index) (the engine's /// `Document::annotation_deletable`: unlocked, and no locked reply to it). pub(crate) fn context_menu( @@ -32,11 +43,32 @@ pub(crate) fn context_menu( info: &DocInfo, deletable: &dyn Fn(usize, usize) -> bool, prefs: &CommentPrefs, - allowed: bool, - can_modify: bool, + rights: MenuRights, ) -> Option { + let MenuRights { allowed, can_paste, can_modify } = rights; let mut action = None; + // Connected lines, a polygon or a cloud being drawn: finish it from the menu, as other + // editors do (Enter and a double-click finish it too), take the last point back, or stop. + if let Some(Gesture::Draw { tool, points, .. }) = &view.comments.gesture + && tool.clicks_points() + { + let enough = points.len() >= if *tool == CommentTool::PolyLine { 2 } else { 3 }; + if ui.add_enabled(enough, egui::Button::new(tl!("Complete"))).clicked() { + view.comments.finish_request = true; + ui.close(); + } + if ui.button(tl!("Undo last point")).clicked() { + view.comments.undo_point(); + ui.close(); + } + if ui.button(tl!("Cancel drawing")).clicked() { + view.comments.gesture = None; + ui.close(); + } + return None; + } let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && !a.is_reply())); + let several = view.comments.also.len(); match selected { Some(a) => { let (page, index) = (a.page, a.index); @@ -69,14 +101,19 @@ pub(crate) fn context_menu( let keep_open = egui::containers::menu::MenuConfig::new().close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); let _ = egui::containers::menu::SubMenuButton::new(tl!("Colour")).config(keep_open).ui(ui, |ui| { if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) { - action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation { - page, - index, - color: Some(c), - opacity: None, - width: None, - endings: None, - }))); + // Several selected: the app gives each of them the colour. + action = Some(if several > 0 { + CanvasAction::Colour(c) + } else { + CanvasAction::Edit(Box::new(Edit::StyleAnnotation { + page, + index, + color: Some(c), + opacity: None, + width: None, + endings: None, + })) + }); ui.close(); } }); @@ -92,17 +129,31 @@ pub(crate) fn context_menu( ui.close(); } ui.separator(); - // The engine's own check: a locked comment, or one with a locked reply, stays. - if ui.add_enabled(allowed && deletable(page, index), egui::Button::new(tl!("Delete"))).clicked() { - view.comments.selected = None; - action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index }))); + // The comments themselves, to paste into this or another document (⌘C / ⌘V too). + let copy_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); + if ui.add(egui::Button::new(tl!(if several > 0 { "Copy comments" } else { "Copy comment" })).shortcut_text(copy_key)).clicked() { + view.comments.copy_request = true; + ui.close(); + } + let paste_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::PASTE); + if ui.add_enabled(allowed && can_paste, egui::Button::new(tl!("Paste comments")).shortcut_text(paste_key)).clicked() { + let at = view.comments.context_at.unwrap_or((page, [f64::from(a.rect[0]), f64::from(a.rect[1])])); + view.comments.paste_request = Some(at); + ui.close(); + } + ui.separator(); + // The engine's own check, for each selected comment: a locked comment, or one with a + // locked reply, stays. + let deletes = deletable(page, index) && view.comments.also.iter().all(|&(p, i)| deletable(p, i)); + if ui.add_enabled(allowed && deletes, egui::Button::new(tl!(if several > 0 { "Delete comments" } else { "Delete" }))).clicked() { + action = delete_selected(&mut view.comments).map(|e| CanvasAction::Edit(Box::new(e))); ui.close(); } if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() { action = Some(CanvasAction::Properties(page, index)); ui.close(); } - if ui.add_enabled(tool_for(a).is_some(), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { + if ui.add_enabled(defaultable(a), egui::Button::new(tl!("Make Current Properties Default"))).clicked() { view.comments.default_request = Some((page, index)); ui.close(); } @@ -140,6 +191,8 @@ pub enum CanvasAction { OpenComments, /// Comment Properties for (page, index). Properties(usize, usize), + /// Give every selected comment this colour. + Colour(Rgb), /// Copy Image: image (page, index in `page_images`) to the clipboard. CopyImage(usize, usize), /// Copy Image in Original Format: the same image as a file (JPEG as stored, else PNG). @@ -286,25 +339,32 @@ pub fn fill_picker(ui: &mut egui::Ui, current: Option) -> Option Option { - let style = prefs.style(tool); - let mut selected_color = None; - if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() { - prefs.pinned = !prefs.pinned; - } +pub(crate) fn quick_bar_colour(ui: &mut egui::Ui, current: Rgb) -> Option { let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); - ui.painter().circle_filled(r.center(), 9.0, color32(style.color)); + ui.painter().circle_filled(r.center(), 9.0, color32(current)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50))); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour"))); let resp = resp.on_hover_text(tl!("Colour")); + let mut picked = None; egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(c) = swatch_grid(ui, Some(style.color)) { - prefs.set_color(tool, c); - selected_color = Some(c); + if let Some(c) = swatch_grid(ui, Some(current)) { + picked = Some(c); ui.close(); } }); + picked +} + +/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness. +pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option { + let style = prefs.style(tool); + if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() { + prefs.pinned = !prefs.pinned; + } + let selected_color = quick_bar_colour(ui, style.color); + if let Some(c) = selected_color { + prefs.set_color(tool, c); + } if tool.has_fill() { let resp = fill_button(ui, style.fill); egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { @@ -320,8 +380,10 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m ui.set_min_width(180.0); let mut percent = (style.opacity * 100.0).round(); ui.label(egui::RichText::new(tl!("Opacity")).font(theme::semibold(12.0))); + // The whole comment's: its fill and text take it too. if ui.add(egui::Slider::new(&mut percent, 10.0..=100.0).step_by(5.0).suffix(" %")).changed() { prefs.set_opacity(tool, percent / 100.0); + prefs.set_part_opacity(tool, None, None); } }); if tool.has_width() { @@ -338,6 +400,33 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m selected_color } +/// A border-colour button: a hollow square in the border's colour (`current`, else the text's, +/// `text`), with a menu of "Same as text" and the swatches. Returns the choice (`Some(None)`: +/// the text's colour again). +pub(crate) fn border_colour_button(ui: &mut egui::Ui, current: Option, text: Rgb) -> Option> { + let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); + let sq = Rect::from_center_size(r.center(), vec2(15.0, 15.0)); + ui.painter().rect_stroke(sq, 1.0, Stroke::new(3.0, color32(current.unwrap_or(text))), egui::StrokeKind::Inside); + if resp.hovered() { + ui.painter().rect_stroke(r.shrink(2.0), 4.0, Stroke::new(1.0, Color32::from_black_alpha(40)), egui::StrokeKind::Inside); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Border colour"))); + let resp = resp.on_hover_text(tl!("Border colour")); + let mut picked = None; + egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).show(|ui| { + ui.label(egui::RichText::new(tl!("Border colour")).font(theme::semibold(12.0))); + if ui.add(egui::Button::new(tl!("Same as text")).selected(current.is_none())).clicked() { + picked = Some(None); + ui.close(); + } + if let Some(c) = swatch_grid(ui, current) { + picked = Some(Some(c)); + ui.close(); + } + }); + picked +} + /// The quick bar's fill button: a filled square in the fill colour, or a crossed-out outline /// for no fill. fn fill_button(ui: &mut egui::Ui, fill: Option) -> egui::Response { diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 8faae3d02e..c122bca275 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,8 +1,8 @@ -use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable}; +use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, reshapable, reshaped, resizable}; use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, - Stroke, color32, vec2, + Stroke, color32, pos2, vec2, }; use crate::page_router::{CommentPart, Hit, Owner}; @@ -38,6 +38,12 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } + // The comments selected besides the one with the handles: outlined, not resizable. + for a in cv.also.iter().filter(|(p, _)| *p == cx.page).filter_map(|(_, i)| cx.get(*i)) { + for r in cx.screen_rects(a) { + painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle); + } + } if let Some((page, index)) = cv.selected && page == cx.page && let Some(a) = cx.get(index) @@ -93,6 +99,11 @@ pub(super) fn changing_points( }), (_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()), (_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + // Moved with the rest of the selection: its own move in the batch. + (_, Some(Edit::Batch { edits, .. })) => edits.iter().find_map(|e| match e { + Edit::MoveAnnotation { page: p, index: i, dx, dy } if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)), + _ => None, + }), _ => None, } } @@ -128,6 +139,82 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { { paint_draw(painter, cx, tool, points); } + // The bundle of copied comments, hanging on the pointer over this page: its box, and each + // comment's outline in its colour, so the drop can be aimed to the line. + if let Some(Gesture::Place { bounds, count, outlines, .. }) = &view.comments.gesture + && let Some(p) = cx.ptr.pos + && cx.xf.rect.contains(p) + { + let [x, y] = cx.to_user(p); + let (dx, dy) = (x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0); + let r = Rect::from_two_pos(cx.to_screen([bounds[0] + dx, bounds[1] + dy]), cx.to_screen([bounds[2] + dx, bounds[3] + dy])); + painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.6)), egui::StrokeKind::Middle); + let at = |q: [f64; 2]| cx.to_screen([q[0] + dx, q[1] + dy]); + for outline in outlines { + paint_outline(painter, outline, &at); + } + let label = crate::i18n::fmt(tl!("Comments: {n}"), &[("n", &count.to_string())]); + painter.text(r.left_top() + vec2(0.0, -4.0), egui::Align2::LEFT_BOTTOM, label, crate::theme::regular(11.0), SELECT_BLUE); + } +} + +/// One copied comment's outline on screen (`at` maps a user-space point, moved to where the +/// bundle hangs), in the comment's colour. +fn paint_outline(painter: &egui::Painter, outline: &pdfcraft_engine::CommentOutline, at: &dyn Fn([f64; 2]) -> Pos2) { + use pdfcraft_engine::CommentOutline as O; + let color = |c: &Option<[f64; 3]>| { + c.map(|[r, g, b]| Color32::from_rgb((r.clamp(0.0, 1.0) * 255.0) as u8, (g.clamp(0.0, 1.0) * 255.0) as u8, (b.clamp(0.0, 1.0) * 255.0) as u8)) + .unwrap_or(SELECT_BLUE) + }; + match outline { + O::Rect { rect, round, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]])); + if *round { + let (cx, cy, rx, ry) = (r.center().x, r.center().y, r.width() / 2.0, r.height() / 2.0); + let points: Vec = + (0..48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|t| pos2(cx + rx * t.cos(), cy + ry * t.sin())).collect(); + painter.add(egui::Shape::closed_line(points, stroke)); + } else { + painter.rect_stroke(r, CornerRadius::ZERO, stroke, egui::StrokeKind::Middle); + } + } + O::Line { points, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + painter.add(egui::Shape::line(points.iter().map(|p| at(*p)).collect(), stroke)); + } + O::Poly { points, closed, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + let points: Vec = points.iter().map(|p| at(*p)).collect(); + painter.add(if *closed { egui::Shape::closed_line(points, stroke) } else { egui::Shape::line(points, stroke) }); + } + O::Ink { strokes, color: c } => { + let stroke = Stroke::new(1.5, color(c)); + for s in strokes { + painter.add(egui::Shape::line(s.iter().map(|p| at(*p)).collect(), stroke)); + } + } + O::Quads { quads, color: c } => { + let fill = color(c).gamma_multiply(0.35); + for q in quads { + // Upper left, upper right, lower right, lower left: a closed shape in that order. + let points = vec![at([q[0], q[1]]), at([q[2], q[3]]), at([q[6], q[7]]), at([q[4], q[5]])]; + painter.add(egui::Shape::convex_polygon(points, fill, Stroke::NONE)); + } + } + O::Text { rect, text, size, color: c } => { + // At the page's zoom, wrapped in the box; too small to read at this zoom, left out. + let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]])); + let zoom = at([1.0, 0.0]).distance(at([0.0, 0.0])); + let px = *size as f32 * zoom; + if !(px.is_finite() && px >= 3.0) { + return; + } + let pad = 2.0 * zoom; + let galley = painter.layout(text.clone(), egui::FontId::proportional(px.min(400.0)), color(c), (r.width() - 2.0 * pad).max(1.0)); + painter.with_clip_rect(r.intersect(painter.clip_rect())).galley(r.min + vec2(pad, pad), galley, color(c)); + } + } } /// A drawing tool's shape from its points (a live drag, or a released one still settling). @@ -165,10 +252,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke)); } CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => { - // The vertices so far, and a rubber band to the pointer. + // The vertices so far, and a rubber band to where a click would put the next one: the + // first, where the click closes the shape, Shift or not. let mut line = pts.clone(); if let Some(p) = cx.ptr.pos { - line.push(p); + let shift = painter.ctx().input(|i| i.modifiers.shift); + line.push(if closes_at(cx, *tool, points, p) { pts[0] } else { cx.to_screen(held(points.last(), cx.to_user(p), shift)) }); } painter.add(egui::Shape::line(line, stroke)); if let Some(first) = pts.first().filter(|_| *tool != CommentTool::PolyLine && pts.len() >= 3) { @@ -187,6 +276,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin } } +/// Whether a click at `p` (on screen) closes the polygon or cloud drawn so far (`points`, user +/// space): on its first point again, once it has three. +pub(super) fn closes_at(cx: &PageCx<'_>, tool: CommentTool, points: &[[f64; 2]], p: Pos2) -> bool { + points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0 +} + /// The shape a finished drawing gesture makes (`None` if it is too small to mean anything). pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option { let (first, last) = (*points.first()?, *points.last()?); diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 9c876d950f..cff7b1d8a6 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -29,6 +29,10 @@ pub enum Gesture { /// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points` /// before the drag, in user space) to where the pointer is. Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> }, + /// Copied comments hanging on the pointer as one bundle (their box `bounds` in user space, + /// `count` of them, their `outlines` drawn along so the drop can be aimed): a click puts + /// their centre there, on the page clicked; Escape or a right-click lets them go. + Place { xfdf: String, bounds: [f64; 4], count: usize, outlines: Vec }, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -127,8 +131,21 @@ impl CommentView { /// Per-document commenting state. #[derive(Clone, Debug, Default)] pub struct CommentView { - /// The selected comment: (page, index in `/Annots`). + /// The selected comment: (page, index in `/Annots`). With several selected (`also`), the one + /// the handles, the properties and the context menu act on. pub selected: Option<(usize, usize)>, + /// The other selected comments (⌘/Ctrl-click or ⇧-click on the page adds one, ⇧-click in the + /// Comments panel a range): Delete, Copy, Properties and a drag act on them with `selected`. + pub also: Vec<(usize, usize)>, + /// Copy the selected comments (set by the menus; the app has the clipboard). + pub copy_request: bool, + /// Paste the copied comments onto this page, centred at this user-space point (set by the + /// page's menu, which knows where it was opened). + pub paste_request: Option<(usize, [f64; 2])>, + /// A notice for the app to show (the page input has no app). + pub notice: Option<&'static str>, + /// Finish the connected lines, polygon or cloud being drawn (the context menu's Complete). + pub finish_request: bool, pub gesture: Option, /// Drawings just released: shown from their points until the page raster that includes the /// committed annotation arrives, so the stroke never vanishes in between (#1858). @@ -187,6 +204,55 @@ pub(crate) enum EditorFocus { } impl CommentView { + /// Every selected comment, the one the handles belong to first. + pub fn selection(&self) -> Vec<(usize, usize)> { + let mut all: Vec<(usize, usize)> = self.selected.into_iter().collect(); + all.extend(self.also.iter().copied().filter(|k| Some(*k) != self.selected)); + all + } + + /// Whether `key` (page, index) is among the selected comments. + pub fn is_selected(&self, key: (usize, usize)) -> bool { + self.selected == Some(key) || self.also.contains(&key) + } + + /// Select `key` alone. + pub fn select_only(&mut self, key: (usize, usize)) { + self.selected = Some(key); + self.also.clear(); + } + + /// ⇧-click in a list (`order`, as shown): every comment from the one with the handles to + /// `key` is selected, and only those, so another ⇧-click from the same comment changes the + /// range. Without a comment with the handles, or with either end not in the list, `key` is + /// added as by ⌘/Ctrl-click. + pub fn select_range(&mut self, order: &[(usize, usize)], key: (usize, usize)) { + let ends = self.selected.and_then(|anchor| Some((order.iter().position(|k| *k == anchor)?, order.iter().position(|k| *k == key)?))); + let Some((a, b)) = ends else { return self.toggle(key) }; + let anchor = self.selected; + self.also = order.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().copied().filter(|k| Some(*k) != anchor).collect(); + } + + /// ⌘/Ctrl-click: add `key` to the selection, or take it out again. The handles go to the + /// comment clicked last. + pub fn toggle(&mut self, key: (usize, usize)) { + if self.selected == Some(key) { + self.selected = self.also.pop(); + } else if let Some(at) = self.also.iter().position(|k| *k == key) { + self.also.remove(at); + } else { + if let Some(previous) = self.selected.replace(key) { + self.also.push(previous); + } + } + } + + /// Nothing selected. + pub fn clear_selection(&mut self) { + self.selected = None; + self.also.clear(); + } + /// Takes back the last point of a polygon, cloud or connected lines being drawn; the first /// point's removal cancels the drawing. Returns whether there was such a drawing. pub fn undo_point(&mut self) -> bool { @@ -226,6 +292,7 @@ impl CommentView { /// went too. pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) { self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k)); + self.also = std::mem::take(&mut self.also).into_iter().filter_map(|k| after_delete(before, after, gone, k)).collect(); self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text))); if self.editing.is_none() { self.editor_focus = None; @@ -237,7 +304,7 @@ impl CommentView { /// editor close, its unsaved text with it, rather than Save writing that text over whatever /// took its place. pub(crate) fn history_stepped(&mut self) { - self.selected = None; + self.clear_selection(); self.editing = None; self.editor_focus = None; } diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 0f9edd6c22..acb4008d3b 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -33,6 +33,13 @@ fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() { let to = Some(pos2(60.0, 10.0)); assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]])); assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing"); + // Released with the rest of the selection: its own move in the batch. + let together = Edit::Batch { + label: "Move comments".into(), + edits: vec![Edit::MoveAnnotation { page: 0, index: 1, dx: 1.0, dy: 1.0 }, Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }], + }; + assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]])); + assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, false), None, "not in the batch"); } /// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit @@ -407,7 +414,8 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men let mut h = egui_kittest::Harness::new_ui_state( |ui, (view, actions): &mut (DocView, Vec)| { ui.button("Page").context_menu(|ui| { - if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, true, true) { + let rights = MenuRights { allowed: true, can_paste: false, can_modify: true }; + if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, rights) { actions.push(a); } }); @@ -490,6 +498,24 @@ fn drawn_shapes_ignore_slips() { )); } +#[test] +fn shift_click_selects_the_range_from_the_comment_with_the_handles() { + let order = [(0, 3), (0, 1), (0, 0), (1, 2), (1, 0)]; + let mut cv = CommentView::default(); + cv.select_only((0, 1)); + cv.select_range(&order, (1, 2)); + assert_eq!(cv.selection(), vec![(0, 1), (0, 0), (1, 2)], "from the anchor down, the anchor keeps the handles"); + // Another ⇧-click from the same anchor changes the range, upwards too. + cv.select_range(&order, (0, 3)); + assert_eq!(cv.selection(), vec![(0, 1), (0, 3)]); + // Nothing to start from, or a card not in the list: added as by ⌘/Ctrl-click. + let mut none = CommentView::default(); + none.select_range(&order, (0, 0)); + assert_eq!(none.selection(), vec![(0, 0)]); + cv.select_range(&order, (9, 9)); + assert_eq!(cv.selected, Some((9, 9))); +} + #[test] fn text_boxes_fit_their_text() { let r = text_box_rect([100.0, 500.0], "Hi", 12.0, 0); diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 55f0624c08..c7e270fdb9 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -6,7 +6,7 @@ use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; -use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn}; +use pdfcraft_engine::{BorderShape, Edit, FillMark, NewAnnotation, PhotoRect, Rgb, Shape, SignatureImage, SignaturePhoto, Style, Turn}; use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageToolContext, PageXform}; @@ -215,9 +215,24 @@ pub struct TypeBox { pub text: String, pub size: f64, pub color: [f64; 3], + /// The border around the text. + pub border: TextBorder, pub focus: bool, } +/// The border around typed text: its width in points (0: none), its own colour (`None`: the +/// text's) and its shape (an oval or a circle goes around the text). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TextBorder { + pub width: f64, + pub color: Option, + pub shape: BorderShape, +} + +impl TextBorder { + pub const NONE: Self = Self { width: 0.0, color: None, shape: BorderShape::Rectangle }; +} + /// The default size Fill & Sign uses for typed text. pub const TEXT_SIZE: f64 = 10.0; pub const MIN_TEXT_SIZE: f64 = 4.0; @@ -231,6 +246,14 @@ pub fn valid_text_color(color: [f64; 3]) -> Option<[f64; 3]> { color.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)).then_some(color) } +/// The borders the toolbar offers around typed text, in points (0: none). +pub const TEXT_BORDERS: [f64; 6] = [0.0, 0.5, 1.0, 1.5, 2.0, 3.0]; +pub const MAX_TEXT_BORDER: f64 = 12.0; + +pub fn valid_text_border(width: f64) -> Option { + (width.is_finite() && (0.0..=MAX_TEXT_BORDER).contains(&width)).then_some(width) +} + fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { let (vx, vy) = xf.screen_to_view(p); let u = info.pages[page].view_to_user(vx, vy); @@ -243,17 +266,36 @@ fn new(page: usize, shape: Shape, contents: String, author: &str) -> Edit { } /// A typewriter annotation sized to its text, its displayed top-left at `at` on a page turned by -/// `rotation` (its `/Rotate`). +/// `rotation` (its `/Rotate`), in black and without a border. pub fn typed(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str) -> Edit { - typed_with_color(page, at, text, size, rotation, author, [0.0; 3]) + typed_with(page, at, text, size, rotation, author, TypedLook::default()) } -/// Typed text/date colour applies to its annotation appearance and survives saving. -pub fn typed_with_color(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, color: [f64; 3]) -> Edit { - let rect = crate::comments::text_box_rect(at, text, size, rotation); - let shape = Shape::Typewriter { rect, font_size: size }; - let mut style = Style::default_for(&shape); - style.color = valid_text_color(color).unwrap_or([0.0; 3]); +/// How typed text and dates look: their colour, and the border around them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct TypedLook { + pub color: [f64; 3], + pub border: TextBorder, +} + +/// [`typed`] in `look`: the colour applies to the annotation's appearance and survives saving; a +/// border goes around the box sized to the text, so the text sits where it would without one. +pub fn typed_with(page: usize, at: [f64; 2], text: &str, size: f64, rotation: i64, author: &str, look: TypedLook) -> Edit { + let width = valid_text_border(look.border.width).unwrap_or(0.0); + // A circle goes around the text, so its box is no wider than the text needs. + let [x0, y0, x1, y1] = if width > 0.0 && look.border.shape == BorderShape::Circle { + crate::comments::snug_text_box_rect(at, text, size, rotation) + } else { + crate::comments::text_box_rect(at, text, size, rotation) + }; + let shape = Shape::Typewriter { rect: [x0 - width, y0 - width, x1 + width, y1 + width], font_size: size }; + let style = Style { + color: valid_text_color(look.color).unwrap_or([0.0; 3]), + width, + border_color: look.border.color.filter(|_| width > 0.0), + border_shape: if width > 0.0 { look.border.shape } else { BorderShape::Rectangle }, + ..Style::default_for(&shape) + }; Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: text.to_string(), author: author.to_string() }) } @@ -374,20 +416,22 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, } continue; }; - ui.horizontal(|ui| { + let on = app.quick_tool == crate::QuickTool::Fill(if i == 0 { FillTool::Signature } else { FillTool::Initials }); + let wash = ui.painter().add(egui::Shape::Noop); + let row = ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click()); let use_label = label("Use {what}"); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone())); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, use_label.clone())); let painter = ui.painter_at(rect); painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE); painter.rect_stroke( rect, CornerRadius::same(4), - Stroke::new(1.0, if response.hovered() { t.accent } else { t.border }), + Stroke::new(1.0, if on || response.hovered() { t.accent } else { t.border }), egui::StrokeKind::Inside, ); paint_saved(ui, rect.shrink(8.0), saved, &mut app.saved_signature_previews[i], &format!("saved-{what}")); - if response.on_hover_text(label("Place saved {what}")).clicked() { + if crate::theme::hand(response).on_hover_text(label("Place saved {what}")).clicked() { command = Some(use_id); } if crate::icons::button(ui, "pencil", 26.0, false, &label("Change {what}")).clicked() { @@ -397,6 +441,9 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, command = Some(remove_id); } }); + if on { + ui.painter().set(wash, egui::Shape::rect_filled(row.response.rect.expand(3.0), CornerRadius::same(6), t.accent_soft)); + } ui.add_space(4.0); } command @@ -448,6 +495,7 @@ pub(crate) struct PageInput<'a> { pub(crate) tool: FillTool, pub(crate) text_size: f64, pub(crate) text_color: [f64; 3], + pub(crate) text_border: TextBorder, pub(crate) signature: Option<&'a SavedSig>, pub(crate) initials: Option<&'a SavedSig>, pub(crate) preview: &'a mut Option<(SavedSig, egui::TextureHandle)>, @@ -458,7 +506,7 @@ pub(crate) struct PageInput<'a> { /// Preview and clicks with a Fill & Sign tool on one page. pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; - let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input; + let PageInput { tool, text_size, text_color, text_border, signature, initials, preview, author, date_text } = input; let pointer = ptr.within(xf.rect)?; let p = info.pages.get(page)?; let at = to_user(xf, info, page, pointer); @@ -486,22 +534,41 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo match tool { FillTool::Text => { // Clicking away from an open box commits it: the page sees this click before the box - // sees its lost focus, so replacing the box first would drop what was typed. - let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true }); + // sees its lost focus, so replacing the box first would drop what was typed. A new box + // deselects, as a comment tool's note or text box does: Escape then closes the box and + // leaves the tool in two presses. + view.comments.clear_selection(); + let open = view.fill_text.replace(TypeBox { + page, + at: top, + text: String::new(), + size: text_size, + color: text_color, + border: text_border, + focus: true, + }); open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| { - FillAction::Edit(Box::new(typed_with_color( + FillAction::Edit(Box::new(typed_with( tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, - tb.color, + TypedLook { color: tb.color, border: tb.border }, ))) }) } FillTool::Date => Some(match date_text { - Ok(text) => FillAction::Edit(Box::new(typed_with_color(page, top, text, text_size, rotation, author, text_color))), + Ok(text) => FillAction::Edit(Box::new(typed_with( + page, + top, + text, + text_size, + rotation, + author, + TypedLook { color: text_color, border: text_border }, + ))), Err(why) => FillAction::Refused(why.clone()), }), FillTool::Signature => match signature { @@ -578,11 +645,40 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, .id_salt("fill-text-edit"), ) .accessible_name(tl!("Text to place")); + // The border the text will get, around the box being typed into. + if t.border.width > 0.0 { + let w = (t.border.width as f32 * zoom).max(1.0); + let color = t.border.color.map_or(Color32::BLACK, crate::comments::color32); + match t.border.shape { + BorderShape::Rectangle => { + ui.painter().rect_stroke(r.rect, 0.0, Stroke::new(w, color), egui::StrokeKind::Outside); + } + // A circle around the text typed so far, as `typed` will draw it: the box sized to + // the text, the border around that. + BorderShape::Circle => { + let (bw, size) = (t.border.width, t.size); + let longest = pdfcraft_engine::annot_text::text_width(t.text.trim(), size); + let (tw, th) = (longest + 8.0 + 2.0 * bw, size * 1.2 + 6.0 + 2.0 * bw); + let o = BorderShape::Circle.around([0.0, 0.0, tw, th], bw, Some([longest, size * 1.2])); + let centre = pos + egui::vec2((tw / 2.0 - bw) as f32, (th / 2.0 - bw) as f32) * zoom; + let radius = (o[2] - o[0]) as f32 / 2.0 * zoom - w / 2.0; + ui.painter().add(egui::Shape::circle_stroke(centre, radius, Stroke::new(w, color))); + } + // An oval around it, as it will be drawn (half the line outside). + shape => { + let b = r.rect.expand(w); + let o = shape.around([b.min.x, b.min.y, b.max.x, b.max.y].map(f64::from), f64::from(w), None); + let half = egui::vec2((o[2] - o[0]) as f32 / 2.0 - w / 2.0, (o[3] - o[1]) as f32 / 2.0 - w / 2.0); + ui.painter().add(egui::Shape::ellipse_stroke(r.rect.center(), half, Stroke::new(w, color))); + } + } + } if t.focus { r.request_focus(); t.focus = false; } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Taken here: the same press must not also leave the tool (one step back per press). + if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { cancel = true; } else if r.lost_focus() { commit = true; @@ -595,7 +691,8 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed_with_color(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); + let look = TypedLook { color: tb.color, border: tb.border }; + return Some(typed_with(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, look)); } } None diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index fffbd9d6d8..0ef5806ee3 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -578,7 +578,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } if let Some((page, annotation, reading, scale)) = select { app.views[index].current = page; - app.views[index].comments.selected = Some((page, annotation)); + app.views[index].comments.select_only((page, annotation)); app.views[index].measure.reading = Some(reading); app.views[index].measure.scale = Some(scale); app.quick_tool = QuickTool::Select; diff --git a/crates/ui-egui/src/widgets/icon_data.rs b/crates/ui-egui/src/widgets/icon_data.rs index 72829fe4b8..d58ddcbab6 100644 --- a/crates/ui-egui/src/widgets/icon_data.rs +++ b/crates/ui-egui/src/widgets/icon_data.rs @@ -187,6 +187,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("wrench", include_bytes!("../../../../assets/icons/wrench.svg")), ("move-horizontal", include_bytes!("../../../../assets/icons/move-horizontal.svg")), ("pilcrow", include_bytes!("../../../../assets/icons/pilcrow.svg")), + ("a-large-small", include_bytes!("../../../../assets/icons/a-large-small.svg")), ("subscript", include_bytes!("../../../../assets/icons/subscript.svg")), ("superscript", include_bytes!("../../../../assets/icons/superscript.svg")), ("x", include_bytes!("../../../../assets/icons/x.svg")), diff --git a/crates/ui-egui/src/widgets/icons.rs b/crates/ui-egui/src/widgets/icons.rs index 5ad5425e8d..13f50a0d6c 100644 --- a/crates/ui-egui/src/widgets/icons.rs +++ b/crates/ui-egui/src/widgets/icons.rs @@ -99,7 +99,7 @@ mod tests { #[test] fn shared_catalog_preserves_existing_icons_and_fallback_uri() { - assert_eq!(ICONS.len(), 190); + assert_eq!(ICONS.len(), 191); assert!(exists("minimize-2")); for (name, original) in ICONS { let expected = diff --git a/crates/ui-egui/src/workflows.rs b/crates/ui-egui/src/workflows.rs index 63686a70cc..9314914ec4 100644 --- a/crates/ui-egui/src/workflows.rs +++ b/crates/ui-egui/src/workflows.rs @@ -11,4 +11,5 @@ pub(super) mod js_ui; pub(super) mod ocr_ui; pub(super) mod optimize_ui; pub(super) mod print_ui; +pub(super) mod scan_clean_ui; pub(super) mod web_print; diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index 8b428a9727..fee4f7c42b 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -387,6 +387,11 @@ impl PdfCraftApp { self.notify_tr("Optimization is already running"); return; } + // Both jobs share the progress card and capture the same document state. + if self.scan_clean_run.is_some() { + self.notify_tr("Scanned pages are already being enhanced"); + return; + } let job = match self.session.optimize_job(id, settings, discard) { Ok(job) => job, Err(e) => return self.save_optimized(id, kind, Err(e)), @@ -518,4 +523,22 @@ mod tests { app.optimize_with_draft(); assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); } + + #[test] + fn optimize_and_enhance_scanned_file_do_not_run_at_once() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap(); + let (_, doc) = app.active_ids().unwrap(); + app.scan_clean_run = Some(crate::scan_clean_ui::ScanCleanRun { doc, progress: Arc::default() }); + app.optimize_with_draft(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Scanned pages are already being enhanced")); + assert!(app.optimize_run.is_none()); + + app.scan_clean_run = None; + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() }); + app.start_scan_clean(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); + assert!(app.scan_clean_run.is_none()); + } } diff --git a/crates/ui-egui/src/workflows/scan_clean_ui.rs b/crates/ui-egui/src/workflows/scan_clean_ui.rs new file mode 100644 index 0000000000..81d6764af9 --- /dev/null +++ b/crates/ui-egui/src/workflows/scan_clean_ui.rs @@ -0,0 +1,262 @@ +//! Scan & OCR ▸ Enhance scanned file: the dialog (pages, background removal level, edge +//! shadows, despeckle) and the background run with its progress card. The scans are cleaned +//! on a worker thread ([`pdfcraft_engine::scan_clean`]); the result is applied as one undoable +//! step when it is done, unless the document changed meanwhile. + +use std::sync::{Arc, Mutex}; + +use egui::{Align, Layout}; +use pdfcraft_engine::scan_clean::{ScanCleanReport, ScanCleanSettings, ScanCleaned}; +use pdfcraft_engine::{DocId, EditError}; + +use crate::ocr_ui::OcrPages; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +/// The dialog's choices (kept between runs). +#[derive(Clone, Debug, PartialEq)] +pub struct ScanCleanDraft { + pub pages: OcrPages, + pub from: usize, + pub to: usize, + pub settings: ScanCleanSettings, +} + +impl Default for ScanCleanDraft { + fn default() -> Self { + ScanCleanDraft { pages: OcrPages::All, from: 1, to: 1, settings: ScanCleanSettings::default() } + } +} + +/// Progress of a run: images done, total, the result once finished, and a cancel request. +#[derive(Default)] +pub struct ScanCleanProgress { + pub done: usize, + pub total: usize, + pub result: Option>, + pub cancel: bool, +} + +pub struct ScanCleanRun { + pub doc: DocId, + pub progress: Arc>, +} + +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len().max(1)); + let d = &mut app.scan_clean_draft; + d.to = d.to.clamp(1, pages); + d.from = d.from.clamp(1, d.to); + ui.label(egui::RichText::new(tl!("Enhance Scanned File")).font(theme::semibold(18.0))); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tl!("Cleans up the scanned pictures in place: recognized text, comments and links on the pages stay as they are.")) + .small() + .color(t.text_muted), + ); + ui.add_space(8.0); + let group = |ui: &mut egui::Ui, title: &str, body: &mut dyn FnMut(&mut egui::Ui)| { + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(13.0))); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + body(ui); + }); + ui.add_space(8.0); + }; + group(ui, "Pages", &mut |ui| { + widgets::radio_value(ui, &mut d.pages, OcrPages::All, tl!("All pages")); + widgets::radio_value(ui, &mut d.pages, OcrPages::Current, tl!("Current page")); + ui.horizontal(|ui| { + let from = widgets::radio_value(ui, &mut d.pages, OcrPages::Range, tl!("From")); + let on = d.pages == OcrPages::Range; + ui.add_enabled(on, egui::DragValue::new(&mut d.from).range(1..=pages)).labelled_by(from.id); + let to = ui.label(tl!("to")); + ui.add_enabled(on, egui::DragValue::new(&mut d.to).range(1..=pages)).labelled_by(to.id); + }); + }); + group(ui, "Filters", &mut |ui| { + ui.horizontal(|ui| { + let label = ui.label(tl!("Background removal")); + ui.spacing_mut().slider_width = 220.0; + ui.add(egui::Slider::new(&mut d.settings.level, 0..=pdfcraft_engine::camera_clean::MAX_LEVEL).trailing_fill(true)) + .labelled_by(label.id) + .on_hover_text(tl!("Evens out the paper so a grey or yellowed background turns white, and darkens faint strokes. 0 is off.")); + }); + ui.add(widgets::checkbox(&mut d.settings.edge_shadows, tl!("Edge shadow removal"))) + .on_hover_text(tl!("Lightens shadows along the edges of the scan, such as a book's gutter or a copier's dark border")); + ui.add(widgets::checkbox(&mut d.settings.despeckle, tl!("Despeckle"))) + .on_hover_text(tl!("Removes isolated dark specks such as dust and scanner noise. The only filter black and white scans take.")); + }); + ui.add_space(6.0); + let (mut go, mut cancel) = (false, false); + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let ready = d.settings.changes_something(); + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Enhance"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }) + }); + (go, cancel) +} + +/// What a finished run says: what was cleaned, what was not a scan, and what was left and why. +fn summary(r: &ScanCleanReport) -> String { + let lang = crate::i18n::current(); + let mut out = if r.pages.is_empty() { + tl!("No scanned pages were cleaned up.").to_string() + } else { + crate::i18n::trn(lang, r.pages.len() as u64, "Cleaned up the scan on {n} page.", "Cleaned up the scans on {n} pages.") + }; + if !r.not_scanned.is_empty() { + out.push(' '); + out.push_str(&crate::i18n::trn(lang, r.not_scanned.len() as u64, "{n} page is not a scan.", "{n} pages are not scans.")); + } + if let Some(first) = r.skipped.first() { + out.push(' '); + let page = first.pages.first().map_or_else(String::new, |p| (p + 1).to_string()); + // The reason is the engine's own sentence, in English. + out.push_str(&crate::i18n::fmt( + &crate::i18n::trn( + lang, + r.skipped.len() as u64, + "{n} image was left as it was (page {p}: {why}).", + "{n} images were left as they were (page {p}: {why}, …).", + ), + &[("p", &page), ("why", &first.reason)], + )); + } + out +} + +impl PdfCraftApp { + /// Clean up the scans on the pages chosen in the dialog, in the background. + pub fn start_scan_clean(&mut self) { + let Some((vi, id)) = self.active_ids() else { return }; + if self.scan_clean_run.is_some() { + self.notify_tr("Scanned pages are already being enhanced"); + return; + } + // Both jobs share the progress card and capture the same document state. + if self.optimize_run.is_some() { + self.notify_tr("Optimization is already running"); + return; + } + // What's typed in a form field is part of the document (#166). + if !self.commit_form_typing() { + return; + } + let d = &self.scan_clean_draft; + let pages: Vec = match d.pages { + OcrPages::All => Vec::new(), + OcrPages::Current => self.views.get(vi).map(|v| vec![v.current]).unwrap_or_default(), + OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), + }; + let job = match self.session.scan_clean_job(id, &pages, d.settings) { + Ok(job) => job, + Err(e) => return self.notify(e.to_string()), + }; + let progress = Arc::new(Mutex::new(ScanCleanProgress { total: job.total(), ..Default::default() })); + let p = progress.clone(); + let work = move || { + let result = job.run(|done, total| { + let Ok(mut s) = p.lock() else { return false }; + s.done = done; + s.total = total; + !s.cancel + }); + if let Ok(mut s) = p.lock() { + s.result = Some(result); + } + }; + #[cfg(not(target_arch = "wasm32"))] + if self.run_inline { + work(); + } else if let Err(e) = std::thread::Builder::new().name("pdfcraft-scan-clean".into()).spawn(work) { + // No worker: report the failure instead of waiting for a result that never comes. + if let Ok(mut s) = progress.lock() { + s.result = Some(Err(EditError::Invalid(e.to_string()))); + } + } + #[cfg(target_arch = "wasm32")] + work(); + self.scan_clean_run = Some(ScanCleanRun { doc: id, progress }); + self.poll_scan_clean(); + } + + /// Stop a running clean-up (nothing changes). + pub fn cancel_scan_clean(&mut self) { + if let Some(r) = &self.scan_clean_run + && let Ok(mut s) = r.progress.lock() + { + s.cancel = true; + } + } + + /// Show progress; apply the result once the worker is done. + pub(crate) fn poll_scan_clean(&mut self) { + let Some(run) = self.scan_clean_run.as_ref() else { return }; + let (doc, progress) = (run.doc, run.progress.clone()); + let Ok(mut s) = progress.lock() else { + // The worker panicked while holding the lock: it will never report back. + self.scan_clean_run = None; + self.progress_notice = None; + self.notify_tr("Couldn't enhance the scanned pages"); + return; + }; + let Some(result) = s.result.take() else { + let (cancelling, done, total) = (s.cancel, s.done, s.total); + drop(s); + let label = if cancelling { + tl!("Cancelling…").to_string() + } else { + crate::i18n::fmt( + tl!("Enhancing scans… image {d} of {t}"), + &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], + ) + }; + let fraction = if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }; + self.progress_notice = Some(crate::widgets::ProgressNotice { label, fraction, cancellable: !cancelling }); + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + return; + }; + drop(s); + self.scan_clean_run = None; + self.progress_notice = None; + match result.and_then(|cleaned| self.session.apply_scan_clean(doc, cleaned)) { + Ok(report) => { + // The page rasters, tiles and thumbnails show the old pixels at the same scale. + for v in self.views.iter_mut().filter(|v| v.id == doc) { + v.invalidate_content(); + } + let msg = summary(&report); + self.notify(msg); + } + Err(EditError::Cancelled) => self.notify_tr("Enhancing the scanned pages was cancelled"), + Err(e) => self.notify(e.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_engine::scan_clean::SkippedImage; + + #[test] + fn the_summary_says_what_was_done_and_what_was_left() { + let r = ScanCleanReport { + pages: vec![0, 2], + images: 1, + not_scanned: vec![1], + skipped: vec![SkippedImage { pages: vec![3], name: "Im0".into(), reason: "it is CMYK".into() }], + }; + assert_eq!(summary(&r), "Cleaned up the scans on 2 pages. 1 page is not a scan. 1 image was left as it was (page 4: it is CMYK)."); + assert_eq!(summary(&ScanCleanReport::default()), "No scanned pages were cleaned up."); + } +} diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 04bee7d423..dd6e7178b0 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -100,6 +100,22 @@ fn every_registered_command_is_implemented() { if spec.id == "edit.redo" { app.undo(); } + // Copying comments needs a selected comment; pasting needs copied ones. + if matches!(spec.id, "comment.copy" | "comment.paste") { + let shape = pdfcraft_engine::Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: String::new(), + author: String::new(), + }))); + app.views[0].comments.select_only((0, 0)); + if spec.id == "comment.paste" { + assert!(app.copy_selected_comments(0)); + } + } if spec.id.starts_with("edit.") && (spec.id.ends_with(".update") || spec.id.ends_with(".remove")) { use pdfcraft_engine::{Background, Edit, HeaderFooter, Watermark}; let mut hf = HeaderFooter::default(); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index e8dfb1b8b0..2308ba9e0f 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -84,6 +84,19 @@ fn click_pt(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)) { let p = at(h, p.0, p.1); click(h, p); } +/// Click a page point with modifier keys held (⌘-click adds a comment to the selection). +fn click_pt_with(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32), modifiers: egui::Modifiers) { + let p = at(h, p.0, p.1); + h.hover_at(p); + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(2); +} /// A text field by its placeholder ("Add a comment", "Add a reply"). fn field<'a>(h: &'a Harness<'static, PdfCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> { @@ -143,6 +156,53 @@ fn sidebar_comment_centres_the_annotation_at_high_zoom() { } } +/// A double-click on the text of a card in the Comments panel edits it, as one on its header does +/// (a click there would select a word): on a selected card, and on one the first click selects. +#[test] +fn double_clicking_a_cards_text_edits_it() { + for selected in [true, false] { + double_click_a_cards_text(selected); + } +} + +fn double_click_a_cards_text(selected: bool) { + // Frames 1/60 s apart, so two clicks make a double-click. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "comments").unwrap(); + let shape = pdfcraft_engine::Shape::Note { at: [150.0, 90.0], icon: pdfcraft_engine::NoteIcon::Comment }; + assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: "Edit this note".into(), + author: "Tester".into(), + }))); + app.views[0].comments.selected = selected.then_some((0, 0)); + app + }); + h.run_steps(4); + crate::support::settle(&mut h); + assert_eq!(h.state().views[0].comments.selected.is_some(), selected); + let p = h.query_all_by_label("Edit this note").next().expect("the card's text").rect().center(); + h.hover_at(p); + h.run_steps(1); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + } + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "selected: {selected}"); + assert_eq!(h.state().views[0].comments.editing, Some((0, 0, "Edit this note".into())), "selected: {selected}"); + // And the editor is drawn. + note_editor(&h); +} + #[test] fn dragging_over_text_with_the_highlighter_highlights_it() { let mut h = harness(|app| app.set_option("quick", "highlight").unwrap()); @@ -909,8 +969,8 @@ fn comments_are_selected_moved_and_deleted_with_the_select_tool() { } /// A line's ends have handles: dragging one with the Select tool changes the line's length and -/// angle (Shift holds it to 45°), as one undo step. The line tool keeps drawing from an end: a -/// new line starts there rather than dragging the one just drawn. +/// angle (Shift holds it level or plumb with the other end), as one undo step. The line tool keeps +/// drawing from an end: a new line starts there rather than dragging the one just drawn. #[test] fn line_ends_are_dragged_to_change_length_and_angle() { let mut h = harness(|app| app.set_option("quick", "line").unwrap()); @@ -934,7 +994,7 @@ fn line_ends_are_dragged_to_change_length_and_angle() { assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}"); let id = h.state().views[0].id; assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment")); - // Shift: held to the nearest 45° from the other end (39° here). + // Shift: held level or plumb with the other end, here level (110 pt across, 90 up). let a = at(&h, 200.0, 160.0); let b = at(&h, 150.0, 190.0); h.hover_at(a); @@ -951,8 +1011,7 @@ fn line_ends_are_dragged_to_change_length_and_angle() { h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); h.run_steps(1); let p = ends(&h); - let (dx, dy) = (p[1][0] - p[0][0], p[1][1] - p[0][1]); - assert!(dx > 50.0 && (dx - dy).abs() < 0.5, "45°: {p:?}"); + assert!((p[1][1] - p[0][1]).abs() < 0.01 && (p[1][0] - 150.0).abs() < 1.0, "level: {p:?}"); // Undo puts the line back as it was before the drag. h.state_mut().undo(); h.run_steps(2); @@ -1036,6 +1095,80 @@ fn alt_arrows_navigate_instead_of_nudging_the_selected_comment() { assert!(comments(&h)[0].rect[0] > before[0], "→ nudged it"); } +/// Over the first corner, where a click closes the polygon, the rubber band runs to that corner +/// with Shift held too: the closing side the click will make, not a level or plumb one. +#[test] +fn the_rubber_band_shows_the_closing_side_with_shift_held() { + let _gpu = crate::gpu(); + let mut h = harness(|app| app.set_option("quick", "polygon").unwrap()); + for p in [(40.0, 40.0), (140.0, 40.0), (140.0, 140.0)] { + click_pt(&mut h, p); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + h.hover_at(at(&h, 40.0, 40.0) + egui::vec2(2.0, 2.0)); + h.run_steps(3); + let pixels = h.render().unwrap(); + // Halfway along the diagonal closing side, (90, 90). + let m = at(&h, 90.0, 90.0); + let drawn = (-2..=2).any(|d: i32| { + let v = pixels.get_pixel((m.x as i32 + d) as u32, (m.y as i32 + d) as u32).0; + v[0] > 150 && v[1] < 100 && v[2] < 100 + }); + assert!(drawn, "the closing side is drawn"); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(1); +} + +/// Shift holds a new line or arrow level or plumb with where it starts, and each new side of +/// connected lines or a polygon with the corner before (a CAD drawing's ortho mode). Without +/// Shift they go where the pointer is. +#[test] +fn shift_holds_new_lines_and_sides_level_or_plumb() { + let mut h = harness(|app| app.set_option("quick", "line").unwrap()); + let shift = |h: &mut Harness<'static, PdfCraftApp>, on: bool| { + h.event(egui::Event::ModifiersChanged(if on { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE })); + h.run_steps(1); + }; + let points = |h: &Harness<'static, PdfCraftApp>, subtype: &str| { + // In the order drawn: the list isn't sorted by index. + let mut drawn: Vec<_> = comments(h).into_iter().filter(|a| a.subtype == subtype).collect(); + drawn.sort_by_key(|a| a.index); + drawn.into_iter().map(|a| a.points).collect::>() + }; + let close = |p: [f32; 2], q: [f32; 2]| (p[0] - q[0]).abs() < 1.0 && (p[1] - q[1]).abs() < 1.0; + shift(&mut h, true); + drag_pt(&mut h, (40.0, 100.0), (140.0, 120.0)); + drag_pt(&mut h, (200.0, 40.0), (215.0, 150.0)); + shift(&mut h, false); + drag_pt(&mut h, (40.0, 160.0), (140.0, 190.0)); + let lines = points(&h, "Line"); + assert_eq!(lines.len(), 3, "{lines:?}"); + assert!(close(lines[0][0], [40.0, 100.0]) && close(lines[0][1], [140.0, 100.0]), "mostly across: level: {lines:?}"); + assert!(close(lines[1][0], [200.0, 40.0]) && close(lines[1][1], [200.0, 150.0]), "mostly up: plumb: {lines:?}"); + assert!(close(lines[2][1], [140.0, 190.0]), "no Shift, no hold: {lines:?}"); + + h.state_mut().set_option("quick", "arrow").unwrap(); + shift(&mut h, true); + drag_pt(&mut h, (260.0, 180.0), (150.0, 170.0)); + shift(&mut h, false); + let arrow = points(&h, "Line").pop().expect("an arrow"); + assert!(close(arrow[1], [150.0, 180.0]), "an arrow too, leftwards: {arrow:?}"); + + // Connected lines: each click with Shift is held to the corner before. + h.state_mut().set_option("quick", "polyline").unwrap(); + click_pt(&mut h, (20.0, 20.0)); + shift(&mut h, true); + click_pt(&mut h, (90.0, 30.0)); + click_pt(&mut h, (95.0, 80.0)); + shift(&mut h, false); + click_pt(&mut h, (130.0, 60.0)); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let poly = points(&h, "PolyLine").pop().expect("connected lines"); + assert_eq!(poly.len(), 4, "{poly:?}"); + assert!(close(poly[1], [90.0, 20.0]) && close(poly[2], [90.0, 80.0]) && close(poly[3], [130.0, 60.0]), "{poly:?}"); +} + #[test] fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() { // #2338: the new shape showed its handles, but only the Select tool answered them. @@ -1240,6 +1373,165 @@ fn shapes_take_a_fill_colour_from_the_quick_bar_and_properties() { assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, Some([0.0, 0.0, 1.0])); } +/// Comment Properties ▸ Border shape puts an oval or a circle around a text box's text; a +/// callout has none to choose. Make Properties Default carries it into the Text box tool. +#[test] +fn text_box_properties_choose_the_border_shape() { + use pdfcraft_engine::BorderShape; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + style.width = 1.0; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Pos. 3".into(), + author: "Tester".into(), + }))); + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.get_by_label("Border shape"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.original.border_shape, Some(BorderShape::Rectangle)); + d.edited.border_shape = Some(BorderShape::Oval); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.comment_props(0, 0).unwrap().border_shape, Some(BorderShape::Oval)); + assert_eq!(doc.can_undo(), Some("Change comment properties")); + assert_eq!(s.comment_prefs.style(CommentTool::TextBox).border_shape, BorderShape::Oval, "the default for new text boxes"); +} + +/// Thickness 0 in Comment Properties takes a circle away with the line, as Border ▸ None does on +/// the toolbar, and a default made from it draws no circle either. +#[test] +fn no_thickness_in_text_box_properties_takes_the_circle_away() { + use pdfcraft_engine::BorderShape; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + style.width = 1.0; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Pos. 3".into(), + author: "Tester".into(), + }))); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::BorderShapeAnnotation { page: 0, index: 0, shape: BorderShape::Circle })); + h.state_mut().comment_prefs.set_border_shape(CommentTool::TextBox, BorderShape::Circle); + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.original.border_shape, Some(BorderShape::Circle)); + d.edited.width = Some(0.0); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let p = doc.comment_props(0, 0).unwrap(); + assert_eq!((p.width, p.border_shape), (Some(0.0), Some(BorderShape::Rectangle))); + assert_eq!(doc.can_undo(), Some("Change comment properties")); + assert_eq!(s.comment_prefs.style(CommentTool::TextBox).border_shape, BorderShape::Rectangle, "the default for new text boxes"); +} + +/// A filled rectangle's line and fill, and a text box's text, border and fill, each take an +/// opacity of their own in Properties; Make Properties Default carries them into the tool, so +/// the next rectangle is drawn with a see-through fill and an opaque line. +#[test] +fn comment_properties_set_each_parts_opacity() { + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + h.state_mut().comment_prefs.set_fill(CommentTool::Rectangle, Some([1.0, 1.0, 0.0])); + h.run_steps(2); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + let p = s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap(); + (p.opacity, p.fill_opacity, p.text_opacity) + }; + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + // The sliders (each labelled by its row, which shares the name). + h.get_by_role_and_label(egui::accesskit::Role::Slider, "Line opacity"); + h.get_by_role_and_label(egui::accesskit::Role::Slider, "Fill opacity"); + assert_eq!(h.query_all_by_label("Text opacity").count(), 0, "a rectangle has no text"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + d.edited.fill_opacity = Some(0.3); + d.make_default = true; + } + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 0), (1.0, Some(0.3), None)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties")); + assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill_opacity, Some(0.3)); + h.state_mut().set_option("quick", "square").unwrap(); + h.run_steps(2); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + assert_eq!(props(&h, 1), (1.0, Some(0.3), None), "the next rectangle takes the default"); + + // A text box with a border and a fill: its text, border and fill. + let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 }; + let mut style = pdfcraft_engine::Style::default_for(&shape); + (style.width, style.fill) = (1.0, Some([1.0, 1.0, 0.6])); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Note".into(), + author: "Tester".into(), + }))); + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + for label in ["Text opacity", "Border opacity", "Fill opacity"] { + h.get_by_role_and_label(egui::accesskit::Role::Slider, label); + } + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + let _gpu = crate::gpu(); + h.render().unwrap().save(format!("{dir}/comment-part-opacity.png")).unwrap(); + } + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + (d.edited.opacity, d.edited.fill_opacity) = (0.5, Some(0.2)); + } + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2), (0.5, Some(0.2), Some(1.0)), "the border at half, the fill at a fifth, the text opaque"); + // Without its fill and border, the text is the whole text box: its opacity is the comment's. + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + (d.edited.fill, d.edited.width) = (None, Some(0.0)); + } + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + let shown = egui_kittest::kittest::NodeT::accesskit_node(&h.get_by_role_and_label(egui::accesskit::Role::Slider, "Text opacity")).numeric_value(); + assert_eq!(shown, Some(100.0), "the text's slider shows the opacity it is drawn at, not the border's half"); + h.state_mut().comment_props.as_mut().expect("open").edited.opacity = 0.4; + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2), (0.4, Some(0.4), Some(0.4))); +} + #[test] fn the_panel_filters_and_sorts() { let mut h = harness(|app| { @@ -2127,6 +2419,114 @@ fn properties_dialog_makes_the_strikethrough_colour_default() { assert!(made.iter().zip(green).all(|(m, r)| (f64::from(*m) - r).abs() < 0.01), "the next strikethrough is green: {made:?}"); } +/// A text box with neither a border nor a fill is all text: Make Properties Default (and the +/// context menu's Make Current Properties Default) gives the tool the opacity its text is drawn +/// at, the one Comment Properties shows, not the comment's /CA. +#[test] +fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 0.0, fill: None, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Faint".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Faint")).map(|a| a.index).expect("the text box"); + // As the automation's comment_edit sets it. + assert!(h.state_mut().apply_edit(Edit::PartOpacity { page: 0, index, fill: None, text: Some(0.5) })); + h.run_steps(2); + let opacity = |h: &Harness<'static, PdfCraftApp>| h.state().comment_prefs.style(CommentTool::TextBox).opacity; + assert!((opacity(&h) - 1.0).abs() < 1e-6); + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); + // The context menu's way, from a tool at 100 % again. + h.state_mut().comment_prefs.set_opacity(CommentTool::TextBox, 1.0); + h.state_mut().make_comment_default(0, index); + assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h)); +} + +/// Make Properties Default on a text box with a border colour of its own gives the Text box tool +/// that colour. +#[test] +fn a_text_box_made_default_gives_the_tool_its_border_colour() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + use pdfcraft_ui_egui::comments::CommentTool; + let mut h = harness(|_| {}); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Framed".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box"); + let red = Some([0.89, 0.13, 0.13]); + assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: red })); + assert_ne!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); + h.state_mut().open_comment_props(0, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().comment_prefs.style(CommentTool::TextBox).border_color, red); +} + +/// The Text box tool's Border colour button shows the selected text box's own border colour +/// (Comment Properties may have given it one), and the tool's again with none selected. +#[test] +fn the_border_colour_button_shows_the_selected_text_boxs_colour() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|app| app.set_option("quick", "freetext").unwrap()); + let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 }; + let style = Style { width: 1.0, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "Framed".into(), + author: "Tester".into() + }))); + h.run_steps(2); + let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Framed")).map(|a| a.index).expect("the text box"); + // A border colour of its own, which the tool doesn't have. + assert!(h.state_mut().apply_edit(Edit::BorderColorAnnotation { page: 0, index, color: Some([0.0, 0.47, 0.84]) })); + h.run_steps(2); + // The colour the button's square is drawn in. + let shown = |h: &Harness<'static, PdfCraftApp>| { + let button = h.get_by_label("Border colour").rect(); + h.output().shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Rect(r) if r.stroke.width == 3.0 && button.contains_rect(r.rect) => Some(r.stroke.color), + _ => None, + }) + }; + // A new text box comes selected. + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + let tool = shown(&h).expect("the button's square"); + h.state_mut().views[0].comments.selected = Some((0, index)); + h.run_steps(3); + let own = shown(&h).expect("the button's square"); + assert!(own != tool && own.b() > 200 && own.r() < 30, "the selected text box's blue: {own:?}, the tool's {tool:?}"); + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + assert_eq!(shown(&h), Some(tool), "the tool's colour again"); +} + /// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines. #[test] fn an_arrows_default_does_not_restyle_lines() { @@ -2868,7 +3268,16 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() { for shape in [pdfcraft_engine::Shape::Rectangle { rect: [30.0, 30.0, 70.0, 70.0] }, pdfcraft_engine::Shape::Oval { rect: [230.0, 30.0, 270.0, 70.0] }] { - let style = pdfcraft_engine::Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) }; + let style = pdfcraft_engine::Style { + color: [1.0, 0.0, 0.0], + opacity: 1.0, + width: 2.0, + fill: Some([1.0, 0.0, 0.0]), + fill_opacity: None, + text_opacity: None, + border_color: None, + border_shape: pdfcraft_engine::BorderShape::Rectangle, + }; assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page: 0, shape, @@ -3286,7 +3695,7 @@ fn hotbar_docked_left_does_not_override_quick_tools() { }); h.run_steps(5); // Controls in the docked bar share one layout, so none may overlap (#5890). - let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign", "Hotbar settings"]; + let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign: Add signature", "Hotbar settings"]; let rects: Vec<_> = labels.iter().map(|&label| h.get_by_label(label).rect()).collect(); for (i, a) in rects.iter().enumerate() { for (j, b) in rects.iter().enumerate().skip(i + 1) { @@ -3467,3 +3876,442 @@ fn other_edit_composers_keep_trimming_trailing_whitespace() { edit_composer_preserves_only_typewriter_whitespace(kind); } } + +/// ⌘-click (Ctrl on Linux and Windows) adds comments to the selection and takes one out again; +/// Delete removes every selected comment as one undo step. +#[test] +fn ctrl_click_selects_several_comments_and_delete_removes_them_all() { + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] { + drag_pt(&mut h, a, b); + } + assert_eq!(comments(&h).len(), 3); + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + click_pt(&mut h, (40.0, 40.0)); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 0)]); + click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 0), (0, 1)], "the handles go to the one clicked last"); + assert_eq!(h.state().views[0].comments.selected, Some((0, 2))); + // ⌘-click on a selected comment takes it out again. + click_pt_with(&mut h, (40.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 1)]); + // Delete: both go, as one step. + h.key_press(egui::Key::Delete); + h.run_steps(3); + let left = comments(&h); + assert_eq!(left.len(), 1, "{left:?}"); + assert!((left[0].rect[0] - 20.0).abs() < 1.0, "the unselected first rectangle stays: {:?}", left[0].rect); + assert!(h.state().views[0].comments.selection().is_empty()); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comments")); + h.state_mut().undo(); + assert_eq!(comments(&h).len(), 3); + // A plain click selects one alone again. + click_pt(&mut h, (120.0, 40.0)); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]); + // On the page ⇧-click adds one too, and takes it out again (a range is the panel's). + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT); + assert_eq!(h.state().views[0].comments.selection().len(), 2, "{:?}", h.state().views[0].comments.selection()); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT); + assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]); +} + +/// Dragging one of several selected comments moves them all, whichever of them is grabbed: the +/// grabbed one takes the handles and the one that had them stays selected. +#[test] +fn dragging_any_selected_comment_moves_the_whole_selection() { + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] { + drag_pt(&mut h, a, b); + } + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + click_pt(&mut h, (40.0, 40.0)); + click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND); + click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND); + assert_eq!(h.state().views[0].comments.selected, Some((0, 2)), "the last one clicked has the handles"); + // Drag the first one (not the one with the handles) 30 points up. + let before = comments(&h); + drag_pt(&mut h, (40.0, 40.0), (40.0, 70.0)); + let after = comments(&h); + assert_eq!(after.len(), 3); + for (a, b) in before.iter().zip(&after) { + assert!((b.rect[1] - a.rect[1] - 30.0).abs() < 1.5, "every selected rectangle moved up 30 pt: {:?} from {:?}", b.rect, a.rect); + } + let selection = h.state().views[0].comments.selection(); + assert_eq!(selection.len(), 3, "all three stay selected: {selection:?}"); + assert_eq!(selection[0], (0, 0), "the grabbed one has the handles"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments")); + // A locked one in the selection stays where it is; the others still move (it would refuse + // the whole step otherwise, and nothing would move). + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::LockAnnotation { page: 0, index: 1, locked: true })); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selection().len(), 3); + let before = comments(&h); + drag_pt(&mut h, (40.0, 70.0), (40.0, 90.0)); + let after = comments(&h); + // By index: the list isn't kept in index order. + let bottom = |list: &[Annotation], i: usize| list.iter().find(|a| a.index == i).map(|a| a.rect[1]).expect("still there"); + for i in 0..3 { + let moved = bottom(&after, i) - bottom(&before, i); + if i == 1 { + assert!(moved.abs() < 0.01, "the locked one stays: moved {moved}"); + } else { + assert!((moved - 20.0).abs() < 1.5, "rectangle {i} moved up 20 pt: moved {moved}"); + } + } + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments")); +} + +/// Copied comments on the pointer take the click whatever tool is on: with Fill & Sign's Text +/// tool or a stamp tool still on, the click places the bundle and opens no text box or stamp. +#[test] +fn copied_comments_take_the_click_from_the_tool_that_is_on() { + use pdfcraft_ui_egui::fill_sign::FillTool; + let mut h = harness(|_| {}); + let id = h.state().views[0].id; + let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: String::new(), + author: "Tester".into(), + }))); + h.run_steps(2); + h.state_mut().views[0].comments.select_only((0, 0)); + assert!(h.state_mut().execute("comment.copy")); + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.annotations.len(); + for (tool, at) in [(QuickTool::Fill(FillTool::Text), (150.0, 100.0)), (QuickTool::Stamp(pdfcraft_engine::StampKind::Approved), (200.0, 160.0))] { + h.state_mut().quick_tool = tool; + h.run_steps(2); + let before = count(&h); + assert!(h.state_mut().execute("comment.paste")); + click_pt(&mut h, at); + assert_eq!(count(&h), before + 1, "{tool:?}: the copy, and nothing else"); + assert!(h.state().views[0].fill_text.is_none(), "{tool:?}: no text box opens"); + assert!(h.state().views[0].comments.gesture.is_none()); + let placed = h.state().session.get(id).unwrap().info.annotations.iter().max_by_key(|a| a.index).map(|a| a.rect).expect("placed"); + assert!(((placed[0] + placed[2]) / 2.0 - at.0).abs() < 1.0, "{tool:?}: centred on the click: {placed:?}"); + assert_eq!(h.state().quick_tool, tool, "the tool stays on for the next click"); + } + // Afterwards the tool takes its clicks again. + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text); + h.run_steps(2); + click_pt(&mut h, (100.0, 200.0)); + assert!(h.state().views[0].fill_text.is_some(), "Fill & Sign's Text tool opens its box again"); +} + +/// Two boxes on page 1 of the active document, and a reply to the first. +fn add_two_boxes_and_a_reply(h: &mut Harness<'static, PdfCraftApp>) { + for rect in [[20.0, 20.0, 60.0, 60.0], [100.0, 20.0, 140.0, 60.0]] { + let shape = pdfcraft_engine::Shape::Rectangle { rect }; + let style = pdfcraft_engine::Style::default_for(&shape); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + shape, + style, + contents: "Box".into(), + author: "Tester".into(), + }))); + } + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::ReplyToAnnotation { page: 0, index: 0, text: "Reply".into(), author: "Tester".into() })); + h.run_steps(2); +} + +/// Copied comments paste into another document on its current page, each with its replies, as +/// new comments (a second paste gives a second set); the copies are selected afterwards. +#[test] +fn copied_comments_paste_into_another_document() { + let mut h = harness(|app| { + app.open_bytes("other.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.active = Some(0); + }); + let (source, target) = (h.state().views[0].id, h.state().views[1].id); + add_two_boxes_and_a_reply(&mut h); + // Adding a comment selects it; with nothing selected there is nothing to copy. + h.state_mut().views[0].comments.clear_selection(); + assert!(!h.state_mut().execute("comment.copy"), "nothing selected: nothing to copy"); + { + let cv = &mut h.state_mut().views[0].comments; + cv.select_only((0, 0)); + cv.also.push((0, 1)); + } + assert!(h.state_mut().execute("comment.copy")); + assert_eq!(h.state().comment_clipboard.as_ref().map(|c| c.count), Some(2)); + // Into the other document: Edit ▸ Paste comments hangs the bundle on the pointer, a click + // places it, centred there (the two boxes span 20..140 × 20..60: centre 80, 40). + h.state_mut().active = Some(1); + h.run_steps(2); + assert!(h.state_mut().execute("comment.paste")); + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(target).unwrap().info.annotations.len(); + assert_eq!(count(&h), 0, "nothing placed yet"); + assert!(matches!(h.state().views[1].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Place { count: 2, .. }))); + click_pt(&mut h, (150.0, 100.0)); + let pasted = h.state().session.get(target).unwrap().info.annotations.clone(); + assert_eq!(pasted.len(), 3, "two comments and the reply: {pasted:?}"); + assert!(pasted.iter().all(|a| a.page == 0), "on the page clicked: {pasted:?}"); + let parent = pasted.iter().find(|a| a.in_reply_to.is_none() && (a.rect[0] - 90.0).abs() < 1.0).expect("the first box, moved by (70, 60)"); + assert!((parent.rect[1] - 80.0).abs() < 1.0 && (parent.rect[2] - 130.0).abs() < 1.0, "{:?}", parent.rect); + assert_eq!(pasted.iter().find(|a| a.in_reply_to.is_some()).and_then(|r| r.in_reply_to.clone()), parent.name, "the reply follows its copy"); + assert_eq!(h.state().views[1].comments.selection().len(), 2, "the copies are selected"); + assert!(h.state().views[1].comments.gesture.is_none()); + assert_eq!(h.state().session.get(target).unwrap().can_undo(), Some("Paste comments")); + assert_eq!(h.state().session.get(source).unwrap().info.annotations.len(), 3, "the source is untouched"); + // The page menu's Paste comments places the bundle where the menu was opened: page 2 here. + h.state_mut().views[1].comments.paste_request = Some((1, [100.0, 100.0])); + h.run_steps(3); + assert_eq!(count(&h), 6); + let second: Vec<_> = + h.state().session.get(target).unwrap().info.annotations.iter().filter(|a| a.page == 1 && a.in_reply_to.is_none()).cloned().collect(); + assert_eq!(second.len(), 2); + let centre_x = (second.iter().map(|a| a.rect[0]).fold(f32::MAX, f32::min) + second.iter().map(|a| a.rect[2]).fold(f32::MIN, f32::max)) / 2.0; + assert!((centre_x - 100.0).abs() < 1.0, "centred at the menu's point: {second:?}"); + // ⌘V (a Paste event) hangs the bundle on the pointer again; Escape lets it go. + h.event(egui::Event::Paste(String::new())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none()); + assert_eq!(count(&h), 6, "nothing placed"); + h.state_mut().undo(); + assert_eq!(count(&h), 3); + // XFDF arriving from the system clipboard pastes too, even without the app's clipboard. + let xfdf = h.state().comment_clipboard.clone().unwrap().xfdf; + h.state_mut().comment_clipboard = None; + assert!(!h.state_mut().execute("comment.paste"), "nothing copied: disabled"); + h.event(egui::Event::Paste(xfdf.clone())); + h.run_steps(3); + click_pt(&mut h, (200.0, 120.0)); + assert_eq!(count(&h), 6); + h.event(egui::Event::Paste("just text".into())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none(), "plain text is not a paste of comments"); + // Nor when comments were copied earlier: a word copied in another app since is that word. + h.state_mut().comment_clipboard = Some(pdfcraft_ui_egui::CommentClip { xfdf, count: 2 }); + h.event(egui::Event::Paste("a word from elsewhere".into())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_none(), "the old comments don't hang on the pointer"); + // Nothing legible on the system clipboard: the app's own copied comments. + h.event(egui::Event::Paste(String::new())); + h.run_steps(3); + assert!(h.state().views[1].comments.gesture.is_some()); +} + +/// Connected lines (and polygons) finish from the right-click menu too, as other editors do; +/// the menu also takes the last point back and cancels the drawing. +#[test] +fn connected_lines_are_completed_from_the_context_menu() { + let mut h = harness(|app| app.set_option("quick", "polyline").unwrap()); + for p in [(20.0, 150.0), (60.0, 130.0), (100.0, 150.0)] { + click_pt(&mut h, p); + } + assert_eq!(comments(&h).len(), 0, "still drawing"); + let right_click = |h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)| { + let p = at(h, p.0, p.1); + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + }; + right_click(&mut h, (120.0, 100.0)); + h.get_by_label("Complete").click(); + h.run_steps(3); + let c = comments(&h); + assert_eq!(c.len(), 1, "{c:?}"); + assert_eq!(c[0].subtype, "PolyLine"); + // A polygon needs three points: with two, Complete is greyed; Undo last point and Cancel drawing work. + h.state_mut().set_option("quick", "polygon").unwrap(); + for p in [(150.0, 150.0), (200.0, 150.0)] { + click_pt(&mut h, p); + } + assert_eq!(drawn_points(&h), 2); + right_click(&mut h, (220.0, 100.0)); + assert!(h.get_by_label("Complete").accesskit_node().is_disabled()); + h.get_by_label("Undo last point").click(); + h.run_steps(3); + assert_eq!(drawn_points(&h), 1); + right_click(&mut h, (220.0, 100.0)); + h.get_by_label("Cancel drawing").click(); + h.run_steps(3); + assert_eq!(drawn_points(&h), 0); + assert!(h.state().views[0].comments.gesture.is_none()); + assert_eq!(comments(&h).len(), 1, "nothing else was drawn"); + // On the second page, with the first on screen in the same frame: Complete reaches the page + // the drawing is on (the first page passes first and must not take the request). + h.state_mut().set_option("quick", "polyline").unwrap(); + h.state_mut().set_option("zoom", "50").unwrap(); + crate::support::settle(&mut h); + assert!(h.state().views[0].page_screen_rect(0).is_some(), "page 1 is on screen too"); + let at2 = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { + let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height()) + }; + for (x, y) in [(20.0, 100.0), (60.0, 80.0)] { + let p = at2(&h, x, y); + click(&mut h, p); + } + assert_eq!(drawn_points(&h), 2); + let p = at2(&h, 150.0, 150.0); + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + h.get_by_label("Complete").click(); + h.run_steps(3); + let c = comments(&h); + assert_eq!(c.len(), 2, "{c:?}"); + assert!(c.iter().any(|a| a.page == 1 && a.subtype == "PolyLine"), "{c:?}"); + assert!(h.state().views[0].comments.gesture.is_none()); +} + +/// Properties on one of several selected comments changes all of them, each where it applies: the +/// colour goes to every one, the line width to the shapes, the font size to the text box alone; +/// a locked one stays as it is. One undo step. Colouring the selection works the same way. +#[test] +fn properties_change_every_selected_comment_where_it_applies() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|_| {}); + let shapes = [ + Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] }, + Shape::Oval { rect: [80.0, 20.0, 120.0, 60.0] }, + Shape::TextBox { rect: [140.0, 20.0, 200.0, 40.0], font_size: 12.0 }, + Shape::Rectangle { rect: [220.0, 20.0, 260.0, 60.0] }, + ]; + for shape in shapes { + let style = Style::default_for(&shape); + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "x".into(), + author: "Tester".into() + }))); + } + assert!(h.state_mut().apply_edit(Edit::LockAnnotation { page: 0, index: 3, locked: true })); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1), (0, 2), (0, 3)]; + } + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.get_by_label("Changes here apply to all 4 selected comments"); + { + let d = h.state_mut().comment_props.as_mut().expect("open"); + assert_eq!(d.others.len(), 3); + d.edited.color = Some([0.0, 0.0, 1.0]); + d.edited.width = Some(4.0); + } + h.get_by_label("OK").click(); + h.run_steps(3); + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap() + }; + for index in [0, 1] { + let p = props(&h, index); + assert_eq!((p.color, p.width), (Some([0.0, 0.0, 1.0]), Some(4.0)), "shape {index}"); + } + assert_eq!(props(&h, 2).color, Some([0.0, 0.0, 1.0]), "the text box's text colour"); + let locked = props(&h, 3); + assert_ne!(locked.color, Some([0.0, 0.0, 1.0]), "the locked rectangle stays as it was"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Change comment properties"), "one undo step"); + // From the text box: its font size changes, and the shapes, which have none, keep their look. + h.state_mut().views[0].comments.selected = Some((0, 2)); + h.state_mut().views[0].comments.also = vec![(0, 0), (0, 1)]; + h.state_mut().open_comment_props(0, 2); + h.run_steps(2); + h.state_mut().comment_props.as_mut().expect("open").edited.font_size = Some(20.0); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(props(&h, 2).font_size, Some(20.0)); + assert_eq!((props(&h, 0).font_size, props(&h, 0).width), (None, Some(4.0))); + // Colouring the selection (the context menu's Colour with several selected). + h.state_mut().colour_selected_comments([1.0, 0.0, 0.0]); + h.run_steps(2); + for index in [0, 1, 2] { + assert_eq!(props(&h, index).color, Some([1.0, 0.0, 0.0]), "comment {index}"); + } + assert_ne!(props(&h, 3).color, Some([1.0, 0.0, 0.0])); +} + +/// Comment Properties' text opacity, set with several comments selected, goes to the other text +/// boxes whose text shows an opacity of its own (a border or a fill), and leaves a text-only box, +/// whose one opacity is the whole comment's, as it was. +#[test] +fn part_opacity_changes_every_selected_comment_that_shows_the_part() { + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let mut h = harness(|_| {}); + for (n, width) in [1.0, 1.0, 0.0].into_iter().enumerate() { + let x = 20.0 + 100.0 * n as f64; + let shape = Shape::TextBox { rect: [x, 20.0, x + 80.0, 40.0], font_size: 12.0 }; + let style = Style { width, ..Style::default_for(&shape) }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + shape, + style, + contents: "x".into(), + author: "Tester".into() + }))); + } + let props = |h: &Harness<'static, PdfCraftApp>, index: usize| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap() + }; + let text_only = props(&h, 2); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1), (0, 2)]; + } + h.run_steps(2); + h.state_mut().open_comment_props(0, 0); + h.run_steps(2); + h.state_mut().comment_props.as_mut().expect("open").edited.text_opacity = Some(0.4); + h.get_by_label("OK").click(); + h.run_steps(3); + for index in [0, 1] { + let text = props(&h, index).text_opacity.expect("a text box's text opacity"); + assert!((text - 0.4).abs() < 1e-3, "bordered box {index}: {text}"); + } + let after = props(&h, 2); + assert_eq!((after.text_opacity, after.opacity), (text_only.text_opacity, text_only.opacity), "the text-only box keeps its look"); +} + +/// Escape with a Fill & Sign tool on and several comments selected deselects them all first, +/// then drops the tool. +#[test] +fn escape_with_a_fill_tool_deselects_every_comment_then_drops_the_tool() { + use pdfcraft_ui_egui::fill_sign::FillTool; + let mut h = harness(|_| {}); + add_two_boxes_and_a_reply(&mut h); + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text); + { + let c = &mut h.state_mut().views[0].comments; + c.selected = Some((0, 0)); + c.also = vec![(0, 1)]; + } + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + let c = &h.state().views[0].comments; + assert!(c.selected.is_none() && c.also.is_empty(), "the whole selection goes"); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text), "the tool stays for now"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select); +} diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 04f67ce216..1b9cb85a39 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -1801,6 +1801,7 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() { text: "Unsaved signature note".into(), size: 10.0, color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, focus: false, }); h.state_mut().active = None; diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 858e082137..b5bb52cfa7 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -2,7 +2,7 @@ use egui::{Pos2, pos2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig}; use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; @@ -159,12 +159,63 @@ fn clicking_elsewhere_with_add_text_keeps_the_typed_text() { assert!(h.state().views[0].fill_text.as_ref().is_some_and(|t| t.text.is_empty()), "a new, empty box is open"); } +/// Escape steps back out of a Fill & Sign tool as out of a comment tool: the first press closes +/// the open type box, the next one leaves the Text tool for Select, so a click opens no new box. +#[test] +fn escape_leaves_the_fill_and_sign_tools() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + assert!(h.state().views[0].fill_text.is_some(), "a box is open"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().views[0].fill_text.is_none(), "the first Escape closes the box"); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text), "the tool stays for the next box"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select, "the next Escape leaves the tool"); + click(&mut h, 50.0, 150.0); + assert!(h.state().views[0].fill_text.is_none(), "no new box"); + assert!(items(&h).is_empty(), "nothing was added: {:?}", items(&h)); + // A mark tool too. + h.state_mut().execute("sign.fill.check"); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check)); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select); +} + +/// A box opened while a comment is selected deselects it: two presses of Escape still close the +/// box and leave the tool. +#[test] +fn escape_leaves_the_text_tool_in_two_presses_after_a_selection() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Ada".into())); + h.run_steps(1); + commit_typed_text(&mut h); + assert_eq!(items(&h).len(), 1, "{:?}", items(&h)); + h.state_mut().views[0].fill_text = None; + h.state_mut().views[0].comments.selected = Some((0, 0)); + click(&mut h, 50.0, 150.0); + assert!(h.state().views[0].fill_text.is_some(), "a box is open"); + assert_eq!(h.state().views[0].comments.selected, None, "the new box deselected the comment"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().views[0].fill_text.is_none(), "the first Escape closes the box"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select, "the second Escape leaves the tool"); +} + #[test] fn fill_text_size_controls_new_text_and_survives_settings_restore() { let mut h = harness(); h.state_mut().execute("sign.fill.text"); h.run_steps(2); - h.get_by_label("Font size:"); + h.get_by_label("Font size"); h.state_mut().fill_text_size = 24.0; click(&mut h, 50.0, 250.0); assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0)); @@ -201,6 +252,8 @@ fn fill_text_size_resizes_the_selected_text() { }; let before = height(&h); let set_size = |h: &mut Harness<'static, PdfCraftApp>, value: &str| { + h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click(); + h.run_steps(2); h.query_all_by_role(egui::accesskit::Role::SpinButton) .find(|n| n.value().is_some_and(|v| v.ends_with(" pt"))) .expect("the Font size control") @@ -211,6 +264,8 @@ fn fill_text_size_resizes_the_selected_text() { h.run_steps(1); h.key_press(egui::Key::Enter); h.run_steps(4); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click(); + h.run_steps(2); }; // Nothing selected: only the size for new text changes. h.state_mut().views[0].comments.selected = None; @@ -237,6 +292,50 @@ fn fill_text_size_resizes_the_selected_text() { assert!(after > before * 2.0, "36 pt text has a taller box than 10 pt text: {before} -> {after}"); } +/// Dragging the Font size control with a placed text selected resizes it once, when the drag +/// ends, to the size it was dragged to: the frame of the release must not put the old size back. +#[test] +fn dragging_the_font_size_resizes_the_selected_text_to_where_the_drag_ends() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Drag me".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (id, annotation) = { + let s = h.state(); + let id = s.views[0].id; + let a = s.session.get(id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Drag me")).map(|a| (a.page, a.index)); + (id, a.unwrap()) + }; + h.state_mut().views[0].comments.selected = Some(annotation); + h.run_steps(2); + // The size is set in the Font size button's popup. + h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click(); + h.run_steps(2); + let from = h + .query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.value().is_some_and(|v| v.ends_with(" pt"))) + .expect("the Font size control") + .rect() + .center(); + let before = h.state().fill_text_size; + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for dx in [10.0, 20.0, 30.0, 40.0] { + h.hover_at(from + egui::vec2(dx, 0.0)); + h.run_steps(1); + } + h.drop_at(from + egui::vec2(40.0, 0.0)); + h.run_steps(4); + let dragged = h.state().fill_text_size; + assert!(dragged > before, "the drag made the size larger: {before} -> {dragged}"); + let placed = h.state().session.get(id).unwrap().comment_props(annotation.0, annotation.1).and_then(|p| p.font_size); + assert_eq!(placed, Some(dragged), "the selected text has the size the drag ended at"); +} + /// The font sizes of the texts `text` drawn in the last frame. fn drawn_sizes(h: &Harness<'static, PdfCraftApp>, text: &str) -> Vec { fn walk(shape: &egui::Shape, text: &str, out: &mut Vec) { @@ -282,6 +381,274 @@ fn the_type_box_text_matches_the_page_zoom_in_a_turned_view() { } } +/// With a placed text selected, the toolbar shows its own border, also after Comment Properties +/// changed it, and changes only that text: the Border colour button gives its border its own +/// colour ("Same as text" takes it back), and new text keeps the toolbar's own border. +#[test] +fn the_toolbar_shows_and_changes_the_selected_texts_border() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Framed".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Framed")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + let shows = |h: &Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| n.value().as_deref() == Some(value)) + }; + h.state_mut().views[0].comments.selected = Some((page, index)); + // As Comment Properties ▸ Thickness does. + h.state_mut().apply_edit(pdfcraft_engine::Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(2.0), endings: None }); + h.run_steps(3); + assert!(shows(&h, "2 pt"), "the toolbar shows the selected text's border"); + assert_eq!(h.state().fill_text_border, 0.0, "new text keeps none"); + + h.get_by_label("Border colour").click(); + h.run_steps(2); + h.get_by_label("Red").click(); + h.run_steps(4); + let red = Some([0.89, 0.13, 0.13]); + assert_eq!(props(&h).border_color, red, "the selected text's border is red"); + assert_eq!(h.state().fill_text_border_color, None, "new text's border keeps the text's colour"); + h.get_by_label("Border colour").click(); + h.run_steps(2); + h.get_by_label("Same as text").click(); + h.run_steps(4); + assert_eq!(props(&h).border_color, None); + // Nothing selected, the toolbar shows new text's border again. + h.state_mut().views[0].comments.selected = None; + h.run_steps(2); + assert!(shows(&h, "None")); +} + +/// With a border, typed text can have an oval or a circle around it instead of a box (a +/// position number in a circle on a plan): the toolbar's shape control sets it for new text and +/// for the selected text, and shows the selected text's own. The text stays where it was typed. +#[test] +fn typed_text_takes_an_oval_or_a_circle_from_the_toolbar() { + use pdfcraft_engine::BorderShape; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Border shape").count(), 0, "no shape without a border"); + h.state_mut().fill_text_border = 1.0; + h.run_steps(2); + // The shape control shows "Rectangle"; pick Circle for new text. It is the last of the text's + // controls, so in a vertical bar it may sit below the bar's end: scrolled to first, as by hand. + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("Rectangle")) + .expect("the shape control") + .scroll_to_me(); + h.run_steps(2); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Rectangle")).expect("the shape control").click(); + h.run_steps(2); + h.get_by_label("Circle").click(); + h.run_steps(2); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("12".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("12")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!(props(&h).border_shape, Some(BorderShape::Circle), "new text gets the circle"); + let r = h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().find(|a| a.index == index).unwrap().rect; + assert!(((r[2] - r[0]) - (r[3] - r[1])).abs() < 0.01, "a circle's square box: {r:?}"); + // Snug around "12" at 10 pt: its box no wider than the text needs (not the 40 pt a box to type + // into keeps), so the circle is as wide as that box, where it was 50 pt around the wider one. + let w = pdfcraft_engine::annot_text::text_width("12", 10.0) + 10.0; + let across = f64::from(r[2] - r[0]); + assert!((across - w.max(20.0)).abs() < 0.01, "the circle is {across} pt across, not {w}: {r:?}"); + // Selected, an oval from the toolbar: one undo step. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.run_steps(3); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("Circle")) + .expect("shows the text's own") + .scroll_to_me(); + h.run_steps(2); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Circle")).expect("shows the text's own").click(); + h.run_steps(2); + h.get_by_label("Oval").click(); + h.run_steps(4); + assert_eq!(props(&h).border_shape, Some(BorderShape::Oval)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties")); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle, "new text keeps the circle"); + // As Comment Properties does: the toolbar shows it. + h.state_mut().apply_edit(pdfcraft_engine::Edit::BorderShapeAnnotation { page, index, shape: BorderShape::Rectangle }); + h.run_steps(3); + assert!(h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| n.value().as_deref() == Some("Rectangle")), "the selected text's own"); + + // The circle for new text lasts until PdfCraft quits; a saved default is read back. + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!(restored.fill_text_border_shape, BorderShape::Rectangle); + restored.restore(r#"{"fill_text_border_shape":"oval"}"#); + assert_eq!(restored.fill_text_border_shape, BorderShape::Oval); + restored.restore(r#"{"fill_text_border_shape":"star"}"#); + assert_eq!(restored.fill_text_border_shape, BorderShape::Oval, "an unknown shape keeps the setting"); +} + +/// Border ▸ None takes an oval or a circle away with its line, from the selected text and for new +/// text: without one, the text's box stayed grown around a shape that isn't drawn. +#[test] +fn no_border_takes_the_oval_or_circle_away_too() { + use pdfcraft_engine::BorderShape; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.state_mut().fill_text_border = 1.0; + h.state_mut().fill_text_border_shape = BorderShape::Circle; + h.run_steps(2); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("12".into())); + h.run_steps(1); + commit_typed_text(&mut h); + let (page, index) = { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("12")).cloned().unwrap(); + (a.page, a.index) + }; + let props = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!(props(&h).border_shape, Some(BorderShape::Circle)); + let no_border = |h: &mut Harness<'static, PdfCraftApp>| { + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("1 pt")).expect("the Border control").click(); + h.run_steps(2); + h.get_by_label("None").click(); + h.run_steps(4); + }; + // The selected text: no line, and a rectangle again, its box the text's. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.run_steps(3); + no_border(&mut h); + assert_eq!(props(&h).width, Some(0.0)); + assert_eq!(props(&h).border_shape.unwrap_or_default(), BorderShape::Rectangle); + // One undo step brings back the line and the circle together. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!((props(&h).width, props(&h).border_shape), (Some(1.0), Some(BorderShape::Circle)), "one undo step"); + h.state_mut().redo(); + h.run_steps(2); + assert_eq!(h.state().fill_text_border_shape, BorderShape::Circle, "new text keeps its circle"); + // New text: none, and a rectangle when a border is chosen again. + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + no_border(&mut h); + assert_eq!((h.state().fill_text_border, h.state().fill_text_border_shape), (0.0, BorderShape::Rectangle)); +} + +/// The Border control puts a frame around new Fill & Sign text, and around the text that is +/// selected; the box grows outwards, so the text stays where it was typed. The choice persists. +#[test] +fn fill_text_border_frames_new_and_selected_text() { + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + h.run_steps(2); + h.get_by_label("Border:"); + let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32| { + click(h, 50.0, y); + h.event(egui::Event::Text("Same".into())); + h.run_steps(1); + commit_typed_text(h); + }; + // The two texts, oldest first: (page, index, rect). + let texts = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let mut v: Vec<_> = + doc.info.annotations.iter().filter(|a| a.contents.as_deref() == Some("Same")).map(|a| (a.page, a.index, a.rect)).collect(); + v.sort_by_key(|t| t.1); + v + }; + let width = |r: [f32; 4]| r[2] - r[0]; + type_at(&mut h, 150.0); + // Nothing selected, so the toolbar sets new text's border only (it shows a selected text's). + h.state_mut().views[0].comments.selected = None; + h.state_mut().fill_text_border = 1.5; + type_at(&mut h, 250.0); + let [(page, index, plain), (_, _, framed)] = texts(&h)[..] else { panic!("two texts") }; + assert!((width(framed) - width(plain) - 3.0).abs() < 0.01, "1.5 pt on each side: {plain:?} {framed:?}"); + + // The plain text selected, then framed with 2 pt from the toolbar. + h.state_mut().views[0].comments.selected = Some((page, index)); + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.value().as_deref() == Some("1.5 pt")) + .expect("the Border control, showing the border for new text") + .click(); + h.run_steps(2); + h.get_by_label("2 pt").click(); + h.run_steps(4); + assert_eq!(h.state().fill_text_border, 1.5, "new text keeps its own"); + let (_, _, now) = texts(&h)[0]; + assert!((width(now) - width(plain) - 4.0).abs() < 0.01, "2 pt on each side: {plain:?} {now:?}"); + assert!((plain[0] - now[0] - 2.0).abs() < 0.01 && (now[3] - plain[3] - 2.0).abs() < 0.01, "grown outwards, the text in place: {plain:?} {now:?}"); + + // The toolbar's border lasts until PdfCraft quits; a saved default is read back. + let settings = h.state().persist(); + let mut restored = PdfCraftApp::new(); + restored.restore(&settings); + assert_eq!(restored.fill_text_border, 0.0); + restored.restore(r#"{"fill_text_border":2}"#); + assert_eq!(restored.fill_text_border, 2.0); + restored.restore(r#"{"fill_text_border":100}"#); + assert_eq!(restored.fill_text_border, 2.0, "invalid settings do not change the border"); +} + +/// Make Properties Default on typed text sets what Fill & Sign gives new text, its size and +/// border, and keeps it across launches. +#[test] +fn typed_text_made_default_is_what_new_text_gets() { + use pdfcraft_engine::{BorderShape, Edit}; + let mut h = harness(); + h.state_mut().execute("sign.fill.text"); + let type_at = |h: &mut Harness<'static, PdfCraftApp>, y: f32, text: &str| { + click(h, 50.0, y); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + commit_typed_text(h); + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).cloned().unwrap(); + (a.page, a.index) + }; + let (page, index) = type_at(&mut h, 150.0, "Model"); + let red = Some([0.89, 0.13, 0.13]); + for edit in [ + Edit::StyleAnnotation { page, index, color: None, opacity: None, width: Some(1.5), endings: None }, + Edit::BorderColorAnnotation { page, index, color: red }, + Edit::BorderShapeAnnotation { page, index, shape: BorderShape::Circle }, + Edit::FontSizeAnnotation { page, index, size: 14.0 }, + ] { + assert!(h.state_mut().apply_edit(edit)); + } + h.state_mut().open_comment_props(page, index); + h.run_steps(2); + h.get_by_label("Make Properties Default").click(); + h.run_steps(1); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!((s.fill_text_size, s.fill_text_border, s.fill_text_border_color, s.fill_text_border_shape), (14.0, 1.5, red, BorderShape::Circle)); + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + assert_eq!( + (restored.fill_text_size, restored.fill_text_border, restored.fill_text_border_color, restored.fill_text_border_shape), + (14.0, 1.5, red, BorderShape::Circle), + "kept across launches" + ); + h.state_mut().views[0].comments.selected = None; + let (page, index) = type_at(&mut h, 350.0, "Next"); + let p = h.state().session.get(h.state().views[0].id).unwrap().comment_props(page, index).unwrap(); + assert_eq!((p.font_size, p.width, p.border_color, p.border_shape), (Some(14.0), Some(1.5), red, Some(BorderShape::Circle))); +} + #[test] fn fill_text_colour_survives_preview_commit_settings_and_save() { let mut h = harness(); @@ -423,9 +790,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations); // The quick toolbar exposes replacement without changing the normal placement action. - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); h.get_by_label("Change signature").click(); h.run_steps(2); @@ -445,9 +812,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() { click(&mut h, 40.0, 200.0); assert_eq!(items(&h).len(), 2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Place saved signature").click(); h.run_steps(2); - h.get_by_label("Fill & Sign").click(); + h.get_by_label("Fill & Sign: Sign").click(); h.run_steps(2); h.get_by_label("Change initials").click(); h.run_steps(2); @@ -1156,7 +1523,15 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { app.open_bytes("form.pdf", None, FIXTURE.to_vec()).unwrap(); let id = app.views[0].id; app.session.apply(id, mark).unwrap(); - app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false }); + app.views[0].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 200.0], + text: "Typed".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); let kept = dir.join("kept.pdf"); app.save_override = Some(kept.to_string_lossy().into_owned()); assert!(app.save_active(SaveTarget::InPlace)); @@ -1167,7 +1542,15 @@ fn saving_flattens_fill_and_sign_only_when_the_preference_is_on() { assert!(texts.iter().any(|t| t == "Typed"), "an open type box is saved: {texts:?}"); assert_eq!(reopened.session.get(reopened.views[0].id).unwrap().info.annotations.len(), 2, "the check mark stays too"); - app.views[0].fill_text = Some(TypeBox { page: 0, at: [40.0, 160.0], text: "More".into(), size: 10.0, color: [0.0; 3], focus: false }); + app.views[0].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 160.0], + text: "More".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); app.flatten_fill_sign_on_save = true; let flat = dir.join("flat.pdf"); app.save_override = Some(flat.to_string_lossy().into_owned()); @@ -1813,6 +2196,269 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); } +fn place_selected_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + h.state_mut().execute("sign.fill.text"); + click(h, 50.0, 250.0); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + commit_typed_text(h); + let selected = { + let s = h.state(); + s.session + .get(s.views[0].id) + .unwrap() + .info + .annotations + .iter() + .find(|a| a.contents.as_deref() == Some(text)) + .map(|a| (a.page, a.index)) + .unwrap() + }; + h.state_mut().views[0].comments.selected = Some(selected); + h.run_steps(2); +} + +fn placed_height(h: &Harness<'static, PdfCraftApp>, text: &str) -> f32 { + let s = h.state(); + let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).unwrap(); + a.rect[3] - a.rect[1] +} + +fn placed_color(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option<[f32; 3]> { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).unwrap().color +} + +#[test] +fn the_fill_text_size_flyout_opens_from_an_icon_and_leaves_a_stored_size_alone() { + let mut h = harness(); + h.state_mut().fill_text_size = 9.1; + place_selected_text(&mut h, "Keep me"); + let height = placed_height(&h, "Keep me"); + assert_eq!(h.query_all_by_role(egui::accesskit::Role::SpinButton).filter(|n| n.value().is_some_and(|v| v.ends_with(" pt"))).count(), 0); + h.get_by_label("Font size").click(); + h.run_steps(4); + assert!( + h.query_all_by_role(egui::accesskit::Role::SpinButton).any(|n| n.value().is_some_and(|v| v.ends_with(" pt"))), + "the flyout holds the size" + ); + assert_eq!(h.state().fill_text_size, 9.1, "opening the flyout does not round the size"); + assert_eq!(placed_height(&h, "Keep me"), height, "opening the flyout does not resize the selected text"); +} + +#[test] +fn dragging_the_fill_text_size_slider_is_one_undo_step() { + let mut h = harness(); + place_selected_text(&mut h, "Drag me"); + let height = placed_height(&h, "Drag me"); + h.get_by_label("Font size").click(); + h.run_steps(2); + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let y = (slider.min.y + slider.max.y) / 2.0; + let from = pos2(slider.min.x + 10.0, y); + let to = pos2(slider.max.x - 10.0, y); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + assert_eq!(placed_height(&h, "Drag me"), height, "nothing is applied before the release"); + h.drop_at(to); + h.run_steps(3); + assert!(placed_height(&h, "Drag me") > height, "the release resizes the selected text"); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(placed_height(&h, "Drag me"), height, "one undo takes the whole drag back"); +} + +#[test] +fn pressing_the_fill_text_size_slider_without_moving_it_adds_no_undo_step() { + let mut h = harness(); + place_selected_text(&mut h, "Hold me"); + let height = placed_height(&h, "Hold me"); + h.get_by_label("Font size").click(); + h.run_steps(2); + let slider = h.get_by_role(egui::accesskit::Role::Slider).rect(); + let at = pos2(slider.min.x + (slider.max.x - slider.min.x) * 0.75, (slider.min.y + slider.max.y) / 2.0); + let press = |h: &mut Harness<'static, PdfCraftApp>| { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + }; + press(&mut h); + let resized = placed_height(&h, "Hold me"); + assert!(resized > height, "the first press moves the size: {height} -> {resized}"); + press(&mut h); + assert_eq!(placed_height(&h, "Hold me"), resized); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(placed_height(&h, "Hold me"), height, "one undo takes back the only change; the second press added no step"); +} + +#[test] +fn typing_a_fill_text_size_that_rounds_to_the_stored_one_adds_no_undo_step() { + let mut h = harness(); + place_selected_text(&mut h, "Round me"); + let height = placed_height(&h, "Round me"); + h.get_by_label("Font size").click(); + h.run_steps(2); + let type_size = |h: &mut Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| n.value().is_some_and(|v| v.ends_with(" pt"))) + .expect("the Font size control") + .click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(value.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(4); + }; + type_size(&mut h, "36.1234"); + let resized = placed_height(&h, "Round me"); + assert!(resized > height, "the first size applies: {height} -> {resized}"); + type_size(&mut h, "36.1231"); + assert_eq!(h.state().fill_text_size, 36.1231); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(placed_height(&h, "Round me"), height, "one undo takes back the only change; the rounded retype added no step"); +} + +#[test] +fn the_fill_colour_dot_recolours_the_selected_text() { + let mut h = harness(); + place_selected_text(&mut h, "Paint me"); + let before = placed_color(&h, "Paint me"); + h.get_by_label("Colour").click(); + h.run_steps(2); + h.get_by_label("Blue").click(); + h.run_steps(3); + let color = h.state().fill_text_color.map(|v| v as f32); + assert!(color[2] > color[0], "blue was selected: {color:?}"); + assert_ne!(before, Some(color)); + assert_eq!(placed_color(&h, "Paint me"), Some(color)); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(placed_color(&h, "Paint me"), before, "one undo takes the colour back"); +} + +#[test] +fn the_quick_bar_fill_button_shows_the_tool_it_picks() { + let mut h = harness(); + h.get_by_label("Fill & Sign: Add signature").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); + assert_eq!(h.state().dialog, Some(Dialog::Signature)); + + h.state_mut().dialog = None; + h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into())); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Place saved signature").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature)); + assert_eq!(h.state().dialog, None); + h.get_by_label("Fill & Sign: Sign").click(); + h.run_steps(2); + assert_eq!(h.get_by_label("Use signature").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the active tool's card is marked"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + + h.state_mut().execute("sign.fill.check"); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Checkmark").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool"); +} + +#[test] +fn a_restored_fill_tool_shows_without_replacing_the_last_selection() { + let mut h = harness(); + h.state_mut().execute("sign.fill.date"); + h.run_steps(2); + h.state_mut().apply_full_screen(true); + h.run_steps(2); + h.state_mut().execute("sign.fill.check"); + h.run_steps(2); + h.state_mut().apply_full_screen(false); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date)); + h.get_by_label("Fill & Sign: Date"); + + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(2); + h.get_by_label("Fill & Sign: Checkmark").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check)); +} + +#[test] +fn a_fill_tool_selected_while_the_quick_bar_is_hidden_is_remembered() { + let mut h = harness(); + let mode = h.state().mode; + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(2); + h.state_mut().set_option("quick", "fill-date").unwrap(); + h.run_steps(2); + h.state_mut().set_option("quick", "select").unwrap(); + h.state_mut().mode = mode; + h.run_steps(2); + h.get_by_label("Fill & Sign: Date"); + + h.state_mut().initials = Some(SavedSig::Typed("AL".into())); + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(2); + h.state_mut().execute("sign.fill.initials.change"); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials)); + h.state_mut().quick_tool = QuickTool::Select; + h.state_mut().mode = mode; + h.run_steps(2); + h.get_by_label("Fill & Sign: Place saved initials"); +} + +#[test] +fn the_quick_bar_fill_button_keeps_date_after_deselecting_and_across_documents() { + let mut h = harness(); + h.get_by_label("Fill & Sign: Add signature").click_secondary(); + h.run_steps(2); + h.get_by_label("Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date)); + h.get_by_label("Select (V)").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select); + + h.state_mut().open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap(); + h.state_mut().active = Some(1); + h.run_steps(2); + h.get_by_label("Fill & Sign: Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "a click picks the shown tool, not the default"); + assert_eq!(h.state().dialog, None, "the default tool's dialog stays closed"); + h.get_by_label("Select (V)").click(); + h.run_steps(2); + + h.state_mut().close_tab(1); + h.state_mut().close_tab(0); + h.run_steps(2); + h.state_mut().open_bytes("third.pdf", None, FIXTURE.to_vec()).unwrap(); + h.state_mut().active = Some(0); + h.run_steps(2); + h.get_by_label("Fill & Sign: Date").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "the tool outlives closing every document"); +} + fn commit_typed_text(h: &mut Harness<'static, PdfCraftApp>) { let p = pos2(350.0, 850.0); // Blank canvas outside the page and the type box. h.hover_at(p); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 20243ea86d..e324265ebb 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -71,6 +71,7 @@ mod recent_files; mod recovery; mod redact; mod reload; +mod scan_clean; mod script_noop; mod scroll_prefetch; mod side_panels; diff --git a/crates/ui-egui/tests/it/scan_clean.rs b/crates/ui-egui/tests/it/scan_clean.rs new file mode 100644 index 0000000000..afb4a4f75c --- /dev/null +++ b/crates/ui-egui/tests/it/scan_clean.rs @@ -0,0 +1,77 @@ +//! Scan & OCR ▸ Enhance scanned file in the real shell (egui_kittest): the dialog, the run and +//! its report, and that the page keeps its comment while its scan is cleaned in place. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_engine::{Edit, NewAnnotation, NoteIcon, Session, Shape, Style}; +use pdfcraft_ui_egui::PdfCraftApp; + +/// A two-page PDF: page 1 a grey scan with a darker gutter and text bars (and a sticky note), +/// page 2 the same scan again. +fn scan() -> Vec { + 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) { 0.12 } else { 0.78 }; + image::Luma([(ink * light * 255.0) as u8]) + }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::new(); + let bytes = s.create_from_images(&[("a.png".into(), png.clone()), ("b.png".into(), png)]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let shape = Shape::Note { at: [20.0, 20.0], icon: NoteIcon::Comment }; + let note = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "Keep me".into(), author: "Tester".into() }; + s.apply(id, Edit::AddAnnotation(note)).unwrap(); + s.save_bytes(id).unwrap().to_vec() +} + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("scan.pdf", None, scan()).unwrap(); + app.run_inline = true; + app + }); + h.run_steps(4); + h +} + +#[test] +fn enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did() { + let mut h = harness(); + let id = h.state().active_ids().unwrap().1; + assert!(h.state().session.get(id).unwrap().comment_props(0, 0).is_some()); + assert!(h.state_mut().execute("ocr.enhance")); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Background removal").count(), 2, "the label and the slider it names"); + h.get_by_label("Edge shadow removal"); + h.get_by_label("Despeckle"); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Current page").click(); + h.run_steps(1); + h.get_by_label("Enhance").click(); + h.run_steps(3); + let app = h.state(); + assert!(app.scan_clean_run.is_none() && app.dialog.is_none(), "finished and closed"); + let toast = app.toast.clone().unwrap().0; + assert_eq!(toast, "Cleaned up the scan on 1 page."); + let doc = app.session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Enhance scanned file")); + assert_eq!(doc.info.pages.len(), 2); + assert_eq!(doc.comment_props(0, 0).map(|p| p.author), Some("Tester".to_string()), "the sticky note stays"); +} + +#[test] +fn nothing_to_enhance_is_said_not_done() { + let mut h = harness(); + let id = h.state().active_ids().unwrap().1; + h.state_mut().scan_clean_draft.settings = pdfcraft_engine::scan_clean::ScanCleanSettings { level: 0, edge_shadows: false, despeckle: false }; + assert!(h.state_mut().execute("ocr.enhance")); + h.run_steps(2); + // With every filter off the button is disabled; starting anyway changes nothing. + h.state_mut().start_scan_clean(); + h.run_steps(2); + let app = h.state(); + assert!(app.toast.clone().unwrap().0.starts_with("No scanned pages were cleaned up."), "{:?}", app.toast); + assert_eq!(app.session.get(id).unwrap().can_undo(), None); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index c439a51943..a7f5b4cf7f 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -889,6 +889,24 @@ fn a_saved_no_fill_clears_a_comment_tool_default_fill() { assert_eq!(c.comment_prefs.style(CommentTool::Rectangle).fill, None); } +/// A tool's saved "Same as text" border colour (`"border_color": null`) clears an own border +/// colour it had; a missing or malformed one keeps it. +#[test] +fn a_saved_same_as_text_clears_a_comment_tool_border_colour() { + use pdfcraft_ui_egui::comments::CommentTool; + let red = Some([0.89, 0.13, 0.13]); + let mut a = PdfCraftApp::new(); + a.comment_prefs.set_border_color(CommentTool::TextBox, red); + a.restore(r#"{"comment_styles":[{"tool":"comment.freetext","border_color":"red"}]}"#); + assert_eq!(a.comment_prefs.style(CommentTool::TextBox).border_color, red, "malformed keeps it"); + a.restore(r#"{"comment_styles":[{"tool":"comment.freetext","border_color":null}]}"#); + assert_eq!(a.comment_prefs.style(CommentTool::TextBox).border_color, None, "null clears it"); + let mut b = PdfCraftApp::new(); + b.comment_prefs.set_border_color(CommentTool::TextBox, red); + b.restore(&a.persist()); + assert_eq!(b.comment_prefs.style(CommentTool::TextBox).border_color, None, "a round trip keeps it cleared"); +} + /// Persisted per-tool comment styles go through their clamping setters: hostile or malformed /// values are clamped or ignored, never trusted (#340). #[test] @@ -911,6 +929,28 @@ fn comment_style_settings_clamp_and_refuse_hostile_values() { assert_eq!(note.color, [1.0, 0.82, 0.0], "the default colour stays"); } +/// A tool's fill's and text's own opacities survive a restart, clamped; a tool without a fill or +/// text keeps none, and the quick bar's Opacity (the whole comment's) clears them. +#[test] +fn part_opacity_defaults_persist_and_clamp() { + use pdfcraft_ui_egui::comments::CommentTool; + let mut a = PdfCraftApp::new(); + a.comment_prefs.set_part_opacity(CommentTool::Rectangle, Some(0.3), None); + a.comment_prefs.set_part_opacity(CommentTool::TextBox, Some(0.2), Some(1.0)); + a.comment_prefs.set_part_opacity(CommentTool::Line, Some(0.5), Some(0.5)); + let mut b = PdfCraftApp::new(); + b.restore(&a.persist()); + let parts = |app: &PdfCraftApp, tool| { + let s = app.comment_prefs.style(tool); + (s.fill_opacity, s.text_opacity) + }; + assert_eq!(parts(&b, CommentTool::Rectangle), (Some(0.3), None)); + assert_eq!(parts(&b, CommentTool::TextBox), (Some(0.2), Some(1.0))); + assert_eq!(parts(&b, CommentTool::Line), (None, None), "a line has no fill or text of its own"); + b.restore(r#"{"comment_styles":[{"tool":"comment.freetext","fill_opacity":7,"text_opacity":-1}]}"#); + assert_eq!(parts(&b, CommentTool::TextBox), (Some(1.0), Some(0.1)), "clamped; the text stays visible"); +} + #[test] fn zoom_keeps_the_point_under_the_cursor_still() { let mut h = harness(|app| { diff --git a/crates/ui-egui/tests/it/updates.rs b/crates/ui-egui/tests/it/updates.rs index 3ea55e434e..e6cc09d7c5 100644 --- a/crates/ui-egui/tests/it/updates.rs +++ b/crates/ui-egui/tests/it/updates.rs @@ -100,6 +100,93 @@ fn nothing_is_asked_until_the_user_checks() { h.get_by_label_contains("PdfCraft 99.0.0 is available"); } +/// The desktop app's start, with `settings` restored, and the count of what the source was asked. +fn started_with(settings: &'static str) -> (Harness<'static, PdfCraftApp>, Arc) { + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = calls.clone(); + let h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_startup_update_check(true); + app.restore(settings); + let counted = counted.clone(); + app.set_update_source(Arc::new(move || { + counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Some(release("v99.0.0"))) + })); + app + }); + (h, calls) +} + +/// Run frames for `time`, or until the source has been asked. The check at start shows nothing +/// while it runs, so `settle` can't wait for it. +fn run_until_asked(h: &mut Harness<'static, PdfCraftApp>, calls: &std::sync::atomic::AtomicUsize, time: std::time::Duration) { + let deadline = std::time::Instant::now() + time; + while calls.load(std::sync::atomic::Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +#[test] +fn the_preference_keeps_the_check_at_start_from_reaching_the_network() { + use std::sync::atomic::Ordering::SeqCst; + assert!(PdfCraftApp::new().update_check_at_start, "on by default"); + + // The same start with the preference left on does ask, so a count of none below means + // the preference held the check back and not that it hadn't run yet. + let (mut h, calls) = started_with("{}"); + run_until_asked(&mut h, &calls, std::time::Duration::from_secs(30)); + assert_eq!(calls.load(SeqCst), 1, "on, the app asks at start"); + + let (mut h, calls) = started_with(r#"{"update_check_at_start": false}"#); + run_until_asked(&mut h, &calls, std::time::Duration::from_millis(500)); + assert_eq!(calls.load(SeqCst), 0, "nothing is asked at start"); + + let mut restarted = PdfCraftApp::new(); + restarted.restore(&h.state().persist()); + assert!(!restarted.update_check_at_start, "the choice is kept"); + + // Help ▸ Check for updates still asks. + h.state_mut().execute("help.check_updates"); + settle(&mut h); + assert_eq!(calls.load(SeqCst), 1); + h.get_by_label_contains("PdfCraft 99.0.0 is available"); +} + +/// The setting is about starting: turning it on asks nothing until the next start. +#[test] +fn turning_the_check_at_start_on_waits_for_the_next_start() { + use std::sync::atomic::Ordering::SeqCst; + let (mut h, calls) = started_with(r#"{"update_check_at_start": false}"#); + h.run_steps(3); + h.state_mut().set_option("update_check_at_start", "true").unwrap(); + run_until_asked(&mut h, &calls, std::time::Duration::from_millis(500)); + assert_eq!(calls.load(SeqCst), 0, "nothing is asked this session"); +} + +#[test] +fn preferences_turn_the_check_at_start_off() { + let (mut h, _calls) = started_with(r#"{"update_check_at_start": true}"#); + h.state_mut().execute("app.preferences"); + h.run_steps(3); + h.get_by_label("Check for updates when PdfCraft starts").click(); + h.run_steps(3); + assert!(!h.state().update_check_at_start); + // The control channel's option, as an agent sets it. + h.state_mut().set_option("update_check_at_start", "true").unwrap(); + assert!(h.state().update_check_at_start); + assert!(h.state_mut().set_option("update_check_at_start", "maybe").is_err()); +} + +#[test] +fn settings_from_the_old_opt_in_leave_the_check_at_start_on() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"check_updates_at_start": false}"#); + assert!(app.update_check_at_start, "a key of the removed option decides nothing"); +} + #[test] fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() { let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 6f7d3d3cc8..9a06a0bc12 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1046,7 +1046,15 @@ fn the_window_title_marks_a_draft_that_is_not_applied_yet() { h.run_steps(2); assert_eq!(h.state().window_title, "b.pdf — PdfCraft"); let i = h.state().active.unwrap(); - h.state_mut().views[i].fill_text = Some(TypeBox { page: 0, at: [40.0, 200.0], text: "Typed".into(), size: 10.0, color: [0.0; 3], focus: false }); + h.state_mut().views[i].fill_text = Some(TypeBox { + page: 0, + at: [40.0, 200.0], + text: "Typed".into(), + size: 10.0, + color: [0.0; 3], + border: pdfcraft_ui_egui::fill_sign::TextBorder::NONE, + focus: false, + }); h.run_steps(2); let s = h.state(); assert!(!s.session.get(s.views[i].id).unwrap().dirty, "nothing applied to the document yet"); @@ -1484,6 +1492,138 @@ fn up_and_down_turn_pages_in_single_page_view() { assert_eq!(press(&mut h, Key::ArrowDown, 1), 0, "scrolled up from the bottom of page 1"); } +#[test] +fn left_and_right_scroll_a_page_wider_than_the_window_before_turning_it() { + // A plan zoomed in: → / ← turned the page (#185) and so never showed its other side; the + // view moved sideways only by the scroll bar, the wheel or the Hand. + use egui::{Key, Modifiers}; + let mut h = harness(); + h.state_mut().active = Some(0); + h.state_mut().views[0].set_zoom(12.0); + h.run_steps(6); + let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers, key| { + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + let v = &h.state().views[0]; + (v.current, v.page_screen_rect(v.current).map(|r| r.left())) + }; + let start = h.state().views[0].page_screen_rect(0).map(|r| r.left()).expect("on screen"); + let (page, left) = press(&mut h, Modifiers::NONE, Key::ArrowRight); + assert_eq!(page, 0, "→ scrolls the wide page rather than turning it"); + assert!(left.is_some_and(|l| l < start - 10.0), "the page moved left: {left:?} from {start}"); + // At its right edge the next → turns the page. + let mut n = 1; + while press(&mut h, Modifiers::NONE, Key::ArrowRight).0 == 0 { + n += 1; + assert!(n < 500, "→ never reached the right edge"); + } + assert!(n > 3, "→ scrolled across page 1 before turning ({n} presses)"); + assert_eq!(h.state().views[0].current, 1); + // Page 2 starts at its left edge, as reading on across: → scrolls it rather than turning + // again at once. + let shown = |h: &Harness<'static, PdfCraftApp>, page: usize| { + let v = &h.state().views[0]; + (v.page_screen_rect(page).expect("on screen"), v.viewport_rect()) + }; + let (r, view) = shown(&h, 1); + assert!(r.left() >= view.left() && r.right() > view.right(), "page 2 from its left edge: {r:?} in {view:?}"); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 1, "→ scrolls page 2 from its left edge"); + // ← goes back to that edge and turns back: page 1 comes at its right edge, so the next ← scrolls it. + let mut n = 1; + while press(&mut h, Modifiers::NONE, Key::ArrowLeft).0 == 1 { + n += 1; + assert!(n < 50, "← never turned back from page 2"); + } + assert_eq!(h.state().views[0].current, 0); + let (r, view) = shown(&h, 0); + assert!(r.right() <= view.right() && r.left() < view.left(), "page 1 from its right edge: {r:?} in {view:?}"); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowLeft).0, 0, "← scrolls page 1 from its right edge"); + // ⌘→ turns at once. + assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 1, "⌘→ turns the page at once"); + assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 2); + // A page that fits across: → turns pages as before. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(6); + assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 3); +} + +/// Continuous view with pages of different widths: the content is as wide as the widest page, but +/// on a narrow page that shows whole → turns the page; on the wide one it scrolls first. +#[test] +fn left_and_right_follow_the_current_pages_width_in_continuous_view() { + use egui::Key; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("mixed.pdf", None, sized_fixture(&[(200, 1200), (3000, 1200), (200, 1200)])).unwrap(); + app + }); + crate::support::settle(&mut h); + h.state_mut().active = Some(0); + assert!(h.state().views[0].continuous); + // Each page taller than the window, so one is the current page. + h.state_mut().views[0].set_zoom(1.0); + h.run_steps(6); + h.state_mut().views[0].go_to_page(0); + h.run_steps(6); + let press = |h: &mut Harness<'static, PdfCraftApp>| { + h.key_press(Key::ArrowRight); + h.run_steps(4); + let v = &h.state().views[0]; + (v.current, v.page_screen_rect(1).map(|r| r.left())) + }; + assert_eq!(h.state().views[0].current, 0); + let (page, start) = press(&mut h); + assert_eq!(page, 1, "→ on the narrow page shown whole turns it"); + let (page, left) = press(&mut h); + assert_eq!(page, 1, "→ on the wide page scrolls it"); + assert!(left.zip(start).is_some_and(|(l, s)| l < s - 10.0), "the wide page moved left: {left:?} from {start:?}"); +} + +/// A zoomed two-page spread scrolled so its first page ends inside the window and the second is +/// still wholly to the right of it: → scrolls on to the second page rather than turning the +/// spread. (Read from the pages on screen alone, the spread seemed to end with its first page.) +#[test] +fn right_scrolls_to_a_spreads_second_page_off_screen_before_turning() { + use egui::Key; + // The page gap (points on screen). + const GAP: f32 = 14.0; + let build = |zoom: f32| { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("spread.pdf", None, sized_fixture(&[(600, 800); 4])).unwrap(); + app.set_option("layout", "two-page").unwrap(); + // Before the first layout: zoomed about the content's top left, scrolled to it. + app.views[0].set_zoom(zoom); + app + }); + crate::support::settle(&mut h); + h.state_mut().active = Some(0); + h.run_steps(4); + h + }; + let shown = + |h: &Harness<'static, PdfCraftApp>| (h.state().views[0].viewport_rect(), h.state().views[0].page_screen_rect(0).expect("page 1 on screen")); + // Zoom until page 1's right edge sits half a gap inside the window's. + let mut zoom = 2.0; + let mut h = build(zoom); + for _ in 0..3 { + let (inner, first) = shown(&h); + zoom *= (inner.right() - GAP / 2.0 - first.left()) / first.width(); + h = build(zoom); + } + let (inner, first) = shown(&h); + assert!(first.right() <= inner.right() && first.right() + GAP > inner.right(), "the spread's gap at the window's edge: {first:?} in {inner:?}"); + assert!(h.state().views[0].page_screen_rect(1).is_none(), "its second page off screen"); + assert!(h.state().views[0].layout == pdfcraft_ui_egui::canvas::PageLayout::TwoUp && !h.state().views[0].continuous); + h.key_press(Key::ArrowRight); + h.run_steps(4); + let v = &h.state().views[0]; + assert!(v.current < 2, "→ doesn't turn the spread: page {}", v.current + 1); + assert!(v.page_screen_rect(1).is_some(), "it scrolls the second page into view"); +} + #[test] fn v_h_and_space_pick_the_quick_tools() { // The toolbar's tooltips promise "Select (V)" and "Hand (H)", but the keys did nothing. diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 16c554d72f..c42d414918 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -29,9 +29,9 @@ pub use fdf::export_fdf; use fdf::{ImportBudget, fdf_body, import_fdf, walk_fdf}; #[cfg(test)] use fdf::{MAX_FDF_BYTES, MAX_FDF_OBJECTS}; -pub use xfdf::export_xfdf; #[cfg(test)] use xfdf::{LOCKED, find_by_name}; +pub use xfdf::{Outline, comment_count, comment_outlines, comments_bounds, export_xfdf, export_xfdf_comments, import_comments, is_xfdf_comments}; use xfdf::{apply_values, import_xfdf}; #[cfg(test)] diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 70d466f009..686861964d 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -4,6 +4,7 @@ use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement, use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; +use super::xfdf::PDFCRAFT_NS; use super::*; /// Two blank pages with three fields (text, check box, list). @@ -149,7 +150,7 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fn a_replace_text_pair_survives_an_xfdf_round_trip() { let mut src = blank(); let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; - let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; add_text_replacement( &mut src, @@ -2141,3 +2142,323 @@ fn imported_values_tick_the_check_box_kid_they_name() { assert_eq!(pair.value, ["B"], "{xfdf}"); assert_eq!((pair.widget_on(0), pair.widget_on(1)), (false, true)); } + +/// Copy: the chosen comments travel with their replies and nothing else. +#[test] +fn exporting_selected_comments_takes_them_and_their_replies() { + let src = filled(); + let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf"); + assert!(xfdf.contains(""), "no field values"); + assert!(is_xfdf_comments(&xfdf)); + assert!(!is_xfdf_comments("") && !is_xfdf_comments("just text")); + // A position that is no comment exports nothing but a valid, empty file. + let empty = export_xfdf_comments(&src, &[(7, 7)], "form.pdf"); + assert!(empty.contains("\n"), "{empty}"); +} + +/// Paste: the copies land on the asked-for page under names of their own, so nothing is +/// replaced and a second paste gives a second set; the reply keeps its copied parent. +#[test] +fn importing_comments_as_copies_adds_them_on_the_page_with_fresh_names() { + let src = filled(); + let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf"); + let mut dst = blank(); + let r = import_comments(&mut dst, &xfdf, Some(1), Some("paste1"), None).unwrap(); + assert_eq!((r.comments, r.fields), (2, 0), "{r:?}"); + let first: Vec<_> = summaries(&dst); + assert!(first.iter().all(|s| s.page == 1), "every copy is on page 2: {first:?}"); + let parent = first.iter().find(|s| s.subtype == "Text").unwrap(); + let reply = first.iter().find(|s| s.in_reply_to.is_some()).expect("the reply came along"); + assert_eq!(reply.in_reply_to.as_deref(), Some("paste1-1"), "and replies to the copied note, by its new name ({parent:?})"); + // Again: two sets, not one replaced set. + import_comments(&mut dst, &xfdf, Some(0), Some("paste2"), None).unwrap(); + let second = summaries(&dst); + assert_eq!(second.len(), 4, "{second:?}"); + assert_eq!(second.iter().filter(|s| s.page == 0).count(), 2); + // The copies' names are their own, not the originals'. + let xfdf2 = export_xfdf(&dst, true, false, "dst.pdf"); + assert!(!xfdf2.contains("name=\"n1\"") && !xfdf2.contains("name=\"r1\""), "{xfdf2}"); + assert!(xfdf2.contains("name=\"paste1-1\"") && xfdf2.contains("inreplyto=\"paste1-1\""), "{xfdf2}"); + // A page the document doesn't have is refused, and nothing changes. + assert!(import_comments(&mut dst, &xfdf, Some(9), Some("paste3"), None).is_err()); + assert_eq!(summaries(&dst).len(), 4); + // Without a target page the copies keep their own pages; without a prefix the import is + // the usual one (same names replace). + let mut other = blank(); + import_comments(&mut other, &xfdf, None, Some("p"), None).unwrap(); + assert!(summaries(&other).iter().all(|s| s.page == 0)); + import_comments(&mut other, &xfdf, None, None, None).unwrap(); + import_comments(&mut other, &xfdf, None, None, None).unwrap(); + assert_eq!(summaries(&other).len(), 4, "the named import replaced its own earlier copy"); +} + +/// Other tools write comments straight into the page's `/Annots` array, without an object of +/// their own: they copy like any other, and two source comments sharing a name give two copies. +#[test] +fn comments_written_directly_into_the_page_array_copy_and_shared_names_give_two_copies() { + let mut doc = blank(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut direct = Dict::new(); + direct.set(b"Type".to_vec(), Object::name("Annot")); + direct.set(b"Subtype".to_vec(), Object::name("Square")); + direct.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 60.0, 40.0])); + direct.set(b"Contents".to_vec(), PdfString::text("drawn elsewhere")); + let mut twin = direct.clone(); + twin.set(b"NM".to_vec(), PdfString::text("shared")); + let mut other_twin = twin.clone(); + other_twin.set(b"Rect".to_vec(), arr(&[100.0, 10.0, 150.0, 40.0])); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(direct), Object::Dict(twin), Object::Dict(other_twin)]))) + .unwrap(); + assert_eq!(summaries(&doc).len(), 3); + let xfdf = export_xfdf_comments(&doc, &[(0, 0)], "x.pdf"); + assert!(xfdf.contains("\n"), "a position that is no annotation copies nothing"); +} + +/// A selected comment without a name of its own (other tools leave /NM out) gets one for the +/// export, so its replies still find it when the copies are pasted. +#[test] +fn replies_to_a_selected_comment_without_a_name_follow_it_into_the_copy() { + let meta = |id: &str| Meta { date: None, id: id.into() }; + let mut doc = blank(); + let shape = Shape::Rectangle { rect: [10.0, 10.0, 60.0, 40.0] }; + add_annotation( + &mut doc, + &NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "nameless".into(), author: "Ada".into() }, + &meta("gone"), + ) + .unwrap(); + let index = summaries(&doc)[0].index; + add_reply(&mut doc, 0, index, "Agreed", "Bob", &meta("r1")).unwrap(); + // The parent loses its /NM, as another tool may have left it. + let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); + let parent = annots[index].as_ref().unwrap(); + doc.update_dict(parent, |d| { + d.remove(b"NM"); + }) + .unwrap(); + assert_eq!(threads(&doc)[0].0, None, "{:?}", threads(&doc)); + // A comment written straight into the page array already carries the name the parent would + // get: the parent's export name steers clear of it. + let mut direct = Dict::new(); + direct.set(b"Type".to_vec(), Object::name("Annot")); + direct.set(b"Subtype".to_vec(), Object::name("Square")); + direct.set(b"Rect".to_vec(), arr(&[100.0, 100.0, 150.0, 140.0])); + direct.set(b"NM".to_vec(), PdfString::text(&format!("pdfcraft-0-{index}"))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |p| { + if let Some(Object::Array(annots)) = p.get_mut(b"Annots") { + annots.push(Object::Dict(direct)); + } + }) + .unwrap(); + let xfdf = export_xfdf_comments(&doc, &[(0, index)], "x.pdf"); + let given = format!("pdfcraft-0-{index}x"); + assert!(xfdf.contains(&format!("name=\"{given}\"")) && xfdf.contains(&format!("inreplyto=\"{given}\"")), "{xfdf}"); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(0), Some("p"), None).unwrap(); + let copies = threads(&dst); + assert_eq!(copies.len(), 2, "{copies:?}"); + let parent_name = copies.iter().find(|(_, irt, _)| irt.is_none()).and_then(|(n, _, _)| n.clone()); + assert!(parent_name.is_some(), "{copies:?}"); + let reply_to = copies.iter().find(|(_, irt, _)| irt.is_some()).and_then(|(_, irt, _)| irt.clone()); + assert_eq!(reply_to, parent_name, "the reply follows its copied parent: {copies:?}"); +} + +/// A paste placed elsewhere: every copy moves by the offset, rect, quads, ink and vertices +/// alike; the bounds and count of a clipboard text say what hangs on the pointer. +#[test] +fn copies_placed_with_an_offset_move_whole_and_the_text_reports_its_bounds() { + let src = filled(); + let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect(); + let xfdf = export_xfdf_comments(&src, &all, "form.pdf"); + assert_eq!(comment_count(&xfdf), 3, "note, highlight, ink (the reply is not counted)"); + let bounds = comments_bounds(&xfdf).unwrap(); + assert!(bounds[0] <= 10.0 && bounds[1] <= 10.0 && bounds[2] >= 100.0 && bounds[3] >= 500.0, "{bounds:?}"); + assert_eq!(comment_count("just text"), 0); + assert_eq!(comments_bounds(""), None); + // A reply with a rect of its own (some tools write one, at 0,0 or away from its parent) + // doesn't widen the box the paste is centred on: it has no place on the page. + let with_reply = r#" + + re + "#; + assert_eq!(comments_bounds(with_reply), Some([100.0, 100.0, 140.0, 120.0])); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(0), Some("p"), Some([100.0, -50.0])).unwrap(); + let moved = summaries(&dst); + let note = moved.iter().find(|s| s.subtype == "Text").unwrap(); + let original = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap(); + let (a, b) = (note.rect, original.rect); + assert!((a[0] - b[0] - 100.0).abs() < 0.01 && (a[1] - b[1] + 50.0).abs() < 0.01, "{a:?} from {b:?}"); + let again = export_xfdf(&dst, true, false, "dst.pdf"); + assert!(again.contains("coords=\"110,60,190,60,110,50,190,50\""), "quads moved too: {again}"); + assert!(again.contains("110,-40;120,-20;140,-30"), "ink moved too: {again}"); + // The outlines say what hangs on the pointer: a box for the note, quads for the highlight, + // strokes for the ink, each in its colour; the reply draws nothing of its own. + let outlines = comment_outlines(&xfdf); + assert_eq!(outlines.len(), 3, "{outlines:?}"); + assert!(outlines.iter().any(|o| matches!(o, Outline::Rect { round: false, .. }))); + assert!(outlines.iter().any(|o| matches!(o, Outline::Quads { quads, .. } if quads.len() == 1 && quads[0][0] == 10.0))); + assert!(outlines.iter().any(|o| matches!(o, Outline::Ink { strokes, .. } if strokes == &vec![vec![[10.0, 10.0], [20.0, 30.0], [40.0, 20.0]]]))); + assert!(comment_outlines("no xfdf").is_empty()); + let drawn = "\ + \ + 1,1;5,5;9,1\ + 1,1;5,5;9,1\ + \ + \ + "; + let outlines = comment_outlines(drawn); + assert_eq!(outlines.len(), 6, "{outlines:?}"); + assert_eq!(outlines[0], Outline::Line { points: vec![[1.0, 2.0], [3.0, 4.0]], color: Some([1.0, 0.0, 0.0]) }); + assert!(matches!(&outlines[1], Outline::Poly { closed: false, points, .. } if points.len() == 3)); + assert!(matches!(&outlines[2], Outline::Poly { closed: true, .. })); + assert!(matches!(&outlines[3], Outline::Rect { round: true, .. })); + assert!(matches!(&outlines[5], Outline::Line { points, .. } if points == &vec![[20.0, 20.0], [40.0, 50.0]])); + // A non-finite offset is ignored rather than written into the document. + import_comments(&mut dst, &xfdf, Some(1), Some("q"), Some([f64::NAN, 0.0])).unwrap(); + assert_eq!(summaries(&dst).iter().filter(|s| s.page == 1 && s.subtype == "Text").map(|s| s.rect[0]).next(), Some(b[0])); +} + +/// Fill & Sign marks and text boxes copied: the bundle on the pointer shows the tick, cross, dot +/// and line themselves (not empty boxes) and a text box's text, and the pasted copies are drawn +/// with their mark. +#[test] +fn copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted() { + use pdfcraft_annot::FillMark; + let mut src = blank(); + let meta = Meta { date: Some("D:20261020120000Z".into()), id: "m".into() }; + let marks = [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line]; + for (k, mark) in marks.into_iter().enumerate() { + let shape = Shape::Mark { rect: [100.0 + 40.0 * k as f64, 600.0, 120.0 + 40.0 * k as f64, 620.0], mark }; + let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta).unwrap(); + } + let text = Shape::TextBox { rect: [100.0, 400.0, 200.0, 430.0], font_size: 14.0 }; + let new = NewAnnotation { page: 0, style: Style::default_for(&text), shape: text, contents: "Abnahme\nok".into(), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta).unwrap(); + let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect(); + let xfdf = export_xfdf_comments(&src, &all, "protocol.pdf"); + let outlines = comment_outlines(&xfdf); + // The tick is one stroke, the cross two, the dot a circle, the line one stroke: the strokes + // the appearance draws, not the stamps' boxes. + let check = FillMark::Check.strokes([100.0, 600.0, 120.0, 620.0]); + assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == check[0])), "{outlines:?}"); + assert!(!outlines.iter().any(|o| matches!(o, Outline::Rect { rect, round: false, .. } if *rect == [100.0, 600.0, 120.0, 620.0])), "{outlines:?}"); + let cross = FillMark::Cross.strokes([140.0, 600.0, 160.0, 620.0]); + assert!(cross.iter().all(|s| outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if points == s))), "{outlines:?}"); + assert!( + outlines + .iter() + .any(|o| matches!(o, Outline::Rect { rect, round: true, .. } if (rect[0] - 184.0).abs() < 1e-9 && (rect[2] - 196.0).abs() < 1e-9)), + "{outlines:?}" + ); + assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == vec![[220.0, 610.0], [240.0, 610.0]])), "{outlines:?}"); + assert!( + outlines.iter().any(|o| matches!(o, Outline::Text { text, size, .. } if text == "Abnahme\nok" && (*size - 14.0).abs() < 1e-9)), + "the text box shows its text at its size: {outlines:?}" + ); + // Pasted into another document, each mark is drawn with its own strokes. + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([10.0, -20.0])).unwrap(); + let page = pdfcraft_model::pages(&dst)[1].obj; + let annots = + dst.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| dst.resolve(&a).as_array().cloned().unwrap_or_default()).unwrap(); + let stamps: Vec = + annots.iter().filter_map(|a| dst.resolve(a).as_dict().cloned()).filter(|d| d.name(b"Subtype") == Some(b"Stamp".as_slice())).collect(); + assert_eq!(stamps.len(), 4); + for d in &stamps { + let ap = d.get(b"AP").map(|a| dst.resolve(a)).and_then(|a| a.as_dict().cloned()).and_then(|a| a.get(b"N").cloned()).expect("an appearance"); + let normal = dst.resolve(&ap); + let Object::Stream(s) = normal.as_ref() else { panic!("no appearance stream") }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + let drawn = + if d.name(b"Name") == Some(b"PCDot".as_slice()) { content.contains(" c\n") || content.contains(" c ") } else { content.contains(" S\n") }; + assert!(drawn, "{:?}: {content}", d.name(b"Name")); + } +} + +/// A pasted copy is what the original was: typewriter text stays typewriter text (`/IT`) and a +/// Fill & Sign item, a cloud keeps its cloudy border, and PdfCraft's own keys (a turned comment's +/// `/PCBox` and `/PCAngle`) come along. A reference into the source document stays behind, and +/// keys that aren't PdfCraft's, or a malformed attribute, are not taken from the attribute. +#[test] +fn copies_keep_their_intent_cloud_and_pdfcraft_keys() { + let mut src = blank(); + let meta = |id: &str| Meta { date: Some("D:20261023120000Z".into()), id: id.into() }; + let typed = Shape::Typewriter { rect: [50.0, 500.0, 150.0, 520.0], font_size: 12.0 }; + let cloud = Shape::Polygon { vertices: vec![[200.0, 200.0], [300.0, 200.0], [250.0, 280.0]], cloud: true }; + let square = Shape::Rectangle { rect: [100.0, 100.0, 180.0, 160.0] }; + for (shape, id) in [(typed, "t1"), (cloud, "c1"), (square, "s1")] { + let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: format!("{id} text"), author: "Ada".into() }; + add_annotation(&mut src, &new, &meta(id)).unwrap(); + } + let annots = |doc: &Document, page: usize| -> Vec { + let pages = pdfcraft_model::pages(doc); + let list = pages[page].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).filter(|d| !matches!(d.name(b"Subtype"), Some(b"Popup" | b"Widget"))).collect() + }; + let refs: Vec = { + let pages = pdfcraft_model::pages(&src); + let list = pages[0].dict.get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap(); + list.iter().filter_map(Object::as_ref).collect() + }; + let target = src.add(Object::Dict(Dict::new())); + let square_ref = *refs.iter().find(|r| src.get(**r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Square".as_slice()))).unwrap(); + src.update_dict(square_ref, |d| { + d.set(b"PCBox".to_vec(), arr(&[110.0, 110.0, 170.0, 150.0])); + d.set(b"PCAngle".to_vec(), Object::Real(30.0)); + d.set(b"PCElsewhere".to_vec(), Object::Ref(target)); + }) + .unwrap(); + let which: Vec<(usize, usize)> = summaries(&src).iter().map(|c| (c.page, c.index)).collect(); + let xfdf = export_xfdf_comments(&src, &which, "src.pdf"); + assert!(xfdf.contains("intent=\"FreeTextTypeWriter\"") && xfdf.contains("style=\"cloudy\""), "{xfdf}"); + let mut dst = blank(); + import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([5.0, -5.0])).unwrap(); + let copies = annots(&dst, 1); + assert_eq!(copies.len(), 3, "{copies:?}"); + let typed = copies.iter().find(|d| d.name(b"Subtype") == Some(b"FreeText".as_slice())).unwrap(); + assert_eq!(typed.name(b"IT"), Some(b"FreeTextTypeWriter".as_slice()), "{typed:?}"); + assert!(pdfcraft_annot::is_fill_sign(&dst, typed), "still a Fill & Sign item: {typed:?}"); + assert!(matches!(typed.get(b"PCFillSign"), Some(Object::Bool(true))), "{typed:?}"); + let cloud = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Polygon".as_slice())).unwrap(); + assert_eq!(cloud.name(b"IT"), Some(b"PolygonCloud".as_slice())); + let be = cloud.get(b"BE").and_then(Object::as_dict).expect("a cloudy border"); + assert_eq!(be.name(b"S"), Some(b"C".as_slice())); + assert_eq!(be.get(b"I").and_then(Object::as_f64), Some(1.0)); + let square = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Square".as_slice())).unwrap(); + assert_eq!(square.get(b"PCAngle").and_then(Object::as_f64), Some(30.0)); + assert!(square.get(b"PCBox").and_then(Object::as_array).is_some_and(|b| b.len() == 4)); + assert!(square.get(b"PCElsewhere").is_none(), "a reference into the source stays behind: {square:?}"); + // File ▸ Export comments writes the standard attributes only. + let exported = export_xfdf(&src, true, false, "src.pdf"); + assert!(exported.contains("intent=\"FreeTextTypeWriter\"") && !exported.contains("pdfcraft:keys"), "{exported}"); + // A hand-made attribute: only PdfCraft keys with values of their own are taken. + let crafted = format!( + "\ + \ + " + ); + let mut third = blank(); + import_comments(&mut third, &crafted, Some(0), Some("q"), None).unwrap(); + let got = annots(&third, 0); + assert_eq!(got.len(), 2, "{got:?}"); + assert_eq!(got[0].get(b"PCAngle").and_then(Object::as_f64), Some(45.0)); + assert_eq!(got[0].name(b"Subtype"), Some(b"Square".as_slice()), "not taken from the attribute"); + assert!(got[0].get(b"PCRef").is_none() && got[1].get(b"PCAngle").is_none(), "{got:?}"); +} diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index 90dfc6b6ca..494988039c 100644 --- a/crates/xfdf/src/xfdf.rs +++ b/crates/xfdf/src/xfdf.rs @@ -6,22 +6,188 @@ use super::*; // ── export ────────────────────────────────────────────────────────────────────────────────── -/// Every comment as an XFDF element (one line per comment, replies included). -fn xfdf_annots(doc: &Document, out: &mut String) { +/// The annotations of `which` (0-based page, position in the page's `/Annots`) as positions, +/// with every reply to them (and to those replies), so a thread travels whole. Positions, not +/// references: other tools write comments straight into the page's array, without an object of +/// their own, and those copy too. +fn annots_with_replies(doc: &Document, which: &[(usize, usize)]) -> HashSet<(usize, usize)> { let pages = pdfcraft_model::pages(doc); - // NM of every annotation, for inreplyto. + let annots = |p: &pdfcraft_model::Page| -> Vec { + p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() + }; + /// An annotation's position, its reference (when it has one) and its parent's (`/IRT`). + struct Entry { + pos: (usize, usize), + obj: Option, + parent: Option, + } + let all: Vec = pages + .iter() + .enumerate() + .flat_map(|(pi, p)| { + annots(p).into_iter().enumerate().map(move |(ai, a)| { + let parent = doc.resolve(&a).as_dict().and_then(|d| d.get(b"IRT")).and_then(Object::as_ref); + Entry { pos: (pi, ai), obj: a.as_ref(), parent } + }) + }) + .collect(); + let mut set: HashSet<(usize, usize)> = which.iter().copied().filter(|w| all.iter().any(|e| e.pos == *w)).collect(); + // Replies point at their parent through /IRT: a pass per reply depth, bounded by the count. + for _ in 0..all.len() { + let before = set.len(); + let parents: HashSet = all.iter().filter(|e| set.contains(&e.pos)).filter_map(|e| e.obj).collect(); + for e in &all { + if e.parent.is_some_and(|parent| parents.contains(&parent)) { + set.insert(e.pos); + } + } + if set.len() == before { + break; + } + } + set +} + +/// Every comment as an XFDF element (one line per comment, replies included), or only those at +/// the positions in `only`. +/// The namespace of PdfCraft's own attributes in XFDF (`pdfcraft:keys`): other readers skip them. +pub(crate) const PDFCRAFT_NS: &str = "https://github.com/storytold/pdfcraft#xfdf"; +/// At most this much text in a `pdfcraft:keys` attribute is read. +const MAX_OWN_KEYS: usize = 64 * 1024; + +/// A value that means the same in another document: no reference or stream into this one. +fn portable(o: &Object, depth: usize) -> bool { + match o { + Object::Ref(_) | Object::Stream(_) => false, + Object::Array(a) => depth < 4 && a.iter().all(|o| portable(o, depth + 1)), + Object::Dict(d) => depth < 4 && d.iter().all(|(_, o)| portable(o, depth + 1)), + _ => true, + } +} + +/// The annotation's own PdfCraft keys (`/PC…`: a Fill & Sign item, a turned comment's box and +/// angle, …) as one PDF dictionary, for a copy that is what the original was. Values that point +/// into this document stay behind. +fn own_keys(d: &Dict) -> Option { + let mut keys = Dict::new(); + // Each value must also write as text: XML can't hold raw bytes. + let writes_as_text = |v: &Object| { + let mut out = Vec::new(); + pdfcraft_cos::serialize(v, &mut out); + std::str::from_utf8(&out).is_ok() + }; + for (k, v) in d.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0) && writes_as_text(v)) { + keys.set(k.clone(), v.clone()); + } + if keys.is_empty() { + return None; + } + let mut out = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(keys), &mut out); + String::from_utf8(out).ok() +} + +/// The `pdfcraft:keys` of an XFDF comment back as dictionary entries: PdfCraft's keys only, with +/// values that mean the same here. Anything malformed is skipped. +fn read_own_keys(node: roxmltree::Node, d: &mut Dict) { + let Some(text) = node.attribute((PDFCRAFT_NS, "keys")).filter(|t| t.len() <= MAX_OWN_KEYS) else { return }; + let Ok(Object::Dict(keys)) = pdfcraft_cos::Lexer::new(text.as_bytes(), 0).object() else { return }; + for (k, v) in keys.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0)) { + if !d.contains(k) { + d.set(k.clone(), v.clone()); + } + } +} + +/// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one +/// (other tools leave /NM out), a name new to the document for this export, so a reply copied +/// with it still finds it. +fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option<&HashSet<(usize, usize)>>) -> HashMap { let mut names: HashMap = HashMap::new(); - for p in &pages { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { - if let Some(r) = a.as_ref() - && let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM")) - { - names.insert(r, nm); + let mut unnamed: Vec<(usize, usize, ObjRef)> = Vec::new(); + // Every name in use, those of annotations written straight into the page array too, so a + // given name is new. + let mut taken: HashSet = HashSet::new(); + for (pi, p) in pages.iter().enumerate() { + for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { + let nm = doc.resolve(&a).as_dict().and_then(|d| text_of(doc, d, b"NM")); + if let Some(nm) = &nm { + taken.insert(nm.clone()); + } + let Some(r) = a.as_ref() else { continue }; + match nm { + Some(nm) => { + names.insert(r, nm); + } + None if only.is_some_and(|only| only.contains(&(pi, ai))) => unnamed.push((pi, ai, r)), + None => {} } } } + for (pi, ai, r) in unnamed { + let mut name = format!("pdfcraft-{pi}-{ai}"); + while taken.contains(&name) { + name.push('x'); + } + taken.insert(name.clone()); + names.insert(r, name); + } + names +} + +/// The elements inside a comment's XFDF element: its text, default appearance, ink, vertices +/// and pop-up (on page `pi`). +fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) { + if let Some(c) = text_of(doc, d, b"Contents") { + let _ = write!(out, "{}", esc(&c)); + } + if let Some(da) = text_of(doc, d, b"DA") { + let _ = write!(out, "{}", esc(&da)); + } + let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + if !ink.is_empty() { + out.push_str(""); + for g in ink { + let v = nums_of(doc, Some(&g)); + let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + let _ = write!(out, "{}", pts.join(";")); + } + out.push_str(""); + } + // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). + let vertices = nums_of(doc, d.get(b"Vertices")); + let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); + if !pts.is_empty() { + let _ = write!(out, "{}", pts.join(";")); + } + if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + let r = nums_of(doc, pd.get(b"Rect")); + if r.len() == 4 { + let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); + let _ = write!( + out, + "", + n(r[0]), + n(r[1]), + n(r[2]), + n(r[3]), + if open { "yes" } else { "no" } + ); + } + } +} + +/// `own`: write each comment's PdfCraft keys too (see [`own_keys`]), for a copy and paste. +fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, usize)>>, own: bool) { + let pages = pdfcraft_model::pages(doc); + // NM of every annotation, for inreplyto. A selected annotation without one (other tools + // leave /NM out) gets a name for this export, so a reply copied with it still finds it. + let names = annotation_names(doc, &pages, only); for (pi, p) in pages.iter().enumerate() { - for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() { + for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { + if only.is_some_and(|only| !only.contains(&(pi, ai))) { + continue; + } let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; let Some(sub) = d.name(b"Subtype") else { continue }; let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue }; @@ -37,9 +203,12 @@ fn xfdf_annots(doc: &Document, out: &mut String) { n(rect[1].max(rect[3])) ); } + // The name: /NM, or the one given above for this export. + if let Some(nm) = text_of(doc, &d, b"NM").or_else(|| a.as_ref().and_then(|r| names.get(&r).cloned())) { + let _ = write!(attrs, " name=\"{}\"", esc(&nm)); + } for (key, attr) in [ - (&b"NM"[..], "name"), - (b"T", "title"), + (&b"T"[..], "title"), (b"Subj", "subject"), (b"M", "date"), (b"CreationDate", "creationdate"), @@ -81,7 +250,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) { let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into()); let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1]))); } - // A callout: its leader path, text-box inset, intent and (single-name) line ending. + // A callout: its leader path, text-box inset and (single-name) line ending. let cl = nums_of(doc, d.get(b"CL")); if cl.len() == 4 || cl.len() == 6 { let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::>().join(",")); @@ -89,54 +258,31 @@ fn xfdf_annots(doc: &Document, out: &mut String) { if rd.len() == 4 { let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::>().join(",")); } - if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { - let _ = write!(attrs, " intent=\"{}\"", esc(&it)); - } if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { let _ = write!(attrs, " head=\"{}\"", esc(&le)); } } + // What the comment is meant as (/IT): typewriter text, a callout, a cloud, an arrow. + if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) { + let _ = write!(attrs, " intent=\"{}\"", esc(&it)); + } + // A cloudy border (/BE /S /C) and how far its arcs bulge. + if let Some(be) = + d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice())) + { + attrs.push_str(" style=\"cloudy\""); + if let Some(i) = be.get(b"I").and_then(Object::as_f64) { + let _ = write!(attrs, " intensity=\"{}\"", n(i)); + } + } if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) { let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt)); } + if own && let Some(keys) = own_keys(&d) { + let _ = write!(attrs, " pdfcraft:keys=\"{}\"", esc(&keys)); + } let _ = write!(out, "<{tag}{attrs}>"); - if let Some(c) = text_of(doc, &d, b"Contents") { - let _ = write!(out, "{}", esc(&c)); - } - if let Some(da) = text_of(doc, &d, b"DA") { - let _ = write!(out, "{}", esc(&da)); - } - let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - if !ink.is_empty() { - out.push_str(""); - for g in ink { - let v = nums_of(doc, Some(&g)); - let pts: Vec = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - let _ = write!(out, "{}", pts.join(";")); - } - out.push_str(""); - } - // Polygon and polyline points (XFDF ``: `x,y;x,y;…`). - let vertices = nums_of(doc, d.get(b"Vertices")); - let pts: Vec = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect(); - if !pts.is_empty() { - let _ = write!(out, "{}", pts.join(";")); - } - if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { - let r = nums_of(doc, pd.get(b"Rect")); - if r.len() == 4 { - let open = matches!(pd.get(b"Open"), Some(Object::Bool(true))); - let _ = write!( - out, - "", - n(r[0]), - n(r[1]), - n(r[2]), - n(r[3]), - if open { "yes" } else { "no" } - ); - } - } + xfdf_annot_children(doc, out, &d, pi); let _ = writeln!(out, ""); } } @@ -191,12 +337,235 @@ fn xfdf_fields(doc: &Document, out: &mut String) { } } +/// XFDF with these comments alone (0-based page and position in the page's `/Annots`), each +/// with its replies: what Copy puts on the clipboard, and Export selected comments writes. +pub fn export_xfdf_comments(doc: &Document, which: &[(usize, usize)], file: &str) -> String { + let only = annots_with_replies(doc, which); + let mut out = format!( + "\n\n" + ); + out.push_str("\n"); + xfdf_annots(doc, &mut out, Some(&only), true); + out.push_str("\n"); + let _ = writeln!(out, "", esc(file)); + out.push_str("\n"); + out +} + +/// Whether `text` is XFDF with comments in it (what a paste of copied comments carries). +pub fn is_xfdf_comments(text: &str) -> bool { + let head: String = text.chars().take(400).collect(); + head.contains(" }, + /// A line or arrow, and a callout's leader. + Line { points: Vec<[f64; 2]>, color: Option<[f64; 3]> }, + /// A polygon (closed) or connected lines. + Poly { points: Vec<[f64; 2]>, closed: bool, color: Option<[f64; 3]> }, + /// Freehand strokes. + Ink { strokes: Vec>, color: Option<[f64; 3]> }, + /// Text markup: each quad is x1,y1 (upper left), x2,y2 (upper right), x3,y3 (lower left), x4,y4. + Quads { quads: Vec<[f64; 8]>, color: Option<[f64; 3]> }, + /// A text box's text, from the top-left of its box (`rect`, which it wraps in), at its font + /// `size` in points and in its text colour. + Text { rect: [f64; 4], text: String, size: f64, color: Option<[f64; 3]> }, +} + +/// Every comment element of an XFDF text, in order (none when it is no XFDF). +fn with_comment_nodes(text: &str, mut f: impl FnMut(roxmltree::Node<'_, '_>)) { + if check_form_xml_depth(text).is_err() { + return; + } + let Ok(xml) = parse_form_xml(text) else { return }; + let root = xml.root_element(); + if root.tag_name().name() != "xfdf" { + return; + } + let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) else { return }; + for node in annots.children().filter(|c| c.is_element()) { + if SUBTYPES.iter().any(|(_, t)| *t == node.tag_name().name().to_ascii_lowercase()) { + f(node); + } + } +} + +/// The comment elements of an XFDF text: tag, `inreplyto`, rect. +fn comment_nodes(text: &str) -> Vec<(String, Option, Vec)> { + let mut out = Vec::new(); + with_comment_nodes(text, |node| { + out.push(( + node.tag_name().name().to_owned(), + node.attribute("inreplyto").map(str::to_owned), + node.attribute("rect").map(csv).unwrap_or_default(), + )); + }); + out +} + +/// Pairs `x,y;x,y;…` (XFDF vertices and ink gestures). +fn pairs(text: &str) -> Vec<[f64; 2]> { + text.split(';').filter_map(|p| if let [x, y] = csv(p).as_slice() { Some([*x, *y]) } else { None }).collect() +} + +/// The outlines of the comments in an XFDF text (replies, which have no place of their own, +/// left out): what a paste shows hanging on the pointer. +pub fn comment_outlines(text: &str) -> Vec { + let mut out = Vec::new(); + with_comment_nodes(text, |node| { + if node.attribute("inreplyto").is_some() { + return; + } + let tag = node.tag_name().name().to_ascii_lowercase(); + let color = node.attribute("color").and_then(parse_hex); + let rect = node + .attribute("rect") + .map(csv) + .filter(|r| r.len() == 4 && r.iter().all(|v| v.is_finite())) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); + match tag.as_str() { + "line" => { + if let (Some(a), Some(b)) = (node.attribute("start").map(csv), node.attribute("end").map(csv)) + && let ([ax, ay], [bx, by]) = (a.as_slice(), b.as_slice()) + { + out.push(Outline::Line { points: vec![[*ax, *ay], [*bx, *by]], color }); + } + } + "polygon" | "polyline" => { + let points = node.children().find(|c| c.has_tag_name("vertices")).and_then(|v| v.text()).map(pairs).unwrap_or_default(); + if points.len() >= 2 { + out.push(Outline::Poly { points, closed: tag == "polygon", color }); + } + } + "ink" => { + let strokes: Vec> = node + .children() + .find(|c| c.has_tag_name("inklist")) + .map(|list| { + list.children().filter(|g| g.has_tag_name("gesture")).filter_map(|g| g.text()).map(pairs).filter(|s| !s.is_empty()).collect() + }) + .unwrap_or_default(); + if !strokes.is_empty() { + out.push(Outline::Ink { strokes, color }); + } + } + "highlight" | "underline" | "strikeout" | "squiggly" | "redact" => { + let coords = node.attribute("coords").map(csv).unwrap_or_default(); + let quads: Vec<[f64; 8]> = coords.as_chunks::<8>().0.to_vec(); + if quads.is_empty() { + if let Some(rect) = rect { + out.push(Outline::Rect { rect, round: false, color }); + } + } else { + out.push(Outline::Quads { quads, color }); + } + } + // A Fill & Sign mark: its tick, cross, dot or line, as its appearance draws it. + "stamp" + if let Some(rect) = rect + && let Some(mark) = node.attribute("icon").and_then(|i| pdfcraft_annot::FillMark::from_name(i.as_bytes())) => + { + if mark == pdfcraft_annot::FillMark::Dot { + let r = (rect[2] - rect[0]).min(rect[3] - rect[1]) * 0.3; + let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + out.push(Outline::Rect { rect: [cx - r, cy - r, cx + r, cy + r], round: true, color }); + } + for points in mark.strokes(rect) { + out.push(Outline::Line { points, color }); + } + } + _ => { + if let Some(rect) = rect { + out.push(Outline::Rect { rect, round: tag == "circle", color }); + } + // A callout's leader, from the box to what it points at. + let callout = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6); + if tag == "freetext" + && let Some(cl) = &callout + { + out.push(Outline::Line { points: cl.as_chunks::<2>().0.to_vec(), color }); + } + // A text box's text, in its own colour and size, inside the box (a callout's box + // is its rect inset by the fringe). + if tag == "freetext" + && let Some(rect) = rect + && let Some(text) = node.children().find(|c| c.has_tag_name("contents")).and_then(|c| c.text()).filter(|t| !t.trim().is_empty()) + { + let mut da = Dict::new(); + if let Some(s) = node.children().find(|c| c.has_tag_name("defaultappearance")).and_then(|c| c.text()) { + da.set(b"DA".to_vec(), PdfString::literal(s.as_bytes().to_vec())); + } + let (text_color, size) = pdfcraft_annot::appearance::parse_da(&da); + let fringe = node.attribute("fringe").map(csv).filter(|f| callout.is_some() && f.len() == 4 && f.iter().all(|v| v.is_finite())); + let rect = match fringe.as_deref() { + Some([l, b, r, t]) => [rect[0] + l, rect[1] + b, rect[2] - r, rect[3] - t], + _ => rect, + }; + out.push(Outline::Text { rect, text: text.replace("\r\n", "\n").replace('\r', "\n"), size, color: Some(text_color) }); + } + } + } + }); + out +} + +/// How many comments (replies not counted) an XFDF text holds. +pub fn comment_count(text: &str) -> usize { + comment_nodes(text).iter().filter(|(_, irt, _)| irt.is_none()).count() +} + +/// The box around every comment in an XFDF text, in the pages' user space: [x0, y0, x1, y1], +/// or `None` without a comment with a rect. What a paste shows hanging on the pointer and centres +/// on the point: replies, which have no place of their own (some tools still give them a rect, at +/// 0,0 or away from their parent), are left out, as [`comment_outlines`] leaves them out. +pub fn comments_bounds(text: &str) -> Option<[f64; 4]> { + let mut bounds: Option<[f64; 4]> = None; + for (_, in_reply_to, rect) in comment_nodes(text) { + if in_reply_to.is_some() { + continue; + } + if let [x0, y0, x1, y1] = rect.as_slice() + && rect.iter().all(|v| v.is_finite()) + { + let r = [x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)]; + bounds = Some(match bounds { + None => r, + Some(b) => [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])], + }); + } + } + bounds +} + +/// Move an annotation's geometry by (dx, dy) in user space: its rect, quads, vertices, ink, +/// line ends and callout leader (what a copy placed elsewhere needs). +fn translate(d: &mut Dict, dx: f64, dy: f64) { + let shift = |o: &Object| -> Option { + let v: Vec = o.as_array()?.iter().map(|x| x.as_f64()).collect::>>()?; + Some(arr(&v.iter().enumerate().map(|(i, x)| if i % 2 == 0 { x + dx } else { x + dy }).collect::>())) + }; + for key in [&b"Rect"[..], b"QuadPoints", b"Vertices", b"L", b"CL"] { + if let Some(moved) = d.get(key).and_then(shift) { + d.set(key.to_vec(), moved); + } + } + if let Some(Object::Array(strokes)) = d.get(b"InkList").cloned() { + let moved: Vec = strokes.iter().map(|s| shift(s).unwrap_or_else(|| s.clone())).collect(); + d.set(b"InkList".to_vec(), Object::Array(moved)); + } +} + /// XFDF with the document's comments and/or field values. pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String { let mut out = String::from("\n\n"); if comments { out.push_str("\n"); - xfdf_annots(doc, &mut out); + xfdf_annots(doc, &mut out, None, false); out.push_str("\n"); } if fields { @@ -311,6 +680,16 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]), ); } + if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) { + d.set(b"IT".to_vec(), Object::name(it)); + } + if node.attribute("style") == Some("cloudy") { + let mut be = Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + let intensity = node.attribute("intensity").and_then(|i| i.parse::().ok()).filter(|i| i.is_finite()).unwrap_or(1.0); + be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0))); + d.set(b"BE".to_vec(), Object::Dict(be)); + } if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) { d.set(b"CL".to_vec(), arr(&cl)); if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) { @@ -352,6 +731,7 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt _ => {} } } + read_own_keys(node, &mut d); Some(d) } @@ -472,7 +852,32 @@ pub(crate) fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect: Ok(()) } +/// Import XFDF comments as copies: every comment lands on `page` (0-based; its own page when +/// `None`) and, with `fresh` (a prefix unique to this import), under a new name, so nothing in +/// the document is replaced and pasting the same comments twice gives two copies. Replies keep +/// their thread among the pasted comments; a reply to a comment that was not copied stands +/// alone. Field values in the file are ignored. +pub fn import_comments( + doc: &mut Document, + text: &str, + page: Option, + fresh: Option<&str>, + offset: Option<[f64; 2]>, +) -> Result { + import_xfdf_with(doc, text, page, fresh, offset.filter(|o| o.iter().all(|v| v.is_finite()))) +} + pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result { + import_xfdf_with(doc, text, None, None, None) +} + +fn import_xfdf_with( + doc: &mut Document, + text: &str, + target_page: Option, + fresh: Option<&str>, + offset: Option<[f64; 2]>, +) -> Result { check_form_xml_depth(text)?; let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; let root = xml.root_element(); @@ -481,28 +886,65 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); + if let Some(p) = target_page + && p >= pages.len() + { + return Err(DataError::Malformed(format!("page {} is not in the document", p + 1))); + } // Comments: parents first, then replies (whose /IRT needs the parent's reference). let mut by_name: HashMap = HashMap::new(); let mut later: HashMap = HashMap::new(); + // Fresh names: every copy gets one from its own position, and `name` → the first copy's, + // so replies find their copied parent (a name two source comments share, as other tools + // write, still gives two copies). + let mut renamed: HashMap = HashMap::new(); + if let (Some(prefix), Some(annots)) = (fresh, root.children().find(|c| c.has_tag_name("annots"))) { + for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() { + if let Some(nm) = node.attribute("name") { + renamed.entry(nm.to_string()).or_insert_with(|| format!("{prefix}-{}", n + 1)); + } + } + } if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) { - for node in annots.children().filter(|c| c.is_element()) { + for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() { let tag = node.tag_name().name().to_ascii_lowercase(); let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue }; - let Some(page) = node.attribute("page").and_then(|p| p.parse::().ok()).and_then(|p| pages.get(p).copied()) else { continue }; - let Some(d) = annot_from_xml(node, subtype, page) else { continue }; + let own_page = node.attribute("page").and_then(|p| p.parse::().ok()); + let Some(page) = target_page.or(own_page).and_then(|p| pages.get(p).copied()) else { continue }; + let Some(mut d) = annot_from_xml(node, subtype, page) else { continue }; + let assigned = fresh.map(|prefix| format!("{prefix}-{}", n + 1)); + if let Some(name) = &assigned { + // A copy under a name of its own (one even where the original had none). + d.set(b"NM".to_vec(), PdfString::text(name)); + } + if let Some([dx, dy]) = offset { + translate(&mut d, dx, dy); + } let r = place(doc, page, d)?; // A later same-name record replaces this comment, including its reply relation. later.remove(&r); - if let Some(nm) = node.attribute("name") { - by_name.insert(nm.to_string(), r); + if let Some(name) = assigned.clone().or_else(|| node.attribute("name").map(str::to_owned)) { + by_name.insert(name, r); } if let Some(irt) = node.attribute("inreplyto") { - later.insert(r, irt.to_string()); + match (fresh, renamed.get(irt)) { + // The parent is among the copies: the reply follows it. + (Some(_), Some(parent)) => later.insert(r, parent.clone()), + // A copy's parent was not copied: the reply stands alone rather than + // attaching to a same-named comment the document happens to have. + (Some(_), None) => None, + (None, _) => later.insert(r, irt.to_string()), + }; } // A pop-up window. if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) - && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) + && let Some(mut rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) { + if let Some([dx, dy]) = offset { + for (i, v) in rect.iter_mut().enumerate() { + *v += if i % 2 == 0 { dx } else { dy }; + } + } add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; } report.comments += 1; @@ -513,6 +955,10 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result **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-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 3: #2517 a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -173,7 +174,11 @@ update the numbers. ### 7. OCR reads only unaccented Latin - **Missing:** every other script and accented Latin; editable-text output; deskew, rotation, - despeckle, background and camera clean-up; MRC compression; pluggable engines; OCR in the + despeckle and background clean-up as options of Recognize text (Enhance scanned file removes + the background and edge shadows on grey and RGB scans and despeckles those and 1-bit ones; it + reports CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans drawn inside Form XObjects + and leaves them as they are); MRC compression; + pluggable engines; OCR in the browser; scanner input. - **Evidence:** `ocr.ocr-languages` partial ("the models read only the Latin alphabet without accents"); 19 of 26 OCR features planned; [#744](https://github.com/storytold/pdfcraft/issues/744) diff --git a/docs/roadmap.md b/docs/roadmap.md index d17893c1aa..a7f4a86b7e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **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-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) > **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) @@ -22,7 +23,7 @@ > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font helpers and regression contracts; behavior and estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print-preview font policy matches output, with isolated caches and GBK switching regressions; estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main and retain Japanese serif/weight selection alongside display-only CJK fallback; estimates unchanged) -> **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-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 (M5: one reply in the Comments panel is edited, copied or deleted from its own "…" menu; estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (M11: lossless JBIG2 stores each distinct mark once, as a symbol; estimates unchanged) @@ -63,6 +64,10 @@ > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M10: dotted-leader contents entries export as complete paragraphs; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M6: calculation scripts read their notation only from a comment, and a regular expression after a condition as one; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) + +> **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-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** patch (M2 #3086: dotted TOC leaders no longer hide word spaces; estimates unchanged) > @@ -78,6 +83,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) > @@ -204,7 +210,7 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Refreshed against upstream `99941daa` (2026-10-15). COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration preserves edited nonzero-generation objects through incremental save/reopen. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. For damaged live rows, either the xref generation or a same-number header at its indexed offset may identify the object; neither-match and free slots remain null. Header probes read at most 128 bytes per candidate offset, and opening records at most 256 object disagreements plus a summary without expanding compact xref rows. Synthetic dictionary-edit and independent save/reopen controls cover this repair policy. Repair-aware preflight edits and restored snapshots retain both accepted aliases, with full rewrite for repaired edits; classic/packed output coalesces them to one object identity. Materialized diagnostics inspect retained primary/hybrid rows before folding, with the same cap. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | @@ -239,7 +245,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -308,6 +314,7 @@ Estimates unchanged. | 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer | +| 2026-11-02 | patch | M14 #5737, #5460: Preferences turns off the update check at start, on by default | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | | 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. | @@ -322,6 +329,7 @@ Estimates unchanged. | 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. | | 2026-10-23 | minor | M6: calculation checks: a formula reading a field the form doesn't have, and a script building the names it reads, are flagged by `form_calculation_order` and on the Calculation order page (a red mark, a card naming the broken formulas, a note for built names); `form_calculation_order` gives each formula and `form_fields` a custom Calculate script. Estimates unchanged. | | 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. | +| 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | @@ -350,6 +358,8 @@ Estimates unchanged. | 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. | | 2026-10-19 | minor | M4 #3309: named-destination pruning keeps large documents usable; 60,000-name and deep-tree regressions verify targets through undo/redo and both save modes. Estimates unchanged. | | 2026-10-19 | minor | M6: Text field table (rows and columns of text fields as one edit, `form_add_field_table`); estimates unchanged | +| 2026-10-20 | patch | M1, #1143 review: preserve accepted aliases through preflight/snapshots, normalize them to one output identity with full-save fallback for repaired edits, and report retained materialized/hybrid generation disagreements before folding. Synthetic red/green and four-save controls; 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: Edit text caret height follows its row, with a kittest regression; estimates unchanged | @@ -362,6 +372,7 @@ Estimates unchanged. | 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 | M5 #2669, item 5: the Comments panel groups replies and counts all page roots once per frame. Estimates unchanged. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | +| 2026-10-15 | minor | M1: refresh object-generation reference identity, retaining current upstream progress and regression tests. Estimates unchanged. | | 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | | 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d03c27c95c..44364aee66 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro +> **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) > Integration update 2026-10-30: lossless JBIG2 generic and exact-copy symbol coding are included; no lossy substitution. Combined tests passed at bdf5620; the subsequent review fixes require fresh CI. > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-26 · **Change:** minor (K Optimize and standards no longer lists monochrome downsampling as missing; scores and estimates unchanged) @@ -210,7 +211,7 @@ full parity in that area. | F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | -| I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | +| I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners. Background removal, edge shadow removal and despeckle work on scanned pages (Enhance scanned file) but not yet as options of Recognize text, nor on CMYK, indexed, JBIG2 or CCITT scans | | J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | | K Optimize and standards | 58 | 15 | 11 | 35.3% | 20% | 65–115 | Reduce File Size and the Optimizer (images, CCITT and lossless JBIG2 monochrome compression, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, lossy/refinement JBIG2 | | L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f950e36a94..3523db9eed 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; full screen presents one page at a time #1459; the selected field nudges with the arrow keys in Prepare a form #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it; a comment's text, border or line, and fill each at an opacity of its own) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -44,11 +44,12 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Zoom: wheel, pinch, keys, marquee, fit page/width/height/visible | yes | yes | shipped | | Fixed zoom percentages match Acrobat's physical size | yes | differ (#739, documented in #736) | gap | | Dynamic zoom, loupe, pan-and-zoom window | yes | no | planned | -| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial | +| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll; → / ← scroll a page wider than the window before turning it | partial | | Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped | | Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial | | Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | shipped | | Object handles: corners keep proportion, edges free | yes | yes for added images and signatures; Shift on a corner keeps the proportions of comments, stamps and form fields (#2199); no Alt resize from the centre | shipped | +| Shift while drawing a line | holds it to 45° steps | holds it level or plumb (a CAD drawing's ortho): new lines and arrows, each side of connected lines, polygons and clouds, and a dragged end or corner | shipped | | Arrange, align and distribute page objects | yes | fields only | partial | | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | @@ -75,13 +76,16 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Print dialog with preview | yes | yes | shipped | | Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial | | Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial | -| Comment properties and properties bar | yes | yes | shipped | +| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment); text boxes and typed text take an oval or a circle for their border (Acrobat: a rectangle) | shipped | +| Select several comments; copy and paste comments between documents | yes | yes: ⌘/Ctrl-click adds, Delete, a drag, Properties and Colour act on all, ⌘C/⌘V as XFDF (also across PdfCraft processes) | shipped | | Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once | +| 2026-10-24 | minor | Comment Properties sets a text box's text, border and fill opacity and a shape's line and fill opacity each on its own (Acrobat: one per comment); Make Properties Default carries them into the tool | | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | | 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) | diff --git a/packaging/web/README.md b/packaging/web/README.md index 7aaa49d6a5..3149dc75d5 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -78,6 +78,64 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo downloads) are blocked. - Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page. +## Same-document host mode (no iframe) + +A host application can also run PdfCraft from its own document: it loads the wasm-bindgen module +itself and gives PdfCraft one of its own canvases, so there is no iframe and no `?host=parent`. +It opts in with `data-host="page"` on that canvas — the exact value `page` — and the canvas +keeps the id the app looks up (`id="pdfcraft"`). + +The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`. +The app dispatches the same messages it would post to a parent — `pdfcraft:ready`, +`pdfcraft:save`, `pdfcraft:dirty`, `pdfcraft:failed` — with the message fields (without the +`type`) as the event's `detail`, and it opens a file when the page dispatches a `pdfcraft:open` +event with `detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself. + +Add the listeners before `init`: the app announces itself with `pdfcraft:ready` while `init` is +still running, and a file sent in reply would otherwise be missed. + +```js +import init from "./pdfcraft-web-.js"; // the wasm-bindgen glue shipped with the app + +const canvas = document.getElementById("pdfcraft"); +canvas.dataset.host = "page"; // or + +canvas.addEventListener("pdfcraft:ready", () => { + // The app is listening; hand it a document now, if you have one. + canvas.dispatchEvent(new CustomEvent("pdfcraft:open", { + detail: { name: "handout.pdf", bytes: pdfBytes }, // ArrayBuffer or Uint8Array + })); +}); +canvas.addEventListener("pdfcraft:save", (event) => { + const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer + // Store the file under `name`; `saveAs` is true for Save As. Report your own errors. +}); +canvas.addEventListener("pdfcraft:dirty", (event) => { + unsaved = event.detail.dirty; // warn before leaving, as you like +}); +canvas.addEventListener("pdfcraft:failed", (event) => { + showError(event.detail.error); // the app couldn't start +}); + +await init({ module_or_path: "./pdfcraft-web-_bg.wasm" }); +``` + +- Saves are not downloads in this mode: the `bytes` in `pdfcraft:save` hold the saved PDF, and + the host decides where the file goes. +- `?author=` still signs the user's comments, as with `?host=parent`. +- An unframed page that uses `?host=parent` keeps the normal app with downloads. + `data-host="page"` turns this mode on only when `?host=parent` is absent: if `?host=parent` is + present it takes precedence, so a same-origin framed page uses the parent bridge, while an + unframed or cross-origin page gets no bridge and keeps normal downloads. + +The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `pdfcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. PdfCraft reads bytes, so a host sends `bytes`, not a `File`. + +### Trust and limits + +- **Who is trusted.** In page host mode the page and PdfCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin. +- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters. +- **The event and message names are the interface.** `pdfcraft:ready`, `pdfcraft:open`, `pdfcraft:save`, `pdfcraft:dirty` and `pdfcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change. + ## Renderer selection and fallback flags PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c8d51e7375..1f1c039e23 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -70,8 +70,8 @@ milestone = "M1" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired"] -notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them" +evidence = ["crates/ui-egui/tests/it/viewer.rs::damaged_files_say_they_were_repaired", "crates/cos/tests/it/generations.rs::contradictory_xref_generation_recovers_the_matching_header_and_records_repair", "crates/cos/tests/it/generations.rs::generation_repair_diagnostics_are_bounded_without_disabling_recovery", "crates/cos/tests/it/generations.rs::retained_generation_disagreements_are_reported_in_materialized_mode_too", "crates/cos/tests/it/generations.rs::hybrid_generation_diagnostics_observe_rows_before_the_table_hides_them", "crates/cos/tests/it/generations.rs::discarded_xref_repair_notes_do_not_survive_object_header_reconstruction"] +notes = "a notice says the file was repaired; Document Properties ▸ Advanced lists the repairs; doc_info reports them. COS records bounded live xref/header generation disagreements on open, including retained materialized and hybrid rows before folding; this follow-up validates the COS policy, without a fresh GUI claim." [[feature]] id = "core.xref-streams" @@ -82,8 +82,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/it/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/it/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"] -notes = "" +evidence = ["crates/cos/tests/it/generations.rs::indirect_stream_lengths_require_the_referenced_generation", "crates/cos/tests/it/generations.rs::compressed_objects_only_accept_generation_zero", "crates/cos/tests/it/writer.rs::incremental_saves_stack_on_an_xref_stream_file", "crates/cos/tests/it/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers"] +notes = "Compressed objects accept generation zero only; indirect stream lengths are resolved by their complete reference. Synthetic regression coverage." [[feature]] id = "core.hybrid-xref" @@ -298,8 +298,8 @@ milestone = "M1" status = "shipped" commands = ["file.save_as"] tools = ["doc_save"] -evidence = ["crates/organize/src/tests.rs::full_save_drops_unreachable_objects", "crates/engine/src/tests.rs::full_save_is_readable_and_smaller_after_deletes"] -notes = "" +evidence = ["crates/cos/tests/it/generations.rs::full_save_does_not_turn_stale_references_into_live_ones", "crates/cos/tests/it/generations.rs::repaired_page_tree_aliases_keep_one_identity_in_full_saves", "crates/organize/src/tests.rs::full_save_drops_unreachable_objects", "crates/engine/src/tests.rs::full_save_is_readable_and_smaller_after_deletes"] +notes = "Full rewrites coalesce accepted repair aliases to one output object, preserving page-tree parent identity. They preserve reference-generation identity: stale references become null while live replacement objects remain reachable. Synthetic classic and object-stream save tests; this does not establish general rendering fidelity." [[feature]] id = "core.object-stream-compression" @@ -334,8 +334,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/src/document/tests.rs::reads_classic_revision_and_edits_are_copy_on_write"] -notes = "" +evidence = ["crates/cos/tests/it/generations.rs::free_xref_slots_never_revive_retained_object_headers", "crates/cos/tests/it/generations.rs::restoring_equal_values_still_restores_the_accepted_generation_aliases", "crates/cos/tests/it/generations.rs::restoring_explicit_sets_removes_old_generation_aliases", "crates/preflight/tests/generation_repairs.rs::preflight_preserves_repaired_image_references_in_snapshots_and_saves", "crates/cos/tests/it/generations.rs::repaired_header_references_survive_full_saves_and_dictionary_edits", "crates/cos/tests/it/generations.rs::incremental_generation_identity_is_visible_to_an_independent_reader", "crates/cos/tests/it/generations.rs::restoring_snapshot_preserves_changes_to_nonzero_generation_objects", "crates/cos/tests/it/generations.rs::overlay_generation_changes_do_not_retarget_old_references_or_clones", "crates/cos/tests/it/generations.rs::references_require_the_current_generation_even_with_a_warm_cache", "crates/cos/src/document/tests.rs::reads_classic_revision_and_edits_are_copy_on_write"] +notes = "Generation-aware references stay distinct across warm caches, edit overlays and snapshots, including restoration and incremental save/reopen of edited nonzero-generation objects. A damaged live xref row may additionally resolve the matching same-number object header; unrelated generations and free slots remain null. Repair-aware edits preserve both aliases and require full rewrite; explicit replacement keeps only its supplied generation. Synthetic preflight/snapshot and classic/packed full/incremental controls include an independent reader. Broader real-file fidelity remains tracked separately." [[feature]] id = "core.write-validity" @@ -734,8 +734,8 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it"] -notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value" +evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"] +notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat" [[feature]] id = "view.render-shadings-patterns" @@ -1154,8 +1154,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"] -notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" +evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it"] +notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" [[feature]] id = "view.goto-page-label" @@ -1778,8 +1778,8 @@ milestone = "M3" status = "partial" commands = ["help.check_updates"] tools = [] -evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks"] -notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved" +evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "crates/ui-egui/tests/it/updates.rs::turning_the_check_at_start_on_waits_for_the_next_start", "crates/ui-egui/tests/it/updates.rs::the_preference_keeps_the_check_at_start_from_reaching_the_network", "crates/ui-egui/tests/it/updates.rs::settings_from_the_old_opt_in_leave_the_check_at_start_on"] +notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved; Preferences ▸ Check for updates when PdfCraft starts (update_check_at_start, on by default) turns the check at start off, leaving only the request from the Help menu; turning it on takes effect at the next start" [[feature]] id = "view.single-key-accelerators" @@ -3758,8 +3758,20 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated"] -notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches. fill_sign_add accepts font_size for text and dates." +evidence = ["crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_size_controls_new_text_and_survives_settings_restore", "crates/automation/tests/it/automation.rs::fill_sign_text_font_size_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_border_frames_new_and_selected_text", "crates/annot/src/tests.rs::a_typewriter_border_goes_around_its_text_which_stays_put", "crates/automation/tests/it/automation.rs::fill_sign_text_border_is_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::the_toolbar_shows_and_changes_the_selected_texts_border", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets", "crates/annot/src/tests.rs::a_text_border_can_have_its_own_colour"] +notes = "Fill & Sign text and dates have a 4–300 pt size control in the active toolbar; new items use the chosen size, which persists across launches (with a placed text selected it resizes that text too). A Border control (none to 3 pt) frames new text or the selected text, in the text's colour or a Border colour of its own; the box grows outwards so the text keeps its place. With a placed text selected, the border controls show its own border and border colour (also after Comment Properties changed them) and change only that text; without one they set new text's until PdfCraft quits. Make Current Properties Default on typed text (its menus, or the box in Comment Properties) sets what new text gets, its size and border, kept across launches. Text boxes and callouts take a border colour too (quick bar, Comment Properties), the stroking colour in /DA. fill_sign_add accepts font_size, border and border_color for text and dates; comment_edit border_color." + +[[feature]] +id = "comment.text-border-shape" +title = "An oval or a circle around a text box's or typed text's text" +area = "E" +tier = "P2" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add", "comment_edit", "fill_sign_add", "comment_list"] +evidence = ["crates/annot/src/tests.rs::text_boxes_take_an_oval_or_a_circle_around_their_text", "crates/annot/src/tests.rs::a_circle_goes_snug_around_its_text_and_follows_it", "crates/annot/src/tests.rs::a_circle_touching_its_text_box_keeps_following_its_text", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_takes_an_oval_or_a_circle_from_the_toolbar", "crates/ui-egui/tests/it/comments.rs::text_box_properties_choose_the_border_shape", "crates/automation/tests/it/automation.rs::text_borders_take_an_oval_or_a_circle_through_the_tools"] +notes = "Comment Properties ▸ Border shape (text boxes, typewriter text) and Fill & Sign's toolbar beside Border (typed text and dates, for new text and the selected one, and showing the selected one's) choose Rectangle, Oval or Circle: a position number in a circle, a label in an oval on a plan. The text box keeps its place and the shape goes around it, centred: an oval through its corners (√2 its size), a point clear of the border; the text is centred in it. A circle goes snug around its text: the text box is no wider than its text needs (typed text with a circle skips the 40 pt a box to type into keeps; choosing Circle shrinks an existing box to its lines), the circle a point clear of the text's lines and touching the box's longer sides, so a 10 pt \"3\" gets a 20 pt circle; a snug circle follows its text and font size, one made larger by hand keeps its size. /Rect grows to hold the shape and /RD keeps the text box inside it (§12.5.6.6, as a callout's; 0 where a circle touches the box, a hair below 0 read as 0), with PdfCraft's /PCBorderShape; background and border take the shape, the appearance draws it so every viewer shows it, and an app that redraws the comment from its keys draws a rectangle. Resizing the frame takes the text box along (a circle's keeping its proportions); a thicker border leaves the text where it is. Make Properties Default carries it into the Text box tool, and on typed text into Fill & Sign's new text. Headless: comment_add / comment_edit / fill_sign_add `border_shape` (rectangle, oval, circle); comment_list reports an oval or a circle. Callouts keep a rectangle. Acrobat's text boxes have a rectangular border only." [[feature]] id = "comment.text-box" @@ -3878,8 +3890,8 @@ milestone = "M5" status = "shipped" commands = ["comment.polyline"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] -notes = "" +evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/it/comments.rs::connected_lines_are_completed_from_the_context_menu", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] +notes = "Click the corners; Enter, a double-click or the right-click menu's Complete finishes the lines (the menu also takes the last point back and cancels the drawing), Backspace takes a point back." [[feature]] id = "comment.cloud" @@ -3965,6 +3977,18 @@ tools = ["comment_edit"] evidence = ["crates/ui-egui/tests/it/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/it/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/it/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/it/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/it/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/it/automation.rs::text_box_font_size_through_the_tool"] notes = "Appearance (colour, fill colour for rectangles/ovals/polygons/text boxes/callouts, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)" +[[feature]] +id = "comment.part-opacity" +title = "A comment's text, border or line, and fill each at an opacity of its own" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add", "comment_edit", "comment_list"] +evidence = ["crates/annot/src/tests.rs::parts_are_drawn_at_opacities_of_their_own", "crates/ui-egui/tests/it/comments.rs::comment_properties_set_each_parts_opacity", "crates/automation/tests/it/automation.rs::comment_parts_take_opacities_of_their_own_through_the_tools", "crates/ui-egui/tests/it/ui.rs::part_opacity_defaults_persist_and_clamp"] +notes = "Comment Properties shows an opacity for each part instead of one for the whole comment: a text box, typewriter text or callout its Text, Border and Fill opacity, a rectangle, oval, polygon or cloud its Line and Fill opacity (a part that isn't drawn, such as an unset fill, is greyed); lines, drawings and the rest keep one Opacity. A see-through fill with an opaque line marks an area on a plan without hiding it. Stored as the line's /CA, the fill's /ca (PDF 2.0's opacity for what is filled) and the text's /PCTextOpacity (PdfCraft's own), each only where it differs, and drawn with two graphics states in the appearance, so every viewer shows it; an app that redraws the comment from its keys (Acrobat after editing it) uses one opacity again. The quick bar's Opacity and comment_edit `opacity` set the whole comment's; Make Properties Default carries the parts into the tool (saved with the settings). Headless: comment_add / comment_edit `fill_opacity`, `text_opacity`; comment_list reports `opacity`, `fill_opacity`, `text_opacity`. Acrobat has one opacity per comment." + [[feature]] id = "comment.properties-bar" title = "Comment properties bar" @@ -3998,8 +4022,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = [] -evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] -notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" +evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default", "crates/ui-egui/tests/it/fill_sign.rs::typed_text_made_default_is_what_new_text_gets"] +notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340). Typed text, which no comment tool makes, gives Fill & Sign's new text its size and border" [[feature]] id = "comment.keep-tool-selected" @@ -4095,7 +4119,19 @@ status = "shipped" commands = [] tools = ["comment_edit"] evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::comments_are_reshaped_through_the_tool"] -notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." +notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it level or plumb with the point before, comment.draw-ortho), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner." + +[[feature]] +id = "comment.draw-ortho" +title = "Shift holds lines level or plumb while drawing (ortho)" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_add"] +evidence = ["crates/measure/src/tests.rs::ortho_holds_a_vertex_level_or_plumb_with_the_last", "crates/ui-egui/tests/it/comments.rs::shift_holds_new_lines_and_sides_level_or_plumb", "crates/ui-egui/tests/it/comments.rs::the_rubber_band_shows_the_closing_side_with_shift_held", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::lines_and_corners_are_held_level_or_plumb_through_the_tool"] +notes = "With Shift held, a new line or arrow runs level or plumb from where it starts, whichever is nearer to the pointer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before, the rubber band included. Over the first corner, where a click closes a polygon or cloud, the closing side runs straight to that corner, Shift or not. A dragged end or corner of a drawn one is held the same way (comment.reshape). Acrobat holds lines to 45° steps instead; on drawings and plans the level and plumb lines are the ones wanted. Headless: comment_add `ortho`. Measuring tools keep their 45° steps." [[feature]] id = "comment.move-preview" @@ -4118,8 +4154,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays", "crates/ui-egui/tests/it/comments.rs::undo_and_redo_close_a_reply_editor_left_open"] -notes = "double-click a comment or card, or Edit in the menus (a reply from its own \"…\" menu); Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines). Undo or Redo closes the panel's editor without saving it, since either can move the comment it was open on" +evidence = ["crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn", "crates/annot/src/tests.rs::editing_a_text_box_refits_its_rectangle", "crates/automation/tests/it/automation.rs::comments_through_tools", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_highlight_comment_and_shift_enter_adds_a_line", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays", "crates/ui-egui/tests/it/comments.rs::undo_and_redo_close_a_reply_editor_left_open", "crates/ui-egui/tests/it/comments.rs::double_clicking_a_cards_text_edits_it"] +notes = "double-click a comment or card (its header or its text), or Edit in the menus; double-click a comment or card, or Edit in the menus (a reply from its own \"…\" menu); Ctrl+Enter (also Command+Enter) saves text in the focused comment editor, Shift+Enter adds a newline; a text box's rectangle follows the new text (wrap width and top edge stay, the height fits the wrapped lines). Undo or Redo closes the panel's editor without saving it, since either can move the comment it was open on" [[feature]] id = "comment.copy-comment-text" @@ -4319,11 +4355,35 @@ title = "Export selected comments" area = "E" tier = "P2" milestone = "M5" -status = "planned" +status = "partial" +commands = ["comment.copy"] +tools = ["comments_copy"] +evidence = ["crates/xfdf/src/tests.rs::exporting_selected_comments_takes_them_and_their_replies", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools", "crates/xfdf/src/tests.rs::copies_keep_their_intent_cloud_and_pdfcraft_keys"] +notes = "The selected comments, each with its replies, as XFDF: Copy puts it on the clipboard (desktop) and comments_copy returns it headlessly. Each keeps what it is meant as (`intent`: typewriter text, a cloud, an arrow), a cloudy border (`style`, `intensity`; both also in File ▸ Export comments) and PdfCraft's own keys (`pdfcraft:keys`, in a namespace of its own: a Fill & Sign item, a turned comment's box and angle), so a paste is the same comment. Not yet: a Save as… to a data file from the interface." + +[[feature]] +id = "comment.multi-select" +title = "Select several comments (⌘-click, ⇧-click)" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/it/comments.rs::ctrl_click_selects_several_comments_and_delete_removes_them_all", "crates/ui-egui/tests/it/comments.rs::properties_change_every_selected_comment_where_it_applies", "crates/ui-egui/tests/it/comments.rs::dragging_any_selected_comment_moves_the_whole_selection", "crates/ui-egui/src/tools/comments/tests.rs::shift_click_selects_the_range_from_the_comment_with_the_handles"] +notes = "On the page, ⌘-click (Ctrl on Windows and Linux) or ⇧-click adds a comment to the selection or takes it out again; in the Comments panel ⌘/Ctrl-click does, and ⇧-click selects the cards from the one with the handles to the one clicked. The one clicked last has the handles and is what Reply acts on. Delete (key or menu) removes every selected comment as one undo step, dragging one of them moves them all (text markup stays with its text, a locked one where it is), and the context menu copies them all. Properties (opened on one of them), the context menu's Colour and the quick bar's colour change every selected comment as one undo step, each where the property applies (a font size to text, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Escape and a plain click select one alone again. Not yet: a rubber-band (marquee) selection on the page." + +[[feature]] +id = "comment.copy-paste" +title = "Copy and paste comments, within and between documents" +area = "E" +tier = "P1" +milestone = "M5" +status = "shipped" +commands = ["comment.copy", "comment.paste"] +tools = ["comments_copy", "comments_paste"] +evidence = ["crates/ui-egui/tests/it/comments.rs::copied_comments_paste_into_another_document", "crates/ui-egui/tests/it/comments.rs::copied_comments_take_the_click_from_the_tool_that_is_on", "crates/engine/src/tests.rs::pasting_copied_comments_adds_copies_that_undo_as_one_step", "crates/xfdf/src/tests.rs::importing_comments_as_copies_adds_them_on_the_page_with_fresh_names", "crates/xfdf/src/tests.rs::copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools"] +notes = "⌘C with comments selected (and no text selected) copies them as XFDF, each with its replies, to the app's clipboard (every tab and window) and as text to the system clipboard. ⌘V or Edit ▸ Paste comments hangs the copies on the pointer as one bundle (their box, with a count, and each comment's outline in its colour: Fill & Sign ticks, crosses, dots and lines as themselves, text boxes with their text); a click places them, centred there, on the page clicked (whatever tool is on: Fill & Sign's Text tool opens no box, a stamp tool places no stamp), so a copy never lands on its original and a bundle from an A0 page goes where it fits on an A4 one (placed as it is when larger than the page, with a notice); Escape or a right-click lets the bundle go. The page's context menu places the bundle at the point the menu was opened at. The copies are new comments under names of their own (nothing is replaced; pasting twice gives two sets) and are selected afterwards. XFDF with comments on the system clipboard pastes too, so comments travel between PdfCraft processes. The Comments panel's card menu copies that card's comment. Headless: comments_copy / comments_paste (`at` places the bundle's centre), undoable as one step. Not yet: appearance streams (the copies are drawn again), file attachments' data (the attachment comment is copied without its file), pasting in the web build's other tabs through the system clipboard." [[feature]] id = "comment.import-from-pdf" @@ -5423,8 +5483,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"] -notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" +evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated", "crates/ui-egui/tests/it/fill_sign.rs::escape_leaves_the_fill_and_sign_tools"] +notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Escape closes the open box, and a second Escape leaves the Fill & Sign tool for Select, as it leaves a comment tool. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" [[feature]] id = "form.fill-sign-marks" @@ -6695,8 +6755,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample" @@ -6753,10 +6813,10 @@ area = "I" tier = "P1" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: from clean-up level 60, isolated dark pixels are removed. Missing: for scanned pages and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::despeckle_is_its_own_switch", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans inside form XObjects (reported, not changed)." [[feature]] id = "ocr.background-removal" @@ -6765,10 +6825,10 @@ area = "I" tier = "P1" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: Enhance camera image's clean-up level (and page_enhance_camera level) evens out the paper's illumination per channel, so the background turns white and colour casts go; colour ink stays. Missing: the same for scanned pages and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::a_scan_gets_white_paper_even_edges_dark_strokes_and_no_specks_in_place", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images (Enhance camera image level) and scanned pages (Enhance scanned file ▸ Background removal, page_enhance_scan level): the paper's illumination is evened out per channel so a grey, yellowed or unevenly lit background turns white, then the contrast is raised; colour ink stays. Scans are cleaned in place in their own image objects (JPEG stays JPEG at quality 90, others lossless), so the OCR text layer, comments and links stay; one undo step. Missing: as an option of Recognize text; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans inside form XObjects (reported, not changed); a preview." [[feature]] id = "ocr.descreen" @@ -6801,10 +6861,10 @@ area = "I" tier = "P2" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: the clean-up level divides out a smooth illumination estimate, which removes shadows and darker edges on the straightened photo. Missing: for scanned pages (book gutters) and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images (the clean-up level divides out a smooth illumination estimate) and scanned pages (Enhance scanned file ▸ Edge shadow removal, page_enhance_scan edge_shadows): within the outer fifth of each side, every column and row is lightened to the brightest paper of that band, which removes book gutters and copier borders the coarse estimate misses; a black scanner border is left alone. Missing: as an option of Recognize text; curved gutters that also bend the text (no dewarping); the scan formats background removal also skips." [[feature]] id = "ocr.camera-perspective" @@ -9920,11 +9980,11 @@ title = "View 3D (U3D/PRC) content" area = "N" tier = "P3" milestone = "M12" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["model_render"] +evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] +notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" [[feature]] id = "misc.3d-add" diff --git a/vendor/README.md b/vendor/README.md index e1a5676254..7ae867cb11 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -40,7 +40,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated image whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared` | diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 0b9cfe779e..1e46e3b79e 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -193,6 +193,67 @@ fn image_buffer(len: usize) -> Option> { Some(out) } +/// PdfCraft patch: `mask` sampled onto the grid of a `width` × `height` image (no side smaller +/// than the mask's). Each image sample takes the mask sample under its centre or, for a mask that +/// asks for `/Interpolate`, the bilinear blend of the four around it, so the mask's edges stay as +/// smooth as when it was enlarged on its own. `None` for an empty side, an image side smaller than +/// the mask's, or a buffer over the limits. +fn mask_on_image_grid(mask: &LumaData, width: u32, height: u32) -> Option> { + if width == 0 || height == 0 || mask.width == 0 || mask.height == 0 { + return None; + } + let mut out = image_buffer(source_byte_len(width, height, 1, MAX_IMAGE_PIXELS)?)?; + let columns = grid_axis(mask.width, width, mask.interpolate)?; + let rows = grid_axis(mask.height, height, mask.interpolate)?; + let mask_width = mask.width as usize; + let row = |y: usize| { + let start = y.checked_mul(mask_width)?; + mask.data.get(start..start.checked_add(mask_width)?) + }; + let one = u64::from(GRID_ONE); + for &(y0, y1, fy) in &rows { + let (top, bottom, fy) = (row(y0)?, row(y1)?, u64::from(fy)); + for &(x0, x1, fx) in &columns { + let fx = u64::from(fx); + let blend = |r: &[u8]| Some(u64::from(*r.get(x0)?) * (one - fx) + u64::from(*r.get(x1)?) * fx); + // At most 255 × GRID_ONE², so the sum fits in a u64 and the result in a u8. + let v = (blend(top)? * (one - fy) + blend(bottom)? * fy + one * one / 2) / (one * one); + out.push(v.min(255) as u8); + } + } + Some(out) +} + +/// PdfCraft patch: fixed-point unit of [`grid_axis`]'s blend weights. +const GRID_ONE: u32 = 1 << 16; + +/// PdfCraft patch: for each of `dst` samples (`dst >= src`) taken from `src` along one axis, the +/// two source samples it lies between and the weight of the second, in [`GRID_ONE`] units. +/// Centres line up: target `t` lies at `(t + ½)·src/dst − ½` source samples, held to the ends. +/// Without `smooth` both are the nearest sample, so the weight does not matter. +fn grid_axis(src: u32, dst: u32, smooth: bool) -> Option> { + if src == 0 || dst < src { + return None; + } + let mut out = Vec::new(); + out.try_reserve_exact(dst as usize).ok()?; + let last = u64::from(src - 1) << 16; + for t in 0..dst { + if !smooth { + let s = nearest_source_index(src, dst, t); + out.push((s, s, 0)); + continue; + } + // In u128, as `(2t + 1)·src·2^16` can pass 2^64 for a long side; the quotient is at + // most `src·2^16`, so it fits back in a u64. + let centre = ((2 * u128::from(t) + 1) * u128::from(src) << 16) / (2 * u128::from(dst)); + let at = u64::try_from(centre).ok()?.saturating_sub(u64::from(GRID_ONE / 2)).min(last); + let s = (at >> 16) as usize; + out.push((s, (s + 1).min(src as usize - 1), (at & 0xFFFF) as u32)); + } + Some(out) +} + /// PdfCraft patch (#624): the source index that PicScale's nearest-neighbour plan copies for /// destination `index` of `dst` samples taken from `src`. It is the same fixed-point centre /// sampling as `ResampleNearestPlan`, so these are the samples a nearest resize would copy. @@ -236,6 +297,255 @@ fn nearest_premultiplied_rgba( Some(out) } +/// PdfCraft patch: the shorter side, in samples, from which an image that did not ask for +/// `/Interpolate` is still smoothed when enlarged. A page scanned at 72 dpi is 612 samples wide; +/// icons, QR codes and pixel art are usually far smaller and keep their hard pixels. +const SMOOTHED_IMAGE_SIDE: u32 = 512; + +/// PdfCraft patch: the scale above which such an image counts as enlarged. A scan drawn at its own +/// resolution can come out a few ulps above 1 (the scale is an `f32`: a 192 dpi scan on a 2× +/// display at 100%), and sampled bilinearly at a half-pixel offset it blurred by half a pixel. +/// Below 1% nearest-neighbour repeats at most one sample in a hundred. +const SMOOTHED_IMAGE_SCALE: f32 = 1.01; + +/// PdfCraft patch: fixed-point unit of [`area_shrink`]'s weights. +const AREA_ONE: u64 = 1 << 16; + +/// PdfCraft patch: the source samples target sample `t` covers when `src` samples shrink to `dst` +/// (`dst <= src`), with their weights in [`AREA_ONE`] units. Target `t` spans `[t·src, (t+1)·src)` +/// and source `s` spans `[s·dst, (s+1)·dst)`, both in units of 1/(src·dst) of the axis, so every +/// overlap is exact. The weights are differences of one rounded running sum, so they add up to +/// exactly [`AREA_ONE`] and a flat area keeps its exact value. +fn area_taps(src: u32, dst: u32, t: u32) -> impl Iterator { + let (src, dst, t) = (u64::from(src), u64::from(dst), u64::from(t)); + // `t < dst <= src <= 2^28` (MAX_IMAGE_PIXELS), so every product below fits in a u64. + let (lo, hi) = (t * src, (t + 1) * src); + let fixed = move |x: u64| (x * AREA_ONE + src / 2) / src; + (lo / dst..hi.div_ceil(dst)).map(move |s| { + let from = (s * dst).max(lo) - lo; + let to = ((s + 1) * dst).min(hi) - lo; + (s as usize, fixed(to) - fixed(from)) + }) +} + +/// PdfCraft patch: [`area_taps`] of every target sample of one axis, worked out once: for each +/// target, the first source sample it covers and the weights of the samples from there on (the +/// samples a target covers are consecutive). There are at most `src + dst` taps in all. +struct AreaAxis { + first: Vec, + /// Where each target's weights start in `weights`, and where the last one's end. + offsets: Vec, + weights: Vec, +} + +impl AreaAxis { + fn new(src: u32, dst: u32) -> Option { + let n = dst as usize; + let (mut first, mut offsets, mut weights) = (Vec::new(), Vec::new(), Vec::new()); + first.try_reserve_exact(n).ok()?; + offsets.try_reserve_exact(n.checked_add(1)?).ok()?; + weights.try_reserve_exact((src as usize).checked_add(n.checked_mul(2)?)?).ok()?; + offsets.push(0); + for t in 0..dst { + let mut start = None; + for (s, w) in area_taps(src, dst, t) { + start.get_or_insert(s); + // A weight is at most AREA_ONE, so it fits in a u32. + weights.push(w as u32); + } + first.push(start?); + offsets.push(weights.len()); + } + Some(Self { first, offsets, weights }) + } + + /// Target `t`'s first source sample and its weights. + fn get(&self, t: usize) -> Option<(usize, &[u32])> { + let (lo, hi) = (*self.offsets.get(t)?, *self.offsets.get(t.checked_add(1)?)?); + Some((*self.first.get(t)?, self.weights.get(lo..hi)?)) + } +} + +/// PdfCraft patch: shrinks `N`-channel samples from `source` to `target` (no side larger than its +/// source side) by area averaging: each target sample is the mean of the source it covers, +/// weighted by how much of it it covers. Nothing is invented between samples, which is what +/// `/Interpolate` governs (ISO 32000-2 §8.9.5.3, for images drawn above their resolution), and +/// nothing is dropped: nearest-neighbour keeps one sample in N per axis, so a scan shown at screen +/// size lost the strokes of its text. Premultiplied input stays premultiplied. `None` if nothing +/// shrinks, the sizes don't fit or a buffer cannot be allocated. +fn area_shrink(data: &[u8], source: (u32, u32), target: (u32, u32)) -> Option> { + if data.len() != source_byte_len(source.0, source.1, N, MAX_IMAGE_PIXELS)? { + return None; + } + let row_len = (source.0 as usize).checked_mul(N)?; + area_shrink_rows::(source, target, |s, row| { + let start = s.checked_mul(row_len)?; + row.copy_from_slice(data.get(start..start.checked_add(row_len)?)?); + Some(()) + }) +} + +/// PdfCraft patch: [`area_shrink`] of a colour image under its same-size soft mask (a stencil's +/// ink under the stencil, or an image under a mask sampled onto its grid), premultiplied a source +/// row at a time as it is averaged. The result equals premultiplying the expanded RGBA and +/// shrinking that, without building it: a 10718 × 13701 stencil shown at page size peaked at +/// 1.16 GB with the full-resolution RGBA, which is 587 MB. +fn area_shrink_premultiplied( + color: &ImageData, + alpha: &[u8], + source: (u32, u32), + target: (u32, u32), +) -> Option> { + let (samples, channels) = match color { + ImageData::Luma(luma) => (luma.data.as_slice(), 1), + ImageData::Rgb(rgb) => (rgb.data.as_slice(), 3), + }; + if samples.len() != source_byte_len(source.0, source.1, channels, MAX_IMAGE_PIXELS)? + || alpha.len() != source_byte_len(source.0, source.1, 1, MAX_IMAGE_PIXELS)? + { + return None; + } + let width = source.0 as usize; + area_shrink_rows::<4>(source, target, |s, row| { + let start = s.checked_mul(width)?; + let end = start.checked_add(width)?; + let colors = samples.get(start.checked_mul(channels)?..end.checked_mul(channels)?)?; + let alphas = alpha.get(start..end)?; + for ((out, px), a) in row.chunks_exact_mut(4).zip(colors.chunks_exact(channels)).zip(alphas) { + let [r, g, b] = match px { + [v] => [*v; 3], + [r, g, b] => [*r, *g, *b], + _ => return None, + }; + out.copy_from_slice(&AlphaColor::from_rgba8(r, g, b, *a).premultiply().to_rgba8().to_u8_array()); + } + Some(()) + }) +} + +/// PdfCraft patch: the area shrink behind [`area_shrink`], reading source row `s` through +/// `read_row(s, row)` into a buffer of one row. Each target row averages the source rows it +/// covers, and that row is shrunk across at once with taps worked out once per image, so work +/// memory is one source row of samples, one of `u32` accumulators, one shrunk row and the +/// horizontal taps (at most `sw + 2 × tw`): never the image. A side over +/// `MAX_RESAMPLING_SOURCE_SIDE` is refused, so none of it exceeds a few times one source row. +fn area_shrink_rows( + source: (u32, u32), + target: (u32, u32), + mut read_row: impl FnMut(usize, &mut [u8]) -> Option<()>, +) -> Option> { + let ((sw, sh), (tw, th)) = (source, target); + // A side over MAX_RESAMPLING_SOURCE_SIDE would make the accumulator row and the tap table + // several times larger than the samples (a 2^27 × 2 image of zeros is a tiny file). + if tw == 0 + || th == 0 + || tw > sw + || th > sh + || (tw, th) == (sw, sh) + || sw > MAX_RESAMPLING_SOURCE_SIDE + || sh > MAX_RESAMPLING_SOURCE_SIDE + { + return None; + } + let row_len = (sw as usize).checked_mul(N)?; + let zeroed = |len: usize| { + let mut v = image_buffer(len)?; + v.resize(len, 0); + Some(v) + }; + let (mut source_row, mut shrunk_row) = (zeroed(row_len)?, image_buffer(row_len)?); + let mut acc: Vec = Vec::new(); + acc.try_reserve_exact(row_len).ok()?; + acc.resize(row_len, 0); + // The taps depend only on the widths, so they are worked out once, not again for each row. + let taps = if tw == sw { None } else { Some(AreaAxis::new(sw, tw)?) }; + let out_len = match taps { + None => source_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?, + Some(_) => image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?, + }; + let mut out = image_buffer(out_len)?; + // Weights sum to AREA_ONE (2^16) and samples are at most 255, so every sum fits in a u32. + let round = |acc: u32| ((acc + (AREA_ONE as u32) / 2) >> 16).min(255) as u8; + // The last source row read: neighbouring target rows share the row on their boundary. + let mut loaded = None; + for t in 0..th { + // Vertical: the whole source rows target row `t` covers, averaged. + let row: &[u8] = if th == sh { + read_row(t as usize, &mut source_row)?; + &source_row + } else { + acc.fill(0); + for (s, w) in area_taps(sh, th, t) { + if loaded != Some(s) { + read_row(s, &mut source_row)?; + loaded = Some(s); + } + // A weight is at most AREA_ONE, so it fits in a u32. + let w = w as u32; + for (a, v) in acc.iter_mut().zip(&source_row) { + *a += w * u32::from(*v); + } + } + shrunk_row.clear(); + shrunk_row.extend(acc.iter().map(|a| round(*a))); + &shrunk_row + }; + // Horizontal: that row shrunk to `tw` samples. + let Some(taps) = &taps else { + out.extend_from_slice(row); + continue; + }; + for x in 0..tw as usize { + let (first, weights) = taps.get(x)?; + let samples = row.get(first * N..(first + weights.len()) * N)?; + let mut acc = [0_u32; N]; + for (w, px) in weights.iter().zip(samples.chunks_exact(N)) { + for (a, v) in acc.iter_mut().zip(px) { + *a += w * u32::from(*v); + } + } + out.extend(acc.map(round)); + } + } + Some(out) +} + +/// PdfCraft patch: [`area_shrink`] for an image's pixel format. If it cannot run, the image keeps +/// its source resolution, as when [`Renderer::resize_image_data`] fails. +fn area_resize( + data: Vec, + source: (u32, u32), + target: (u32, u32), + format: ImagePixelFormat, +) -> (Vec, u32, u32) { + let shrunk = match format { + ImagePixelFormat::Luma => area_shrink::<1>(&data, source, target), + ImagePixelFormat::Rgb => area_shrink::<3>(&data, source, target), + ImagePixelFormat::Rgba => area_shrink::<4>(&data, source, target), + }; + match shrunk { + Some(out) => (out, target.0, target.1), + None => { + log::warn!( + "Image area shrink failed or exceeded its buffer limit; falling back to source data" + ); + (data, source.0, source.1) + } + } +} + +/// PdfCraft patch: premultiplies straight RGBA samples in place. +fn premultiply_rgba(rgba: &mut [u8]) { + let (chunks, _) = rgba.as_chunks_mut::<4>(); + for chunk in chunks { + let [r, g, b, a] = *chunk; + *chunk = AlphaColor::from_rgba8(r, g, b, a) + .premultiply() + .to_rgba8() + .to_u8_array(); + } +} + /// PdfCraft patch: most pieces one dashed stroke may be cut into. Stroke expansion keeps every /// dash, so a pattern that is tiny next to its path asks for billions: a fuzzed `/D [[11] 0]` on /// a line from x = 92234775807 allocated over 5 GB, and `[0.000001] 0 d` on a 20 pt line over @@ -423,9 +733,12 @@ fn snap_minified_image( planned: (u32, u32), ) -> Option<(u32, u32, Affine)> { let [a, b, c, d, e, f] = transform.as_coeffs(); + // Halves round up, not away from zero: a tile's origin moves edges by whole pixels, and only + // then do they snap where the whole page snaps them (-2.5 must go to -2 as 2.5 goes to 3). + let snap = |v: f64| (v + 0.5).floor(); // The device span of a source edge from `origin` across `side` samples of `scale`, rounded. let edge = |origin: f64, scale: f64, side: u32| { - let (p, q) = (origin.round(), (origin + scale * f64::from(side)).round()); + let (p, q) = (snap(origin), snap(origin + scale * f64::from(side))); let (lo, hi) = (p.min(q), p.max(q)); (hi - lo >= 1.0 && lo.abs() < 1e6 && hi.abs() < 1e6).then_some((lo, hi)) }; @@ -935,17 +1248,51 @@ impl Renderer { return; } let interpolate = image_data.interpolate(); - if let Some(a) = &alpha_data { + let requested_resize = x_scale < 1.0 || y_scale < 1.0; + // PdfCraft patch: an image that did not ask for interpolation and is drawn below its + // resolution is shrunk by area averaging (`area_shrink`), not nearest-neighbour, which + // broke up the text of scans shown at screen size. Type 3 glyph images keep the nearest + // shrink of #624: they are drawn with a frame of their own and never snapped. + let area = !interpolate + && requested_resize + && !self.in_type3_glyph + // Wider sources keep #624's bounded nearest-neighbour shrink (`area_shrink` refuses + // them): skipping them, as a refused shrink at source size would, loses the image. + && source_width.max(source_height) <= MAX_RESAMPLING_SOURCE_SIDE; + let mut alpha_data = alpha_data; + if let Some(a) = alpha_data.take() { if source_byte_len(a.width, a.height, 1, MAX_IMAGE_PIXELS) != Some(a.data.len()) { log::warn!("Image has invalid or over-limit alpha dimensions/data and was skipped"); return; } if a.width != source_width || a.height != source_height || a.interpolate != interpolate { - if let Some(alpha) = alpha_data { - self.draw_image_with_alpha_mask(image_data, alpha); + // PdfCraft patch: a soft mask no larger than its area-shrunk image is sampled onto + // the image's grid, so colour and mask are averaged together, premultiplied. Drawn + // as a mask layer over the image instead, the averaged colour (transparent samples + // included) was multiplied by the averaged mask, and the colour of transparent + // samples showed at the mask's edges. A larger mask carries detail the image does + // not (#1649) and keeps the layer. + let on_grid = (area && a.width <= source_width && a.height <= source_height) + .then(|| mask_on_image_grid(&a, source_width, source_height)) + .flatten(); + match on_grid { + Some(data) => { + alpha_data = Some(LumaData { + data, + width: source_width, + height: source_height, + interpolate, + ..a + }); + } + None => { + self.draw_image_with_alpha_mask(image_data, a); + return; + } } - return; + } else { + alpha_data = Some(a); } } let mut quality = if interpolate { @@ -955,7 +1302,6 @@ impl Renderer { }; let has_alpha = alpha_data.is_some(); let mut may_have_transparency = has_alpha || self.force_images_may_have_transparency(); - let requested_resize = x_scale < 1.0 || y_scale < 1.0; let (new_width, new_height) = match image_resampling_size( source_width, source_height, @@ -985,12 +1331,28 @@ impl Renderer { if requested_resize && self.in_type3_glyph && interpolate { quality = ImageQuality::High; } - // PdfCraft patch: an interpolated image that is shrunk, with its axes along the device's - // (upright, mirrored or a quarter turn), lands on whole device pixels. It is shrunk once to - // exactly its snapped device rectangle and drawn 1:1 from the snapped corner. Otherwise the - // shrunk raster (rounded up to a whole size) is sampled a second time at a fractional scale - // and offset, which blurs scans and photos. - let snapped = if interpolate + // PdfCraft patch: an image the size of a page scan is smoothed when drawn above its + // resolution, even without `/Interpolate`: bilinearly, as images that ask for it are. + // Scans rarely ask, and drawn nearest-neighbour each sample of a zoomed scan became a + // block, so the edges of its text were staircases. Bicubic looked the same on a zoomed + // 300 dpi scan and drew its tiles five times slower. Smaller images (icons, QR codes, + // pixel art) keep their hard pixels. A scan shrunk on one side and stretched on the other + // is smoothed too: the area shrink leaves the shrunk side at 1:1, where bilinear adds + // nothing, and the stretched side would otherwise be drawn nearest-neighbour. + if !interpolate + && !self.in_type3_glyph + && (x_scale > SMOOTHED_IMAGE_SCALE || y_scale > SMOOTHED_IMAGE_SCALE) + && source_width.min(source_height) >= SMOOTHED_IMAGE_SIDE + { + quality = ImageQuality::Medium; + } + // PdfCraft patch: an image that is shrunk (filtered or area-averaged), with its axes along + // the device's (upright, mirrored or a quarter turn), lands on whole device pixels. It is + // shrunk once to exactly its snapped device rectangle and drawn 1:1 from the snapped + // corner. Otherwise the shrunk raster (rounded up to a whole size) is sampled a second time + // at a fractional scale and offset, which blurs scans and photos (or, by the nearest + // sampler of a non-interpolated image, drops samples again). + let snapped = if (interpolate || area) && requested_resize && !self.in_type3_glyph && (new_width, new_height) != (source_width, source_height) @@ -1000,13 +1362,31 @@ impl Renderer { None }; let (new_width, new_height) = snapped.map_or((new_width, new_height), |s| (s.0, s.1)); + // PdfCraft patch: an area shrink never enlarges. A side drawn at or above its resolution + // (an image shrunk across and stretched down, as a 204 × 98 dpi fax is, or a snapped side + // rounded past its source) keeps its samples and is enlarged by the sampler; the snap + // then no longer matches, so the full transform places the image. + let (new_width, new_height) = if area { + (new_width.min(source_width), new_height.min(source_height)) + } else { + (new_width, new_height) + }; let needs_resize = (new_width, new_height) != (source_width, source_height); - // PdfCraft patch (#624): a minified image with a soft mask that is not interpolated is - // nearest-sampled straight into premultiplied target RGBA. That skips the full-resolution - // RGBA copy below and the premultiply pass after it. + // PdfCraft patch (#624): a minified image with a soft mask that is neither interpolated nor + // area-averaged (a Type 3 glyph image, or a source wider than MAX_RESAMPLING_SOURCE_SIDE) + // is nearest-sampled straight into premultiplied target RGBA. That skips the + // full-resolution RGBA copy below and the premultiply pass after it. PdfCraft patch: an + // area-averaged one (a stencil's ink, or an image under its mask) skips them too, and is + // premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`). let direct = match &alpha_data { - Some(alpha) if needs_resize && !interpolate => nearest_premultiplied_rgba( + Some(alpha) if needs_resize && !interpolate && !area => nearest_premultiplied_rgba( + &image_data, + &alpha.data, + (source_width, source_height), + (new_width, new_height), + ), + Some(alpha) if needs_resize && area => area_shrink_premultiplied( &image_data, &alpha.data, (source_width, source_height), @@ -1014,7 +1394,7 @@ impl Renderer { ), _ => None, }; - let premultiplied = direct.is_some(); + let mut premultiplied = direct.is_some(); // Preserve the single-channel/RGB fast paths. With alpha, expand directly to RGBA; // every source and alpha length was validated before zipping or allocating. let (data, format) = match (direct, image_data, alpha_data) { @@ -1057,6 +1437,15 @@ impl Renderer { }; let (data, mut img_width, mut img_height) = if premultiplied { (data, new_width, new_height) + } else if needs_resize && area { + let mut data = data; + if matches!(format, ImagePixelFormat::Rgba) { + // Averaging straight colours would bleed the colour of transparent samples into + // their neighbours; premultiplied colours count each by its coverage. + premultiply_rgba(&mut data); + premultiplied = true; + } + area_resize(data, (source_width, source_height), (new_width, new_height), format) } else if needs_resize { self.resize_image_data( data, @@ -1103,14 +1492,7 @@ impl Renderer { rgba }; if has_alpha && !premultiplied { - let (chunks, _) = rgba_data.as_chunks_mut::<4>(); - for chunk in chunks { - let [r, g, b, a] = *chunk; - *chunk = AlphaColor::from_rgba8(r, g, b, a) - .premultiply() - .to_rgba8() - .to_u8_array(); - } + premultiply_rgba(&mut rgba_data); } // Type 3 glyphs get a transparent two-pixel frame for interpolation. Check the padded // RGBA allocation and u16 dimensions too, before narrowing or growing either side. @@ -2635,3 +3017,201 @@ mod snap_tests { assert!(snap_minified_image(&turned_noisy, source, (20_000, 200)).is_some()); } } + +/// PdfCraft patch: the area-averaging shrink of non-interpolated images. +#[cfg(test)] +mod area_shrink_tests { + use super::{ + AREA_ONE, AreaAxis, ImageData, LumaData, MAX_IMAGE_PIXELS, RgbData, area_shrink, + area_shrink_premultiplied, area_taps, mask_on_image_grid, premultiply_rgba, + }; + + /// Deterministic samples that vary from one to the next. + fn noise(len: usize, seed: u32) -> Vec { + let mut x = seed.wrapping_mul(2_654_435_761) | 1; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + (x >> 24) as u8 + }) + .collect() + } + + /// The shrink as two whole passes, every row averaged down and then every row across, each + /// rounded to a byte: what `area_shrink` did before it worked a row at a time. + fn two_pass_shrink(data: &[u8], (sw, sh): (u32, u32), (tw, th): (u32, u32)) -> Vec { + let round = |acc: u64| ((acc + AREA_ONE / 2) >> 16).min(255) as u8; + let row_len = sw as usize * N; + let tall: Vec = (0..th) + .flat_map(|t| { + (0..row_len).map(move |i| { + round(area_taps(sh, th, t).map(|(s, w)| w * u64::from(data[s * row_len + i])).sum()) + }) + }) + .collect(); + tall.chunks_exact(row_len) + .flat_map(|row| { + (0..tw).flat_map(move |t| { + (0..N).map(move |c| round(area_taps(sw, tw, t).map(|(s, w)| w * u64::from(row[s * N + c])).sum())) + }) + }) + .collect() + } + + /// Working a row at a time gives the same bytes as two whole passes, in both directions, one + /// direction alone, and with source rows shared by neighbouring targets. + #[test] + fn a_row_at_a_time_shrink_equals_two_whole_passes() { + let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((13, 11), (1, 1)), ((31, 17), (12, 7))]; + for (i, (source, target)) in sizes.into_iter().enumerate() { + let gray = noise((source.0 * source.1) as usize, i as u32); + assert_eq!(area_shrink::<1>(&gray, source, target), Some(two_pass_shrink::<1>(&gray, source, target)), "{source:?} → {target:?}"); + let rgba = noise((source.0 * source.1 * 4) as usize, i as u32 + 99); + assert_eq!(area_shrink::<4>(&rgba, source, target), Some(two_pass_shrink::<4>(&rgba, source, target)), "{source:?} → {target:?}"); + } + } + + /// Premultiplying a row at a time as it is averaged gives the same bytes as premultiplying the + /// whole expanded RGBA first and shrinking that, for gray and RGB colour. + #[test] + fn premultiplying_row_by_row_equals_premultiplying_the_whole_image() { + let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((31, 17), (12, 7))]; + for (i, (source, target)) in sizes.into_iter().enumerate() { + let (w, h) = source; + let pixels = (w * h) as usize; + let alpha = noise(pixels, i as u32 + 7); + let gray = noise(pixels, i as u32 + 11); + let rgb = noise(pixels * 3, i as u32 + 13); + let colors = [ + ImageData::Luma(LumaData { data: gray.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }), + ImageData::Rgb(RgbData { data: rgb.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }), + ]; + for color in &colors { + let mut whole: Vec = match color { + ImageData::Luma(_) => gray.iter().zip(&alpha).flat_map(|(g, a)| [*g, *g, *g, *a]).collect(), + ImageData::Rgb(_) => rgb.chunks_exact(3).zip(&alpha).flat_map(|(c, a)| [c[0], c[1], c[2], *a]).collect(), + }; + premultiply_rgba(&mut whole); + assert_eq!( + area_shrink_premultiplied(color, &alpha, source, target), + area_shrink::<4>(&whole, source, target), + "{source:?} → {target:?}" + ); + } + let short = alpha.get(1..).unwrap_or_default(); + assert_eq!(area_shrink_premultiplied(&colors[0], short, source, target), None, "alpha shorter than the image"); + } + } + + fn mask(data: &[u8], width: u32, height: u32) -> LumaData { + LumaData { data: data.to_vec(), width, height, interpolate: false, scale_factors: (1.0, 1.0) } + } + + /// Each image sample takes the mask sample under its centre, at whole and fractional ratios. + #[test] + fn a_mask_is_sampled_onto_the_image_grid() { + assert_eq!(mask_on_image_grid(&mask(&[10, 20], 2, 1), 4, 2), Some(vec![10, 10, 20, 20, 10, 10, 20, 20])); + // Centres of 7 samples over 3: 0.5/7·3 = 0.21 → 0, 1.5/7·3 = 0.64 → 0, 2.5/7·3 = 1.07 → 1, … + assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3], 3, 1), 7, 1), Some(vec![1, 1, 2, 2, 2, 3, 3])); + } + + /// A mask that asks for `/Interpolate` is blended between the samples around each centre, + /// and held at its ends: the centres of 4 samples over 2 lie at -¼, ¼, ¾ and 1¼. + #[test] + fn an_interpolated_mask_is_blended_onto_the_image_grid() { + let smooth = |data: &[u8], width, height| LumaData { interpolate: true, ..mask(data, width, height) }; + assert_eq!(mask_on_image_grid(&smooth(&[0, 255], 2, 1), 4, 2), Some(vec![0, 64, 191, 255, 0, 64, 191, 255])); + // Centres of 7 samples over 3 lie 3/7 apart, from -2/7 to 2 2/7: a ramp, flat at the ends. + assert_eq!(mask_on_image_grid(&smooth(&[0, 70, 140], 3, 1), 7, 1), Some(vec![0, 10, 40, 70, 100, 130, 140])); + assert_eq!(mask_on_image_grid(&smooth(&[0, 255], 1, 2), 1, 4), Some(vec![0, 64, 191, 255])); + assert_eq!(mask_on_image_grid(&smooth(&[7, 9], 2, 1), 2, 1), Some(vec![7, 9]), "same size is unchanged"); + } + + #[test] + fn malformed_masks_are_not_sampled() { + assert_eq!(mask_on_image_grid(&mask(&[], 0, 1), 4, 1), None, "an empty mask"); + assert_eq!(mask_on_image_grid(&mask(&[1, 2], 2, 1), 0, 1), None, "an empty image"); + assert_eq!(mask_on_image_grid(&mask(&[1], 2, 1), 4, 1), None, "mask data shorter than its size"); + assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3, 4], 1, 4), 1, 2), None, "a mask larger than the image"); + } + + /// Every target sample's weights add up to exactly one, and together the targets take every + /// source sample exactly once (each `dst / src` of it, in fixed point within rounding). + #[test] + fn taps_are_exact_partitions_of_unity() { + for src in 1..=40_u32 { + for dst in 1..=src { + let mut taken = vec![0_u64; src as usize]; + for t in 0..dst { + let taps: Vec<_> = area_taps(src, dst, t).collect(); + assert_eq!(taps.iter().map(|(_, w)| w).sum::(), AREA_ONE, "{src} → {dst}, target {t}"); + assert!(taps.iter().all(|(_, w)| *w > 0), "{src} → {dst}, target {t}: an empty tap"); + for (s, w) in taps { + taken[s] += w; + } + } + // Source s is dst/src of one target in all; scaled by src/dst, that is one. + for (s, w) in taken.iter().enumerate() { + let share = (*w * u64::from(src)) as f64 / (AREA_ONE * u64::from(dst)) as f64; + assert!((share - 1.0).abs() < 1e-3, "{src} → {dst}: source {s} weighs {share}"); + } + } + } + } + + /// The table the horizontal pass reads holds exactly the taps of every target, in order. + #[test] + fn the_tap_table_holds_every_targets_taps() { + for src in 1..=40_u32 { + for dst in 1..=src { + let axis = AreaAxis::new(src, dst).expect("table"); + for t in 0..dst { + let (first, weights) = axis.get(t as usize).expect("target"); + let expected: Vec<(usize, u64)> = area_taps(src, dst, t).collect(); + let got: Vec<(usize, u64)> = weights.iter().enumerate().map(|(k, w)| (first + k, u64::from(*w))).collect(); + assert_eq!(got, expected, "{src} → {dst}, target {t}"); + } + assert_eq!(axis.get(dst as usize), None, "{src} → {dst}: no target past the last"); + } + } + } + + /// The largest side an image may have still computes in u64 without overflowing. + #[test] + fn taps_at_the_pixel_limit_do_not_overflow() { + let side = MAX_IMAGE_PIXELS as u32; + for (dst, t) in [(1, 0), (3, 2), (side - 1, side - 2), (side, side - 1)] { + assert_eq!(area_taps(side, dst, t).map(|(_, w)| w).sum::(), AREA_ONE, "{side} → {dst}, target {t}"); + } + } + + #[test] + fn flat_images_keep_their_exact_value() { + for (source, target) in [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((13, 11), (1, 1))] { + let data = vec![77_u8; (source.0 * source.1 * 3) as usize]; + let out = area_shrink::<3>(&data, source, target).expect("shrinks"); + assert_eq!(out.len(), (target.0 * target.1 * 3) as usize); + assert!(out.iter().all(|v| *v == 77), "{source:?} → {target:?}: {out:?}"); + } + } + + /// A side over the resampling limit is refused before any work buffer is allocated. + #[test] + fn sides_over_the_resampling_limit_are_refused() { + let wide = (super::MAX_RESAMPLING_SOURCE_SIDE + 1) as usize; + assert_eq!(area_shrink::<1>(&vec![0_u8; wide], (wide as u32, 1), (10, 1)), None, "too wide"); + assert_eq!(area_shrink::<1>(&vec![0_u8; wide], (1, wide as u32), (1, 10)), None, "too tall"); + } + + #[test] + fn malformed_shrinks_are_refused() { + let data = vec![0_u8; 4 * 4]; + assert_eq!(area_shrink::<1>(&data, (4, 4), (4, 4)), None, "nothing shrinks"); + assert_eq!(area_shrink::<1>(&data, (4, 4), (5, 2)), None, "a side grows"); + assert_eq!(area_shrink::<1>(&data, (4, 4), (0, 2)), None, "an empty side"); + assert_eq!(area_shrink::<1>(&data[1..], (4, 4), (2, 2)), None, "data shorter than the source"); + assert_eq!(area_shrink::<3>(&data, (4, 4), (2, 2)), None, "data of another channel count"); + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 4f59c1dc94..923b7121ab 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -45,6 +45,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("ops", Class::Layer(2)), // L3 core services ("render", Class::Layer(3)), + ("prc-render", Class::Layer(3)), ("text", Class::Layer(3)), ("annot", Class::Layer(3)), ("forms", Class::Layer(3)),