Fix #299: typed and drawn signatures stay upright on rotated pages (#562)

On a page with /Rotate 90, 180 or 270, Fill & Sign placed typed and drawn
signatures and initials sideways or upside down: their box and their
strokes/outlines were laid out along user-space axes, which the page turns,
while the click (or fill_sign_add's "at") is a displayed point. Only image
signatures were counter-rotated (#485).

Both now use the same displayed axes as image signatures
(pdfcraft_model::view_matrix_for without its offset): the box's left edge and
vertical centre are at the displayed point, a drawn signature's ink points are
mapped through the displayed right/up vectors, and a typed signature's
normalised outlines are turned back inside a user-space box laid out along the
turned page (the shared signature_image::upright_box, now also used by
SignatureImage::rect). The appearance is plain geometry, so every viewer shows
it upright without relying on a form /Matrix. Unturned pages are unchanged
(the mapping is the identity there).

typed_signature_shape and fill_sign::{signature_at, typed_signature_at} take
the page rotation; fill_sign::place and fill_sign_add pass it.

Tests: a kittest-file test places a drawn, typed signature and typed initials
on /Rotate 0/90/180/270 pages through fill_sign::place, renders them and checks
each displayed ink box is wider than tall, anchored at the point, identical to
the unturned page, and that an asymmetric drawn stroke is not mirrored; an
automation test does the same for fill_sign_add typed signatures and initials,
before and after save/reopen.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 11:54:07 -04:00 committed by GitHub
parent 28804d55d7
commit 5ddf33ccf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 194 additions and 28 deletions

View File

@ -406,12 +406,12 @@ impl Automation {
let t = format!("{m}/{d}/{yy}");
(text_at(&t), t)
}
// A typed signature or initials in the script font, left edge at `at`.
// A typed signature or initials in the script font, left edge at `at`, upright as displayed.
kind @ ("signature" | "initials") => {
let t = a.str("text")?;
let h = if kind == "initials" { 24.0 } else { 32.0 };
let shape =
pdfcraft_engine::typed_signature_shape(at, t, h).ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
let shape = pdfcraft_engine::typed_signature_shape(at, t, h, i64::from(info.rotation))
.ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
(shape, String::new())
}
kind => {

View File

@ -2132,6 +2132,62 @@ fn image_signatures_stay_upright_on_rotated_pages() {
check(&mut a, reopened);
}
/// Issue #299: a typed signature and initials read across, as displayed, on every `/Rotate`,
/// anchored at the displayed point, and look the same as on an unturned page.
#[test]
fn typed_signatures_stay_upright_on_rotated_pages() {
let dir = workdir("typed-upright");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
}
for page in 1..=4 {
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [30, 60], "text": "Ada Lovelace" }));
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "initials", "at": [30, 140], "text": "AL" }));
}
let check = |a: &mut Automation, doc: u64| {
let mut upright = Vec::new();
for page in 1..=4 {
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
let Content::Png { data, .. } = &output[0] else { panic!() };
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
assert_eq!(pixels.dimensions(), if page % 2 == 1 { (200, 300) } else { (300, 200) }, "page {page}");
let dark: Vec<bool> = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect();
for y in [60, 140] {
let mut b = [u32::MAX, u32::MAX, 0, 0];
for (x, py, p) in pixels.enumerate_pixels() {
if py.abs_diff(y) < 40 && p.0[..3].iter().all(|v| *v < 128) {
b = [b[0].min(x), b[1].min(py), b[2].max(x), b[3].max(py)];
}
}
assert!(b[0] <= b[2], "page {page}: no ink near y = {y}");
assert!(b[2] - b[0] > b[3] - b[1], "page {page}: the name reads across: {b:?}");
assert!(b[0].abs_diff(30) <= 2 && b[1] < y && b[3] > y, "page {page}: anchored at (30, {y}): {b:?}");
}
if page == 1 {
upright = dark;
continue;
}
// Same pixels as the unturned page where both pages are (pages 2 and 4 are wider).
let width = pixels.width();
let (mut differ, mut ink) = (0, 0);
for y in 0..200 {
for x in 0..200 {
let (want, got) = (upright[(y * 200 + x) as usize], dark[(y * width + x) as usize]);
ink += usize::from(want);
differ += usize::from(want != got);
}
}
assert!(differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page");
}
};
check(&mut a, doc);
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
check(&mut a, reopened);
}
#[test]
fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
let dir = workdir("comment-polish");

View File

@ -69,17 +69,25 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_outline};
/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user
/// space, vertically centred) and `height` points tall. `None` for text with no outlines.
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64) -> Option<Shape> {
///
/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`):
/// the box runs along the displayed axes, like an image signature's
/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across.
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option<Shape> {
let o = script_outline(text);
let [left, bottom, right, top] = o.bounds();
let span = (top - bottom).max(0.1);
let width = right - left;
if o.contours.is_empty() || o.width <= 0.0 {
if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) {
return None;
}
let k = height / span;
let rect = [at[0], at[1] - height / 2.0, at[0] + width * k, at[1] + height / 2.0];
let contours = o.contours.iter().map(|c| c.iter().map(|p| [(p[0] - left) / width, (p[1] - bottom) / span]).collect()).collect();
let rect = signature_image::upright_box(rotation, at, width * k, height)?;
// Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box
// is at this fraction of the user-space box. Exact for an unturned page.
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)];
let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect();
Some(Shape::TypedSignature { rect, contours })
}
/// Comment geometry helpers (text-box line breaking) for frontends.

View File

@ -68,6 +68,25 @@ impl Document {
}
}
/// The user-space box `width` x `height` points as displayed on a page turned by `rotation` (its
/// `/Rotate`), with its left edge at `at` (a user-space point) and vertically centred on it. On a
/// quarter-turned page it is `height` wide and `width` tall in user space. `None` for a non-finite
/// point.
pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> {
if !at.iter().chain(&[width, height]).all(|v| v.is_finite()) {
return None;
}
// Right and up as displayed, as user-space vectors: the page's view matrix without its offset.
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]);
Some([
at[0] + right[0].min(0.0) - up[0].abs(),
at[1] + right[1].min(0.0) - up[1].abs(),
at[0] + right[0].max(0.0) + up[0].abs(),
at[1] + right[1].max(0.0) + up[1].abs(),
])
}
/// Saved signatures live in settings, so both source and normalized PNG are bounded.
pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20;
const MAX_PIXELS: u64 = 4 << 20;
@ -154,16 +173,7 @@ impl SignatureImage {
}
let [w, h] = self.size;
let scale = (150.0 / w as f64).min(if initials { 24.0 } else { 32.0 } / h as f64);
let (width, height) = (w as f64 * scale, h as f64 * scale);
// Right and up as displayed, as user-space vectors: the page's view matrix without its offset.
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(i64::from(page.rotation), [0.0; 4]);
let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]);
Some([
at[0] + right[0].min(0.0) - up[0].abs(),
at[1] + right[1].min(0.0) - up[1].abs(),
at[0] + right[0].max(0.0) + up[0].abs(),
at[1] + right[1].max(0.0) + up[1].abs(),
])
upright_box(i64::from(page.rotation), at, w as f64 * scale, h as f64 * scale)
}
pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option<Edit> {

View File

@ -190,38 +190,50 @@ pub fn typed(page: usize, at: [f64; 2], text: &str, author: &str) -> Edit {
}
/// Place a saved signature (strokes normalised to a 0–1 box, y up) with its left edge at `at`,
/// 150 pt wide.
pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], author: &str) -> Option<Edit> {
/// 150 pt wide. Left, centred and upright are as displayed on a page turned by `rotation` (its
/// `/Rotate`), so the strokes are turned back into user space.
pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], rotation: i64, author: &str) -> Option<Edit> {
let w = 150.0;
let (min_y, max_y) = strokes.iter().flatten().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[1]), b.max(p[1])));
if !min_y.is_finite() {
return None;
}
let h = f64::from(max_y - min_y).max(0.05) * w;
// Displayed right and up as user-space unit vectors (the identity on an unturned page).
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
let strokes: Vec<Vec<[f64; 2]>> = strokes
.iter()
.filter(|s| !s.is_empty())
.map(|s| s.iter().map(|p| [at[0] + f64::from(p[0]) * w, at[1] - h / 2.0 + f64::from(p[1] - min_y) * w]).collect())
.map(|s| {
s.iter()
.map(|p| {
let (dx, dy) = (f64::from(p[0]) * w, f64::from(p[1] - min_y) * w - h / 2.0);
[at[0] + a * dx + c * dy, at[1] + b * dx + d * dy]
})
.collect()
})
.collect();
(!strokes.is_empty()).then(|| new(page, Shape::Signature { strokes }, String::new(), author))
}
/// Place typed text in the script font with its left edge at `at`, `height` points tall.
pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, author: &str) -> Option<Edit> {
pdfcraft_engine::typed_signature_shape(at, text, height).map(|shape| new(page, shape, String::new(), author))
/// Place typed text in the script font with its left edge at `at`, `height` points tall, upright
/// as displayed on a page turned by `rotation`.
pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, rotation: i64, author: &str) -> Option<Edit> {
pdfcraft_engine::typed_signature_shape(at, text, height, rotation).map(|shape| new(page, shape, String::new(), author))
}
/// Place a saved signature or initials.
/// Place a saved signature or initials, upright as `info`'s page is displayed.
pub fn place(page: usize, info: &PageInfo, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
let rotation = i64::from(info.rotation);
match sig {
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author),
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, rotation, author),
SavedSig::Image(image) => image.edit(page, info, at, initials, author),
SavedSig::Typed(text) => {
let [left, bottom, right, top] = pdfcraft_engine::script_outline(text).bounds();
let height = if initials { 24.0_f64 } else { 32.0_f64 };
// Keep long names within the same placement width as drawn signatures.
let height = height.min(150.0 * (top - bottom).max(0.1) / (right - left).max(0.01));
typed_signature_at(page, at, text, height, author)
typed_signature_at(page, at, text, height, rotation, author)
}
}
}

View File

@ -877,3 +877,83 @@ fn image_import_cancel_clear_and_errors_preserve_saved_signatures() {
assert!(again.initials.is_none());
std::fs::remove_file(path).unwrap();
}
/// Issue #299: drawn and typed signatures and initials are upright as displayed on pages with
/// `/Rotate` 90, 180 and 270, anchored at the displayed point, exactly as on an unturned page.
#[test]
fn drawn_and_typed_signatures_stay_upright_on_rotated_pages() {
// An "L": a long stroke along the bottom and a tick up at its left end, so a turned or
// mirrored signature shows.
let drawn = SavedSig::Drawn(vec![vec![[0.0, 0.0], [1.0, 0.0]], vec![[0.0, 0.0], [0.0, 0.2]]]);
let typed = SavedSig::Typed("Ada Lovelace".into());
// (signature, initials, displayed left-centre point)
let cases = [(&drawn, false, [40.0_f32, 60.0]), (&typed, false, [40.0, 140.0]), (&typed, true, [40.0, 230.0])];
let ink_mask = |rotation: i64| {
let mut session = pdfcraft_engine::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
if rotation != 0 {
session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: rotation }).unwrap();
}
let page = session.get(id).unwrap().info.pages[0].clone();
assert_eq!(i64::from(page.rotation), rotation);
for (sig, initials, shown) in cases {
let at = page.view_to_user(shown[0], shown[1]);
let edit = pdfcraft_ui_egui::fill_sign::place(0, &page, [f64::from(at[0]), f64::from(at[1])], sig, initials, "Ada").unwrap();
session.apply(id, edit).unwrap();
}
let annotations = &session.get(id).unwrap().info.annotations;
assert_eq!(annotations.len(), cases.len());
let bytes = session.get(id).unwrap().bytes.clone();
let mut renderer = pdfcraft_render::PageRenderer::new(bytes, Default::default());
let out = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
let shown_size = if rotation % 180 == 0 { (300, 400) } else { (400, 300) };
assert_eq!((out.width, out.height), shown_size, "rendered as displayed");
let dark: Vec<bool> = out.rgba.as_chunks::<4>().0.iter().map(|p| p[..3].iter().all(|v| *v < 128)).collect();
// The displayed ink box of each signature, in the band around its point.
let boxes: Vec<[u32; 4]> = cases
.iter()
.map(|(_, _, shown)| {
let (top, bottom) = ((shown[1] - 35.0) as u32, (shown[1] + 35.0) as u32);
let mut b = [u32::MAX, u32::MAX, 0, 0];
for y in top..bottom {
for x in 0..out.width {
if dark[(y * out.width + x) as usize] {
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
}
}
}
assert!(b[0] <= b[2], "rotation {rotation}: no ink near {shown:?}");
b
})
.collect();
(dark, out.width, boxes)
};
let (upright, _, upright_boxes) = ink_mask(0);
for (b, (sig, _, shown)) in upright_boxes.iter().zip(cases) {
assert!(b[2] - b[0] > b[3] - b[1], "{sig:?} reads across: {b:?}");
assert!(b[0].abs_diff(shown[0] as u32) <= 2, "{sig:?} starts at the point: {b:?}");
assert!(b[1] < shown[1] as u32 && b[3] > shown[1] as u32, "{sig:?} is centred on the point: {b:?}");
}
// The drawn "L" has its tick at the top left, not mirrored or upside down.
let at = |x: u32, y: u32| upright[(y * 300 + x) as usize];
let b = upright_boxes[0];
assert!(at(b[0] + 1, b[1] + 2) && !at(b[2] - 2, b[1] + 2) && at(b[2] - 2, b[3] - 1), "the drawn L is upright: {b:?}");
for rotation in [90, 180, 270] {
let (dark, width, boxes) = ink_mask(rotation);
for (got, want) in boxes.iter().zip(&upright_boxes) {
assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(*w) <= 1), "rotation {rotation}: displayed box {got:?}, upright {want:?}");
}
// Compare where both pages are shown (the turned ones are 400 x 300).
let (mut differ, mut ink) = (0, 0);
for y in 0..300 {
for x in 0..300 {
let (want, got) = (upright[(y * 300 + x) as usize], dark[(y * width + x) as usize]);
ink += usize::from(want);
differ += usize::from(want != got);
}
}
assert!(differ * 10 < ink, "rotation {rotation}: {differ} of {ink} ink pixels differ from the unturned page");
}
}

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::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; 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"]
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"
[[feature]]
id = "form.fill-sign-flatten"