nigig-org/crates/apps/pdf/pdf-graphics/tests/subset_real_font.rs
andodeki 7d6fc4cbbe
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
feat(pdf): document creation — outlines, forms, attachments, font subsetting
Phase 4 of NIGIG_PDF_FEATURE_PARITY_PLAN.md. ADR 0019.

Almost none of it existed: Outlines, PageLabels, EmbeddedFiles and
ViewerPreferences appeared nowhere in the workspace, in any crate. What did
exist was a builder whose central method was

  pub fn add_page_with_content(&mut self, _width: f64, _height: f64, ...)

which accepted a page size and discarded it. Asking for 200x400 and 300x500
gave two US Letter pages, because no /MediaBox was written at all. The test
asserted the output contained the string "/Type /Page", which it did.

Two more defects sat in the object writer, both producing files our own
parser rejects: dictionary keys were written unescaped (a key with a space
reparses as "expected number"), and f64::NAN was emitted as the literal
token NaN, so one non-finite value anywhere made the document unreadable.

Added: outline trees with the open/closed state in the sign of /Count,
/PageLabels as a number tree with real roman and A..Z/AA..ZZ numbering,
named destinations, attachments with file specs, /Info, XMP, viewer
preferences, page mode and layout; AcroForm creation for text, checkbox,
radio, choice and signature fields with generated appearances; and
TrueType subsetting - DejaVu Sans goes from 759,720 bytes to 4,348 for
twelve characters.

cmap is deliberately not rebuilt: the subset is embedded as a CID font with
Identity-H, so the content stream addresses glyphs by id and /ToUnicode
serves extraction. A cmap disagreeing with the content stream is worse than
none. CFF is refused by name rather than emitting a font with no glyphs.

Nine real bugs, every one found by running the output through an
independent tool rather than by reading the code:

  1 page size discarded              reading a generated file back
  2 dict keys unescaped              probing the writer
  3 NaN written as a keyword         probing the writer
  4 subset zeroed the lsb            fontTools outline compare
  5 hmtx indexed by new gid          fontTools outline compare
  6 name table format read as count  BaseFont came out "Embedded"
  7 add_font shifted numbers already handed out
  8 trees allocated over font numbers - object 29 written twice
  9 widgets missing /F Print, /P and appearance /Resources

7 and 8 are the instructive pair: every reference resolved and every object
existed, each simply named the wrong thing. pypdf reported correct field
values from a file PDFium rendered blank. 9 is the one only a renderer could
find - /F defaults to non-printable, and a form XObject naming a font its
/Resources does not declare is discarded whole.

Verified by three independent implementations: fontTools (0 outline
mismatches of 12 against the source font), pypdf (metadata, page sizes,
outline with resolved page numbers, all five fields, attachment
byte-for-byte, labels ['i','1']) and PDFium, which renders both pages
correctly. cargo run -p nigig-pdf-graphics --example generate_sample
regenerates the sample.

Fourteen mutations. Three survived and each exposed a weak test: the key
test used an attachment name (written as a string, never a key), nothing
read the outline open state, and /P could not be witnessed because
page_index is supplied by the reader, which already knows the page. All
three now killed.

pdf: 789 passed (was 730). pdf-ui: 775. Coverage 85.17%.
2026-08-16 21:02:19 +00:00

221 lines
8.3 KiB
Rust

//! Subsetting a real TrueType font.
//!
//! The unit tests in `subset.rs` cover refusals and edge cases with
//! hand-built table stubs. They cannot catch the failure that actually
//! happened: a subset that is *structurally valid* — correct table
//! directory, checksums, every glyph decoding — and whose glyphs are in the
//! wrong place. Writing a zero left side bearing shifted every outline
//! horizontally, and `hmtx` was indexed by new glyph id against the
//! original font, so glyphs took other glyphs' metrics.
//!
//! So these tests assert **geometry against the source font**, not that the
//! subset parses.
//!
//! Skipped when the system font is absent rather than failing, because the
//! font is a property of the machine and not of this crate. CI installs
//! `fonts-dejavu-core`.
//!
//! See `REVIEWS/adr/0019-pdf-document-creation.md`.
use std::collections::BTreeSet;
use nigig_pdf_graphics::sfnt::SfntFont;
use nigig_pdf_graphics::subset::{glyph_index, subset_truetype, SubsetError};
const FONT: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
fn font_bytes() -> Option<Vec<u8>> {
std::fs::read(FONT).ok()
}
/// Read a glyph's advance and left side bearing straight from a font's
/// `hmtx`, independently of the subsetter.
fn metrics_of(data: &[u8], gid: u16) -> (u16, i16) {
let font = SfntFont::parse(data).expect("parses");
let metrics = font.metrics().expect("metrics");
(
font.advance(gid, &metrics).expect("advance"),
font.left_side_bearing(gid, &metrics).expect("lsb"),
)
}
#[test]
fn a_subset_keeps_only_the_glyphs_asked_for() {
let Some(data) = font_bytes() else { return };
let chars: BTreeSet<char> = "Hello".chars().collect();
let subset = subset_truetype(&data, &chars).expect("subsets");
// H, e, l, o plus .notdef. No composites are involved here, so the
// count is exact.
assert_eq!(
subset.glyph_map.len(),
5,
"expected .notdef plus four distinct letters, got {:?}",
subset.to_unicode
);
assert!(
subset.data.len() < data.len() / 50,
"a five-glyph subset of a 750 KB font should be tiny, got {} bytes",
subset.data.len()
);
// .notdef must survive: a TrueType font without glyph 0 is invalid.
assert_eq!(subset.glyph_map.get(&0), Some(&0));
}
#[test]
fn every_subset_glyph_keeps_its_advance_and_bearing() {
// This is the regression. The outlines were intact and every letter
// was drawn shifted left by its own left side bearing, because the
// rebuilt hmtx wrote a zero bearing and read advances by the new glyph
// id from the *original* font.
let Some(data) = font_bytes() else { return };
let chars: BTreeSet<char> = "HWdelor,!".chars().collect();
let subset = subset_truetype(&data, &chars).expect("subsets");
for (&old_gid, &new_gid) in &subset.glyph_map {
let expected = metrics_of(&data, old_gid);
let actual = metrics_of(&subset.data, new_gid);
assert_eq!(
expected, actual,
"glyph {old_gid} -> {new_gid}: expected (advance, lsb) {expected:?}, got {actual:?}"
);
}
}
#[test]
fn a_composite_glyph_brings_its_components_with_it() {
// "é" is a composite of "e" and an accent. Subsetting without
// following the reference leaves a glyph that renders as nothing.
let Some(data) = font_bytes() else { return };
let mut chars = BTreeSet::new();
chars.insert('é');
let subset = subset_truetype(&data, &chars).expect("subsets");
assert!(
subset.glyph_map.len() > 2,
"é is composite, so its components must be kept too; got {:?}",
subset.glyph_map
);
// Each component named inside the subset must exist in the subset.
let font = SfntFont::parse(&subset.data).expect("subset parses");
let maxp = font.table(b"maxp").expect("maxp");
let num_glyphs = u16::from_be_bytes([maxp[4], maxp[5]]);
assert_eq!(num_glyphs as usize, subset.glyph_map.len());
}
#[test]
fn widths_are_in_pdf_thousandths_and_match_the_source() {
let Some(data) = font_bytes() else { return };
let chars: BTreeSet<char> = "Hi".chars().collect();
let subset = subset_truetype(&data, &chars).expect("subsets");
let source = SfntFont::parse(&data).expect("parses");
let metrics = source.metrics().expect("metrics");
let scale = 1000.0 / metrics.units_per_em as f64;
for (&old_gid, &new_gid) in &subset.glyph_map {
let expected = source.advance(old_gid, &metrics).expect("advance") as f64 * scale;
let actual = subset.widths[new_gid as usize];
assert!(
(expected - actual).abs() < 0.05,
"glyph {new_gid}: expected width {expected:.2}, got {actual:.2}"
);
}
// A width of zero for a visible letter is the silent-empty failure.
let h = subset.glyph_for('H').expect("H is in the subset");
assert!(
subset.widths[h as usize] > 100.0,
"a capital H cannot be {} units wide",
subset.widths[h as usize]
);
}
#[test]
fn identity_h_encoding_is_two_bytes_per_glyph() {
let Some(data) = font_bytes() else { return };
let chars: BTreeSet<char> = "AB".chars().collect();
let subset = subset_truetype(&data, &chars).expect("subsets");
let encoded = subset.encode("AB");
assert_eq!(encoded.len(), 4, "two glyphs, two bytes each");
let a = subset.glyph_for('A').expect("A present");
let b = subset.glyph_for('B').expect("B present");
assert_eq!(
encoded,
vec![(a >> 8) as u8, a as u8, (b >> 8) as u8, b as u8]
);
}
#[test]
fn a_character_outside_the_subset_is_dropped_not_rendered_as_notdef() {
let Some(data) = font_bytes() else { return };
let chars: BTreeSet<char> = "A".chars().collect();
let subset = subset_truetype(&data, &chars).expect("subsets");
assert!(subset.glyph_for('Z').is_none());
// "AZA" keeps only the two A's rather than emitting .notdef boxes.
assert_eq!(subset.encode("AZA").len(), 4);
}
#[test]
fn text_width_sums_the_glyph_advances() {
let Some(data) = font_bytes() else { return };
let chars: BTreeSet<char> = "il".chars().collect();
let subset = subset_truetype(&data, &chars).expect("subsets");
let one = subset.text_width("i", 12.0);
let two = subset.text_width("ii", 12.0);
assert!(one > 0.0, "a glyph must have a width");
assert!(
(two - one * 2.0).abs() < 1e-9,
"two of the same glyph must be twice as wide"
);
// Scaling with the point size.
assert!((subset.text_width("i", 24.0) - one * 2.0).abs() < 1e-9);
}
#[test]
fn the_subset_is_byte_identical_between_runs() {
// A generated PDF that differs run to run cannot be diffed in review
// or cached. The subset tag is derived from the glyph set for exactly
// this reason.
let Some(data) = font_bytes() else { return };
let chars: BTreeSet<char> = "Reproducible".chars().collect();
let first = subset_truetype(&data, &chars).expect("subsets");
let second = subset_truetype(&data, &chars).expect("subsets");
assert_eq!(first.data, second.data);
}
#[test]
fn glyph_lookup_finds_a_known_character() {
let Some(data) = font_bytes() else { return };
let font = SfntFont::parse(&data).expect("parses");
// Assert against a value read from the font, not a hardcoded id.
let a = glyph_index(&font, 'A').expect("A must be in DejaVu Sans");
let b = glyph_index(&font, 'B').expect("B must be in DejaVu Sans");
assert_ne!(a, 0, "A must not map to .notdef");
assert_ne!(a, b, "different characters need different glyphs");
// A character DejaVu Sans does not contain.
assert!(glyph_index(&font, '\u{10FFFF}').is_none());
}
#[test]
fn subsetting_a_cff_font_is_refused_rather_than_silently_wrong() {
// Nothing on this machine is guaranteed to be CFF, so this drives the
// refusal through the public entry point with a synthetic OTTO header.
let mut data = Vec::new();
data.extend_from_slice(b"OTTO");
data.extend_from_slice(&1u16.to_be_bytes());
data.extend_from_slice(&[0; 6]);
data.extend_from_slice(b"CFF ");
data.extend_from_slice(&0u32.to_be_bytes());
data.extend_from_slice(&28u32.to_be_bytes());
data.extend_from_slice(&4u32.to_be_bytes());
data.extend_from_slice(&[0; 4]);
assert_eq!(
subset_truetype(&data, &BTreeSet::new()).unwrap_err(),
SubsetError::CffNotSupported
);
}