From a62633e902c5e52d0dfefae9356ef0ac9e3565b9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 01:16:17 +0100 Subject: [PATCH 1/6] fix(sign): the first signature drops /NeedAppearances so Acrobat recognises it (#5939) Co-Authored-By: Claude Sonnet 5.5 --- crates/sign/src/pdf/signing.rs | 17 ++++++++++++ crates/sign/tests/it/pdf.rs | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index 71dd10b302..b41d7fe91a 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -153,6 +153,7 @@ fn sign_inner( } check_signing_permissions(doc, opts)?; let mut doc = doc.clone(); + let earlier_signatures = !super::validate::signature_contents(&doc).is_empty(); let root = doc.root().ok_or_else(|| SignError::Pdf("the document has no catalog".into()))?; let alg = id.key.preferred_digest(); let reserve = @@ -293,6 +294,22 @@ fn sign_inner( doc.update_dict(r, |af| af.set(b"SigFlags".to_vec(), Object::Int(3)))?; } } + // Acrobat does not recognise a signature in a form that asks viewers to redraw its fields + // (`/NeedAppearances true`): redrawing would change what the signature covers. The first + // signature drops the flag; a later one leaves it, as removing it would count as a change + // to the earlier signatures. + if !earlier_signatures { + if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) { + doc.update_dict(r, |af| { + af.remove(b"NeedAppearances"); + })?; + } + doc.update_dict(root, |c| { + if let Some(Object::Dict(af)) = c.get_mut(b"AcroForm") { + af.remove(b"NeedAppearances"); + } + })?; + } // The appearance. let ap = appearance(&mut doc, rect, turn, &name, &id.certificate, opts)?; let ap_ref = doc.add(Object::Stream(ap)); diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 869a4bebd5..ce4b30de9e 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -122,6 +122,56 @@ fn signing_then_validating_with_and_without_trust() { } } +/// The form dictionary of `bytes`, with the catalog's reference resolved. +fn form_of(bytes: &[u8]) -> Dict { + let doc = open(bytes); + let form = doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"AcroForm").map(|a| doc.resolve(a))).unwrap(); + form.as_dict().unwrap().clone() +} + +/// A form with `/NeedAppearances true` makes Acrobat ignore the signature (#5939): the first +/// signature drops the flag, whether the AcroForm is inline or indirect, and leaves the rest of +/// the form alone. +#[test] +fn signing_drops_need_appearances() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let inline = fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true >> >>", &[]); + let indirect = + fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm 7 0 R >>", &[b"<< /Fields [6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) >>"]); + for (what, bytes) in [("inline", inline), ("indirect", indirect)] { + assert_eq!(form_of(&bytes).get(b"NeedAppearances"), Some(&Object::Bool(true)), "{what}"); + let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); + let form = form_of(&signed); + assert!(form.get(b"NeedAppearances").is_none(), "{what}"); + assert_eq!(form.get(b"SigFlags"), Some(&Object::Int(3)), "{what}"); + assert!(form.get(b"Fields").is_some(), "{what}"); + let doc = open(&signed); + assert!(signatures(&doc, &signed, &TrustStore::default()).iter().any(|s| s.field == "Approval" && s.signed), "{what}"); + } +} + +/// A second signer leaves the form's flags alone: changing them would count as a change to +/// what the first signature covered. +#[test] +fn a_later_signature_does_not_change_need_appearances() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let first = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); + let mut doc = open(&first); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + if let Some(Object::Dict(af)) = c.get_mut(b"AcroForm") { + af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); + } + }) + .unwrap(); + if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) { + doc.update_dict(r, |af| af.set(b"NeedAppearances".to_vec(), Object::Bool(true))).unwrap(); + } + let flagged = write_incremental(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let second = pdfcraft_sign::sign(&open(&flagged), &id, &opts()).unwrap(); + assert_eq!(form_of(&second).get(b"NeedAppearances"), Some(&Object::Bool(true))); +} + #[test] fn signing_an_existing_field_and_counter_signing() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); From a371b24f2a1c0484288fd9700d429216f3917f02 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 01:25:41 +0100 Subject: [PATCH 2/6] test(sign): tighten the NeedAppearances tests (#5939) Co-Authored-By: Claude Sonnet 5.5 --- crates/sign/tests/it/pdf.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index ce4b30de9e..137960e83b 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -130,8 +130,8 @@ fn form_of(bytes: &[u8]) -> Dict { } /// A form with `/NeedAppearances true` makes Acrobat ignore the signature (#5939): the first -/// signature drops the flag, whether the AcroForm is inline or indirect, and leaves the rest of -/// the form alone. +/// signature drops the flag, whether the AcroForm is inline or indirect, and keeps /Fields and +/// sets /SigFlags. #[test] fn signing_drops_need_appearances() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); @@ -170,6 +170,8 @@ fn a_later_signature_does_not_change_need_appearances() { let flagged = write_incremental(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); let second = pdfcraft_sign::sign(&open(&flagged), &id, &opts()).unwrap(); assert_eq!(form_of(&second).get(b"NeedAppearances"), Some(&Object::Bool(true))); + let doc = open(&second); + assert!(signatures(&doc, &second, &TrustStore::default()).iter().any(|s| s.field == "Approval" && s.signed)); } #[test] From 7b6baec065f29f4fe15158542575fbd94fd9f4a3 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:54:58 +0100 Subject: [PATCH 3/6] sign: an empty /Contents is not an earlier signature Only a non-empty signature value counts when deciding whether the first real signature may drop /NeedAppearances. Co-Authored-By: Claude Sonnet 5.5 --- crates/sign/src/pdf/signing.rs | 3 ++- crates/sign/tests/it/pdf.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index b41d7fe91a..fb3569602b 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -153,7 +153,8 @@ fn sign_inner( } check_signing_permissions(doc, opts)?; let mut doc = doc.clone(); - let earlier_signatures = !super::validate::signature_contents(&doc).is_empty(); + // A signature field whose /V holds an empty /Contents is not a signature yet. + let earlier_signatures = super::validate::signature_contents(&doc).iter().any(|der| !der.is_empty()); let root = doc.root().ok_or_else(|| SignError::Pdf("the document has no catalog".into()))?; let alg = id.key.preferred_digest(); let reserve = diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 137960e83b..bcfe41631e 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -150,6 +150,22 @@ fn signing_drops_need_appearances() { } } +/// A field whose /V has an empty /Contents is no earlier signature: the first real one still +/// drops `/NeedAppearances`. +#[test] +fn an_empty_signature_value_does_not_count_as_an_earlier_signature() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let bytes = fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] /NeedAppearances true >> >>", + &[ + b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Empty) /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>", + b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /Contents <> >>", + ], + ); + let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); + assert!(form_of(&signed).get(b"NeedAppearances").is_none()); +} + /// A second signer leaves the form's flags alone: changing them would count as a change to /// what the first signature covered. #[test] From 66ee360067063f31cdb0b19414db128e7de76567 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 02:18:55 +0100 Subject: [PATCH 4/6] docs(sign): note the /NeedAppearances removal and its rendering cost Co-Authored-By: Claude Opus 5.5 --- crates/sign/src/pdf/signing.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index fb3569602b..e5cc6a97f8 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -120,13 +120,19 @@ fn check_signing_permissions(doc: &Document, opts: &SignOptions) -> Result<(), S const BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333]; /// Sign `doc` with `id`; returns the signed file (an incremental update when possible). +/// +/// The document's first signature removes the AcroForm's `/NeedAppearances` flag: Acrobat does +/// not recognise a signature while it is set. Later signatures leave the flag as it is. +/// A field that has a value but no appearance stream of its own then shows empty in viewers that +/// only drew it because of the flag. pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result, SignError> { sign_inner(doc, id, opts, None) } /// Sign and embed an RFC 3161 signature timestamp from `tsa` (PAdES B-T). The transport is the /// caller's: this crate never opens a socket, and a rejected or malformed token fails the -/// signing before any file is produced. +/// signing before any file is produced. The form's `/NeedAppearances` flag is handled as in +/// [`sign`]. pub fn sign_with_timestamp( doc: &Document, id: &DigitalId, @@ -298,7 +304,8 @@ fn sign_inner( // Acrobat does not recognise a signature in a form that asks viewers to redraw its fields // (`/NeedAppearances true`): redrawing would change what the signature covers. The first // signature drops the flag; a later one leaves it, as removing it would count as a change - // to the earlier signatures. + // to the earlier signatures. The cost: a field with a value but no appearance stream of its + // own, which viewers only drew because of the flag, now shows empty in them. if !earlier_signatures { if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) { doc.update_dict(r, |af| { From d4b8c94a00866c73f311e45fbc48edf7be0f792a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 04:28:14 +0100 Subject: [PATCH 5/6] fix(sign): draw the form's fields before the first signature drops /NeedAppearances (#5939) Once the flag is gone, viewers show each field's stored appearance, so a filled field whose /AP /N draws no text (a /Tx BMC EMC placeholder or a stale look) went blank. Session::sign and sign_with_timestamp now sign the copy with_field_appearances draws, as the document is displayed, when the signature is the first. A field whose own appearance draws text keeps it where PdfCraft's drawing would show a character as `?`. The earlier-signature check is now pdfcraft_sign::has_signatures, shared by the engine and the signer, and it also finds signed fields without a /T. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/session/save.rs | 48 +++++++++++++- crates/engine/src/tests.rs | 105 ++++++++++++++++++++++++++++++ crates/sign/src/lib.rs | 2 +- crates/sign/src/pdf.rs | 2 +- crates/sign/src/pdf/signing.rs | 32 ++++++--- crates/sign/tests/it/pdf.rs | 18 +++++ 6 files changed, 195 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 1e6918f6a6..c8fa0d703a 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -276,7 +276,7 @@ impl Session { opts.date = self.signing_date(); } // A typed Password-field value is not signed into the file (#3926), as it isn't saved. - let cos = writable(&editor.cos)?; + let cos = drawn_for_first_signature(writable(&editor.cos)?); Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) } @@ -294,7 +294,7 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - let cos = writable(&editor.cos)?; + let cos = drawn_for_first_signature(writable(&editor.cos)?); let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); // Checked like a save before anyone writes it (#2166). self.check_save(doc, &signed)?; @@ -336,3 +336,47 @@ impl Session { Ok(()) } } + +/// `cos` with its form fields drawn, when the signature about to be made is the document's first +/// and the form sets `/NeedAppearances` (#5939). That signature removes the flag, after which +/// viewers show each field's stored appearance; a filled field whose stored appearance draws no +/// text (an empty placeholder such as `/Tx BMC EMC`, or a stale look) would go blank. So every +/// text and choice field is drawn from its value first, as the document is displayed +/// ([`pdfcraft_forms::with_field_appearances`]), and the signature covers that look. +/// +/// One exception: where PdfCraft's drawing would show a character as `?` (the field's font can't +/// hold it) and the field's own appearance draws text, the field keeps its own appearance, which +/// the program that filled it drew with a font that has the character. A field whose own +/// appearance draws no text gets PdfCraft's drawing, `?` and all. Later signatures change +/// nothing here: redrawing would count as a change to the earlier signatures. +fn drawn_for_first_signature(cos: std::borrow::Cow<'_, pdfcraft_cos::Document>) -> std::borrow::Cow<'_, pdfcraft_cos::Document> { + use pdfcraft_forms::FieldKind; + if !pdfcraft_forms::need_appearances(&cos) || pdfcraft_sign::has_signatures(&cos) { + return cos; + } + let Some(mut drawn) = pdfcraft_forms::with_field_appearances(&cos) else { return cos }; + for f in pdfcraft_forms::fields(&cos) { + if !matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List) { + continue; + } + for w in &f.widgets { + let own = cos.get(w.obj).as_dict().filter(|d| draws_text(&cos, d)).and_then(|d| d.get(b"AP").cloned()); + // `.1`: the first character the drawing showed as `?`. + if let Some(ap) = own + && pdfcraft_forms::appearance::field_appearance_drawn(&cos, &f, w, &f.value, true).1.is_some() + { + drawn.update_dict(w.obj, |d| d.set(b"AP".to_vec(), ap)).ok(); + } + } + } + std::borrow::Cow::Owned(drawn) +} + +/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through a +/// form XObject it paints (counted as text without looking inside). +fn draws_text(cos: &pdfcraft_cos::Document, widget: &pdfcraft_cos::Dict) -> bool { + let ap = widget.get(b"AP").map(|ap| cos.resolve(ap)); + let normal = ap.as_deref().and_then(|ap| ap.as_dict()).and_then(|ap| ap.get(b"N")).map(|n| cos.resolve(n)); + let Some(pdfcraft_cos::Object::Stream(s)) = normal.as_deref() else { return false }; + s.decoded().is_ok_and(|data| pdfcraft_content::ops(&data).any(|op| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"" | b"Do"))) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c109a0bc80..55c57c2f29 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2699,6 +2699,111 @@ fn a_signed_file_carries_no_password_field_value() { assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length"); } +/// A form with `/NeedAppearances true` whose filled fields have stored appearances that draw no +/// text: `name` an empty placeholder, `town` one too with a value Helvetica can't hold (`ň`), +/// `city` such a value with its own appearance that draws it (#5939). +fn need_appearances_form() -> Vec { + let stream = + |content: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Length {} >>\nstream\n{content}\nendstream", content.len()); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada Lovelace) /Rect [20 250 180 270] /P 3 0 R /AP << /N 8 0 R >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (town) /V /Rect [20 200 180 220] /P 3 0 R /AP << /N 9 0 R >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V /Rect [20 150 180 170] /P 3 0 R /AP << /N 10 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + stream("/Tx BMC EMC"), + stream("/Tx BMC EMC"), + stream("/Tx BMC BT /Helv 10 Tf 2 5 Td (Brno r) Tj ET EMC"), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// The decoded normal appearance of the widget `num` in `doc`. +fn normal_appearance(doc: &pdfcraft_cos::Document, num: u32) -> String { + let widget = doc.get(pdfcraft_cos::ObjRef::new(num, 0)); + let ap = doc.resolve(widget.as_dict().unwrap().get(b"AP").unwrap()); + let n = doc.resolve(ap.as_dict().unwrap().get(b"N").unwrap()); + let pdfcraft_cos::Object::Stream(s) = &*n else { panic!("{n:?}") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// #5939 review: the first signature removes `/NeedAppearances`, so the fields are drawn from +/// their values first; without that, `name` and `town` would show their empty placeholders. A +/// field whose own appearance draws its value keeps it rather than get a `?` for `ř`. +#[test] +fn the_first_signature_draws_the_fields_need_appearances_left_to_the_viewer() { + 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(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("form.pdf", None, Arc::new(need_appearances_form()), None).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + let doc = pdfcraft_cos::Document::open(signed.clone()).unwrap(); + assert!(!pdfcraft_forms::need_appearances(&doc)); + assert!(normal_appearance(&doc, 4).contains("(Ada Lovelace) Tj"), "{}", normal_appearance(&doc, 4)); + assert!(normal_appearance(&doc, 5).contains("(Plze?) Tj"), "{}", normal_appearance(&doc, 5)); + assert!(normal_appearance(&doc, 6).contains("(Brno r) Tj"), "{}", normal_appearance(&doc, 6)); + let city = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(6, 0)).as_dict().unwrap().get(b"AP").cloned(); + let original = pdfcraft_cos::Document::open(Arc::new(need_appearances_form())).unwrap(); + assert_eq!(city(&doc), city(&original), "city keeps its own appearance"); + s.mark_signed(id, signed, None).unwrap(); + s.set_trusted_certificates(vec![digital_id.certificate.clone()]); + let sig = &s.get(id).unwrap().signatures[0]; + assert_eq!((sig.signed, sig.status, sig.modification.clone()), (true, SignatureStatus::Valid, sign::Modification::None)); +} + +/// A later signature draws nothing and leaves the flag: either would be a change to what the +/// first signature covers. +#[test] +fn a_later_signature_leaves_need_appearances_forms_alone() { + 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 mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("form.pdf", None, Arc::new(need_appearances_form()), None).unwrap(); + let first = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + // The flag set again after the first signature, and `name` back to its empty placeholder. + let mut cos = pdfcraft_cos::Document::open(first).unwrap(); + let root = cos.root().unwrap(); + cos.update_dict(root, |c| { + if let Some(pdfcraft_cos::Object::Dict(af)) = c.get_mut(b"AcroForm") { + af.set(b"NeedAppearances".to_vec(), pdfcraft_cos::Object::Bool(true)); + } + }) + .unwrap(); + let placeholder = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(Default::default(), b"/Tx BMC EMC".to_vec()))); + cos.update_dict(pdfcraft_cos::ObjRef::new(4, 0), |w| { + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), pdfcraft_cos::Object::Ref(placeholder)); + w.set(b"AP".to_vec(), pdfcraft_cos::Object::Dict(ap)); + }) + .unwrap(); + let flagged = + Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap()); + let id = s.open("flagged.pdf", None, flagged.clone(), None).unwrap(); + let second = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + let (before, after) = (pdfcraft_cos::Document::open(flagged).unwrap(), pdfcraft_cos::Document::open(second.clone()).unwrap()); + assert!(pdfcraft_forms::need_appearances(&after)); + let widget = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(4, 0)); + assert_eq!(widget(&after), widget(&before)); + assert_eq!(normal_appearance(&after, 4), "/Tx BMC EMC"); + s.mark_signed(id, second, None).unwrap(); + assert_eq!(s.get(id).unwrap().signatures.iter().filter(|x| x.signed).count(), 2); +} + /// #3944: a value typed into an XFA `` field stays in the open document (both the /// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`), /// but is written to no file — neither on the widget nor in the XFA datasets stream. diff --git a/crates/sign/src/lib.rs b/crates/sign/src/lib.rs index 2954de288d..a0aeadfdee 100644 --- a/crates/sign/src/lib.rs +++ b/crates/sign/src/lib.rs @@ -29,7 +29,7 @@ pub use graphic::Graphic; pub use keys::{DigestAlg, PrivateKey, PublicKey}; pub use pdf::{ Appearance, AppearanceLabels, Detail, DigestCache, Fill, Modification, SignOptions, SignatureInfo, Status, TrustStore, WORDINGS, Words, - in_english, kind, list as signatures, sign, sign_with_timestamp, timestamp_document, validate, + has_signatures, in_english, kind, list as signatures, sign, sign_with_timestamp, timestamp_document, validate, }; pub use pkcs12::DigitalId; pub use timestamp::{TimestampAuthority, TimestampQuery, TimestampToken}; diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 8fafff7f01..c9d224fea4 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -35,7 +35,7 @@ use changes::only_grew; use listing::{DISCOVERY_CACHE_LIMIT, is_document_timestamp}; pub use listing::{DigestCache, list, list_cached}; pub(crate) use signing::fmt; -pub use signing::{Appearance, AppearanceLabels, SignOptions, display_date, sign, sign_with_timestamp, timestamp_document}; +pub use signing::{Appearance, AppearanceLabels, SignOptions, display_date, has_signatures, sign, sign_with_timestamp, timestamp_document}; pub use types::{Detail, Fill, Modification, Status, TrustStore, WORDINGS, Words, in_english, kind}; pub(crate) use validate::signature_contents; pub use validate::validate; diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index e5cc6a97f8..efb5960ee5 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -119,12 +119,28 @@ fn check_signing_permissions(doc: &Document, opts: &SignOptions) -> Result<(), S /// Placeholder `/ByteRange` values: fixed width, patched after writing. const BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333]; +/// Whether `doc` already holds a signature or a document timestamp: a signature dictionary whose +/// `/Contents` is not empty (a field whose `/V` has an empty `/Contents` is not signed yet). A +/// signature field without a name (`/T`) counts too: indirect signature dictionaries are found by +/// their `/ByteRange`, not through the field tree. +pub fn has_signatures(doc: &Document) -> bool { + let signed = |d: &Dict| { + let contents = d.get(b"Contents").map(|c| doc.resolve(c)); + contents.as_deref().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()) + }; + super::validate::signature_contents(doc).iter().any(|der| !der.is_empty()) + || doc.scan_objects().any(|(_, o)| o.as_dict().is_some_and(|d| d.contains(b"ByteRange") && signed(d))) +} + /// Sign `doc` with `id`; returns the signed file (an incremental update when possible). /// -/// The document's first signature removes the AcroForm's `/NeedAppearances` flag: Acrobat does -/// not recognise a signature while it is set. Later signatures leave the flag as it is. -/// A field that has a value but no appearance stream of its own then shows empty in viewers that -/// only drew it because of the flag. +/// The document's first signature (no [`has_signatures`]) removes the AcroForm's +/// `/NeedAppearances` flag: Acrobat does not recognise a signature while it is set. Later +/// signatures leave the flag as it is. This function draws no field appearances: once the flag is +/// gone, viewers show each field's stored normal appearance (`/AP /N`), so a filled field whose +/// stored appearance draws no text (an empty placeholder such as `/Tx BMC EMC`, or a look left +/// from an earlier value) shows that instead of its value. A caller that wants the values kept +/// draws the fields first, as `Session::sign` in the engine does. pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result, SignError> { sign_inner(doc, id, opts, None) } @@ -159,8 +175,7 @@ fn sign_inner( } check_signing_permissions(doc, opts)?; let mut doc = doc.clone(); - // A signature field whose /V holds an empty /Contents is not a signature yet. - let earlier_signatures = super::validate::signature_contents(&doc).iter().any(|der| !der.is_empty()); + let earlier_signatures = has_signatures(&doc); let root = doc.root().ok_or_else(|| SignError::Pdf("the document has no catalog".into()))?; let alg = id.key.preferred_digest(); let reserve = @@ -304,8 +319,9 @@ fn sign_inner( // Acrobat does not recognise a signature in a form that asks viewers to redraw its fields // (`/NeedAppearances true`): redrawing would change what the signature covers. The first // signature drops the flag; a later one leaves it, as removing it would count as a change - // to the earlier signatures. The cost: a field with a value but no appearance stream of its - // own, which viewers only drew because of the flag, now shows empty in them. + // to the earlier signatures. Without the flag viewers draw each field's stored `/AP /N`, so + // a filled field whose stored appearance draws no text (an empty placeholder, or a stale + // look) shows that instead of its value unless the caller drew the fields first (see `sign`). if !earlier_signatures { if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) { doc.update_dict(r, |af| { diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 0213b53441..b327c104ee 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -162,10 +162,28 @@ fn an_empty_signature_value_does_not_count_as_an_earlier_signature() { b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /Contents <> >>", ], ); + assert!(!pdfcraft_sign::has_signatures(&open(&bytes))); let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); assert!(form_of(&signed).get(b"NeedAppearances").is_none()); } +/// A signed field without a name (`/T` is optional) is an earlier signature too: signing another +/// field leaves `/NeedAppearances` as it is. +#[test] +fn an_unnamed_signed_field_counts_as_an_earlier_signature() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let bytes = fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] /NeedAppearances true >> >>", + &[ + b"<< /Type /Annot /Subtype /Widget /FT /Sig /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>", + b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /ByteRange [0 10 20 10] /Contents <3080> >>", + ], + ); + assert!(pdfcraft_sign::has_signatures(&open(&bytes))); + let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); + assert_eq!(form_of(&signed).get(b"NeedAppearances"), Some(&Object::Bool(true))); +} + /// A second signer leaves the form's flags alone: changing them would count as a change to /// what the first signature covered. #[test] From dcad5ff3b3061a574d41e0a2b8c07f1697e56689 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 05:21:52 +0100 Subject: [PATCH 6/6] fix(sign): zero-filled placeholders are unsigned; look inside appearance XObjects (#5939) - `has_signatures` treats a /Contents of only zero bytes as unsigned and finds signature dictionaries in one pass over the file (field /V values, plus /ByteRange dictionaries and document timestamps) instead of two. - Before the first signature, a field's own appearance counts as drawing text only if it or a form XObject it paints shows text; a background-only XObject no longer keeps an appearance that would leave the field blank. The look inside opens at most 16 streams; past that it counts as text, as before. - Tests: zero-filled placeholders (hex and literal), document timestamps, a widget without /AP, background-only and text-drawing XObject appearances with an unencodable character. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/session/save.rs | 37 ++++++++++++++++++++++++++++--- crates/engine/src/tests.rs | 21 +++++++++++++++--- crates/sign/src/pdf/signing.rs | 20 ++++++++++++----- crates/sign/src/pdf/validate.rs | 2 +- crates/sign/tests/it/pdf.rs | 30 +++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index c8fa0d703a..73c02fe87a 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -372,11 +372,42 @@ fn drawn_for_first_signature(cos: std::borrow::Cow<'_, pdfcraft_cos::Document>) std::borrow::Cow::Owned(drawn) } -/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through a -/// form XObject it paints (counted as text without looking inside). +/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through the +/// form XObjects it paints (a background-only one doesn't count). fn draws_text(cos: &pdfcraft_cos::Document, widget: &pdfcraft_cos::Dict) -> bool { let ap = widget.get(b"AP").map(|ap| cos.resolve(ap)); let normal = ap.as_deref().and_then(|ap| ap.as_dict()).and_then(|ap| ap.get(b"N")).map(|n| cos.resolve(n)); let Some(pdfcraft_cos::Object::Stream(s)) = normal.as_deref() else { return false }; - s.decoded().is_ok_and(|data| pdfcraft_content::ops(&data).any(|op| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"" | b"Do"))) + stream_draws_text(cos, s, &mut 16) +} + +/// Whether the form stream `s` shows text, following `Do` into its form XObjects. `budget` caps +/// the streams opened along the way; running out (deep nesting, a cycle, many XObjects) counts +/// as text, so the field keeps its own appearance as it would without the look inside. +fn stream_draws_text(cos: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, budget: &mut u8) -> bool { + let Ok(data) = s.decoded() else { return false }; + let resources = s.dict.get(b"Resources").map(|r| cos.resolve(r)); + let xobjects = resources.as_deref().and_then(|r| r.as_dict()).and_then(|r| r.get(b"XObject")).map(|x| cos.resolve(x)); + for op in pdfcraft_content::ops(&data) { + match op.op.as_slice() { + b"Tj" | b"TJ" | b"'" | b"\"" => return true, + b"Do" => { + let name = op.operands.first().and_then(|n| n.as_name()); + let x = name.and_then(|n| xobjects.as_deref().and_then(|x| x.as_dict()).and_then(|x| x.get(n))).map(|x| cos.resolve(x)); + let Some(pdfcraft_cos::Object::Stream(f)) = x.as_deref() else { continue }; + if f.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + if *budget == 0 { + return true; + } + *budget -= 1; + if stream_draws_text(cos, f, budget) { + return true; + } + } + _ => {} + } + } + false } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 55c57c2f29..b12e84419f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2701,14 +2701,17 @@ fn a_signed_file_carries_no_password_field_value() { /// A form with `/NeedAppearances true` whose filled fields have stored appearances that draw no /// text: `name` an empty placeholder, `town` one too with a value Helvetica can't hold (`ň`), -/// `city` such a value with its own appearance that draws it (#5939). +/// `city` such a value with its own appearance that draws it (#5939), `street` no appearance at +/// all, `river` a value Helvetica can't hold (`ř`) with an appearance that paints only a +/// background form XObject, and `lake` such a value drawn by a form XObject its appearance +/// paints. fn need_appearances_form() -> Vec { let stream = |content: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Length {} >>\nstream\n{content}\nendstream", content.len()); let objs = [ - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(), + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R 15 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), - "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 11 0 R 12 0 R 15 0 R] >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada Lovelace) /Rect [20 250 180 270] /P 3 0 R /AP << /N 8 0 R >> >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (town) /V /Rect [20 200 180 220] /P 3 0 R /AP << /N 9 0 R >> >>".into(), "<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V /Rect [20 150 180 170] /P 3 0 R /AP << /N 10 0 R >> >>".into(), @@ -2716,6 +2719,13 @@ fn need_appearances_form() -> Vec { stream("/Tx BMC EMC"), stream("/Tx BMC EMC"), stream("/Tx BMC BT /Helv 10 Tf 2 5 Td (Brno r) Tj ET EMC"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (street) /V (Main Street) /Rect [20 100 180 120] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (river) /V /Rect [20 50 180 70] /P 3 0 R /AP << /N 13 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Resources << /XObject << /Bg 14 0 R >> >> /Length 10 >>\nstream\nq /Bg Do Q\nendstream".into(), + stream("0.9 g 0 0 160 20 re f"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (lake) /V /Rect [20 20 180 40] /P 3 0 R /AP << /N 16 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 160 20] /Resources << /XObject << /Fm 17 0 R >> >> /Length 10 >>\nstream\nq /Fm Do Q\nendstream".into(), + stream("BT /Helv 10 Tf 2 5 Td (Orech) Tj ET"), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -2757,9 +2767,14 @@ fn the_first_signature_draws_the_fields_need_appearances_left_to_the_viewer() { assert!(normal_appearance(&doc, 4).contains("(Ada Lovelace) Tj"), "{}", normal_appearance(&doc, 4)); assert!(normal_appearance(&doc, 5).contains("(Plze?) Tj"), "{}", normal_appearance(&doc, 5)); assert!(normal_appearance(&doc, 6).contains("(Brno r) Tj"), "{}", normal_appearance(&doc, 6)); + assert!(normal_appearance(&doc, 11).contains("(Main Street) Tj"), "{}", normal_appearance(&doc, 11)); + // Its own appearance only paints a background, so `river` gets the drawing, `?` and all. + assert!(normal_appearance(&doc, 12).contains("(O?ech) Tj"), "{}", normal_appearance(&doc, 12)); let city = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(6, 0)).as_dict().unwrap().get(b"AP").cloned(); let original = pdfcraft_cos::Document::open(Arc::new(need_appearances_form())).unwrap(); assert_eq!(city(&doc), city(&original), "city keeps its own appearance"); + let lake = |d: &pdfcraft_cos::Document| d.get(pdfcraft_cos::ObjRef::new(15, 0)).as_dict().unwrap().get(b"AP").cloned(); + assert_eq!(lake(&doc), lake(&original), "lake keeps the appearance its form XObject draws"); s.mark_signed(id, signed, None).unwrap(); s.set_trusted_certificates(vec![digital_id.certificate.clone()]); let sig = &s.get(id).unwrap().signatures[0]; diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index efb5960ee5..f331ee8228 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -120,16 +120,24 @@ fn check_signing_permissions(doc: &Document, opts: &SignOptions) -> Result<(), S const BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333]; /// Whether `doc` already holds a signature or a document timestamp: a signature dictionary whose -/// `/Contents` is not empty (a field whose `/V` has an empty `/Contents` is not signed yet). A -/// signature field without a name (`/T`) counts too: indirect signature dictionaries are found by -/// their `/ByteRange`, not through the field tree. +/// `/Contents` holds a nonzero byte (a field whose `/V` has an empty or zero-filled `/Contents` +/// is not signed yet). A signature field without a name (`/T`) counts too: indirect signature +/// dictionaries and document timestamps are found in one pass over the file (by `/ByteRange`, +/// or as timestamps), not through the field tree. pub fn has_signatures(doc: &Document) -> bool { let signed = |d: &Dict| { let contents = d.get(b"Contents").map(|c| doc.resolve(c)); - contents.as_deref().and_then(Object::as_string).is_some_and(|s| !s.bytes.is_empty()) + // Hex strings decode to the DER when parsed; a literal string carries hex text. + let der = contents + .as_deref() + .and_then(Object::as_string) + .and_then(|s| if s.hex { Some(s.bytes.clone()) } else { super::validate::unhex(&s.bytes) }); + der.is_some_and(|der| der.iter().any(|b| *b != 0)) }; - super::validate::signature_contents(doc).iter().any(|der| !der.is_empty()) - || doc.scan_objects().any(|(_, o)| o.as_dict().is_some_and(|d| d.contains(b"ByteRange") && signed(d))) + sig_fields(doc).iter().any(|f| f.dict.get(b"V").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).is_some_and(signed)) + || doc + .scan_objects() + .any(|(_, o)| o.as_dict().is_some_and(|d| (d.contains(b"ByteRange") || super::validate::is_doc_timestamp(d)) && signed(d))) } /// Sign `doc` with `id`; returns the signed file (an incremental update when possible). diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 08b482c4b4..09a4e5264c 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -26,7 +26,7 @@ pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) - list(doc, bytes, trust).into_iter().find(|s| s.field == field) } -fn unhex(s: &[u8]) -> Option> { +pub(super) fn unhex(s: &[u8]) -> Option> { let digits: Vec = s.iter().copied().filter(|c| !c.is_ascii_whitespace()).collect(); let val = |c: u8| (c as char).to_digit(16).map(|d| d as u8); digits.chunks(2).map(|p| Some(val(p[0])? << 4 | p.get(1).map_or(Some(0), |c| val(*c))?)).collect() diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index b327c104ee..de55fdca6c 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -167,6 +167,36 @@ fn an_empty_signature_value_does_not_count_as_an_earlier_signature() { assert!(form_of(&signed).get(b"NeedAppearances").is_none()); } +/// A signature dictionary whose /Contents is still the zero-filled placeholder (hex or literal +/// hex text, with a /ByteRange) is not signed either: the first real signature drops the flag. +#[test] +fn a_zero_filled_signature_placeholder_does_not_count_as_an_earlier_signature() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + for contents in [&b"<0000000000000000>"[..], b"(00000000)"] { + let sig = [&b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached /ByteRange [0 10 20 10] /Contents "[..], contents, b" >>"] + .concat(); + let bytes = fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] /NeedAppearances true >> >>", + &[b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Pending) /Rect [10 20 100 70] /P 3 0 R /F 4 /V 8 0 R >>", &sig], + ); + assert!(!pdfcraft_sign::has_signatures(&open(&bytes)), "{}", String::from_utf8_lossy(contents)); + let signed = pdfcraft_sign::sign(&open(&bytes), &id, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap(); + assert!(form_of(&signed).get(b"NeedAppearances").is_none()); + assert!(pdfcraft_sign::has_signatures(&open(&signed))); + } +} + +/// A document timestamp outside the field tree counts by its /Contents alone, even without a +/// /ByteRange; a zero-filled one does not. +#[test] +fn a_document_timestamp_counts_unless_its_contents_are_zero_filled() { + for (contents, stamped) in [(&b"<3080>"[..], true), (b"<0000>", false)] { + let ts = [&b"<< /Type /DocTimeStamp /SubFilter /ETSI.RFC3161 /Contents "[..], contents, b" >>"].concat(); + let bytes = fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true >> >>", &[&ts]); + assert_eq!(pdfcraft_sign::has_signatures(&open(&bytes)), stamped, "{}", String::from_utf8_lossy(contents)); + } +} + /// A signed field without a name (`/T` is optional) is an earlier signature too: signing another /// field leaves `/NeedAppearances` as it is. #[test]