From b8ddf71e30959dc8a5ca4b66c40b8fcaea68cf76 Mon Sep 17 00:00:00 2001 From: LloydNicholson <34131158+LloydNicholson@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:42:20 +0200 Subject: [PATCH] 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 Co-authored-by: Brandon Thomas --- crates/annot/src/lib.rs | 54 +++++++++++++++++++++++++++++-- crates/annot/src/tests.rs | 38 ++++++++++++++++++++++ crates/ui-egui/src/comments.rs | 11 +++++-- crates/ui-egui/tests/fill_sign.rs | 43 ++++++++++++++++++++++++ parity/acrobat-features.toml | 5 +-- 5 files changed, 144 insertions(+), 7 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 8a0253e7..dce287ad 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -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> = 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 = 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 = 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> = 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> { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 48eeb8c6..7f47b592 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -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 { 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(_)))); +} diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 9916c7bc..bce3d18d 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -533,10 +533,10 @@ fn is_markup(subtype: &str) -> bool { matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly") } -/// Rectangles, ovals, text boxes and stamps. A callout's `/Rect` also holds its leader line, so it -/// only moves. +/// Rectangles, ovals, text boxes, stamps and drawings (drawn signatures among them). A callout's +/// `/Rect` also holds its leader line, so it only moves. fn resizable(a: &Annotation) -> bool { - matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp") && a.intent.as_deref() != Some("FreeTextCallout") + matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText" | "Stamp" | "Ink") && a.intent.as_deref() != Some("FreeTextCallout") } const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)]; @@ -866,6 +866,11 @@ fn select_input( // On screen, the image is also turned by the view's rotation. let turned = !cx.xf.rot.is_multiple_of(180); let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio }); + // A drawn signature keeps its shape from the corners, as image signatures do. + let aspect_ratio = aspect_ratio.or_else(|| { + let r = cx.screen_rect(a); + (a.fill_sign && a.subtype == "Ink" && r.height() > 0.0).then(|| r.width() / r.height()) + }); cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o, aspect_ratio }); consumed = true; } else if let Some(a) = cx.hit(o) diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index a0385384..2b447df6 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -1151,6 +1151,49 @@ fn fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them() { assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check)); } +/// A drawn signature resizes from its handles like typed and image ones: the corners keep its +/// shape, the strokes scale with it, and the size survives undo, redo and saving. +#[test] +fn drawn_signatures_resize_from_their_corners() { + let mut h = harness(); + h.state_mut().signature = Some(SavedSig::Drawn(vec![vec![[0.05, 0.1], [0.3, 0.2], [0.55, 0.05], [0.95, 0.15]]])); + h.state_mut().execute("sign.fill.signature"); + click(&mut h, 40.0, 250.0); + let [placed] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) }; + assert_eq!(h.state().quick_tool, QuickTool::Select); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the placed signature is selected"); + + // The top-right corner handle, dragged out and a little up. + let corner = at(&h, placed[2], placed[3]); + h.hover_at(corner); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNeSw); + h.drag_at(corner); + h.run_steps(1); + for k in 1..=10 { + h.hover_at(corner + egui::vec2(6.0 * k as f32, -(k as f32))); + h.run_steps(1); + } + h.drop_at(corner + egui::vec2(60.0, -10.0)); + h.run_steps(4); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Resize comment")); + let [now] = rects(&h)[..] else { panic!("still one signature: {:?}", rects(&h)) }; + let ratio = |r: [f32; 4]| (r[2] - r[0]) / (r[3] - r[1]); + assert!(now[2] - now[0] > placed[2] - placed[0] + 20.0, "wider: {placed:?} -> {now:?}"); + assert!((ratio(now) - ratio(placed)).abs() < 0.02 * ratio(placed), "same shape: {placed:?} -> {now:?}"); + assert!((now[0] - placed[0]).abs() < 0.01 && (now[1] - placed[1]).abs() < 0.01, "the opposite corner stays put"); + + let id = h.state().views[0].id; + h.state_mut().session.undo(id).unwrap(); + assert_rect(rects(&h)[0], placed); + h.state_mut().session.redo(id).unwrap(); + assert_rect(rects(&h)[0], now); + let saved = h.state().session.save_bytes(id).unwrap(); + let h = harness_bytes(&saved); + assert_rect(rects(&h)[0], now); +} + /// A signature placed over a form field is picked up by the Select tool: the field under it /// doesn't take the press. Away from the signature, a click still fills the field. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6649dff8..0e01060e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3638,8 +3638,8 @@ milestone = "M5" status = "shipped" commands = ["comment.ink"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::ink_strokes_are_smooth_curves_through_their_points"] -notes = "one ink annotation per stroke; strokes are drawn as smooth curves through their points, as in Acrobat" +evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::ink_strokes_are_smooth_curves_through_their_points", "crates/annot/src/tests.rs::drawings_resize_by_scaling_their_strokes"] +notes = "one ink annotation per stroke; strokes are drawn as smooth curves through their points, as in Acrobat; resize with the 8 handles (the strokes scale into the new box)" [[feature]] id = "comment.eraser" @@ -5250,6 +5250,7 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"] tools = ["fill_sign_add", "comment_edit", "comment_image_preview"] +evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them"] evidence = ["crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them"] notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side"