mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 17:24:42 +00:00
feat(automation): image_edit transform action
A resize of a turned image sends ImageEdit::Transform, which had no headless path (AGENTS 3). image_edit now takes action transform with matrix: six numbers in top-left-origin points, like the tool's other geometry, applied after the image's placement. The tool converts it to user space through the page's view mapping, and refuses a matrix that is not finite, is flat (a·d − b·c = 0) or moves the image out of range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac4f26a00d
commit
b5ddf2cc80
4 files changed
+86
-4
No files matched your search
@@ -12,6 +12,35 @@ pub enum Content {
|
||||
Png { data: Vec<u8>, width: u32, height: u32 },
|
||||
}
|
||||
|
||||
/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point
|
||||
/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is
|
||||
/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by
|
||||
/// an affine map, so three corners of the crop box pin the result down.
|
||||
fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> {
|
||||
let [a, b, c, d, e, f] = m;
|
||||
let det = a * d - b * c;
|
||||
if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 {
|
||||
return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into()));
|
||||
}
|
||||
let moved = |x: f32, y: f32| {
|
||||
let [vx, vy] = info.user_to_view(x, y).map(f64::from);
|
||||
info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from)
|
||||
};
|
||||
let [x0, y0, x1, y1] = info.crop;
|
||||
let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0));
|
||||
if w.abs() < 1e-3 || h.abs() < 1e-3 {
|
||||
return Err(ToolError::InvalidArgs("the page has no area to transform in".into()));
|
||||
}
|
||||
let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1));
|
||||
let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h);
|
||||
let (x0, y0) = (f64::from(x0), f64::from(y0));
|
||||
let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0];
|
||||
if !user.iter().all(|v| v.is_finite()) {
|
||||
return Err(ToolError::InvalidArgs("the transform moves the image too far".into()));
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Run the tool `name` with JSON `args` (an object; `null` means no arguments).
|
||||
pub fn call(&mut self, name: &str, args: &Value) -> Result<Vec<Content>> {
|
||||
@@ -395,6 +424,13 @@ 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::Move([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])
|
||||
}
|
||||
"transform" => {
|
||||
let m: Vec<f64> =
|
||||
a.get("matrix").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let m = <[f64; 6]>::try_from(m)
|
||||
.map_err(|_| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
|
||||
ImageEdit::Transform(view_transform_to_user(&self.doc(&a)?.info.pages[page], m)?)
|
||||
}
|
||||
"rotate_by" => ImageEdit::RotateBy(
|
||||
a.get("degrees")
|
||||
.and_then(Value::as_f64)
|
||||
|
||||
@@ -1378,17 +1378,18 @@ 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 (restore the original uncropped visible rectangle), 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), 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 (restore the original uncropped visible rectangle), 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.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"image": { "type": "integer", "minimum": 1 },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"quarters": { "type": "integer" },
|
||||
"degrees": { "type": "number" },
|
||||
"matrix": { "type": "array", "items": { "type": "number" }, "minItems": 6, "maxItems": 6 },
|
||||
"path": { "type": "string" },
|
||||
}),
|
||||
&["doc", "page", "image", "action"],
|
||||
|
||||
@@ -3500,6 +3500,51 @@ fn arranging_page_images_through_tools() {
|
||||
assert_eq!(after_reopen["images"][1]["rect"], json!([50.0, 50.0, 100.0, 100.0]));
|
||||
}
|
||||
|
||||
/// The transform a rotated-image resize sends (`ImageEdit::Transform`) is reachable headlessly,
|
||||
/// in the same top-left-origin points as the other image tools, also on a turned page.
|
||||
#[test]
|
||||
fn transforming_page_images_through_tools() {
|
||||
let dir = workdir("transform-page-images");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1] }));
|
||||
let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
|
||||
let rect = |a: &mut Automation| ok(a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
|
||||
let transform = |a: &mut Automation, m: Value| ok(a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": m }));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "move", "rect": [5, 5, 25, 35] }));
|
||||
|
||||
// Right 10, down 20.
|
||||
assert_eq!(transform(&mut a, json!([1, 0, 0, 1, 10, 20]))["undo"], "Transform image");
|
||||
assert_eq!(rect(&mut a), json!([15.0, 25.0, 35.0, 55.0]));
|
||||
// Twice the size about the top-left corner of the page.
|
||||
transform(&mut a, json!([2, 0, 0, 2, 0, 0]));
|
||||
assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0]));
|
||||
// A quarter turn, (x, y) → (200 − y, x): the box turns with it, and it undoes in one step.
|
||||
transform(&mut a, json!([0, 1, -1, 0, 200, 0]));
|
||||
assert_eq!(rect(&mut a), json!([90.0, 30.0, 150.0, 70.0]));
|
||||
ok(&mut a, "edit_undo", json!({ "doc": made }));
|
||||
assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0]));
|
||||
|
||||
// On a turned page "right" is still right on screen.
|
||||
ok(&mut a, "page_rotate", json!({ "doc": made, "pages": [1], "degrees": 90 }));
|
||||
let numbers = |v: Value| [0, 1, 2, 3].map(|i| v[i].as_f64().unwrap());
|
||||
let [x0, y0, x1, y1] = numbers(rect(&mut a));
|
||||
transform(&mut a, json!([1, 0, 0, 1, 10, 0]));
|
||||
let after = numbers(rect(&mut a));
|
||||
for (got, want) in after.into_iter().zip([x0 + 10.0, y0, x1 + 10.0, y1]) {
|
||||
assert!((got - want).abs() < 0.015, "{after:?} is not ({x0}, {y0}, {x1}, {y1}) moved right by 10");
|
||||
}
|
||||
|
||||
// Flat, non-finite, short and missing matrices are refused before anything changes.
|
||||
let before = rect(&mut a);
|
||||
for bad in [json!([1, 2, 2, 4, 0, 0]), json!([0, 0, 0, 0, 0, 0]), json!([1e300, 0, 0, 1e300, 0, 0]), json!([1, 0, 0, 1])] {
|
||||
let r = a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": bad }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{bad}: {r:?}");
|
||||
}
|
||||
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(rect(&mut a), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enhance_camera_image_through_tools() {
|
||||
let dir = workdir("enhance-camera");
|
||||
|
||||
@@ -2162,8 +2162,8 @@ milestone = "M7"
|
||||
status = "shipped"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["image_edit", "image_hit"]
|
||||
evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip"]
|
||||
notes = "existing raster images and grouped Form artwork: select, drag to move, 8-point handles to resize (corners keep proportions, edges scale along axis, Shift inverts aspect lock); Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md"
|
||||
evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip", "crates/automation/tests/it/automation.rs::transforming_page_images_through_tools"]
|
||||
notes = "existing raster images and grouped Form artwork: select, drag to move, 8-point handles to resize (corners keep proportions, edges scale along axis, Shift inverts aspect lock); Delete removes one placement. Headless: image_edit action move (a new box) or transform (any finite, non-flat 6-number matrix in top-left-origin points, applied after the placement: the edit a resize of a turned image makes). Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-extract"
|
||||
|
||||
Reference in new issue
Block a user