Files
pdfcraft/crates/fonts/src/lib.rs
T
Tanish MankuandClaude d9607ec3be F3: one documented substitution chain, and report what it chose and why
A font the document does not embed was replaced by whatever the caller
happened to pick, and nothing told the user. `doc_info` said only
`embedded: false`, which is why "my Chinese PDF shows nothing" has been
a mystery rather than a sentence.

`crates/fonts/src/fallback.rs` applies one chain, in a documented order,
and returns what it chose with a reason:

1. embedded -> nothing substituted;
2. /BaseFont names a standard-14 face or a known alias (exact metrics);
3. craft-fonts holds that exact family;
4. /CIDSystemInfo /Ordering names a script we have a face for;
5. the ordering names a script we have no licensed face for -- report
   none, so nothing is drawn rather than the wrong glyphs;
6. otherwise the nearest of the fourteen by descriptor flags.

It delegates to the existing per-script selectors instead of restating
their order, so each keeps one source of truth. It answers the
dictionary question only; "which face has a glyph for this character?"
depends on the string being drawn and stays where the drawing happens.

`doc_info` gains `substitute` and `substitution` per font. Both null
means the document's own glyphs are shown; a null `substitute` with a
reason means no face was available at all. That is Acrobat's Actual
Font, minus the interface, which `parity/` now records as still missing.

Two findings on the way:

`Std14::from_base_font` matches loosely, so "SomeHouseSerif" resolved to
Times by its name containing "serif". The face is a fair guess but
reporting it as "names one of the standard 14" would have been untrue of
a font nobody has heard of. `Std14::recognise` now says whether a name
matched an alias or only a family hint, and the two report differently.
`from_base_font` is unchanged.

The generated table moved to `crates/fonts/src/std14_metrics.rs`, data
alone, and the ATTRIBUTION entry and AGENTS.md 1.1 line follow it. F1
put the hash on a file holding both the table and the code reading it,
so this commit's ordinary edits tripped the gate -- and a hash that
churns on unrelated changes gets re-attested reflexively, which is the
protection gone. Verified both directions after the split: editing
std14.rs passes the gate, changing one width in std14_metrics.rs fails
it.

Every new test was watched failing first: removing the script step
breaks the two unit tests and the doc_info integration test.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-13 16:42:10 -07:00

351 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! pdfcraft-fonts — font metrics and encodings for generated appearances (L2).
//!
//! Standard-14 advance widths are exact (`Std14`, ISO 32000-2 Annex D). What remains approximate
//! is everything that needs a font *program*: this crate reads font dictionaries, never embedded
//! programs, so it has no glyph outlines for document fonts and cannot subset or re-embed. That
//! is phase F 4.2 in docs/plan/execution-plan.md.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod craft;
pub mod embedding;
mod encodings;
pub mod fallback;
pub mod pdf;
mod script;
mod shaping;
pub mod source_shaping;
mod std14;
mod std14_metrics;
pub use craft::{
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style,
document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts,
ui_telugu_fonts, ui_thai_fonts,
};
pub use script::{
GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline,
};
pub use shaping::{ShapedCluster, ShapedScript, arabic_glyph, arabic_has, shape_arabic, shape_thai, shaped_glyph, thai_has};
pub use std14::Std14;
/// Advance of `s` in Helvetica at `size` points, using the exact standard-14 metrics.
///
/// Kept as a free function because generated appearances overwhelmingly use Helvetica; reach for
/// [`Std14`] directly for any other face, and never for a scaled approximation of this one.
pub fn helvetica_width(s: &str, size: f64) -> f64 {
Std14::Helvetica.text_width(s, size)
}
/// Approximate advance of `s` at `size` points in a CJK font: full-width characters take one
/// em, half-width forms half of one, and everything else is measured as Helvetica.
pub fn cjk_width(s: &str, size: f64) -> f64 {
let mut buf = [0u8; 4];
s.chars()
.map(|c| match c {
'\u{ff61}'..='\u{ffdc}' | '\u{ffe8}'..='\u{ffee}' => size * 0.5,
_ if is_wide(c) => size,
_ => helvetica_width(c.encode_utf8(&mut buf), size),
})
.sum()
}
/// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block).
fn is_wide(c: char) -> bool {
matches!(c,
'\u{1100}'..='\u{115f}' // Hangul Jamo initials
| '\u{2e80}'..='\u{303e}' // CJK radicals, Kangxi, ideographic description, CJK symbols
| '\u{3041}'..='\u{33ff}' // kana, Bopomofo, Hangul compatibility, Kanbun, CJK compatibility
| '\u{3400}'..='\u{4dbf}' // CJK extension A
| '\u{4e00}'..='\u{9fff}' // CJK unified ideographs
| '\u{a000}'..='\u{a4cf}' // Yi
| '\u{ac00}'..='\u{d7a3}' // Hangul syllables
| '\u{f900}'..='\u{faff}' // CJK compatibility ideographs
| '\u{fe30}'..='\u{fe4f}' // CJK compatibility forms
| '\u{ff00}'..='\u{ff60}' // full-width forms
| '\u{ffe0}'..='\u{ffe6}'
| '\u{20000}'..='\u{3fffd}' // supplementary ideographic planes
)
}
/// Greedy line breaking within `width` points (paragraphs split on newlines; words longer
/// than a line are broken by character).
pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
wrap_with(text, size, width, helvetica_width)
}
/// [`wrap`], measuring with `measure` (for example [`cjk_width`]).
pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec<String> {
let mut lines = Vec::new();
for para in text.split(['\n', '\r']) {
let mut line = String::new();
for word in para.split(' ') {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width {
line = candidate;
continue;
}
if !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
for ch in word.chars() {
if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width {
lines.push(std::mem::take(&mut line));
}
line.push(ch);
}
}
lines.push(line);
}
lines
}
/// The WinAnsiEncoding (ISO 32000-2 Annex D) code for `c`, if the encoding has one.
///
/// Codes 0x80–0x9F come from the same table the text extractor decodes with, so encoding and
/// decoding agree (Š š Ž ž Œ œ Ÿ ƒ † ‡ ˆ ˜ ‰ ‹ › as well as quotes, dashes, € and ™).
pub fn win_ansi_byte(c: char) -> Option<u8> {
match c {
'\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' => u8::try_from(u32::from(c)).ok(),
_ => {
let high = encodings::WIN_ANSI.get(0x80..0xa0)?;
let i = high.iter().position(|&u| u != 0 && u == u32::from(c))?;
u8::try_from(0x80 + i).ok()
}
}
}
/// Encode text in WinAnsiEncoding (ISO 32000-2 Annex D); tabs become spaces and unmappable
/// characters become `?`.
pub fn win_ansi(s: &str) -> Vec<u8> {
s.chars().map(|c| if c == '\t' { b' ' } else { win_ansi_byte(c).unwrap_or(b'?') }).collect()
}
/// The character encoding of a predefined Unicode CMap (`UniJIS-UTF16-H`, `UniGB-UCS2-H`, …;
/// ISO 32000-2 §9.7.5.2), which maps Unicode text straight to codes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UnicodeCMap {
Ucs2,
Utf16,
Utf8,
Utf32,
}
impl UnicodeCMap {
/// The encoding of the predefined CMap `name`, if it is a horizontal Unicode one. Vertical
/// (`-V`) CMaps are refused: field appearances are laid out horizontally, so their glyphs
/// would be drawn stacked down the field instead of across it.
pub fn from_name(name: &[u8]) -> Option<Self> {
let name = std::str::from_utf8(name).ok()?;
if !name.starts_with("Uni") || !name.ends_with("-H") {
return None;
}
name.split('-').find_map(|part| match part {
"UCS2" => Some(Self::Ucs2),
"UTF16" => Some(Self::Utf16),
"UTF8" => Some(Self::Utf8),
"UTF32" => Some(Self::Utf32),
_ => None,
})
}
/// The first character of `s` this encoding can't hold, which [`Self::encode`] would write as
/// `?`: a character beyond U+FFFF in UCS-2 (`𠀋`). UTF-16, UTF-8 and UTF-32 hold every one.
pub fn first_unencodable(self, s: &str) -> Option<char> {
match self {
Self::Ucs2 => s.chars().find(|c| u16::try_from(u32::from(*c)).is_err()),
Self::Utf16 | Self::Utf8 | Self::Utf32 => None,
}
}
/// Encode `s`; tabs become spaces, and characters UCS-2 can't hold become `?` (check them
/// first with [`Self::first_unencodable`]: PdfCraft refuses such text rather than write `?`).
pub fn encode(self, s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len().saturating_mul(2));
let mut units = [0u16; 2];
let mut buf = [0u8; 4];
for c in s.chars() {
let c = if c == '\t' { ' ' } else { c };
match self {
Self::Ucs2 => {
let unit = u16::try_from(u32::from(c)).unwrap_or(u16::from(b'?'));
out.extend_from_slice(&unit.to_be_bytes());
}
Self::Utf16 => {
for unit in c.encode_utf16(&mut units) {
out.extend_from_slice(&unit.to_be_bytes());
}
}
Self::Utf8 => out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()),
Self::Utf32 => out.extend_from_slice(&u32::from(c).to_be_bytes()),
}
}
out
}
}
/// Whether [`win_ansi`] keeps `c` rather than writing `?` (a tab becomes a space).
pub fn win_ansi_encodable(c: char) -> bool {
c == '\t' || win_ansi_byte(c).is_some()
}
/// The first character of `s` that [`win_ansi`] can't encode and would write as `?`.
pub fn first_non_win_ansi(s: &str) -> Option<char> {
s.chars().find(|c| !win_ansi_encodable(*c))
}
/// The first character of `s` the standard fonts (Helvetica and the rest of the standard 14, in
/// WinAnsiEncoding) can't draw, so that a generated appearance would show it as `?`. Line breaks
/// don't count: appearances lay lines out themselves. PdfCraft refuses such text with
/// [`undrawable_message`] instead of writing `?` (AGENTS.md §4: never lose work silently).
pub fn first_undrawable(s: &str) -> Option<char> {
s.chars().find(|c| !matches!(c, '\n' | '\r') && !win_ansi_encodable(*c))
}
/// The error for text the standard fonts can't draw ([`first_undrawable`]): which character, in
/// what (`"the comment"`, `"the reason"`), and what can be written instead. One wording for every
/// path that writes text in the standard fonts, so agents and users can act on it the same way.
pub fn undrawable_message(what: &str, c: char) -> String {
format!(
"the standard fonts can't draw \"{}\" (U+{:04X}) in {what}; only Western European characters can be written there for now",
c.escape_debug(),
u32::from(c)
)
}
/// The private key on an annotation (a comment or a form field's widget) whose appearance PdfCraft
/// drew with `?` in place of characters its fonts can't draw: a text string holding the first
/// such character. The text itself (a field's `/V`, a comment's `/Contents`) is stored as typed;
/// the mark lets flattening refuse to make that `?` permanent, lets an edit report it, and tells
/// such an appearance apart from one another program drew with a font that has those characters.
pub const UNDRAWN_MARK: &[u8] = b"PCUndrawn";
/// The character [`UNDRAWN_MARK`] records on `d`, if PdfCraft drew its appearance with `?`.
pub fn undrawn_mark(d: &pdfcraft_cos::Dict) -> Option<char> {
d.get(UNDRAWN_MARK).and_then(pdfcraft_cos::Object::as_string).and_then(|s| s.to_text().chars().next())
}
/// Record (`Some`) or clear (`None`) [`UNDRAWN_MARK`] on `d`, for the appearance just drawn.
pub fn set_undrawn_mark(d: &mut pdfcraft_cos::Dict, c: Option<char>) {
match c {
Some(c) => d.set(UNDRAWN_MARK.to_vec(), pdfcraft_cos::PdfString::text(&c.to_string())),
None => {
d.remove(UNDRAWN_MARK);
}
}
}
/// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped.
pub fn literal(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len() + 2);
out.push(b'(');
for &b in bytes {
match b {
b'(' | b')' | b'\\' => out.extend_from_slice(&[b'\\', b]),
b'\r' => out.extend_from_slice(b"\\r"),
b'\n' => out.extend_from_slice(b"\\n"),
_ => out.push(b),
}
}
out.push(b')');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn win_ansi_encodability_matches_the_encoder() {
for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] {
assert!(win_ansi_encodable(c), "{c:?}");
}
for c in ['中', 'あ', 'Ω', 'č', 'ă', '☃', '😀'] {
assert!(!win_ansi_encodable(c), "{c:?}");
}
assert_eq!(first_non_win_ansi("Café — 5€"), None);
assert_eq!(first_non_win_ansi("Dvořák"), Some('ř'));
// Line breaks are laid out, not drawn; other controls would be written as `?`.
assert_eq!(first_undrawable("Café\r\n— 5€\tok"), None);
assert_eq!(first_undrawable("签名 Ada"), Some('签'));
assert_eq!(first_undrawable("a\u{7}b"), Some('\u{7}'));
assert_eq!(first_undrawable("𠀋"), Some('𠀋'));
let m = undrawable_message("the comment", '签');
assert_eq!(m, "the standard fonts can't draw \"签\" (U+7B7E) in the comment; only Western European characters can be written there for now");
assert!(undrawable_message("the reason", '\u{7}').contains("\"\\u{7}\" (U+0007)"));
assert!(undrawable_message("x", '𠀋').contains("(U+2000B)"));
}
#[test]
fn the_undrawn_mark_records_one_character_and_clears() {
let mut d = pdfcraft_cos::Dict::new();
assert_eq!(undrawn_mark(&d), None);
set_undrawn_mark(&mut d, Some('𠀋'));
assert_eq!(undrawn_mark(&d), Some('𠀋'), "beyond U+FFFF, as a UTF-16 text string");
set_undrawn_mark(&mut d, Some('签'));
assert_eq!(undrawn_mark(&d), Some('签'));
set_undrawn_mark(&mut d, None);
assert!(!d.contains(UNDRAWN_MARK));
// Another value under the key is not a character: no mark.
d.set(UNDRAWN_MARK.to_vec(), pdfcraft_cos::Object::Bool(true));
assert_eq!(undrawn_mark(&d), None);
}
#[test]
fn widths_wrap_and_encode() {
assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0);
assert_eq!(helvetica_width("", 12.0), 0.0);
let lines = wrap("the quick brown fox jumps over the lazy dog", 12.0, 80.0);
assert!(lines.len() > 2 && lines.iter().all(|l| helvetica_width(l, 12.0) <= 80.0));
assert_eq!(wrap("a\nb", 12.0, 100.0), ["a", "b"]);
let long = wrap("Supercalifragilisticexpialidocious", 12.0, 40.0);
assert!(long.len() > 3 && long.concat() == "Supercalifragilisticexpialidocious");
assert_eq!(win_ansi("Café — 5€ ☃"), b"Caf\xe9 \x97 5\x80 ?");
assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)");
}
#[test]
fn win_ansi_covers_the_0x80_to_0x9f_glyphs() {
// Every character in the WinAnsiEncoding table encodes to its code and back (#347).
for (code, &u) in encodings::WIN_ANSI.iter().enumerate() {
let Some(c) = char::from_u32(u).filter(|_| u != 0) else { continue };
assert_eq!(win_ansi_byte(c), Some(code as u8), "{c:?} (U+{u:04X})");
assert_eq!(win_ansi(&c.to_string()), [code as u8]);
}
assert_eq!(
win_ansi("Šta? žaba, œuvre — „ok“ ‰ ƒ † ‡ ˆ ˜ ‹›Ÿ"),
b"\x8ata? \x9eaba, \x9cuvre \x97 \x84ok\x93 \x89 \x83 \x86 \x87 \x88 \x98 \x8b\x9b\x9f"
);
// Not in WinAnsi: č ć still need the fallback font; undefined codes are never produced.
assert_eq!(win_ansi("čć\u{81}\u{8d}\t"), b"???? ");
assert_eq!(win_ansi_byte('\0'), None);
}
#[test]
fn unicode_cmaps_encode_cjk_text() {
assert_eq!(UnicodeCMap::from_name(b"UniJIS-UTF16-H"), Some(UnicodeCMap::Utf16));
assert_eq!(UnicodeCMap::from_name(b"UniGB-UCS2-V"), None);
assert_eq!(UnicodeCMap::from_name(b"UniJIS-UCS2-HW-H"), Some(UnicodeCMap::Ucs2));
assert_eq!(UnicodeCMap::from_name(b"UniKS-UTF8-H"), Some(UnicodeCMap::Utf8));
assert_eq!(UnicodeCMap::from_name(b"UniJIS2004-UTF32-H"), Some(UnicodeCMap::Utf32));
assert_eq!(UnicodeCMap::from_name(b"Identity-H"), None);
assert_eq!(UnicodeCMap::from_name(b"90ms-RKSJ-H"), None);
assert_eq!(UnicodeCMap::from_name(b"\xff\xfe"), None);
assert_eq!(UnicodeCMap::Utf16.encode("令A"), [0x4e, 0xe4, 0x00, 0x41]);
assert_eq!(UnicodeCMap::Utf16.encode("𠀋"), [0xd8, 0x40, 0xdc, 0x0b]);
assert_eq!(UnicodeCMap::Ucs2.encode("𠀋\t"), [0x00, b'?', 0x00, b' ']);
// That `?` is never written silently: callers check first.
assert_eq!(UnicodeCMap::Ucs2.first_unencodable("令和𠀋"), Some('𠀋'));
assert_eq!(UnicodeCMap::Ucs2.first_unencodable("令和"), None);
for cmap in [UnicodeCMap::Utf16, UnicodeCMap::Utf8, UnicodeCMap::Utf32] {
assert_eq!(cmap.first_unencodable("令和𠀋"), None, "{cmap:?}");
}
assert_eq!(UnicodeCMap::Utf8.encode("令"), "令".as_bytes());
assert_eq!(UnicodeCMap::Utf32.encode("令"), [0, 0, 0x4e, 0xe4]);
assert_eq!(cjk_width("令和", 10.0), 20.0);
assert_eq!(cjk_width("ア", 10.0), 5.0);
assert_eq!(cjk_width("A", 10.0), helvetica_width("A", 10.0));
let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width);
assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}");
}
}