From 0cb311d21c44940302ac7e82dbb43312a4d2adaf Mon Sep 17 00:00:00 2001 From: Francesco Clemeno Date: Mon, 12 Oct 2026 13:47:35 +0200 Subject: [PATCH] M3: select an image on the page and copy it (#1363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M3: select an image on the page and copy it Select tool: a click on a raster image selects it (also one drawn through Form artwork, or with text over it such as an OCR layer); ⌘C copies it as a file in its original format (a JPEG as stored in the PDF, otherwise a lossless PNG). Right-click ▸ Copy Image copies its pixels at full resolution, and Copy Image in Original Format the file; Edit text & images has both for the selected image. Both respect the document's copy permission. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWUuTDPKL5wivnSJuCkC8Q * M3: translate the copy-image strings into Czech, Kazakh, Slovene and Turkish Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWUuTDPKL5wivnSJuCkC8Q * Copy image: keep the clipboard file in a private per-user folder Copy Image in Original Format wrote into the shared temporary folder (/tmp/pdfcraft-clipboard) and deleted everything in it first, so another user on the machine could plant a link or folder there and have PdfCraft delete or overwrite their target. The file now goes in PdfCraft's per-user cache folder (or the portable data folder), which is checked to be a real folder and set to 0700 on Unix (refused when it isn't ours). Only earlier copies (regular files named ' p img.jpg|png') are removed, links are never followed, and the new file is created with create_new so it can't be redirected. Co-Authored-By: Claude Opus 5.5 * Tests: escape Windows paths in synthetic GoToR file specifications canonicalize() returns \\?\C:\... on Windows; unescaped backslashes in a PDF literal string become control characters, so link_open refused the target and main's Windows CI failed. Taken from #1359's helper fix. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 38 ++-- crates/automation/tests/automation.rs | 4 + crates/engine/src/copy_image.rs | 108 ++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 71 +++++++ crates/ui-egui/src/canvas.rs | 41 +++- crates/ui-egui/src/comments.rs | 4 + crates/ui-egui/src/content_ui.rs | 26 +++ crates/ui-egui/src/edit_text_ui.rs | 11 + crates/ui-egui/src/i18n/ar.tsv | 6 + crates/ui-egui/src/i18n/bg.tsv | 6 + crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/kk.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sl.tsv | 6 + crates/ui-egui/src/i18n/te.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/image_pick.rs | 273 +++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 10 + crates/ui-egui/tests/document_links.rs | 4 + crates/ui-egui/tests/viewer.rs | 123 +++++++++++ parity/acrobat-features.toml | 12 ++ 33 files changed, 820 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/copy_image.rs create mode 100644 crates/ui-egui/src/image_pick.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 8456df07..6aafc77e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dd5da0d13f9e75b2d285a2337e2ebda61e99ffae28a2e807c719e094a620c71f" +sha256 = "7f600eaa47f23fac4f9f0fece1454e59756c8dc57c7c697502cb02ee21410ac0" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d3729a1cd0d616e434d2852e918e16542712a682432484b964d4c9530e0001fa" +sha256 = "37733adc79d7d32a057811b4dc5eb31251ef8ec91f1dac430253c69faac1c33f" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c1bf8de7fd14d2814ae552900eced600d5c13ebe951bba62e5fee265c072dec7" +sha256 = "285b689b5224a66079738ab39d5a27e5f9ba72e1ab42e148f263b7ea320c4729" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6b7e7f11ef0bd19e4862f2b3e4f3fa4e7c77857c264dc36620875cb62b4d71ab" +sha256 = "e8154a154129ca158396de84d2d432ce1c587477795a6a14985f7834db255744" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4d915ac6ec905b24af4b9659c8457ac6fd8de93b2f989403c90fb7c2617c6fc3" +sha256 = "50d4e66209e8b56707bcd148d4644eb0ad9f49dfc195287cfbd851ffbe5c3830" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "273d16de3d4044aa70eb1c1861610cfe306908df53a03ffd96242d4434be9882" +sha256 = "13e677d44d5224e60af28fcc89d8f8af552368ca3c11f0a871498c60fefa7ee2" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bd02e51b45e3f2a4f647af1602a31e15401e3715dc88a964afd34a4c29aa5392" +sha256 = "e81112d69acc2e1bbb44c5252f8f323b1d637059b5ac5f4c9fcd3eb985a53442" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c597d66f587ff89fe88ee1893f418665a1f7f81ba786378a65e69bd5bf65a952" +sha256 = "df3d3650af984251372bf22b859ba6aca47f26eeff790e834d57a5a4071a1b48" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "91d826a767d02eeaa6c0d0084fcb78c29c5d65b57328c5300d471e67bb256350" +sha256 = "c9e453382d9d5c83f7b0f986befc6113a62b4736a6b94a6462805cead406753b" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6e976b1deaee1ed25f30626c833ea7dca8c0daf806fc6d9c97e096a90b1acb36" +sha256 = "2a205868b13f719dbc91fdc50f1133f5f317aa2245143bfeaa048190bc9c7d8c" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c9467546f1659c734ee4ebcf23eec357bae84230c2e5a3f68baccec7c2cab86c" +sha256 = "3053aff461c289404bf3009c611037870b945692c65ce8d7c14ad26ac477660c" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f643927cf4de4038f4c80f4deede8f440d7f5e4adf57fb92c6f0afbef86258d3" +sha256 = "882b3937802bbbd5afe0087264ee4118161351e8828cb02b608488ccfe39bfe9" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "30cf24faab89cf2fe9c97871ba9d5daf38cbf8adb3bbcaf0ff9bcb3fb2ff7bc0" +sha256 = "8cca054e167995ca6d95bc45fc0f75385a15a78852e4b173bd4c5b7b651c5a9d" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4d3b525a99206de8b583de2ef3be3de2343051a5068ddad723e4e684472a11ec" +sha256 = "2f4a6429c0c290a53268733dac08da221085992036a4d304d7663d7947d806d3" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6d37d306022c6f6860beeef4aa423b1a43b5ce0e941268624642aaed5d0a790c" +sha256 = "2792f38b1699269b5886fa99a17c7773c3934df95dc4e2db3ba6a475dd652ba0" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3daf5f42f0ce558add463370da25d823c9371eec0d95c11d6d2f2fd0cb1da0c8" +sha256 = "937ac7eac4230c9a1d26d6bd7ef4f08f8d8c9d391a091682e8758fba3ca9678e" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b401a44ff5a5d07bf1cb6786139f9886f0f354137fe4dcc41c46115d09235730" +sha256 = "7ced08c3485c5eddf92767e6deb009d72351d70cefe60be654cfdac441f40f4b" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f1ded17cdf0c3fe53f4af77b91319913122ea4e3df2f7be0123def1e753de49b" +sha256 = "4e70c6db374db37bf70a17e251675b94128fb11b4d6d20ef669b36fd4ae14ff1" [[asset]] path = "assets/icons/accessibility.svg" @@ -3328,4 +3328,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "14301dcc1f33e13c20543a95fc9434049ff21affe0c49f54147fb037e2a10b79" +sha256 = "6c01a439220415b83acab79c014286e5871ba7946a9c3b9a4776be797d55a086" diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 644ae404..1c8f931b 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -36,6 +36,10 @@ fn fixture(n: usize) -> Vec { } fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec { + // canonicalize() uses a Windows device prefix; a PDF file specification uses the ordinary drive path. + #[cfg(windows)] + let file = file.strip_prefix(r"\\?\").filter(|path| path.as_bytes().get(1) == Some(&b':')).unwrap_or(file); + let file = file.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)"); let destination = destination.map_or_else(String::new, |d| format!("/D {d}")); let objects: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs new file mode 100644 index 00000000..642c84e6 --- /dev/null +++ b/crates/engine/src/copy_image.rs @@ -0,0 +1,108 @@ +//! Copy Image: find the picture under a point and hand it to the clipboard, as pixels or as a +//! file in its original format. A picture drawn through grouped +//! Form artwork (as Word, InDesign and many scanners write them) is found inside the group. + +use std::collections::HashSet; + +use pdfcraft_cos::{Dict, Document as Cos, ObjRef, Object}; + +use crate::Document; + +/// Clipboards copy the whole bitmap; past this many pixels a pixel copy is refused. +const MAX_COPY_PIXELS: u64 = 64_000_000; +/// How deep Form artwork is searched for its picture. +const MAX_FORM_DEPTH: usize = 12; + +impl Document { + /// The topmost picture on `page` under user-space point `at`, as its index in + /// [`Document::page_images`]: a raster image, or Form artwork that holds one. + pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option { + let editor = self.editor.as_ref()?; + let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]); + self.page_images(page).iter().rposition(|i| { + inside(&i.rect) + && match (i.is_form, i.object) { + (false, Some(_)) => true, + (true, Some(form)) => picture_in_form(&editor.cos, form).is_some(), + _ => false, + } + }) + } + + /// Copy Image in Original Format: picture `index` on `page` as a file (extension, bytes): + /// a JPEG as stored in the PDF, otherwise a lossless PNG. + pub fn page_image_copy_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; + let object = img.object.ok_or("the image has no object")?; + let picture = if img.is_form { picture_in_form(&editor.cos, object).ok_or("this artwork holds no picture")? } else { object }; + pdfcraft_create::image_file(&editor.cos, picture) + } + + /// Copy Image: picture `index` on `page` at its own resolution, as straight (unpremultiplied) + /// RGBA pixels (width, height, pixels) for the clipboard. Soft masks become alpha. + pub fn page_image_rgba(&self, page: usize, index: usize) -> Result<(u32, u32, Vec), String> { + let (_, bytes) = self.page_image_copy_file(page, index)?; + let format = image::guess_format(&bytes).map_err(|e| e.to_string())?; + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes.as_slice()), format); + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_COPY_PIXELS * 4); + reader.limits(limits); + let decoder = reader.into_decoder().map_err(|e| e.to_string())?; + let (w, h) = image::ImageDecoder::dimensions(&decoder); + if w == 0 || h == 0 { + return Err("the image is empty".into()); + } + if u64::from(w) * u64::from(h) > MAX_COPY_PIXELS { + return Err(format!("the image is too large to copy ({w} × {h} pixels); use Save Image As")); + } + let rgba = image::DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?.into_rgba8(); + Ok((rgba.width(), rgba.height(), rgba.into_raw())) + } +} + +/// The largest raster image Form `form` draws through its resources (nested Forms included). +fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { + let mut seen = HashSet::from([form]); + let mut best: Option<(u64, ObjRef)> = None; + let res = resources(cos, form)?; + collect(cos, &res, 0, &mut seen, &mut best); + best.map(|(_, r)| r) +} + +fn resources(cos: &Cos, stream: ObjRef) -> Option { + match &*cos.get(stream) { + Object::Stream(s) => s.dict.get(b"Resources").map(|r| cos.resolve(r)).and_then(|r| r.as_dict().cloned()), + _ => None, + } +} + +fn collect(cos: &Cos, res: &Dict, depth: usize, seen: &mut HashSet, best: &mut Option<(u64, ObjRef)>) { + if depth > MAX_FORM_DEPTH { + return; + } + let Some(xobjects) = res.get(b"XObject").map(|x| cos.resolve(x)).and_then(|x| x.as_dict().cloned()) else { return }; + for (_, v) in xobjects.iter() { + let Some(r) = v.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let obj = cos.get(r); + let Object::Stream(s) = &*obj else { continue }; + match s.dict.name(b"Subtype") { + Some(b"Image") => { + let px = |k: &[u8]| u64::try_from(s.dict.int(k).unwrap_or(0)).unwrap_or(0); + let size = px(b"Width").saturating_mul(px(b"Height")); + if size > 0 && best.is_none_or(|(b, _)| size > b) { + *best = Some((size, r)); + } + } + Some(b"Form") => { + if let Some(inner) = resources(cos, r) { + collect(cos, &inner, depth + 1, seen, best); + } + } + _ => {} + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8ae22a54..d2479b4e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod camera_edges; pub mod catalog; pub mod commands; pub mod compare; +mod copy_image; pub mod dates; pub mod export; pub mod js; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 831954ad..d07af91c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4124,3 +4124,74 @@ fn edits_let_go_of_the_previous_working_file() { assert_eq!(doc.info.annotations.len(), 1); let _ = shown(&s, id, 0); } + +#[test] +fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() { + let mut s = Session::new(); + let mut px = image::RgbaImage::new(3, 2); + px.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + px.put_pixel(2, 1, image::Rgba([0, 0, 255, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(px.clone()).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let pic = s.create_from_images(&[("p.png".into(), png.into_inner())]).unwrap(); + let id = s.open("p.pdf", None, pic, None).unwrap(); + let d = s.get(id).unwrap(); + let r = d.page_images(0)[0].rect; + assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0)); + assert_eq!(d.page_image_at(0, [r[2] + 10.0, r[3] + 10.0]), None); + assert_eq!(d.page_image_at(7, [0.0, 0.0]), None); + let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap(); + assert_eq!((w, h), (3, 2)); + assert_eq!(rgba, px.into_raw()); + assert!(d.page_image_rgba(0, 1).is_err()); +} + +/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write +/// them), with a caption over it and an empty full-page Form on top. +#[test] +fn copy_image_finds_a_picture_inside_form_artwork_under_text() { + let jpeg = jpeg_bytes(); + let content = b"q 100 0 0 100 50 50 cm /Fm1 Do Q BT /F1 12 Tf 60 100 Td (Caption over the picture) Tj ET /Fm2 Do".to_vec(); + let form = b"/Im1 Do".to_vec(); + let stream = |dict: &str, data: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + o.extend_from_slice(data); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Fm1 6 0 R /Fm2 9 0 R >> >> >>".to_vec(), + stream("", &content), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im1 7 0 R /Tiny 8 0 R >> >>", &form), + stream("/Type /XObject /Subtype /Image /Width 3 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode", &jpeg), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", &[255, 0, 0]), + stream("/Type /XObject /Subtype /Form /BBox [0 0 200 300]", b""), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(out), None).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.page_images(0).len(), 2, "{:?}", d.page_images(0)); + // Under the caption, inside the picture: the Form holding it, not the empty Form on top. + assert_eq!(d.page_image_at(0, [100.0, 100.0]), Some(0)); + assert_eq!(d.page_image_at(0, [10.0, 10.0]), None, "the empty full-page Form holds no picture"); + // The largest picture in the group, as the JPEG stored in the PDF. + assert_eq!(d.page_image_copy_file(0, 0).unwrap(), ("jpg", jpeg)); + assert!(d.page_image_copy_file(0, 1).is_err()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 08e2a3ab..1f9105e2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -398,6 +398,8 @@ pub struct DocView { pub(crate) edit_images: HashMap)>, pub(crate) objects: crate::object_ui::ObjectSelection, pub image_selection: Option, + /// Select tool: the picture clicked, to copy in its original format. + pub picked_image: Option, /// A paragraph box being dragged (moved, or resized from its right edge) in Edit text. pub block_drag: Option, /// Commenting state: selected comment, gestures, composer. @@ -636,6 +638,7 @@ impl DocView { edit_images: HashMap::new(), objects: Default::default(), image_selection: None, + picked_image: None, block_drag: None, pending_action: None, insert_at: None, @@ -2858,6 +2861,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } + if tool == QuickTool::Select && !consumed { + crate::image_pick::page_input(&resp, &xf, i, info, doc, pointer, view); + } + crate::image_pick::paint(painter, &xf, i, info, view); comments::page_after_text(&resp, &pcx, view); if tool == QuickTool::Redact && can_modify { crate::redact_ui::after_text(&resp, i, info, view); @@ -3055,7 +3062,35 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } return; } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed); + // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. + let mut copy_image = None; + if view.comments.selected.is_none() + && let Some((page, at)) = view.comments.context_at + { + // The page's content is parsed once per right-click, not on every frame the menu is open. + let key = egui::Id::new("copy-image-under-menu"); + let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); + let hit = match cached { + Some(c) => c.2, + None => { + let hit = doc.page_image_at(page, at); + ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); + hit + } + }; + if let Some(image) = hit { + if ui.button(tl!("Copy Image")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImage(page, image)); + ui.close(); + } + if ui.button(tl!("Copy Image in Original Format")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); + ui.close(); + } + ui.separator(); + } + } + canvas_action = comments::context_menu(ui, view, info, prefs, allowed).or(copy_image); }); (wanted, visible_now) }); @@ -3236,6 +3271,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { app.comments_panel_closed = false; } Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)), + Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)), + Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} } match clicked_link { @@ -3314,6 +3351,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { match image_action { Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index), Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index), + Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => app.copy_page_image(page, index), + Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index), None => {} } if let Some(why) = refused { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index dba9159b..623f89b6 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1431,6 +1431,10 @@ pub enum CanvasAction { OpenComments, /// Comment Properties for (page, index). Properties(usize, usize), + /// Copy Image: image (page, index in `page_images`) to the clipboard. + CopyImage(usize, usize), + /// Copy Image in Original Format: the same image as a file (JPEG as stored, else PNG). + CopyImageFile(usize, usize), } /// How many custom colours the "Recent colours" row keeps. diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index fa781e18..5553c850 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -549,6 +549,32 @@ impl crate::PdfCraftApp { } } + /// Copy Image: image `index` on `page`, at its own resolution, to the clipboard. + pub(crate) fn copy_page_image(&mut self, page: usize, index: usize) { + let Some((_, id)) = self.active_ids() else { return }; + if !self.may_copy(id) { + return; + } + let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return }; + let copied = pixels.and_then(|(w, h, rgba)| { + #[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) }; + arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?; + } + #[cfg(target_arch = "wasm32")] + if self.system_clipboard { + return Err("the web version can't put images on the clipboard yet; use Save Image As".to_string()); + } + self.last_copied_image = Some((w, h, rgba)); + Ok((w, h)) + }); + match copied { + Ok((w, h)) => self.notify_fmt("The image has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]), + Err(e) => self.notify_fmt("Couldn't copy the image: {e}", &[("e", &e)]), + } + } + /// Click an image field: pick a picture for it. pub fn choose_field_image(&mut self, name: &str) { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 4cd8aaea..e8e91ab3 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -392,6 +392,8 @@ fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> { pub(crate) enum ImageAction { Replace(usize, usize), Save(usize, usize), + Copy(usize, usize), + CopyFile(usize, usize), } fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { @@ -591,6 +593,15 @@ pub(crate) fn image_input( *action = Some(ImageAction::Save(page, hit)); ui.close(); } + // Copying also finds the picture inside grouped Form artwork. + if ui.button(tl!("Copy Image")).clicked() { + *action = Some(ImageAction::Copy(page, hit)); + ui.close(); + } + if ui.button(tl!("Copy Image in Original Format")).clicked() { + *action = Some(ImageAction::CopyFile(page, hit)); + ui.close(); + } ui.separator(); if ui.button(tl!("Delete")).clicked() { view.image_selection = None; diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3c50c86a..8163ee30 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -760,6 +760,12 @@ Flip Vertical قلب عمودي Replace Image… استبدال الصورة… Save Image As… حفظ الصورة باسم… + Copy Image نسخ الصورة + The image has been copied ({w} × {h} pixels) نُسخت الصورة ({w} × {h} بكسل) + Couldn't copy the image: {e} تعذّر نسخ الصورة: {e} + Copy Image in Original Format نسخ الصورة بتنسيقها الأصلي + The image has been copied as a {ext} file نُسخت الصورة كملف {ext} + The page has changed; select the image again تغيّرت الصفحة؛ حدّد الصورة مرة أخرى Check for updates التحقق من التحديثات No check has run yet. لم يُجرَ أي تحقق بعد. Checking for a newer version… جارٍ التحقق من وجود إصدار أحدث… diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index b55496f3..febd1f9c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -436,6 +436,12 @@ Flip Vertical Обърни вертикално Replace Image… Замени изображението… Save Image As… Запиши изображението като… + Copy Image Копирай изображението + The image has been copied ({w} × {h} pixels) Изображението е копирано ({w} × {h} пиксела) + Couldn't copy the image: {e} Изображението не можа да бъде копирано: {e} + Copy Image in Original Format Копирай изображението в оригиналния формат + The image has been copied as a {ext} file Изображението е копирано като файл {ext} + The page has changed; select the image again Страницата е променена; изберете изображението отново Check for updates Провери за актуализации No check has run yet. Още не е извършвана проверка. Checking for a newer version… Проверка за по-нова версия… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9e1b6239..ee4f9bcb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -835,6 +835,12 @@ key Space Mezerník Flip Vertical Převrátit svisle Replace Image… Nahradit obrázek… Save Image As… Uložit obrázek jako… + Copy Image Kopírovat obrázek + The image has been copied ({w} × {h} pixels) Obrázek byl zkopírován ({w} × {h} pixelů) + Couldn't copy the image: {e} Obrázek nelze zkopírovat: {e} + Copy Image in Original Format Kopírovat obrázek v původním formátu + The image has been copied as a {ext} file Obrázek byl zkopírován jako soubor {ext} + The page has changed; select the image again Stránka se změnila; vyberte obrázek znovu Check for updates Zkontrolovat aktualizace No check has run yet. Kontrola zatím neproběhla. Checking for a newer version… Hledání novější verze… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8cdbeca4..7811a78e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -738,6 +738,12 @@ Flip Vertical Vertikal spiegeln Replace Image… Bild ersetzen… Save Image As… Bild speichern unter… + Copy Image Bild kopieren + The image has been copied ({w} × {h} pixels) Das Bild wurde kopiert ({w} × {h} Pixel) + Couldn't copy the image: {e} Das Bild konnte nicht kopiert werden: {e} + Copy Image in Original Format Bild im Originalformat kopieren + The image has been copied as a {ext} file Das Bild wurde als {ext}-Datei kopiert + The page has changed; select the image again Die Seite hat sich geändert; wählen Sie das Bild erneut aus Check for updates Nach Updates suchen No check has run yet. Es wurde noch nicht gesucht. Checking for a newer version… Suche nach einer neueren Version… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b71ad79a..ba79a5b8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -754,6 +754,12 @@ Flip Vertical Voltear verticalmente Replace Image… Reemplazar imagen… Save Image As… Guardar imagen como… + Copy Image Copiar imagen + The image has been copied ({w} × {h} pixels) Se ha copiado la imagen ({w} × {h} píxeles) + Couldn't copy the image: {e} No se pudo copiar la imagen: {e} + Copy Image in Original Format Copiar imagen en formato original + The image has been copied as a {ext} file Se ha copiado la imagen como archivo {ext} + The page has changed; select the image again La página ha cambiado; vuelva a seleccionar la imagen Check for updates Buscar actualizaciones No check has run yet. Aún no se ha hecho ninguna comprobación. Checking for a newer version… Buscando una versión más reciente… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a698bcc6..e81b5f01 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -734,6 +734,12 @@ Flip Vertical Retourner verticalement Replace Image… Remplacer l'image… Save Image As… Enregistrer l'image sous… + Copy Image Copier l'image + The image has been copied ({w} × {h} pixels) L'image a été copiée ({w} × {h} pixels) + Couldn't copy the image: {e} Impossible de copier l'image : {e} + Copy Image in Original Format Copier l'image au format d'origine + The image has been copied as a {ext} file L'image a été copiée en tant que fichier {ext} + The page has changed; select the image again La page a changé ; sélectionnez de nouveau l'image Check for updates Rechercher des mises à jour No check has run yet. Aucune vérification n'a encore été faite. Checking for a newer version… Recherche d'une version plus récente… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8f37bbda..b9b3f558 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -731,6 +731,12 @@ Flip Vertical Függőleges tükrözés Replace Image… Kép cseréje… Save Image As… Kép mentése másként… + Copy Image Kép másolása + The image has been copied ({w} × {h} pixels) A kép a vágólapra került ({w} × {h} képpont) + Couldn't copy the image: {e} A képet nem sikerült másolni: {e} + Copy Image in Original Format Kép másolása eredeti formátumban + The image has been copied as a {ext} file A kép {ext}-fájlként a vágólapra került + The page has changed; select the image again Az oldal megváltozott; jelölje ki újra a képet Check for updates Frissítések keresése No check has run yet. Még nem futott ellenőrzés. Checking for a newer version… Újabb verzió keresése… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d2244a91..5448ef63 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -757,6 +757,12 @@ Flip Vertical Capovolgi verticalmente Replace Image… Sostituisci immagine… Save Image As… Salva immagine con nome… + Copy Image Copia immagine + The image has been copied ({w} × {h} pixels) L'immagine è stata copiata ({w} × {h} pixel) + Couldn't copy the image: {e} Impossibile copiare l'immagine: {e} + Copy Image in Original Format Copia immagine nel formato originale + The image has been copied as a {ext} file L'immagine è stata copiata come file {ext} + The page has changed; select the image again La pagina è cambiata; seleziona di nuovo l'immagine Check for updates Verifica aggiornamenti No check has run yet. Nessuna verifica eseguita finora. Checking for a newer version… Ricerca di una versione più recente… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ed8b20b9..c4277720 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -761,6 +761,12 @@ Flip Vertical 上下反転 Replace Image… 画像を置換… Save Image As… 画像を別名で保存… + Copy Image 画像をコピー + The image has been copied ({w} × {h} pixels) 画像をコピーしました({w} × {h} ピクセル) + Couldn't copy the image: {e} 画像をコピーできませんでした: {e} + Copy Image in Original Format 画像を元の形式でコピー + The image has been copied as a {ext} file 画像を {ext} ファイルとしてコピーしました + The page has changed; select the image again ページが変更されました。画像をもう一度選択してください Check for updates アップデートを確認 No check has run yet. まだ確認していません。 Checking for a newer version… 新しいバージョンを確認中… diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 28d9364e..fc807c78 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -717,6 +717,12 @@ Flip Vertical Тікке аудару Replace Image… Суретті ауыстыру… Save Image As… Суретті басқаша сақтау… + Copy Image Суретті көшіру + The image has been copied ({w} × {h} pixels) Сурет көшірілді ({w} × {h} пиксель) + Couldn't copy the image: {e} Суретті көшіру мүмкін болмады: {e} + Copy Image in Original Format Суретті бастапқы пішімде көшіру + The image has been copied as a {ext} file Сурет {ext} файлы ретінде көшірілді + The page has changed; select the image again Бет өзгерді; суретті қайта таңдаңыз Check for updates Жаңартуларды тексеру No check has run yet. Тексеру әлі жүргізілген жоқ. Checking for a newer version… Жаңа нұсқа бар ма екені тексерілуде… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 527a58a5..ee205f56 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -753,6 +753,12 @@ Flip Vertical Odbij w pionie Replace Image… Zastąp obraz… Save Image As… Zapisz obraz jako… + Copy Image Kopiuj obraz + The image has been copied ({w} × {h} pixels) Obraz został skopiowany ({w} × {h} pikseli) + Couldn't copy the image: {e} Nie można skopiować obrazu: {e} + Copy Image in Original Format Kopiuj obraz w oryginalnym formacie + The image has been copied as a {ext} file Obraz został skopiowany jako plik {ext} + The page has changed; select the image again Strona się zmieniła; zaznacz obraz ponownie Check for updates Sprawdź aktualizacje No check has run yet. Nie sprawdzono jeszcze aktualizacji. Checking for a newer version… Sprawdzanie dostępności nowszej wersji… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a97f0543..f22ad9c8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -390,6 +390,12 @@ Flip Vertical Inverter verticalmente Replace Image… Substituir imagem… Save Image As… Salvar imagem como… + Copy Image Copiar imagem + The image has been copied ({w} × {h} pixels) A imagem foi copiada ({w} × {h} pixels) + Couldn't copy the image: {e} Não foi possível copiar a imagem: {e} + Copy Image in Original Format Copiar imagem no formato original + The image has been copied as a {ext} file A imagem foi copiada como arquivo {ext} + The page has changed; select the image again A página mudou; selecione a imagem novamente Check for updates Verificar atualizações No check has run yet. Nenhuma verificação foi executada ainda. Checking for a newer version… Verificando se há uma versão mais recente… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4e5593ad..ac054d02 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -754,6 +754,12 @@ Flip Vertical Отразить по вертикали Replace Image… Заменить изображение… Save Image As… Сохранить изображение как… + Copy Image Копировать изображение + The image has been copied ({w} × {h} pixels) Изображение скопировано ({w} × {h} пикселей) + Couldn't copy the image: {e} Не удалось скопировать изображение: {e} + Copy Image in Original Format Копировать изображение в исходном формате + The image has been copied as a {ext} file Изображение скопировано как файл {ext} + The page has changed; select the image again Страница изменилась; выберите изображение снова Check for updates Проверить обновления No check has run yet. Проверка ещё не выполнялась. Checking for a newer version… Проверка новой версии… diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 75930355..e8dc0162 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1536,6 +1536,12 @@ Save & Close Shrani in zapri Save (M4) Shrani (M4) Save Image As… Shrani sliko kot… + Copy Image Kopiraj sliko + The image has been copied ({w} × {h} pixels) Slika je kopirana ({w} × {h} slikovnih pik) + Couldn't copy the image: {e} Slike ni bilo mogoče kopirati: {e} + Copy Image in Original Format Kopiraj sliko v izvirni obliki + The image has been copied as a {ext} file Slika je kopirana kot datoteka {ext} + The page has changed; select the image again Stran se je spremenila; znova izberite sliko Save as PDF Shrani kot PDF Save as PDF/A Shrani kot PDF/A Save as PDF/A-1b Shrani kot PDF/A-1b diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 72400403..292b9ae8 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -735,6 +735,12 @@ comment menu Edit సవరించు Flip Vertical నిలువుగా తిరగేయి Replace Image… చిత్రాన్ని భర్తీ చేయి… Save Image As… చిత్రాన్ని ఫైల్‌గా సేవ్ చేయి… + Copy Image చిత్రాన్ని కాపీ చేయి + The image has been copied ({w} × {h} pixels) చిత్రం కాపీ చేయబడింది ({w} × {h} పిక్సెల్‌లు) + Couldn't copy the image: {e} చిత్రాన్ని కాపీ చేయడం సాధ్యం కాలేదు: {e} + Copy Image in Original Format చిత్రాన్ని అసలు ఫార్మాట్‌లో కాపీ చేయి + The image has been copied as a {ext} file చిత్రం {ext} ఫైల్‌గా కాపీ చేయబడింది + The page has changed; select the image again పేజీ మారింది; చిత్రాన్ని మళ్లీ ఎంచుకోండి Check for updates అప్‌డేట్‌ల కోసం తనిఖీ చేయి No check has run yet. ఇంకా తనిఖీ జరగలేదు. Checking for a newer version… కొత్త వెర్షన్ కోసం తనిఖీ చేస్తోంది… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5af8648a..0d990dc9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -716,6 +716,12 @@ Flip Vertical Dikey Çevir Replace Image… Resmi Değiştir… Save Image As… Resmi Farklı Kaydet… + Copy Image Resmi Kopyala + The image has been copied ({w} × {h} pixels) Resim kopyalandı ({w} × {h} piksel) + Couldn't copy the image: {e} Resim kopyalanamadı: {e} + Copy Image in Original Format Resmi Özgün Biçimde Kopyala + The image has been copied as a {ext} file Resim {ext} dosyası olarak kopyalandı + The page has changed; select the image again Sayfa değişti; resmi yeniden seçin Check for updates Güncellemeleri denetle No check has run yet. Henüz denetim yapılmadı. Checking for a newer version… Daha yeni bir sürüm denetleniyor… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c8278f56..89aa68d5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -758,6 +758,12 @@ Flip Vertical Віддзеркалити вертикально Replace Image… Замінити зображення… Save Image As… Зберегти зображення як… + Copy Image Копіювати зображення + The image has been copied ({w} × {h} pixels) Зображення скопійовано ({w} × {h} пікселів) + Couldn't copy the image: {e} Не вдалося скопіювати зображення: {e} + Copy Image in Original Format Копіювати зображення в оригінальному форматі + The image has been copied as a {ext} file Зображення скопійовано як файл {ext} + The page has changed; select the image again Сторінка змінилася; виберіть зображення ще раз Check for updates Перевірити оновлення No check has run yet. Перевірку ще не виконано. Checking for a newer version… Перевірка наявності новішої версії… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 02048fd7..a1e6e25e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -442,6 +442,12 @@ Flip Vertical 垂直翻转 Replace Image… 替换图像… Save Image As… 图像另存为… + Copy Image 复制图像 + The image has been copied ({w} × {h} pixels) 图像已复制({w} × {h} 像素) + Couldn't copy the image: {e} 无法复制图像:{e} + Copy Image in Original Format 以原始格式复制图像 + The image has been copied as a {ext} file 图像已复制为 {ext} 文件 + The page has changed; select the image again 页面已更改;请重新选择图像 Check for updates 检查更新 No check has run yet. 尚未运行检查。 Checking for a newer version… 正在检查新版本… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 282b5a52..c9db457e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -395,6 +395,12 @@ Flip Vertical 垂直翻轉 Replace Image… 替換圖片… Save Image As… 圖片另存為… + Copy Image 複製圖片 + The image has been copied ({w} × {h} pixels) 圖片已複製({w} × {h} 畫素) + Couldn't copy the image: {e} 無法複製圖片:{e} + Copy Image in Original Format 以原始格式複製圖片 + The image has been copied as a {ext} file 圖片已複製為 {ext} 檔案 + The page has changed; select the image again 頁面已變更;請重新選取圖片 Check for updates 檢驗更新 No check has run yet. 尚未執行檢驗。 Checking for a newer version… 正在檢驗新版本… diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs new file mode 100644 index 00000000..a05aefe2 --- /dev/null +++ b/crates/ui-egui/src/image_pick.rs @@ -0,0 +1,273 @@ +//! Select tool: click a picture on the page to select it, then ⌘C / Ctrl+C (or the right-click +//! menu) copies it in its original format: a JPEG image as the JPEG file stored in the PDF, any +//! other image as a lossless PNG (with its transparency). The clipboard gets the file, as when a +//! file is copied in Finder or Explorer; Copy Image copies the pixels instead. + +use egui::{Color32, CornerRadius, Pos2, Stroke}; + +use crate::PdfCraftApp; +use crate::canvas::{DocView, PageXform}; +use pdfcraft_render::DocInfo; + +/// The selected picture: page, index in `page_images` and its box in user space. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PickedImage { + pub page: usize, + pub index: usize, + pub rect: [f64; 4], +} + +const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0); + +/// A click on page `page` (Select tool, not taken by anything else) selects the picture under +/// it, even with text drawn over it (scans with an OCR layer, captions); dragging or a double +/// click still selects text, and clears the picture. +pub(crate) fn page_input( + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + doc: &pdfcraft_engine::Document, + pointer: Option, + view: &mut DocView, +) { + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return }; + if resp.drag_started() || resp.double_clicked() || resp.triple_clicked() { + view.picked_image = None; + return; + } + if !resp.clicked() { + return; + } + let Some(info_page) = info.pages.get(page) else { return }; + let (vx, vy) = xf.screen_to_view(p); + let u = info_page.view_to_user(vx, vy); + let at = [f64::from(u[0]), f64::from(u[1])]; + view.picked_image = + doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect })); +} + +/// The selected picture's highlight on page `page`. +pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) { + let Some(picked) = view.picked_image.filter(|p| p.page == page) else { return }; + if info.pages.get(page).is_none() || !picked.rect.iter().all(|v| v.is_finite()) { + return; + } + let r = xf.user_rect(info, page, picked.rect.map(|v| v as f32)); + painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 50)); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, BLUE), egui::StrokeKind::Outside); +} + +/// A file name without characters file systems refuse. +fn safe_name(s: &str) -> String { + let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect(); + let cleaned = cleaned.trim().trim_matches('.').to_string(); + if cleaned.is_empty() { "PdfCraft".into() } else { cleaned } +} + +/// The per-user folder that holds the file of the last Copy Image in Original Format: `Clipboard` +/// in the portable data folder in portable mode ([`crate::portable`]), otherwise the platform's +/// per-user cache folder: `~/Library/Caches/PdfCraft/Clipboard` (macOS), +/// `%LOCALAPPDATA%\PdfCraft\Clipboard` (Windows), or `$XDG_CACHE_HOME/pdfcraft/clipboard` / +/// `~/.cache/pdfcraft/clipboard` (others). Never the shared temporary folder, where another user +/// could plant a link or a folder of their own. +#[cfg(not(target_arch = "wasm32"))] +fn clipboard_dir() -> Option { + use std::path::PathBuf; + if let Some(dir) = crate::portable::data_dir() { + return Some(dir.join("Clipboard")); + } + let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from).filter(|p| p.is_absolute()); + if cfg!(target_os = "macos") { + env("HOME").map(|h| h.join("Library/Caches/PdfCraft/Clipboard")) + } else if cfg!(windows) { + env("LOCALAPPDATA").map(|d| d.join("PdfCraft").join("Clipboard")) + } else { + env("XDG_CACHE_HOME").or_else(|| env("HOME").map(|h| h.join(".cache"))).map(|d| d.join("pdfcraft/clipboard")) + } +} + +/// Whether `name` is a file Copy Image in Original Format writes: ` p img.` +/// with a JPEG or PNG extension. Only such files are ever removed from the clipboard folder. +#[cfg(not(target_arch = "wasm32"))] +fn is_copied_image_name(name: &str) -> bool { + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + let Some((rest, ext)) = name.rsplit_once('.') else { return false }; + if !matches!(ext, "jpg" | "png") { + return false; + } + let Some((rest, index)) = rest.rsplit_once(" img") else { return false }; + let Some((stem, page)) = rest.rsplit_once(" p") else { return false }; + !stem.is_empty() && digits(index) && digits(page) +} + +/// Makes `dir` (a real folder, not a link, owned by us and private to us on Unix), removes the +/// earlier copies PdfCraft wrote there, and writes `bytes` as a new file `name`. Links are never +/// followed and nothing else in the folder is touched. +#[cfg(not(target_arch = "wasm32"))] +fn write_clipboard_file(dir: &std::path::Path, name: &str, bytes: &[u8]) -> Result { + use std::io::Write; + if !is_copied_image_name(name) || name.contains(['/', '\\']) { + return Err("the file name is not valid".into()); + } + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let meta = std::fs::symlink_metadata(dir).map_err(|e| e.to_string())?; + if !meta.is_dir() { + return Err(format!("{} is not a folder", dir.display())); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Only the owner may change a folder's permissions, so this also refuses a folder that + // belongs to someone else. + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).map_err(|e| format!("{}: {e}", dir.display()))?; + } + // Only the latest copy needs to exist; older ones are removed (best effort). Only regular + // files with the copy's name pattern: links and anything else are left alone. + if let Ok(entries) = std::fs::read_dir(dir) { + for e in entries.flatten() { + let ours = e.file_name().to_str().is_some_and(is_copied_image_name); + if ours && e.file_type().is_ok_and(|t| t.is_file()) { + let _ = std::fs::remove_file(e.path()); + } + } + } + let path = dir.join(name); + // `create_new` fails on anything already there, a link included, instead of following it. + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| e.to_string())?; + if let Err(e) = file.write_all(bytes) { + drop(file); + let _ = std::fs::remove_file(&path); + return Err(e.to_string()); + } + Ok(path) +} + +impl PdfCraftApp { + /// ⌘C / Ctrl+C copies the selected picture in its original format; Escape deselects it. + pub(crate) fn image_pick_keys(&mut self, i: usize, ctx: &egui::Context) { + let Some(picked) = self.views.get(i).and_then(|v| v.picked_image) else { return }; + if ctx.egui_wants_keyboard_input() { + return; + } + if ctx.input(|inp| inp.key_pressed(egui::Key::Escape)) { + self.views[i].picked_image = None; + return; + } + let copy = ctx.input(|inp| inp.events.iter().any(|e| matches!(e, egui::Event::Copy))); + if !copy || self.views[i].selected_text().is_some() { + return; + } + // The document may have changed since the click: the same picture must still be there. + let current = self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(|im| im.rect)); + if current == Some(picked.rect) { + self.copy_page_image_file(picked.page, picked.index); + } else { + self.views[i].picked_image = None; + self.notify_tr("The page has changed; select the image again"); + } + } + + /// Copy Image in Original Format: image `index` on `page` as a file on the clipboard (JPEG + /// as stored in the PDF, otherwise PNG). + pub(crate) fn copy_page_image_file(&mut self, page: usize, index: usize) { + let Some((_, id)) = self.active_ids() else { return }; + if !self.may_copy(id) { + return; + } + let Some(doc) = self.session.get(id) else { return }; + let stem = safe_name(doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(s, _)| s)); + let file = doc.page_image_copy_file(page, index); + let copied = file.and_then(|(ext, bytes)| { + let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1)); + self.put_file_on_clipboard(&name, &bytes)?; + self.last_copied_file = Some((name, bytes)); + Ok(ext) + }); + match copied { + Ok(ext) => self.notify_fmt("The image has been copied as a {ext} file", &[("ext", &ext.to_uppercase())]), + Err(e) => self.notify_fmt("Couldn't copy the image: {e}", &[("e", &e)]), + } + } + + /// Writes `bytes` to PdfCraft's private clipboard folder ([`clipboard_dir`]), replacing the + /// previous copy, and puts that file on the system clipboard. + #[cfg(not(target_arch = "wasm32"))] + fn put_file_on_clipboard(&self, name: &str, bytes: &[u8]) -> Result<(), String> { + if !self.system_clipboard { + return Ok(()); + } + let dir = clipboard_dir().ok_or("there is no folder for the copied file")?; + let path = write_clipboard_file(&dir, name, bytes)?; + arboard::Clipboard::new().and_then(|mut c| c.set().file_list(&[path])).map_err(|e| e.to_string()) + } + + #[cfg(target_arch = "wasm32")] + fn put_file_on_clipboard(&self, _name: &str, _bytes: &[u8]) -> Result<(), String> { + if self.system_clipboard { + return Err("the web version can't put files on the clipboard yet; use Save Image As".to_string()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::safe_name; + #[cfg(not(target_arch = "wasm32"))] + use super::{is_copied_image_name, write_clipboard_file}; + + #[test] + fn file_names_are_safe() { + assert_eq!(safe_name("a/b:c"), "a_b_c"); + assert_eq!(safe_name(" .. "), "PdfCraft"); + assert_eq!(safe_name("Relazione 2026"), "Relazione 2026"); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn copied_image_names_are_recognised() { + assert!(is_copied_image_name("Report p1 img2.jpg")); + assert!(is_copied_image_name("a p12 img3.png")); + assert!(!is_copied_image_name("notes.txt")); + assert!(!is_copied_image_name("Report p1 img2.pdf")); + assert!(!is_copied_image_name(" p1 img2.png")); + assert!(!is_copied_image_name("Report px img2.png")); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn clipboard_folder_only_replaces_our_own_copies() { + let dir = std::env::temp_dir().join(format!("pdfcraft-clipboard-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("keep.txt"), b"other").unwrap(); + std::fs::write(dir.join("Old p1 img1.png"), b"old").unwrap(); + let path = write_clipboard_file(&dir, "New p2 img1.jpg", b"new").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert!(dir.join("keep.txt").exists(), "files PdfCraft didn't write are kept"); + assert!(!dir.join("Old p1 img1.png").exists(), "the previous copy is removed"); + assert!(write_clipboard_file(&dir, "../escape p1 img1.png", b"x").is_err()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); + // A link with a copy's name is neither followed nor removed. + let target = dir.join("keep.txt"); + let link = dir.join("Link p1 img1.png"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + write_clipboard_file(&dir, "Again p1 img1.png", b"again").unwrap(); + assert!(std::fs::symlink_metadata(&link).is_ok(), "links are left alone"); + assert_eq!(std::fs::read(&target).unwrap(), b"other"); + assert!(write_clipboard_file(&dir, "Link p1 img1.png", b"x").is_err(), "writing never follows a link"); + assert_eq!(std::fs::read(&target).unwrap(), b"other"); + // A link in place of the folder is refused. + let linked = std::env::temp_dir().join(format!("pdfcraft-clipboard-test-link-{}", std::process::id())); + let _ = std::fs::remove_file(&linked); + std::os::unix::fs::symlink(&dir, &linked).unwrap(); + assert!(write_clipboard_file(&linked, "X p1 img1.png", b"x").is_err()); + let _ = std::fs::remove_file(&linked); + } + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a730450..2d4f5b09 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ mod appearance; pub mod canvas; mod chrome; mod combine_grid; +mod image_pick; mod tags_ui; pub use combine_grid::ThumbState as CombineThumb; mod combine_ui; @@ -641,6 +642,12 @@ 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)>, + /// 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)>, + /// The last image copied in its original format (file name, bytes), also put on the + /// system clipboard as a file when `system_clipboard` is on. + pub last_copied_file: Option<(String, Vec)>, pub system_clipboard: bool, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, @@ -905,6 +912,8 @@ impl PdfCraftApp { page_clipboard: None, grid_drop: None, last_snapshot: None, + last_copied_image: None, + last_copied_file: None, system_clipboard: true, attach_override: None, duplicate_draft: None, @@ -2291,6 +2300,7 @@ impl PdfCraftApp { self.tool_keys(i, ctx); canvas::shortcuts(&mut self.views[i], ctx); self.copy_key(i, ctx); + self.image_pick_keys(i, ctx); } } } diff --git a/crates/ui-egui/tests/document_links.rs b/crates/ui-egui/tests/document_links.rs index 3c46ef88..0ee9def7 100644 --- a/crates/ui-egui/tests/document_links.rs +++ b/crates/ui-egui/tests/document_links.rs @@ -49,6 +49,10 @@ fn fixture() -> Vec { } fn remote_fixture(file: &str, destination: &str) -> Vec { + // canonicalize() uses a Windows device prefix; a PDF file specification uses the ordinary drive path. + #[cfg(windows)] + let file = file.strip_prefix(r"\\?\").filter(|path| path.as_bytes().get(1) == Some(&b':')).unwrap_or(file); + let file = file.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)"); let objs = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index a8f15793..35a82c59 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -921,3 +921,126 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() { again.restore(&app.persist()); assert_eq!(again.scroll_speed, ScrollSpeed::Fast); } + +/// A 40 × 30 picture (one red corner) and a harness showing a PDF of it. +fn picture_harness() -> (image::RgbaImage, Harness<'static, PdfCraftApp>) { + let mut px = image::RgbaImage::from_pixel(40, 30, image::Rgba([10, 200, 30, 255])); + px.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(px.clone()).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let pdf = pdfcraft_engine::Session::new().create_from_images(&[("p.png".into(), png.into_inner())]).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pic.pdf", None, pdf.to_vec()).unwrap(); + app + }); + h.run_steps(4); + h.state_mut().system_clipboard = false; + (px, h) +} + +#[test] +fn a_clicked_picture_is_selected_and_copied_in_its_original_format() { + let (px, mut h) = picture_harness(); + let i = h.state().active.unwrap(); + let at = h.state().views[i].page_screen_rect(0).expect("on screen").center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(h.state().views[i].picked_image.map(|p| (p.page, p.index)), Some((0, 0))); + h.event(egui::Event::Copy); + h.run_steps(2); + let (name, bytes) = h.state().last_copied_file.clone().expect("the image was copied as a file"); + assert_eq!(name, "pic p1 img1.png"); + let decoded = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png).unwrap().into_rgba8(); + assert_eq!(decoded.into_raw(), px.into_raw(), "lossless, at full size"); + // Escape deselects. + h.key_press(egui::Key::Escape); + h.run_steps(1); + assert_eq!(h.state().views[i].picked_image, None); +} + +#[test] +fn right_click_on_a_picture_copies_it_at_full_size() { + let (px, mut h) = picture_harness(); + let i = h.state().active.unwrap(); + let at = h.state().views[i].page_screen_rect(0).expect("on screen").center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.get_by_label("Copy Image").click(); + h.run_steps(2); + let (w, hgt, rgba) = h.state().last_copied_image.clone().expect("the image was copied"); + assert_eq!((w, hgt), (40, 30)); + assert_eq!(rgba, px.into_raw(), "the image's own pixels, not a rendering of the page"); +} + +/// A picture drawn through grouped Form artwork with a caption over it (as Word, InDesign and +/// scanners with OCR write them): one click still selects it, and ⌘C copies the picture. +#[test] +fn a_picture_inside_form_artwork_under_text_is_selected_and_copied() { + let stream = |dict: &str, data: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + o.extend_from_slice(data); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Fm1 6 0 R >> >> >>".to_vec(), + stream("", b"q 180 0 0 280 10 10 cm /Fm1 Do Q BT /F1 40 Tf 15 130 Td (WWWWWWW) Tj ET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1] /Resources << /XObject << /Im1 7 0 R >> >>", b"/Im1 Do"), + stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", &[255, 0, 0, 0, 0, 255]), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("scan.pdf", None, pdf).unwrap(); + app + }); + for _ in 0..40 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.state_mut().system_clipboard = false; + let i = h.state().active.unwrap(); + let r = h.state().views[i].page_screen_rect(0).expect("on screen"); + // On the caption ("WWWWWWW" at y 130–170 of 300, from x 15). + let at = egui::pos2(r.left() + 60.0 / 200.0 * r.width(), r.top() + (300.0 - 145.0) / 300.0 * r.height()); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(h.state().views[i].picked_image.map(|p| (p.page, p.index)), Some((0, 0))); + h.event(egui::Event::Copy); + h.run_steps(2); + let (name, bytes) = h.state().last_copied_file.clone().expect("the picture was copied"); + assert_eq!(name, "scan p1 img1.png"); + let png = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png).unwrap().into_rgba8(); + assert_eq!(png.into_raw(), vec![255, 0, 0, 255, 0, 0, 255, 255]); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 77026be8..275ad3d3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1553,6 +1553,18 @@ tools = ["page_render"] evidence = ["crates/ui-egui/tests/viewer.rs::marquee_zoom_and_snapshot"] notes = "copies the area at the current zoom to the clipboard as an image" +[[feature]] +id = "view.copy-image" +title = "Select an image on the page and copy it" +area = "B" +tier = "P2" +milestone = "M3" +status = "partial" +commands = [] +tools = [] +evidence = ["crates/ui-egui/tests/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"] +notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/JBIG2/CCITT images, multi-format clipboard (file and bitmap at once), the web build" + [[feature]] id = "view.rulers" title = "Rulers"