mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
Drawn signatures and drawings resize from their handles (#920)
A drawn Fill & Sign signature is an Ink annotation, and Ink couldn't be
resized: the Select tool showed no handles and the engine refused
ResizeAnnotation ("Ink comments can't be resized"). Typed and image
signatures (stamps) already resize (#335, #355), so drawn ones were the
gap left in #331.
- annot: set_rect scales an Ink annotation's strokes into the new
rectangle, keeping each side's margin between the strokes and /Rect
so the line isn't clipped, and regenerates the appearance. A straight
stroke with no extent on one axis is centred on it.
- ui-egui: Ink is resizable with the 8 handles. A drawn signature's
corners keep its shape, as image signatures' do; other drawings resize
freely like shapes.
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
Brandon Thomas
parent
10965fbf5e
commit
b8ddf71e30
+52
-2
@@ -1593,8 +1593,9 @@ pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, d
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resize a rectangle, oval, text box or stamp to `rect`. Stamps keep their appearance,
|
||||
/// which PDF viewers scale from its bounding box into the new rectangle.
|
||||
/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance,
|
||||
/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their
|
||||
/// strokes and get a new one.
|
||||
pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
@@ -1611,12 +1612,61 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
if subtype == "Ink" {
|
||||
return resize_ink(doc, r, normalize(rect), meta);
|
||||
}
|
||||
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
|
||||
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
|
||||
}
|
||||
apply_text_box(doc, r, rect, meta)
|
||||
}
|
||||
|
||||
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
|
||||
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
|
||||
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||||
let d = annot_dict(doc, r);
|
||||
let strokes: Vec<Vec<[f64; 2]>> = d
|
||||
.get(b"InkList")
|
||||
.map(|l| doc.resolve(l))
|
||||
.and_then(|l| l.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let v: Vec<f64> = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default();
|
||||
v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()
|
||||
})
|
||||
.collect();
|
||||
let Some(inner) = bounds(strokes.iter().flatten().copied()) else {
|
||||
return Err(AnnotError::Invalid("the drawing has no strokes".into()));
|
||||
};
|
||||
let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
let old: Vec<f64> = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect();
|
||||
let old = match old[..] {
|
||||
[a, b, c, e] => normalize([a, b, c, e]),
|
||||
_ => inner,
|
||||
};
|
||||
let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0));
|
||||
let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]];
|
||||
if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
|
||||
}
|
||||
// An axis with no extent (a straight stroke) is centred rather than scaled.
|
||||
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 };
|
||||
let scaled: Vec<Vec<[f64; 2]>> = strokes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect()
|
||||
})
|
||||
.collect();
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect()));
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
set_appearance(doc, r)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
|
||||
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn.
|
||||
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
|
||||
|
||||
@@ -1058,3 +1058,41 @@ fn non_markup_annotations_are_not_comments() {
|
||||
assert!(is_comment_subtype(s), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing (a drawn Fill & Sign signature is one) resizes by scaling its strokes into the new
|
||||
/// rectangle, keeping each side's stroke margin, and gets a fresh appearance.
|
||||
#[test]
|
||||
fn drawings_resize_by_scaling_their_strokes() {
|
||||
let mut doc = fixture();
|
||||
let strokes = vec![vec![[100.0, 400.0], [150.0, 460.0], [200.0, 410.0]], vec![[120.0, 420.0], [180.0, 430.0]]];
|
||||
let i = add_annotation(&mut doc, &new(0, Shape::Ink { strokes }), &meta("i")).unwrap();
|
||||
let nums = |o: &Object| -> Vec<f64> { o.as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() };
|
||||
let points = |doc: &Document| -> Vec<[f64; 2]> {
|
||||
let d = &list(doc, 0)[i];
|
||||
d.get(b"InkList").unwrap().as_array().unwrap().iter().flat_map(|s| nums(s).as_chunks::<2>().0.to_vec()).collect()
|
||||
};
|
||||
let before = points(&doc);
|
||||
let r0 = nums(list(&doc, 0)[i].get(b"Rect").unwrap());
|
||||
let lo = |k: usize| before.iter().map(|p| p[k]).fold(f64::MAX, f64::min);
|
||||
let hi = |k: usize| before.iter().map(|p| p[k]).fold(f64::MIN, f64::max);
|
||||
let margin = [lo(0) - r0[0], lo(1) - r0[1], r0[2] - hi(0), r0[3] - hi(1)];
|
||||
assert!(margin.iter().all(|m| *m > 0.0), "{margin:?}");
|
||||
// Twice the size, bottom-left corner fixed.
|
||||
let rect = [r0[0], r0[1], r0[0] + 2.0 * (r0[2] - r0[0]), r0[1] + 2.0 * (r0[3] - r0[1])];
|
||||
set_rect(&mut doc, 0, i, rect, &meta("r")).unwrap();
|
||||
let d = &list(&doc, 0)[i];
|
||||
let r1 = nums(d.get(b"Rect").unwrap());
|
||||
assert!(r1.iter().zip(rect).all(|(a, b)| (a - b).abs() < 1e-6), "{r1:?} != {rect:?}");
|
||||
// Each point keeps its place relative to the drawing's bounds, inside the same margin.
|
||||
let inner = |r: &[f64]| [r[0] + margin[0], r[1] + margin[1], r[2] - margin[2], r[3] - margin[3]];
|
||||
let (a, b) = (inner(&r0), inner(&r1));
|
||||
for (p, q) in before.iter().zip(points(&doc)) {
|
||||
let expected = [b[0] + (p[0] - a[0]) / (a[2] - a[0]) * (b[2] - b[0]), b[1] + (p[1] - a[1]) / (a[3] - a[1]) * (b[3] - b[1])];
|
||||
assert!((q[0] - expected[0]).abs() < 1e-6 && (q[1] - expected[1]).abs() < 1e-6, "{q:?} != {expected:?}");
|
||||
}
|
||||
let ap = ap_content(&doc, d);
|
||||
let first = points(&doc)[0];
|
||||
assert!(ap.contains(&format!("{} {} m", n(first[0]), n(first[1]))), "appearance redrawn at the new size: {ap}");
|
||||
// Too small to hold the stroke margin.
|
||||
assert!(matches!(set_rect(&mut doc, 0, i, [100.0, 400.0, 101.0, 401.0], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user