M7.5: crop an added image by more than half from one side (#4521)

* M7.5: crop an added image by more than half from one side

Each side of an added image's crop was capped at 45 %, so keeping its right
third (70 % off the left) wasn't possible, and the drawing silently cut a
larger crop down. The limit is now per axis: a side may trim up to 95 % as
long as left and right, and bottom and top, keep at least 5 % together.
AddedImage::crop_valid checks input (content_update refuses the rest with
the rule), clamp_crop brings a crop read from a file into range keeping the
sides' proportion, and the Edit panel lets a side go up to what the opposite
leaves.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Marco Hagspiel <m.hagspiel@lcs-group.com>

* M7.5: accept crops that sum to the limit; keep untouched crop sides exact

`0.05 + 0.9` is just above `0.95` in floating point, so `crop_valid` rejected a crop that
keeps 5 %. Sums now pass within a rounding tolerance, and `clamp_crop` leaves such a crop as
it is. The image panel no longer rounds the stored crop to whole percent or clamps sides the
user doesn't touch: only an edited side is written back.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Marco Hagspiel <m.hagspiel@lcs-group.com>

* M7.5: an excessive crop from a file keeps its proportion

`clamp_crop` capped each side before fitting the pair, so `[1.5, 0, 0.5, 0]` lost its 3:1
proportion. The pair is now scaled first, from its halves so two huge sides can't overflow;
a pair within the limit keeps each side within it.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Marco Hagspiel <m.hagspiel@lcs-group.com>

---------

Signed-off-by: Marco Hagspiel <m.hagspiel@lcs-group.com>
Co-authored-by: Marco Hagspiel <m.hagspiel@lcs-group.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Marco Hagspiel
2026-10-25 10:01:48 -04:00
committed by GitHub
co-authored by Claude Marco Hagspiel
parent 7609d21488
commit b6fedc09f0
7 changed files with 89 additions and 11 deletions
+4 -2
View File
@@ -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;
}
+1 -1
View File
@@ -627,7 +627,7 @@ pub fn tools() -> Vec<ToolDef> {
"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"],
+6
View File
@@ -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(_))));
+35 -3
View File
@@ -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]));
+25
View File
@@ -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();
+5 -1
View File
@@ -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]
+13 -4
View File
@@ -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)));
}