From 5515f321c8aacc66cee501e958ec3e9c6ed319e9 Mon Sep 17 00:00:00 2001 From: nleva Date: Fri, 23 Oct 2026 18:20:59 +0200 Subject: [PATCH] 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 bd3e060d4..da4ba0fa5 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 bfbaac7d2..8e7738054 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 167ddbdd0..b377903ee 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 5871677e1..696c7831b 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 9d1f4d644..84a9da8e9 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"