M7: fold repeated image transforms into one cm instead of nesting q (#1746)

Moving, resizing, rotating or flipping a figure wrapped it in a fresh
`q cm … Q` every time, so twenty drags left twenty nested graphics
states and twenty `cm` operators around one `Do`. Each edit made the
content stream longer and pushed the figure closer to the nesting limit
viewers enforce (PDF 32000-1 Annex C recommends 28 levels).

When the placement already sits alone in a `q cm … Q` (its own
placement, or the wrapper an earlier edit added), `transform_image` and
`translate_images` now change that `cm` in place: the CTM inside is
`local · cm · outer` either way (8.4.4, 8.3.4), so the result is
identical. A bare `cm Do` without a `q`, a shared clip or any other
content in the scope still gets the wrapper, once.

The new test runs twenty resizes and twenty group moves on a plain
placement, one with a crop exclusive to the image, and a bare `cm Do`,
and checks the visible rectangle after each step, that the `q` depth
stays at its first-edit value, and that the following text is untouched.
It fails without this change ("twenty resizes nest no deeper than one").

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
komo1iddin
2026-10-15 02:22:31 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8e74a4850c
commit 0f86ba905f
3 changed files with 100 additions and 0 deletions
+42
View File
@@ -420,6 +420,19 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> {
Some((at, Matrix(ops.get(at + 3)?.nums::<6>()?)))
}
/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced
/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same
/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each.
fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> {
let at = start.checked_sub(1)?;
let cm = ops.get(at)?;
if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 {
return None;
}
let m = Matrix(cm.nums::<6>()?);
m.0.iter().all(|v| v.is_finite()).then_some((at, m))
}
/// The `cm` to put before the clip so that user-space transform `t` also moves the clip:
/// the clip is drawn in the CTM before the image's own `cm` (`cm` · that = `draw`).
fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option<Matrix> {
@@ -603,6 +616,20 @@ fn transform_image(
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image transform is too large".into()));
}
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
let folded = local.then(&cm);
if !folded.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 == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], 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))?;
return Ok(());
}
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())];
@@ -672,10 +699,25 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
plans.remove(&(stream, index));
clips.insert((stream, at), c);
}
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
let mut folds = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue };
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
let folded = movement.then(&cm);
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.remove(&(stream, index));
folds.insert(at, folded);
}
let changed = crate::text::splice(&pieces, &ops, |index| {
if let Some(c) = clips.remove(&(stream, index)) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true);
}
if let Some(m) = folds.remove(&index) {
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
}
let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) };
let original = ops.get(index).cloned().into_iter();
// Local translations can be tiny under a large CTM. Preserve their precision
+55
View File
@@ -1630,6 +1630,61 @@ fn close(a: [f64; 4], b: [f64; 4]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
/// The deepest `q` nesting on the page.
fn q_depth(doc: &Document) -> usize {
let mut depth = 0_usize;
let mut deepest = 0;
for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops {
if op.is("q") {
depth += 1;
deepest = deepest.max(depth);
} else if op.is("Q") {
depth = depth.saturating_sub(1);
}
}
deepest
}
#[test]
fn repeated_image_edits_change_one_transform_instead_of_nesting() {
let variants = [
// Its own placement, a private crop, and a bare `Do` that the first edit wraps.
"q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET",
"q 100 100 200 100 re W* n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET",
"200 0 0 100 100 100 cm /Im0 Do",
];
for content in variants {
let mut doc = image_page_with(content);
let resize = |doc: &mut Document, n: usize| -> usize {
let mut depth = 0;
for i in 0..n {
let r = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap();
// Grow by 3% about the lower-left corner and move right by 2, staying on the page.
let target = [r[0] + 2.0, r[1], r[0] + 2.0 + (r[2] - r[0]) * 1.03, r[1] + (r[3] - r[1]) * 1.03];
images::change_image(doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(r, target))).unwrap();
let after = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap();
assert!(after.iter().zip(target).all(|(a, b)| (a - b).abs() < 1e-6), "{content}: {after:?} vs {target:?}");
if i == 0 {
depth = q_depth(doc);
}
}
depth
};
let first = resize(&mut doc, 20);
assert_eq!(q_depth(&doc), first, "{content}: twenty resizes nest no deeper than one");
let mut doc = reopen(&doc);
let before = images::page_images(&doc, 0).unwrap()[0].rect;
for _ in 0..20 {
images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap();
}
let after = images::page_images(&doc, 0).unwrap()[0].rect;
assert!(close(after, [before[0] + 20.0, before[1] - 40.0, before[2] + 20.0, before[3] - 40.0]), "{content}: {after:?}");
assert_eq!(q_depth(&doc), first, "{content}: group moves change the same transform");
let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(caption.is_empty(), !content.contains("Caption"), "{content}: the text after the image is untouched");
}
}
#[test]
fn page_images_move_turn_replace_and_delete() {
let mut doc = image_page();
+3
View File
@@ -6,6 +6,9 @@ that placement. The context menu also rotates or flips it. Changes support undo
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.
When the figure already sits alone in a `q … cm … Q` (its own placement, or the one an
earlier edit added), moving or resizing it again changes that `cm`, so repeated edits don't
nest one more graphics state each time.
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