@@ -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