From 13637b8440873846411ee3579e5e4b38aff1a95a Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:38:32 +0500 Subject: [PATCH] M7: transform private image crops across nested states and streams (#1269) Co-authored-by: OpenAI Codex Co-authored-by: Brandon Thomas --- ROADMAP.md | 7 +- crates/automation/tests/automation.rs | 58 +++++++++ crates/edit/src/images.rs | 164 ++++++++++++++++++++++---- crates/edit/src/tests.rs | 43 +++++-- docs/editing-images.md | 10 +- docs/gaps.md | 4 +- docs/roadmap.md | 7 +- docs/target-app-parity.md | 3 +- parity/acrobat-features.toml | 4 +- 9 files changed, 251 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 26365339..fe74d300 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,8 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -137,6 +137,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. + - **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged. - **2026-10-12 (M14, gap 13, large-file memory):** Measured on synthetic 1 GiB and 100,000-page files. Edits let go of the previous working file (working bytes, display bytes, renderer) before writing the next, so a 1 GiB file peaks at two copies while editing instead of three (five edits: 3.1 → 2.1 GiB, 16 → 8.5 s). Saving still peaks at three; reading from disk on demand is blocked on the renderer (gap 2). Estimates unchanged. @@ -383,6 +385,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M10: borderless inferred tables and positioned invoice watermarks (#773); estimates unchanged. | | 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. | | 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged | diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index eca4f9ab..19434dd3 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4912,3 +4912,61 @@ fn architectural_and_iso_papers_through_tools() { assert_eq!(std::fs::read(dir.join("plot.pdf")).unwrap(), before); assert!(a.call("doc_print", &json!({"doc": doc, "paper": "ARCH D", "path": "../escape.pdf"})).is_err()); } + +#[test] +fn private_crops_with_nested_state_resize_save_reopen_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; + let dir = workdir("private-crop-state"); + let mut cos = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&cos)[0].clone(); + let mut image = Dict::new(); + for (key, value) in [ + ("Type", Object::name("XObject")), + ("Subtype", Object::name("Image")), + ("Width", Object::Int(1)), + ("Height", Object::Int(1)), + ("BitsPerComponent", Object::Int(8)), + ("ColorSpace", Object::name("DeviceRGB")), + ] { + image.set(key.as_bytes().to_vec(), value); + } + let image = cos.add(Object::Stream(Stream::flate(image, &[220, 235, 245]))); + let mut resources = cos.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + resources.set(b"XObject".to_vec(), Object::Dict(xo)); + let parts: [&[u8]; 3] = + [b"q 30 170 130 80 re W*", b"n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do", b"Q Q BT /F1 12 Tf 30 130 Td (Neighbour caption) Tj ET"]; + let refs = parts.iter().map(|b| Object::Ref(cos.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect(); + cos.update_dict(page.obj, |d| { + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Array(refs)); + }) + .unwrap(); + std::fs::write(dir.join("crop.pdf"), write_incremental(&cos, &Default::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"crop.pdf"}))["doc"].as_u64().unwrap(); + let original = ok(&mut a, "page_images", json!({"doc":doc,"page":1})); + let labels = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + let pixels = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + for factor in [1.2, 0.7] { + let rect = [30.0 + (-300.0 - 30.0) * factor, 50.0 + (-200.0 - 50.0) * factor, 30.0 + (500.0 - 30.0) * factor, 50.0 + (700.0 - 50.0) * factor]; + ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":rect})); + let images = ok(&mut a, "page_images", json!({"doc":doc,"page":1})); + let expected = [30.0, 50.0, 30.0 + 130.0 * factor, 50.0 + 80.0 * factor]; + for (actual, expected) in images["images"][0]["visible_rect"].as_array().unwrap().iter().zip(expected) { + assert!((actual.as_f64().unwrap() - expected).abs() < 0.01); + } + assert_eq!(images["images"][0]["pixels"], original["images"][0]["pixels"]); + assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})), labels); + let resized = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + assert_ne!(resized, pixels, "the actual crop changes size"); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"out/resized.pdf"})); + let re = ok(&mut a, "doc_open", json!({"path":"out/resized.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "page_images", json!({"doc":re,"page":1})), images); + assert_eq!(a.call("page_render", &json!({"doc":re,"page":1,"dpi":72})).unwrap(), resized); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), pixels); + assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1})), original); + } +} diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 7bb17d06..85ae6b39 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -435,33 +435,14 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let all = streams(doc, &p.dict); let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; let mut ops = parse(&data).ops; - let original = ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + if ops.get(img.op).is_none() { + return Err(EditError::Invalid("the image operation is missing".into())); + } let mut resources = None; - let mut clip_move = None; let replacement: Vec = match change { ImageChange::Delete => Vec::new(), ImageChange::Transform(t) => { - if !t.iter().all(|v| v.is_finite()) { - return Err(EditError::Invalid("the image transform must be finite".into())); - } - // New CTM = placement × T; as a `cm` before the Do: M = P · T · P⁻¹. - let pm = Matrix(img.draw_matrix); - let inv = pm.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; - let m = pm.then(&Matrix(*t)).then(&inv); - if !m.0.iter().all(|v| v.is_finite()) { - return Err(EditError::Invalid("the image transform is too large".into())); - } - // `q re W n cm Do Q` clips only this image: move the clip with it instead of leaving it behind. - if let Some((at, cm)) = exclusive_clip(&ops, img.op) { - if let Some(c) = clip_matrix(Matrix(img.draw_matrix), cm, Matrix(*t)).filter(|c| c.0.iter().all(|v| v.is_finite())) { - clip_move = Some((at, Op::new("cm", c.0.iter().map(|v| pdfcraft_content::num(*v)).collect()))); - vec![original] - } else { - return Err(EditError::Invalid("the image transform is too large".into())); - } - } else { - vec![Op::new("q", vec![]), Op::new("cm", m.0.iter().map(|v| pdfcraft_content::num(*v)).collect()), original, Op::new("Q", vec![])] - } + return transform_image(doc, &p, &all, &img, *t); } ImageChange::Replace(r) => { if img.is_form { @@ -479,9 +460,6 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag } }; ops.splice(img.op..=img.op, replacement); - if let Some((at, cm)) = clip_move { - ops.insert(at, cm); - } let mut dict = match &*doc.resolve(&obj) { Object::Stream(s) => s.dict.clone(), _ => Dict::new(), @@ -513,6 +491,140 @@ pub(crate) fn selectable_images( .collect()) } +/// Find the saved graphics-state scope containing just this image and its crop. Applying +/// the transform before that scope moves the actual clipping path as well as the pixels. +/// A shared clip must never be moved along with unrelated artwork or text. +fn image_transform_scope(ops: &[Op], target: usize) -> Result, EditError> { + struct Scope { + start: usize, + matrix: Matrix, + inherited_clip: bool, + inherited_path: bool, + selected: bool, + other_paint: bool, + } + let mut stack: Vec = Vec::new(); + let mut matrix = Matrix::IDENTITY; + let mut clipped = false; + let mut path = false; + let mut pending_clip = false; + let mut text = false; + let mut at_image = None; + let mut isolated = None; + for (index, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"q" => { + if stack.len() >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested to transform".into())); + } + stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); + } + b"Q" => { + if let Some(scope) = stack.pop() { + if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path { + // Prefer the innermost scope that contains every active clipping path. + isolated.get_or_insert((scope.start, index, scope.matrix)); + } + matrix = scope.matrix; + clipped = scope.inherited_clip; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"BT" => text = true, + b"ET" => text = false, + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true, + b"W" | b"W*" => pending_clip = true, + _ => {} + } + if index == target { + if text { + return Err(EditError::Invalid("an image inside a text object cannot be transformed".into())); + } + at_image = Some((matrix, clipped)); + for scope in &mut stack { + scope.selected = true; + } + } else if matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) { + for scope in &mut stack { + scope.other_paint = true; + } + } + if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { + clipped |= pending_clip; + pending_clip = false; + path = false; + } + } + let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + if !clipped { + return Ok(Some((target, target, placement))); + } + Ok(isolated) +} + +fn transform_image( + doc: &mut Document, + page: &pdfcraft_model::Page, + streams: &[(Object, Vec)], + image: &PageImage, + transform: [f64; 6], +) -> Result<(), EditError> { + if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into())); + } + let mut offset = 0_usize; + for (_, data) in streams.iter().take(image.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + // A private path crop moves with its image regardless of intervening state operators. + // Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint. + let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; + let local = placement.then(&Matrix(transform)).then(&inverse); + if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image transform is too large".into())); + } + let changed = crate::text::splice(&pieces, &ops, |index| { + if index == start { + let mut out = vec![Op::new("q", vec![]), Op::new("cm", local.0.map(Object::Real).to_vec())]; + if start == end { + out.extend(ops.get(index).cloned()); + out.push(Op::new("Q", vec![])); + return (out, false); + } + (out, true) + } else if index == end { + let mut out: Vec<_> = ops.get(index).cloned().into_iter().collect(); + out.push(Op::new("Q", vec![])); + (out, false) + } else { + (Vec::new(), true) + } + }); + let contents = crate::text::rewritten_contents(doc, streams, changed); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// Plan all transforms before changing a stream; indexes refer to the original inventory. /// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries. pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 27a0ea7d..f0da637e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2946,11 +2946,7 @@ fn image_selection_bounds_respect_clips_and_restore_graphics_state() { images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(pdfcraft_content::Matrix::translate(offset[0], offset[1]).0)).unwrap(); let reopened = reopen(&doc); let after = images::page_images(&reopened, 0).unwrap(); - assert_eq!( - after[0].visible_rect, - Some([30.0, 50.0, 130.0, 130.0]), - "a clip outside the image's own `q re W n cm Do Q` stays where it is (#1049)" - ); + assert_eq!(after[0].visible_rect, Some([42.0, 64.0, 142.0, 144.0]), "an exclusive crop moves with the image even across nested graphics states"); for (a, b) in after[0].rect.iter().zip(before[0].rect.iter()).enumerate() { assert!((b.0 - b.1 - offset[a % 2]).abs() < 0.001); } @@ -2978,7 +2974,7 @@ fn image_clip_path_and_graphics_state_continue_across_content_streams() { ) .unwrap(); let after = images::page_images(&reopen(&doc), 0).unwrap(); - assert!(close(after[0].visible_rect.unwrap(), [20.0, 30.0, 60.0, 80.0]), "a clip split across streams stays where it is"); + assert!(close(after[0].visible_rect.unwrap(), [20.0, 30.0, 80.0, 105.0]), "an exclusive crop scales with its image across streams"); assert_eq!(after[1].matrix, images[1].matrix, "graphics state restored across stream boundaries"); } @@ -3026,6 +3022,35 @@ fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1); } +#[test] +fn private_image_crop_with_intervening_state_resizes_across_streams() { + let variants: Vec> = vec![ + vec![b"q 1 0 0 1 10 20 cm 20 150 130 80 re W* n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET"], + vec![b"q 1 0 0 1 10 20 cm 20 150 130 80 re W*", b"n 0.5 g q 800 0 0 900 -300 -400 cm /Im0 Do", b"Q Q BT /F1 12 Tf 30 130 Td (Caption) Tj ET"], + ]; + for pieces in variants { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect(); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(refs))).unwrap(); + let caption = text::text_lines(&doc, 0).unwrap(); + for target in [[30.0, 170.0, 190.0, 270.0], [30.0, 170.0, 110.0, 220.0], [70.0, 210.0, 150.0, 260.0]] { + let before = images::page_images(&doc, 0).unwrap()[0].clone(); + let transform = images::rect_to_rect(before.visible_rect.unwrap(), target); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap(); + doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(after.visible_rect.unwrap(), target), "the private crop follows the image without a literal operator pattern"); + assert!(close(after.rect, pdfcraft_content::Matrix(transform).bbox(before.rect))); + assert_eq!(after.object, before.object); + assert_eq!( + text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>(), + caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>() + ); + } + } +} + #[test] fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() { let mut doc = image_page(); @@ -3042,9 +3067,9 @@ fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() { assert!(!images[1].contains([150.0, 150.0]), "even-odd hole"); images::change_image(&mut doc, 0, 1, &images::ImageChange::Transform([1.2, 0.0, 0.0, 1.2, 10.0, 15.0])).unwrap(); let after = images::page_images(&reopen(&doc), 0).unwrap(); - // Only a rectangle in the image's own `q re W n cm Do Q` moves with it (#1049): these stay. - assert!(!after[1].contains([150.0, 150.0]), "the even-odd hole stays where it is"); - assert!(after[1].contains([110.0, 110.0])); + assert!(!after[1].contains([190.0, 195.0]), "the transformed even-odd hole remains empty"); + assert!(after[1].contains([142.0, 147.0]), "the transformed filled corner remains selectable"); + assert_eq!(after[0].visible_rect, images[0].visible_rect, "the neighbouring curved clip stays put"); } #[test] diff --git a/docs/editing-images.md b/docs/editing-images.md index d4bc4dd9..3417b4c6 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -3,9 +3,9 @@ In **Edit a PDF → Edit text and images**, click a figure to select it. Drag the selected figure to move it, drag a corner to resize it proportionally, or press Delete to remove that placement. The context menu also rotates or flips it. Changes support undo and -survive saving/reopening. An image painted inside its own rectangular clip -(`q … re W n … cm Do Q`, as PowerPoint exports it) moves and resizes together with that -clip; a clip shared with other content stays where it is. +survive saving/reopening. For single-figure transforms, a clipping path exclusive to one image moves and resizes +together with its pixels, including nested graphics states, intervening state operators +and paths split across content streams. A clip shared with other content stays where it is. PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**, which packages text, vector paths, and nested images into one object. PdfCraft selects @@ -33,8 +33,8 @@ curved clips can still have a loose bounding frame. Dragging the visible frame m the full placement by the same change; resizing keeps the opposite corner fixed, including horizontal-only or vertical-only corner drags, and grabbing a grip slightly off-centre does not jump. A selected corner keeps control of its drag even over a text label; the drag previews one -frame and applies one undoable edit on release. A clip that belongs to the image alone -(`q re W n cm Do Q`) moves with it; a clip shared with other content stays where it is. +frame and applies one undoable edit on release. Exclusive crops and their holes move +with the selected image; shared clips stay fixed without moving neighbouring text or artwork. `image_hit` applies the same path and hole checks headlessly. `page_images` retains `rect` as the full placement rectangle used by `image_edit` and adds `visible_rect` for selection (null diff --git a/docs/gaps.md b/docs/gaps.md index 65c1c668..0e847d5d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 13: large-file memory measured, edits keep two copies instead of three, file-backed input blocked on gap 2) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (gap 1 closed by #836; fixed issues removed from gaps 3, 6 and 16) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document-font subset completion and curved/shared image clips in the contributor fork; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -80,7 +81,7 @@ update the numbers. Page/path-clipped artwork bounds and text-first hit testing prevent large cropped images from blocking text. A separate private four-page regression opens all 73 detected text regions; curve/hole hit testing now evaluates clipping paths and fill rules. A clip the image owns - (`q re W n cm Do Q`) moves with it; shared path clips stay where they are. + moves with it across nested states and content streams; shared path clips stay where they are. Full matching document fonts complete absent subset glyphs; explicit installed/file font selection, TrueType/OpenType-CFF embedding, disjoint style ranges, marked paragraph boundaries and single-face OpenType shaping/bidi have regression coverage. Actual Chinese keyboard @@ -319,6 +320,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-12 | minor | Gap 13: measured large-file memory; edits no longer keep the previous working file while writing the next (peak 3 → 2 copies); file-backed input recorded as blocked on gap 2 | | 2026-10-11 | minor | Word invoice failure #773 has synthetic regression coverage; broader Office conversion remains gap 8. | | 2026-10-11 | minor | Contributor fork: mixed-style visual regions, source-font space reuse and a read-only textless background. Embedded sfnt source faces and Mac subset Unicode mappings keep edit baselines stable; live frames follow Apply/Save geometry as text grows or shrinks. Engine/tool/UI regressions and private all-page checks cover the changes; full matching font import, CFF, range styles, shaping and private Chinese typing checks added; legacy programs and general fidelity remain open; estimates unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 546df8de..e0b751a7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,7 +1,7 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (M2: short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request; earlier: direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (M13/#971: bound per-page search match allocation; text cache byte budget and extraction warnings remain open) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -83,7 +83,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | @@ -140,6 +140,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | M4: editable Organize page-range selection (#1092), synchronized with thumbnails and available through UI control. Estimates unchanged. | | 2026-10-11 | minor | M10: Word invoice export regressions (#773); estimates unchanged. | | 2026-10-12 | minor | M2: short same-line redraws split before placement; estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 96b0ba6e..f3eecdee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (document-font subset completion, source styles and curved/shared image clips in the contributor fork; closed issues synced, scores not re-judged: #774, #756, #740, #759, #799–#821, #825, #844; earlier: readiness table with hours per audience; full number restated as the standard's additive weighted sum, 47% → 49%; earlier: second look: cross-cutting double count removed, mainstream-practitioner number added, core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** exclusive image-crop resize follow-up; minor (document-font subset completion, source styles and curved/shared image clips in the contributor fork; closed issues synced, scores not re-judged: #774, #756, #740, #759, #799–#821, #825, #844; earlier: readiness table with hours per audience; full number restated as the standard's additive weighted sum, 47% → 49%; earlier: second look: cross-cutting double count removed, mainstream-practitioner number added, core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) The authoritative assessment of how close PdfCraft is to Acrobat Pro. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every shortfall one by one; the deep-dive checklists are @@ -292,6 +292,7 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), #825 and the #799–#821 fix batch. Text only: scores, readiness and hours not re-judged | | 2026-10-10 | minor | Readiness table: hours to ≈ 95% per audience (full 780–1,400, mainstream 500–900, essentials 180–330). Full number restated as the standard's additive weighted sum over dimensions with written weights: 47% → 49%. Method aligned with the standard, no new evidence | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4015a231..2461a5d9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2082,8 +2082,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_edit", "image_hit"] -evidence = ["crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"] -notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; an image's own `q re W n cm Do Q` clip moves with it (#1049), shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" +evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"] +notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams; shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" [[feature]] id = "edit.image-extract"