From d9607ec3befcda3e78062ceb5e54b97563aedad1 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 12 Oct 2026 15:35:48 -0700 Subject: [PATCH] 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 --- AGENTS.md | 2 +- ATTRIBUTION.md | 2 +- ATTRIBUTION.toml | 6 +- crates/automation/src/lib.rs | 5 + crates/automation/tests/automation.rs | 7 + crates/fonts/src/fallback.rs | 370 ++++++++++++++++++++++++++ crates/fonts/src/lib.rs | 2 + crates/fonts/src/std14.rs | 352 +++--------------------- crates/fonts/src/std14_metrics.rs | 300 +++++++++++++++++++++ crates/render/src/inspect.rs | 154 ++++++++++- parity/acrobat-features.toml | 4 +- xtask/src/assets.rs | 6 +- 12 files changed, 877 insertions(+), 333 deletions(-) create mode 100644 crates/fonts/src/fallback.rs create mode 100644 crates/fonts/src/std14_metrics.rs diff --git a/AGENTS.md b/AGENTS.md index 2ac90cfd..0665ad1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ An **asset** is any non-code material that the project includes, ships, embeds, - Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs. - The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed: - The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency. - - The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14.rs` (approved by the owner, 2026-10-08), which is what generated appearances measure with and what survives hayro being retired. + - The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14_metrics.rs` (approved by the owner, 2026-10-08; that file holds the data alone, so the code reading it can change without re-attesting the table), which is what generated appearances measure with and what survives hayro being retired. Adding anything to this list needs the user's explicit approval. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index be77e718..3b451f24 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -222,7 +222,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) | -| `crates/fonts/src/std14.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | +| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | | `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 28785f76..714e7763 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2365,16 +2365,16 @@ usage = "Default CMYK colour conversion (vendored hayro-interpret)" sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0" [[asset]] -path = "crates/fonts/src/std14.rs" +path = "crates/fonts/src/std14_metrics.rs" title = "Standard-14 font advance widths and encodings (as Rust tables)" author = "Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors" -source = "ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs." +source = "ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash." licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "data" adobe_data = true usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree." -sha256 = "4ce47db7e65467d4ea1d1642ff76dfcaeb9669c1811a93819d7e00e63fa9fcf8" +sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" [[bundled]] crate = "epaint_default_fonts" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index d7eb8a2b..accb76b9 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2205,8 +2205,13 @@ fn info(d: &Document) -> Value { })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + // `substitute` names the face standing in for a program the document does not embed, and + // `substitution` says why. Both are null for an embedded font: nothing stands in. A null + // `substitute` with a non-null `substitution` means no face was available at all, so + // nothing is drawn for that font -- which is the answer to "why is my text missing?". "fonts": i.fonts.iter().map(|f| json!({ "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, + "substitute": f.substitute, "substitution": f.substitution, })).collect::>(), "warnings": i.warnings, "repairs": d.repair_log(), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 0ff3930b..5b9050db 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -409,6 +409,13 @@ fn open_inspect_render_and_find() { assert_eq!(info["pages"][0]["width"], 200.0); assert_eq!(info["pages"][1]["label"], "2"); + // The fixture names /Helvetica without embedding it, so an agent can see both that a face is + // standing in and why that face. `substitution` is null only when nothing stands in. + let font = &info["fonts"][0]; + assert_eq!((&font["name"], &font["embedded"]), (&json!("Helvetica"), &json!(false))); + assert_eq!(font["substitute"], json!("Helvetica")); + assert!(font["substitution"].as_str().is_some_and(|w| w.contains("standard 14")), "{font}"); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]); let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page 2" })); diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs new file mode 100644 index 00000000..64069a3d --- /dev/null +++ b/crates/fonts/src/fallback.rs @@ -0,0 +1,370 @@ +//! 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 { + 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:?}"); + } + } + } + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 622c609f..a0573b1e 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -10,11 +10,13 @@ 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, diff --git a/crates/fonts/src/std14.rs b/crates/fonts/src/std14.rs index 0aca38a1..0556b112 100644 --- a/crates/fonts/src/std14.rs +++ b/crates/fonts/src/std14.rs @@ -32,44 +32,60 @@ pub enum Std14 { ZapfDingbats, } +/// How [`Std14::recognise`] matched a `/BaseFont` name. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Match { + /// A standard-14 face or a well-known alias of one (Arial for Helvetica, CourierNew for + /// Courier). The document is asking for this face. + Alias, + /// Only a family hint matched -- "Serif", "Roman" or "Mono" in an otherwise unknown name. + /// The face is a guess from the name, not the one the document asked for. + FamilyHint, +} + impl Std14 { /// The font a `/BaseFont` name selects, by the spec's names and the common aliases viewers /// accept (`Arial` for `Helvetica`, `TimesNewRoman` for `Times`, `CourierNew` for `Courier`). /// A subset prefix such as `ABCDEF+Helvetica` is ignored. pub fn from_base_font(base: &str) -> Option { + Self::recognise(base).map(|(face, _)| face) + } + + /// [`Std14::from_base_font`], also saying *how* the name matched. + /// + /// Reporting needs the difference. "Arial" really is a request for Helvetica, but + /// "SomeHouseSerif" is a house font whose name merely contains "Serif"; choosing Times for it + /// is a reasonable guess from the name, not the face the document asked for, and a substitution + /// report that called it a standard-14 name would be telling the reader something untrue. + pub fn recognise(base: &str) -> Option<(Self, Match)> { let base = base.rsplit('+').next().unwrap_or(base); let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect(); let has = |w: &str| flat.contains(w); let (bold, italic) = (has("bold"), has("italic") || has("oblique")); + // Two bools index a four-element array, so the subscript is provably 0..=3. + let styled = |faces: [Self; 4]| faces[usize::from(bold) | usize::from(italic) << 1]; + let courier = || styled([Self::Courier, Self::CourierBold, Self::CourierOblique, Self::CourierBoldOblique]); + let times = || styled([Self::TimesRoman, Self::TimesBold, Self::TimesItalic, Self::TimesBoldItalic]); if has("symbol") { - return Some(Self::Symbol); + return Some((Self::Symbol, Match::Alias)); } if has("zapf") || has("dingbat") { - return Some(Self::ZapfDingbats); + return Some((Self::ZapfDingbats, Match::Alias)); } - if has("courier") || has("mono") { - return Some(match (bold, italic) { - (false, false) => Self::Courier, - (true, false) => Self::CourierBold, - (false, true) => Self::CourierOblique, - (true, true) => Self::CourierBoldOblique, - }); + if has("courier") { + return Some((courier(), Match::Alias)); } - if has("times") || has("serif") || has("roman") { - return Some(match (bold, italic) { - (false, false) => Self::TimesRoman, - (true, false) => Self::TimesBold, - (false, true) => Self::TimesItalic, - (true, true) => Self::TimesBoldItalic, - }); + if has("mono") { + return Some((courier(), Match::FamilyHint)); + } + if has("times") { + return Some((times(), Match::Alias)); + } + if has("serif") || has("roman") { + return Some((times(), Match::FamilyHint)); } if has("helvetica") || has("arial") { - return Some(match (bold, italic) { - (false, false) => Self::Helvetica, - (true, false) => Self::HelveticaBold, - (false, true) => Self::HelveticaOblique, - (true, true) => Self::HelveticaBoldOblique, - }); + return Some((styled([Self::Helvetica, Self::HelveticaBold, Self::HelveticaOblique, Self::HelveticaBoldOblique]), Match::Alias)); } None } @@ -77,7 +93,7 @@ impl Std14 { /// The advance of one character `code`, in thousandths of an em. Zero where the font's /// encoding leaves the code undefined. pub fn width(self, code: u8) -> f64 { - f64::from(WIDTHS[self as usize][code as usize]) + f64::from(crate::std14_metrics::WIDTHS[self as usize][code as usize]) } /// The advance of `code` at `size` points. @@ -101,298 +117,10 @@ impl Std14 { /// The `/BaseFont` name for this font, as it is written into a PDF. pub fn base_font(self) -> &'static str { - BASE_FONTS[self as usize] + crate::std14_metrics::BASE_FONTS[self as usize] } } -static BASE_FONTS: [&str; 14] = [ - "Helvetica", - "Helvetica-Bold", - "Helvetica-Oblique", - "Helvetica-BoldOblique", - "Times-Roman", - "Times-Bold", - "Times-Italic", - "Times-BoldItalic", - "Courier", - "Courier-Bold", - "Courier-Oblique", - "Courier-BoldOblique", - "Symbol", - "ZapfDingbats", -]; - -/// Advance widths by code, one row per [`Std14`] variant in declaration order. -#[rustfmt::skip] -static WIDTHS: [[u16; 256]; 14] = [ - // Helvetica - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, - 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, - 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, - 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, - 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, - 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, - 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, - 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, - 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, - 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, - 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, - 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, - 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, - 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, - ], - // HelveticaBold - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, - 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, - 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, - 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, - 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, - 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, - 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, - 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, - 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, - 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, - 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, - 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, - 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, - 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, - ], - // HelveticaOblique - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, - 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, - 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, - 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, - 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, - 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, - 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, - 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, - 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, - 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, - 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, - 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, - 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, - 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, - ], - // HelveticaBoldOblique - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, - 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, - 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, - 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, - 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, - 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, - 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, - 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, - 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, - 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, - 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, - 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, - 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, - 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, - ], - // TimesRoman - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, - 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, - 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, - 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, - 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, - 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0, - 500, 0, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 0, 611, 0, - 0, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 0, 444, 722, - 250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333, - 400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444, - 722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, - 722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500, - 444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, - 500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500, - ], - // TimesBold - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278, - 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, - 930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, - 611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500, - 333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, - 556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0, - 500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 0, 667, 0, - 0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 444, 722, - 250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333, - 400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500, - 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389, - 722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556, - 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, - 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500, - ], - // TimesItalic - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278, - 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500, - 920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722, - 611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500, - 333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500, - 500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 0, - 500, 0, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 0, 556, 0, - 0, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 0, 389, 556, - 250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333, - 400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500, - 611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, - 722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500, - 500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, - 500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444, - ], - // TimesBoldItalic - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278, - 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, - 832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722, - 611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500, - 333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500, - 500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 0, - 500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 0, 611, 0, - 0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 389, 611, - 250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333, - 400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500, - 667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389, - 722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500, - 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, - 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444, - ], - // Courier - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, - 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, - 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - ], - // CourierBold - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, - 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, - 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - ], - // CourierOblique - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, - 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, - 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - ], - // CourierBoldOblique - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, - 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, - 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, - ], - // Symbol - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278, - 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444, - 549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722, - 768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500, - 500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549, - 549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603, - 400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658, - 823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713, - 768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603, - 494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494, - 0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0, - ], - // ZapfDingbats - [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933, - 911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537, - 577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831, - 923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826, - 815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762, - 762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0, - 390, 390, 317, 317, 276, 276, 509, 509, 410, 410, 234, 234, 334, 334, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 732, 544, 544, 910, 667, 760, 760, 776, 595, 694, 626, 788, 788, 788, 788, - 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, - 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, - 788, 788, 788, 788, 894, 838, 1016, 458, 748, 924, 748, 918, 927, 928, 928, 834, - 873, 828, 924, 924, 917, 930, 931, 463, 883, 836, 836, 867, 867, 696, 696, 874, - 0, 874, 760, 946, 771, 865, 771, 888, 967, 888, 831, 873, 927, 970, 918, 0, - ], -]; - #[cfg(test)] mod tests { use super::*; diff --git a/crates/fonts/src/std14_metrics.rs b/crates/fonts/src/std14_metrics.rs new file mode 100644 index 00000000..05ba41c7 --- /dev/null +++ b/crates/fonts/src/std14_metrics.rs @@ -0,0 +1,300 @@ +//! The generated standard-14 tables: the fourteen base font names and their per-code advance +//! widths. **Data only, no logic** -- see `std14.rs` for everything that interprets it. +//! +//! This file is the closed-list item in AGENTS.md 1.1 and carries a SHA-256 in `ATTRIBUTION.toml`, +//! so `cargo xtask assets` fails until a change to it is re-attested. It is kept separate from the +//! code that reads it for exactly that reason: if ordinary edits churned the hash, re-attesting +//! would become reflexive and the check would stop meaning anything. +//! +//! Widths are in thousandths of an em, indexed by character code in each face's own encoding -- +//! WinAnsi for the twelve text faces, and their built-in encodings for Symbol and ZapfDingbats. +//! A zero means the encoding leaves that code undefined. Verified against the published +//! specification by the tests in `std14.rs`, which type the expected values in by hand. + +pub(crate) static BASE_FONTS: [&str; 14] = [ + "Helvetica", + "Helvetica-Bold", + "Helvetica-Oblique", + "Helvetica-BoldOblique", + "Times-Roman", + "Times-Bold", + "Times-Italic", + "Times-BoldItalic", + "Courier", + "Courier-Bold", + "Courier-Oblique", + "Courier-BoldOblique", + "Symbol", + "ZapfDingbats", +]; + +/// Advance widths by code, one row per [`Std14`] variant in declaration order. +#[rustfmt::skip] +pub(crate) static WIDTHS: [[u16; 256]; 14] = [ + // Helvetica + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, + 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, + ], + // HelveticaBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, + 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, + 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, + ], + // HelveticaOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, + 1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, + 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, + 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, 0, + 556, 0, 222, 556, 333, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 222, 222, 333, 333, 350, 556, 1000, 333, 1000, 500, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 260, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 556, 537, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 667, 667, 667, 667, 667, 667, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 500, 556, 556, 556, 556, 278, 278, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 584, 611, 556, 556, 556, 556, 500, 556, 500, + ], + // HelveticaBoldOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, + 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, + 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, + 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, + 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, + 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, 0, + 556, 0, 278, 556, 500, 1000, 556, 556, 333, 1000, 667, 333, 1000, 0, 611, 0, + 0, 278, 278, 500, 500, 350, 556, 1000, 333, 1000, 556, 333, 944, 0, 500, 667, + 278, 333, 556, 556, 556, 556, 280, 556, 333, 737, 370, 556, 584, 333, 737, 333, + 400, 584, 333, 333, 333, 611, 556, 278, 333, 333, 365, 556, 834, 834, 834, 611, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 278, 278, 278, 278, + 722, 722, 778, 778, 778, 778, 778, 584, 778, 722, 722, 722, 722, 667, 667, 611, + 556, 556, 556, 556, 556, 556, 889, 556, 556, 556, 556, 556, 278, 278, 278, 278, + 611, 611, 611, 611, 611, 611, 611, 584, 611, 611, 611, 611, 611, 556, 611, 556, + ], + // TimesRoman + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, + 921, 722, 667, 667, 722, 611, 556, 722, 722, 333, 389, 722, 611, 889, 722, 722, + 556, 722, 667, 556, 611, 722, 722, 944, 722, 722, 611, 333, 278, 333, 469, 500, + 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500, 278, 778, 500, 500, + 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541, 0, + 500, 0, 333, 500, 444, 1000, 500, 500, 333, 1000, 556, 333, 889, 0, 611, 0, + 0, 333, 333, 444, 444, 350, 500, 1000, 333, 980, 389, 333, 722, 0, 444, 722, + 250, 333, 500, 500, 500, 500, 200, 500, 333, 760, 276, 500, 564, 333, 760, 333, + 400, 564, 300, 300, 333, 500, 453, 250, 333, 300, 310, 500, 750, 750, 750, 444, + 722, 722, 722, 722, 722, 722, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, + 722, 722, 722, 722, 722, 722, 722, 564, 722, 722, 722, 722, 722, 722, 556, 500, + 444, 444, 444, 444, 444, 444, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 500, 500, 500, 500, 500, 500, 564, 500, 500, 500, 500, 500, 500, 500, 500, + ], + // TimesBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 555, 500, 500, 1000, 833, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 930, 722, 667, 722, 722, 667, 611, 778, 778, 389, 500, 778, 667, 944, 722, 778, + 611, 778, 722, 556, 667, 722, 722, 1000, 722, 722, 667, 333, 278, 333, 581, 500, + 333, 500, 556, 444, 556, 444, 333, 500, 556, 278, 333, 556, 278, 833, 556, 500, + 556, 556, 444, 389, 333, 556, 500, 722, 500, 500, 444, 394, 220, 394, 520, 0, + 500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 1000, 0, 667, 0, + 0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 444, 722, + 250, 333, 500, 500, 500, 500, 220, 500, 333, 747, 300, 500, 570, 333, 747, 333, + 400, 570, 300, 300, 333, 556, 540, 250, 333, 300, 330, 500, 750, 750, 750, 500, + 722, 722, 722, 722, 722, 722, 1000, 722, 667, 667, 667, 667, 389, 389, 389, 389, + 722, 722, 778, 778, 778, 778, 778, 570, 778, 722, 722, 722, 722, 722, 611, 556, + 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 500, 556, 500, + ], + // TimesItalic + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 420, 500, 500, 833, 778, 214, 333, 333, 500, 675, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 675, 675, 675, 500, + 920, 611, 611, 667, 722, 611, 611, 722, 722, 333, 444, 667, 556, 833, 667, 722, + 611, 722, 611, 500, 556, 722, 611, 833, 611, 556, 556, 389, 278, 389, 422, 500, + 333, 500, 500, 444, 500, 444, 278, 500, 500, 278, 278, 444, 278, 722, 500, 500, + 500, 500, 389, 389, 278, 500, 444, 667, 444, 444, 389, 400, 275, 400, 541, 0, + 500, 0, 333, 500, 556, 889, 500, 500, 333, 1000, 500, 333, 944, 0, 556, 0, + 0, 333, 333, 556, 556, 350, 500, 889, 333, 980, 389, 333, 667, 0, 389, 556, + 250, 389, 500, 500, 500, 500, 275, 500, 333, 760, 276, 500, 675, 333, 760, 333, + 400, 675, 300, 300, 333, 500, 523, 250, 333, 300, 310, 500, 750, 750, 750, 500, + 611, 611, 611, 611, 611, 611, 889, 667, 611, 611, 611, 611, 333, 333, 333, 333, + 722, 667, 722, 722, 722, 722, 722, 675, 722, 722, 722, 722, 722, 556, 611, 500, + 500, 500, 500, 500, 500, 500, 667, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 500, 500, 500, 500, 500, 500, 675, 500, 500, 500, 500, 500, 444, 500, 444, + ], + // TimesBoldItalic + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 389, 555, 500, 500, 833, 778, 278, 333, 333, 500, 570, 250, 333, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 333, 333, 570, 570, 570, 500, + 832, 667, 667, 667, 722, 667, 667, 722, 778, 389, 500, 667, 611, 889, 722, 722, + 611, 722, 667, 556, 611, 722, 667, 889, 667, 611, 611, 333, 278, 333, 570, 500, + 333, 500, 500, 444, 500, 444, 333, 500, 556, 278, 278, 500, 278, 778, 556, 500, + 500, 500, 389, 389, 278, 556, 444, 667, 500, 444, 389, 348, 220, 348, 570, 0, + 500, 0, 333, 500, 500, 1000, 500, 500, 333, 1000, 556, 333, 944, 0, 611, 0, + 0, 333, 333, 500, 500, 350, 500, 1000, 333, 1000, 389, 333, 722, 0, 389, 611, + 250, 389, 500, 500, 500, 500, 220, 500, 333, 747, 266, 500, 606, 333, 747, 333, + 400, 570, 300, 300, 333, 576, 500, 250, 333, 300, 300, 500, 750, 750, 750, 500, + 667, 667, 667, 667, 667, 667, 944, 667, 667, 667, 667, 667, 389, 389, 389, 389, + 722, 722, 722, 722, 722, 722, 722, 570, 722, 722, 722, 722, 722, 611, 611, 500, + 500, 500, 500, 500, 500, 500, 722, 444, 444, 444, 444, 444, 278, 278, 278, 278, + 500, 556, 500, 500, 500, 500, 500, 570, 500, 556, 556, 556, 556, 444, 500, 444, + ], + // Courier + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierBold + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // CourierBoldOblique + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, + 600, 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 0, + 0, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 0, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, 600, + ], + // Symbol + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278, + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444, + 549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722, + 768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500, + 500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549, + 549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603, + 400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658, + 823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713, + 768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603, + 494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494, + 0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0, + ], + // ZapfDingbats + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 278, 974, 961, 974, 980, 719, 789, 790, 791, 690, 960, 939, 549, 855, 911, 933, + 911, 945, 974, 755, 846, 762, 761, 571, 677, 763, 760, 759, 754, 494, 552, 537, + 577, 692, 786, 788, 788, 790, 793, 794, 816, 823, 789, 841, 823, 833, 816, 831, + 923, 744, 723, 749, 790, 792, 695, 776, 768, 792, 759, 707, 708, 682, 701, 826, + 815, 789, 789, 707, 687, 696, 689, 786, 787, 713, 791, 785, 791, 873, 761, 762, + 762, 759, 759, 892, 892, 788, 784, 438, 138, 277, 415, 392, 392, 668, 668, 0, + 390, 390, 317, 317, 276, 276, 509, 509, 410, 410, 234, 234, 334, 334, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 732, 544, 544, 910, 667, 760, 760, 776, 595, 694, 626, 788, 788, 788, 788, + 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, + 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, 788, + 788, 788, 788, 788, 894, 838, 1016, 458, 748, 924, 748, 918, 927, 928, 928, 834, + 873, 828, 924, 924, 917, 930, 931, 463, 883, 836, 836, 867, 867, 696, 696, 874, + 0, 874, 760, 946, 771, 865, 771, 888, 967, 888, 831, 873, 927, 970, 918, 0, + ], +]; diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 8d54c3d5..a3af3876 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -365,6 +365,13 @@ pub struct FontInfo { pub embedded: bool, pub subset: bool, pub encoding: Option, + /// The face standing in for a program the document does not embed. `None` when the font is + /// embedded (nothing stands in) or when no face was available at all -- `substitution` then + /// says which of the two it is. + pub substitute: Option, + /// Why a face was substituted, as a sentence for whoever has to act on it. `None` only when + /// the document's own glyphs are being shown. + pub substitution: Option, } /// Check that `bytes` still open as the same document: the parser reads them and the page @@ -1290,20 +1297,38 @@ impl<'a> Inspector<'a> { _ => None, }; // Embedded: FontFile/2/3 in the descriptor (for Type0, in the descendant font). - let descriptor_of = |d: &Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)).cloned(); - let desc = descriptor_of(f).or_else(|| { - f.get(b"DescendantFonts") - .ok() - .and_then(|o| self.resolve(o).as_array().ok()) - .and_then(|a| a.first()) - .and_then(|o| self.dict(o)) - .and_then(descriptor_of) - }); + let descriptor_of = |d: &'a Dictionary| d.get(b"FontDescriptor").ok().and_then(|o| self.dict(o)); + let descendant = + f.get(b"DescendantFonts").ok().and_then(|o| self.resolve(o).as_array().ok()).and_then(|a| a.first()).and_then(|o| self.dict(o)); + let desc = descriptor_of(f).or_else(|| descendant.and_then(descriptor_of)); let embedded = kind == "Type3" || desc.is_some_and(|d| d.get(b"FontFile").is_ok() || d.get(b"FontFile2").is_ok() || d.get(b"FontFile3").is_ok()); + // What the substitution chain needs beyond the name: the descriptor flags, and for + // a composite font the script its descendant declares (ISO 32000-2 9.7.3). + let flags = desc + .and_then(|d| d.get(b"Flags").ok()) + .and_then(|o| self.resolve(o).as_i64().ok()) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0); + let ordering = + descendant.and_then(|d| d.get(b"CIDSystemInfo").ok()).and_then(|o| self.dict(o)).and_then(|c| self.text(c, b"Ordering")); let subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase()); let name = if subset { base[7..].to_string() } else { base }; - let info = FontInfo { name, kind, embedded, subset, encoding }; + let chosen = pdfcraft_fonts::fallback::substitute(&pdfcraft_fonts::fallback::Request { + base_font: &name, + embedded, + flags, + ordering: ordering.as_deref(), + }); + let info = FontInfo { + name, + kind, + embedded, + subset, + encoding, + substitute: chosen.face_name(), + substitution: (!chosen.why.is_faithful()).then(|| chosen.why.note().to_owned()), + }; if seen.insert((info.name.clone(), info.kind.clone(), info.embedded)) { out.push(info); } @@ -1981,7 +2006,20 @@ trailer << /Root 1 0 R >> let expected: &[u8] = if a.name == "notes.txt" { b"hello notes" } else { b"a,b,c" }; assert_eq!(data, expected, "{}", a.name); } - assert_eq!(info.fonts, vec![FontInfo { name: "Helvetica".into(), kind: "Type1".into(), embedded: false, subset: true, encoding: None }]); + // A non-embedded /Helvetica is reported as standing in for itself: the standard-14 + // metrics are exact, so the substitution is named rather than left implicit. + assert_eq!( + info.fonts, + vec![FontInfo { + name: "Helvetica".into(), + kind: "Type1".into(), + embedded: false, + subset: true, + encoding: None, + substitute: Some("Helvetica".into()), + substitution: Some(pdfcraft_fonts::fallback::Why::NamedStandard14.note().into()), + }] + ); } const LAYERS: &[u8] = b"%PDF-1.7 @@ -2520,3 +2558,97 @@ mod version_tests { assert_eq!(super::version_label("Unknown"), "Unknown"); } } + +#[cfg(test)] +mod substitution_tests { + use std::sync::Arc; + + use super::{FontInfo, inspect}; + + /// One page naming four fonts the document does not embed: a Chinese CID font (Adobe-GB1), a + /// Japanese one (Adobe-Japan1), an unknown serif-flagged simple font, and an aliased + /// standard-14 name. + const NOT_EMBEDDED: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R /F2 6 0 R /F3 8 0 R /F4 9 0 R /F5 11 0 R >> >> >> endobj +4 0 obj << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [5 0 R] >> endobj +5 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >> endobj +6 0 obj << /Type /Font /Subtype /Type0 /BaseFont /KozMinPro-Regular /Encoding /UniJIS-UCS2-H /DescendantFonts [7 0 R] >> endobj +7 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /KozMinPro-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> >> endobj +8 0 obj << /Type /Font /Subtype /TrueType /BaseFont /SomeHouseSerif /FontDescriptor 10 0 R >> endobj +9 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ArialMT >> endobj +10 0 obj << /Type /FontDescriptor /FontName /SomeHouseSerif /Flags 34 >> endobj +11 0 obj << /Type /Font /Subtype /TrueType /BaseFont /HouseGrotesk /FontDescriptor 12 0 R >> endobj +12 0 obj << /Type /FontDescriptor /FontName /HouseGrotesk /Flags 33 >> endobj +trailer << /Size 13 /Root 1 0 R >> +%%EOF"; + + fn font<'a>(fonts: &'a [FontInfo], name: &str) -> &'a FontInfo { + fonts.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name} listed; got {:?}", fonts.iter().map(|f| &f.name).collect::>())) + } + + #[test] + fn doc_info_says_which_face_stands_in_and_why() { + let info = inspect(Arc::new(NOT_EMBEDDED.to_vec()), None).expect("opens"); + + // The one that matters most to a user: a Chinese PDF that draws nothing. Before this, + // doc_info said only `embedded: false` and left them to guess. + let cjk = font(&info.fonts, "STSong-Light"); + assert!(!cjk.embedded); + assert_eq!(cjk.substitute, None, "no licensed Chinese face, so nothing stands in"); + let why = cjk.substitution.as_deref().unwrap_or_default(); + assert!(why.contains("no licensed face") && why.contains("rather than the wrong glyphs"), "{why}"); + + // An aliased standard-14 name resolves to the real face, whose metrics are exact. + let arial = font(&info.fonts, "ArialMT"); + assert_eq!(arial.substitute.as_deref(), Some("Helvetica")); + assert!(arial.substitution.as_deref().unwrap_or_default().contains("standard 14")); + + // A house font whose name merely contains "Serif" gets Times -- reported as the guess it + // is, not as a standard-14 name, which would be untrue of a font nobody has heard of. + let serif = font(&info.fonts, "SomeHouseSerif"); + assert_eq!(serif.substitute.as_deref(), Some("Times-Roman")); + assert!(serif.substitution.as_deref().unwrap_or_default().contains("hints at one"), "{serif:?}"); + + // No hint in the name at all, so the descriptor flags decide: 33 = fixed pitch + nonsymbolic. + let grotesk = font(&info.fonts, "HouseGrotesk"); + assert_eq!(grotesk.substitute.as_deref(), Some("Courier")); + assert!(grotesk.substitution.as_deref().unwrap_or_default().contains("descriptor flags"), "{grotesk:?}"); + + // Japanese depends on the optional craft-fonts build input; either way it is reported, + // and never as a Latin face. + let jpan = font(&info.fonts, "KozMinPro-Regular"); + assert!(jpan.substitution.is_some(), "a substitution is always explained"); + match &jpan.substitute { + Some(face) => { + assert!(!face.starts_with("Helvetica") && !face.starts_with("Times"), "a Latin face is never a Japanese substitute: {face}") + } + None => assert!(jpan.substitution.as_deref().unwrap_or_default().contains("no licensed face")), + } + + // Nothing claims to be embedded, so every font here carries a reason. + assert!(info.fonts.iter().all(|f| !f.embedded && f.substitution.is_some()), "{:?}", info.fonts); + } + + /// An embedded font is reported as faithful: nothing stands in, and there is nothing to explain. + const EMBEDDED: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 4 0 R >> >> >> endobj +4 0 obj << /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+SomeHouseSans /FontDescriptor 5 0 R >> endobj +5 0 obj << /Type /FontDescriptor /FontName /ABCDEF+SomeHouseSans /Flags 32 /FontFile2 6 0 R >> endobj +6 0 obj << /Length 4 >> stream +test +endstream endobj +trailer << /Size 7 /Root 1 0 R >> +%%EOF"; + + #[test] + fn an_embedded_font_reports_no_substitution_at_all() { + let info = inspect(Arc::new(EMBEDDED.to_vec()), None).expect("opens"); + let f = font(&info.fonts, "SomeHouseSans"); + assert!(f.embedded && f.subset, "{f:?}"); + assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 756f7237..904ca868 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3436,8 +3436,8 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding"] -notes = "" +evidence = ["crates/ui-egui/tests/view.rs::fonts_tab_lists_fonts_and_embedding", "crates/render/src/inspect.rs::doc_info_says_which_face_stands_in_and_why", "crates/render/src/inspect.rs::an_embedded_font_reports_no_substitution_at_all"] +notes = "name, type, embedding, subset and encoding; doc_info also reports the face standing in for a program the document does not embed and why it was chosen (Acrobat's Actual Font), including the case where no licensed face exists so nothing is drawn. Still missing: the Fonts tab in the interface does not show the substituted face yet, only the data layer does" [[feature]] id = "organize.initial-view" diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index fc95a6ff..1352a64c 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -47,7 +47,7 @@ pub const ADOBE_DATA_ALLOWED: &[(&str, &str)] = &[("hayro-cmap", "assets/cmaps.b /// technical data we hold ourselves rather than through a dependency. Each still needs an /// `[[asset]]` entry with `adobe_data = true`, so its SHA-256 is checked and any change to the /// table has to be re-attested deliberately. -pub const ADOBE_DATA_FILES: &[&str] = &["crates/fonts/src/std14.rs"]; +pub const ADOBE_DATA_FILES: &[&str] = &["crates/fonts/src/std14_metrics.rs"]; /// File extensions that count as assets wherever they appear in the repository. const ASSET_EXTENSIONS: &[&str] = &[ @@ -580,7 +580,7 @@ mod tests { fn first_party_adobe_data_only_on_the_closed_list() { let l = lock(&[]); // The generated standard-14 table is on the list, as `kind = "data"`. - let mut ok = asset("crates/fonts/src/std14.rs", "Adobe (Core14 AFM metrics); tables by the PdfCraft contributors", "data", "MIT"); + let mut ok = asset("crates/fonts/src/std14_metrics.rs", "Adobe (Core14 AFM metrics); tables by the PdfCraft contributors", "data", "MIT"); ok.adobe_data = true; let m = Manifest { asset: vec![ok], ..Default::default() }; assert!(check(&root(), &m, &[], &l).is_empty(), "{:?}", check(&root(), &m, &[], &l)); @@ -592,7 +592,7 @@ mod tests { assert!(check(&root(), &m, &[], &l).iter().any(|p| p.contains("closed list"))); // Even on the list, it may only be data — never a typeface. - let mut wrong_kind = asset("crates/fonts/src/std14.rs", "Adobe", "font", "MIT"); + let mut wrong_kind = asset("crates/fonts/src/std14_metrics.rs", "Adobe", "font", "MIT"); wrong_kind.adobe_data = true; let m = Manifest { asset: vec![wrong_kind], ..Default::default() }; assert!(!check(&root(), &m, &[], &l).is_empty());