From 6c22c78a72f0cb0b549b09bb580ee9c2048aa707 Mon Sep 17 00:00:00 2001 From: regendorf-ux Date: Thu, 15 Oct 2026 02:37:56 -0400 Subject: [PATCH] Edit: crop an existing page image with its handles (#2002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Edit: crop an existing page image with its handles ImageChange::Crop(rect) keeps the part of a page image inside rect (user space) with a clipping path placed just inside the scope that holds the image (or a new q … Q around its Do); a clipping path already at the scope's start, the picture's earlier crop, is replaced, so a crop can grow back. The pixels are never scaled, and a later move or resize wraps the scope, so the crop moves with the picture. ImageEdit::Crop (undo label "Crop image"), image_edit action crop. In the window, right-click ▸ Crop Image puts the selected picture in crop mode: eight handles trim (the inside pans), the part that goes is dimmed, Enter or Done applies, Esc or Cancel leaves; Remove Crop shows the whole picture again. Rows in every language, their checksums in ATTRIBUTION.toml, parity edit.image-crop shipped. Co-Authored-By: Claude Opus 5.5 * Image crop: Bulgarian hint without a character the monospace UI font lacks JetBrains Mono has no 'ѝ', so primary_ui_fonts_cover_latin_and_cyrillic_catalogs failed; the hint is reworded ("with its handles" -> "with the handles"). Co-Authored-By: Claude Opus 5.5 * Refresh catalog hashes after merging main --------- Co-authored-by: Osvaldo Duran <338149330+regendorf-ux@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 42 +++--- crates/automation/src/lib.rs | 9 ++ crates/automation/src/tools.rs | 4 +- crates/automation/tests/automation.rs | 6 + crates/edit/src/images.rs | 87 ++++++++++++ crates/engine/src/lib.rs | 4 + crates/engine/src/tests.rs | 65 +++++++++ crates/ui-egui/src/edit_text_ui.rs | 189 +++++++++++++++++++++++++- crates/ui-egui/src/i18n/ar.tsv | 3 + crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/kk.tsv | 3 + crates/ui-egui/src/i18n/lt.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/te.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/tests/editing.rs | 63 +++++++++ parity/acrobat-features.toml | 8 +- 32 files changed, 513 insertions(+), 28 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7bb657250e..3f0515243e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "30b8a13b90b850f7d837d1a458df70fa08ae38b5aa0af9c197de620729089a88" +sha256 = "312db664c98a25a4ed6b098b4849a95da2a29efe8d1a9e039ff47a92d27ccd0e" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bc71a9ceefbeb481e331e218578d4d65d220731da09cafc3ca9e32da792cb4fb" +sha256 = "1928f3ee702ac4c5d24bd68d8ea3985920e3405347d46a5c8c636dad1a88f761" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6d8e89cd5c2d2fe209867eff4dbc670a00927f5ce2a05ebb7b56d66b858e6b48" +sha256 = "8b10d4e4bd7ce694afa0653d53148af767f5619dd50637b578e55a3c7e955361" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "12287718a0846e043253fbbbc3ac9d7a67ed838e66b1b55e47c343153b1bbc6d" +sha256 = "f953308d54011abee040dd192f48a923216dd2a572f1d14dd550dd6ac8ba86df" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6397dc77f168f6db633c09424a60f6b1042218fc2625a030143e25e8e193e44a" +sha256 = "e01a0ec8da6dffc4dbafe4cc411a7a1ed9cba76bc75b10435818aae04dda0e4f" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cad58817e722d974ff38ce3e57419e87d332d082c2ecad11b040cfee05f2a5de" +sha256 = "d1414ea5a02fd634b635231e4439b5f279dcc3473869bbd534bc9a70dad6dbd8" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a5662ab337d49184e709ab6f5fbc30398c937ec3d8fdb835efc8076b7ea182b6" +sha256 = "234b7850d3067361eb13ec85170f45e5c9708e702b091b529a4c336e9f7b6560" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e47428da655a1179d8809a0d96e1860ce12c0f05e179bc584c7dfb28a4c75f04" +sha256 = "0699859ac9c163d408bb6c8273f4d9d0be8e68f6338d2dd156d96fc5a2e411c8" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e5f95f946060eea5c5a85848cf465952278c782c6150c03517355da68150f867" +sha256 = "512ecfeb69f0ebc1caaa58ce30350c63ffa9a80e9222629b69c9d9df413ba499" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f42099446b10e95c7188c3a0551c96ce6c9c87f69f4a7cf93de65225ec16ba7c" +sha256 = "766362711c5980ba2fae81781f815621afb390db4c47fcfd8a3e77d5f1deb934" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dce2fb816235f9a271348d1d2c9ddcdeb6e7c5a91b4deb52b3cfbed358b75718" +sha256 = "ea9f02fc204f19e464b802bf32ba090dfac890283883a04eeb066b529cb4d3d1" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6358f605742f4b8c351392df9ad3b634f3ab508ea09e11ddf76d617e809dd4c5" +sha256 = "e42b97aad738f199698d38ed475aa3eada7ab1765526ef1e8250af3a863768dd" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7d4ab7edc9d0ddc9426a26a646293fba95c0bf5a0fd4717c127cd19cb2e2e584" +sha256 = "848cc35acaf475c6a532e20cfcefc3f9d4be7570764882bc1becc6ad1d144abb" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "16bffa630c2bd94a226cf3246e367b51db14efe5e576b5106ba4742c8cca2fa6" +sha256 = "9e65e6297aea35211aff2198700b5596a55d23c67ce8a6d83b7098f49e29b22a" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a4150b6b602849a54523c22faa552dcdf9a596afee55396fac4f32592f948487" +sha256 = "5c2e5a2b373e83506025ae2a065a74e5fc34639145e932d9abaf38be650a1b56" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8c4894897538c5f95d2e1c312e424b5fd0f427405512da604a64764d1120394e" +sha256 = "44ac275334de6ccd1ac14c45bd51378c9d86d7fe6060430c16c581cdcf09715c" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1dde51fbb63fdbacf0b9d091cd191ee63b1e4ee322729577b45bc791b5807915" +sha256 = "03638838a52bb75bf9bd17d5e050cffdef42c2f4f2eafd61e04953feaf663ff6" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "de5e9932aa69dcf8796ce90a21ca4496e0db884bb37755e5c7e9e23d4acd3e2e" +sha256 = "df1501a33b15029dac0740994a286809c5ec710195efb3f3c983bd363695cd3f" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3f6fb73c122ca2f52dabc6ae86d24e70afd97c7461f09ac20c66296863685c0" +sha256 = "ae494a56449a5e574621219c18d1c45a817a803ccff8a3e8decdbdfe4f5cca21" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "661a2f8da7a3406d2dbed23e77030921be3f8e57ff91a769ee70d2ffdc560f41" +sha256 = "e2f03a2c1832fabdc037a5b7d4d314c15d4d7b2b337aeec9b0e92b7584b4a49c" [[asset]] @@ -3363,4 +3363,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d6715789a67cfeeab549380ef49b7cb501b401b0ae6abb3f8f0ff182cae4e212" +sha256 = "291a76520d622c7b5ef141e6f0bcaeb3e319974a1390b23179cb14dbcf03a28c" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 12c5d094dd..769cff8131 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -468,6 +468,15 @@ impl Automation { 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]) } "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = + <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + 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 }, "delete" => ImageEdit::Delete, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 909fe47cf7..1bbce5d89f 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1199,14 +1199,14 @@ 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), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. 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), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. 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", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "path": { "type": "string" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 09a7c9ade1..fbf4b4bbae 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2326,6 +2326,12 @@ fn editing_page_images_through_tools() { assert_eq!(r["undo"], "Move image"); let moved = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); assert_eq!(moved, json!([5.0, 5.0, 25.0, 35.0])); + // Crop keeps part of it in place: the placement stays, visible_rect says what shows. + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "crop", "rect": [10, 10, 25, 30] })); + assert_eq!(r["undo"], "Crop image"); + let cropped = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0].clone(); + assert_eq!(cropped["rect"], json!([5.0, 5.0, 25.0, 35.0])); + assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" })); diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 83f6c108d0..fb62125880 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -372,6 +372,10 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi pub enum ImageChange { /// Apply a user-space transform after its placement (move, resize, rotate, flip). Transform([f64; 6]), + /// Show only this part of it (a box in user space): a clipping path around the drawing, + /// inside its own scope so a later move or resize carries it along. The pixels stay where + /// they are; nothing is scaled. + Crop([f64; 4]), /// Draw this image XObject instead, in the same place. Replace(ObjRef), Delete, @@ -457,6 +461,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag ImageChange::Transform(t) => { return transform_image(doc, &p, &all, &img, *t); } + ImageChange::Crop(keep) => { + return crop_image(doc, &p, &all, &img, *keep); + } ImageChange::Replace(r) => { if img.is_form { return Err(EditError::Invalid( @@ -652,6 +659,86 @@ fn transform_image( Ok(()) } +/// Crop: clip the image's drawing to `keep` (user space). The clip goes just inside the scope +/// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in +/// that scope's coordinates as the box's four corners, so a turned image crops to an upright +/// box too and a later transform of the scope moves the crop with the pixels. +fn crop_image( + doc: &mut Document, + page: &pdfcraft_model::Page, + streams: &[(Object, Vec)], + image: &PageImage, + keep: [f64; 4], +) -> Result<(), EditError> { + if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into())); + } + let [x0, y0, x1, y1] = [keep[0].min(keep[2]), keep[1].min(keep[3]), keep[0].max(keep[2]), keep[1].max(keep[3])]; + if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 { + return Err(EditError::Invalid("the crop box has no area".into())); + } + let mut offset = 0_usize; + for (_, data) in streams.iter().take(image.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; + let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y)); + if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) { + return Err(EditError::Invalid("the crop box is too large for the image's placement".into())); + } + let clip: Vec = corners + .iter() + .enumerate() + .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) + .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) + .collect(); + // The image's own earlier crop (a clipping path right after the scope's `q`) gives way to + // the new one, so a crop can grow again as well as shrink. + let old_clip: Option> = (start != end) + .then(|| { + let mut k = start + 1; + while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { + k += 1; + } + (k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n"))) + .then_some(start + 1..k + 2) + }) + .flatten(); + let changed = crate::text::splice(&pieces, &ops, |index| { + if start == end && index == target { + // No scope of its own yet: one around the drawing, with the crop inside. + let mut out = vec![Op::new("q", vec![])]; + out.extend(clip.iter().cloned()); + out.extend(ops.get(index).cloned()); + out.push(Op::new("Q", vec![])); + (out, false) + } else if start != end && index == start + 1 { + // Inside the scope's `q`, before whatever it draws (in place of its old crop). + (clip.clone(), old_clip.is_none()) + } else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) { + (Vec::new(), false) + } else { + (Vec::new(), true) + } + }); + let contents = crate::text::rewritten_contents(doc, streams, changed); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// Plan all transforms before changing a stream; indexes refer to the original inventory. /// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries. pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4141064c96..e3ec7c457d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -78,6 +78,8 @@ pub enum ImageEdit { Flip { horizontal: bool, }, + /// Show only this box of it (user space), the pixels staying where they are. + Crop([f64; 4]), /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. Replace { name: String, @@ -1872,6 +1874,7 @@ impl Edit { Edit::EditPageImage { change, .. } => Label::of(match change { ImageEdit::Move(_) => "Move image", ImageEdit::Rotate(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", ImageEdit::Flip { .. } => "Flip image", ImageEdit::Replace { .. } => "Replace image", ImageEdit::Delete => "Delete image", @@ -2381,6 +2384,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 07a84dad4e..884dc237d8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5074,3 +5074,68 @@ fn exporting_a_jpeg_logo_to_word_keeps_the_image() { assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})"); } } + +/// Cropping an existing page image (Acrobat's crop handles on a picture) shows only the chosen +/// box, the pixels staying where they are: `visible_rect` says what shows, the full placement is +/// unchanged, and a later move carries the crop along. +#[test] +fn cropping_an_existing_image_clips_it_and_moves_with_it() { + let mut s = Session::new(); + let mut px = image::RgbaImage::new(40, 40); + for (x, y, p) in px.enumerate_pixels_mut() { + *p = if x < 20 { + image::Rgba([255, 0, 0, 255]) + } else if y < 20 { + image::Rgba([0, 255, 0, 255]) + } else { + image::Rgba([0, 0, 255, 255]) + }; + } + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(px).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let pic = s.create_from_images(&[("p.png".into(), png.into_inner())]).unwrap(); + let id = s.open("p.pdf", None, pic, None).unwrap(); + let full = s.get(id).unwrap().page_images(0)[0].rect; + let (w, h) = (full[2] - full[0], full[3] - full[1]); + // Keep the right half, upper part. + let keep = [full[0] + w / 2.0, full[1] + h / 2.0, full[2], full[3]]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(keep) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Crop image")); + let im = &d.page_images(0)[0]; + assert_eq!(im.rect, full, "the placement is not scaled"); + let v = im.visible_rect.expect("clipped"); + assert!(v.iter().zip(keep.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {keep:?}"); + // What shows: the kept quarter is green, the cropped-away quarters are paper. + let (pw, ph, rgba) = { + let mut ex = crate::export::Exporter::new(d); + let png = ex.png(0, 72.0).unwrap(); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + (img.width(), img.height(), img) + }; + let at = |fx: f64, fy: f64| rgba.get_pixel((pw as f64 * fx) as u32, (ph as f64 * fy) as u32).0; + let page_h = d.info.pages[0].height as f64; + let page_w = d.info.pages[0].width as f64; + // Fractions of the page from the top-left: the kept box is the top-right quarter of the image. + let fx = |x: f64| x / page_w; + let fy = |y: f64| 1.0 - y / page_h; + assert_eq!(at(fx(full[0] + 0.75 * w), fy(full[1] + 0.75 * h))[1], 255, "kept green"); + assert_eq!(at(fx(full[0] + 0.25 * w), fy(full[1] + 0.25 * h)), [255, 255, 255, 255], "cropped-away red is gone"); + // A move takes the crop along (down and left, so the kept part stays on the page). + let to = [full[0] - 10.0, full[1] - 10.0, full[2] - 10.0, full[3] - 10.0]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(to) }).unwrap(); + let im = &s.get(id).unwrap().page_images(0)[0]; + let v = im.visible_rect.expect("still clipped"); + let moved_keep = [keep[0] - 10.0, keep[1] - 10.0, keep[2] - 10.0, keep[3] - 10.0]; + assert!(v.iter().zip(moved_keep.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {moved_keep:?}"); + // Cropping again replaces the earlier crop, so the picture can grow back (Remove crop). + let full_now = s.get(id).unwrap().page_images(0)[0].rect; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(full_now) }).unwrap(); + let im = &s.get(id).unwrap().page_images(0)[0]; + let v = im.visible_rect.expect("visible"); + let page = [0.0, 0.0, page_w, page_h]; + let shown = [full_now[0].max(page[0]), full_now[1].max(page[1]), full_now[2].min(page[2]), full_now[3].min(page[3])]; + assert!(v.iter().zip(shown.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {shown:?}"); + // No area, or a box off the image, is refused. + assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); +} diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 91fbfeb3ae..620ba2b30b 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -321,6 +321,175 @@ pub struct ImageSelection { pub index: usize, /// Dragging: the start point and, for a corner, the opposite corner (screen). drag: Option<(Pos2, Option)>, + /// Crop mode (Acrobat's crop handles): the box kept so far. + pub crop: Option, +} + +/// Cropping a page image: the handles trim instead of resizing, the part that will go is +/// dimmed, Enter or Done applies and Esc or Cancel leaves it. +#[derive(Clone, Debug, PartialEq)] +pub struct CropMode { + /// The box to keep (user space). + pub keep: [f64; 4], + /// A drag in progress: the grip (0–3 corners, 4–7 edges, 8 the inside), where it started + /// and the box then. + drag: Option<(usize, Pos2, [f64; 4])>, +} + +impl CropMode { + pub fn new(keep: [f64; 4]) -> Self { + Self { keep, drag: None } + } +} + +/// The screen boxes of the crop's eight handles: corners, then the top, right, bottom and left +/// edge midpoints. +fn crop_handles(k: Rect) -> [Pos2; 8] { + [k.left_top(), k.right_top(), k.left_bottom(), k.right_bottom(), k.center_top(), k.right_center(), k.center_bottom(), k.left_center()] +} + +/// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` +/// (the pointer and keys are the crop's while it lasts). +#[allow(clippy::too_many_arguments)] +fn crop_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + view: &mut DocView, + i: usize, + b: Rect, + allow_interaction: bool, +) -> bool { + let painter = ui.painter(); + let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; + let hover_pos = ui.input(|inp| inp.pointer.hover_pos()); + let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); + // A drag in progress moves a grip; the box stays inside the picture and at least 6 px. + if allow_interaction + && resp.drag_started() + && let Some(o) = ui.input(|inp| inp.pointer.press_origin()) + { + let k = keep_screen(mode.keep); + let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8)); + if let Some(g) = grip { + mode.drag = Some((g, o, mode.keep)); + } + } + if let Some((grip, start, at_start)) = mode.drag + && let Some(p) = hover_pos + { + let d = p - start; + let k0 = keep_screen(at_start); + let (mut min, mut max) = (k0.min, k0.max); + match grip { + 0 => { + min.x += d.x; + min.y += d.y; + } + 1 => { + max.x += d.x; + min.y += d.y; + } + 2 => { + min.x += d.x; + max.y += d.y; + } + 3 => { + max.x += d.x; + max.y += d.y; + } + 4 => min.y += d.y, + 5 => max.x += d.x, + 6 => max.y += d.y, + 7 => min.x += d.x, + _ => { + let shift = egui::vec2(d.x.clamp(b.min.x - k0.min.x, b.max.x - k0.max.x), d.y.clamp(b.min.y - k0.min.y, b.max.y - k0.max.y)); + min += shift; + max += shift; + } + } + let (min_w, min_h) = (6.0_f32.min(b.width()), 6.0_f32.min(b.height())); + min.x = min.x.clamp(b.min.x, b.max.x - min_w); + min.y = min.y.clamp(b.min.y, b.max.y - min_h); + max.x = max.x.clamp(min.x + min_w, b.max.x); + max.y = max.y.clamp(min.y + min_h, b.max.y); + mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max)); + if resp.drag_stopped() || !ui.input(|inp| inp.pointer.primary_down()) { + mode.drag = None; + } + } + let k = keep_screen(mode.keep); + // What goes is dimmed; what stays keeps its frame and handles. + let dim = Color32::from_black_alpha(96); + for r in [ + Rect::from_min_max(b.min, Pos2::new(b.max.x, k.min.y)), + Rect::from_min_max(Pos2::new(b.min.x, k.max.y), b.max), + Rect::from_min_max(Pos2::new(b.min.x, k.min.y), Pos2::new(k.min.x, k.max.y)), + Rect::from_min_max(Pos2::new(k.max.x, k.min.y), Pos2::new(b.max.x, k.max.y)), + ] { + if r.is_positive() { + painter.rect_filled(r.intersect(xf.rect), CornerRadius::ZERO, dim); + } + } + painter.rect_stroke(k, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + for h in crop_handles(k) { + painter.rect( + Rect::from_center_size(h, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + // Done and Cancel, painted under the box (above it near the bottom of the view). + let font = egui::FontId::proportional(12.5); + let pill = |label: &str, right: f32, y: f32| -> Rect { + let w = painter.layout_no_wrap(label.to_string(), font.clone(), Color32::WHITE).size().x + 20.0; + Rect::from_min_size(Pos2::new(right - w, y), egui::vec2(w, 22.0)) + }; + let y = if k.max.y + 30.0 < xf.rect.max.y { k.max.y + 6.0 } else { k.min.y - 28.0 }; + let done = pill(tl!("Done"), k.max.x, y); + let cancel = pill(tl!("Cancel"), done.min.x - 6.0, y); + let t = crate::theme::Tokens::get(ui.ctx()); + painter.rect_filled(done, CornerRadius::same(11), ACCENT); + painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE); + painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text); + if let Some(p) = hover_pos { + let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) { + match g { + 0 | 3 => egui::CursorIcon::ResizeNwSe, + 1 | 2 => egui::CursorIcon::ResizeNeSw, + 4 | 6 => egui::CursorIcon::ResizeVertical, + _ => egui::CursorIcon::ResizeHorizontal, + } + } else if done.contains(p) || cancel.contains(p) { + egui::CursorIcon::PointingHand + } else if k.contains(p) { + egui::CursorIcon::Move + } else { + egui::CursorIcon::Default + }; + ui.ctx().set_cursor_icon(icon); + } + let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)); + let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); + if clicked(done) || keys(egui::Key::Enter) { + let keep = mode.keep; + if let Some(s) = view.image_selection.as_mut() { + s.crop = None; + } + if keep_screen(keep) != b { + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) }); + } + } else if (clicked(cancel) || keys(egui::Key::Escape)) + && let Some(s) = view.image_selection.as_mut() + { + s.crop = None; + } + true } impl ImageSelection { @@ -470,6 +639,9 @@ pub(crate) fn image_input( // The selected image: frame, corner handles, dragging. if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; + if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { + return crop_input(ui, resp, xf, page, info, view, i, b, allow_interaction); + } let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; let handles = |rect: Rect| { for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { @@ -601,7 +773,7 @@ pub(crate) fn image_input( _ => egui::CursorIcon::PointingHand, }); if resp.clicked() || resp.secondary_clicked() { - view.image_selection = Some(ImageSelection { page, index: hit, drag: None }); + view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None }); } if selected == Some(hit) { resp.context_menu(|ui| { @@ -618,6 +790,21 @@ pub(crate) fn image_input( ui.close(); } } + // Crop: the handles trim instead of resizing until Done. + if ui.button(tl!("Crop Image")).clicked() { + if let (Some(s), Some(im)) = (view.image_selection.as_mut(), images.get(hit)) { + s.crop = Some(CropMode::new(im.visible_rect.unwrap_or(im.rect))); + } + ui.close(); + } + let cropped = + images.get(hit).is_some_and(|im| im.visible_rect.is_some_and(|v| v.iter().zip(im.rect.iter()).any(|(a, b)| (a - b).abs() > 0.01))); + if ui.add_enabled(cropped, egui::Button::new(tl!("Remove Crop"))).clicked() { + if let Some(im) = images.get(hit) { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Crop(im.rect) }); + } + ui.close(); + } let raster = images.get(hit).is_some_and(|image| !image.is_form); if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() { *action = Some(ImageAction::Replace(page, hit)); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 1b271e4d8a..ccfcb5b568 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2412,3 +2412,6 @@ animation effects Off معطّلة Print as image الطباعة كصورة Resolution: الدقة: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. استخدمه عندما تتجاهل الطابعة النص أو التظليل أو الشفافية. لا تعود المطبوعة نصًا قابلًا للبحث؛ ومع الطباعة بتدرج الرمادي تكون الصور رمادية. + Crop Image قص الصورة + Remove Crop إزالة القص + Trim the picture with its handles; Enter or Done applies قص الصورة بمقابضها؛ يطبّق Enter أو تم diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d4911e343f..523e3a6ace 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2354,3 +2354,6 @@ animation effects Off Изключени Print as image Печат като изображение Resolution: Разделителна способност: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Използвайте го, когато принтерът пропуска текст, сенки или прозрачност. Отпечатаното вече не е текст за търсене; с печат в сиво изображенията са сиви. + Crop Image Изрязване на изображението + Remove Crop Премахване на изрязването + Trim the picture with its handles; Enter or Done applies Изрежете картината чрез дръжките; Enter или Готово прилага diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index edad18660a..33bd18b3be 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2465,3 +2465,6 @@ animation effects Off Vypnuto Print as image Tisknout jako obrázek Resolution: Rozlišení: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Použijte, když tiskárna vynechává text, stínování nebo průhlednost. Výtisk už není prohledávatelný text; s tiskem v odstínech šedi jsou obrázky šedé. + Crop Image Oříznout obrázek + Remove Crop Odebrat oříznutí + Trim the picture with its handles; Enter or Done applies Ořízněte obrázek úchyty; Enter nebo Hotovo použije diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4729053d9a..bed9fdb06e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2392,3 +2392,6 @@ animation effects Off Aus Print as image Als Bild drucken Resolution: Auflösung: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Verwenden, wenn ein Drucker Text, Schattierungen oder Transparenz auslässt. Der Ausdruck ist kein durchsuchbarer Text mehr; mit Graustufendruck sind die Bilder grau. + Crop Image Bild zuschneiden + Remove Crop Zuschnitt entfernen + Trim the picture with its handles; Enter or Done applies Bild mit den Griffen zuschneiden; Eingabe oder Fertig übernimmt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7bcd1fc90b..a8baf42699 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2401,3 +2401,6 @@ animation effects Off Ανενεργά Print as image Εκτύπωση ως εικόνα Resolution: Ανάλυση: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Χρησιμοποιήστε το όταν ένας εκτυπωτής παραλείπει κείμενο, σκίαση ή διαφάνεια. Η εκτύπωση δεν είναι πλέον κείμενο με δυνατότητα αναζήτησης· με εκτύπωση σε αποχρώσεις του γκρι, οι εικόνες είναι γκρι. + Crop Image Περικοπή εικόνας + Remove Crop Κατάργηση περικοπής + Trim the picture with its handles; Enter or Done applies Περικόψτε την εικόνα με τις λαβές της· Enter ή Τέλος για εφαρμογή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8467a00138..f3bd6af011 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2521,3 +2521,6 @@ animation effects Off Desactivados Print as image Imprimir como imagen Resolution: Resolución: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Úsalo cuando una impresora omite texto, sombreados o transparencias. La impresión deja de ser texto que se puede buscar; con Imprimir en escala de grises, las imágenes son grises. + Crop Image Recortar imagen + Remove Crop Quitar recorte + Trim the picture with its handles; Enter or Done applies Recorta la imagen con sus tiradores; Intro o Hecho lo aplica diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e4a767323..efcb048120 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2391,3 +2391,6 @@ animation effects Off Désactivés Print as image Imprimer comme image Resolution: Résolution : Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. À utiliser quand une imprimante saute du texte, des dégradés ou de la transparence. L'impression n'est plus du texte consultable ; avec l'impression en niveaux de gris, les images sont grises. + Crop Image Recadrer l'image + Remove Crop Supprimer le recadrage + Trim the picture with its handles; Enter or Done applies Recadrez l'image avec ses poignées ; Entrée ou Terminé applique diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1312608c52..f0f62078cb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2390,3 +2390,6 @@ animation effects Off Ki Print as image Nyomtatás képként Resolution: Felbontás: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Akkor használja, ha a nyomtató kihagy szöveget, árnyékolást vagy átlátszóságot. A nyomat már nem kereshető szöveg; szürkeárnyalatos nyomtatással a képek szürkék. + Crop Image Kép körülvágása + Remove Crop Körülvágás eltávolítása + Trim the picture with its handles; Enter or Done applies Vágja körül a képet a fogantyúival; Enter vagy Kész alkalmazza diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9f7b92a94d..46f67d6b9e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2400,3 +2400,6 @@ animation effects Off Disattivati Print as image Stampa come immagine Resolution: Risoluzione: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Usalo quando una stampante salta testo, sfumature o trasparenze. La stampa non è più testo ricercabile; con Stampa in scala di grigi le immagini sono grigie. + Crop Image Ritaglia immagine + Remove Crop Rimuovi ritaglio + Trim the picture with its handles; Enter or Done applies Ritaglia l'immagine con le maniglie; Invio o Fatto applica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 386d024a3d..b4ad39bf8e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2391,3 +2391,6 @@ animation effects Off オフ Print as image 画像として印刷 Resolution: 解像度: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. プリンターが文字、グラデーション、透明部分を印刷しないときに使います。印刷物は検索できる文字ではなくなります。グレースケール印刷と併用すると画像はグレーになります。 + Crop Image 画像を切り抜き + Remove Crop 切り抜きを解除 + Trim the picture with its handles; Enter or Done applies ハンドルで画像を切り抜きます。Enter または完了で適用 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index c546b8320f..0058907f4c 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2389,3 +2389,6 @@ animation effects Off Өшірулі Print as image Сурет ретінде басып шығару Resolution: Ажыратымдылық: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Принтер мәтінді, көлеңкені немесе мөлдірлікті өткізіп жіберсе, қолданыңыз. Басылған құжат енді іздеуге болатын мәтін емес; сұр реңкпен басып шығарғанда суреттер сұр болады. + Crop Image Суретті қию + Remove Crop Қиюды алып тастау + Trim the picture with its handles; Enter or Done applies Суретті тұтқаларымен қиыңыз; Enter немесе Дайын қолданады diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index df454fbed6..5b1d5e482b 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2635,3 +2635,6 @@ key Space Tarpas Print as image Spausdinti kaip vaizdą Resolution: Raiška: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Naudokite, kai spausdintuvas praleidžia tekstą, šešėliavimą ar skaidrumą. Atspaudas nebėra ieškomas tekstas; spausdinant pilkais atspalviais vaizdai yra pilki. + Crop Image Apkarpyti vaizdą + Remove Crop Pašalinti apkarpymą + Trim the picture with its handles; Enter or Done applies Apkarpykite paveikslėlį rankenėlėmis; Enter arba Atlikta pritaiko diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index bc929c2020..204b61a64e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2371,3 +2371,6 @@ animation effects Off Wyłączone Print as image Drukuj jako obraz Resolution: Rozdzielczość: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Użyj, gdy drukarka pomija tekst, cieniowanie lub przezroczystość. Wydruk nie jest już tekstem do przeszukiwania; przy druku w skali szarości obrazy są szare. + Crop Image Przytnij obraz + Remove Crop Usuń przycięcie + Trim the picture with its handles; Enter or Done applies Przytnij obraz uchwytami; Enter lub Gotowe stosuje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 242e4ecc9f..82c06d301c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2405,3 +2405,6 @@ animation effects Off Desativados Print as image Imprimir como imagem Resolution: Resolução: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Use quando uma impressora omite texto, sombreamento ou transparência. A impressão deixa de ser texto pesquisável; com Imprimir em tons de cinza, as imagens ficam cinza. + Crop Image Cortar imagem + Remove Crop Remover corte + Trim the picture with its handles; Enter or Done applies Corte a imagem com as alças; Enter ou Concluído aplica diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d910616356..7aaddf0e60 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2304,3 +2304,6 @@ animation effects Off Выкл Print as image Печать как изображение Resolution: Разрешение: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Используйте, когда принтер пропускает текст, заливки или прозрачность. Отпечаток больше не текст с поиском; при печати в оттенках серого изображения серые. + Crop Image Обрезать изображение + Remove Crop Убрать обрезку + Trim the picture with its handles; Enter or Done applies Обрежьте изображение за маркеры; Enter или «Готово» применяет diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index d787292c04..820186e6ae 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2396,3 +2396,6 @@ animation effects Off Izklopljeni Print as image Natisni kot sliko Resolution: Ločljivost: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Uporabite, ko tiskalnik izpusti besedilo, senčenje ali prosojnost. Natis ni več iskalno besedilo; s tiskanjem v sivinah so slike sive. + Crop Image Obreži sliko + Remove Crop Odstrani obrezovanje + Trim the picture with its handles; Enter or Done applies Obrežite sliko z ročicami; Enter ali Končano uporabi diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 92c43cdbf8..f8fc6f4a75 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2304,3 +2304,6 @@ animation effects Off ఆఫ్ Print as image చిత్రంగా ముద్రించు Resolution: రిజల్యూషన్: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. ప్రింటర్ వచనం, షేడింగ్ లేదా పారదర్శకతను వదిలేసినప్పుడు దీన్ని వాడండి. ముద్రణ ఇక శోధించగల వచనం కాదు; బూడిద రంగులో ముద్రించినప్పుడు చిత్రాలు బూడిద రంగులో ఉంటాయి. + Crop Image చిత్రాన్ని కత్తిరించు + Remove Crop కత్తిరింపును తీసివేయి + Trim the picture with its handles; Enter or Done applies హ్యాండిల్‌లతో చిత్రాన్ని కత్తిరించండి; Enter లేదా పూర్తయింది వర్తింపజేస్తుంది diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c353976dee..9d03c43713 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2387,3 +2387,6 @@ animation effects Off Kapalı Print as image Resim olarak yazdır Resolution: Çözünürlük: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Yazıcı metni, gölgelendirmeyi veya saydamlığı atladığında kullanın. Çıktı artık aranabilir metin değildir; gri tonlamalı yazdırmayla resimler gri olur. + Crop Image Resmi kırp + Remove Crop Kırpmayı kaldır + Trim the picture with its handles; Enter or Done applies Resmi tutamaçlarıyla kırpın; Enter veya Bitti uygular diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fc47b826a1..7ae401ef26 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2413,3 +2413,6 @@ animation effects Off Вимкнено Print as image Друкувати як зображення Resolution: Роздільність: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Використовуйте, коли принтер пропускає текст, заливки або прозорість. Відбиток більше не текст із пошуком; під час друку у відтінках сірого зображення сірі. + Crop Image Обрізати зображення + Remove Crop Прибрати обрізання + Trim the picture with its handles; Enter or Done applies Обріжте зображення за маркери; Enter або «Готово» застосовує diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0be259299e..f710c1fe48 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2355,3 +2355,6 @@ animation effects Off 关 Print as image 作为图像打印 Resolution: 分辨率: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. 当打印机漏印文字、渐变或透明效果时使用。打印结果不再是可搜索的文字;配合灰度打印时图像为灰色。 + Crop Image 裁剪图像 + Remove Crop 移除裁剪 + Trim the picture with its handles; Enter or Done applies 用控制柄裁剪图片;按 Enter 或“完成”应用 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 56145d3648..607b1bbd7e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2346,3 +2346,6 @@ animation effects Off 關 Print as image 以影像列印 Resolution: 解析度: Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. 當印表機漏印文字、漸層或透明效果時使用。列印結果不再是可搜尋的文字;搭配灰階列印時影像為灰色。 + Crop Image 裁切影像 + Remove Crop 移除裁切 + Trim the picture with its handles; Enter or Done applies 用控點裁切圖片;按 Enter 或「完成」套用 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d45f609e55..d8ded6f6f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -73,6 +73,7 @@ mod bidi; pub mod bulk_fields; mod dialogs; mod edit_text_ui; +pub use edit_text_ui::CropMode; mod editing; #[cfg(not(target_arch = "wasm32"))] pub mod file_watch; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 78448dfb97..386738aebe 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4361,3 +4361,66 @@ fn moving_text_offers_the_four_way_arrow() { let frame = screen((moved.rect[0] + moved.rect[2]) / 2.0, moved.rect[3]) - egui::vec2(0.0, 4.0); assert_eq!(cursor_at(&mut h, frame), egui::CursorIcon::Move); } + +/// Cropping a picture on the page (Acrobat's crop handles): in crop mode the +/// handles trim, Enter applies what is kept, Esc leaves the picture as it was. +#[test] +fn cropping_a_picture_with_its_handles() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some(), "selected"); + // Crop mode (as the context menu's Crop Image starts it): drag the right-bottom handle a + // quarter of the picture inward, then Enter. + h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(before)); + h.run_steps(2); + let corner = r.right_bottom(); + let to = corner - egui::vec2(r.width() / 4.0, r.height() / 4.0); + h.hover_at(corner); + h.run_steps(1); + h.drag_at(corner); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(corner + (to - corner) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(2); + let keep = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep; + assert!(keep[2] < before[2] - 1.0 && keep[1] > before[1] + 1.0, "trimmed on the right and the bottom: {keep:?} of {before:?}"); + h.key_press(egui::Key::Enter); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Crop image")); + let im = &doc.page_images(0)[0]; + assert_eq!(im.rect, [0.0, 0.0, 80.0, 40.0], "the placement is not scaled"); + let v = im.visible_rect.unwrap(); + assert!((v[2] - 60.0).abs() < 1.5 && (v[1] - 10.0).abs() < 1.5, "kept {v:?}"); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_none()), "crop mode ends"); + // Esc leaves it. + h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new([10.0, 10.0, 20.0, 20.0])); + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.page_images(0)[0].visible_rect.unwrap(), v); + assert!(h.state().views[0].image_selection.as_ref().is_none_or(|s| s.crop.is_none())); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cefacacd8a..2df880e85f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2091,11 +2091,11 @@ title = "Crop an image" area = "C" tier = "P0" milestone = "M7" -status = "partial" +status = "shipped" commands = ["edit.image"] -tools = ["content_update"] -evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] -notes = "for added images: trim each side, clipped" +tools = ["content_update", "image_edit"] +evidence = ["crates/ui-egui/tests/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] +notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed." [[feature]] id = "edit.image-rotate-flip"