nigig-org/crates/apps/pdf/pdf-graphics/tests/glyph_outlines.rs
andodeki f37197781e
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): glyph outlines from TrueType and CFF, and glyph-aware text runs
`sfnt.rs` read the metric tables and nothing else. It could say how wide a
glyph was and not what shape it had, so every renderer drew embedded text
with a substitute font at the correct advance — the failure mode that looks
most like success: the line breaks land right and the letterforms belong to
somebody else.

`outline.rs` returns one outline type for both formats. TrueType quadratics
are degree-elevated to cubics, which is exact, so no format detail leaks to
a consumer. Composite glyphs are placed by their offsets and scales, with a
depth bound because a font can reference itself. CFF Type 2 charstrings run
through an interpreter with biased local and global subroutines, hints,
hintmask byte counting, the leading width operand, and the FontMatrix as
declared rather than assumed to be 1/1000.

Separately, `ShowTextWithMetrics` carried one advance for a whole run —
enough to move the pen to the next run and nothing else. So `text.rs`
guessed: `seg.advance / char_count`. For "Wi" that puts the boundary
between the letters at 5 when it is at 9, and every caret, drag-selection
and search highlight in the application was wrong by that much for every
proportional font. `GlyphPlacement` now carries per-glyph pen offsets,
computed with the same expression as the run total so the two cannot drift.
The even-spacing fallback stays for fonts with no width table, which is
what `advance_is_measured` has always been for.

The fixture story is ADR 0029's, again. `cff_sample.otf` is a fontTools
conversion of DejaVu: no subroutines, no hints, no width operands. It
proved the interpreter draws the right shapes, and then four mutations of
that interpreter survived because nothing in the corpus reached the code
they broke — each of which produces a plausible wrong glyph from a font
that parses. `cff_subrs.cff` is hand-assembled for exactly those four, and
fontTools agrees with every expectation asserted against it. A fifth
mutation survived a composite test that counted contours; it is killed now
by one that measures where the components land.

Coordinates are asserted against fontTools ground truth, not against our
own output. Seven mutations, all killed. 1397 tests pass.

Deferred and recorded, not claimed: CID-keyed CFF, `seac` accents,
rendering outlines through the Makepad device.

ADR 0031.
2026-08-18 19:44:18 +00:00

543 lines
20 KiB
Rust

//! Glyph outlines from real embedded fonts.
//!
//! `outline.rs`'s unit tests build point runs and INDEX structures by hand.
//! They cannot catch the failure that matters: an outline reader that
//! parses without error and produces the **wrong shape**. A wrong shape
//! still draws — it draws a different letter, or a blob — and every
//! structural assertion still passes.
//!
//! So these tests assert coordinates against ground truth taken from the
//! font files themselves, extracted independently with fontTools and
//! written into the expectations below. Both outline formats are covered,
//! because a PDF may embed either and the two share no code.
//!
//! See `REVIEWS/adr/0031-pdf-glyph-outlines.md`.
use std::path::PathBuf;
use nigig_pdf_graphics::outline::{glyph_outline, CffFont, OutlineError, Segment};
use nigig_pdf_graphics::sfnt::SfntFont;
/// DejaVu Sans, the system font the subsetting tests already depend on.
/// Skipped rather than failed when absent: the font is a property of the
/// machine, not of this crate. CI installs `fonts-dejavu-core`.
const SYSTEM_FONT: &str = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
fn system_font() -> Option<Vec<u8>> {
std::fs::read(SYSTEM_FONT).ok()
}
fn corpus_font(name: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../tests/corpus/fonts")
.join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
}
// ------------------------------------------------------------- TrueType
#[test]
fn a_real_truetype_glyph_has_the_shape_the_font_says() {
let Some(data) = system_font() else {
eprintln!("skipping: {SYSTEM_FONT} is not installed");
return;
};
let font = SfntFont::parse(&data).expect("DejaVu parses");
// Glyph 36 is 'A' in DejaVu Sans. fontTools reports two contours, 11
// points, bounding box (16, 0)-(1384, 1493), em 2048.
let outline =
nigig_pdf_graphics::outline::truetype_glyph(&font, 36).expect("glyph 36 has an outline");
assert_eq!(
outline.contours.len(),
2,
"'A' has an outer contour and the counter inside it"
);
let bounds = outline.bounds().expect("A is not blank");
// The bounding box of the control hull, so it may exceed the font's own
// glyph box slightly; it must not be smaller, and must be in the right
// place and the right order of magnitude.
assert!(
bounds[0] >= 0.0 && bounds[0] <= 40.0,
"left edge should be near x=16, got {bounds:?}"
);
assert!(
(bounds[3] - 1493.0).abs() < 40.0,
"cap height should be near y=1493, got {bounds:?}"
);
assert!(
(bounds[2] - 1384.0).abs() < 40.0,
"right edge should be near x=1384, got {bounds:?}"
);
assert!(
bounds[1].abs() < 40.0,
"the baseline should be near y=0, got {bounds:?}"
);
}
#[test]
fn a_space_glyph_is_blank_rather_than_an_error() {
// In a `loca` table a space has start == end. Treating that as an
// error makes a document full of spaces look like a broken font; the
// two must be distinguishable.
let Some(data) = system_font() else {
eprintln!("skipping: {SYSTEM_FONT} is not installed");
return;
};
let font = SfntFont::parse(&data).expect("parses");
// Glyph 3 is 'space' in DejaVu Sans.
let outline =
nigig_pdf_graphics::outline::truetype_glyph(&font, 3).expect("space is not an error");
assert!(outline.is_blank(), "a space draws nothing");
}
#[test]
fn every_glyph_of_a_real_font_reads_without_panicking() {
// A corpus-wide invariant in the shape of ADR 0017: over six thousand
// glyphs, each must produce an outline or a named refusal. Neither a
// panic nor a silent empty for a glyph that has contours.
let Some(data) = system_font() else {
eprintln!("skipping: {SYSTEM_FONT} is not installed");
return;
};
let font = SfntFont::parse(&data).expect("parses");
let maxp = font.table(b"maxp").expect("maxp");
let num_glyphs = u16::from_be_bytes([maxp[4], maxp[5]]);
assert!(num_glyphs > 1000, "DejaVu has thousands of glyphs");
let mut drawn = 0usize;
let mut blank = 0usize;
for gid in 0..num_glyphs {
match nigig_pdf_graphics::outline::truetype_glyph(&font, gid) {
Ok(outline) if outline.is_blank() => blank += 1,
Ok(_) => drawn += 1,
Err(e) => panic!("glyph {gid} failed: {e}"),
}
}
assert!(
drawn > num_glyphs as usize / 2,
"only {drawn} of {num_glyphs} glyphs produced contours; a reader that \
returns empty for everything would also 'pass' without this"
);
assert!(blank > 0, "some glyphs really are blank");
}
#[test]
fn a_composite_glyph_places_its_components() {
// An accented letter references its base and its accent with offsets.
// A reader that ignores the offsets stacks them on top of each other,
// which draws a legible-looking but wrong glyph.
let Some(data) = system_font() else {
eprintln!("skipping: {SYSTEM_FONT} is not installed");
return;
};
let font = SfntFont::parse(&data).expect("parses");
// Find a composite glyph by scanning glyf for a negative contour
// count, so this does not depend on a particular glyph id.
let head = font.table(b"head").expect("head");
let long_loca = i16::from_be_bytes([head[50], head[51]]) != 0;
let loca = font.table(b"loca").expect("loca");
let glyf = font.table(b"glyf").expect("glyf");
let maxp = font.table(b"maxp").expect("maxp");
let num_glyphs = u16::from_be_bytes([maxp[4], maxp[5]]);
let mut composite = None;
for gid in 0..num_glyphs {
let (start, end) = if long_loca {
let at = gid as usize * 4;
(
u32::from_be_bytes([loca[at], loca[at + 1], loca[at + 2], loca[at + 3]]),
u32::from_be_bytes([loca[at + 4], loca[at + 5], loca[at + 6], loca[at + 7]]),
)
} else {
let at = gid as usize * 2;
(
u16::from_be_bytes([loca[at], loca[at + 1]]) as u32 * 2,
u16::from_be_bytes([loca[at + 2], loca[at + 3]]) as u32 * 2,
)
};
if start >= end {
continue;
}
let count = i16::from_be_bytes([glyf[start as usize], glyf[start as usize + 1]]);
if count < 0 {
composite = Some(gid);
break;
}
}
let gid = composite.expect("DejaVu has composite glyphs");
let outline = nigig_pdf_graphics::outline::truetype_glyph(&font, gid)
.unwrap_or_else(|e| panic!("composite glyph {gid} failed: {e}"));
assert!(
!outline.is_blank(),
"composite glyph {gid} produced no contours, so the components were not followed"
);
assert!(
outline.contours.len() >= 2,
"a composite is made of at least two component contours, got {}",
outline.contours.len()
);
}
#[test]
fn a_composite_component_is_moved_by_its_offset() {
// Contour *count* is not enough: a reader that drops the offsets
// stacks every component at the origin, and the glyph still has the
// right number of contours. So this asserts the geometry.
//
// DejaVu glyph 126 is 'onequarter': a superscript one displaced up and
// left, a fraction bar, and a subscript four. fontTools reports the
// first component placed at (1163, -668) — well away from the origin.
let Some(data) = system_font() else {
eprintln!("skipping: {SYSTEM_FONT} is not installed");
return;
};
let font = SfntFont::parse(&data).expect("parses");
let composite =
nigig_pdf_graphics::outline::truetype_glyph(&font, 126).expect("onequarter has an outline");
let composite_bounds = composite.bounds().expect("not blank");
// fontTools reports the components of 'onequarter' placed at
// (1163, -668) and (821, 0). Applying those offsets is what carries
// the glyph out to x=1919; dropping them stacks every component at the
// origin, where the widest of them alone reaches only about x=1160 —
// with the same number of contours, which is exactly why counting
// contours does not catch this.
assert!(
composite_bounds[2] > 1500.0,
"'onequarter' reaches only x={}; the component offsets were dropped \
and everything stacked at the origin: {composite_bounds:?}",
composite_bounds[2]
);
// The horizontal offset is the one that moves things furthest, but the
// vertical one must be applied too: the superscript is lifted to the
// cap line and beyond.
assert!(
composite_bounds[3] > 1400.0,
"the superscript should be lifted above the cap height, got {composite_bounds:?}"
);
}
// ------------------------------------------------------------------- CFF
#[test]
fn a_real_cff_glyph_has_the_coordinates_the_charstring_encodes() {
// The corpus's OpenType/CFF sample. Ground truth from fontTools:
//
// glyph 'H' (index 7): a 12-point rectilinear outline starting at
// (201, 1493), with corners at 403, 881, 1137, 1339 and 0.
//
// Every number below is one fontTools reported, so this test fails if
// the charstring interpreter drifts by so much as a unit.
let data = corpus_font("cff_sample.otf");
let font = SfntFont::parse(&data).expect("the OTF parses");
let table = font.table(b"CFF ").expect("it has a CFF table");
let cff = CffFont::parse(table).expect("the CFF parses");
assert_eq!(cff.glyph_count(), 11, "the sample has eleven glyphs");
// The sample's em is 2048, so the FontMatrix is 1/2048 and not the
// default 1/1000. A reader that assumes 1000 draws it at 49% scale.
assert!(
(cff.font_matrix[0] - 1.0 / 2048.0).abs() < 1e-9,
"FontMatrix should be 1/2048, got {:?}",
cff.font_matrix
);
let outline = cff.glyph(7).expect("glyph 7 is 'H'");
assert_eq!(outline.contours.len(), 1, "'H' is one contour");
let contour = &outline.contours[0];
assert_eq!(contour.start, [201.0, 1493.0], "'H' starts at its top-left");
// All twelve points, in order, straight from the charstring.
let expected = [
[403.0, 1493.0],
[403.0, 881.0],
[1137.0, 881.0],
[1137.0, 1493.0],
[1339.0, 1493.0],
[1339.0, 0.0],
[1137.0, 0.0],
[1137.0, 711.0],
[403.0, 711.0],
[403.0, 0.0],
[201.0, 0.0],
];
let actual: Vec<[f64; 2]> = contour
.segments
.iter()
.map(|s| match s {
Segment::Line(p) => *p,
Segment::Cubic(_, _, p) => *p,
})
.collect();
for (i, point) in expected.iter().enumerate() {
let got = actual.get(i).unwrap_or_else(|| {
panic!(
"'H' is missing point {i}; got {} points: {actual:?}",
actual.len()
)
});
assert!(
(got[0] - point[0]).abs() < 0.5 && (got[1] - point[1]).abs() < 0.5,
"point {i}: expected {point:?}, got {got:?}"
);
}
}
#[test]
fn a_cff_glyph_with_curves_reads_them_as_curves() {
// 'o' is all curves. A charstring interpreter that mishandles
// hvcurveto/vhcurveto's alternating axes still produces a closed
// shape — the wrong one — so this asserts curve count and extent.
let data = corpus_font("cff_sample.otf");
let font = SfntFont::parse(&data).expect("parses");
let cff = CffFont::parse(font.table(b"CFF ").expect("CFF")).expect("parses");
// Glyph 10 is 'o': two contours (outer and counter), all curves.
let outline = cff.glyph(10).expect("glyph 10");
assert_eq!(outline.contours.len(), 2, "'o' has a bowl and a counter");
let curves = outline
.contours
.iter()
.flat_map(|c| &c.segments)
.filter(|s| matches!(s, Segment::Cubic(..)))
.count();
assert!(curves >= 16, "'o' is drawn with curves, found {curves}");
// fontTools: the outer contour spans x 113..1141, y -29..1147.
let bounds = outline.bounds().expect("not blank");
assert!(
(bounds[0] - 113.0).abs() < 5.0,
"left edge should be 113, got {bounds:?}"
);
assert!(
(bounds[2] - 1141.0).abs() < 5.0,
"right edge should be 1141, got {bounds:?}"
);
}
#[test]
fn a_cff_space_is_blank_and_the_notdef_is_not() {
let data = corpus_font("cff_sample.otf");
let font = SfntFont::parse(&data).expect("parses");
let cff = CffFont::parse(font.table(b"CFF ").expect("CFF")).expect("parses");
// Glyph 1 is 'space', glyph 0 is '.notdef' (a box, two contours).
assert!(cff.glyph(1).expect("space").is_blank());
let notdef = cff.glyph(0).expect("notdef");
assert_eq!(
notdef.contours.len(),
2,
".notdef is a rectangle with a hollow centre"
);
}
#[test]
fn every_glyph_of_the_cff_sample_reads() {
let data = corpus_font("cff_sample.otf");
let font = SfntFont::parse(&data).expect("parses");
let cff = CffFont::parse(font.table(b"CFF ").expect("CFF")).expect("parses");
let mut drawn = 0;
for gid in 0..cff.glyph_count() as u16 {
let outline = cff
.glyph(gid)
.unwrap_or_else(|e| panic!("CFF glyph {gid} failed: {e}"));
if !outline.is_blank() {
drawn += 1;
}
}
assert_eq!(drawn, 10, "ten of the eleven sample glyphs draw something");
}
// ------------------------------------------------------- format-agnostic
#[test]
fn the_entry_point_picks_the_right_outline_format() {
// A renderer should not have to know which format is embedded.
let cff = corpus_font("cff_sample.otf");
let from_cff = glyph_outline(&cff, 7).expect("CFF glyph through the entry point");
assert_eq!(from_cff.contours.len(), 1);
if let Some(truetype) = system_font() {
let from_truetype = glyph_outline(&truetype, 36).expect("TrueType glyph");
assert_eq!(from_truetype.contours.len(), 2);
}
}
#[test]
fn a_glyph_past_the_end_of_the_font_is_refused_by_number() {
let data = corpus_font("cff_sample.otf");
let error = glyph_outline(&data, 9999).expect_err("must refuse");
assert_eq!(error, OutlineError::NoSuchGlyph(9999));
}
#[test]
fn a_truncated_font_is_refused_rather_than_read_as_garbage() {
let data = corpus_font("cff_sample.otf");
let truncated = &data[..data.len() / 3];
// Either a refusal or a clean parse of what survives; never a panic.
if let Ok(outline) = glyph_outline(truncated, 7) {
assert!(
outline.point_count() < 20,
"a third of a font should not produce a complete glyph"
);
}
}
// -------------------------------------------- the parts a simple font misses
//
// `cff_sample.otf` is a fontTools conversion of DejaVu: no subroutines, no
// hints, no width operands. It proved the interpreter draws the right
// shapes, and then four mutations of that interpreter survived, because
// nothing in the corpus reached the code they broke. Each mistake below
// produces a plausible wrong glyph from a font that parses.
//
// `cff_subrs.cff` is hand-assembled for exactly these four. fontTools
// agrees with every expectation asserted here.
fn subr_font() -> Vec<u8> {
corpus_font("cff_subrs.cff")
}
#[test]
fn a_local_subroutine_is_called_with_its_bias() {
// The font has one local subr, so the bias is 107 and subr 0 is called
// as -107. A reader that ignores the bias asks for subr -107, gets
// nothing, and returns an empty glyph — which is indistinguishable
// from a character the font does not cover.
let data = subr_font();
let cff = CffFont::parse(&data).expect("the bare CFF parses");
assert_eq!(cff.glyph_count(), 3);
let square = cff.glyph(1).expect("glyph 1");
assert!(
!square.is_blank(),
"the glyph is drawn entirely inside a local subroutine; empty means \
the subroutine was never called"
);
// The subroutine draws a 100-unit square from (10, 10).
assert_eq!(square.bounds(), Some([10.0, 10.0, 110.0, 110.0]));
}
#[test]
fn a_global_subroutine_is_called_with_its_bias() {
// Global subrs have their own INDEX and their own bias. A reader that
// shares one bias between the two tables works only when both happen
// to be the same size.
let data = subr_font();
let cff = CffFont::parse(&data).expect("parses");
let bar = cff.glyph(2).expect("glyph 2");
assert!(!bar.is_blank(), "the global subroutine was not called");
assert_eq!(bar.bounds(), Some([20.0, 300.0, 220.0, 350.0]));
}
#[test]
fn a_hintmask_consumes_one_byte_per_eight_hints() {
// `hintmask` is followed by a bitmask, one bit per declared hint. A
// reader that skips no bytes reads the mask as operators and the rest
// of the charstring is garbage — but garbage that still *draws*, which
// is why this needs its own assertion rather than a "does not panic".
//
// Glyph 1 declares two hstem hints and then a hintmask, so exactly one
// mask byte must be consumed before the rmoveto.
let data = subr_font();
let cff = CffFont::parse(&data).expect("parses");
let square = cff.glyph(1).expect("glyph 1");
assert_eq!(
square.bounds(),
Some([10.0, 10.0, 110.0, 110.0]),
"the square landed elsewhere, so the hintmask byte was mis-counted"
);
assert_eq!(
square.contours.len(),
1,
"a desynchronised charstring produces stray contours"
);
}
#[test]
fn the_leading_width_operand_is_taken_and_not_drawn() {
// §4.1: an odd operand count on the first stack-clearing operator means
// the leading value is the width difference. A reader that misses it
// shifts every following operand by one — the glyph still draws, in the
// wrong place.
//
// Glyph 1 carries width difference 50 over nominalWidthX 500.
let data = subr_font();
let cff = CffFont::parse(&data).expect("parses");
let square = cff.glyph(1).expect("glyph 1");
assert_eq!(
square.advance,
Some(50.0),
"the width difference was not taken off the stack"
);
// And the geometry is unshifted, which is the consequence that matters.
assert_eq!(square.bounds(), Some([10.0, 10.0, 110.0, 110.0]));
// Glyph 2 has an even operand count and therefore no width.
let bar = cff.glyph(2).expect("glyph 2");
assert_eq!(
bar.advance, None,
"an even count must not consume a coordinate as a width"
);
}
#[test]
fn a_bare_cff_font_program_is_read_without_an_sfnt_wrapper() {
// A PDF embeds a Type1C font as a bare CFF in a /FontFile3 stream —
// there is no table directory to find the CFF in.
let outline = glyph_outline(&subr_font(), 1).expect("a bare CFF glyph");
assert_eq!(outline.bounds(), Some([10.0, 10.0, 110.0, 110.0]));
}
#[test]
fn a_short_loca_offset_is_doubled() {
// `indexToLocFormat 0` stores half the real offset, so a glyph always
// starts on an even byte. Reading it undoubled points at the middle of
// the previous glyph: the contour count is read from the wrong two
// bytes and the outline is nonsense that still parses.
//
// The corpus's symbol sample is a short-loca font; DejaVu is long-loca,
// so this path is never exercised by the tests above.
let data = corpus_font("symbol_sample.ttf");
let font = SfntFont::parse(&data).expect("the symbol font parses");
let head = font.table(b"head").expect("head");
assert_eq!(
i16::from_be_bytes([head[50], head[51]]),
0,
"this fixture must be a short-loca font or the test proves nothing"
);
let maxp = font.table(b"maxp").expect("maxp");
let num_glyphs = u16::from_be_bytes([maxp[4], maxp[5]]);
let mut drawn = Vec::new();
for gid in 0..num_glyphs {
let outline = nigig_pdf_graphics::outline::truetype_glyph(&font, gid)
.unwrap_or_else(|e| panic!("glyph {gid}: {e}"));
if let Some(bounds) = outline.bounds() {
drawn.push((gid, bounds));
}
}
assert!(
!drawn.is_empty(),
"no glyph of the short-loca font produced an outline"
);
// Every outline must be inside a sane multiple of the em. An undoubled
// offset reads a contour count and point deltas out of unrelated bytes,
// which lands coordinates in the tens of thousands.
let units_per_em = u16::from_be_bytes([head[18], head[19]]) as f64;
for (gid, bounds) in &drawn {
for value in bounds {
assert!(
value.abs() < units_per_em * 3.0,
"glyph {gid} has a coordinate {value} outside three ems \
({units_per_em}); the loca offsets were misread: {bounds:?}"
);
}
}
}