diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cfc16f78f4..edba4995df 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -275,7 +275,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | | `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | | `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | -| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | +| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 | | `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index be935422ce..4cdaa70575 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2235,7 +2235,7 @@ author = "John Slegers" source = "https://github.com/jslegers/emoji-icon-font" licence = "MIT" kind = "font" -usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." +usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8" [[asset]] diff --git a/Cargo.lock b/Cargo.lock index 7008f8f56a..ad5e30912f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1696,7 +1696,7 @@ dependencies = [ [[package]] name = "ecolor" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "bytemuck", "emath", @@ -1728,7 +1728,7 @@ dependencies = [ [[package]] name = "eframe" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "ahash", "bytemuck", @@ -1766,7 +1766,7 @@ dependencies = [ [[package]] name = "egui" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "accesskit", "ahash", @@ -1787,7 +1787,7 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "ahash", "bytemuck", @@ -1806,7 +1806,7 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "accesskit_winit", "arboard", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "egui_extras" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "ahash", "egui", @@ -1847,7 +1847,7 @@ dependencies = [ [[package]] name = "egui_glow" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "bytemuck", "egui", @@ -1860,7 +1860,7 @@ dependencies = [ [[package]] name = "egui_kittest" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "eframe", "egui", @@ -1903,7 +1903,7 @@ dependencies = [ [[package]] name = "emath" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "bytemuck", "serde", @@ -1984,7 +1984,7 @@ dependencies = [ [[package]] name = "epaint" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" dependencies = [ "ahash", "bytemuck", @@ -2011,7 +2011,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" [[package]] name = "equator" @@ -4565,6 +4565,7 @@ dependencies = [ "pdfcraft-fonts", "png 0.18.1", "rten", + "rten-imageproc", "serde_json", "sha2 0.11.1", "tempfile", @@ -8873,14 +8874,14 @@ dependencies = [ [[patch.unused]] name = "egui_demo_lib" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" [[patch.unused]] name = "egui_inspection" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" [[patch.unused]] name = "egui_system_font_provider" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc" diff --git a/Cargo.toml b/Cargo.toml index ab8b964ce8..aaafc8805a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,22 +150,22 @@ lopdf = { path = "vendor/lopdf" } vello_common = { path = "vendor/vello_common" } vello_cpu = { path = "vendor/vello_cpu" } # The egui and winit crates below are pinned to immutable storytold git revisions (egui -# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). +# 725a39fca940f77149ffbb9c58eef405a16997bc, winit a517b3846ee8457d84ee4975110bfda6797c44dc). # Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's # earlier patches and their source guards, not path patches; the other `vendor/` entries above # remain real path patches. See vendor/README.md and the migration plan. -ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } -egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +emath = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" } winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/ROADMAP.md b/ROADMAP.md index e8138a7e3d..b7cb77857a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -454,6 +454,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged. - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged. - **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged. +- **2026-10-15 (M10, print a selected area, #2051):** The area Take a Snapshot copies stays outlined with a small menu under it, Print area… and Save as image… (PNG). Print area… opens Print with Selected area (page N, size) under Pages to Print: the area prints alone, as a page of that size, with Fit, Actual size, Shrink or a custom scale, clipped to the area; Multiple, Booklet, Poster and Label stay for pages. `doc_print` takes `area`. New parity entry `print.selected-area` (shipped). Estimates unchanged. - **2026-10-15 (M7, find and replace in Edit text):** In Edit text mode the floating Find box gains a Replace with row (Edit ▸ Replace…, ⇧⌘H / Ctrl+Shift+H): Replace changes the highlighted match, Replace all every match as one undo step. Each match is rewritten inside its own paragraph through the paragraph rewrite, so fonts, sizes and colours stay and the paragraph rewraps; matches that span paragraphs, sit in OCR text, need another font or lie outside editable paragraphs are left alone and counted. `text_replace` drives the same plan. `edit.find-replace` planned → partial. Estimates unchanged. - **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. - **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged. @@ -845,6 +846,7 @@ Newest first: the date, what moved, and the new overall estimate. | 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-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-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | +| 2026-10-28 | minor | M10: Recognize text widens each found line by fractions of its word height before reading it, so the first and last letters are no longer clipped at 300 dpi (word errors on synthetic 150–400 dpi pages 3.8 % → 0.3 %); word boxes keep their size. 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-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. 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. | diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs index a6a4d4b292..aa870c92cc 100644 --- a/apps/pdfcraft/tests/it/vendor_graph.rs +++ b/apps/pdfcraft/tests/it/vendor_graph.rs @@ -49,7 +49,7 @@ fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) { #[test] fn vendor_provenance_window_stack_graph_is_resolved() { assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc"); - assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a"); + assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "725a39fca940f77149ffbb9c58eef405a16997bc"); // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows. let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md"); for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] { diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index e592b5d934..5af547970e 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,5 +1,5 @@ use super::draw::draw_appearance; -use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; +use super::lookup::{annot_dict, annot_ref, annots, change_annots, fill_sign_shape, page_ref}; use super::modify::{free_text_da, n, refuse_undrawable_thai, 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, @@ -535,7 +535,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let index = list.len(); list.push(Object::Ref(r)); add_popup(doc, r, popup, &mut list)?; - set_annots(doc, page, list)?; + change_annots(doc, page, list)?; Ok(index) } diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index f8681f899b..788774c428 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -89,6 +89,56 @@ thread_local! { pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// Apple Markup's private copy of a page's markup: on the page and on each of its annotations. +const APPLE_PAGE_MARKUP: [&[u8]; 2] = [b"AAPL:PPK", b"AAPL:PPKHash"]; +const APPLE_ANNOT_MARKUP: &[u8] = b"AAPL:AKExtras"; + +/// Drop Apple Markup's private copy of the page's markup once the annotation list changes. iOS +/// draws Markup annotations from that copy, which no longer matches `/Annots`: iOS 26 showed a +/// Markup signature in its own place and again over a signature added here. Without it, Apple +/// draws every annotation from its appearance, as other viewers do. The drop is recorded in the +/// document's repair log (iOS can no longer re-edit those strokes as live ink). +fn drop_apple_markup(doc: &mut Document, page: ObjRef, list: &mut [Object]) -> Result<(), AnnotError> { + let mut dropped = false; + if doc.get(page).as_dict().is_some_and(|d| APPLE_PAGE_MARKUP.iter().any(|k| d.contains(k))) { + doc.update_dict(page, |d| { + for k in APPLE_PAGE_MARKUP { + d.remove(k); + } + })?; + dropped = true; + } + for entry in list { + match entry { + Object::Dict(d) if d.contains(APPLE_ANNOT_MARKUP) => { + d.remove(APPLE_ANNOT_MARKUP); + dropped = true; + } + Object::Ref(r) if doc.get(*r).as_dict().is_some_and(|d| d.contains(APPLE_ANNOT_MARKUP)) => { + doc.update_dict(*r, |d| { + d.remove(APPLE_ANNOT_MARKUP); + })?; + dropped = true; + } + _ => {} + } + } + if dropped { + doc.note_repair( + "dropped Apple Markup's private copy of a page's annotations after the page changed; iOS draws them from their appearance instead of as editable ink" + .into(), + ); + } + Ok(()) +} + +/// Replace a page's `/Annots` after an annotation was added, removed or answered: the point at +/// which Apple Markup's private copy of the page goes stale (see [`drop_apple_markup`]). +pub(crate) fn change_annots(doc: &mut Document, page: ObjRef, mut list: Vec) -> Result<(), AnnotError> { + drop_apple_markup(doc, page, &mut list)?; + Ok(set_annots(doc, page, list)?) +} + /// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty /// list removes a direct `/Annots`. pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index c478c98a16..ae2222b387 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -4,7 +4,7 @@ use super::create::{ }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value}; -use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots}; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, change_annots, is_fill_sign, page_ref, set_annots}; use super::text_style::{self, TextStyle}; use super::types::{ BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, @@ -117,7 +117,10 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) { /// go together from either ([`own_deletion`]). pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { let index = deleted_index(doc, page, index)?; - lift_annotation(doc, page, index) + lift_annotation(doc, page, index)?; + // Not in lift_annotation: a drag preview lifts on a throwaway copy and must not log a repair. + let p = page_ref(doc, page)?; + change_annots(doc, p, annots(doc, p)) } /// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without @@ -429,7 +432,7 @@ fn reply( let mut list = annots(doc, p); list.push(Object::Ref(r)); let i = list.len() - 1; - set_annots(doc, p, list)?; + change_annots(doc, p, list)?; Ok(i) } @@ -549,7 +552,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a let mut list = annots(doc, p); list.push(Object::Ref(r)); let i = list.len() - 1; - set_annots(doc, p, list)?; + change_annots(doc, p, list)?; Ok(i) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index f1d2698439..ce5a8c1604 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -5529,3 +5529,93 @@ fn summaries_list_only_usable_quads() { let summary = summaries(&doc).into_iter().find(|a| a.page == 0 && a.index == 0).unwrap(); assert_eq!(summary.quads, vec![QUAD.map(|v| v as f32)]); } + +/// A page as iOS Markup saves a signature: its private copy on the page and on the stamp, which +/// also carries a normal appearance. A plain note follows the stamp, at index 1. +fn apple_markup_page() -> Document { + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R] /AAPL:PPK 5 0 R /AAPL:PPKHash 5 0 R >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AP << /N 6 0 R >> /AAPL:AKExtras << /PPKType (draw) >> >>", + b"<< /Length 0 >> +stream + +endstream", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 22 >> +stream +0 0 1 RG 1 1 m 9 9 l S +endstream", + b"<< /Type /Annot /Subtype /Text /Rect [400 400 420 420] /Contents (note) >>", + ]) +} + +/// Apple's copy is gone from the page and from every annotation left on it, and the drop is +/// in the repair log (fidelity: never silent). +fn assert_apple_copy_dropped(doc: &Document) { + assert!(doc.repair_log().iter().any(|l| l.contains("Apple Markup")), "{:?}", doc.repair_log()); + let doc = reopen(doc); + let page = doc.get(page_refs(&doc).unwrap()[0]); + let page = page.as_dict().unwrap(); + assert!(!page.contains(b"AAPL:PPK") && !page.contains(b"AAPL:PPKHash")); + let all = list(&doc, 0); + assert_eq!(all[0].name(b"Subtype"), Some(&b"Stamp"[..])); + assert!(all.iter().all(|d| !d.contains(b"AAPL:AKExtras"))); +} + +#[test] +fn deleting_from_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + delete_annotation(&mut doc, 0, 1).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn replying_on_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + add_reply(&mut doc, 0, 1, "Agreed", "Bob", &meta("r")).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn marking_on_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + set_marked(&mut doc, 0, 1, true, "Bob", &meta("m")).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn adding_to_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + add_annotation(&mut doc, &new(0, Shape::Note { at: [300.0, 300.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + assert_apple_copy_dropped(&doc); + // The Markup stamp itself stays, with its flags and its appearance, byte for byte. + let doc = reopen(&doc); + let all = list(&doc, 0); + assert_eq!(all[0].get(b"F").and_then(Object::as_int), Some(644)); + assert_eq!(ap_content(&doc, &all[0]), "0 0 1 RG 1 1 m 9 9 l S"); +} + +#[test] +fn a_refused_edit_on_an_apple_markup_page_drops_nothing() { + // An inline, locked Markup stamp: restyling it is refused, so Apple's copy must stay put + // (promoting the inline dictionary to an object is not a change to the markup). + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AAPL:AKExtras << /PPKType (draw) >> >>] /AAPL:PPK 4 0 R /AAPL:PPKHash 4 0 R >>", + b"<< /Length 0 >> +stream + +endstream", + ]); + assert_eq!( + set_style(&mut doc, 0, 0, StyleChange { color: Some([1.0, 0.0, 0.0]), opacity: None, width: None, endings: None }, &meta("")), + Err(AnnotError::Invalid("the comment is locked".into())) + ); + let page = doc.get(page_refs(&doc).unwrap()[0]); + let page = page.as_dict().unwrap(); + assert!(page.contains(b"AAPL:PPK") && page.contains(b"AAPL:PPKHash")); + assert!(list(&doc, 0)[0].contains(b"AAPL:AKExtras")); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); +} diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index a77ef98b8b..75a4e7fd5b 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -21,7 +21,7 @@ fn to_view(r: [f64; 4], ph: f64) -> [f64; 4] { [r[0], ph - r[3], r[2], ph - r[1]] } -fn rect_arg(a: &Args, key: &str) -> Result> { +pub(crate) fn rect_arg(a: &Args, key: &str) -> Result> { a.get(key) .map(|v| { let r: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 3f12802e92..5e9d231ddf 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -325,8 +325,18 @@ impl Automation { } else { None }; - let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); + // Print a selected area: points from the top-left of the displayed page, as other tools + // take rectangles; the print crate takes it y up. + let area = match crate::content::rect_arg(a, "area")? { + None => None, + Some(r) => { + let [page] = pages.as_slice() else { return Err(bad("area prints one page: give pages as a single page")) }; + let Some(&(_, h)) = sizes.get(*page) else { return Err(bad("area's page is not in the document")) }; + Some([r[0].min(r[2]), h - r[1].max(r[3]), r[0].max(r[2]), h - r[1].min(r[3])]) + } + }; + let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new(), area }; let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?; let sheets = laid_out.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 98d42674a1..56c654fa1c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -794,6 +794,7 @@ pub fn tools() -> Vec { "collate": { "type": "boolean" }, "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." }, "grayscale": { "type": "boolean" }, + "area": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Print a selected area: [x0, y0, x1, y1] in points from the top-left of the displayed page, of the one page in pages, sized by layout fit/actual/shrink/custom." }, "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." }, "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." }, "options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 386030176f..632f692e9f 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -12292,6 +12292,53 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// `doc_print` prints a selected area (Acrobat's "print selected area"): one page's rectangle, +/// in points from the top-left, at actual size on its own sheet; several pages, an empty area or +/// a layout that isn't a size choice are refused. +#[test] +fn doc_print_a_selected_area() { + let dir = workdir("print-area"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok( + &mut a, + "doc_print", + json!({ "doc": doc, "pages": "1", "area": [36, 36, 236, 136], "layout": "actual", "paper": "Letter", "path": "area.pdf" }), + ); + assert_eq!((r["sheets"].as_u64(), r["pages"].as_u64()), (Some(1), Some(1))); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("area.pdf")).unwrap())).unwrap(); + let pages = pdfcraft_model::pages(&out); + assert_eq!(pages.len(), 1); + // The sheet is the paper (Letter, turned to suit the wide area); the page inside it is cut to the area: a clip to the + // area's rectangle, then the source page drawn through it, and nothing else of the page. + let media: Vec = + out.resolve(pages[0].dict.get(b"MediaBox").unwrap()).as_array().unwrap().iter().map(|v| out.resolve(v).as_f64().unwrap()).collect(); + assert_eq!(media, [0.0, 0.0, 792.0, 612.0], "a landscape area takes a landscape Letter sheet"); + let contents = out.resolve(pages[0].dict.get(b"Contents").unwrap()); + let pdfcraft_cos::Object::Stream(stream) = &*contents else { panic!("sheet content") }; + let content = String::from_utf8(stream.decoded().unwrap()).unwrap(); + let tokens: Vec<&str> = content.split_whitespace().collect(); + let at = tokens.iter().position(|t| *t == "re").expect("the sheet clips to a rectangle"); + let rect: Vec = tokens[at - 4..at].iter().map(|t| t.parse().unwrap()).collect(); + assert_eq!(&tokens[at + 1..at + 3], ["W", "n"], "the rectangle is the clip: {content}"); + assert_eq!(rect, [36.0, 164.0, 164.0, 100.0], "the clip is the area (36..200 wide, 36..136 from the top, cut to the 200 x 300 page): {content}"); + assert_eq!(tokens.iter().filter(|t| **t == "re").count(), 1, "one clip, nothing drawn outside it: {content}"); + assert!(tokens.contains(&"Do"), "the page is drawn inside the clip: {content}"); + for (args, why) in [ + (json!({ "doc": doc, "pages": "1-2", "area": [36, 36, 236, 136], "path": "x.pdf" }), "one page"), + (json!({ "doc": doc, "pages": "1", "area": [36, 36, 36.5, 136], "path": "x.pdf" }), "empty"), + (json!({ "doc": doc, "pages": "1", "area": [36, 36, 36, 136], "path": "x.pdf" }), "empty"), + (json!({ "doc": doc, "pages": "1", "area": [5000, 36, 5200, 136], "path": "x.pdf" }), "empty"), + (json!({ "doc": doc, "pages": "1", "area": [36, 36, 236], "path": "x.pdf" }), "4 numbers"), + (json!({ "doc": doc, "pages": "1", "area": [36, 36, 236, 136], "layout": "multiple", "per_sheet": 2, "path": "x.pdf" }), "Fit"), + ] { + match a.call("doc_print", &args) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains(why), "{why}: {m}"), + other => panic!("{why}: {other:?}"), + } + } +} + /// Printer marks through the tools: added around the trim box (the page widened to make room), /// removable by kind. #[test] diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 512c1c97ad..539a03a6aa 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -2321,4 +2321,44 @@ mod tests { } } } + + /// The first and last letters of a line are read: ocrs' own 3-pixel margin clipped them at + /// 300 dpi ("The" read as "he", "and" as "an", full stops lost, about 4 % of the words). + /// Needs the OCR models (`cargo xtask models`). + #[test] + fn the_first_and_last_letters_of_a_line_are_read() { + // The default engine is ocrs: Tesseract alone makes `available()` true but cannot run it. + if Models::find().is_none() { + eprintln!("skipped: OCR models not installed"); + return; + } + let paragraph = "The annual report summarizes revenue, operating costs and the outlook for the coming year. \ + Sales in the northern region grew by twelve percent, while the southern offices remained flat. \ + Management expects stable margins despite higher prices for energy and transport. "; + let text = paragraph.repeat(6); + let s = Session::new(); + let pdf = s.create_from_text("t", &text).unwrap(); + let mut viewer = Session::new(); + let id = viewer.open("text.pdf", None, pdf, None).unwrap(); + assert_eq!(viewer.get(id).unwrap().info.pages.len(), 1, "the text fits one page"); + let png = export::Exporter::new(viewer.get(id).unwrap()).png(0, 300.0).unwrap(); + let scan = s.create_from_images(&[("1.png".into(), png)]).unwrap(); + let mut reader = Session::new(); + let id = reader.open("scan.pdf", None, scan, None).unwrap(); + let read = reader.recognize_text(id, &[], OcrSettings::default()).unwrap()[0].text(); + let (got, want): (Vec<&str>, Vec<&str>) = (read.split_whitespace().collect(), text.split_whitespace().collect()); + // Word-level edit distance. + let mut row: Vec = (0..=want.len()).collect(); + for g in &got { + let mut prev = row[0]; + row[0] += 1; + for (j, w) in want.iter().enumerate() { + let next = (prev + usize::from(g != w)).min(row[j] + 1).min(row[j + 1] + 1); + prev = row[j + 1]; + row[j + 1] = next; + } + } + let errors = row[want.len()]; + assert!(errors * 100 <= want.len(), "{errors} word errors in {} words:\n{read}", want.len()); + } } diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 60e66d46c9..d3374fb41e 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -14,6 +14,7 @@ workspace = true pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } +rten-imageproc = "0.26.0" serde_json.workspace = true sha2 = "0.11" thiserror.workspace = true diff --git a/crates/ocr/src/ocrs.rs b/crates/ocr/src/ocrs.rs index 94f2abfa18..53070d90c5 100644 --- a/crates/ocr/src/ocrs.rs +++ b/crates/ocr/src/ocrs.rs @@ -4,6 +4,8 @@ use std::path::{Path, PathBuf}; +use rten_imageproc::{RectF, RotatedRect}; + use super::{Line, MAX_PIXELS, MAX_SIDE, OcrError, OcrImage, RecognizeOptions, Recognizer, Word}; /// The validated byte length of a `width` × `height` RGBA raster, with the same caps @@ -85,6 +87,47 @@ impl Models { } } +/// How far each found line is widened before its image is cut out for recognition, as fractions +/// of the line's median word height: above and below every word, and before its first and after +/// its last word. ocrs grows each word box by a fixed 3 pixels, a sliver of a letter on a page +/// scanned at 300 dpi or more: the line images clipped the first and last letters ("The" read as +/// "he", "and" as "an", closing full stops lost), and the more so the higher the resolution. +/// Chosen on synthetic pages at 150 to 400 dpi, clean and noisy, and on MDPBench's pages. More +/// padding above and below costs small, noisy text (the recogniser scales each line image to a +/// fixed height, so the margin leaves less height for the letters): at 0.15 the 8 pt "Telefon" +/// label of a noisy form scanned at 150 dpi read "Teleton". +const LINE_PAD_ACROSS: f32 = 0.12; +const LINE_PAD_ALONG: f32 = 0.1; + +/// Widen `line` (its word boxes, in reading order) as [`LINE_PAD_ACROSS`] and +/// [`LINE_PAD_ALONG`] say, after the lines were found, so finding them is unchanged. Returns the +/// box the words covered before, which the recognised words are clipped to: the text layer sizes +/// its text from them. +fn pad_line(line: &mut [RotatedRect]) -> Option { + let before = rten_imageproc::bounding_rect(line.iter())?; + // A box's height runs along its up axis and its width along the line, whichever way the + // line lies on the page. + let mut heights: Vec = line.iter().map(RotatedRect::height).filter(|h| h.is_finite() && *h > 0.0).collect(); + heights.sort_by(f32::total_cmp); + let height = heights.get(heights.len() / 2).copied().unwrap_or(0.0); + let last = line.len().saturating_sub(1); + for (i, r) in line.iter_mut().enumerate() { + let along = if i == 0 || i == last { 2.0 * LINE_PAD_ALONG * height } else { 0.0 }; + let across = 2.0 * LINE_PAD_ACROSS * height; + r.resize(r.width() + along, r.height() + across); + } + Some(before) +} + +/// `rect` ([left, top, right, bottom]) clipped to `to`; unchanged if that leaves nothing: a word +/// wholly in the padding is a letter the line found had cut off, and keeps its own box. +fn clip(rect: [f32; 4], to: Option) -> [f32; 4] { + let Some(to) = to else { return rect }; + let [l, t, r, b] = rect; + let clipped = [l.max(to.left()), t.max(to.top()), r.min(to.right()), b.min(to.bottom())]; + if clipped[0] < clipped[2] && clipped[1] < clipped[3] { clipped } else { rect } +} + /// A loaded recogniser. Loading takes a moment; keep one and reuse it. pub struct Ocr { engine: ocrs::OcrEngine, @@ -140,18 +183,20 @@ impl Ocr { let source = ocrs::ImageSource::from_bytes(&rgb, (width, height)).map_err(|e| err(&e))?; let input = self.engine.prepare_input(source).map_err(|e| err(&e))?; let found = self.engine.detect_words(&input).map_err(|e| err(&e))?; - let lines = self.engine.find_text_lines(&input, &found); + let mut lines = self.engine.find_text_lines(&input, &found); + let before: Vec> = lines.iter_mut().map(|l| pad_line(l)).collect(); let read = self.engine.recognize_text(&input, &lines).map_err(|e| err(&e))?; use ocrs::TextItem; Ok(read .into_iter() - .flatten() - .map(|line| Line { + .zip(before) + .filter_map(|(line, before)| line.map(|l| (l, before))) + .map(|(line, before)| Line { words: line .words() .map(|w| { let r = w.bounding_rect(); - Word::new(w.to_string(), [r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32], "ocrs") + Word::new(w.to_string(), clip([r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32], before), "ocrs") }) .filter(|w| !w.text.trim().is_empty()) .collect(), @@ -207,8 +252,39 @@ impl Recognizer for OcrsRecognizer { #[cfg(test)] mod tests { + use rten_imageproc::{PointF, Vec2}; + use super::*; + /// A found line is widened by fractions of its word height: every word above and below, the + /// first and the last word along the line; the box returned is the line's before. + #[test] + fn a_line_is_padded_by_its_word_height() { + let word = |l: f32, r: f32| RotatedRect::from_rect(RectF::from_tlbr(100.0, l, 140.0, r)); + let mut line = vec![word(10.0, 90.0), word(100.0, 200.0), word(210.0, 260.0)]; + let before = pad_line(&mut line).unwrap(); + assert_eq!([before.left(), before.top(), before.right(), before.bottom()], [10.0, 100.0, 260.0, 140.0]); + let (across, along) = (2.0 * 0.12 * 40.0, 2.0 * 0.1 * 40.0); + assert!(line.iter().all(|r| (r.height() - (40.0 + across)).abs() < 1e-3)); + assert!((line[0].width() - (80.0 + along)).abs() < 1e-3 && (line[2].width() - (50.0 + along)).abs() < 1e-3); + assert!((line[1].width() - 100.0).abs() < 1e-3, "a word inside the line keeps its width"); + // A box on its side (up axis horizontal) keeps its height across the line and its width + // along it. + let mut side = vec![RotatedRect::new(PointF::from_yx(0.0, 0.0), Vec2::from_yx(0.0, 1.0), 40.0, 80.0)]; + pad_line(&mut side).unwrap(); + assert!((side[0].height() - (80.0 + 2.0 * 0.12 * 80.0)).abs() < 1e-3 && (side[0].width() - (40.0 + 2.0 * 0.1 * 80.0)).abs() < 1e-3); + assert!(pad_line(&mut []).is_none()); + } + + #[test] + fn recognised_words_are_clipped_to_the_line_found() { + let line = Some(RectF::from_tlbr(100.0, 10.0, 140.0, 260.0)); + assert_eq!(clip([4.0, 94.0, 60.0, 146.0], line), [10.0, 100.0, 60.0, 140.0]); + // A word wholly in the padding keeps its own box rather than none. + assert_eq!(clip([262.0, 94.0, 270.0, 146.0], line), [262.0, 94.0, 270.0, 146.0]); + assert_eq!(clip([1.0, 2.0, 3.0, 4.0], None), [1.0, 2.0, 3.0, 4.0]); + } + /// Raw dimensions are untrusted (they come from callers, ultimately from documents): zero /// sides, sides or pixel totals over the caps, and products that would overflow the naive /// `width * height * 4` are errors, never panics, before anything is touched. diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index cd6baa84e3..961a1296bb 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -165,6 +165,10 @@ pub struct Settings { /// resolution before it is placed on the paper. The engine does the rendering; `impose` is /// then given the image-only document. pub raster: Option, + /// Print a selected area (Acrobat's "print selected area"): only this rectangle of the one + /// page in `pages`, in that page's display space (points, origin at the bottom-left of the + /// page as shown, y up), sized like a page of its own by a [`Layout::Size`] choice. + pub area: Option<[f64; 4]>, } /// How pages are rendered when printing as image. @@ -206,6 +210,7 @@ impl Default for Settings { content: Content::default(), raster: None, crops: Vec::new(), + area: None, } } } diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index ccc0c1d737..24b1b5d5de 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -191,6 +191,52 @@ fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } } +/// A selected area: one page, within it, printed as if it were a page of that size. +fn selected_area(sizes: &[(f64, f64)], pages: &[usize], settings: &Settings) -> Result, PrintError> { + let Some(a) = settings.area else { return Ok(None) }; + if !matches!(settings.layout, Layout::Size(_)) { + return Err(PrintError::Invalid("an area prints with Fit, Actual size, Shrink or a custom scale".into())); + } + let refused = || PrintError::Invalid("the selected area is empty or not on one page".into()); + let [p] = pages else { return Err(refused()) }; + let d = sizes.get(*p).copied().ok_or_else(refused)?; + // `min` and `max` pass over NaN, which would make an all-NaN area the whole page. + if a.iter().any(|v| !v.is_finite()) { + return Err(refused()); + } + // Cut to the page; what is left must be a point wide. + let [x0, y0, x1, y1] = [a[0].min(a[2]).max(0.0), a[1].min(a[3]).max(0.0), a[0].max(a[2]).min(d.0), a[1].max(a[3]).min(d.1)]; + if !(x1 - x0 >= 1.0 && y1 - y0 >= 1.0) { + return Err(refused()); + } + Ok(Some([x0, y0, x1, y1])) +} + +/// One sheet per page (or per selected area), scaled by `mode`. +fn layout_size(sizes: &[(f64, f64)], pages: &[usize], settings: &Settings, mode: SizeMode, area: Option<[f64; 4]>) -> Result, PrintError> { + let mut sheets = Vec::new(); + for &p in pages { + let d = area.map_or(sizes[p], |a| (a[2] - a[0], a[3] - a[1])); + let size = oriented(settings.paper, settings.orientation, d.0 > d.1); + let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); + let s = match mode { + SizeMode::Fit => fit, + SizeMode::Actual => 1.0, + SizeMode::Shrink => fit.min(1.0), + SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, + SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), + }; + let sheet = [0.0, 0.0, size.0, size.1]; + // An area is placed as a page of its size would be, and clipped to it. + let placed = match area { + Some(a) => place_crop(p, a, sheet, s, 0), + None => place_in(p, d, sheet, s, false), + }; + sheets.push(Sheet { size, placed: vec![placed], ..Sheet::default() }); + } + Ok(sheets) +} + /// Lay out the sheets for `settings` given each source page's display size. pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); @@ -201,23 +247,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { return Err(PrintError::Invalid("invalid paper size".into())); } + let area = selected_area(sizes, &pages, settings)?; let mut sheets = Vec::new(); match settings.layout { - Layout::Size(mode) => { - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); - let s = match mode { - SizeMode::Fit => fit, - SizeMode::Actual => 1.0, - SizeMode::Shrink => fit.min(1.0), - SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, - SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), - }; - sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); - } - } + Layout::Size(mode) => sheets = layout_size(sizes, &pages, settings, mode, area)?, Layout::Label { flip } => { sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 42d1254fb9..77da1e221b 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1307,6 +1307,58 @@ fn imposed_pages_preserve_large_user_unit_matrices() { } } +#[test] +fn a_selected_area_prints_alone_fitted_or_at_actual_size() { + // Page 1 is 200×300; the area is its 100×50 block whose corner is at (40, 200). + let area = [40.0, 200.0, 140.0, 250.0]; + let fit = layout(&[(200.0, 300.0), (200.0, 300.0)], &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Fit)) }).unwrap(); + assert_eq!(fit.len(), 1); + let pl = fit[0].placed[0]; + assert_eq!(pl.clip, area, "clipped to the area"); + // A landscape area on Auto takes a landscape sheet and fills its width inside the margin. + assert_eq!(fit[0].size, (792.0, 612.0)); + let s = pl.matrix.0[0]; + assert!(close(s, (792.0 - 2.0 * MARGIN) / 100.0), "{s}"); + // The area's own corner lands where a page of its size would: centred on the sheet. + let (x, y) = (pl.matrix.0[0] * area[0] + pl.matrix.0[4], pl.matrix.0[3] * area[1] + pl.matrix.0[5]); + assert!(close(x, (792.0 - 100.0 * s) / 2.0) && close(y, (612.0 - 50.0 * s) / 2.0), "{x} {y}"); + let actual = layout(&[(200.0, 300.0)], &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Actual)) }).unwrap(); + assert_eq!(actual[0].placed[0].matrix.0[0], 1.0, "100%"); + // An area outside the page is cut to it; an empty one, several pages or another layout are refused. + let cut = layout(&[(200.0, 300.0)], &Settings { area: Some([150.0, 250.0, 400.0, 900.0]), ..settings(vec![0], Layout::Size(SizeMode::Actual)) }) + .unwrap(); + assert_eq!(cut[0].placed[0].clip, [150.0, 250.0, 200.0, 300.0]); + for (pages, area, layout_) in [ + (vec![0], [10.0, 10.0, 10.5, 40.0], Layout::Size(SizeMode::Fit)), + (vec![0, 1], area, Layout::Size(SizeMode::Fit)), + (vec![0], [10.0, 10.0, 10.0, 40.0], Layout::Size(SizeMode::Fit)), + (vec![0], [250.0, 10.0, 400.0, 40.0], Layout::Size(SizeMode::Fit)), + (vec![0], [f64::NAN, 0.0, 50.0, 50.0], Layout::Size(SizeMode::Fit)), + (vec![0], [f64::NAN; 4], Layout::Size(SizeMode::Fit)), + (vec![0], [0.0, 0.0, f64::INFINITY, 50.0], Layout::Size(SizeMode::Fit)), + ( + vec![0], + area, + Layout::Multiple { cols: 2, rows: 1, per_sheet: 2, order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: false }, + ), + (vec![0], area, Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 }), + (vec![0], area, Layout::Poster { scale: 200.0, overlap: 18.0, cut_marks: true }), + (vec![0], area, Layout::Label { flip: false }), + ] { + assert!(matches!( + layout(&[(200.0, 300.0), (200.0, 300.0)], &Settings { area: Some(area), ..settings(pages, layout_) }), + Err(PrintError::Invalid(_)) + )); + } + // A page the document doesn't have is refused before the area is looked at. + let missing = Settings { area: Some(area), ..settings(vec![5], Layout::Size(SizeMode::Fit)) }; + assert!(matches!(layout(&[(200.0, 300.0)], &missing), Err(PrintError::NoPages))); + // The print-ready PDF has one sheet with only that area. + let bytes = impose(&fixture(1), &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Actual)) }).unwrap(); + let out = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(pdfcraft_model::pages(&out).len(), 1); +} + /// `output_within` (#1818): the bounded wait every platform's `lpoptions` call goes through. #[cfg(not(target_arch = "wasm32"))] mod bounded_commands { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 4abdff3ab9..759682fade 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -280,6 +280,7 @@ impl PdfCraftApp { object_clip_mark: Default::default(), grid_drop: None, last_snapshot: None, + last_snapshot_of: None, last_copied_image: None, last_copied_file: None, system_clipboard: true, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2c9481d465..f6afc0327c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -742,6 +742,10 @@ pub struct DocView { /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. pub marquee: Option<(usize, Pos2)>, pub marquee_done: Option, + /// Snapshot: the area just copied (page, view-space rect), kept with a small menu under it + /// (Print area…, Save as image…), and what was chosen there. + pub area_pick: Option<(usize, [f32; 4])>, + pub area_choice: Option, /// Fill & Sign text being typed. pub fill_text: Option, /// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement. @@ -1165,6 +1169,8 @@ impl DocView { pages_focus_id: None, marquee: None, marquee_done: None, + area_pick: None, + area_choice: None, fill_text: None, fill_signature_page: None, lock_offer_dismissed: false, @@ -1193,6 +1199,10 @@ impl DocView { if self.select_anchor.is_some_and(|a| a > last) { self.select_anchor = None; } + // A Snapshot area names a page by its index and a rectangle on it: after pages were + // deleted or moved it would outline, print and save a different page. + self.area_pick = None; + self.area_choice = None; self.goto = None; self.goto_point = None; self.zoom_anchor = None; @@ -2904,6 +2914,21 @@ fn finish_sign_requests(app: &mut PdfCraftApp, index: usize, mut tool: QuickTool if let Some(done) = app.views[index].marquee_done.take() { app.finish_marquee(index, done); } + // The copied area and its menu belong to Snapshot: another tool puts them away. The tool is + // the app's, not the tab's, so every tab's area goes, not only the one on screen. + if tool != QuickTool::Snapshot { + for v in &mut app.views { + v.area_pick = None; + } + } + if let Some(choice) = app.views[index].area_choice.take() + && let Some((page, area)) = app.views[index].area_pick + { + match choice { + crate::zoom_snap::AreaChoice::Print => app.open_print_area(page, area), + crate::zoom_snap::AreaChoice::Save => app.save_snapshot_image(index, page, area), + } + } // A signature rectangle was drawn, or an empty signature field clicked. let drawn = app.views[index].sign.drawn.take(); if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 93e0b13210..6412241699 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -13,6 +13,15 @@ use crate::{PdfCraftApp, QuickTool}; /// [x0, y0, x1, y1]); a plain click has an empty rectangle. pub type Marquee = (usize, Rect, [f32; 4]); +/// What was chosen in the menu under a copied area. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AreaChoice { + /// Print only this area (Acrobat's "print selected area"). + Print, + /// Save the copied picture as a PNG file. + Save, +} + /// Drag a rectangle on page `page` (Marquee Zoom, Snapshot), with a press nobody holds (`free`). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, free: bool, view: &mut DocView) { let (pointer, origin) = (ptr.pos, ptr.origin); @@ -25,6 +34,30 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.marquee = Some((page, o)); + view.area_pick = None; + } + // The area just copied stays outlined, with what can be done with it underneath. + if let Some((p, area)) = view.area_pick + && p == page + && view.marquee.is_none() + { + let r = xf.view_rect(area); + ui.painter().rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); + egui::Area::new(egui::Id::unique("snapshot-area-menu")) + .order(egui::Order::Foreground) + .fixed_pos(r.left_bottom() + egui::vec2(0.0, 6.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + if ui.button(tl!("Print area…")).clicked() { + view.area_choice = Some(AreaChoice::Print); + } + if ui.button(tl!("Save as image…")).clicked() { + view.area_choice = Some(AreaChoice::Save); + } + }); + }); + }); } if let Some((p, start)) = view.marquee && p == page @@ -74,6 +107,7 @@ impl PdfCraftApp { } match self.snapshot(index, page, view_rect) { Ok((w, h)) => { + self.views[index].area_pick = Some((page, view_rect)); self.notify_fmt("The selected area has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]) } Err(e) => self.notify_fmt("Couldn't take the snapshot: {e}", &[("e", &e.to_string())]), @@ -120,10 +154,83 @@ impl PdfCraftApp { Ok(()) } + /// Print ▸ Selected area: the Print dialog for only `view_rect` (page view points, y down) of + /// `page`, sized like a page of its own (Fit to paper or Actual size). + pub(crate) fn open_print_area(&mut self, page: usize, view_rect: [f32; 4]) { + let Some((_, id)) = self.active_ids() else { return }; + let Some(height) = self.session.get(id).and_then(|d| d.info.pages.get(page)).map(|p| f64::from(p.height)) else { return }; + self.open_print(); + if self.dialog == Some(crate::Dialog::Print) { + let [x0, y0, x1, y1] = view_rect.map(f64::from); + let d = &mut self.print_draft; + // The print crate takes the area in display space, y up. + d.area = Some((page, [x0.min(x1), height - y0.max(y1), x0.max(x1), height - y0.min(y1)])); + d.which = crate::print_ui::Which::Area; + d.handling = crate::print_ui::Handling::Size; + d.sheet = 0; + } + } + + /// Save the area `view_rect` of `page` that view `index` copied as a PNG file (asked where; + /// `save_override` in tests). The last snapshot is used when it was taken of that area; + /// otherwise (another tab snapshotted since) the area is rendered again. + pub(crate) fn save_snapshot_image(&mut self, index: usize, page: usize, view_rect: [f32; 4]) { + let this_area = self.views.get(index).is_some_and(|v| self.last_snapshot_of == Some((v.id, page, view_rect))); + let png = match self.last_snapshot.as_ref() { + Some((w, h, pixels)) if this_area => pdfcraft_engine::export::encode_png(*w, *h, pixels), + _ => self.render_area(index, page, view_rect).and_then(|(w, h, pixels)| pdfcraft_engine::export::encode_png(w, h, &pixels)), + }; + let png = match png { + Ok(png) => png, + Err(e) => { + self.notify_fmt("Couldn't save the image: {e}", &[("e", &e)]); + return; + } + }; + let stem = self + .active_ids() + .and_then(|(_, id)| self.session.get(id)) + .map_or_else(String::new, |d| d.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string()); + let name = format!("{stem} area.png"); + #[cfg(not(target_arch = "wasm32"))] + { + let write = move |app: &mut Self, path: std::path::PathBuf| match crate::editing::write_atomically(&path.to_string_lossy(), &png) { + Ok(()) => app.notify_fmt("Exported to {path}", &[("path", &path.display().to_string())]), + Err(e) => app.notify_fmt("Couldn't write {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]), + }; + match self.save_override.clone() { + Some(p) => write(self, p.into()), + None => { + let dialog = + rfd::AsyncFileDialog::new().set_title(tl!("Save as image").to_string()).add_filter("PNG", &["png"]).set_file_name(name); + self.ask_one(crate::pickers::Ask::Save(dialog), None, write); + } + } + } + #[cfg(target_arch = "wasm32")] + if let Err(e) = crate::editing::download(&name, &png) { + self.notify_error(e); + } + } + /// Render `view_rect` (page view points) of `page` and copy it to the clipboard (and keep it /// in `last_snapshot`), at the current zoom or [`SNAPSHOT_MIN_SCALE`] if that is finer. pub fn snapshot(&mut self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32), String> { - let view = &self.views[index]; + let (w, h, rgba) = self.render_area(index, page, view_rect)?; + #[cfg(not(target_arch = "wasm32"))] + if self.system_clipboard { + let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) }; + crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?; + } + self.last_snapshot = Some((w, h, rgba)); + self.last_snapshot_of = Some((self.views[index].id, page, view_rect)); + Ok((w, h)) + } + + /// Render `view_rect` (page view points) of `page` in view `index` at its current zoom: + /// (width, height, premultiplied RGBA). + fn render_area(&self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32, pdfcraft_render::Pixels), String> { + let view = self.views.get(index).ok_or("no document")?; let doc = self.session.get(view.id).ok_or("no document")?; let ppp = self.ctx.as_ref().map_or(2.0, |c| c.pixels_per_point()); let scale = snapshot_scale(view.render_scale(ppp), view_rect[2] - view_rect[0], view_rect[3] - view_rect[1]); @@ -142,14 +249,7 @@ impl PdfCraftApp { if let Some(e) = out.error { return Err(e); } - let (w, h) = (out.width, out.height); - #[cfg(not(target_arch = "wasm32"))] - if self.system_clipboard { - let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) }; - crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?; - } - self.last_snapshot = Some((w, h, out.rgba)); - Ok((w, h)) + Ok((out.width, out.height, out.rgba)) } } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 96fac50129..29180aeab7 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -15,6 +15,8 @@ pub use save::PendingLock; pub use save::PendingOverwrite; #[cfg(target_arch = "wasm32")] pub(crate) use save::download; +#[doc(hidden)] +pub use save::download_mime_type; #[cfg(not(target_arch = "wasm32"))] pub(crate) use save::in_document_folder; pub use save::{SaveTarget, write_atomically}; diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index b27b953cfa..ea29f7fe46 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -429,6 +429,31 @@ fn short_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) } +/// The MIME type a download of `name` is offered as, from its extension. +#[doc(hidden)] +pub fn download_mime_type(name: &str) -> &'static str { + let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + match ext.as_str() { + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "tif" | "tiff" => "image/tiff", + "svg" => "image/svg+xml", + "csv" => "text/csv", + "txt" => "text/plain", + "html" | "htm" => "text/html", + "xml" => "application/xml", + "json" => "application/json", + "xfdf" => "application/vnd.adobe.xfdf", + "fdf" => "application/vnd.fdf", + "rtf" => "application/rtf", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + _ => "application/octet-stream", + } +} + /// Offer bytes as a browser download. #[cfg(target_arch = "wasm32")] pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> { @@ -437,7 +462,7 @@ pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let array = js_sys::Uint8Array::from(bytes); let parts = js_sys::Array::of1(&array); let opts = web_sys::BlobPropertyBag::new(); - opts.set_type("application/pdf"); + opts.set_type(download_mime_type(name)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?; let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?; let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f2a45a2012..7dfa39aa8c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,6 +72,8 @@ mod widgets; use pdfcraft_engine::{DocId, Session}; pub use canvas::{DocView, RasterMemory}; +#[doc(hidden)] +pub use editing::download_mime_type; /// Smallest window the shell is laid out for, in egui points (logical pixels, so the OS scale /// factor is already applied: 480 points are 600 px at 125 % and 720 px at 150 %). 480 is half of a @@ -708,6 +710,8 @@ pub struct PdfCraftApp { /// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the /// system clipboard (tests turn that off). pub last_snapshot: Option<(u32, u32, pdfcraft_render::Pixels)>, + /// What `last_snapshot` was taken of: (document, page, rectangle in page view points). + last_snapshot_of: Option<(DocId, usize, [f32; 4])>, /// The last image copied with Copy Image (width, height, straight RGBA), also put on the /// system clipboard when `system_clipboard` is on. pub last_copied_image: Option<(u32, u32, Vec)>, diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 5cdec92df9..bda59ce5d6 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -247,6 +247,7 @@ impl PdfCraftApp { sheet: 0, selected, which, + area: None, custom_new: None, ..keep }; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index a8f3583124..8e272b99c3 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -296,7 +296,7 @@ pub(crate) fn body( d.paper_overridden = true; } // Label stock gets the label layout (cut, turned upright, fitted). - if paper_clicked && d.paper != before && d.paper == print::LABEL_PAPER { + if paper_clicked && d.paper != before && d.paper == print::LABEL_PAPER && d.which != Which::Area { d.paper_before_label = Some(before); d.handling = Handling::Label; d.sheet = 0; @@ -360,6 +360,17 @@ pub(crate) fn body( d.which = Which::Range; } }); + // Offered only when an area was chosen with Snapshot (its menu's Print area…). + if let Some((page, a)) = d.area { + let label = crate::i18n::fmt( + tl!("Selected area (page {page}, {size})"), + &[("page", &(page + 1).to_string()), ("size", &d.units.size(a[2] - a[0], a[3] - a[1]))], + ); + if widgets::radio_value(ui, &mut d.which, Which::Area, label).clicked() { + // Leaving Label this way gives back the paper it replaced, as its tabs do. + d.set_handling(Handling::Size); + } + } // Offered only when pages were picked before the dialog opened. if !d.selected.is_empty() { let label = format!("{} ({})", tl!("Selected pages"), d.selected.len()); @@ -387,7 +398,9 @@ pub(crate) fn body( (Handling::Booklet, tl!("Booklet")), (Handling::Label, tl!("Label")), ] { - if widgets::mode_tab(ui, label, d.handling == h).clicked() { + // A selected area prints only with Size. + let offered = d.which != Which::Area || h == Handling::Size; + if ui.add_enabled_ui(offered, |ui| widgets::mode_tab(ui, label, d.handling == h)).inner.clicked() { d.set_handling(h); } } diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 536ab54e68..5c6d87313f 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -9,6 +9,8 @@ pub enum Which { Range, /// The pages picked in the Pages panel or the organize grid when the dialog opened. Selected, + /// Only the area chosen with Snapshot (`PrintDraft::area`). + Area, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -47,6 +49,8 @@ pub struct PrintDraft { pub range: String, /// The pages `Which::Selected` prints (0-based, in page order); empty when none were picked. pub selected: Vec, + /// The area Snapshot's Print area… asked for: (page, rectangle in display space, points, y up). + pub area: Option<(usize, [f64; 4])>, pub subset: Subset, pub reverse: bool, pub handling: Handling, @@ -133,6 +137,7 @@ impl Default for PrintDraft { which: Which::All, range: String::new(), selected: Vec::new(), + area: None, subset: Subset::All, reverse: false, handling: Handling::Size, @@ -428,11 +433,34 @@ impl PrintDraft { /// The engine settings for this draft (page count and labels from the document). pub fn settings(&self, count: usize, labels: &[String]) -> Result { + let size = Layout::Size(match self.size { + SizeMode::Custom(_) => SizeMode::Custom(self.custom_scale), + m => m, + }); + let paper = self.paper_size(); + let raster = self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }); + // A selected area prints alone, its one page sized like a page of its own: Size is its + // only handling (the dialog offers no other while it is chosen). + if self.which == Which::Area { + let Some((page, area)) = self.area.filter(|(p, _)| *p < count) else { + return Err("The selected area is no longer on a page of this document.".into()); + }; + return Ok(print::Settings { + pages: vec![page], + paper, + orientation: self.orientation, + layout: size, + content: self.content, + raster, + crops: Vec::new(), + area: Some(area), + }); + } if self.cut_and_stack() && matches!(self.duplex, Some(spool::Duplex::LongEdge | spool::Duplex::ShortEdge)) { return Err("Cut and stack needs Two-sided: Off. Print single-sided sheets.".into()); } let range = match self.which { - Which::All | Which::Selected | Which::Current => None, + Which::All | Which::Selected | Which::Current | Which::Area => None, Which::Range => Some(self.range.clone()), }; // Selected and Current are positions, not a typed range: a range would read numbers as @@ -440,16 +468,13 @@ impl PrintDraft { let pages = match self.which { Which::Selected => print::select_listed(count, &self.selected, self.subset, self.reverse), Which::Current => print::select_listed(count, &[self.current_page], self.subset, self.reverse), - Which::All | Which::Range => print::select_pages(count, range.as_deref(), labels, self.subset, self.reverse), + Which::All | Which::Range | Which::Area => print::select_pages(count, range.as_deref(), labels, self.subset, self.reverse), } // What the person got wrong about what to print is said in their language: the engine hands // the wording over whole and the page numbers apart from it. .map_err(|e| e.said(&crate::i18n::words))?; let layout = match self.handling { - Handling::Size => Layout::Size(match self.size { - SizeMode::Custom(_) => SizeMode::Custom(self.custom_scale), - m => m, - }), + Handling::Size => size, Handling::Multiple => { let grid = if self.custom_grid { Layout::grid(self.cols, self.rows).ok_or_else(|| tl!("Columns and rows must each be 1 to 99.").to_string())? @@ -469,12 +494,13 @@ impl PrintDraft { }; Ok(print::Settings { pages, - paper: self.paper_size(), + paper, orientation: self.orientation, layout, content: self.content, raster: self.raster(), crops: self.crops.clone(), + area: None, }) } diff --git a/crates/ui-egui/tests/it/download_mime.rs b/crates/ui-egui/tests/it/download_mime.rs new file mode 100644 index 0000000000..29674ec55f --- /dev/null +++ b/crates/ui-egui/tests/it/download_mime.rs @@ -0,0 +1,25 @@ +//! The type a web download is offered as follows the file's extension (it was `application/pdf` +//! for every file). + +use pdfcraft_ui_egui::download_mime_type; + +#[test] +fn png_downloads_as_image_png() { + assert_eq!(download_mime_type("area.png"), "image/png"); + assert_eq!(download_mime_type("Area.PNG"), "image/png"); +} + +#[test] +fn other_files_get_their_own_types() { + assert_eq!(download_mime_type("report.pdf"), "application/pdf"); + assert_eq!(download_mime_type("measurements.csv"), "text/csv"); + assert_eq!(download_mime_type("fields.xfdf"), "application/vnd.adobe.xfdf"); + assert_eq!(download_mime_type("photo.jpeg"), "image/jpeg"); + assert_eq!(download_mime_type("page.tif"), "image/tiff"); +} + +#[test] +fn unknown_extensions_are_plain_bytes() { + assert_eq!(download_mime_type("data.bin"), "application/octet-stream"); + assert_eq!(download_mime_type("no extension"), "application/octet-stream"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 4849562c7a..50c17eaf25 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -39,6 +39,7 @@ mod create; mod date_format; mod dialog_chrome; mod document_links; +mod download_mime; mod editing; mod editor_taps; mod export; @@ -82,6 +83,7 @@ mod scroll_prefetch; mod search_position; mod side_panels; mod signing; +mod snapshot_tabs; mod split_view; mod split_view_edits; mod split_view_find; diff --git a/crates/ui-egui/tests/it/snapshot_tabs.rs b/crates/ui-egui/tests/it/snapshot_tabs.rs new file mode 100644 index 0000000000..877be129f7 --- /dev/null +++ b/crates/ui-egui/tests/it/snapshot_tabs.rs @@ -0,0 +1,105 @@ +//! Snapshot with several tabs: each tab keeps its own area and its menu (Print area…, Save as +//! image…), so what the menu does is about that tab's area; leaving Snapshot puts away the area in +//! every tab, not only the one on screen. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; + +/// Two tabs, a.pdf (0) and b.pdf (1), with Snapshot chosen and the system clipboard left alone. +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("a.pdf", None, crate::support::fixtures::pdf_pages(2)).unwrap(); + app.open_bytes("b.pdf", None, crate::support::fixtures::pdf_pages(2)).unwrap(); + app.system_clipboard = false; + app + }); + crate::support::settle(&mut h); + assert!(h.state_mut().execute("edit.snapshot")); + h.run_steps(2); + h +} + +/// Take a snapshot of `area` on page 1 in tab `index`, as the Snapshot tool does at the end of a drag. +fn snapshot_in(h: &mut Harness<'static, PdfCraftApp>, index: usize, area: [f32; 4]) -> (u32, u32, Vec) { + h.state_mut().active = Some(index); + h.run_steps(2); + h.state_mut().snapshot(index, 0, area).expect("a snapshot"); + h.state_mut().views[index].area_pick = Some((0, area)); + h.run_steps(2); + let (w, hgt, px) = h.state().last_snapshot.clone().expect("kept"); + (w, hgt, px.to_vec()) +} + +/// Snapshot in tab A, then in tab B, back to A and Save as image…: the PNG is A's area, not the +/// snapshot B took last. +#[test] +fn save_as_image_writes_this_tabs_area() { + let mut h = harness(); + // Areas of different shapes, so their pictures can't be mistaken for each other. + let (wa, ha, pa) = snapshot_in(&mut h, 0, [10.0, 130.0, 150.0, 170.0]); + let (wb, hb, _) = snapshot_in(&mut h, 1, [20.0, 20.0, 60.0, 140.0]); + assert_ne!((wa, ha), (wb, hb)); + // Back to A: its area is still outlined with its menu. + h.state_mut().active = Some(0); + h.run_steps(3); + assert_eq!(h.state().views[0].area_pick, Some((0, [10.0, 130.0, 150.0, 170.0]))); + let dir = std::env::temp_dir().join(format!("pdfcraft-snapshot-tabs-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a area.png"); + h.state_mut().save_override = Some(file.to_string_lossy().into_owned()); + h.get_by_label("Save as image…").click(); + h.run_steps(3); + let png = std::fs::read(&file).expect("written"); + let _ = std::fs::remove_dir_all(&dir); + // The PNG header's width and height (IHDR), then the picture itself. + let size = |png: &[u8]| (u32::from_be_bytes(png[16..20].try_into().unwrap()), u32::from_be_bytes(png[20..24].try_into().unwrap())); + assert_eq!(size(&png), (wa, ha), "tab A's area, not tab B's ({wb} × {hb})"); + assert_eq!(png, pdfcraft_engine::export::encode_png(wa, ha, &pa).unwrap(), "tab A's pixels"); +} + +/// Print area… opens Print on the area; the area stays outlined (with its menu) in the tab, as +/// the parity note says, until another tool is chosen. +#[test] +fn the_area_stays_after_print_area() { + let mut h = harness(); + let area = [10.0, 130.0, 150.0, 170.0]; + snapshot_in(&mut h, 0, area); + h.get_by_label("Print area…").click(); + h.run_steps(4); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Print)); + assert_eq!(h.state().views[0].area_pick, Some((0, area))); +} + +/// An area is picked in each of two tabs while Snapshot is on; choosing another tool puts both +/// away, including the tab that is not on screen. +#[test] +fn leaving_snapshot_clears_every_tab() { + let mut h = harness(); + assert_eq!(h.state().quick_tool, QuickTool::Snapshot); + for index in 0..2 { + h.state_mut().active = Some(index); + h.run_steps(2); + h.state_mut().views[index].area_pick = Some((0, [10.0, 130.0, 150.0, 170.0])); + h.run_steps(2); + // Still Snapshot: the area stays outlined. + assert!(h.state().views[index].area_pick.is_some(), "tab {index} keeps its area under Snapshot"); + } + // Tab B is on screen; choose another tool. + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(3); + assert!(h.state().views[1].area_pick.is_none(), "the tab on screen"); + assert!(h.state().views[0].area_pick.is_none(), "the other tab"); +} + +/// A change to the page list leaves no area behind: its page index would name another page. +#[test] +fn a_document_change_puts_the_area_away() { + let mut h = harness(); + h.state_mut().views[0].area_pick = Some((0, [10.0, 130.0, 150.0, 170.0])); + let info = h.state().session.get(h.state().views[0].id).unwrap().info.clone(); + h.state_mut().views[0].document_changed(&info); + assert!(h.state().views[0].area_pick.is_none()); +} diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index fb94cc2c5b..16fb19cc86 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1076,6 +1076,59 @@ fn marquee_zoom_and_snapshot() { assert!(after > before * 2.0, "{before} → {after}"); } +/// Print a selected area: the area Snapshot copied stays outlined with a small menu under it; +/// Print area… opens Print on just that area (Fit to paper), and Save as image… writes a PNG. +#[test] +fn a_snapshot_area_prints_alone_or_saves_as_an_image() { + use pdfcraft_ui_egui::PrintWhich; + let mut h = harness(); + let i = h.state().active.unwrap(); + let r = h.state().views[i].page_screen_rect(0).expect("on screen"); + let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()); + h.state_mut().system_clipboard = false; + assert!(h.state_mut().execute("edit.snapshot")); + drag(&mut h, at(15.0, 175.0), at(115.0, 125.0)); + h.run_steps(2); + let (page, area) = h.state().views[i].area_pick.expect("the copied area is kept"); + assert_eq!(page, 0); + assert!(area[2] - area[0] > 90.0 && area[3] - area[1] > 40.0, "{area:?}"); + // Save as image… writes the copied picture as a PNG. + let dir = std::env::temp_dir().join(format!("pdfcraft-area-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + h.state_mut().save_override = Some(dir.join("area.png").to_string_lossy().into_owned()); + h.get_by_label("Save as image…").click(); + h.run_steps(3); + assert!(std::fs::read(dir.join("area.png")).expect("written").starts_with(b"\x89PNG")); + // Print area… opens Print on that area alone. + h.get_by_label("Print area…").click(); + h.run_steps(4); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Print)); + assert_eq!(h.state().print_draft.which, PrintWhich::Area); + h.get_by_label_contains("Selected area (page 1,"); + h.state_mut().print_draft.printer = None; + h.state_mut().save_override = Some(dir.join("area.pdf").to_string_lossy().into_owned()); + h.run_steps(2); + h.get_by_label("Save as PDF").click(); + h.run_steps(4); + assert_eq!(h.state().dialog, None); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("area.pdf")).expect("saved"))).unwrap(); + assert_eq!(pdfcraft_model::pages(&out).len(), 1, "one sheet for the area"); + // The next Print is for pages again. + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + assert_eq!(h.state().print_draft.which, PrintWhich::All); + h.state_mut().dialog = None; + h.run_steps(2); + // Another snapshot keeps its area; another tool puts it away. + drag(&mut h, at(15.0, 175.0), at(115.0, 125.0)); + h.run_steps(2); + assert!(h.state().views[i].area_pick.is_some()); + h.state_mut().set_option("quick", "select").unwrap(); + h.run_steps(2); + assert!(h.state().views[i].area_pick.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + /// #5716: what a snapshot holds doesn't depend on how far the page is zoomed out. #[test] fn a_snapshot_keeps_its_resolution_when_zoomed_out() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dc9663aebc..865bbabb42 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1598,8 +1598,8 @@ milestone = "M3" status = "shipped" commands = ["edit.snapshot"] tools = ["page_render"] -evidence = ["crates/ui-egui/tests/it/viewer.rs::marquee_zoom_and_snapshot", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_keeps_its_resolution_when_zoomed_out", "crates/ui-egui/src/canvas/zoom_snap.rs::a_snapshot_is_drawn_at_the_finer_of_the_zoom_and_the_minimum", "crates/ui-egui/src/canvas/zoom_snap.rs::a_snapshot_stays_within_what_the_renderer_draws_at_once", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_of_a_large_page_reaches_its_far_corner"] -notes = "copies the area to the clipboard as an image, at 300 ppi or the current zoom if that is finer (#5716), or as fine as the renderer draws in one tile (4096 pixels a side), drawn while the app waits. Missing: a choice of resolution, and a selection that stays active to copy later" +evidence = ["crates/ui-egui/tests/it/viewer.rs::marquee_zoom_and_snapshot", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_keeps_its_resolution_when_zoomed_out", "crates/ui-egui/src/canvas/zoom_snap.rs::a_snapshot_is_drawn_at_the_finer_of_the_zoom_and_the_minimum", "crates/ui-egui/src/canvas/zoom_snap.rs::a_snapshot_stays_within_what_the_renderer_draws_at_once", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_of_a_large_page_reaches_its_far_corner", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_area_prints_alone_or_saves_as_an_image"] +notes = "copies the area to the clipboard as an image, at 300 ppi or the current zoom if that is finer (#5716), or as fine as the renderer draws in one tile (4096 pixels a side), drawn while the app waits; the area then stays outlined with Print area… and Save as image… (PNG) under it until another tool is chosen (print.selected-area). Missing: a choice of resolution, and a selection that stays active to copy later" [[feature]] id = "view.copy-image" @@ -8228,6 +8228,18 @@ tools = ["doc_print"] evidence = ["crates/print/src/tests.rs::page_selection", "crates/print/src/tests.rs::a_number_in_parentheses_names_the_page_at_that_position", "crates/automation/tests/it/automation.rs::printing_through_tools"] notes = "all, current, ranges with labels and open ends; (n) names the nth page whatever its label (ISO 32000-2 §12.4.2: labels and page indices need not coincide), alone or as a range end; labels are looked up first, so a page labelled exactly \"(n)\" keeps that name. The same parser serves Combine files ranges. Organize Pages range selection still takes positions only, without labels or (n)." +[[feature]] +id = "print.selected-area" +title = "Print a selected area" +area = "L" +tier = "P2" +milestone = "M10" +status = "shipped" +commands = ["edit.snapshot", "print.dialog"] +tools = ["doc_print"] +evidence = ["crates/print/src/tests.rs::a_selected_area_prints_alone_fitted_or_at_actual_size", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_area_prints_alone_or_saves_as_an_image", "crates/automation/tests/it/automation.rs::doc_print_a_selected_area"] +notes = "Requested in #2051. The area Take a Snapshot copies stays outlined with a small menu under it, Print area… and Save as image…; Print then offers Selected area (page N, size), printed alone as a page of that size with Fit, Actual size, Shrink or a custom scale, clipped to the area. Multiple, Booklet, Poster and Label are for pages: the dialog offers only Size while the area is chosen and the print crate refuses the others. Print as image, the paper and the printer's options apply as usual. doc_print takes area [x0, y0, x1, y1] in points from the top-left of the displayed page, with one page in pages. Missing: an area spanning two pages, drawing the area from inside the Print dialog, and adjusting it after it is drawn." + [[feature]] id = "print.odd-even-reverse" title = "Odd/even pages and reverse order" diff --git a/vendor/README.md b/vendor/README.md index 4e6707f772..135101d4ac 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -4,7 +4,7 @@ The checked-in, resolved Cargo graph pins `winit` to storytold/winit `a517b3846ee8457d84ee4975110bfda6797c44dc` and the egui crates to storytold/egui -`6d518fc799681571963842f95b55a6b68431b15a`. Neither `vendor/winit` nor +`725a39fca940f77149ffbb9c58eef405a16997bc`. Neither `vendor/winit` nor `vendor/egui-wgpu` is a root `[patch.crates-io]` path entry. The retained copies record the app's earlier patches; their source-reading tests establish provenance, while the resolved forks and their behavioral tests establish runtime behavior.