mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
33 files changed
+820
-20
No files matched your search
+19
-19
@@ -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"
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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… Жаңа нұсқа бар ма екені тексерілуде…
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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…
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user