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%.
221 lines
8.3 KiB
Rust
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
|
|
);
|
|
}
|