From b512e3627f9a5788466dec31c06bc5bc3f3448c8 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 31 Oct 2026 18:44:20 +0530 Subject: [PATCH] fix(engine): fold quarter-turns into AddedImage rotation and reject non-axis-aligned transforms --- crates/engine/src/edit/run.rs | 40 ++++++++++++++---- crates/engine/src/tests.rs | 56 ++++++++++++++++++++++++++ crates/ui-egui/src/tools/content_ui.rs | 6 +-- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c536284530..056b28d766 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -328,9 +328,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi || (v[2] - img.rect[2]).abs() > 1e-4 || (v[3] - img.rect[3]).abs() > 1e-4 }); - let rotated_transform = matches!(change, ImageEdit::Transform(t) if t[1].abs() >= 1e-6 || t[2].abs() >= 1e-6); let should_promote = has_active_crop - || rotated_transform || matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete); if should_promote { @@ -382,13 +380,39 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi ] }; let t_display = mul(mul(view, *t), to_display); - if t_display[0] < 0.0 { - ai.flip_h = !ai.flip_h; + let is_parallel = t_display[1].abs() < 1e-5 && t_display[2].abs() < 1e-5; + let is_perpendicular = t_display[0].abs() < 1e-5 && t_display[3].abs() < 1e-5; + + if is_parallel { + if t_display[0] < 0.0 { + ai.flip_h = !ai.flip_h; + } + if t_display[3] < 0.0 { + ai.flip_v = !ai.flip_v; + } + ai.rect = bbox(t_display, ai.rect); + } else if is_perpendicular { + let b_pos = t_display[1] > 0.0; + let c_pos = t_display[2] > 0.0; + let (quarters_ccw, flip_h, flip_v) = match (b_pos, c_pos) { + (true, false) => (1, false, false), + (false, true) => (3, false, false), + (true, true) => (1, false, true), + (false, false) => (1, true, false), + }; + ai.rotation = (ai.rotation + quarters_ccw) % 4; + if flip_h { + ai.flip_h = !ai.flip_h; + } + if flip_v { + ai.flip_v = !ai.flip_v; + } + ai.rect = bbox(t_display, ai.rect); + } else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "non-axis-aligned transforms are not supported for added images".into(), + ))); } - if t_display[3] < 0.0 { - ai.flip_v = !ai.flip_v; - } - ai.rect = bbox(t_display, ai.rect); pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; } ImageEdit::Rotate(q) => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8a39afda7e..d5550437b0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9437,6 +9437,62 @@ fn mirroring_transform_on_added_image_flips_and_non_finite_is_refused() { assert!(res_move.is_err(), "NaN position must be refused"); } +#[test] +fn transform_on_added_image_folds_quarter_turns_and_rejects_non_axis_aligned() { + let make_png = |rgb: [u8; 3]| { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]; + enc.write_header().unwrap().write_image_data(&data).unwrap(); + } + Arc::new(png) + }; + let png1 = make_png([0, 128, 255]); + + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png1 }).unwrap(); + + // 1. A pure 90° clockwise rotation matrix about the image centre: + let rot_90_cw = pdfcraft_edit::turn_about_centre([100.0, 100.0, 200.0, 150.0], 1, false, false); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_cw) }).unwrap(); + + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let added = pdfcraft_edit::list_added(&cos); + assert_eq!(added.len(), 1, "stays as PCAdded image"); + let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") }; + assert_eq!(ai.rotation, 3, "quarter turn clockwise folds into ai.rotation = 3 (270° CCW)"); + // Centre is (150, 125); rotated width=50, height=100 -> [125, 75, 175, 175] + assert!((ai.rect[0] - 125.0).abs() < 1e-4); + assert!((ai.rect[1] - 75.0).abs() < 1e-4); + assert!((ai.rect[2] - 175.0).abs() < 1e-4); + assert!((ai.rect[3] - 175.0).abs() < 1e-4); + + // 2. A pure 90° counter-clockwise rotation matrix: + let rot_90_ccw = pdfcraft_edit::turn_about_centre(ai.rect, 3, false, false); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_ccw) }).unwrap(); + + let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") }; + assert_eq!(ai2.rotation, 0, "quarter turn counter-clockwise restores ai.rotation = 0"); + assert!((ai2.rect[0] - 100.0).abs() < 1e-4); + assert!((ai2.rect[1] - 100.0).abs() < 1e-4); + assert!((ai2.rect[2] - 200.0).abs() < 1e-4); + assert!((ai2.rect[3] - 150.0).abs() < 1e-4); + + // 3. Non-axis-aligned transforms (e.g. 45° rotation or skew) are rejected: + let rot_45 = pdfcraft_edit::turn_by_degrees([100.0, 100.0, 200.0, 150.0], 45.0).unwrap(); + let res_45 = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_45) }); + assert!(res_45.is_err(), "non-axis-aligned 45° rotation must be rejected for added image"); + assert!(res_45.unwrap_err().to_string().contains("non-axis-aligned")); + + let skew = [1.0, 0.5, 0.0, 1.0, 0.0, 0.0]; + let res_skew = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(skew) }); + assert!(res_skew.is_err(), "skew must be rejected for added image"); + assert!(res_skew.unwrap_err().to_string().contains("non-axis-aligned")); +} + /// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb /// field the file leaves to the viewer. The page as displayed and printed as an image shows the /// check box's caption symbol in its text colour, the button's grey face and the comb's cells in diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 3fdfab1b53..7e52502530 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -1046,9 +1046,9 @@ impl crate::PdfCraftApp { if modifiers.command { self.last_command_frame = Some((self.frame_count, inp.time)); } - let command_recent = self.last_command_frame.is_some_and(|(f, t)| { - self.frame_count.saturating_sub(f) <= 10 || (inp.time > 0.0 && (inp.time - t).abs() < 0.35) - }); + let command_recent = self + .last_command_frame + .is_some_and(|(f, t)| self.frame_count.saturating_sub(f) <= 10 || (inp.time > 0.0 && (inp.time - t).abs() < 0.35)); let command_active = modifiers.command || command_recent; if !*pressed && !self.v_pressed && command_active { released = true;