mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
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:
co-authored by
Claude
Marco Hagspiel
parent
7609d21488
commit
b6fedc09f0
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(_))));
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user