diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 0f1fdc17ef..e8fc8d66fb 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -12,6 +12,35 @@ pub enum Content { Png { data: Vec, 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> { @@ -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 = + 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) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 99dd0ec8db..f144beedc6 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1378,17 +1378,18 @@ pub fn tools() -> Vec { .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"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 7e8a75e12c..9a08a36ea2 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 23afb12e83..b36cba3677 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"