From f35de22d2126eb09720c3ca22a76f277e85d92fe Mon Sep 17 00:00:00 2001 From: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:53:54 -0500 Subject: [PATCH 1/8] feat(print): print a selected area from Take a Snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. - print: Settings.area (one page's rectangle in display space, y up), placed with place_crop under a Size layout; other layouts are refused. - ui: PrintDraft.area and Which::Area; only Size is offered while the area is chosen. Another tool puts the area and its menu away. - automation: doc_print takes area [x0, y0, x1, y1] from the top-left. - Five new labels in all 22 catalogs (ATTRIBUTION.toml checksums refreshed); parity print.selected-area; ROADMAP progress log. Requested in #2051. Carries the review fixes: - Save as image: save this tab's area, not the last snapshot taken anywhere - Web downloads: type the file by its extension; keep the snapshot area after Print area - Clear the copied area of every tab when Snapshot is left - i18n(ui): carry this PR's new wordings in the catalogues that must cover them - style: rustfmt after the merge with main - Use Id::unique for the snapshot area menu (Id::new is deprecated in egui 0.37) - rustfmt - print area: refuse an area that isn't finite, and a page out of range first Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/automation/src/content.rs | 2 +- crates/automation/src/printing.rs | 12 +- crates/automation/src/tools.rs | 1 + crates/automation/tests/it/automation.rs | 31 +++++ crates/print/src/settings.rs | 5 + crates/print/src/sheets.rs | 32 ++++- crates/print/src/tests.rs | 48 +++++++ crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/canvas.rs | 21 ++++ crates/ui-egui/src/canvas/zoom_snap.rs | 118 ++++++++++++++++-- crates/ui-egui/src/editing.rs | 2 + crates/ui-egui/src/editing/save.rs | 27 +++- crates/ui-egui/src/i18n/ar.tsv | 5 + crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/kk.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/lt.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ro.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sl.tsv | 5 + crates/ui-egui/src/i18n/sq.tsv | 5 + crates/ui-egui/src/i18n/te.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/workflows/print_ui.rs | 3 +- crates/ui-egui/src/workflows/print_ui/body.rs | 17 ++- .../ui-egui/src/workflows/print_ui/draft.rs | 42 +++++-- crates/ui-egui/tests/it/download_mime.rs | 25 ++++ crates/ui-egui/tests/it/main.rs | 3 + crates/ui-egui/tests/it/snapshot_area_tabs.rs | 73 +++++++++++ crates/ui-egui/tests/it/snapshot_tabs.rs | 106 ++++++++++++++++ crates/ui-egui/tests/it/viewer.rs | 53 ++++++++ parity/acrobat-features.toml | 16 ++- 52 files changed, 761 insertions(+), 27 deletions(-) create mode 100644 crates/ui-egui/tests/it/download_mime.rs create mode 100644 crates/ui-egui/tests/it/snapshot_area_tabs.rs create mode 100644 crates/ui-egui/tests/it/snapshot_tabs.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6e7140b909..76735a05e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -372,6 +372,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. 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 (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. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 6b25c31f61..ac9b61d651 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 9a1c1a6b7c..d2088f51b7 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -200,8 +200,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 sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 866138899d..5f8514b368 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -613,6 +613,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 sends no setting, so the printer's own default applies." }, "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." }, "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 0d73dbb900..a7e93df7b9 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9457,6 +9457,37 @@ 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(); + assert_eq!(pdfcraft_model::pages(&out).len(), 1); + 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:?}"), + } + } +} + #[test] fn attaching_and_deleting_files_through_the_tools() { let dir = workdir("attach"); diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 0506eb5b18..7ebb41a3cc 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -142,6 +142,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. @@ -175,6 +179,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 061a6ed5c3..6d0bdd9b5c 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -144,11 +144,33 @@ 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())); } + // A selected area: one page, within it, printed as if it were a page of that size. + let area = match settings.area { + None => None, + Some(_) if !matches!(settings.layout, Layout::Size(_)) => { + return Err(PrintError::Invalid("an area prints with Fit, Actual size, Shrink or a custom scale".into())); + } + Some(a) => { + let refused = || PrintError::Invalid("the selected area is empty or not on one page".into()); + let [p] = pages.as_slice() 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()); + } + Some([x0, y0, x1, y1]) + } + }; let mut sheets = Vec::new(); match settings.layout { Layout::Size(mode) => { for &p in &pages { - let d = sizes[p]; + let d = area.map_or(sizes[p], |a| (a[2] - a[0], a[3] - a[1])); 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 { @@ -158,7 +180,13 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P 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() }); + 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() }); } } Layout::Label { flip } => { diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 885756e8c6..de419c3629 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -863,6 +863,54 @@ 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 }), + (vec![0], area, Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left }), + (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 43d0066e63..718ccc3e50 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -229,6 +229,7 @@ impl PdfCraftApp { page_clipboard: None, 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 a220644421..008d59a36f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -638,6 +638,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. @@ -999,6 +1003,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, signature_drag: Default::default(), @@ -3263,6 +3269,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 159adf2b7e..52d4987178 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). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, 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 @@ -73,6 +106,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())]), @@ -119,10 +153,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` at the current zoom and copy it to the /// clipboard (and keep it in `last_snapshot`). 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 = (view.zoom * ppp).clamp(0.5, 8.0); @@ -141,14 +248,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 b810e2abe5..fee266ce6c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -14,6 +14,8 @@ pub use close::CloseRequest; 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 f685247c9d..1028f9049c 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -283,6 +283,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> { @@ -291,7 +316,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/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1b08929307..93d5cb3fe2 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2815,6 +2815,11 @@ certificate Version الإصدار Edit this PDF تحرير هذا الملف PDF Export this PDF تصدير هذا الملف PDF Find text or tools البحث عن نص أو أدوات + Print area… طباعة المنطقة… + Save as image… حفظ كصورة… + Save as image حفظ كصورة + Selected area (page {page}, {size}) المنطقة المحددة (صفحة {page}، {size}) + The selected area is no longer on a page of this document. لم تعد المنطقة المحددة على أي صفحة من هذا المستند. Create & Edit الإنشاء والتحرير Review & Compare المراجعة والمقارنة Forms & Signatures النماذج والتوقيعات diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e269febf18..f492a88c25 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2502,6 +2502,11 @@ animation effects Off Изключени Edit this PDF Редактирай този PDF Export this PDF Експортирай този PDF Find text or tools Търсене на текст или инструменти + Print area… Отпечатай областта… + Save as image… Запиши като изображение… + Save as image Запиши като изображение + Selected area (page {page}, {size}) Избрана област (страница {page}, {size}) + The selected area is no longer on a page of this document. Избраната област вече не е на страница от този документ. Create & Edit Създаване и редактиране Review & Compare Преглед и сравнение Forms & Signatures Формуляри и подписи diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 7ff0f83fee..fbc294b98b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2846,6 +2846,11 @@ theme Appearance Aspecte Unpin tool Desenganxa l'eina Arrange image Organitza la imatge Crop Escapça + Print area… Àrea d'impressió… + Save as image Desa com a imatge + Save as image… Desa com a imatge… + Selected area (page {page}, {size}) Àrea seleccionada (pàgina {page}, {size}) + The selected area is no longer on a page of this document. L'àrea seleccionada ja no és en cap pàgina d'aquest document. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Avui diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 22f6e3e056..7c99a00682 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2613,6 +2613,11 @@ animation effects Off Vypnuto Edit this PDF Upravit toto PDF Export this PDF Exportovat toto PDF Find text or tools Hledat text nebo nástroje + Print area… Tisk oblasti… + Save as image… Uložit jako obrázek… + Save as image Uložit jako obrázek + Selected area (page {page}, {size}) Vybraná oblast (stránka {page}, {size}) + The selected area is no longer on a page of this document. Vybraná oblast už není na žádné stránce tohoto dokumentu. Create & Edit Vytváření a úpravy Review & Compare Revize a porovnání Forms & Signatures Formuláře a podpisy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c9ce7ae36..7d318eb768 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2540,6 +2540,11 @@ animation effects Off Aus Edit this PDF Dieses PDF bearbeiten Export this PDF Dieses PDF exportieren Find text or tools Text oder Werkzeuge suchen + Print area… Bereich drucken… + Save as image… Als Bild speichern… + Save as image Als Bild speichern + Selected area (page {page}, {size}) Ausgewählter Bereich (Seite {page}, {size}) + The selected area is no longer on a page of this document. Der ausgewählte Bereich liegt auf keiner Seite dieses Dokuments mehr. Create & Edit Erstellen & Bearbeiten Review & Compare Prüfen & Vergleichen Forms & Signatures Formulare & Signaturen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d83bf4aef7..80f4d7eca2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2546,6 +2546,11 @@ animation effects Off Ανενεργά Edit this PDF Επεξεργασία αυτού του PDF Export this PDF Εξαγωγή αυτού του PDF Find text or tools Εύρεση κειμένου ή εργαλείων + Print area… Εκτύπωση περιοχής… + Save as image… Αποθήκευση ως εικόνα… + Save as image Αποθήκευση ως εικόνα + Selected area (page {page}, {size}) Επιλεγμένη περιοχή (σελίδα {page}, {size}) + The selected area is no longer on a page of this document. Η επιλεγμένη περιοχή δεν βρίσκεται πλέον σε σελίδα αυτού του εγγράφου. Create & Edit Δημιουργία και επεξεργασία Review & Compare Αναθεώρηση και σύγκριση Forms & Signatures Φόρμες και υπογραφές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 131d2dacaf..2b1791c5a9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2709,6 +2709,11 @@ animation effects Off Desactivados Edit this PDF Editar este PDF Export this PDF Exportar este PDF Find text or tools Buscar texto o herramientas + Print area… Imprimir área… + Save as image… Guardar como imagen… + Save as image Guardar como imagen + Selected area (page {page}, {size}) Área seleccionada (página {page}, {size}) + The selected area is no longer on a page of this document. El área seleccionada ya no está en una página de este documento. Attach a file… Adjuntar un archivo… Delete attachment Eliminar adjunto Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8c1a9baab9..027d4191ce 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2856,6 +2856,11 @@ theme Appearance Teema Unpin tool Irrota työkalu Arrange image Järjestä kuva Crop Rajaa + Print area… Tulostusalue… + Save as image Tallenna kuvana + Save as image… Tallenna kuvana… + Selected area (page {page}, {size}) Valittu alue (sivu {page}, {size}) + The selected area is no longer on a page of this document. Valittu alue ei ole enää tämän asiakirjan millään sivulla. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Tänään diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1c22cf9d81..979bba5e16 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2539,6 +2539,11 @@ animation effects Off Désactivés Edit this PDF Modifier ce PDF Export this PDF Exporter ce PDF Find text or tools Rechercher du texte ou des outils + Print area… Imprimer la zone… + Save as image… Enregistrer comme image… + Save as image Enregistrer comme image + Selected area (page {page}, {size}) Zone sélectionnée (page {page}, {size}) + The selected area is no longer on a page of this document. La zone sélectionnée n'est plus sur une page de ce document. Create & Edit Créer et modifier Review & Compare Réviser et comparer Forms & Signatures Formulaires et signatures diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 52c5450bb3..a3e9c7d0bc 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2816,6 +2816,11 @@ theme Appearance स्वरूप Remove crop क्रॉप हटाएँ Send backward पीछे भेजें Send to back सबसे पीछे भेजें + Print area… प्रिंट क्षेत्र… + Save as image छवि के रूप में सहेजें + Save as image… छवि के रूप में सहेजें… + Selected area (page {page}, {size}) चयनित क्षेत्र (पृष्ठ {page}, {size}) + The selected area is no longer on a page of this document. चयनित क्षेत्र अब इस दस्तावेज़ के किसी पृष्ठ पर नहीं है। # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today आज diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 47d9c24b40..f20dab4d1d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2539,6 +2539,11 @@ animation effects Off Ki Edit this PDF Ezen PDF szerkesztése Export this PDF Ezen PDF exportálása Find text or tools Szöveg vagy eszközök keresése + Print area… Terület nyomtatása… + Save as image… Mentés képként… + Save as image Mentés képként + Selected area (page {page}, {size}) Kijelölt terület ({page}. oldal, {size}) + The selected area is no longer on a page of this document. A kijelölt terület már nincs a dokumentum egyik oldalán sem. Create & Edit Létrehozás és szerkesztés Review & Compare Áttekintés és összehasonlítás Forms & Signatures Űrlapok és aláírások diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 89152f62a4..e7472b3591 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2817,6 +2817,11 @@ theme Appearance Tampilan Remove crop Hapus pangkasan Send backward Mundur Selangkah Send to back Kirim ke Belakang + Print area… Area cetak… + Save as image Simpan sebagai gambar + Save as image… Simpan sebagai gambar… + Selected area (page {page}, {size}) Area terpilih (halaman {page}, {size}) + The selected area is no longer on a page of this document. Area terpilih tidak lagi berada di halaman dokumen ini. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hari ini diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0cae8f3e98..e2b4b9dfb2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2751,6 +2751,11 @@ certificate SHA-256 digest Impronta SHA-256 Edit this PDF Modifica questo PDF Export this PDF Esporta questo PDF Find text or tools Trova testo o strumenti + Print area… Stampa area… + Save as image… Salva come immagine… + Save as image Salva come immagine + Selected area (page {page}, {size}) Area selezionata (pagina {page}, {size}) + The selected area is no longer on a page of this document. L'area selezionata non è più su una pagina di questo documento. Create & Edit Crea e modifica Review & Compare Revisione e confronto Forms & Signatures Moduli e firme diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index abc616b817..f375e5db5c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2539,6 +2539,11 @@ animation effects Off オフ Edit this PDF この PDF を編集 Export this PDF この PDF を書き出す Find text or tools テキストまたはツールを検索 + Print area… 範囲を印刷… + Save as image… 画像として保存… + Save as image 画像として保存 + Selected area (page {page}, {size}) 選択範囲({page} ページ、{size}) + The selected area is no longer on a page of this document. 選択範囲はもうこの文書のページ上にありません。 Create & Edit 作成と編集 Review & Compare レビューと比較 Forms & Signatures フォームと署名 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index fc6a2d783a..ded2ff83c5 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2537,6 +2537,11 @@ animation effects Off Өшірулі Edit this PDF Осы PDF файлын түзету Export this PDF Осы PDF файлын экспорттау Find text or tools Мәтінді немесе құралдарды табу + Print area… Аймақты басып шығару… + Save as image… Кескін ретінде сақтау… + Save as image Кескін ретінде сақтау + Selected area (page {page}, {size}) Таңдалған аймақ ({page}-бет, {size}) + The selected area is no longer on a page of this document. Таңдалған аймақ енді осы құжаттың ешбір бетінде жоқ. Create & Edit Жасау және өңдеу Review & Compare Шолу және салыстыру Forms & Signatures Пішіндер және қолтаңбалар diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 20315b5fcf..1a279e8130 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2816,6 +2816,11 @@ theme Appearance 모양 Remove crop 자르기 제거 Send backward 뒤로 보내기 Send to back 맨 뒤로 보내기 + Print area… 인쇄 영역… + Save as image 이미지로 저장 + Save as image… 이미지로 저장… + Selected area (page {page}, {size}) 선택 영역 ({page}페이지, {size}) + The selected area is no longer on a page of this document. 선택한 영역이 더 이상 이 문서의 페이지에 없습니다. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 오늘 diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index fd7f431154..76b0ff137f 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2809,6 +2809,11 @@ theme Appearance Išvaizda Edit this PDF Redaguoti šį PDF Export this PDF Eksportuoti šį PDF Find text or tools Rasti tekstą arba įrankius + Print area… Spausdinti sritį… + Save as image… Įrašyti kaip paveikslėlį… + Save as image Įrašyti kaip paveikslėlį + Selected area (page {page}, {size}) Pasirinkta sritis ({page} puslapis, {size}) + The selected area is no longer on a page of this document. Pasirinktos srities nebėra šio dokumento puslapyje. Attach a file… Pridėti failą… Delete attachment Ištrinti priedą Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 208dd1c61e..934a7c907f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2515,6 +2515,11 @@ animation effects Off Wyłączone Edit this PDF Edytuj ten plik PDF Export this PDF Eksportuj ten plik PDF Find text or tools Znajdź tekst lub narzędzia + Print area… Drukuj obszar… + Save as image… Zapisz jako obraz… + Save as image Zapisz jako obraz + Selected area (page {page}, {size}) Zaznaczony obszar (strona {page}, {size}) + The selected area is no longer on a page of this document. Zaznaczony obszar nie znajduje się już na żadnej stronie tego dokumentu. Create & Edit Tworzenie i edycja Review & Compare Recenzja i porównanie Forms & Signatures Formularze i podpisy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a172a03879..74ba9bb180 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2552,6 +2552,11 @@ animation effects Off Desativados Edit this PDF Editar este PDF Export this PDF Exportar este PDF Find text or tools Localizar texto ou ferramentas + Print area… Imprimir área… + Save as image… Salvar como imagem… + Save as image Salvar como imagem + Selected area (page {page}, {size}) Área selecionada (página {page}, {size}) + The selected area is no longer on a page of this document. A área selecionada não está mais em uma página deste documento. Create & Edit Criar e editar Review & Compare Revisar e comparar Forms & Signatures Formulários e assinaturas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0d0fbf16b4..0d722d535b 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2846,6 +2846,11 @@ theme Appearance Aspect Unpin tool Anulează fixarea instrumentului Arrange image Aranjează imaginea Crop Decupare + Print area… Zonă de imprimare… + Save as image Salvare ca imagine + Save as image… Salvare ca imagine… + Selected area (page {page}, {size}) Zonă selectată (pagina {page}, {size}) + The selected area is no longer on a page of this document. Zona selectată nu se mai află pe o pagină a acestui document. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b45d73ecab..695b9461c9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2452,6 +2452,11 @@ animation effects Off Выкл Edit this PDF Редактировать этот PDF Export this PDF Экспортировать этот PDF Find text or tools Найти текст или инструменты + Print area… Печать области… + Save as image… Сохранить как изображение… + Save as image Сохранить как изображение + Selected area (page {page}, {size}) Выделенная область (страница {page}, {size}) + The selected area is no longer on a page of this document. Выделенной области больше нет ни на одной странице этого документа. Create & Edit Создание и редактирование Review & Compare Рецензирование и сравнение Forms & Signatures Формы и подписи diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index c95f6d6bd6..772adf1b35 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2544,6 +2544,11 @@ animation effects Off Izklopljeni Edit this PDF Uredi ta PDF Export this PDF Izvozi ta PDF Find text or tools Poišči besedilo ali orodja + Print area… Natisni območje… + Save as image… Shrani kot sliko… + Save as image Shrani kot sliko + Selected area (page {page}, {size}) Izbrano območje (stran {page}, {size}) + The selected area is no longer on a page of this document. Izbrano območje ni več na nobeni strani tega dokumenta. Create & Edit Ustvarjanje in urejanje Review & Compare Pregled in primerjava Forms & Signatures Obrazci in podpisi diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index eb9eed3a0e..57d054b3e4 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2571,6 +2571,11 @@ animation effects Off Joaktiv Remove crop Hiq prerjen Send backward Kalo prapa Send to back Dërgo mbrapa + Print area… Zona e printimit… + Save as image Ruaj si imazh + Save as image… Ruaj si imazh… + Selected area (page {page}, {size}) Zona e zgjedhur (faqja {page}, {size}) + The selected area is no longer on a page of this document. Zona e zgjedhur nuk është më në një faqe të këtij dokumenti. # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 9b81ffb8b9..def4a1f211 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2448,6 +2448,11 @@ animation effects Off ఆఫ్ Edit this PDF ఈ PDFను సవరించు Export this PDF ఈ PDFను ఎగుమти చేయి Find text or tools వచనం లేదా సాధనాలను కనుగొనండి + Print area… భాగాన్ని ముద్రించు… + Save as image… చిత్రంగా సేవ్ చేయి… + Save as image చిత్రంగా సేవ్ చేయి + Selected area (page {page}, {size}) ఎంచుకున్న భాగం (పేజీ {page}, {size}) + The selected area is no longer on a page of this document. ఎంచుకున్న భాగం ఇకపై ఈ పత్రంలోని ఏ పేజీలోనూ లేదు. Create & Edit సృష్టించు & సవరించు Review & Compare సమీక్షించు & పోల్చు Forms & Signatures ఫారమ్‌లు & సంతకాలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6921a6c33e..2c869f29f2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2535,6 +2535,11 @@ animation effects Off Kapalı Edit this PDF Bu PDF'i düzenle Export this PDF Bu PDF'i dışa aktar Find text or tools Metin veya araç bul + Print area… Alanı yazdır… + Save as image… Resim olarak kaydet… + Save as image Resim olarak kaydet + Selected area (page {page}, {size}) Seçilen alan (sayfa {page}, {size}) + The selected area is no longer on a page of this document. Seçilen alan artık bu belgenin hiçbir sayfasında değil. Create & Edit Oluştur ve Düzenle Review & Compare İncele ve Karşılaştır Forms & Signatures Formlar ve İmzalar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a824769600..335f0ef705 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2561,6 +2561,11 @@ animation effects Off Вимкнено Edit this PDF Редактировать цей PDF Export this PDF Експортувати цей PDF Find text or tools Знайти текст або інструменти + Print area… Друк області… + Save as image… Зберегти як зображення… + Save as image Зберегти як зображення + Selected area (page {page}, {size}) Вибрана область (сторінка {page}, {size}) + The selected area is no longer on a page of this document. Вибраної області більше немає на жодній сторінці цього документа. Create & Edit Створення й редагування Review & Compare Рецензування й порівняння Forms & Signatures Форми й підписи diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e79a8aacc4..3649896a47 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2819,6 +2819,11 @@ key Space Space Edit this PDF Chỉnh sửa PDF này Export this PDF Xuất PDF này Find text or tools Tìm văn bản hoặc công cụ + Print area… In vùng… + Save as image… Lưu dưới dạng hình ảnh… + Save as image Lưu dưới dạng hình ảnh + Selected area (page {page}, {size}) Vùng đã chọn (trang {page}, {size}) + The selected area is no longer on a page of this document. Vùng đã chọn không còn nằm trên trang nào của tài liệu này. Attach a file… Đính kèm tệp… Delete attachment Xóa tệp đính kèm Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 96e9c3bb75..6ea70ec4e1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2503,6 +2503,11 @@ animation effects Off 关 Edit this PDF 编辑此 PDF Export this PDF 导出此 PDF Find text or tools 查找文本或工具 + Print area… 打印区域… + Save as image… 另存为图像… + Save as image 另存为图像 + Selected area (page {page}, {size}) 所选区域(第 {page} 页,{size}) + The selected area is no longer on a page of this document. 所选区域已不在此文档的任何页面上。 Create & Edit 创建和编辑 Review & Compare 审阅和比较 Forms & Signatures 表单和签名 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a1b933b830..0831e8aec6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2489,6 +2489,11 @@ animation effects Off 關 Edit this PDF 編輯此 PDF Export this PDF 匯出此 PDF Find text or tools 尋找文字或工具 + Print area… 列印區域… + Save as image… 另存為圖片… + Save as image 另存為圖片 + Selected area (page {page}, {size}) 所選區域(第 {page} 頁,{size}) + The selected area is no longer on a page of this document. 所選區域已不在此文件的任何頁面上。 Create & Edit 建立與編輯 Review & Compare 審閱與比較 Forms & Signatures 表單與簽名 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fe0138f27f..a186752909 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -67,6 +67,8 @@ mod widgets; use pdfcraft_engine::{DocId, Session}; pub use canvas::{DocView, RasterMemory}; +#[doc(hidden)] +pub use editing::download_mime_type; pub use editing::{CloseRequest, SaveTarget}; pub use files::{ExtractDraft, FilePurpose, FileRequest, RotateDraft, SplitDraft, SplitMode, SplitPlan}; pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore}; @@ -552,6 +554,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 9145256297..c6b0778f1f 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -154,7 +154,7 @@ impl PdfCraftApp { // left over from another document falls back to the whole document. let which = match (selected.is_empty(), keep.which) { (false, _) => Which::Selected, - (true, Which::Selected) => Which::All, + (true, Which::Selected | Which::Area) => Which::All, (true, other) => other, }; self.print_draft = PrintDraft { @@ -169,6 +169,7 @@ impl PdfCraftApp { sheet: 0, selected, which, + area: None, ..keep }; self.dialog = Some(crate::Dialog::Print); diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index d5cdbf16ed..944a8c1b77 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -212,7 +212,7 @@ pub(crate) fn body( .response .labelled_by(paper_label.id); // Label stock gets the label layout (cut, turned upright, fitted). - if d.paper != before && d.paper == print::LABEL_PAPER { + if 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; @@ -270,6 +270,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()); @@ -297,7 +308,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 be2a22efb3..df13af7c29 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -7,6 +7,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)] @@ -39,6 +41,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, @@ -100,6 +104,7 @@ impl Default for PrintDraft { which: Which::All, range: String::new(), selected: Vec::new(), + area: None, subset: Subset::All, reverse: false, handling: Handling::Size, @@ -158,11 +163,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 = PAPERS[self.paper.min(PAPERS.len() - 1)].1; + 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 @@ -170,16 +198,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 => match Layout::multiple(self.per_sheet) { Layout::Multiple { cols, rows, per_sheet, .. } => { Layout::Multiple { cols, rows, per_sheet, order: self.order, border: self.border, auto_rotate: self.auto_rotate } @@ -192,12 +217,13 @@ impl PrintDraft { }; Ok(print::Settings { pages, - paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1, + paper, orientation: self.orientation, layout, content: self.content, - raster: self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }), + 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 bce7c4d069..10b15ec586 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; @@ -74,6 +75,8 @@ mod reload; mod script_noop; mod side_panels; mod signing; +mod snapshot_area_tabs; +mod snapshot_tabs; mod stamps; mod text_select; mod theme; diff --git a/crates/ui-egui/tests/it/snapshot_area_tabs.rs b/crates/ui-egui/tests/it/snapshot_area_tabs.rs new file mode 100644 index 0000000000..b6dd497f6b --- /dev/null +++ b/crates/ui-egui/tests/it/snapshot_area_tabs.rs @@ -0,0 +1,73 @@ +//! The Snapshot tool belongs to the app, not to a tab: leaving it puts away the copied area +//! (and its menu) in every tab, not only the one on screen. + +use egui_kittest::Harness; +use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; + +/// One page of 200×300 saying "Page 1". +fn fixture() -> Vec { + let body = "BT /F1 18 Tf 20 150 Td (Page 1) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// 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, fixture()).unwrap(); + app.open_bytes("b.pdf", None, fixture()).unwrap(); + app.system_clipboard = false; + app + }); + for _ in 0..3000 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(h.state_mut().execute("edit.snapshot")); + h.run_steps(2); + h +} + +/// 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"); +} 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..64cb491ea4 --- /dev/null +++ b/crates/ui-egui/tests/it/snapshot_tabs.rs @@ -0,0 +1,106 @@ +//! Snapshot's area menu (Print area…, Save as image…) with several tabs: each tab keeps its own +//! area, so what it does must be about that tab's area. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +/// `n` pages of 200×300, each saying "Page i+1". +fn fixture(n: usize) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" "))); + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for i in 0..n { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); + let body = format!("BT /F1 18 Tf 20 150 Td (Page {}) Tj ET", i + 1); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// 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, fixture(2)).unwrap(); + app.open_bytes("b.pdf", None, fixture(2)).unwrap(); + app.system_clipboard = false; + app + }); + // Background rendering can take seconds on a loaded CI runner. + for _ in 0..3000 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + 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))); +} diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 3d824b9ea1..493386c8a0 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -968,6 +968,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); +} + #[test] fn fit_visible_zooms_to_the_content_width() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cabfc7823a..c49096a515 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1586,8 +1586,8 @@ milestone = "M3" status = "shipped" commands = ["edit.snapshot"] tools = ["page_render"] -evidence = ["crates/ui-egui/tests/it/viewer.rs::marquee_zoom_and_snapshot"] -notes = "copies the area at the current zoom to the clipboard as an image" +evidence = ["crates/ui-egui/tests/it/viewer.rs::marquee_zoom_and_snapshot", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_area_prints_alone_or_saves_as_an_image"] +notes = "copies the area at the current zoom to the clipboard as an image; the area then stays outlined with Print area… and Save as image… (PNG) under it until another tool is chosen (print.selected-area)" [[feature]] id = "view.copy-image" @@ -8112,6 +8112,18 @@ tools = ["doc_print"] evidence = ["crates/print/src/tests.rs::page_selection", "crates/automation/tests/it/automation.rs::printing_through_tools"] notes = "all, current, ranges with labels and open ends" +[[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" From 2be21c2e667f2224ca33640eea104785c46ed05f Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 9 Nov 2026 10:51:30 +0100 Subject: [PATCH 2/8] chore(deps): bump storytold/egui to 725a39fc Moves the egui crates from 6d518fc7 to the tip of the fork's migration/egui-037-20261024 line. It adds two patches: - E22: the fallback JavaScript highlighter no longer colours TypeScript-only keywords (storytold/egui#24). - E23: dragging a Resize corner keeps a direction that isn't resizable (storytold/egui#25). The rest is lint and PATCHES.md only. Default fonts are unchanged, so the egui-icons provenance link only moves to the new rev. The vendor graph test and vendor/README.md follow the pin. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 2 +- ATTRIBUTION.toml | 2 +- Cargo.lock | 28 ++++++++++++------------ Cargo.toml | 30 +++++++++++++------------- apps/pdfcraft/tests/it/vendor_graph.rs | 2 +- vendor/README.md | 2 +- 6 files changed, 33 insertions(+), 33 deletions(-) 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 67b588f25e..cbfb898f22 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 0c1bcdec8d..57892ae32b 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" @@ -8839,14 +8839,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 b0f3411624..7f5e61724f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,22 +149,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/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/vendor/README.md b/vendor/README.md index cd29f5e128..5d85dcf00f 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. From 3613779a01469656a2b6b3d32f65d38e0ea35902 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran Melendez Date: Mon, 9 Nov 2026 18:28:36 -0500 Subject: [PATCH 3/8] Snapshot: a document change puts the copied area away Its page index would name another page after pages were deleted or moved. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 4 ++++ crates/ui-egui/tests/it/snapshot_area_tabs.rs | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fba8eb6ee3..8ad7a9f932 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1214,6 +1214,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; diff --git a/crates/ui-egui/tests/it/snapshot_area_tabs.rs b/crates/ui-egui/tests/it/snapshot_area_tabs.rs index b6dd497f6b..5b4e450036 100644 --- a/crates/ui-egui/tests/it/snapshot_area_tabs.rs +++ b/crates/ui-egui/tests/it/snapshot_area_tabs.rs @@ -71,3 +71,13 @@ fn leaving_snapshot_clears_every_tab() { 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()); +} From 6034de11bf66705efadfa67a47a254a90647a261 Mon Sep 17 00:00:00 2001 From: Osvaldo Duran Melendez Date: Mon, 9 Nov 2026 22:22:43 -0500 Subject: [PATCH 4/8] Print a selected area: the automation test reads the printed PDF (sheet size, the area's clip rectangle, the page drawn through it) Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/tests/it/automation.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 64381fc21f..c847a67280 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -12126,7 +12126,23 @@ fn doc_print_a_selected_area() { ); 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(); - assert_eq!(pdfcraft_model::pages(&out).len(), 1); + 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"), From 51a87eb3d9dd21fa12cbe8b9a9af8d03e367e8b7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 10 Nov 2026 14:27:17 +0700 Subject: [PATCH 5/8] Give the snapshot-area booklet test main's creep field --- crates/print/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index b17b66bb7a..402b2aaa13 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1319,7 +1319,7 @@ fn a_selected_area_prints_alone_fitted_or_at_actual_size() { 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 }), + (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 }), ] { From e987f46fb68e395d494c46e067f931c41644a848 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 10 Nov 2026 15:26:39 +0700 Subject: [PATCH 6/8] Snapshot tab tests share one harness on the shared page fixture --- crates/ui-egui/tests/it/main.rs | 1 - crates/ui-egui/tests/it/snapshot_area_tabs.rs | 83 ------------------- crates/ui-egui/tests/it/snapshot_tabs.rs | 77 +++++++++-------- 3 files changed, 38 insertions(+), 123 deletions(-) delete mode 100644 crates/ui-egui/tests/it/snapshot_area_tabs.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a217238c7d..6dcbf5d1ba 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -80,7 +80,6 @@ mod scroll_prefetch; mod search_position; mod side_panels; mod signing; -mod snapshot_area_tabs; mod snapshot_tabs; mod split_view; mod split_view_edits; diff --git a/crates/ui-egui/tests/it/snapshot_area_tabs.rs b/crates/ui-egui/tests/it/snapshot_area_tabs.rs deleted file mode 100644 index 5b4e450036..0000000000 --- a/crates/ui-egui/tests/it/snapshot_area_tabs.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! The Snapshot tool belongs to the app, not to a tab: leaving it puts away the copied area -//! (and its menu) in every tab, not only the one on screen. - -use egui_kittest::Harness; -use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; - -/// One page of 200×300 saying "Page 1". -fn fixture() -> Vec { - let body = "BT /F1 18 Tf 20 150 Td (Page 1) Tj ET"; - let objs = [ - "<< /Type /Catalog /Pages 2 0 R >>".to_string(), - "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), - "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), - "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(), - format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let x = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offs { - 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); - out -} - -/// 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, fixture()).unwrap(); - app.open_bytes("b.pdf", None, fixture()).unwrap(); - app.system_clipboard = false; - app - }); - for _ in 0..3000 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert!(h.state_mut().execute("edit.snapshot")); - h.run_steps(2); - h -} - -/// 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/snapshot_tabs.rs b/crates/ui-egui/tests/it/snapshot_tabs.rs index 64cb491ea4..877be129f7 100644 --- a/crates/ui-egui/tests/it/snapshot_tabs.rs +++ b/crates/ui-egui/tests/it/snapshot_tabs.rs @@ -1,54 +1,22 @@ -//! Snapshot's area menu (Print area…, Save as image…) with several tabs: each tab keeps its own -//! area, so what it does must be about that tab's area. +//! 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; - -/// `n` pages of 200×300, each saying "Page i+1". -fn fixture(n: usize) -> Vec { - let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; - let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); - objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" "))); - objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); - for i in 0..n { - objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); - let body = format!("BT /F1 18 Tf 20 150 Td (Page {}) Tj ET", i + 1); - objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); - } - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let x = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offs { - 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); - out -} +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, fixture(2)).unwrap(); - app.open_bytes("b.pdf", None, fixture(2)).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 }); - // Background rendering can take seconds on a loaded CI runner. - for _ in 0..3000 { - h.run_steps(2); - if !h.state().render_pending() { - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } + crate::support::settle(&mut h); assert!(h.state_mut().execute("edit.snapshot")); h.run_steps(2); h @@ -104,3 +72,34 @@ fn the_area_stays_after_print_area() { 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()); +} From 4a83ec87f52c384ce22c130f8c87182a40e4f109 Mon Sep 17 00:00:00 2001 From: KiwiKwi <101988920+Kiwi-Kwi@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:38:55 +0700 Subject: [PATCH 7/8] fix(annot): drop Apple Markup's stale copy when a page's annotations change (#2829) iOS Markup keeps a private copy of a page's markup (/AAPL:PPK, /AAPL:PPKHash on the page, /AAPL:AKExtras on each annotation) and draws from it. Once /Annots changes, that copy is stale: iOS 26 drew a Markup signature twice. Adding, deleting or answering a comment now drops the copy, records the drop in the repair log, and leaves a refused edit untouched. The change lives in lookup.rs (change_annots), called from create.rs (add) and modify.rs (delete, reply, mark). Each of the four operations has a regression test on an Apple Markup page. Co-authored-by: BabaKush <101988920+BabaKush@users.noreply.github.com> --- crates/annot/src/create.rs | 4 +- crates/annot/src/lookup.rs | 50 +++++++++++++++++++++ crates/annot/src/modify.rs | 11 +++-- crates/annot/src/tests.rs | 90 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) 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()); +} From 8864903795373b3b23d13cb98ab0a1df692f2e16 Mon Sep 17 00:00:00 2001 From: huppiflupp <144213886+huppiflupp@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:23:21 +0100 Subject: [PATCH 8/8] M10: Recognize text no longer clips the first and last letters of a line (#5058) Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- Cargo.lock | 1 + ROADMAP.md | 1 + crates/engine/src/ocr.rs | 40 +++++++++++++++++++ crates/ocr/Cargo.toml | 1 + crates/ocr/src/ocrs.rs | 84 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 781255619d..5558d1a06f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4565,6 +4565,7 @@ dependencies = [ "pdfcraft-fonts", "png 0.18.1", "rten", + "rten-imageproc", "serde_json", "sha2 0.11.1", "tempfile", diff --git a/ROADMAP.md b/ROADMAP.md index e8138a7e3d..83ba1c46d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -845,6 +845,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/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.