From bc6cfbd6cbbf5c2eb101e4cdb1dd365771b0208f Mon Sep 17 00:00:00 2001 From: PINYO PATTANAWASANPORN Date: Tue, 13 Oct 2026 13:15:21 +0700 Subject: [PATCH 01/21] fix(sign): render Unicode characters in visible signatures using bundled Inter font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables visible signature appearances in pdfcraft_sign to render Unicode characters (specifically Latin Extended-A / Central European characters such as č, ć, ž, š, đ) by embedding the bundled Inter-Regular.ttf font with /Identity-H encoding and a /ToUnicode CMap when the signer name, labels, reason, or location contain non-WinAnsi text. When all text is within WinAnsiEncoding, the existing zero-byte overhead behavior (using standard /Helv) is preserved. Closes #2207 --- crates/fonts/src/lib.rs | 20 +++++++++ crates/sign/src/pdf.rs | 88 ++++++++++++++++++++++++++++------------ crates/sign/tests/pdf.rs | 35 +++++++++++++++- 3 files changed, 116 insertions(+), 27 deletions(-) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 622c609f6..e1af92d05 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -47,6 +47,18 @@ pub fn cjk_width(s: &str, size: f64) -> f64 { .sum() } +/// Approximate advance of `s` in Inter Regular at `size` points. +pub fn inter_width(s: &str, size: f64) -> f64 { + use skrifa::MetadataProvider as _; + let Ok(font) = skrifa::FontRef::new(INTER_REGULAR) else { return helvetica_width(s, size) }; + let loc = skrifa::instance::LocationRef::default(); + let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em.max(1) as f64; + let scale = size / upem; + let charmap = font.charmap(); + let advances = font.glyph_metrics(skrifa::instance::Size::unscaled(), loc); + s.chars().map(|c| charmap.map(c).and_then(|gid| advances.advance_width(gid)).unwrap_or(0.0) as f64 * scale).sum() +} + /// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block). fn is_wide(c: char) -> bool { matches!(c, @@ -288,6 +300,14 @@ mod tests { assert_eq!(undrawn_mark(&d), None); } + #[test] + fn inter_width_measures_latin_extended_and_ascii() { + assert!(inter_width("Luka Petravić", 12.0) > 0.0); + assert_eq!(inter_width("", 12.0), 0.0); + let w = inter_width("čžšć", 10.0); + assert!(w > 10.0 && w < 100.0); + } + #[test] fn widths_wrap_and_encode() { assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0); diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 012cd43a6..b5808cabf 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1943,16 +1943,18 @@ pub fn display_date(pdf: &str) -> String { } /// The visible signature: the signer's name large on the left, or the graphic in its place, and -/// the details on the right (Acrobat's standard layout), in Helvetica. A graphic with no details -/// beside it fills the box. An error, before anything is drawn, when a detail it writes in -/// Helvetica (the signer's name, the certificate's subject, the reason or the location) has a -/// character Helvetica can't 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. +/// the details on the right (Acrobat's standard layout), in Helvetica or the bundled Inter font +/// when non-Western-European characters are needed. A graphic with no details beside it fills the +/// box. An error, before anything is drawn, when a detail it writes has a character the fonts +/// can't 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. fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result { - use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, undrawable_message, win_ansi, wrap}; + 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 a = &opts.appearance; let dn = cert.subject.display(); + let wording = &opts.appearance_labels; + let mut needs_unicode = false; if w > 1.0 && h > 1.0 { let shown = [ (a.name, "the signer's name", Some(name)), @@ -1961,18 +1963,34 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate (a.location, "the location", opts.location.as_deref()), ]; for (on, what, text) in shown { - if on && let Some(c) = text.and_then(first_undrawable) { - return Err(SignError::Unsupported(format!( - "{}; sign without a visible appearance, or leave {what} out of it", - undrawable_message(&format!("{what} of the visible signature"), c) - ))); + if on + && let Some(s) = text + && first_undrawable(s).is_some() + { + for c in s.chars() { + if matches!(c, '\n' | '\r') { + continue; + } + if !pdfcraft_fonts::embedding::covers(pdfcraft_fonts::INTER_REGULAR, &c.to_string()) { + return Err(SignError::Unsupported(format!( + "{}; sign without a visible appearance, or leave {what} out of it", + undrawable_message(&format!("{what} of the visible signature"), c) + ))); + } + } + needs_unicode = true; + } + } + if !needs_unicode { + let label_texts = [&wording.signed_by, &wording.distinguished_name, &wording.reason, &wording.location, &wording.date]; + if label_texts.iter().any(|l| first_undrawable(l).is_some()) { + needs_unicode = true; } } } let mut lines: Vec = Vec::new(); let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() }; // The labels are the caller's words, so a signature signed in another language reads in it. - let wording = &opts.appearance_labels; if a.name { lines.push(if a.labels { wording.signed_by.clone() } else { String::new() }); lines.push(name.to_string()); @@ -1996,6 +2014,31 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate lines.retain(|l| !l.is_empty()); let mut out = Vec::new(); let mut image = None; + let mut fonts = Dict::new(); + let (font_tag, metrics) = if needs_unicode { + let tag = pdfcraft_fonts::embedding::embed(doc, &mut fonts, pdfcraft_fonts::INTER_REGULAR, "Inter") + .map_err(|e| SignError::Pdf(format!("failed to embed signature font: {e}")))?; + let font_dict = fonts + .get(tag.as_bytes()) + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_dict().cloned()) + .ok_or_else(|| SignError::Pdf("embedded signature font dictionary not found".into()))?; + let m = pdfcraft_fonts::pdf::Metrics::from_dict(doc, &font_dict); + (tag, Some(m)) + } else { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(b"Helv".to_vec(), Object::Dict(font)); + ("Helv".to_string(), None) + }; + let measure = |s: &str, sz: f64| -> f64 { if needs_unicode { inter_width(s, sz) } else { helvetica_width(s, sz) } }; + let wrap_text = + |s: &str, sz: f64, max_w: f64| -> Vec { if needs_unicode { wrap_with(s, sz, max_w, inter_width) } else { wrap(s, sz, max_w) } }; + let encode_line = + |l: &str| -> Vec { if let Some(m) = &metrics { literal(&m.encode(l).unwrap_or_else(|| win_ansi(l))) } else { literal(&win_ansi(l)) } }; if w > 1.0 && h > 1.0 { let pad = (h * 0.06).clamp(1.0, 6.0); let (left_w, right_x) = match (&opts.graphic, a.name) { @@ -2014,14 +2057,14 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate // The name fills the left half. let mut size = (h * 0.4).min(36.0); let fit = (left_w - 2.0 * pad).max(1.0); - let longest = helvetica_width(name, 1.0).max(0.01); + let longest = measure(name, 1.0).max(0.01); size = size.min(fit / longest).max(4.0); - let words = wrap(name, size, fit); + let words = wrap_text(name, size, fit); let total = words.len() as f64 * size * 1.1; let mut y = (h + total) / 2.0 - size * 0.85; for l in &words { - out.extend(format!("/Helv {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(pad), fmt(y)).bytes()); - out.extend(literal(&win_ansi(l))); + out.extend(format!("/{font_tag} {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(pad), fmt(y)).bytes()); + out.extend(encode_line(l)); out.extend_from_slice(b" Tj\n"); y -= size * 1.1; } @@ -2029,7 +2072,7 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate // The details, shrunk to fit the right side. let mut size = (h / (lines.len().max(1) as f64 * 1.15)).min(12.0); let wrapped = loop { - let all: Vec = lines.iter().flat_map(|l| wrap(l, size, right_w)).collect(); + let all: Vec = lines.iter().flat_map(|l| wrap_text(l, size, right_w)).collect(); if all.len() as f64 * size * 1.15 <= h - 2.0 * pad || size <= 3.0 { break all; } @@ -2037,8 +2080,8 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate }; let mut y = h - pad - size * 0.9; for l in &wrapped { - out.extend(format!("/Helv {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(right_x), fmt(y)).bytes()); - out.extend(literal(&win_ansi(l))); + out.extend(format!("/{font_tag} {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(right_x), fmt(y)).bytes()); + out.extend(encode_line(l)); out.extend_from_slice(b" Tj\n"); y -= size * 1.15; } @@ -2048,13 +2091,6 @@ 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())); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); 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/pdf.rs b/crates/sign/tests/pdf.rs index 609926f3b..8cbebf37a 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -4,7 +4,8 @@ use std::sync::Arc; use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental}; use pdfcraft_sign::{ - Detail, Modification, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, in_english, kind, pkcs12, signatures, + Certificate, Detail, DigitalId, Modification, Name, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, + in_english, kind, pkcs12, signatures, }; fn data(name: &str) -> Vec { @@ -1815,6 +1816,38 @@ fn visible_appearance_text_helvetica_cannot_draw_is_refused() { assert_eq!(s.reason.as_deref(), Some("同意此文件")); } +/// A visible signature with Central European characters (č, ć, ž, š) embeds the bundled +/// Inter font and draws them cleanly instead of refusing or writing `?`. +#[test] +fn visible_appearance_draws_central_european_characters_with_embedded_font() { + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let name = Name::build("Luka Petravić", "Oddelek", "Podjetje d.o.o.", "luka@example.com", "SI"); + let cert = Certificate::self_signed(&name, &id.key, Time::from_unix(1_700_000_000), 5, &[1]).unwrap(); + let luka_id = DigitalId { key: id.key, certificate: cert, chain: Vec::new(), friendly_name: None }; + let shown = + pdfcraft_sign::Appearance { name: true, distinguished_name: true, reason: true, location: true, ..pdfcraft_sign::Appearance::default() }; + let sl = SignOptions { + rect: Some([150.0, 20.0, 280.0, 70.0]), + reason: Some("Podpisano s potrdilom".into()), + location: Some("Ljubljana, Slovenija".into()), + appearance: shown, + ..opts() + }; + let signed = pdfcraft_sign::sign(&open(&fixture()), &luka_id, &sl).unwrap(); + let (content, res) = appearance_of(&signed, "Signature1"); + // Verify that the appearance stream does not write `?` in place of non-WinAnsi characters. + assert!(!content.contains('?'), "appearance content should not write '?' for Central European characters: {content}"); + // Verify an embedded TrueType font was added to the appearance resources. + let fonts = res.get(b"Font").and_then(|o| o.as_dict()).unwrap(); + assert!(fonts.contains(b"PCFull0"), "appearance should reference the embedded font: {fonts:?}"); + // Verify signature validation still passes. + let doc = open(&signed); + let trust = TrustStore { certs: vec![luka_id.certificate.clone()] }; + let s = signatures(&doc, &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert_eq!(s.status, Status::Valid); + assert_eq!(s.reason.as_deref(), Some("Podpisano s potrdilom")); +} + /// Every sentence this crate says about a signature is listed in `WORDINGS`, and every sentence on /// that list is one it still says: the details are written as `Detail::plain`/`Detail::of`, the /// sentences the modules that read a signature word for themselves are pushed as notes and quirks, From f536ac7a056e8233162d0147ef8278a105176e8b Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Thu, 15 Oct 2026 19:27:45 +0800 Subject: [PATCH 02/21] fix(sign): fill typed signature outlines with non-zero winding (#1786) --- crates/annot/src/appearance.rs | 2 +- crates/annot/src/lib.rs | 2 +- crates/annot/src/tests.rs | 13 ++++++++++ crates/sign/src/graphic.rs | 8 +++--- crates/sign/tests/pdf.rs | 2 +- crates/ui-egui/src/fill_sign.rs | 46 +++++++++++++++++++++++++++------ 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 0f0706428..c692d09e3 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -559,7 +559,7 @@ pub fn build(d: &Dict) -> Option { c.push_str("h\n"); } } - c.push_str("f*\n"); + c.push_str("f\n"); return Some(form(rect, c.as_bytes(), res)); } // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f4973aa02..0c9eff742 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -386,7 +386,7 @@ pub enum Shape { by: Option, }, /// A typed signature or initials: filled outlines (each contour as points normalised to - /// `rect`, 0–1, y up), drawn with the even-odd rule. + /// `rect`, 0–1, y up), drawn with the non-zero winding rule. TypedSignature { rect: [f64; 4], contours: Vec>, diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index faed409ab..0ccfc6a73 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1308,3 +1308,16 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() { assert_eq!(props(&doc, 0, i).unwrap().font_size, None); } } + +/// #1786: TrueType glyph contours (such as Inter's composite Ђ and ћ) overlap with the same +/// winding direction where strokes cross, so typed signature appearances must fill with the +/// non-zero winding operator (`f`), not even-odd (`f*`). +#[test] +fn typed_signature_uses_nonzero_winding_fill() { + let mut doc = fixture(); + let contours = vec![vec![[0.4, 0.0], [0.6, 0.0], [0.6, 1.0], [0.4, 1.0]], vec![[0.0, 0.7], [1.0, 0.7], [1.0, 0.85], [0.0, 0.85]]]; + let idx = add_annotation(&mut doc, &new(0, Shape::TypedSignature { rect: [100.0, 100.0, 200.0, 140.0], contours }), &meta("sig")).unwrap(); + let ap = ap_content(&doc, &list(&doc, 0)[idx]); + assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); + assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); +} diff --git a/crates/sign/src/graphic.rs b/crates/sign/src/graphic.rs index 5d50c6f76..7b1535043 100644 --- a/crates/sign/src/graphic.rs +++ b/crates/sign/src/graphic.rs @@ -1,7 +1,7 @@ //! A picture in a visible signature, where the signer's name would be large (Acrobat: Configure //! Signature Appearance ▸ Draw or Image, and typed signatures). Drawn as Fill & Sign draws its -//! signatures: ink smoothed and stroked with round ends, typed outlines filled even-odd, images -//! with their alpha as a soft mask. +//! signatures: ink smoothed and stroked with round ends, typed outlines filled with the non-zero +//! winding rule, images with their alpha as a soft mask. use std::sync::Arc; @@ -21,7 +21,7 @@ const MAX_PIXELS: u64 = 1 << 24; pub enum Graphic { /// Drawn strokes, y up, in units where the drawing pad is 1 wide (as Fill & Sign saves them). Strokes(Vec>), - /// Closed outlines, y up, filled even-odd: a typed signature in the script font. + /// Closed outlines, y up, filled with the non-zero winding rule: a typed signature in the script font. Outlines(Vec>), /// Straight (not premultiplied) RGBA pixels, rows from the top. Image { width: u32, height: u32, rgba: Arc> }, @@ -141,7 +141,7 @@ impl Graphic { } c.push_str("h\n"); } - c.push_str("f*\nQ\n"); + c.push_str("f\nQ\n"); Ok((c, None)) } Graphic::Image { width, height, rgba } => { diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index 908a5c0fe..f7d873daf 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -290,7 +290,7 @@ fn visible_signatures_show_a_drawn_typed_or_image_graphic() { let typed = Graphic::Outlines(vec![vec![[0.0, 0.0], [1.0, 0.0], [1.0, 0.5], [0.0, 0.5]], vec![[0.2, 0.1], [0.8, 0.1], [0.5, 0.4]]]); for (graphic, drawn) in [ (strokes, " c\n"), - (typed, "f*\n"), + (typed, "f\n"), // 40 x 10 in the left half (94 x 44 inside the padding): 94 wide, centred vertically. (picture(true), "q 94 0 0 23.5 3 13.25 cm /Im1 Do Q"), ] { diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 794fe0bf1..cebb97d60 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -495,7 +495,8 @@ pub(crate) fn script_preview(text: &str, w: usize, h: usize) -> egui::ColorImage } let k = ((h as f64 * 0.9) / span).min((w as f64 * 0.95) / width); let x0 = (w as f64 - width * k) / 2.0; - // Device points (y down), then an even-odd scanline fill. + // Device points (y down), then a non-zero winding scanline fill so overlapping same-direction + // TrueType contours (such as Inter's composite Ђ and ћ) stay filled where strokes cross. let polys: Vec> = o .contours .iter() @@ -503,20 +504,29 @@ pub(crate) fn script_preview(text: &str, w: usize, h: usize) -> egui::ColorImage .collect(); for y in 0..h { let sy = y as f64 + 0.5; - let mut xs: Vec = Vec::new(); + let mut xs: Vec<(f64, i32)> = Vec::new(); for poly in &polys { for i in 0..poly.len() { let (a, b) = (poly[i], poly[(i + 1) % poly.len()]); if (a.1 <= sy) != (b.1 <= sy) { - xs.push(a.0 + (sy - a.1) / (b.1 - a.1) * (b.0 - a.0)); + let dir = if a.1 < b.1 { 1 } else { -1 }; + xs.push((a.0 + (sy - a.1) / (b.1 - a.1) * (b.0 - a.0), dir)); } } } - xs.sort_by(|a, b| a.total_cmp(b)); - for pair in xs.as_chunks::<2>().0 { - let (from, to) = (pair[0].round().clamp(0.0, w as f64) as usize, pair[1].round().clamp(0.0, w as f64) as usize); - for x in from..to { - img[(x, y)] = Color32::BLACK; + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut winding = 0i32; + let mut start = 0.0; + for (x, dir) in xs { + if winding == 0 { + start = x; + } + winding += dir; + if winding == 0 { + let (from, to) = (start.round().clamp(0.0, w as f64) as usize, x.round().clamp(0.0, w as f64) as usize); + for px in from..to { + img[(px, y)] = Color32::BLACK; + } } } } @@ -1270,4 +1280,24 @@ mod tests { } } } + + #[test] + fn overlapping_glyph_contours_fill_without_holes_while_keeping_counters() { + let runs = + |img: &egui::ColorImage, y: usize| (0..img.width()).filter(|&x| img[(x, y)].a() > 0 && (x == 0 || img[(x - 1, y)].a() == 0)).count(); + // #1786: Inter's Ђ (T + right hook) and ћ (h + crossbar) have two same-direction contours + // that overlap on the stem; non-zero winding fills the join instead of punching a hole. + let dje = super::script_preview("Ђ", 200, 200); + assert_eq!(dje[(97, 108)], egui::Color32::BLACK, "stem-hook overlap of Ђ is filled"); + assert_eq!(runs(&dje, 108), 2, "stem and right loop of Ђ are two solid strokes"); + let tshe = super::script_preview("ћ", 200, 200); + assert_eq!(tshe[(93, 53)], egui::Color32::BLACK, "stem-crossbar overlap of ћ is filled"); + assert_eq!(runs(&tshe, 53), 1, "crossbar of ћ is one unbroken stroke across the stem"); + // Counter-clockwise inner contours remain open holes. + let omicron = super::script_preview("О", 200, 200); + assert_eq!(omicron[(109, 94)].a(), 0, "counter of О stays transparent"); + assert_eq!(runs(&omicron, 94), 2); + assert!(super::script_preview("", 200, 200).pixels.iter().all(|p| p.a() == 0)); + assert!(super::script_preview("Ђћ", 0, 0).pixels.is_empty()); + } } From 4a35a1cb235c51babb10a946540de2affbccf56f Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:21:28 +0800 Subject: [PATCH 03/21] M9: rewriting a certified form's XFA data is a form fill (#2759) The IRCC IMM forms are certified (DocMDP P=2) and then saved once more with Reader Extensions: a later revision adds the UR3 usage-rights signature and rewrites the XFA `datasets` packet. The diff classified any rewritten stream that wasn't page content or the catalog's XMP as "other changes", so these untouched forms validated as invalid. A stream the signed revision's /AcroForm /XFA array names `datasets` or `form` (the form's data and state) is now a form fill when rewritten, permitted at P=2 and P=3 and refused at P=1 as before. The roles come from the signed revision, so a later revision that names the template "datasets" doesn't make rewriting it a form fill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- crates/sign/src/pdf.rs | 23 +++++++++++++ crates/sign/tests/pdf.rs | 63 ++++++++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 4 +-- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 78f2ad55d..1c07eb4b2 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1200,6 +1200,8 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod .collect(); // The signed revision's document-level XMP stream. let old_metadata = old.root().and_then(|root| old.get(root).as_dict().and_then(|c| c.get(b"Metadata").and_then(Object::as_ref))); + // The signed revision's XFA data packets: filling the form rewrites them. + let old_xfa_data = xfa_data_packets(&old); // What each object was in the signed revision: its pages, and its trailer /Info. A changed // object is judged by the role it held when signed, never by the labels or keys its new, // attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its @@ -1295,6 +1297,12 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod // else, even one it already had when signed (a page's Form XObject: the // renderer ignores /Type), does not make rewriting it a metadata change. kind::METADATA + } else if old_xfa_data.contains(&r) { + // The form's XFA data (`datasets`) or state (`form`) as the signed revision + // named it: filling an XFA form rewrites these, as a Reader Extensions (UR3) + // save of a certified form does (#2759). The role comes from the signed + // revision, so a later one can't name page content as XFA data. + kind::FORM_FILL } else { kind::OTHER } @@ -1318,6 +1326,21 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) } } +/// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form` +/// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one +/// XFA stream holds its template too, and is not listed. +fn xfa_data_packets(doc: &Document) -> HashSet { + let Some(form) = acroform(doc) else { return HashSet::new() }; + let Some(xfa) = form.get(b"XFA").map(|x| doc.resolve(x)) else { return HashSet::new() }; + let Object::Array(xfa) = &*xfa else { return HashSet::new() }; + xfa.as_chunks::<2>() + .0 + .iter() + .filter(|[name, _]| doc.resolve(name).as_string().is_some_and(|s| matches!(s.to_text().as_str(), "datasets" | "form"))) + .filter_map(|[_, packet]| packet.as_ref()) + .collect() +} + /// Where an object sits in the Document Security Store (ISO 32000-2 §12.8.4.3). #[derive(Clone, Copy, PartialEq, Eq)] enum DssRole { diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index 908a5c0fe..75d2b5505 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -13,14 +13,20 @@ fn data(name: &str) -> Vec { /// One page with text and an unsigned signature field "Approval". fn fixture() -> Vec { - let objs: Vec<&[u8]> = vec![ - b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[]) +} + +/// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on. +fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec { + let mut objs: Vec<&[u8]> = vec![ + catalog, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", b"<< /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] >>", b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", 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); let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -749,6 +755,59 @@ fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() { } } +/// A form whose AcroForm carries its XFA as packets: template (7), datasets (8), form (9). +fn xfa_fixture() -> Vec { + fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /XFA [(template) 7 0 R (datasets) 8 0 R (form) 9 0 R] >> >>", + &[ + b"<< /Length 11 >>\nstream\n