mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 15:05:02 -06:00
fix(automation): flip images along displayed page axes
Map horizontal and vertical tool actions to PDF user-space axes after accounting for page rotation. The tool description states the displayed-coordinate semantics. A four-quadrant raster and transformed-corner regression covers both actions at 0, 90, 180 and 270 degrees, with undo between actions; it fails on the wrong axes before this fix. Scoped automation clippy, all 354 tests, wasm, formatting, layers and assets pass on GGEO-AI02. Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
This commit is contained in:
co-authored by
Codex GPT-6.1
parent
0b0d39c661
commit
73655948c6
@@ -449,8 +449,12 @@ impl Automation {
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
|
||||
"flip_vertical" => ImageEdit::Flip { horizontal: false },
|
||||
action @ ("flip_horizontal" | "flip_vertical") => {
|
||||
let horizontal = action == "flip_horizontal";
|
||||
let info = self.doc(&a)?.info.pages.get(page).ok_or_else(|| ToolError::InvalidArgs("the page does not exist".into()))?;
|
||||
// The tool names displayed axes; the engine names PDF user-space axes.
|
||||
ImageEdit::Flip { horizontal: horizontal ^ (info.rotation % 180 == 90) }
|
||||
}
|
||||
"delete" => ImageEdit::Delete,
|
||||
"bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
|
||||
"send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack),
|
||||
|
||||
@@ -1378,7 +1378,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
|
||||
@@ -10342,3 +10342,78 @@ fn editor_crop_group_move_and_arrange_survive_added_item_deletion_through_tools(
|
||||
let rendered = a.call("page_render", &json!({"doc":saved,"page":1,"dpi":72})).unwrap();
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png {data,width,height} if !data.is_empty() && *width > 0 && *height > 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_flip_tools_follow_displayed_axes_on_every_page_rotation() {
|
||||
fn corners(a: &Automation, id: u64) -> [[f64; 2]; 4] {
|
||||
let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap();
|
||||
let [aa, b, c, d, e, f] = doc.page_images(0)[0].matrix;
|
||||
[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]
|
||||
.map(|(x, y)| doc.info.pages[0].user_to_view((aa * x + c * y + e) as f32, (b * x + d * y + f) as f32).map(f64::from))
|
||||
}
|
||||
fn raster(a: &mut Automation, id: u64) -> (usize, usize, Vec<u8>) {
|
||||
let rendered = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||||
let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") };
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||||
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let frame = reader.next_frame(&mut bytes).unwrap();
|
||||
bytes.truncate(frame.buffer_size());
|
||||
if frame.color_type == png::ColorType::Rgb {
|
||||
bytes = bytes.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect();
|
||||
} else {
|
||||
assert_eq!(frame.color_type, png::ColorType::Rgba);
|
||||
}
|
||||
(frame.width as usize, frame.height as usize, bytes)
|
||||
}
|
||||
let dir = workdir("displayed-image-flip");
|
||||
let mut png_bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png_bytes, 8, 4);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
let colors = [[240, 20, 30], [20, 230, 40], [30, 40, 220], [220, 210, 20]];
|
||||
let pixels: Vec<u8> = (0..4).flat_map(|y| (0..8).flat_map(move |x| colors[usize::from(x >= 4) + 2 * usize::from(y >= 2)])).collect();
|
||||
encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("quadrants.png"), png_bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let mut failures = Vec::new();
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["quadrants.png"]}))["doc"].as_u64().unwrap();
|
||||
if rotation != 0 {
|
||||
ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":rotation}));
|
||||
}
|
||||
let before = corners(&a, doc);
|
||||
let (width, height, pixels) = raster(&mut a, doc);
|
||||
let xmin = before.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
|
||||
let xmax = before.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
|
||||
let ymin = before.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min);
|
||||
let ymax = before.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max);
|
||||
for (action, horizontal) in [("flip_horizontal", true), ("flip_vertical", false)] {
|
||||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":action}));
|
||||
let after = corners(&a, doc);
|
||||
for (old, new) in before.into_iter().zip(after) {
|
||||
let expected = if horizontal { [xmin + xmax - old[0], old[1]] } else { [old[0], ymin + ymax - old[1]] };
|
||||
if new.into_iter().zip(expected).any(|(actual, want)| (actual - want).abs() > 0.001) {
|
||||
failures.push(format!("rotation {rotation} {action}: corner {old:?} became {new:?}, expected {expected:?}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (new_width, new_height, actual_pixels) = raster(&mut a, doc);
|
||||
assert_eq!((new_width, new_height), (width, height));
|
||||
let mut expected_pixels = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let source = if horizontal { (y * width + width - 1 - x) * 4 } else { ((height - 1 - y) * width + x) * 4 };
|
||||
expected_pixels.extend_from_slice(&pixels[source..source + 4]);
|
||||
}
|
||||
}
|
||||
if actual_pixels.iter().zip(&expected_pixels).any(|(actual, want)| actual.abs_diff(*want) > 1) {
|
||||
failures.push(format!("rotation {rotation} {action}: raster flipped the wrong displayed axis"));
|
||||
}
|
||||
assert_ne!(actual_pixels, pixels, "the asymmetric image makes both flip axes observable");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(corners(&a, doc), before, "one undo restores the original placement");
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user