mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user