Skip to content

Commit f300b0f

Browse files
ajbufortclaude
andcommitted
test: prove the embedded-font subset choice is deterministic
Addresses review on #853: the fix removes HashMap-iteration nondeterminism but shipped no test, which for a determinism fix is the one thing that must not be taken on trust. Add a one-page PDF embedding two subsets of the same base font - ABCDEF+Helvetica and GHIJKL+Helvetica - with font programs of different sizes, and assert that: - the two subsets dedup to a single entry, prefix stripped - the LARGER program wins regardless of which resource slot holds it (the helper swaps them), so the choice follows the total order rather than encounter order - repeated extraction of one unchanged PDF is byte-identical Extraction is repeated 64 times because each call builds a fresh HashMap whose iteration order is independently seeded: one round would pass on the old code by luck. Verified the test EARNS its place - reverting either site to `or_insert` fails it at round 2, on both the plain and the with-unicode-maps variant (which carries its own copy of the choice, so it gets its own guard). Also soften the "largest = most glyph coverage" comment, which overstated the claim: byte size grows with hinting and auxiliary tables too, so a larger subset is not necessarily a superset. The guarantee is stability, not coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Anthony J. (Tony) Bufort <ajbufort@ajbconsulting.us>
1 parent a52edec commit f300b0f

1 file changed

Lines changed: 181 additions & 6 deletions

File tree

src/document.rs

Lines changed: 181 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22280,8 +22280,14 @@ impl PdfDocument {
2228022280
// When several subsets share a base name, `get_font_set()` yields
2228122281
// them in HashMap order, so `or_insert` kept a NONDETERMINISTIC
2228222282
// one - the returned bytes changed run to run for the same PDF.
22283-
// Keep the LARGEST subset (most glyph coverage), with a byte
22284-
// comparison to break ties, so the choice is total-order stable.
22283+
//
22284+
// Choose by a TOTAL ORDER instead: largest program, ties broken
22285+
// bytewise. Size is only a heuristic for "the richer subset" - a
22286+
// program's byte count also grows with hinting and auxiliary
22287+
// tables, so a larger subset is not necessarily a superset of a
22288+
// smaller one. What the total order does guarantee is the property
22289+
// callers actually depend on: the same PDF always yields the same
22290+
// bytes.
2228522291
match by_name.entry(canonical.to_string()) {
2228622292
std::collections::hash_map::Entry::Vacant(v) => {
2228722293
v.insert(data.as_ref().clone());
@@ -22500,10 +22506,21 @@ impl PdfDocument {
2250022506
let entry = by_name
2250122507
.entry(canonical.to_string())
2250222508
.or_insert_with(|| (data.as_ref().clone(), HashMap::new(), HashMap::new()));
22503-
// Deterministic subset choice: keep the largest font program
22504-
// (tie-broken by bytes) instead of whichever HashMap order
22505-
// surfaced first. The Unicode/width maps accumulate across all
22506-
// subsets below regardless, so coverage is never reduced.
22509+
// Same total-order choice as `extract_embedded_fonts`: largest
22510+
// program, ties broken bytewise, rather than whichever HashMap
22511+
// order surfaced first. (Size is a heuristic for the richer
22512+
// subset, not a proof of superset - see the note there.)
22513+
//
22514+
// KNOWN GAP, deliberately left for a follow-up: the maps below
22515+
// still merge across ALL subsets while the emitted program is now
22516+
// a single chosen one, so a GID in the maps need not exist in the
22517+
// program we hand back. Worse, when two subsets disagree about a
22518+
// codepoint's GID - which subsets of one base font routinely do -
22519+
// `or_insert` keeps whichever arrived first, so the maps carry the
22520+
// very HashMap-order nondeterminism this fix removes from the
22521+
// program. Fixing it means binding the maps to the chosen subset
22522+
// instead of merging; that is a behaviour change (coverage may
22523+
// shrink where subsets are disjoint) and belongs in its own PR.
2250722524
let cand = data.as_ref();
2250822525
if (cand.len(), cand.as_slice()) > (entry.0.len(), entry.0.as_slice()) {
2250922526
entry.0 = cand.clone();
@@ -23534,6 +23551,164 @@ mod tests {
2353423551

2353523552
/// Build a minimal PDF with a `/Font` resource (needed for `Tf`/`Tj`
2353623553
/// to resolve glyph widths), used by the NaN-bbox regression test.
23554+
/// Build a one-page PDF embedding TWO subsets of the SAME base font -
23555+
/// `ABCDEF+Helvetica` and `GHIJKL+Helvetica` - whose font programs differ
23556+
/// in size. `big_in_f1` chooses which resource slot carries the larger
23557+
/// program, so a caller can show the choice does not depend on the order
23558+
/// the fonts are encountered.
23559+
///
23560+
/// Returns `(pdf_bytes, small_program, big_program)`. The programs are not
23561+
/// real TrueType: `FontFile2` is decoded and stored verbatim, never parsed,
23562+
/// so distinguishable payloads keep the test on the dedup logic.
23563+
fn build_pdf_with_two_font_subsets(big_in_f1: bool) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
23564+
let small: Vec<u8> = b"SMALL-SUBSET-".iter().cycle().take(64).copied().collect();
23565+
let big: Vec<u8> = b"BIG-SUBSET-".iter().cycle().take(512).copied().collect();
23566+
let (f1_prog, f2_prog) = if big_in_f1 {
23567+
(big.clone(), small.clone())
23568+
} else {
23569+
(small.clone(), big.clone())
23570+
};
23571+
23572+
let mut pdf = b"%PDF-1.4\n".to_vec();
23573+
let mut offs: Vec<usize> = Vec::new();
23574+
23575+
offs.push(pdf.len());
23576+
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
23577+
23578+
offs.push(pdf.len());
23579+
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
23580+
23581+
offs.push(pdf.len());
23582+
pdf.extend_from_slice(
23583+
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
23584+
/Resources << /Font << /F1 4 0 R /F2 7 0 R >> >> >>\nendobj\n",
23585+
);
23586+
23587+
// /F1 = ABCDEF+Helvetica, /F2 = GHIJKL+Helvetica. Same canonical base
23588+
// name, so they must dedup to a single entry.
23589+
for (obj, prefix, desc_obj, file_obj, prog) in
23590+
[(4, "ABCDEF", 5, 6, &f1_prog), (7, "GHIJKL", 8, 9, &f2_prog)]
23591+
{
23592+
offs.push(pdf.len());
23593+
pdf.extend_from_slice(
23594+
format!(
23595+
"{obj} 0 obj\n<< /Type /Font /Subtype /TrueType /BaseFont /{prefix}+Helvetica \
23596+
/FontDescriptor {desc_obj} 0 R >>\nendobj\n"
23597+
)
23598+
.as_bytes(),
23599+
);
23600+
23601+
offs.push(pdf.len());
23602+
pdf.extend_from_slice(
23603+
format!(
23604+
"{desc_obj} 0 obj\n<< /Type /FontDescriptor /FontName /{prefix}+Helvetica \
23605+
/Flags 32 /FontFile2 {file_obj} 0 R >>\nendobj\n"
23606+
)
23607+
.as_bytes(),
23608+
);
23609+
23610+
offs.push(pdf.len());
23611+
pdf.extend_from_slice(
23612+
format!(
23613+
"{file_obj} 0 obj\n<< /Length {} /Length1 {} >>\nstream\n",
23614+
prog.len(),
23615+
prog.len()
23616+
)
23617+
.as_bytes(),
23618+
);
23619+
pdf.extend_from_slice(prog);
23620+
pdf.extend_from_slice(b"\nendstream\nendobj\n");
23621+
}
23622+
23623+
// Objects were emitted 1,2,3 then 4,5,6 then 7,8,9 - already in order.
23624+
let xref_off = pdf.len();
23625+
let total = offs.len() + 1;
23626+
pdf.extend_from_slice(format!("xref\n0 {total}\n").as_bytes());
23627+
pdf.extend_from_slice(b"0000000000 65535 f \n");
23628+
for off in &offs {
23629+
pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
23630+
}
23631+
pdf.extend_from_slice(
23632+
format!("trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_off}\n%%EOF\n")
23633+
.as_bytes(),
23634+
);
23635+
23636+
(pdf, small, big)
23637+
}
23638+
23639+
/// Two subsets of one base font must dedup to ONE entry, and that entry
23640+
/// must be the SAME bytes every time - the bug this fix exists for.
23641+
///
23642+
/// `get_font_set()` hands the subsets back in `HashMap` order, and each
23643+
/// call builds a fresh map whose iteration order is independently seeded,
23644+
/// so the old `or_insert` returned a different subset from run to run for
23645+
/// one unchanged PDF. Extracting repeatedly makes that flake fatal rather
23646+
/// than occasional; swapping which resource slot holds the larger program
23647+
/// shows the choice is driven by the total order, not by encounter order.
23648+
#[test]
23649+
fn embedded_font_subset_choice_is_deterministic() {
23650+
for big_in_f1 in [true, false] {
23651+
let (pdf, small, big) = build_pdf_with_two_font_subsets(big_in_f1);
23652+
let mut previous: Option<Vec<u8>> = None;
23653+
23654+
for round in 0..64 {
23655+
let doc = PdfDocument::from_bytes(pdf.clone()).expect("open two-subset pdf");
23656+
let fonts = doc.extract_embedded_fonts().expect("extract fonts");
23657+
23658+
assert_eq!(
23659+
fonts.len(),
23660+
1,
23661+
"the two subsets share a base name and must dedup to one entry \
23662+
(big_in_f1={big_in_f1}, round={round})"
23663+
);
23664+
let (name, bytes) = &fonts[0];
23665+
assert_eq!(name, "Helvetica", "the subset prefix must be stripped");
23666+
assert_eq!(
23667+
bytes, &big,
23668+
"the LARGER subset must win regardless of which slot holds it \
23669+
(big_in_f1={big_in_f1}, round={round})"
23670+
);
23671+
assert_ne!(bytes, &small);
23672+
23673+
if let Some(prev) = &previous {
23674+
assert_eq!(
23675+
prev, bytes,
23676+
"repeated extraction of one unchanged PDF must be byte-identical \
23677+
(big_in_f1={big_in_f1}, round={round})"
23678+
);
23679+
}
23680+
previous = Some(bytes.clone());
23681+
}
23682+
}
23683+
}
23684+
23685+
/// The same guarantee on the variant that also returns the Unicode/width
23686+
/// maps: it carries its own copy of the subset choice, so it needs its own
23687+
/// guard against regressing back to `or_insert`.
23688+
#[test]
23689+
fn embedded_font_subset_choice_is_deterministic_with_maps() {
23690+
let (pdf, small, big) = build_pdf_with_two_font_subsets(true);
23691+
let mut previous: Option<Vec<u8>> = None;
23692+
23693+
for round in 0..64 {
23694+
let doc = PdfDocument::from_bytes(pdf.clone()).expect("open two-subset pdf");
23695+
let fonts = doc
23696+
.extract_embedded_fonts_with_unicode_maps_and_widths()
23697+
.expect("extract fonts with maps");
23698+
23699+
assert_eq!(fonts.len(), 1, "must dedup to one entry (round={round})");
23700+
let (name, bytes, _uni, _widths) = &fonts[0];
23701+
assert_eq!(name, "Helvetica");
23702+
assert_eq!(bytes, &big, "the LARGER subset must win (round={round})");
23703+
assert_ne!(bytes, &small);
23704+
23705+
if let Some(prev) = &previous {
23706+
assert_eq!(prev, bytes, "repeated extraction must be stable (round={round})");
23707+
}
23708+
previous = Some(bytes.clone());
23709+
}
23710+
}
23711+
2353723712
fn build_minimal_pdf_with_font(content: &[u8]) -> Vec<u8> {
2353823713
let mut pdf = b"%PDF-1.4\n".to_vec();
2353923714

0 commit comments

Comments
 (0)