From f46268fbe4664abe9b19b4000ef6427d4fe5eccf Mon Sep 17 00:00:00 2001 From: Friday's for beer <96867863+vjeko2404@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:43:54 +0200 Subject: [PATCH] M9: visible signatures read upright on rotated pages (#4067) * M9: visible signatures read upright on rotated pages A visible digital signature placed on a page with /Rotate was drawn in user space, so its graphic and details turned with the page: on scans stored landscape with /Rotate 270 the signature came out sideways. Stamps (#1398) and Fill & Sign marks (#2392) were already corrected; signatures were not. A new signature widget on a turned page gets /MK /R equal to the page rotation (inherited rotation included, via pdfcraft_model::pages), and its appearance is laid out in the box as displayed (sides swapped for a quarter turn) with a /Matrix from pdfcraft_model::view_matrix_for that maps it exactly onto /Rect. Signing an existing field follows that field's own /MK /R, so a field without one is drawn as before. Pages that are not turned produce the same bytes as before. Tests: sign crate (layout equals the upright box's, /MK /R, /Matrix, inherited /Rotate, existing fields with and without /MK /R, malformed /MK /R) and an engine render test on 0/90/180/270 pages with the quadrant image. Both fail with the fix reverted. Co-Authored-By: Claude Opus 5.5 * Keep one evidence/notes pair in the sign.digital parity entry --------- Co-authored-by: vjeko2404 Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + crates/engine/src/tests.rs | 25 +++++++++ crates/sign/Cargo.toml | 1 + crates/sign/README.md | 5 +- crates/sign/src/pdf.rs | 50 +++++++++++++++-- crates/sign/tests/it/pdf.rs | 104 +++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 181 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9129e85d6..496eeab0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4347,6 +4347,7 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-fonts", + "pdfcraft-model", "rc2", "ripemd", "rsa", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e888b0bf9..ed4e6efa1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -373,6 +373,31 @@ fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() { } } +/// A visible digital signature reads upright as the page is displayed on every page rotation: +/// its picture, alone in the box, shows its quadrants where they are in the image. +#[test] +fn visible_signatures_keep_displayed_orientation_on_rotated_pages() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let picture = image::load_from_memory(&quadrant_png()).unwrap().to_rgba8(); + let graphic = sign::Graphic::Image { width: picture.width(), height: picture.height(), rgba: Arc::new(picture.into_raw()) }; + let bare = sign::Appearance { name: false, date: false, reason: false, location: false, distinguished_name: false, labels: false }; + for degrees in [0, 90, 180, 270] { + // After the test certificate's start date (2026-10-02). + let mut session = Session::new().with_clock(|| 1_800_000_000); + let blank = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open_new("contract.pdf", blank).unwrap(); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + let info = session.get(id).unwrap().info.pages[0].clone(); + // The picture's proportions (2:1) as displayed, centred at (100, 100). + let rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0); + let opts = SignOptions { page: 0, rect: Some(rect), graphic: Some(graphic.clone()), appearance: bare.clone(), ..SignOptions::default() }; + let signed = session.sign(id, &digital_id, opts).unwrap(); + let reopened = session.open("signed.pdf", None, signed, None).unwrap(); + assert_upright_quadrants(&session, reopened, degrees); + } +} + /// #1398: a PDF page used as a stamp stays upright, at its natural size and in an explicit box. #[test] fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() { diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index d58811d55..4f1f0d409 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -14,6 +14,7 @@ workspace = true pdfcraft-cos = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-fonts = { workspace = true } +pdfcraft-model = { workspace = true } thiserror.workspace = true # ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } diff --git a/crates/sign/README.md b/crates/sign/README.md index 72f72dd8a..6844b1a6e 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -22,7 +22,10 @@ for s in signatures(&doc, &bytes, &trust) { // li An existing unsigned field or a new one (visible with Acrobat's name-and-details appearance, or invisible); certification with DocMDP P=1/2/3. A visible signature can show a `Graphic` where the large name would be: drawn strokes, filled outlines (a typed signature) or RGBA - pixels (an image XObject with a soft mask), checked and size-capped first. The document is written incrementally + pixels (an image XObject with a soft mask), checked and size-capped first. On a page shown + turned (`/Rotate`, inherited or not), a new visible signature reads upright as displayed: its + widget gets `/MK /R` and its appearance is laid out in the displayed box and turned back by a + `/Matrix`; an existing field follows its own `/MK /R`. The document is written incrementally with a zero-filled `/Contents` and fixed-width `/ByteRange`, which are then patched in place. Encrypted documents are refused for now. - **Timestamps (PAdES B-T):** the `timestamp` module builds and parses RFC 3161 requests, diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 11dad5dfb..d9669f08e 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -672,6 +672,17 @@ fn page_index(doc: &Document, p: ObjRef) -> Option { pdfcraft_annot::page_refs(doc).ok()?.iter().position(|r| *r == p) } +/// A widget's `/MK /R` as a quarter turn (0, 90, 180 or 270); missing, malformed or not a +/// multiple of 90 counts as 0, as `pdfcraft-forms` reads it for the fields it draws. +fn mk_rotation(doc: &Document, widget: &Dict) -> i64 { + let r = widget + .get(b"MK") + .map(|mk| doc.resolve(mk)) + .and_then(|mk| mk.as_dict().and_then(|mk| mk.get(b"R")).map(|r| doc.resolve(r))) + .and_then(|r| r.as_int().or_else(|| r.as_f64().filter(|v| v.is_finite() && v.abs() <= 1.0e6).map(|v| v.round() as i64))); + r.map(|r| r.rem_euclid(360)).filter(|q| matches!(q, 90 | 180 | 270)).unwrap_or(0) +} + /// Validate one signature dictionary. pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -> Option { list(doc, bytes, trust).into_iter().find(|s| s.field == field) @@ -2054,7 +2065,8 @@ fn sign_inner( .field .as_deref() .map(|n| sig_fields(&doc).into_iter().find(|f| f.name == n).ok_or_else(|| SignError::Pdf(format!("there is no signature field {n:?}")))); - let (widget, rect) = match existing { + // `turn`: how far the appearance is turned to read upright (`/MK /R`, counterclockwise). + let (widget, rect, turn) = match existing { Some(f) => { let f = f?; if f.signature(&doc).is_some() { @@ -2067,7 +2079,9 @@ fn sign_inner( doc.update_dict(fr, |d| d.set(b"V".to_vec(), Object::Ref(sig)))?; let (wr, wd) = f.widgets.first().cloned().ok_or_else(|| SignError::Pdf("the field has no widget".into()))?; let rect = nums(&doc, &wd, b"Rect").filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]); - (wr.unwrap_or(fr), rect.unwrap_or([0.0; 4])) + // The field's own rotation, as a form author set it: an existing field is not + // reoriented, so one without `/MK /R` is drawn as before. + (wr.unwrap_or(fr), rect.unwrap_or([0.0; 4]), mk_rotation(&doc, &wd)) } None => { let page = *pages.get(opts.page).ok_or_else(|| SignError::Pdf(format!("page {} does not exist", opts.page + 1)))?; @@ -2087,6 +2101,14 @@ fn sign_inner( w.set(b"P".to_vec(), Object::Ref(page)); // Print + Locked. w.set(b"F".to_vec(), Object::Int(4 | 128)); + // On a page shown turned, the signature reads upright as the page is displayed: the + // widget is turned back by the page's rotation (ISO 32000-2 §12.5.6.19, `/MK /R`). + let turn = pdfcraft_model::pages(&doc).get(opts.page).map_or(0, |p| p.rotation(&doc)); + if turn != 0 { + let mut mk = Dict::new(); + mk.set(b"R".to_vec(), Object::Int(turn)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + } let wr = doc.add(Object::Dict(w)); // Page /Annots (indirect arrays are updated in place). let pd = doc.get(page).as_dict().cloned().unwrap_or_default(); @@ -2122,7 +2144,7 @@ fn sign_inner( // Inline (or new) AcroForms stay in the catalog. None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?, } - (wr, rect) + (wr, rect, turn) } }; if opts.field.is_some() { @@ -2136,7 +2158,7 @@ fn sign_inner( } } // The appearance. - let ap = appearance(&mut doc, rect, &name, &id.certificate, opts)?; + let ap = appearance(&mut doc, rect, turn, &name, &id.certificate, opts)?; let ap_ref = doc.add(Object::Stream(ap)); let mut apd = Dict::new(); apd.set(b"N".to_vec(), Object::Ref(ap_ref)); @@ -2264,9 +2286,15 @@ pub fn display_date(pdf: &str) -> String { /// draw: signing invisibly, or leaving that part out, still works. A graphic doesn't change this: /// the name it replaces on the left is still among the details. A label that is drawn /// ([`AppearanceLabels`], the caller's words) must be drawable in Helvetica, and is refused otherwise. -fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result { +/// +/// `turn` is the widget's `/MK /R` (0, 90, 180 or 270; anything else counts as 0): the layout is +/// drawn upright in the box as turned by it, and a `/Matrix` maps that box onto `rect`. +fn appearance(doc: &mut Document, rect: [f64; 4], turn: i64, name: &str, cert: &Certificate, opts: &SignOptions) -> Result { use pdfcraft_fonts::{first_undrawable, helvetica_width, inter_width, literal, undrawable_message, win_ansi, wrap, wrap_with}; - let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0)); + let turn = if matches!(turn, 90 | 180 | 270) { turn } else { 0 }; + let (rw, rh) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0)); + // The upright layout box: a quarter turn swaps its sides. + let (w, h) = if turn % 180 == 0 { (rw, rh) } else { (rh, rw) }; let a = &opts.appearance; let dn = cert.subject.display(); let mut needs_unicode = false; @@ -2412,6 +2440,16 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|x| Object::Real(*x)).collect())); + if turn != 0 { + // Viewers fit the box, transformed by `/Matrix`, to the widget's `/Rect` (ISO 32000-2 + // §12.5.5); this one lands on `rect` exactly, turned as `/MK /R` says. + let mut m = pdfcraft_model::view_matrix_for(turn, [rect[0], rect[1], rect[0] + rw, rect[1] + rh]); + if !m.iter().all(|x| x.is_finite()) { + // A caller's non-finite corner: the turn alone, which viewers fit to `/Rect` the same. + m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + } + d.set(b"Matrix".to_vec(), Object::Array(m.iter().map(|x| Object::Real(*x)).collect())); + } let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); if let Some(im) = image { diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 097d9603b..55aa732d7 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -28,6 +28,11 @@ fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec { b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>", ]; objs.extend_from_slice(more); + write_pdf(&objs) +} + +/// A classic-xref PDF of `objs`, numbered from 1, with object 1 as the catalog. +fn write_pdf(objs: &[&[u8]]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -365,6 +370,105 @@ fn visible_signatures_show_a_drawn_typed_or_image_graphic() { assert!(!invisible.windows(6).any(|w| w == b"/Image")); } +/// [`fixture`]'s page and "Approval" widget with extra entries: `page` (a `/Rotate`, say) and +/// `widget` (an `/MK`); `pages` goes on the page tree node, where `/Rotate` is inheritable. +fn rotated_fixture(pages: &str, page: &str, widget: &str) -> Vec { + let pages = format!("<< /Type /Pages /Kids [3 0 R] /Count 1 {pages} >>"); + let page = format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] {page} >>" + ); + let widget = format!("<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 {widget} >>"); + write_pdf(&[ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + pages.as_bytes(), + page.as_bytes(), + b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + widget.as_bytes(), + ]) +} + +/// The signature field `name`'s widget and its normal appearance's dictionary and content. +fn widget_and_appearance(bytes: &[u8], name: &str) -> (pdfcraft_cos::Dict, pdfcraft_cos::Dict, String) { + let doc = open(bytes); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let form = doc.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let fields = doc.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); + let widget = fields + .iter() + .filter_map(|f| doc.resolve(f).as_dict().cloned()) + .find(|f| matches!(f.get(b"T"), Some(Object::String(s)) if s.bytes == name.as_bytes())) + .unwrap(); + let ap = doc.resolve(widget.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(s) = &*doc.resolve(ap.get(b"N").unwrap()) else { panic!("no normal appearance") }; + (widget.clone(), s.dict.clone(), String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()) +} + +fn numbers(d: &pdfcraft_cos::Dict, key: &[u8]) -> Option> { + d.get(key).and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|n| n.as_f64()).collect()) +} + +/// On a page shown turned, a visible signature reads upright as displayed: its appearance is the +/// one an unturned page gets in the box with the displayed proportions, turned back onto the +/// widget by `/MK /R` and the matching `/Matrix`. +#[test] +fn visible_signatures_read_upright_on_rotated_pages() { + use pdfcraft_sign::Appearance; + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + // 200 x 50 in user space: shown 50 wide and 200 tall on a page turned a quarter. + let rect = [20.0, 100.0, 220.0, 150.0]; + let swapped = [20.0, 100.0, 70.0, 300.0]; + let shown = Appearance { reason: true, location: true, ..Appearance::default() }; + let sign = |bytes: &[u8], rect: [f64; 4]| { + let o = SignOptions { rect: Some(rect), graphic: Some(picture(false)), appearance: shown.clone(), ..opts() }; + pdfcraft_sign::sign(&open(bytes), &id, &o).unwrap() + }; + let upright = |rect: [f64; 4]| widget_and_appearance(&sign(&fixture(), rect), "Signature1"); + for (pages, page, turn) in [ + ("", "", 0), + ("", "/Rotate 90", 90), + ("", "/Rotate 180", 180), + ("", "/Rotate 270", 270), + ("", "/Rotate -90", 270), + // Inherited from the page tree. + ("/Rotate 270", "", 270), + // Not a quarter turn: drawn as unturned, as the page is. + ("", "/Rotate 45", 0), + ] { + let doc = rotated_fixture(pages, page, ""); + let signed = sign(&doc, rect); + let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap(); + assert_eq!((s.status, s.modification), (Status::Unknown, Modification::None), "{pages} {page}: {:?}", s.details); + let (widget, form, content) = widget_and_appearance(&signed, "Signature1"); + let (_, expected_form, expected) = upright(if turn % 180 == 0 { rect } else { swapped }); + assert_eq!(content, expected, "{pages} {page}: the layout is the upright box's"); + assert_eq!(numbers(&form, b"BBox"), numbers(&expected_form, b"BBox"), "{pages} {page}"); + let mk = widget.get(b"MK").and_then(|m| m.as_dict()).and_then(|m| m.int(b"R")); + let matrix = numbers(&form, b"Matrix"); + if turn == 0 { + assert_eq!((mk, matrix), (None, None), "{pages} {page}: an unturned page's signature is unchanged"); + } else { + assert_eq!(mk, Some(turn), "{pages} {page}"); + assert_eq!(matrix, Some(pdfcraft_model::view_matrix_for(turn, rect).to_vec()), "{pages} {page}"); + } + } + // An existing field keeps its own orientation: `/MK /R` turns it, whatever the page does. + let field = |bytes: &[u8]| { + let o = SignOptions { field: Some("Approval".into()), graphic: Some(picture(false)), appearance: shown.clone(), ..opts() }; + widget_and_appearance(&pdfcraft_sign::sign(&open(bytes), &id, &o).unwrap(), "Approval") + }; + let field_rect = [150.0, 20.0, 280.0, 70.0]; + let (_, plain, plain_content) = field(&rotated_fixture("", "/Rotate 90", "")); + assert_eq!((numbers(&plain, b"Matrix"), numbers(&plain, b"BBox")), (None, Some(vec![0.0, 0.0, 130.0, 50.0]))); + let (_, turned, turned_content) = field(&rotated_fixture("", "/Rotate 90", "/MK << /R 90 >>")); + assert_eq!(numbers(&turned, b"BBox"), Some(vec![0.0, 0.0, 50.0, 130.0])); + assert_eq!(numbers(&turned, b"Matrix"), Some(pdfcraft_model::view_matrix_for(90, field_rect).to_vec())); + assert_ne!(turned_content, plain_content); + // A malformed `/MK /R` counts as 0. + let (_, odd, _) = field(&rotated_fixture("", "", "/MK << /R (ninety) >>")); + assert_eq!(numbers(&odd, b"Matrix"), None); +} + #[test] fn malformed_signature_graphics_are_refused_not_drawn() { use pdfcraft_sign::Graphic; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2f04d928f..4af0cd98c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6001,8 +6001,8 @@ milestone = "M9" status = "shipped" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools"] -notes = "drag a rectangle; Acrobat's name-and-details appearance" +evidence = ["crates/ui-egui/tests/it/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/automation/tests/it/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/sign/tests/it/pdf.rs::visible_signatures_read_upright_on_rotated_pages", "crates/engine/src/tests.rs::visible_signatures_keep_displayed_orientation_on_rotated_pages"] +notes = "drag a rectangle; Acrobat's name-and-details appearance; reads upright on rotated pages (/MK /R)" [[feature]] id = "sign.sign-existing-field"