Merge PR #1679: fix(sign): render Unicode characters in visible signatures using bundled Inter font

Conflicts:
- crates/sign/src/pdf.rs: details switch to Inter as the PR does; drawn labels keep main's
  Helvetica check and refusal, so the PR's label-driven switch to Inter is dropped. Doc comment
  merged.
- crates/sign/tests/pdf.rs: both tests kept.
This commit is contained in:
aiongg committed 2026-10-22 01:46:01 +00:00
commit e42ccbb440
3 files changed
+111 -27

No files matched your search

+20
View File
@@ -51,6 +51,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,
@@ -313,6 +325,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);
+57 -26
View File
@@ -2063,17 +2063,19 @@ 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.
/// A label that is drawn ([`AppearanceLabels`], the caller's words) is refused the same way.
/// the details on the right (Acrobat's standard layout), in Helvetica, or in the bundled Inter
/// font when a detail needs characters beyond Western European. A graphic with no details beside
/// it fills the box. An error, before anything is drawn, when a detail it writes (the signer's
/// name, the certificate's subject, the reason or the location) has a character neither font can
/// draw: signing invisibly, or leaving that part out, still works. A graphic doesn't change this:
/// the name it replaces on the left is still among the details. A label that is drawn
/// ([`AppearanceLabels`], the caller's words) must be drawable in Helvetica, and is refused otherwise.
fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result<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 mut needs_unicode = false;
if w > 1.0 && h > 1.0 {
let shown = [
(a.name, "the signer's name", Some(name)),
@@ -2082,11 +2084,22 @@ 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;
}
}
// The labels are the caller's words (a translation), checked as the details are: only
@@ -2128,6 +2141,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) {
@@ -2146,14 +2184,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;
}
@@ -2161,7 +2199,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;
}
@@ -2169,8 +2207,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;
}
@@ -2180,13 +2218,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> {
@@ -1950,6 +1951,38 @@ fn appearance_labels_helvetica_cannot_draw_are_refused() {
assert!(!content.contains("Reason") && !content.contains("Date:") && !content.contains('?'), "{content}");
}
/// 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,