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:
regendorf-ux
2026-10-15 02:37:56 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Osvaldo Duran Brandon Thomas
parent 9ed12cb6a8
commit 6c22c78a72
32 changed files with 513 additions and 28 deletions
+21 -21
View File
@@ -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"
+9
View File
@@ -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,
+2 -2
View File
@@ -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" },
+6
View File
@@ -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" }));
+87
View File
@@ -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> {
+4
View File
@@ -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)?;
+65
View File
@@ -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());
}
+188 -1
View File
@@ -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));
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+1
View File
@@ -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;
+63
View File
@@ -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()));
}
+4 -4
View File
@@ -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"