diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index d0b0b69fd..c860298d9 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -188,8 +188,10 @@ impl Automation { i.flip_v = !i.flip_v; } if let Some(c) = rect_arg(a, "crop")? { - if !c.iter().all(|v| (0.0..0.5).contains(v)) { - return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)")); + if !pdfcraft_engine::AddedImage::crop_valid(c) { + return Err(bad( + "crop takes fractions from 0 to under 1 (left, bottom, right, top); left and right together, and bottom and top together, trim at most 0.95", + )); } i.crop = c; } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6adce237c..4b4ac8873 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -627,7 +627,7 @@ pub fn tools() -> Vec { "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, - "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 }, + "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." }, "image": { "type": "string", "description": "Replace the picture with this file." }, }), &["doc", "page", "index"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 687dd37a4..54c3d6272 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4471,6 +4471,12 @@ fn adding_content_through_tools() { assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}"); // Image tools: rotate, flip, crop, replace. ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] })); + // One side may trim most of the picture; together an axis keeps at least 5 %. + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.7, 0, 0, 0] })); + assert!(matches!( + a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.6, 0, 0.4, 0] })), + Err(ToolError::InvalidArgs(_)) + )); ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" })); assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true); assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_)))); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 4157c57e4..d7dbcee75 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -149,7 +149,8 @@ pub struct AddedImage { pub rotation: u8, pub flip_h: bool, pub flip_v: bool, - /// The fraction trimmed from each side of the image: left, bottom, right, top. + /// The fraction trimmed from each side of the image: left, bottom, right, top (see + /// [`AddedImage::crop_valid`]). pub crop: [f64; 4], } @@ -158,12 +159,43 @@ impl AddedImage { AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] } } + /// The least of the image's width and of its height that a crop keeps. + pub const MIN_CROP_KEEP: f64 = 0.05; + + /// How far a sum of two sides may pass the most a crop trims: `0.05 + 0.9` is + /// `0.9500000000000001` in floating point and still keeps 5 %. + const CROP_ROUNDING: f64 = 1e-9; + + /// Whether `crop` trims every side by a fraction from 0 to under 1 and keeps at least + /// [`AddedImage::MIN_CROP_KEEP`] across each axis: one side may trim 95 % when its opposite + /// trims nothing. + pub fn crop_valid(crop: [f64; 4]) -> bool { + let [l, b, r, t] = crop; + let most = 1.0 - Self::MIN_CROP_KEEP; + crop.iter().all(|v| v.is_finite() && (0.0..1.0).contains(v)) && l + r <= most + Self::CROP_ROUNDING && b + t <= most + Self::CROP_ROUNDING + } + + /// `crop` brought into [`AddedImage::crop_valid`] range, for one read from a file: each side + /// in range, and an axis that trims too much keeps its two sides' proportion. + pub fn clamp_crop(crop: [f64; 4]) -> [f64; 4] { + let most = 1.0 - Self::MIN_CROP_KEEP; + let [l, b, r, t] = crop.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // Scaled from the halves, whose sum can't overflow, so the pair keeps its proportion; + // a pair within the limit keeps each side within it too. + let fit = |a: f64, c: f64| { + let half = a / 2.0 + c / 2.0; + if half > (most + Self::CROP_ROUNDING) / 2.0 { (a / 2.0 / half * most, c / 2.0 / half * most) } else { (a, c) } + }; + let ((l, r), (b, t)) = (fit(l, r), fit(b, t)); + [l, b, r, t] + } + /// The matrix from image space (the unit square) to display space: crop, flip, rotate, /// then fill the box. fn matrix(&self) -> pdfcraft_content::Matrix { use pdfcraft_content::Matrix as M; - let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45)); - let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05)); + let [l, b, r, t] = Self::clamp_crop(self.crop); + let (cw, ch) = (1.0 - l - r, 1.0 - b - t); let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]); if self.flip_h { m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0])); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 5e84cfc33..2ba817750 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -75,6 +75,31 @@ fn an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() { assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none()); } +#[test] +fn an_added_image_crop_keeps_some_of_each_axis() { + use crate::added::AddedImage; + assert!(AddedImage::crop_valid([0.7, 0.0, 0.0, 0.95])); + assert!(AddedImage::crop_valid([0.5, 0.45, 0.45, 0.5])); + // 0.05 + 0.9 rounds to just above 0.95: still the 5 % kept, accepted and left as it is. + assert!(AddedImage::crop_valid([0.05, 0.0, 0.9, 0.0])); + assert_eq!(AddedImage::clamp_crop([0.05, 0.0, 0.9, 0.0]), [0.05, 0.0, 0.9, 0.0]); + assert!(!AddedImage::crop_valid([0.06, 0.0, 0.9, 0.0]), "more than 95 % across"); + assert!(!AddedImage::crop_valid([0.6, 0.0, 0.4, 0.0]), "nothing left across"); + assert!(!AddedImage::crop_valid([-0.1, 0.0, 0.0, 0.0])); + assert!(!AddedImage::crop_valid([f64::NAN, 0.0, 0.0, 0.0])); + assert_eq!(AddedImage::clamp_crop([0.7, 0.0, 0.0, 0.0]), [0.7, 0.0, 0.0, 0.0], "a valid crop stays as it is"); + // From a file: too much across keeps the two sides' proportion; nonsense trims nothing. + let [l, b, r, t] = AddedImage::clamp_crop([0.6, f64::INFINITY, 0.6, -1.0]); + assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12 && b == 0.0 && t == 0.0); + // Sides beyond the whole image keep their proportion too, and huge ones don't overflow. + let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.5, 0.0]); + assert!((l - 0.7125).abs() < 1e-12 && (r - 0.2375).abs() < 1e-12, "{l} {r}"); + let [l, _, r, _] = AddedImage::clamp_crop([f64::MAX, 0.0, f64::MAX, 0.0]); + assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12, "{l} {r}"); + let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.0, 0.0]); + assert!((l - 0.95).abs() < 1e-12 && r == 0.0, "one side alone is capped at the limit"); +} + #[test] fn tokens_expand() { let c = cx(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ba6f2c1c7..a9b5318e1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3650,8 +3650,12 @@ fn added_images_rotate_flip_and_crop_as_drawn() { assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped"); img.flip_h = false; img.crop = [0.0, 0.0, 0.5, 0.0]; - s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box"); + // More than half from one side: only the blue right part is left. + img.crop = [0.7, 0.0, 0.0, 0.0]; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + assert!(blue(colour_at(&s, 53, 150)) && blue(colour_at(&s, 130, 150)), "70 % trimmed from the left: blue fills the box to its left edge"); } #[test] diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index ed4d0622f..87ec76e77 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -944,19 +944,28 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: } }); ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted)); - let mut crop = img.crop.map(|v| (v * 100.0).round()); + let most = (1.0 - pdfcraft_engine::AddedImage::MIN_CROP_KEEP) * 100.0; + // Unrounded: a side the user doesn't touch keeps its stored value, also one read from a file. + let shown = img.crop.map(|v| v * 100.0); + let mut crop = shown; let mut changed = false; ui.horizontal(|ui| { for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() { ui.label(label); - // Applied when the drag ends, so a drag is one undo step. - let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%")); + // Up to what the opposite side leaves. Applied when the drag ends: one undo step. + let room = (most - crop.get((k + 2) % 4).copied().unwrap_or(0.0)).max(0.0); + let Some(side) = crop.get_mut(k) else { continue }; + let r = ui.add(egui::DragValue::new(side).range(0.0..=room).clamp_existing_to_range(false).speed(0.5).suffix("%")); changed |= r.drag_stopped() || (r.changed() && !r.dragged()); } }); if changed { let mut i = img.clone(); - i.crop = crop.map(|v| v / 100.0); + for (stored, (now, was)) in i.crop.iter_mut().zip(crop.iter().zip(&shown)) { + if now != was { + *stored = now / 100.0; + } + } if i.crop != img.crop { out = Some(ImageAction::Update(AddedContent::Image(i))); }