From 302ad6850210a125c7cc24f1021fdb35e78e69a7 Mon Sep 17 00:00:00 2001 From: nleva Date: Fri, 23 Oct 2026 17:25:28 +0200 Subject: [PATCH 1/2] M7: paste added text boxes and images in the engine and through content_copy (#2636) Edit::PasteContent pastes an added text box or image copied in the same document or another, as it was (a text box's style; an image's picture, turns, flips and crop), as one undo step. A pasted image draws its picture from the copied object while that is still the picture in the target, else from an added image there that already holds it (an earlier paste from another document), else the file is embedded. Document::added_image_file gives a copied image's picture as a file and honours the copy permission. pdfcraft_edit::added::paste_rect / paste_place place a paste where the item was, 18 pt down and to the right past each copy of it on the page (an image with the same box and transforms counts whichever object holds its picture), or at a point, kept on the page. The content_copy tool drives it headlessly, across pages and documents. The desktop keys and menus come in follow-up PRs. Co-Authored-By: Opus 5.5 medium ultracode Claude-Session: https://claude.ai/code/session_017gEgu9NcTcPqKovsLyfPxK --- ROADMAP.md | 2 + crates/automation/src/content.rs | 57 ++++++++++++++++ crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 14 ++++ crates/automation/tests/it/automation.rs | 85 ++++++++++++++++++++++++ crates/edit/src/added.rs | 55 +++++++++++++++ crates/edit/src/tests.rs | 46 +++++++++++++ crates/engine/src/copy_image.rs | 40 +++++++++++ crates/engine/src/lib.rs | 47 ++++++++++++- crates/engine/src/tests.rs | 82 +++++++++++++++++++++++ docs/roadmap.md | 1 + parity/acrobat-features.toml | 11 +++ 12 files changed, 439 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5634d6bf28..bd3e060d49 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) @@ -191,6 +192,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and a JPEG 2000 picture can't be copied. The desktop keys and menus follow in separate PRs. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 8926a1368b..b406bb43ea 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -203,6 +203,63 @@ impl Automation { let (page, index, _, _) = self.item(a)?; self.apply(a, Edit::DeleteContent { page, index }) } + + /// Edit ▸ Copy, then Paste: an added item onto `to_page` of `to_doc` (its own page and + /// document by default), as it is, at `at` (its top-left) or where it was, stepped down and to + /// the right past copies of it already on that page. + pub(crate) fn content_copy(&mut self, a: &Args) -> Result { + let (page, index, content, _) = self.item(a)?; + let source = self.doc(a)?; + source.check_copying()?; + let from = source.id; + let file = match &content { + AddedContent::Text(_) => None, + AddedContent::Image(_) => { + let (ext, bytes) = source.added_image_file(page, index).map_err(failed)?; + Some((format!("Pasted image.{ext}"), std::sync::Arc::new(bytes))) + } + }; + let to_doc = match a.opt_int("to_doc")? { + Some(d) => { + let d = u64::try_from(d).map_err(|_| bad("to_doc must be positive"))?; + self.session.get(pdfcraft_engine::DocId(d)).ok_or_else(|| failed(format!("no open document with id {d} (see doc_list)")))?.id + } + None => from, + }; + let doc = self.session.get(to_doc).ok_or_else(|| failed("the document is no longer open"))?; + let n = doc.info.pages.len(); + let to_page = match a.opt_int("to_page")? { + Some(p) => usize::try_from(p) + .ok() + .and_then(|p| p.checked_sub(1)) + .filter(|p| *p < n) + .ok_or_else(|| bad(format!("to_page {p} is out of range: the document has {n} pages")))?, + None if page < n => page, + None => return Err(bad(format!("page {} is out of range in the target: it has {n} pages; pass to_page", page + 1))), + }; + let info = doc.info.pages.get(to_page).ok_or_else(|| bad("the page no longer exists"))?; + let (pw, ph) = (f64::from(info.width), f64::from(info.height)); + let at = match a.get("at") { + Some(v) => { + let p: Option> = v.as_array().and_then(|x| x.iter().map(Value::as_f64).collect()); + let [x, y]: [f64; 2] = p.and_then(|p| p.try_into().ok()).ok_or_else(|| bad("at must be [x, y]"))?; + Some([x, ph - y]) + } + None => None, + }; + let on_page = || doc.added().iter().filter(|x| x.page == to_page); + let rect = match at { + Some(at) => pdfcraft_engine::added_paste_rect(content.rect(), [pw, ph], 0, Some(at)), + None => pdfcraft_engine::added_paste_place(on_page().map(|x| &x.content), &content, [pw, ph]), + }; + let before = on_page().count(); + let target = json!({ "doc": to_doc.0 }); + let mut out = self.apply(&Args(&target), Edit::PasteContent { page: to_page, content: content.with_rect(rect), file })?; + out["page"] = json!(to_page + 1); + out["index"] = json!(before + 1); + out["rect"] = json!(to_view(rect, ph).map(|x| (x * 100.0).round() / 100.0)); + Ok(out) + } } impl Automation { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1dae412e41..040a1db29e 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -835,6 +835,7 @@ impl Automation { "page_add_image" => self.page_add_image(&a)?, "content_update" => self.content_update(&a)?, "content_delete" => self.content_delete(&a)?, + "content_copy" => self.content_copy(&a)?, "doc_print" => self.doc_print(&a)?, "doc_remove_hidden" => self.doc_remove_hidden(&a)?, "doc_audit_redactions" => self.doc_audit_redactions(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c1170..bfbaac7d2b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -621,6 +621,20 @@ pub fn tools() -> Vec { }), &["doc", "page", "index"], )), + t( + "content_copy", + "Copy and paste added content", + "Copy an added item (page, index from content_list) and paste it, as Edit ▸ Copy and Paste do: onto to_page of to_doc (by default its own page and document) as it is: a text box's text, font, size, colour, alignment and box; an image's picture, box, turns, flips and crop (within its document the same image object is drawn again; into another one the picture goes as its JPEG, or else a lossless PNG, without a colour profile). It goes at at [x, y] (its top-left, points from the top-left of the page), else where it was, 18 points down and to the right past each copy of it already there; kept on the page. Refused when the source document doesn't allow copying. Returns the new item's page, index and rect. Undoable in the target document.", + ) + .with(schema( + json!({ + "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, + "to_doc": { "type": "integer", "minimum": 1, "description": "The document to paste into (default doc)." }, + "to_page": { "type": "integer", "minimum": 1, "description": "The page to paste onto (default page)." }, + "at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }, + }), + &["doc", "page", "index"], + )), t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..7c47235035 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4271,6 +4271,91 @@ fn adding_content_through_tools() { assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } +/// Edit ▸ Copy and Paste headlessly (#2636): an added text box or image, as it is, onto its own +/// page beside it, onto a point, and into another document. +#[test] +fn copying_added_content_through_tools() { + let dir = workdir("content-copy"); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 40, 20); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[90u8; 2400]).unwrap(); + } + std::fs::write(dir.join("logo.png"), png).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let other = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "page_add_text", + json!({ "doc": doc, "page": 1, "text": "Approved", "at": [20, 20], "width": 100, "size": 18, "bold": true, "font": "times", "color": "red", "align": "center" }), + ); + ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [100, 200, 180, 240] })); + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true })); + let original = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone(); + // Beside itself on its own page: 18 points down and to the right, with the style content_list + // shows (the engine test checks colour, alignment and the image transforms). + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 1 })); + assert_eq!((r["page"].as_u64(), r["index"].as_u64()), (Some(1), Some(3))); + let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone(); + let (from, to) = (&original[0], &items[2]); + for key in ["type", "text", "font", "size", "bold", "italic"] { + assert_eq!(from[key], to[key], "{key}"); + } + let shifted: Vec = from["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 18.0).collect(); + assert_eq!(to["rect"], json!(shifted)); + assert_eq!(r["rect"], to["rect"]); + // An image onto another page at a point, and into another document where it was. + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_page": 2, "at": [10, 30] })); + assert_eq!((r["page"].as_u64(), r["index"].as_u64()), (Some(2), Some(1))); + assert_eq!(r["rect"][0].as_f64().zip(r["rect"][1].as_f64()), Some((10.0, 30.0)), "its top-left at the point"); + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_doc": other })); + assert_eq!(r["rect"], original[1]["rect"], "in the same place"); + let theirs = ok(&mut a, "content_list", json!({ "doc": other })); + assert_eq!((theirs["count"].as_u64(), theirs["items"][0]["type"].as_str()), (Some(1), Some("image"))); + // Copied there again: it steps past the first copy, whose picture that document stores itself. + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_doc": other })); + let stepped: Vec = original[1]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 18.0).collect(); + assert_eq!(r["rect"], json!(stepped), "beside the first copy, not on it"); + // Each paste is one undo step in the document it went into. + ok(&mut a, "edit_undo", json!({ "doc": other })); + ok(&mut a, "edit_undo", json!({ "doc": other })); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": other }))["count"], 0); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 4); + // Out of range: a readable failure. + assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 9 })), Err(ToolError::Failed(_)))); + assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "to_page": 9 })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "to_doc": 999 })), Err(ToolError::Failed(_)))); + for at in [json!([1]), json!([10, "x", 30]), json!("10, 30")] { + assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "at": at })), Err(ToolError::InvalidArgs(_))), "{at}"); + } + // A second copy onto its own page steps past the first. + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 1 })); + let twice: Vec = from["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 36.0).collect(); + assert_eq!(r["rect"], json!(twice)); + + // A document that doesn't allow copying keeps its content in, even with changes allowed. + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + cos.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + std::fs::write(dir.join("no-copy.pdf"), pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()).unwrap(); + let locked = ok(&mut a, "doc_open", json!({ "path": "no-copy.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": locked, "page": 1, "text": "Secret", "at": [20, 20], "width": 100 })); + for to in [locked, other] { + let refused = a.call("content_copy", &json!({ "doc": locked, "page": 1, "index": 1, "to_doc": to })); + assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("copying")), "{refused:?}"); + } + assert_eq!(ok(&mut a, "content_list", json!({ "doc": locked }))["count"], 1); +} + /// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable /// failure for `content_update` and `content_delete`, not a panic. #[test] diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index d39bb0fa91..a4fa1b9c5f 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -509,6 +509,61 @@ pub fn text_rect(t: &AddedText) -> [f64; 4] { [r[0], r[3] - h, r[2], r[3]] } +/// How far each paste onto a page already holding the copy is offset, down and to the right, so a +/// copy doesn't hide what it was copied from (points). +pub const PASTE_STEP: f64 = 18.0; + +/// Where a pasted item of box `rect` goes on a page of `size` (display space): with its top-left +/// corner at `at`, else where it was, `steps` paste steps ([`PASTE_STEP`]) down and to the right. +/// It is kept on the page; one larger than the page keeps its left and top edges on it. A page +/// or point that isn't a number leaves the box where it was. +pub fn paste_rect(rect: [f64; 4], size: [f64; 2], steps: u32, at: Option<[f64; 2]>) -> [f64; 4] { + let [x0, y0, x1, y1] = norm(rect); + let (w, h) = (x1 - x0, y1 - y0); + let (left, top) = match at { + Some([x, y]) => (x, y), + None => { + let d = PASTE_STEP * f64::from(steps); + (x0 + d, y1 - d) + } + }; + if !size.iter().chain(&[left, top, w, h]).all(|v| v.is_finite()) { + return rect; + } + let left = left.min(size[0] - w).max(0.0); + let top = top.max(h).min(size[1]); + [left, top - h, left + w, top] +} + +/// Most paste steps [`paste_place`] tries before giving up on finding a free place. +const MAX_PASTE_STEPS: u32 = 64; + +/// Where Edit ▸ Paste puts `content` on a page of `size` already holding `items` (display space): +/// where it was, or as many paste steps down and to the right as it takes not to land exactly on +/// a copy of it (the original it was copied from, or an earlier paste). +pub fn paste_place<'a>(items: impl IntoIterator, content: &Content, size: [f64; 2]) -> [f64; 4] { + let same = copy_key(content); + let copies: Vec<[f64; 4]> = items.into_iter().filter(|i| copy_key(i) == same).map(Content::rect).collect(); + let taken = |rect: [f64; 4]| copies.iter().any(|c| c.iter().zip(rect).all(|(a, b)| (a - b).abs() < 0.5)); + let mut rect = paste_rect(content.rect(), size, 0, None); + for steps in 1..=MAX_PASTE_STEPS { + if !taken(rect) { + break; + } + rect = paste_rect(content.rect(), size, steps, None); + } + rect +} + +/// What makes two items copies of each other for [`paste_place`]: all but the box, and for an +/// image all but the object holding its picture, which a paste into another document stores anew. +fn copy_key(c: &Content) -> Content { + match c.with_rect([0.0; 4]) { + Content::Image(img) => Content::Image(AddedImage { image: ObjRef::new(0, 0), ..img }), + text => text, + } +} + /// The widest explicit line before wrapping, measured with the PDF writing fonts. pub fn text_width(t: &AddedText) -> f64 { t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0549906dbe..e4a6e5c8e6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -983,6 +983,52 @@ fn added_text_and_images_are_page_content_that_stays_editable() { assert!(marks_present(&doc).is_empty()); } +/// Edit ▸ Paste (#2636): where a pasted item lands. +#[test] +fn a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page() { + use crate::added::{PASTE_STEP, paste_rect}; + let page = [300.0, 400.0]; + let rect = [40.0, 300.0, 140.0, 320.0]; + assert_eq!(paste_rect(rect, page, 0, None), rect, "in place"); + let d = 2.0 * PASTE_STEP; + assert_eq!(paste_rect(rect, page, 2, None), [40.0 + d, 300.0 - d, 140.0 + d, 320.0 - d], "two steps down and right"); + assert_eq!(paste_rect(rect, page, 1, Some([10.0, 50.0])), [10.0, 30.0, 110.0, 50.0], "its top-left corner at the point"); + assert_eq!(paste_rect(rect, page, 0, Some([290.0, 5.0])), [200.0, 0.0, 300.0, 20.0], "pulled back onto the page"); + assert_eq!(paste_rect([0.0, 0.0, 500.0, 600.0], page, 0, Some([10.0, 10.0])), [0.0, -200.0, 500.0, 400.0], "too large: left and top on the page"); + assert_eq!(paste_rect([140.0, 320.0, 40.0, 300.0], page, 0, None), rect, "a reversed box is normalized"); + assert_eq!(paste_rect(rect, [f64::NAN, 400.0], 1, None), rect, "a page without a size leaves the box alone"); + assert_eq!(paste_rect(rect, page, 1, Some([f64::INFINITY, 5.0])), rect, "so does a point that isn't a number"); + assert_eq!(paste_rect(rect, page, u32::MAX, None), [200.0, 0.0, 300.0, 20.0], "many pastes stop at the page's corner"); +} + +/// A paste steps on past exact copies of the item (its original, earlier pastes), and only those. +#[test] +fn a_paste_steps_past_copies_of_itself_only() { + use crate::added::{PASTE_STEP, paste_place}; + let page = [300.0, 400.0]; + let text = |rect, t: &str| Content::Text(AddedText { rect, text: t.into(), ..AddedText::default() }); + let item = text([40.0, 300.0, 140.0, 314.4], "Note"); + let step = |n: f64| [40.0 + n * PASTE_STEP, 300.0 - n * PASTE_STEP, 140.0 + n * PASTE_STEP, 314.4 - n * PASTE_STEP]; + assert_eq!(paste_place([], &item, page), step(0.0), "an empty page: where it was"); + assert_eq!(paste_place([&item], &item, page), step(1.0), "beside its original"); + let first = item.with_rect(step(1.0)); + assert_eq!(paste_place([&item, &first], &item, page), step(2.0), "beside the last copy"); + assert_eq!(paste_place([&first], &item, page), step(0.0), "its place is free again (the original was cut)"); + let other = text(step(0.0), "Other text"); + assert_eq!(paste_place([&other], &item, page), step(0.0), "a different item in the way doesn't count"); + // A page full of copies at its corner: the search ends. + let corner = item.with_rect([200.0, 0.0, 300.0, 14.4]); + let stuck = paste_place([&corner], &item.with_rect([200.0, 0.0, 300.0, 14.4]), page); + assert_eq!(stuck, [200.0, 0.0, 300.0, 14.4]); + // An image pasted into another document draws its picture from an object of that document: + // still a copy, so the next paste steps past it. + let image = Content::Image(AddedImage { crop: [0.1, 0.0, 0.0, 0.0], ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(7, 0)) }); + let pasted = Content::Image(AddedImage { crop: [0.1, 0.0, 0.0, 0.0], ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(42, 0)) }); + assert_eq!(paste_place([&pasted], &image, page), step(1.0), "a copy whose picture is stored anew"); + let turned = Content::Image(AddedImage { rotation: 1, ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(7, 0)) }); + assert_eq!(paste_place([&turned], &image, page), step(0.0), "the same picture turned or cropped otherwise isn't a copy"); +} + #[test] fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() { let mut doc = fixture(); diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index 642c84e670..167ddbdd09 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -59,6 +59,46 @@ impl Document { let rgba = image::DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?.into_rgba8(); Ok((rgba.width(), rgba.height(), rgba.into_raw())) } + + /// Edit ▸ Copy of an added image (`index` among `page`'s added items): its picture as a file + /// (extension, bytes), a JPEG as stored, otherwise a lossless PNG, for + /// [`Edit::PasteContent`](crate::Edit::PasteContent). Refused when the document doesn't allow + /// copying. + pub fn added_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + self.check_copying().map_err(|e| e.to_string())?; + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let item = self.added().iter().filter(|a| a.page == page).nth(index); + let Some(pdfcraft_edit::Content::Image(img)) = item.map(|a| &a.content) else { + return Err(format!("item {} on page {} is not an added image", index + 1, page + 1)); + }; + pdfcraft_create::image_file(&editor.cos, img.image) + } +} + +/// Whether image object `image` in `cos` is the picture `file` (as `image_file` writes it). The +/// sizes are compared first, so an unrelated image is not re-encoded to find out. +pub(crate) fn is_picture(cos: &Cos, image: ObjRef, file: &[u8]) -> bool { + let size = |s: &pdfcraft_cos::Stream| Some((u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?)); + // The object at that number now, not one of an earlier generation of it. + if cos.generation(image.num) != image.generation { + return false; + } + let stored = match &*cos.get(image) { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => size(s), + _ => return false, + }; + let given = image::ImageReader::new(std::io::Cursor::new(file)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); + stored.is_some() && stored == given && pdfcraft_create::image_file(cos, image).is_ok_and(|(_, data)| data == file) +} + +/// The image object an added image in `cos` already draws picture `file` from, if any: where a +/// second paste from another document finds the first one's. +pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option { + let mut seen = std::collections::HashSet::new(); + pdfcraft_edit::list_added(cos).into_iter().find_map(|a| match a.content { + pdfcraft_edit::Content::Image(img) if seen.insert(img.image) && is_picture(cos, img.image, file) => Some(img.image), + _ => None, + }) } /// The largest raster image Form `form` draws through its resources (nested Forms included). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b97..9490db8ee3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -55,7 +55,9 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific pub use pdfcraft_cos::Algorithm; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; -pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width}; +pub use pdfcraft_edit::added::{ + paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, text_width as added_text_width, +}; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; @@ -309,6 +311,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::UpdateContent { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } @@ -1782,6 +1785,16 @@ pub enum Edit { name: String, bytes: Arc>, }, + /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its + /// style, an image in its box (display space) with its turns, flips and crop. An image's + /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused + /// while it is still that picture in this document, else one an added image here already draws + /// it from, else the file is embedded. + PasteContent { + page: usize, + content: AddedContent, + file: Option<(String, Arc>)>, + }, /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. ApplyRedactions { pages: Option>, @@ -2034,6 +2047,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Add image", "Edit content", "Delete content", + "Paste", "Apply redactions", "Remove redaction marks", "Remove hidden information", @@ -2176,6 +2190,7 @@ impl Edit { Edit::UpdateContent { .. } => Label::of("Edit content"), Edit::DeleteContent { .. } => Label::of("Delete content"), Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::PasteContent { .. } => Label::of("Paste"), Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), Edit::ClearRedactions => Label::of("Remove redaction marks"), Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), @@ -2329,6 +2344,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::MoveObjects { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } | Edit::ClearRedactions | Edit::RemoveHidden { .. } | Edit::ImportData { .. } @@ -2784,6 +2800,28 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let (image, _) = embed_image(doc, name, bytes)?; pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; } + Edit::PasteContent { page, content, file } => { + let content = match content { + AddedContent::Text(_) => content.clone(), + AddedContent::Image(img) => { + let Some((name, bytes)) = file else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); + }; + // The same picture in this document (copied here, pasted here before, or an + // identical file): drawn again from the one image object rather than stored twice. + let image = if copy_image::is_picture(doc, img.image, bytes) { + img.image + } else { + match copy_image::added_picture(doc, bytes) { + Some(image) => image, + None => embed_image(doc, name, bytes)?.0, + } + }; + AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) + } + }; + pdfcraft_edit::add_content(doc, *page, &content)?; + } Edit::ApplyRedactions { pages } => { cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; } @@ -4180,7 +4218,12 @@ impl Session { let touches_added = edit.is_none_or(|e| { matches!( e, - Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..5871677e15 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3155,6 +3155,88 @@ fn added_text_and_images_render_and_stay_editable() { assert_eq!(s.get(id).unwrap().added().len(), 2); } +/// Edit ▸ Copy and Paste of added content (#2636): a text box comes back with all its style, an +/// image with its picture and transforms, in the same document and in another. +#[test] +fn pasted_added_content_keeps_its_style_and_picture() { + let (mut s, id) = session_with(2); + let text = AddedText { + rect: [20.0, 250.0, 180.0, 264.4], + text: "Styled note".into(), + family: FontFamily::Times, + bold: true, + italic: true, + size: 12.0, + color: [0.8, 0.1, 0.2], + align: TextAlign::Center, + }; + s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap(); + let copied = s.get(id).unwrap().added()[0].content.clone(); + let moved = copied.with_rect([40.0, 100.0, 200.0, 114.4]); + s.apply(id, Edit::PasteContent { page: 1, content: moved.clone(), file: None }).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Paste")); + assert_eq!(doc.added()[1].page, 1); + assert_eq!(doc.added()[1].content, moved, "every part of the style, in the new box"); + + // An image, turned, flipped and cropped. + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 20, 10); + enc.set_color(png::ColorType::Rgb); + let px: Vec = (0..200).flat_map(|i| [(i % 256) as u8, 40, 200]).collect(); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 150.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + let AddedContent::Image(mut img) = s.get(id).unwrap().added()[1].content.clone() else { panic!("the image is the page's second item") }; + (img.rotation, img.flip_h, img.crop) = (1, true, [0.1, 0.0, 0.2, 0.05]); + s.apply(id, Edit::UpdateContent { page: 0, index: 1, content: AddedContent::Image(img.clone()) }).unwrap(); + let (ext, file) = s.get(id).unwrap().added_image_file(0, 1).unwrap(); + assert_eq!(ext, "png"); + let file = Some(("Pasted image.png".to_string(), Arc::new(file))); + assert!(s.get(id).unwrap().added_image_file(0, 0).is_err(), "the first item is text"); + let pasted = AddedImage { rect: [60.0, 90.0, 160.0, 140.0], ..img.clone() }; + s.apply(id, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted.clone()), file: file.clone() }).unwrap(); + let AddedContent::Image(got) = s.get(id).unwrap().added()[2].content.clone() else { panic!("an image was pasted") }; + assert_eq!(got, pasted, "the same picture object, box, turn, flip and crop"); + + // Into another document: the picture comes along as a file of its own. + let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap(); + s.apply(other, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted.clone()), file: file.clone() }).unwrap(); + s.apply(other, Edit::PasteContent { page: 0, content: copied.clone(), file: None }).unwrap(); + let d = s.get(other).unwrap(); + let AddedContent::Image(theirs) = &d.added()[0].content else { panic!("an image was pasted") }; + assert_eq!(AddedImage { image: img.image, ..theirs.clone() }, pasted, "the same box and transforms"); + assert_eq!(d.added_image_file(0, 0).unwrap().1, file.as_ref().unwrap().1.as_slice(), "the same picture"); + assert_eq!(d.added()[1].content, copied); + // Pasted there again, it draws the first paste's picture object rather than storing a third. + let first = theirs.image; + s.apply(other, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted.clone()), file: file.clone() }).unwrap(); + let AddedContent::Image(again) = s.get(other).unwrap().added()[2].content.clone() else { panic!("an image was pasted") }; + assert_eq!(again.image, first, "one picture object for both pastes"); + // The object holds another picture of the same size (another document's numbering, an undone + // add): the file is embedded instead of drawing the wrong picture. + let mut other_png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut other_png, 20, 10); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[7u8; 600]).unwrap(); + } + let other_file = Some(("other.png".to_string(), Arc::new(other_png))); + s.apply(id, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted.clone()), file: other_file }).unwrap(); + let AddedContent::Image(fresh) = s.get(id).unwrap().added()[3].content.clone() else { panic!("an image was pasted") }; + assert_ne!(fresh.image, img.image, "a picture object of its own"); + assert_ne!(s.get(id).unwrap().added_image_file(0, 3).unwrap().1, file.as_ref().unwrap().1.as_slice()); + assert!(matches!( + s.apply(id, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted), file: None }), + Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(_))) + )); + // Four items on page 1 and the pasted text on page 2. + assert_eq!(s.get(id).unwrap().added().len(), 5, "refused pastes add nothing"); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().added().len(), 4, "a paste is one undo step"); +} + #[test] fn added_images_rotate_flip_and_crop_as_drawn() { // A 20×10 image: left half red, right half blue. diff --git a/docs/roadmap.md b/docs/roadmap.md index bb542aefc7..8ab3696b27 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4ec39848c5..9d1f4d6442 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2073,6 +2073,17 @@ tools = ["page_add_image"] evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::a_picture_on_the_clipboard_is_pasted_onto_the_page", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect. ⌘V / Ctrl+V pastes a picture from the system clipboard onto the current page the same way (desktop only)" +[[feature]] +id = "edit.cut-copy-paste-objects" +title = "Cut, copy and paste text boxes and images" +area = "C" +tier = "P1" +milestone = "M7" +status = "partial" +tools = ["content_copy"] +evidence = ["crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"] +notes = "Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG, or else a lossless PNG, so a colour profile is dropped and a JPEG 2000 picture can't be copied, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)." + [[feature]] id = "edit.image-replace" title = "Replace an image (keep position and size)" From 5515f321c8aacc66cee501e958ec3e9c6ed319e9 Mon Sep 17 00:00:00 2001 From: nleva Date: Fri, 23 Oct 2026 18:20:59 +0200 Subject: [PATCH 2/2] M7: copy added JPEG 2000 images as their file (#2636) Review: copying an added JPEG 2000 image failed even within its own document, because the exporter can't write JPEG 2000 yet. Add Image stores such a picture as the file it was given, so that file is the copy: it pastes into the same document as the same object and into another as one embedded image. The notes now also say that a CMYK picture stored losslessly turns to RGB in another document. Co-Authored-By: Opus 5.5 medium ultracode Claude-Session: https://claude.ai/code/session_017gEgu9NcTcPqKovsLyfPxK --- ROADMAP.md | 2 +- crates/automation/src/tools.rs | 2 +- crates/engine/src/copy_image.rs | 21 +++++++++++++----- crates/engine/src/tests.rs | 39 +++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bd3e060d49..da4ba0fa57 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -192,7 +192,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log -- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and a JPEG 2000 picture can't be copied. The desktop keys and menus follow in separate PRs. Estimates unchanged. +- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index bfbaac7d2b..8e7738054f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -624,7 +624,7 @@ pub fn tools() -> Vec { t( "content_copy", "Copy and paste added content", - "Copy an added item (page, index from content_list) and paste it, as Edit ▸ Copy and Paste do: onto to_page of to_doc (by default its own page and document) as it is: a text box's text, font, size, colour, alignment and box; an image's picture, box, turns, flips and crop (within its document the same image object is drawn again; into another one the picture goes as its JPEG, or else a lossless PNG, without a colour profile). It goes at at [x, y] (its top-left, points from the top-left of the page), else where it was, 18 points down and to the right past each copy of it already there; kept on the page. Refused when the source document doesn't allow copying. Returns the new item's page, index and rect. Undoable in the target document.", + "Copy an added item (page, index from content_list) and paste it, as Edit ▸ Copy and Paste do: onto to_page of to_doc (by default its own page and document) as it is: a text box's text, font, size, colour, alignment and box; an image's picture, box, turns, flips and crop (within its document the same image object is drawn again; into another one the picture goes as its JPEG or JPEG 2000 file, or else a PNG, without a colour profile and with CMYK colours turned to RGB). It goes at at [x, y] (its top-left, points from the top-left of the page), else where it was, 18 points down and to the right past each copy of it already there; kept on the page. Refused when the source document doesn't allow copying. Returns the new item's page, index and rect. Undoable in the target document.", ) .with(schema( json!({ diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index 167ddbdd09..b377903ee4 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -61,7 +61,7 @@ impl Document { } /// Edit ▸ Copy of an added image (`index` among `page`'s added items): its picture as a file - /// (extension, bytes), a JPEG as stored, otherwise a lossless PNG, for + /// (extension, bytes), a JPEG or JPEG 2000 file as stored, otherwise a lossless PNG, for /// [`Edit::PasteContent`](crate::Edit::PasteContent). Refused when the document doesn't allow /// copying. pub fn added_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { @@ -71,12 +71,21 @@ impl Document { let Some(pdfcraft_edit::Content::Image(img)) = item.map(|a| &a.content) else { return Err(format!("item {} on page {} is not an added image", index + 1, page + 1)); }; - pdfcraft_create::image_file(&editor.cos, img.image) + picture_file(&editor.cos, img.image) } } -/// Whether image object `image` in `cos` is the picture `file` (as `image_file` writes it). The -/// sizes are compared first, so an unrelated image is not re-encoded to find out. +/// An added image's picture as a file: [`pdfcraft_create::image_file`], except that a JPEG 2000 +/// picture, which Add Image stores as the file it was given, is that file. +fn picture_file(cos: &Cos, image: ObjRef) -> Result<(&'static str, Vec), String> { + match &*cos.get(image) { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") && s.dict.name(b"Filter") == Some(b"JPXDecode") => Ok(("jp2", s.raw.to_vec())), + _ => pdfcraft_create::image_file(cos, image), + } +} + +/// Whether image object `image` in `cos` is the picture `file` (as [`picture_file`] writes it). +/// The sizes are compared first, so an unrelated image is not re-encoded to find out. pub(crate) fn is_picture(cos: &Cos, image: ObjRef, file: &[u8]) -> bool { let size = |s: &pdfcraft_cos::Stream| Some((u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?)); // The object at that number now, not one of an earlier generation of it. @@ -84,11 +93,13 @@ pub(crate) fn is_picture(cos: &Cos, image: ObjRef, file: &[u8]) -> bool { return false; } let stored = match &*cos.get(image) { + // A JPEG 2000 picture is stored as its file. + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") && s.dict.name(b"Filter") == Some(b"JPXDecode") => return s.raw[..] == *file, Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => size(s), _ => return false, }; let given = image::ImageReader::new(std::io::Cursor::new(file)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); - stored.is_some() && stored == given && pdfcraft_create::image_file(cos, image).is_ok_and(|(_, data)| data == file) + stored.is_some() && stored == given && picture_file(cos, image).is_ok_and(|(_, data)| data == file) } /// The image object an added image in `cos` already draws picture `file` from, if any: where a diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5871677e15..696c7831bd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3237,6 +3237,45 @@ fn pasted_added_content_keeps_its_style_and_picture() { assert_eq!(s.get(id).unwrap().added().len(), 4, "a paste is one undo step"); } +/// An added JPEG 2000 picture is copied as the file it was added from: pasted into its own +/// document it draws the same object, and into another one it is embedded once. +#[test] +fn an_added_jpeg_2000_image_is_copied_as_its_file() { + // A JP2 file (30 × 20, three components) with a stub codestream, as Add Image takes it. + let bx = |ty: &[u8], payload: &[u8]| [((payload.len() + 8) as u32).to_be_bytes().as_slice(), ty, payload].concat(); + let ihdr = [20u32.to_be_bytes().as_slice(), &30u32.to_be_bytes(), &[0, 3, 7, 7, 0, 0]].concat(); + let signature = [0, 0, 0, 0x0C, b'j', b'P', b' ', b' ', 0x0D, 0x0A, 0x87, 0x0A]; + let file = [signature.as_slice(), &bx(b"ftyp", b"jp2 \0\0\0\0jp2 "), &bx(b"jp2h", &bx(b"ihdr", &ihdr)), &bx(b"jp2c", &[0xFF, 0x4F, 0xFF, 0x51])] + .concat(); + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 110.0, 140.0]), name: "photo.jp2".into(), bytes: Arc::new(file.clone()) }) + .unwrap(); + let (ext, copied) = s.get(id).unwrap().added_image_file(0, 0).unwrap(); + assert_eq!((ext, &copied), ("jp2", &file), "the file as it was added"); + let AddedContent::Image(img) = s.get(id).unwrap().added()[0].content.clone() else { panic!("an image was added") }; + let copied = Arc::new(copied); + let file = Some(("Pasted image.jp2".to_string(), copied.clone())); + let pasted = AddedContent::Image(AddedImage { rect: [70.0, 80.0, 130.0, 120.0], ..img.clone() }); + s.apply(id, Edit::PasteContent { page: 0, content: pasted.clone(), file: file.clone() }).unwrap(); + assert!(matches!(&s.get(id).unwrap().added()[1].content, AddedContent::Image(i) if i.image == img.image), "the same picture object"); + let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap(); + for _ in 0..2 { + s.apply(other, Edit::PasteContent { page: 0, content: pasted.clone(), file: file.clone() }).unwrap(); + } + let d = s.get(other).unwrap(); + let images: Vec<_> = d + .added() + .iter() + .filter_map(|a| match &a.content { + AddedContent::Image(i) => Some(i.image), + _ => None, + }) + .collect(); + assert_eq!(images.len(), 2); + assert_eq!(images[0], images[1], "embedded once"); + assert_eq!(d.added_image_file(0, 0).unwrap().1, copied.as_slice()); +} + #[test] fn added_images_rotate_flip_and_crop_as_drawn() { // A 20×10 image: left half red, right half blue. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9d1f4d6442..84a9da8e9a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2081,8 +2081,8 @@ tier = "P1" milestone = "M7" status = "partial" tools = ["content_copy"] -evidence = ["crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"] -notes = "Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG, or else a lossless PNG, so a colour profile is dropped and a JPEG 2000 picture can't be copied, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)." +evidence = ["crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/engine/src/tests.rs::an_added_jpeg_2000_image_is_copied_as_its_file", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"] +notes = "Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG or JPEG 2000 file, or else a PNG, so a colour profile is dropped and CMYK colours in a PNG are turned to RGB, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)." [[feature]] id = "edit.image-replace"