diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 62bef8bc5..79f2c2cd2 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -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)> { .collect() } +/// The page's streams joined into the one stream they are: an operator may span two of them. +fn joined(streams: &[(Object, Vec)]) -> Pieces { + Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()) +} + fn page_of(doc: &Document, page: usize) -> Result { 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, 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, 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, ) -> 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, 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, 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, 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 = 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::>()); + 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::>()); + 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(()) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d8a8374af..f2def2fd6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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();