M3: select an image on the page and copy it (#1363)

* 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 <[email protected]>
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 <[email protected]>
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 '<stem> p<N> img<M>.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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 07:47:35 -04:00
1 parent efd68b377e
commit 0cb311d21c
33 files changed
+820 -20

No files matched your search

+19 -19
View File
@@ -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"
+4
View File
@@ -36,6 +36,10 @@ fn fixture(n: usize) -> Vec<u8> {
}
fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec<u8> {
// 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<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
+108
View File
@@ -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<usize> {
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<u8>), 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<u8>), 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<ObjRef> {
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<Dict> {
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<ObjRef>, 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);
}
}
_ => {}
}
}
}
+1
View File
@@ -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;
+71
View File
@@ -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<u8>> = 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());
}
+40 -1
View File
@@ -398,6 +398,8 @@ pub struct DocView {
pub(crate) edit_images: HashMap<usize, (u64, Vec<pdfcraft_engine::PageImage>)>,
pub(crate) objects: crate::object_ui::ObjectSelection,
pub image_selection: Option<crate::edit_text_ui::ImageSelection>,
/// Select tool: the picture clicked, to copy in its original format.
pub picked_image: Option<crate::image_pick::PickedImage>,
/// A paragraph box being dragged (moved, or resized from its right edge) in Edit text.
pub block_drag: Option<crate::edit_text_ui::BlockDrag>,
/// 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<usize>)>(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 {
+4
View File
@@ -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.
+26
View File
@@ -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"))]
+11
View File
@@ -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;
+6
View File
@@ -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… جارٍ التحقق من وجود إصدار أحدث…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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… Проверка за по-нова версия…
Can't render this file because it contains an unexpected character in line 8 and column 16.
+6
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 51.
+6
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 60.
+6
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 39.
+6
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 7 and column 44.
+6
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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… 新しいバージョンを確認中…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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… Жаңа нұсқа бар ма екені тексерілуде…
1 Menu Мәзір
717 Flip Vertical Тікке аудару
718 Replace Image… Суретті ауыстыру…
719 Save Image As… Суретті басқаша сақтау…
720 Copy Image Суретті көшіру
721 The image has been copied ({w} × {h} pixels) Сурет көшірілді ({w} × {h} пиксель)
722 Couldn't copy the image: {e} Суретті көшіру мүмкін болмады: {e}
723 Copy Image in Original Format Суретті бастапқы пішімде көшіру
724 The image has been copied as a {ext} file Сурет {ext} файлы ретінде көшірілді
725 The page has changed; select the image again Бет өзгерді; суретті қайта таңдаңыз
726 Check for updates Жаңартуларды тексеру
727 No check has run yet. Тексеру әлі жүргізілген жоқ.
728 Checking for a newer version… Жаңа нұсқа бар ма екені тексерілуде…
+6
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+6
View File
@@ -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… Проверка новой версии…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+6
View File
@@ -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… కొత్త వెర్షన్ కోసం తనిఖీ చేస్తోంది…
Can't render this file because it contains an unexpected character in line 13 and column 16.
+6
View File
@@ -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…
1 # Turkish (tr) catalog. Format: see the header of ja.tsv
716
717
718
719
720
721
722
723
724
725
726
727
+6
View File
@@ -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… Перевірка наявності новішої версії…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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… 正在检查新版本…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+6
View File
@@ -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… 正在檢驗新版本…
Can't render this file because it contains an unexpected character in line 9 and column 16.
+273
View File
@@ -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<Pos2>,
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<std::path::PathBuf> {
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: `<stem> p<page> img<index>.<ext>`
/// 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<std::path::PathBuf, String> {
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);
}
}
+10
View File
@@ -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<u8>)>,
/// 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<u8>)>,
pub system_clipboard: bool,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec<u8>)>,
@@ -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);
}
}
}
+4
View File
@@ -49,6 +49,10 @@ fn fixture() -> Vec<u8> {
}
fn remote_fixture(file: &str, destination: &str) -> Vec<u8> {
// 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(),
+123
View File
@@ -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<u8>> = 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]);
}
+12
View File
@@ -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"