From 0c1528f17dc4d1b98f02d44794ef8a282035b2f5 Mon Sep 17 00:00:00 2001 From: mjohnson7cd <61306576+mjohnson7cd@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:10:03 -0400 Subject: [PATCH] feat: Scale down large signature images instead of rejecting (#1075) (#1124) * Fill & Sign: scale down large signature images instead of rejecting them (#1075) A 6025 x 2506 PNG signature failed with "Image size exceeds limit": imports were capped at 4096 px per side and 4 megapixels, and the decoder's limit error leaked through raw. Sources up to 8192 px per side and 32 megapixels are now decoded and scaled down to fit the old bounds; anything larger reports the normal size message. Reported-by: @HassanAlthaf Co-authored-by: mjohnson7cd * A placed image too large to preview gets its own message; scale with thumbnail, which needs far less memory --------- Co-authored-by: mjohnson7cd Co-authored-by: Brandon Thomas --- crates/automation/src/tools.rs | 2 +- crates/engine/src/signature_image.rs | 51 +++++++++++++++++++++------- parity/acrobat-features.toml | 4 +-- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 467fc81d..7e5e0267 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -995,7 +995,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.", ) .cmd("sign.fill.text") .with(schema( diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index 84dfb4bb..3e1d621d 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -52,8 +52,8 @@ impl Document { let obj = editor.cos.get(picture); let pdfcraft_cos::Object::Stream(s) = &*obj else { return Err("the signature image is damaged".into()) }; let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0)); - if w <= 0 || h <= 0 || w > 4096 || h > 4096 || w.saturating_mul(h) > MAX_PIXELS as i64 { - return Err(SignatureImageError::Size.to_string()); + if w <= 0 || h <= 0 || w > i64::from(MAX_SIDE) || h > i64::from(MAX_SIDE) || w.saturating_mul(h) > MAX_PIXELS as i64 { + return Err(SignatureImageError::PlacedSize.to_string()); } let (_, bytes) = pdfcraft_create::image_file(&editor.cos, picture)?; let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?; @@ -90,13 +90,20 @@ pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Opti /// 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; +const MAX_SIDE: u32 = 4096; +/// Larger sources (scans, phone photos) are decoded within these bounds, then scaled down. +const MAX_SOURCE_SIDE: u32 = 8192; +const MAX_SOURCE_PIXELS: u64 = 32 << 20; #[derive(Debug, thiserror::Error)] pub enum SignatureImageError { #[error("Choose a PNG or JPEG image.")] Format, - #[error("Choose an image smaller than 4 MiB and 4 megapixels (at most 4096 pixels per side).")] + #[error("Choose an image smaller than 4 MiB and 32 megapixels (at most 8192 pixels per side).")] Size, + /// An image already in the document, which was stored at 4 megapixels or less when placed. + #[error("This signature image is larger than 4 megapixels (or 4096 pixels per side), so it can't be previewed.")] + PlacedSize, #[error("Couldn't read the signature image: {0}")] Read(#[from] std::io::Error), #[error("Couldn't decode the signature image: {0}")] @@ -129,18 +136,27 @@ impl SignatureImage { } let mut reader = ImageReader::with_format(Cursor::new(bytes), format); let mut limits = Limits::default(); - limits.max_image_width = Some(4096); - limits.max_image_height = Some(4096); - limits.max_alloc = Some(32 << 20); + limits.max_image_width = Some(MAX_SOURCE_SIDE); + limits.max_image_height = Some(MAX_SOURCE_SIDE); + // 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size. + limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4); reader.limits(limits); - let mut decoder = reader.into_decoder()?; + let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() }; + let mut decoder = reader.into_decoder().map_err(too_big)?; let (w, h) = decoder.dimensions(); - if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS { return Err(SignatureImageError::Size); } let orientation = decoder.orientation()?; - let mut image = DynamicImage::from_decoder(decoder)?; + let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?; image.apply_orientation(orientation); + let (w, h) = (f64::from(image.width()), f64::from(image.height())); + let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt()); + if scale < 1.0 { + let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1)); + // `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps. + image = image.thumbnail(nw, nh); + } let rgba = image.into_rgba8(); let size = [rgba.width() as usize, rgba.height() as usize]; let mut png = Cursor::new(Vec::new()); @@ -250,12 +266,23 @@ mod tests { assert!(SignatureImage::from_bytes(b"not an image").is_err()); assert!(SignatureImage::from_bytes(b"\x89PNG\r\n\x1a\ntruncated").is_err()); assert!(SignatureImage::from_bytes(&vec![0; MAX_SIGNATURE_IMAGE_BYTES + 1]).is_err()); - assert!(SignatureImage::from_bytes(&png(4097, 1)).is_err()); - assert!(SignatureImage::from_bytes(&png(4096, 1025)).is_err()); + assert!(matches!(SignatureImage::from_bytes(&png(8193, 1)), Err(SignatureImageError::Size))); + assert!(matches!(SignatureImage::from_bytes(&png(8192, 4097)), Err(SignatureImageError::Size))); assert!(SignatureImage::read(std::io::repeat(0)).is_err()); assert!(matches!(SignatureImage::from_bytes(b"GIF89a\x02\0\x02\0"), Err(SignatureImageError::Format))); } + /// #1075: a 6025 x 2506 scan is scaled down to fit 4096 per side and 4 megapixels. + #[test] + fn large_signature_images_are_scaled_down() { + let image = SignatureImage::from_bytes(&png(6025, 2506)).unwrap(); + let [w, h] = image.size(); + assert!(w <= 4096 && (w * h) as u64 <= MAX_PIXELS, "{w} x {h}"); + assert!((w as f64 / h as f64 - 6025.0 / 2506.0).abs() < 0.01, "{w} x {h}"); + assert_eq!(SignatureImage::from_bytes(&png(4096, 1)).unwrap().size(), [4096, 1]); + assert_eq!(SignatureImage::from_bytes(&png(8192, 1)).unwrap().size(), [4096, 1]); + } + #[test] fn jpeg_signatures_are_normalized_to_png() { let mut jpeg = Cursor::new(Vec::new()); @@ -330,7 +357,7 @@ mod tests { stream.dict.set(b"Width".to_vec(), pdfcraft_cos::Object::Int(i64::MAX)); cos.set(picture, pdfcraft_cos::Object::Stream(stream)); let error = doc.image_signature_preview(0, 0).err().unwrap(); - assert!(error.contains("4 megapixels"), "{error}"); + assert_eq!(error, SignatureImageError::PlacedSize.to_string()); } /// "Left edge at the point, centered vertically" is as displayed: on a turned page the box diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 14b20f7b..051878ce 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5262,8 +5262,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_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", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field"] -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" +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::large_signature_images_are_scaled_down", "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", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field"] +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, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075)" [[feature]] id = "form.fill-sign-flatten"