mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 13:37:35 -06:00
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:
co-authored by
Claude Opus 5.5
parent
8e74a4850c
commit
0f86ba905f
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user