M5.7: Fill & Sign tools pick up placed signatures to move and resize them (#822)

A signature placed with Fill & Sign could only be moved with the Select
tool. While a Fill & Sign tool was active (Sign, Initials, Add text,
Check, Date...), hovering the signature showed the placement crosshair,
a drag did nothing and a click stamped another mark on top of it.

Now, as in Acrobat, a Fill & Sign tool picks up the Fill & Sign marks
already on the page: hovering one shows the move cursor, a click selects
it, a drag moves it and the selected mark's handles resize it. Away from
them the tool places as before, including on other comments. Image
signatures keep their live move/resize preview.

Comments now carry a fill_sign flag (pdfcraft_annot::is_fill_sign for
the engine's list, the same rules in the viewer's inspector), so this
also works on reopened files.

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
LloydNicholson 2026-10-10 21:47:58 +02:00 committed by GitHub
parent c49148a290
commit 5ad2ecfef9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 164 additions and 6 deletions

View File

@ -1784,6 +1784,8 @@ pub struct Summary {
pub quads: Vec<[f32; 8]>,
pub locked: bool,
pub intent: Option<String>,
/// A Fill & Sign mark ([`is_fill_sign`]).
pub fill_sign: bool,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@ -1854,6 +1856,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
quads,
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
fill_sign: is_fill_sign(doc, d),
});
}
}

View File

@ -1772,6 +1772,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
quads: s.quads,
locked: s.locked,
intent: s.intent,
fill_sign: s.fill_sign,
})
.collect()
}

View File

@ -264,6 +264,9 @@ pub struct Annotation {
pub locked: bool,
/// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`…
pub intent: Option<String>,
/// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture
/// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`).
pub fill_sign: bool,
}
impl Annotation {
@ -553,6 +556,25 @@ impl<'a> Inspector<'a> {
self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned())
}
/// Whether an annotation is a Fill & Sign mark. Keep in step with
/// `pdfcraft_annot::is_fill_sign`, which the engine's comment list uses.
fn is_fill_sign(&self, d: &Dictionary, subtype: &str) -> bool {
let yes = |key: &[u8]| matches!(d.get(key).map(|o| self.resolve(o)), Ok(Object::Boolean(true)));
if yes(b"PCFillSign") {
return true;
}
match subtype {
"FreeText" => self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter"),
"Stamp" => match self.name(d, b"Name").as_deref() {
Some("PCCheck" | "PCCross" | "PCDot" | "PCLine" | "PCTypedSignature") => true,
Some("PCCustomSignature" | "PCCustomInitials") => yes(b"PCPictureImage"),
_ => false,
},
"Ink" => self.text(d, b"Subj").as_deref() == Some("Signature"),
_ => false,
}
}
fn page_of(&self, o: &Object) -> Option<usize> {
match o {
Object::Reference(id) => self.page_index.get(id).copied(),
@ -871,6 +893,7 @@ impl<'a> Inspector<'a> {
_ => Vec::new(),
};
let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM"));
let fill_sign = self.is_fill_sign(d, &subtype);
info.annotations.push(Annotation {
page,
subtype,
@ -886,6 +909,7 @@ impl<'a> Inspector<'a> {
quads,
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
intent: self.name(d, b"IT"),
fill_sign,
});
}
}

View File

@ -2179,8 +2179,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
stamp_placed = true;
}
}
// Over a mark already placed, the Fill & Sign tool picks it up (comments, below).
if let QuickTool::Fill(ft) = tool
&& allowed
&& !comments::fill_grabs(ui, &pcx, view)
{
match crate::fill_sign::page_input(
ui,
@ -2268,7 +2270,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view);
let consumed = on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view);
let preview_target = (tool == QuickTool::Select && !comments_hidden).then_some(view.comments.selected).flatten();
let preview_target =
(matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten();
view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale);
view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref());

View File

@ -576,6 +576,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
let page_rect = cx.xf.rect;
let pressed_here = origin.is_some_and(|o| page_rect.contains(o));
let over_page = pointer.is_some_and(|p| page_rect.contains(p));
let fill_grab = fill_grabs(ui, cx, view);
let cv = &mut view.comments;
if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
cv.gesture = None;
@ -768,10 +769,35 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
true
}
QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here),
QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here),
_ => false,
}
}
/// With a Fill & Sign tool, the Fill & Sign marks already on the page are picked up as with the
/// Select tool, as in Acrobat: hovering one shows the move cursor, a click selects it, a drag
/// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While
/// the button is down the press decides; otherwise the pointer (egui clears the press origin on
/// release, so a click is located by the pointer).
pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool {
if !matches!(cx.tool, QuickTool::Fill(_)) || cx.hidden {
return false;
}
let cv = &view.comments;
if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. }) if page == cx.page) {
return true;
}
let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down()));
let Some(p) = (if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p)) else { return false };
let on_handle = cv
.selected
.filter(|(page, _)| *page == cx.page)
.and_then(|(_, i)| cx.get(i))
.filter(|a| a.fill_sign && cx.allowed && resizable(a))
.is_some_and(|a| HANDLES.into_iter().any(|h| handle_pos(cx.screen_rect(a), h).distance(p) <= 7.0));
on_handle || cx.hit(p).is_some_and(|a| a.fill_sign)
}
fn clamp_to(r: Rect, p: Pos2) -> Pos2 {
pos2(p.x.clamp(r.left(), r.right()), p.y.clamp(r.top(), r.bottom()))
}
@ -917,9 +943,9 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut
pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
let cv = &view.comments;
let pointer = ui.input(|i| i.pointer.hover_pos());
if cx.tool == QuickTool::Select
&& cv.gesture.is_none()
if cv.gesture.is_none()
&& let Some(a) = pointer.and_then(|p| cx.hit(p))
&& (cx.tool == QuickTool::Select || matches!(cx.tool, QuickTool::Fill(_)) && a.fill_sign)
&& cv.selected != Some((cx.page, a.index))
{
for r in cx.screen_rects(a) {

View File

@ -124,7 +124,7 @@ impl SignatureDrag {
pub fn paint(&self, painter: &egui::Painter, cx: &PageCx<'_>, cv: &CommentView, pending: Option<&Edit>) {
let (Some((_, page, index)), Some(layers)) = (self.key, &self.layers) else { return };
if page != cx.page || cx.tool != QuickTool::Select || cx.hidden {
if page != cx.page || !matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_)) || cx.hidden {
return;
}
let dragging = matches!(cv.gesture, Some(Gesture::Move { page: p, index: i, .. } | Gesture::Resize { page: p, index: i, .. }) if (p, i) == (page, index));

View File

@ -1016,3 +1016,104 @@ fn drawn_and_typed_signatures_stay_upright_on_rotated_pages() {
assert!(differ * 10 < ink, "rotation {rotation}: {differ} of {ink} ink pixels differ from the unturned page");
}
}
/// Press, move in steps and release, from one user-space point to another.
fn drag(h: &mut Harness<'static, PdfCraftApp>, from: (f32, f32), to: (f32, f32)) {
let (a, b) = (at(h, from.0, from.1), at(h, to.0, to.1));
h.hover_at(a);
h.run_steps(1);
h.drag_at(a);
h.run_steps(1);
for k in 1..=5 {
h.hover_at(a + (b - a) * (k as f32 / 5.0));
h.run_steps(1);
}
h.drop_at(b);
h.run_steps(4);
}
fn rects(h: &Harness<'static, PdfCraftApp>) -> Vec<[f32; 4]> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.iter().map(|a| a.rect).collect()
}
/// A placed signature moves while a Fill & Sign tool is still active (as in Acrobat), instead of
/// the drag doing nothing and a click stamping another mark on top of it. Elsewhere on the page
/// the tool still places its mark.
#[test]
fn fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them() {
for tool in ["sign.fill.signature", "sign.fill.initials", "sign.fill.text", "sign.fill.check", "sign.fill.date"] {
let mut h = harness();
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
h.state_mut().initials = Some(SavedSig::Typed("AL".into()));
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!(h.state_mut().execute(tool));
h.run_steps(2);
let mid = ((placed[0] + placed[2]) / 2.0, (placed[1] + placed[3]) / 2.0);
h.hover_at(at(&h, mid.0, mid.1));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "{tool}: a placed mark can be picked up");
drag(&mut h, mid, (mid.0 + 40.0, mid.1 - 60.0));
assert!(matches!(h.state().quick_tool, QuickTool::Fill(_)), "{tool}: the tool stays");
let moved = [placed[0] + 40.0, placed[1] - 60.0, placed[2] + 40.0, placed[3] - 60.0];
let [now] = rects(&h)[..] else { panic!("{tool}: still one mark: {:?}", rects(&h)) };
assert_rect(now, moved);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert_eq!(doc.can_undo(), Some("Move comment"), "{tool}");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{tool}");
// The selected signature's handle resizes it.
let corner = at(&h, moved[2], moved[1]);
h.hover_at(corner);
h.run_steps(2);
h.drag_at(corner);
h.run_steps(1);
h.hover_at(corner + egui::vec2(30.0, 10.0));
h.run_steps(2);
h.drop_at(corner + egui::vec2(30.0, 10.0));
h.run_steps(3);
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Resize comment"), "{tool}");
assert_eq!(rects(&h).len(), 1, "{tool}");
// A click on it selects it rather than placing another mark on top.
h.state_mut().views[0].comments.selected = None;
let r = rects(&h)[0];
click(&mut h, (r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
assert_eq!(rects(&h).len(), 1, "{tool}: no mark stamped on the signature");
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "{tool}");
assert!(h.state().views[0].fill_text.is_none(), "{tool}: no text box opened on the signature");
}
// A drawn signature in a saved and reopened file (read by the viewer, not the editor).
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 saved = h.state().session.save_bytes(h.state().views[0].id).unwrap();
let mut h = harness_bytes(&saved);
let [placed] = rects(&h)[..] else { panic!("one signature: {:?}", rects(&h)) };
h.state_mut().execute("sign.fill.check");
let mid = ((placed[0] + placed[2]) / 2.0, (placed[1] + placed[3]) / 2.0);
drag(&mut h, mid, (mid.0 + 40.0, mid.1 - 60.0));
assert_rect(rects(&h)[0], [placed[0] + 40.0, placed[1] - 60.0, placed[2] + 40.0, placed[3] - 60.0]);
// Away from Fill & Sign marks, the tool still places (checks stay repeatable), also on other
// comments.
let mut h = harness();
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
h.state_mut().execute("sign.fill.signature");
click(&mut h, 40.0, 250.0);
let id = h.state().views[0].id;
let square = pdfcraft_engine::Shape::Rectangle { rect: [100.0, 80.0, 200.0, 140.0] };
let style = pdfcraft_engine::Style::default_for(&square);
let new = pdfcraft_engine::NewAnnotation { page: 0, shape: square, style, contents: String::new(), author: "Ada".into() };
h.state_mut().session.apply(id, pdfcraft_engine::Edit::AddAnnotation(new)).unwrap();
h.run_steps(2);
h.state_mut().execute("sign.fill.check");
click(&mut h, 60.0, 100.0);
click(&mut h, 150.0, 110.0);
assert_eq!(items(&h).len(), 4, "{:?}", items(&h));
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check));
}

View File

@ -5226,8 +5226,8 @@ 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_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"]
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; 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"
evidence = ["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"
[[feature]]
id = "form.fill-sign-flatten"