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:
parent
10965fbf5e
commit
b8ddf71e30
|
|
@ -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(_))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue