Find and edit images whose Do or cm spans two content streams (#3294)

Fixes #3113

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-18 17:07:51 -04:00
committed by GitHub
co-authored by Claude Haiku 5.5
parent e2d4814089
commit c00ee14be8
2 changed files with 221 additions and 177 deletions
+153 -177
View File
@@ -4,8 +4,8 @@
//! or delete. Only the stream that draws it is rewritten, as a new object.
use kurbo::{BezPath, Point, Shape};
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_content::{Matrix, Op, Pieces, parse};
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
@@ -30,6 +30,8 @@ pub struct PageImage {
/// Pixel size; zero for vector/grouped Form artwork.
pub width: u32,
pub height: u32,
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
/// operators of the page's streams joined ([`Pieces`]).
stream: usize,
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
@@ -90,6 +92,11 @@ fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
.collect()
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
}
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
@@ -214,72 +221,71 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
let p = page_of(doc, page)?;
let xo = xobjects(doc, &p.dict);
let mut out = Vec::new();
// The graphics state carries over from one stream to the next.
// The streams are one stream in pieces, so the graphics state and operators carry over.
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
for (i, op) in parse(&data).ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
let pieces = joined(&streams(doc, &p.dict));
for (i, op) in pieces.parse().ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
if op.is("Do") {
let Some(name) = op.name(0) else { continue };
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
clip_rect: Some(state.placement.clip),
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: si,
op: i,
draw_matrix: ctm.0,
clips: state.placement.clips.clone(),
});
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
clip_rect: Some(state.placement.clip),
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: pieces.piece_of(op.span.start),
op: i,
draw_matrix: ctm.0,
clips: state.placement.clips.clone(),
});
}
}
Ok(out)
@@ -298,15 +304,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
}
fn walk(
doc: &Document,
data: &[u8],
ops: &[Op],
resources: &Dict,
st: &mut ImageState,
stream: usize,
stream: &dyn Fn(&Op) -> usize,
forms: &mut Forms,
out: &mut Vec<PageImage>,
) -> Result<(), EditError> {
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
for (i, op) in parse(data).ops.iter().enumerate() {
for (i, op) in ops.iter().enumerate() {
st.apply(op)?;
let ctm = st.placement.matrix;
if op.is("Do") {
@@ -325,7 +331,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
);
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?;
let piece = stream(op);
walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?;
if form.obj.is_some() {
forms.path.pop();
}
@@ -347,7 +354,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
is_form: false,
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
stream,
stream: stream(op),
op: i,
draw_matrix: ctm.0,
clips: st.placement.clips.clone(),
@@ -361,9 +368,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
let mut out = Vec::new();
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut forms = Forms { path: Vec::new(), visits: 0 };
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?;
}
let pieces = joined(&streams(doc, &p.dict));
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
Ok(out)
}
@@ -450,9 +456,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
let p = page_of(doc, page)?;
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;
if ops.get(img.op).is_none() {
let pieces = joined(&all);
let ops = pieces.parse().ops;
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation is missing".into()));
}
let mut resources = None;
@@ -479,16 +485,10 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
vec![Op::new("Do", vec![Object::name(&name)])]
}
};
ops.splice(img.op..=img.op, replacement);
let mut dict = match &*doc.resolve(&obj) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
};
dict.remove(b"Length");
let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops))));
let contents: Vec<Object> = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect();
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
let contents = crate::text::rewritten_contents(doc, &all, changed);
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) });
d.set(b"Contents".to_vec(), contents);
if let Some(res) = resources {
d.set(b"Resources".to_vec(), Object::Dict(res));
}
@@ -599,22 +599,12 @@ fn transform_image(
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 pieces = joined(streams);
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()))?;
let target = image.op;
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(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)));
@@ -677,22 +667,12 @@ fn crop_image(
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
return Err(EditError::Invalid("the crop box has no area".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 pieces = joined(streams);
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()))?;
let target = image.op;
if !ops.get(target).is_some_and(|op| op.is("Do")) {
return Err(EditError::Invalid("the image operation could not be located".into()));
}
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 corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
@@ -758,76 +738,72 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
return Err(EditError::Invalid("the image move is too large".into()));
}
plans.insert((image.stream, image.op), (movement, placement, offset));
plans.insert(image.op, (movement, placement, offset));
}
let mut data = Vec::with_capacity(all.len());
let pieces = joined(&all);
let ops = pieces.parse().ops;
let mut inside_text = false;
for (stream, (_, bytes)) in all.iter().enumerate() {
let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]);
let ops = pieces.parse().ops;
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
inside_text = true;
} else if op.is("ET") {
inside_text = false;
} else if inside_text && plans.contains_key(&(stream, index)) {
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
}
for (index, op) in ops.iter().enumerate() {
if op.is("BT") {
inside_text = true;
} else if op.is("ET") {
inside_text = false;
} else if inside_text && plans.contains_key(&index) {
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
}
// Images alone in their own `q re W n cm Do Q` take the clip with them.
let mut clips = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue };
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&(stream, index));
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
let folded = at
.checked_sub(1)
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
match folded {
Some((i, m)) => clips.insert((stream, i), (m, false)),
None => clips.insert((stream, at), (c, true)),
};
}
// 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, keep)) = clips.remove(&(stream, index)) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
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
// rather than rounding them to zero or amplifying an error on the page.
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
out.extend(original);
out.push(Op::new("Q", vec![]));
(out, false)
});
data.push(changed.into_iter().next().unwrap_or_default());
}
if !plans.is_empty() {
// Images alone in their own `q re W n cm Do Q` take the clip with them.
let mut clips = std::collections::BTreeMap::new();
for index in 0..ops.len() {
let Some(&(_, placement, offset)) = plans.get(&index) else { continue };
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&index);
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
let folded = at
.checked_sub(1)
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
match folded {
Some((i, m)) => clips.insert(i, (m, false)),
None => clips.insert(at, (c, true)),
};
}
// 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(&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(&index);
folds.insert(at, folded);
}
if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) {
return Err(EditError::Invalid("a selected image operation is missing".into()));
}
let data = crate::text::splice(&pieces, &ops, |index| {
if let Some((c, keep)) = clips.remove(&index) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
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(&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
// rather than rounding them to zero or amplifying an error on the page.
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
out.extend(original);
out.push(Op::new("Q", vec![]));
(out, false)
});
let contents = crate::text::rewritten_contents(doc, &all, data);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
+68
View File
@@ -3928,6 +3928,74 @@ fn private_image_crop_with_intervening_state_resizes_across_streams() {
}
}
/// [`image_page`] with `pieces` as its `/Contents` array and a Form /Fm0 drawing 72,500–172,600.
fn split_image_page(pieces: &[&[u8]]) -> Document {
let mut doc = image_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([72, 500, 172, 600].map(Object::Int).to_vec()));
let form = doc.add(Object::Stream(Stream::flate(form, b"0.5 g 72 500 100 100 re f")));
let image = doc.get(pdfcraft_model::pages(&doc)[0].obj);
let mut res = image.as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| r.as_dict().cloned()).unwrap();
let mut xo = res.get(b"XObject").and_then(|x| x.as_dict().cloned()).unwrap();
xo.set(b"Fm0".to_vec(), Object::Ref(form));
res.set(b"XObject".to_vec(), Object::Dict(xo));
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));
d.set(b"Resources".to_vec(), Object::Dict(res));
})
.unwrap();
doc
}
#[test]
fn an_image_whose_operator_starts_the_next_stream_can_be_selected_moved_and_deleted() {
// #3113: an operator's operands end one piece and its keyword starts the next.
let square = [72.0, 500.0, 172.0, 600.0];
let cases: [(&[&[u8]], bool); 4] = [
(&[b"q 100 0 0 100 72 500 cm /Im0", b"Do Q"], false),
(&[b"q 100 0 0 100 72 500", b"cm /Im0 Do Q"], false),
(&[b"q /Fm0", b"Do Q"], true),
(&[b"q 100 0 0 100 72 500 cm /Im0 Do Q"], false),
];
for (pieces, is_form) in cases {
let doc = split_image_page(pieces);
let label: Vec<_> = pieces.iter().map(|p| String::from_utf8_lossy(p)).collect();
let images = images::page_images(&doc, 0).unwrap();
assert_eq!(images.len(), 1, "{label:?}");
assert!(close(images[0].rect, square), "{label:?}: {:?}", images[0].rect);
assert_eq!(images[0].is_form, is_form);
assert!(images[0].contains([122.0, 550.0]), "{label:?}");
if !is_form {
let read = images::reading_images(&doc, 0).unwrap();
assert!(read.len() == 1 && close(read[0].rect, square), "{label:?}: {read:?}");
}
let mut deleted = doc.clone();
images::change_image(&mut deleted, 0, 0, &images::ImageChange::Delete).unwrap();
let deleted = reopen(&deleted);
assert!(images::page_images(&deleted, 0).unwrap().is_empty(), "{label:?}");
let (joined, ops) = joined_ops(&deleted);
assert!(!ops.iter().any(|o| o.is("Do")), "{joined}");
let target = [300.0, 500.0, 400.0, 600.0];
let mut moved = doc.clone();
images::change_image(&mut moved, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(square, target))).unwrap();
let moved = reopen(&moved);
let after = images::page_images(&moved, 0).unwrap();
assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&moved).0);
let mut shifted = doc.clone();
images::translate_images(&mut shifted, 0, &[0], [228.0, 0.0]).unwrap();
let shifted = reopen(&shifted);
let after = images::page_images(&shifted, 0).unwrap();
assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&shifted).0);
}
}
#[test]
fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() {
let mut doc = image_page();