diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 671254268e..28f73aae36 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -83,6 +83,7 @@ impl PageImage { struct ClipPath { path: Arc, even_odd: bool, + app_crop: bool, } /// Image draws one page's inventory lists at most: a form drawn many times from other forms @@ -128,11 +129,19 @@ struct ImageState { path: BezPath, subpath_start: Option, pending_clip: Option, + app_crop: bool, } impl ImageState { fn new(matrix: Matrix, clip: [f64; 4], clips: Vec) -> Self { - Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), subpath_start: None, pending_clip: None } + Self { + placement: Placement { matrix, clip, clips }, + stack: Vec::new(), + path: BezPath::new(), + subpath_start: None, + pending_clip: None, + app_crop: false, + } } fn current_point(&self) -> Option { @@ -240,6 +249,7 @@ impl ImageState { self.path.close_path(); } } + b"MP" if op.name(0) == Some(b"PdfCraftImageCrop") => self.app_crop = true, b"W" => self.pending_clip = Some(false), b"W*" => self.pending_clip = Some(true), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { @@ -248,10 +258,11 @@ impl ImageState { // follows the same rule, and its bounds use cubic extrema, not controls. let bounds = self.path.bounding_box(); self.placement.clip = intersection(self.placement.clip, [bounds.x0, bounds.y0, bounds.x1, bounds.y1]).unwrap_or([0.0; 4]); - self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd }); + self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd, app_crop: self.app_crop }); } self.path = BezPath::new(); self.subpath_start = None; + self.app_crop = false; } _ => {} } @@ -835,6 +846,9 @@ fn compute_inherited_drawing_ops(source: &DrawingState, dest: &DrawingState, ext /// finite points with `m`, `l`, `c` and `h`, so `None` (a quadratic segment) does not happen. fn clip_path_to_ops(clip: &ClipPath) -> Option> { let mut ops = Vec::new(); + if clip.app_crop { + ops.push(crop_marker()); + } for el in clip.path.elements() { let (name, points) = match el { kurbo::PathEl::MoveTo(p) => ("m", vec![*p]), @@ -1257,7 +1271,9 @@ fn arrange_image( return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into())); } - let mut extracted = match extract_scope_state(&ops, scope_start, target, scope_start_ctm)? { + let owned_crop = ops.get(scope_start..target).unwrap_or_default().iter().any(|op| op.is("MP") && op.name(0) == Some(b"PdfCraftImageCrop")); + let extracted_scope = if owned_crop { None } else { extract_scope_state(&ops, scope_start, target, scope_start_ctm)? }; + let mut extracted = match extracted_scope { Some(ext) => ext, None if scope_start != target => { if !marked_unit || has_other_paint { @@ -1472,55 +1488,44 @@ fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result Result<(Vec, bool), EditError> { - let is_path = |o: &Op| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re"); - let mut drawing = Vec::new(); - let mut removed_clip = false; - let mut i = start.saturating_add(1); - while i < end { - let Some(op) = ops.get(i) else { break }; - if is_path(op) { - let mut k = i; - while ops.get(k).is_some_and(is_path) { - k = k.saturating_add(1); - } - if ops.get(k).is_some_and(|o| o.is("W") || o.is("W*")) && ops.get(k.saturating_add(1)).is_some_and(|o| o.is("n")) { - removed_clip = true; - i = k.saturating_add(2); - continue; - } - } - drawing.push(op.clone()); - i = i.saturating_add(1); - } - Ok((drawing, removed_clip)) +/// A marked-content point identifies only the clipping path written by this editor. +/// It does not open a marked-content sequence or change the document's structure tags. +fn crop_marker() -> Op { + Op::new("MP", vec![Object::Name(b"PdfCraftImageCrop".to_vec())]) } -/// Remove any private clipping path / crop on `img`, drawn on page `p` by its content streams `all`. +fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec> { + let mut ranges = Vec::new(); + for (index, op) in ops.iter().enumerate().take(target).skip(start) { + if !op.is("MP") || op.name(0) != Some(b"PdfCraftImageCrop") { + continue; + } + let path_start = index.saturating_add(1); + let mut end = path_start; + while end < target && ops.get(end).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { + end = end.saturating_add(1); + } + if end > path_start && ops.get(end).is_some_and(|o| o.is("W") || o.is("W*")) && ops.get(end.saturating_add(1)).is_some_and(|o| o.is("n")) { + ranges.push(index..end.saturating_add(2)); + } + } + ranges +} + +/// Remove only crops written by PdfCraft, preserving the document author's clipping paths. fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Object, Vec)], img: &PageImage) -> Result<(), EditError> { let pieces = joined(all); let ops = pieces.parse().ops; let target = img.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 Some((start, end, _)) = image_scopes(&ops, target, "cropped")?.isolated else { - return Err(EditError::Invalid("the image has no crop to remove".into())); + let scope = image_scopes(&ops, target, "cropped")?; + let Some((start, _, _)) = scope.transform_scope(target) else { + return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); }; - let (drawing_ops, removed_clip) = extract_image_drawing_ops(&ops, start, end)?; - if !removed_clip { - return Err(EditError::Invalid("the image has no crop to remove".into())); + let crops = app_crop_ranges(&ops, start, target); + if crops.is_empty() { + return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); } - let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; - let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; - let range = start_op.span.start..end_op.span.end; - let mut unit_ops = Vec::new(); - unit_ops.push(Op::new("q", vec![])); - unit_ops.extend(drawing_ops); - unit_ops.push(Op::new("Q", vec![])); - let serialized = serialize_ops(&unit_ops); - let changed = pieces.splice([(range, serialized)]); + let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index)))); let contents = crate::text::rewritten_contents(doc, all, changed); doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) @@ -1701,7 +1706,7 @@ fn crop_image_impl( && (corners[1].1 - corners[2].1).abs() < 1e-4 && (corners[2].0 - corners[3].0).abs() < 1e-4 && (corners[3].1 - corners[0].1).abs() < 1e-4); - let clip: Vec = if is_axis_aligned { + let mut clip: Vec = if is_axis_aligned { let min_x = corners.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min); let max_x = corners.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max); let min_y = corners.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min); @@ -1719,19 +1724,10 @@ fn crop_image_impl( .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) .collect() }; - // The image's own earlier crop (a clipping path right after the scope's `q`, in the CTM before - // the scope) gives way to the new one, so a crop can grow again as well as shrink. - let old_clip: Option> = (start != end) - .then(|| { - let mut k = start + 1; - while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { - k += 1; - } - (k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n"))) - .then_some(start + 1..k + 2) - }) - .flatten(); - let insert_at = old_clip.as_ref().map_or(start + 1, |r| r.start); + clip.insert(0, crop_marker()); + // Replace only our identified crop; an author's path must remain intact. + let old_crops = app_crop_ranges(&ops, start, target); + let insert_at = start.saturating_add(1); let changed = crate::text::splice(&pieces, &ops, |index| { if start == end && index == target { // No scope of its own yet: one around the drawing, with the crop inside. @@ -1742,8 +1738,8 @@ fn crop_image_impl( (out, false) } else if start != end && index == insert_at { // Inside the scope's `q`, before whatever it draws (in place of its old crop). - (clip.clone(), old_clip.is_none()) - } else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) { + (clip.clone(), !old_crops.iter().any(|r| r.contains(&index))) + } else if old_crops.iter().any(|r| r.contains(&index)) { (Vec::new(), false) } else { (Vec::new(), true) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 160681478d..de5e79224c 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3836,8 +3836,9 @@ fn image_arrange_stacking_order_and_remove_crop() { assert!(close(imgs_after_back[1].rect, [200.0, 200.0, 350.0, 350.0])); // Test remove crop - let cropped_content = "q 100 100 50 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q"; + let cropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; let mut doc = image_page_with(cropped_content); + crop(&mut doc, 0, 0, [100.0, 100.0, 150.0, 150.0]).unwrap(); let img_before = &images::page_images(&doc, 0).unwrap()[0]; assert_ne!(img_before.visible_rect, Some(img_before.rect)); remove_crop(&mut doc, 0, 0).unwrap(); @@ -4200,8 +4201,9 @@ fn image_arrange_preserves_exact_non_rectangular_clipping_path() { fn image_remove_crop_after_arrange_keeps_position_under_outer_transform() { // Arrange writes `cm inverse`, the clip, then the absolute placement; Remove Crop used to drop // only the first, which drew the image under the outer translation twice. - let content = "1 0 0 1 50 50 cm q 0 0 50 50 re W n 100 0 0 100 0 0 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let content = "1 0 0 1 50 50 cm q 100 0 0 100 0 0 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; let mut doc = image_page_with(content); + crop(&mut doc, 0, 0, [50.0, 50.0, 100.0, 100.0]).unwrap(); assert_eq!(images::page_images(&doc, 0).unwrap()[0].rect, [50.0, 50.0, 150.0, 150.0]); arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); @@ -9755,8 +9757,9 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { fn image_exclusive_scope_resets_path_on_scope_exit() { // An earlier scope has a path operation closed by Q (with 'h' closepath). // The subsequent image in its own scope must cleanly extract and allow crop removal. - let content = "q 0 0 m 10 10 l h Q q 100 100 200 100 re W n 200 0 0 100 100 100 cm /Im0 Do Q"; + let content = "q 0 0 m 10 10 l h Q q 200 0 0 100 100 100 cm /Im0 Do Q"; let mut doc = image_page_with(content); + crop(&mut doc, 0, 0, [100.0, 100.0, 250.0, 180.0]).unwrap(); let before = images::page_images(&doc, 0).unwrap(); assert_eq!(before.len(), 1); remove_crop(&mut doc, 0, 0).unwrap(); @@ -9851,11 +9854,13 @@ fn remove_crop_preserves_cm_before_extgstate() { let mut doc = image_page_with(content); let before = images::page_images(&doc, 0).unwrap(); assert_eq!(before.len(), 1); + crop(&mut doc, 0, 0, [100.0, 100.0, 110.0, 110.0]).unwrap(); remove_crop(&mut doc, 0, 0).unwrap(); let doc = reopen(&doc); let bytes = page_content_bytes(&doc, 0); let s = String::from_utf8_lossy(&bytes); - assert!(!s.contains("W n"), "clipping path must be removed: {s}"); + assert!(s.contains("W n"), "the author clipping path must remain: {s}"); + assert!(!s.contains("PdfCraftImageCrop"), "the editor crop must be removed: {s}"); // Verify that the initial cm appears before /GS0 gs let cm_pos = s.find("2 0 0 2 0 0 cm").expect("cm must be preserved"); let gs_pos = s.find("/GS0 gs").expect("gs must be preserved"); @@ -9939,16 +9944,37 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning() assert_eq!(page_content_bytes(&reopen(&doc), 0), before); } - #[test] fn image_arrange_refuses_crossed_scope_boundaries_without_changes() { - for content in [ - "/P <> BDC q 10 0 0 10 0 0 cm /Im0 Do EMC Q /Im0 Do", - "q /P <> BDC /Im0 Do Q EMC /Im0 Do", - ] { + for content in ["/P <> BDC q 10 0 0 10 0 0 cm /Im0 Do EMC Q /Im0 Do", "q /P <> BDC /Im0 Do Q EMC /Im0 Do"] { let mut doc = image_page_with(content); let before = page_content_bytes(&doc, 0); assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err(), "{content}"); assert_eq!(page_content_bytes(&doc, 0), before); } } + +#[test] +fn removing_a_crop_preserves_author_clips_and_works_under_outer_clip() { + for content in ["0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q", "q 50 50 8 8 re W n 10 0 0 10 50 50 cm /Im0 Do Q"] { + let mut doc = image_page_with(content); + let original = images::page_images(&doc, 0).unwrap()[0].clone(); + crop(&mut doc, 0, 0, [52.0, 52.0, 55.0, 55.0]).unwrap(); + let mut doc = reopen(&doc); + remove_crop(&mut doc, 0, 0).unwrap(); + let mut doc = reopen(&doc); + let restored = images::page_images(&doc, 0).unwrap()[0].clone(); + assert_eq!(restored.rect, original.rect); + assert_eq!(restored.visible_rect, original.visible_rect); + assert_eq!(restored.contains([59.0, 59.0]), original.contains([59.0, 59.0])); + assert!(remove_crop(&mut doc, 0, 0).is_err()); + } +} + +#[test] +fn remove_crop_refuses_an_unmarked_author_clip_without_changes() { + let mut doc = image_page_with("q 50 50 8 8 re W n 10 0 0 10 50 50 cm /Im0 Do Q"); + let before = page_content_bytes(&doc, 0); + assert!(remove_crop(&mut doc, 0, 0).is_err()); + assert_eq!(page_content_bytes(&doc, 0), before); +}