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:
LloydNicholson
2026-10-10 17:42:20 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 10965fbf5e
commit b8ddf71e30
5 changed files with 144 additions and 7 deletions
+52 -2
View File
@@ -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> {
+38
View File
@@ -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(_))));
}