From bc6cfbd6cbbf5c2eb101e4cdb1dd365771b0208f Mon Sep 17 00:00:00 2001 From: PINYO PATTANAWASANPORN Date: Tue, 13 Oct 2026 13:15:21 +0700 Subject: [PATCH] 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,