diff --git a/ROADMAP.md b/ROADMAP.md index a36a5ae284..7ae1dc8ccc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,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 (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **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-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) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -193,6 +194,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. +- **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/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 d2114ac7bf..2a4541512a 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..8e7738054f 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 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!({ + "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 535dfdc36b..295cadddae 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4293,6 +4293,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 6a183c03a5..334450e8b3 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..b377903ee4 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -59,6 +59,57 @@ 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 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> { + 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)); + }; + picture_file(&editor.cos, img.image) + } +} + +/// 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. + if cos.generation(image.num) != image.generation { + 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 && 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 +/// 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 f7d901c31c..92b1a2da65 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; } @@ -4187,7 +4225,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 c709c2b513..58f55fec6f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3155,6 +3155,127 @@ 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"); +} + +/// 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 a_batch_of_content_edits_refreshes_the_added_content_and_marks() { let (mut s, id) = session_with(1); diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d1a563cb4..a1ff83408f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,7 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **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-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9c4a59b5f6..b9c4f6c886 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/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" title = "Replace an image (keep position and size)"