mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
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>
371 lines
17 KiB
Rust
371 lines
17 KiB
Rust
//! The one chain PdfCraft follows when a document does not embed a font program, and a record of
|
|
//! what it chose and why.
|
|
//!
|
|
//! Substitution used to be decided per caller and never written down: `edit` picked a craft face
|
|
//! by the script of the text it was drawing, `Std14::from_base_font` matched Latin names, and the
|
|
//! renderer did its own thing. Nothing told the *user* that the glyphs they are looking at are not
|
|
//! the ones the document asked for, which is the difference between "PdfCraft shows nothing for my
|
|
//! Chinese PDF" being a mystery and being a sentence.
|
|
//!
|
|
//! This module answers the *dictionary* question — "the document does not carry this program; what
|
|
//! will stand in for it, and why?" — which is what inspection and reporting need. It deliberately
|
|
//! does not answer the *text* question ("which face has a glyph for こ?"), because that depends on
|
|
//! the string being drawn and is already handled where the drawing happens. It delegates to the
|
|
//! existing per-script selectors rather than restating their order, so there is one source of
|
|
//! truth for each.
|
|
//!
|
|
//! The chain is tried in order, and the first step that yields a face wins:
|
|
//!
|
|
//! 1. The document embeds the program. Nothing is substituted.
|
|
//! 2. `/BaseFont` names one of the standard 14 (or a well-known alias such as Arial). Exact
|
|
//! published metrics apply, so this is as close to right as a substitution gets.
|
|
//! 3. craft-fonts holds that exact family. Never another family — see [`crate::embedding`].
|
|
//! 4. The descendant font's `/CIDSystemInfo /Ordering` names a script, and craft-fonts has a face
|
|
//! for it. Today that means Adobe-Japan1.
|
|
//! 5. The ordering names a script we have **no** licensed face for (GB1, CNS1, Korea1). There is
|
|
//! no honest substitute, so the chain reports none: the caller draws nothing and can say why.
|
|
//! 6. Otherwise, the nearest of the standard 14 by `/FontDescriptor /Flags` — serif, fixed pitch,
|
|
//! bold and italic. This step always succeeds, so a simple font is never left without a face.
|
|
|
|
use crate::{CraftFont, Std14};
|
|
|
|
/// What a font dictionary asks for, as far as the dictionary alone can say.
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
pub struct Request<'a> {
|
|
/// `/BaseFont` with any `ABCDEF+` subset tag already stripped.
|
|
pub base_font: &'a str,
|
|
/// Whether the descriptor carries `/FontFile`, `/FontFile2` or `/FontFile3` (or it is Type 3).
|
|
pub embedded: bool,
|
|
/// `/FontDescriptor /Flags`, or 0 when there is no descriptor.
|
|
pub flags: u32,
|
|
/// The descendant font's `/CIDSystemInfo /Ordering`, for a composite font.
|
|
pub ordering: Option<&'a str>,
|
|
}
|
|
|
|
/// The face the chain picked.
|
|
///
|
|
/// `Debug`, `PartialEq` and `Eq` are written by hand rather than derived, because [`CraftFont`]
|
|
/// carries a whole font program: deriving would print megabytes and compare font files.
|
|
#[derive(Clone, Copy)]
|
|
pub enum Face {
|
|
/// The document's own program; nothing was substituted.
|
|
Embedded,
|
|
/// One of the standard 14, whose metrics are exact.
|
|
Standard14(Std14),
|
|
/// A face from the optional craft-fonts build input.
|
|
Craft(&'static CraftFont),
|
|
/// No face at all. The caller must draw nothing rather than draw the wrong glyphs.
|
|
None,
|
|
}
|
|
|
|
impl PartialEq for Face {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
match (self, other) {
|
|
(Self::Embedded, Self::Embedded) | (Self::None, Self::None) => true,
|
|
(Self::Standard14(a), Self::Standard14(b)) => a == b,
|
|
// craft-fonts entries are unique statics, so identity is the comparison that means
|
|
// what we want: two styles of one family are different faces and must not be equal.
|
|
(Self::Craft(a), Self::Craft(b)) => std::ptr::eq(*a, *b),
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Eq for Face {}
|
|
|
|
impl std::fmt::Debug for Face {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Embedded => f.write_str("Embedded"),
|
|
Self::Standard14(s) => write!(f, "Standard14({})", s.base_font()),
|
|
// The name, never the bytes.
|
|
Self::Craft(c) => write!(f, "Craft({})", c.name()),
|
|
Self::None => f.write_str("None"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why the chain picked that face. The variants are in the order the chain tries them.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Why {
|
|
/// The document carries the font program.
|
|
Embedded,
|
|
/// `/BaseFont` names a standard-14 face or a well-known alias of one.
|
|
NamedStandard14,
|
|
/// `/BaseFont` is otherwise unknown, but its name hints at a family ("Serif", "Roman",
|
|
/// "Mono"). A guess from the name, and reported as one.
|
|
NameHintedStandard14,
|
|
/// craft-fonts holds this exact family.
|
|
ExactCraftFamily,
|
|
/// A craft-fonts face for the script named by `/CIDSystemInfo /Ordering`.
|
|
ScriptFace,
|
|
/// The ordering names a script with no licensed face available.
|
|
NoFaceForScript,
|
|
/// The nearest standard-14 face by descriptor flags.
|
|
StyleMatched,
|
|
}
|
|
|
|
impl Why {
|
|
/// A stable sentence for a report, written for whoever has to act on it.
|
|
pub fn note(self) -> &'static str {
|
|
match self {
|
|
Self::Embedded => "embedded in the document; not substituted",
|
|
Self::NamedStandard14 => "names one of the standard 14 fonts, or a well-known alias of one, whose metrics are exact",
|
|
Self::NameHintedStandard14 => {
|
|
"not embedded and not a known family, but its name hints at one (Serif, Roman or Mono), so the nearest standard-14 face was chosen from that hint"
|
|
}
|
|
Self::ExactCraftFamily => "this exact family is available, so no other family was chosen",
|
|
Self::ScriptFace => "substituted by a face for the script the font declares",
|
|
Self::NoFaceForScript => {
|
|
"no licensed face is available for the script this font declares, so nothing is drawn for it \
|
|
rather than the wrong glyphs"
|
|
}
|
|
Self::StyleMatched => "not embedded and not a known family, so the nearest standard-14 face was chosen by its descriptor flags",
|
|
}
|
|
}
|
|
|
|
/// Whether the document's own glyphs are being shown. Only [`Why::Embedded`] is faithful.
|
|
pub fn is_faithful(self) -> bool {
|
|
matches!(self, Self::Embedded)
|
|
}
|
|
}
|
|
|
|
/// What the chain chose, and why.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Substitution {
|
|
pub face: Face,
|
|
pub why: Why,
|
|
}
|
|
|
|
impl Substitution {
|
|
/// The chosen face's name, for a report. `None` when nothing was chosen.
|
|
pub fn face_name(&self) -> Option<String> {
|
|
match self.face {
|
|
Face::Embedded => None,
|
|
Face::Standard14(f) => Some(f.base_font().to_owned()),
|
|
Face::Craft(f) => Some(f.name()),
|
|
Face::None => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// CID orderings we recognise, and whether a craft-fonts face may exist for them.
|
|
///
|
|
/// `Identity` is not a script: it says the codes are glyph ids, so it tells us nothing about
|
|
/// which face would suit and the chain falls through to the flags.
|
|
fn script_for(ordering: &str) -> Option<&'static str> {
|
|
match ordering {
|
|
"Japan1" => Some("Jpan"),
|
|
// Registered orderings we have no openly licensed face for. AGENTS.md 1.1 rules out
|
|
// Source Han and Noto CJK (Source Han rebranded), which is why these have none.
|
|
"GB1" | "CNS1" | "Korea1" | "KR" => Some(""),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Apply the chain to `req`. Total: every request gets a [`Substitution`].
|
|
///
|
|
/// See the module documentation for the order and the reasoning behind each step.
|
|
pub fn substitute(req: &Request<'_>) -> Substitution {
|
|
let at = |face, why| Substitution { face, why };
|
|
if req.embedded {
|
|
return at(Face::Embedded, Why::Embedded);
|
|
}
|
|
if let Some((f, how)) = Std14::recognise(req.base_font) {
|
|
return match how {
|
|
crate::std14::Match::Alias => at(Face::Standard14(f), Why::NamedStandard14),
|
|
crate::std14::Match::FamilyHint => at(Face::Standard14(f), Why::NameHintedStandard14),
|
|
};
|
|
}
|
|
if let Some(f) = crate::embedding::matching_faces(req.base_font).first() {
|
|
return at(Face::Craft(f), Why::ExactCraftFamily);
|
|
}
|
|
if let Some(script) = req.ordering.and_then(script_for) {
|
|
// A script we hold a face for; otherwise there is no honest substitute to offer.
|
|
return match crate::CRAFT_FONTS.iter().find(|f| f.covers(script)) {
|
|
Some(f) => at(Face::Craft(f), Why::ScriptFace),
|
|
None => at(Face::None, Why::NoFaceForScript),
|
|
};
|
|
}
|
|
at(Face::Standard14(style_matched(req.flags)), Why::StyleMatched)
|
|
}
|
|
|
|
/// The nearest standard-14 face for `/FontDescriptor /Flags` (ISO 32000-2 table 121): fixed pitch
|
|
/// is bit 1, serif bit 2, italic bit 7 and ForceBold bit 19.
|
|
fn style_matched(flags: u32) -> Std14 {
|
|
let (bold, italic) = (flags & 0x4_0000 != 0, flags & 0x40 != 0);
|
|
match (flags & 1 != 0, flags & 2 != 0, bold, italic) {
|
|
(true, _, false, false) => Std14::Courier,
|
|
(true, _, true, false) => Std14::CourierBold,
|
|
(true, _, false, true) => Std14::CourierOblique,
|
|
(true, _, true, true) => Std14::CourierBoldOblique,
|
|
(false, true, false, false) => Std14::TimesRoman,
|
|
(false, true, true, false) => Std14::TimesBold,
|
|
(false, true, false, true) => Std14::TimesItalic,
|
|
(false, true, true, true) => Std14::TimesBoldItalic,
|
|
(false, false, false, false) => Std14::Helvetica,
|
|
(false, false, true, false) => Std14::HelveticaBold,
|
|
(false, false, false, true) => Std14::HelveticaOblique,
|
|
(false, false, true, true) => Std14::HelveticaBoldOblique,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn req<'a>(base: &'a str, embedded: bool, flags: u32, ordering: Option<&'a str>) -> Request<'a> {
|
|
Request { base_font: base, embedded, flags, ordering }
|
|
}
|
|
|
|
#[test]
|
|
fn an_embedded_font_is_never_substituted() {
|
|
// True even when the name and flags would otherwise pick something: embedding wins first.
|
|
for (base, ordering) in [("Helvetica", None), ("Whatever", Some("GB1")), ("", None)] {
|
|
let s = substitute(&req(base, true, 0, ordering));
|
|
assert_eq!(s, Substitution { face: Face::Embedded, why: Why::Embedded });
|
|
assert!(s.why.is_faithful());
|
|
assert_eq!(s.face_name(), None, "nothing stands in, so there is no substitute to name");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_standard_14_name_wins_over_the_flags() {
|
|
// Flags claiming a fixed-pitch serif must not override a name we have exact metrics for.
|
|
let s = substitute(&req("Times-BoldItalic", false, 0b11, None));
|
|
assert_eq!(s.face, Face::Standard14(Std14::TimesBoldItalic));
|
|
assert_eq!(s.why, Why::NamedStandard14);
|
|
assert_eq!(s.face_name().as_deref(), Some("Times-BoldItalic"));
|
|
// Aliases and subset tags resolve the same way.
|
|
for name in ["Arial", "ArialMT", "Arial-Bold", "Symbol", "ZapfDingbats", "CourierNew"] {
|
|
assert_eq!(substitute(&req(name, false, 0, None)).why, Why::NamedStandard14, "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_hint_is_reported_as_a_guess_not_as_a_standard_14_name() {
|
|
// A house font whose name merely contains "Serif" gets Times, which is a sensible guess --
|
|
// but calling that "names one of the standard 14" would tell the reader something untrue.
|
|
let hinted = substitute(&req("SomeHouseSerif", false, 0, None));
|
|
assert_eq!(hinted.face, Face::Standard14(Std14::TimesRoman));
|
|
assert_eq!(hinted.why, Why::NameHintedStandard14);
|
|
assert!(hinted.why.note().contains("hints at one"), "{}", hinted.why.note());
|
|
|
|
// Style words still apply to the guess.
|
|
assert_eq!(substitute(&req("SomeHouseSerif-BoldItalic", false, 0, None)).face, Face::Standard14(Std14::TimesBoldItalic));
|
|
// "Mono" hints at Courier the same way.
|
|
assert_eq!(substitute(&req("HouseMono", false, 0, None)).why, Why::NameHintedStandard14);
|
|
|
|
// A real alias is still reported as the request it is.
|
|
for alias in ["Arial", "CourierNew", "Times-Roman", "Symbol"] {
|
|
assert_eq!(substitute(&req(alias, false, 0, None)).why, Why::NamedStandard14, "{alias}");
|
|
}
|
|
// A name with no hint at all falls through to the flags instead.
|
|
assert_eq!(substitute(&req("HouseGrotesk", false, 0b10, None)).why, Why::StyleMatched);
|
|
}
|
|
|
|
#[test]
|
|
fn flags_pick_the_nearest_of_the_fourteen() {
|
|
// Bit 1 fixed pitch, bit 2 serif, bit 7 italic, bit 19 ForceBold.
|
|
let cases = [
|
|
(0, Std14::Helvetica),
|
|
(0b10, Std14::TimesRoman),
|
|
(0b1, Std14::Courier),
|
|
(0x40, Std14::HelveticaOblique),
|
|
(0x4_0000, Std14::HelveticaBold),
|
|
(0x4_0040, Std14::HelveticaBoldOblique),
|
|
(0b10 | 0x40, Std14::TimesItalic),
|
|
(0b10 | 0x4_0000, Std14::TimesBold),
|
|
(0b10 | 0x4_0040, Std14::TimesBoldItalic),
|
|
(0b1 | 0x40, Std14::CourierOblique),
|
|
(0b1 | 0x4_0000, Std14::CourierBold),
|
|
(0b1 | 0x4_0040, Std14::CourierBoldOblique),
|
|
// Fixed pitch and serif together: monospace is the stronger signal for layout.
|
|
(0b11, Std14::Courier),
|
|
];
|
|
for (flags, want) in cases {
|
|
let s = substitute(&req("SomethingUnknown", false, flags, None));
|
|
assert_eq!(s.face, Face::Standard14(want), "flags {flags:#x}");
|
|
assert_eq!(s.why, Why::StyleMatched);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_script_with_no_licensed_face_reports_none_rather_than_latin() {
|
|
// The user's Chinese PDF draws nothing. The point of this is that we can now say so,
|
|
// instead of quietly standing Helvetica in and showing the wrong glyphs.
|
|
for ordering in ["GB1", "CNS1", "Korea1", "KR"] {
|
|
let s = substitute(&req("SomeCJKFont", false, 0, Some(ordering)));
|
|
assert_eq!(s.face, Face::None, "{ordering}");
|
|
assert_eq!(s.why, Why::NoFaceForScript, "{ordering}");
|
|
assert_eq!(s.face_name(), None);
|
|
assert!(!s.why.is_faithful());
|
|
assert!(s.why.note().contains("no licensed face"), "{ordering}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn identity_ordering_is_not_a_script_and_falls_through_to_the_flags() {
|
|
// /Ordering (Identity) says the codes are glyph ids. It implies nothing about the face,
|
|
// so treating it as a script would invent a substitution the dictionary never asked for.
|
|
let s = substitute(&req("SomeSubsetFont", false, 0b10, Some("Identity")));
|
|
assert_eq!(s.face, Face::Standard14(Std14::TimesRoman));
|
|
assert_eq!(s.why, Why::StyleMatched);
|
|
// An unregistered ordering is treated the same way, not guessed at.
|
|
assert_eq!(substitute(&req("X", false, 0, Some("MadeUp1"))).why, Why::StyleMatched);
|
|
}
|
|
|
|
#[test]
|
|
fn japanese_depends_on_craft_fonts_and_says_which_it_got() {
|
|
// craft-fonts is an optional build input, so both outcomes are correct -- but they must be
|
|
// distinguishable, and neither may be a silent Latin stand-in.
|
|
let s = substitute(&req("SomeJapaneseFont", false, 0, Some("Japan1")));
|
|
if crate::CRAFT_FONTS.iter().any(|f| f.covers("Jpan")) {
|
|
assert_eq!(s.why, Why::ScriptFace);
|
|
assert!(matches!(s.face, Face::Craft(_)));
|
|
assert!(s.face_name().is_some_and(|n| !n.is_empty()));
|
|
} else {
|
|
assert_eq!(s.why, Why::NoFaceForScript);
|
|
assert_eq!(s.face, Face::None);
|
|
}
|
|
assert!(!s.why.is_faithful(), "a substitution is never faithful");
|
|
}
|
|
|
|
#[test]
|
|
fn every_why_has_a_distinct_usable_note() {
|
|
let all = [
|
|
Why::Embedded,
|
|
Why::NamedStandard14,
|
|
Why::NameHintedStandard14,
|
|
Why::ExactCraftFamily,
|
|
Why::ScriptFace,
|
|
Why::NoFaceForScript,
|
|
Why::StyleMatched,
|
|
];
|
|
let mut notes: Vec<&str> = all.iter().map(|w| w.note()).collect();
|
|
notes.sort_unstable();
|
|
let before = notes.len();
|
|
notes.dedup();
|
|
assert_eq!(notes.len(), before, "each reason reads differently");
|
|
assert!(all.iter().all(|w| !w.note().is_empty() && w.note().len() > 20), "notes are sentences, not labels");
|
|
assert_eq!(all.iter().filter(|w| w.is_faithful()).count(), 1, "only an embedded program is faithful");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_input_is_total_and_never_panics() {
|
|
// Every field is attacker-controlled: a name is any bytes the document chose.
|
|
let long = "a".repeat(10_000);
|
|
let names = ["", " ", "+", "ABCDEF+", "\u{0}\u{1}", long.as_str(), "../../etc/passwd", "Ĥéļvéťïçà"];
|
|
for name in names {
|
|
for flags in [0, u32::MAX, 0x4_0040, 1] {
|
|
for ordering in [None, Some(""), Some("Japan1"), Some("\u{0}")] {
|
|
let s = substitute(&req(name, false, flags, ordering));
|
|
// Always a decision, and the reason always reads.
|
|
assert!(!s.why.note().is_empty());
|
|
assert_eq!(s.face == Face::None, s.why == Why::NoFaceForScript, "only one step declines: {name:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|