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] 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"