mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
Edit: crop an existing page image with its handles (#2002)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Osvaldo Duran
Brandon Thomas
parent
9ed12cb6a8
commit
6c22c78a72
+21
-21
@@ -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"
|
||||
|
||||
@@ -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<f64> =
|
||||
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,
|
||||
|
||||
@@ -1199,14 +1199,14 @@ 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), 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" },
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -372,6 +372,10 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, 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<u8>)],
|
||||
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::<Vec<_>>());
|
||||
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<Op> = 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<std::ops::Range<usize>> = (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> {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<Pos2>)>,
|
||||
/// Crop mode (Acrobat's crop handles): the box kept so far.
|
||||
pub crop: Option<CropMode>,
|
||||
}
|
||||
|
||||
/// 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));
|
||||
|
||||
@@ -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 أو تم
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 или Готово прилага
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -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 ή Τέλος για εφαρμογή
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 475 and column 9.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 または完了で適用
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 немесе Дайын қолданады
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 822 and column 9.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 401 and column 9.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 или «Готово» применяет
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 లేదా పూర్తయింది వర్తింపజేస్తుంది
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 821 and column 9.
|
@@ -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 або «Готово» застосовує
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 或“完成”应用
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 或「完成」套用
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user