fix(sign): render Unicode characters in visible signatures using bundled Inter font

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
This commit is contained in:
PINYO PATTANAWASANPORN
2026-10-13 13:15:21 +07:00
parent 685fd7eeea
commit bc6cfbd6cb
3 changed files with 116 additions and 27 deletions
+20
View File
@@ -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);
+62 -26
View File
@@ -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<Stream, SignError> {
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<String> = 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<String> { if needs_unicode { wrap_with(s, sz, max_w, inter_width) } else { wrap(s, sz, max_w) } };
let encode_line =
|l: &str| -> Vec<u8> { 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<String> = lines.iter().flat_map(|l| wrap(l, size, right_w)).collect();
let all: Vec<String> = 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 {
+34 -1
View File
@@ -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<u8> {
@@ -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,