diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 85ae6b391b..83f6c108d0 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -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 { @@ -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 diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ecbedb9b4f..5c555814a3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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(); diff --git a/docs/editing-images.md b/docs/editing-images.md index 3417b4c6d4..eba918ef39 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -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