M7: transform private image crops across nested states and streams (#1269)
Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
5a91d1b7d0
commit
13637b8440
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Op> = 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<Option<(usize, usize, Matrix)>, EditError> {
|
||||
struct Scope {
|
||||
start: usize,
|
||||
matrix: Matrix,
|
||||
inherited_clip: bool,
|
||||
inherited_path: bool,
|
||||
selected: bool,
|
||||
other_paint: bool,
|
||||
}
|
||||
let mut stack: Vec<Scope> = 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<u8>)],
|
||||
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::<Vec<_>>());
|
||||
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> {
|
||||
|
|
|
|||
|
|
@ -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<&[u8]>> = 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::<Vec<_>>(),
|
||||
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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.<br>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.<br>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.<br>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.<br>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.<br>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<br>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.<br>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<br>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. |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue