fix(engine): fold quarter-turns into AddedImage rotation and reject non-axis-aligned transforms

This commit is contained in:
Combiler01
2026-10-31 18:44:20 +05:30
parent e781d31187
commit b512e3627f
3 changed files with 91 additions and 11 deletions
+32 -8
View File
@@ -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) => {
+56
View File
@@ -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
+3 -3
View File
@@ -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;