//! 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> { std::fs::read(SYSTEM_FONT).ok() } fn corpus_font(name: &str) -> Vec { 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 { 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:?}" ); } } }