xberg_native_pdf/document/fonts.rs
1//! Font loading, identity hashing, and embedded-font extraction.
2//!
3//! Split out of the parent's single 18,544-line `impl PdfDocument`, which made
4//! `document.rs` 1.2 MiB and tripped the 500 KiB file-safety limit. A child module's
5//! `impl` is the same inherent impl and sees the parent's private items unchanged. ~keep
6
7use super::*;
8
9impl PdfDocument {
10 /// Resolve an object reference.
11 ///
12 /// This is useful when working with indirect object references
13 /// in content streams or resource dictionaries.
14 pub fn resolve_object(&self, obj: &Object) -> Result<Object> {
15 if let Some(ref_val) = obj.as_reference() {
16 self.load_object(ref_val)
17 } else {
18 Ok(obj.clone())
19 }
20 }
21
22 /// Look up a font from the per-document `font_cache`, parsing and inserting
23 /// on a cache miss. Used by the page renderer so that `FontInfo::from_dict`
24 /// (which decodes widths, CID maps, ToUnicode CMaps, and extracts embedded
25 /// font bytes) is called at most once per PDF object reference, even when
26 /// multiple pages share the same font resources.
27 pub fn get_or_load_font_for_rendering(&self, font_obj: &Object) -> Result<Arc<crate::fonts::FontInfo>> {
28 if let Some(font_ref) = font_obj.as_reference() {
29 let cached = self.font_cache.lock_or_recover().get(&font_ref).cloned();
30 if let Some(arc) = cached {
31 return Ok(arc);
32 }
33 }
34 let resolved = self.deref_object_for_inks(font_obj)?;
35 let info = crate::fonts::FontInfo::from_dict(&resolved, self)?;
36 let arc = Arc::new(info);
37 if let Some(font_ref) = font_obj.as_reference() {
38 self.font_cache.lock_or_recover().insert(font_ref, Arc::clone(&arc));
39 }
40 Ok(arc)
41 }
42
43 /// Compute a cheap content-based font identity hash from a loaded font object.
44 /// Uses only inline fields (no reference resolution / load_object calls) to keep
45 /// the cost at ~200ns. Relies on BaseFont + Subtype + Encoding (when inline) to
46 /// uniquely identify fonts within a document. For reference-only fields (ToUnicode,
47 /// FontDescriptor, DescendantFonts), hashes their presence to avoid false positives
48 /// between fonts with vs without these features.
49 /// `font_identity_hash_cheap` of `font_ref`'s object, memoized (an object's
50 /// content is fixed within a document).
51 fn cached_font_identity_hash(&self, font_ref: ObjectRef) -> Option<u64> {
52 if !self.font_identity_shared_cache_enabled.load(Ordering::Acquire) {
53 return None;
54 }
55 if let Some(cached) = self.font_id_hash_cache.lock_or_recover().get(&font_ref) {
56 return *cached;
57 }
58 let font = self.load_object(font_ref).ok()?;
59 let identity = self.font_identity_hash_details(&font);
60 let cached = identity.cacheable.then_some(identity.value);
61 self.font_id_hash_cache.lock_or_recover().insert(font_ref, cached);
62 cached
63 }
64
65 fn font_set_identity_hash(&self, entries: &[(&String, &Object)]) -> Option<u64> {
66 use std::hash::{Hash, Hasher};
67 let mut hasher = std::collections::hash_map::DefaultHasher::new();
68 for (name, font_obj) in entries {
69 let font_ref = font_obj.as_reference()?;
70 let font_hash = self.cached_font_identity_hash(font_ref)?;
71 name.as_str().hash(&mut hasher);
72 font_hash.hash(&mut hasher);
73 }
74 Some(hasher.finish())
75 }
76
77 /// Document-aware font identity that follows every indirect object consumed
78 /// by `FontInfo::from_dict`. PDF object numbers are local to one document;
79 /// only resolved content can safely key the process-wide cache. ~keep
80 #[cfg(test)]
81 pub(super) fn font_identity_hash_with_descendants(&self, font_obj: &Object) -> u64 {
82 self.font_identity_hash_details(font_obj).value
83 }
84
85 pub(super) fn font_identity_hash_details(&self, font_obj: &Object) -> FontIdentityHash {
86 use std::hash::{Hash, Hasher};
87 let base = Self::font_identity_hash_cheap(font_obj);
88 // Encrypted stream bytes are document-key-dependent ciphertext, so
89 // neither hashing nor cross-document identity reuse is safe. ~keep
90 if self.is_encrypted() || !self.font_identity_shared_cache_enabled.load(Ordering::Acquire) {
91 return FontIdentityHash {
92 value: base,
93 cacheable: false,
94 subtree_depth: 0,
95 };
96 }
97 let mut hasher = std::collections::hash_map::DefaultHasher::new();
98 base.hash(&mut hasher);
99 let mut cacheable = true;
100
101 if let Some(font_dict) = font_obj.as_dict() {
102 let mut traversal = FontHashTraversal::default();
103 for field in FONT_IDENTITY_SEMANTIC_FIELDS {
104 if let Some(value) = font_dict.get(*field) {
105 field.hash(&mut hasher);
106 cacheable &= self.hash_pdf_object_resolved(value, &mut hasher, &mut traversal, 0);
107 }
108 }
109 }
110
111 FontIdentityHash {
112 value: hasher.finish(),
113 cacheable: cacheable && self.font_identity_shared_cache_enabled.load(Ordering::Acquire),
114 subtree_depth: 0,
115 }
116 }
117
118 fn reserve_font_identity_hash_bytes(&self, bytes: usize) -> bool {
119 if !self.font_identity_shared_cache_enabled.load(Ordering::Acquire) {
120 return false;
121 }
122 #[allow(deprecated, reason = "fetch_update supports the Rust 1.92 MSRV")]
123 let reserved = self
124 .font_identity_hashed_bytes
125 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
126 current
127 .checked_add(bytes)
128 .filter(|next| *next <= FONT_IDENTITY_MAX_HASHED_BYTES)
129 });
130 if reserved.is_err() {
131 self.font_identity_shared_cache_enabled.store(false, Ordering::Release);
132 return false;
133 }
134 self.font_identity_shared_cache_enabled.load(Ordering::Acquire)
135 }
136
137 #[cfg(test)]
138 pub(super) fn font_identity_hashed_bytes(&self) -> usize {
139 self.font_identity_hashed_bytes.load(Ordering::Acquire)
140 }
141
142 #[cfg(test)]
143 pub(super) fn font_identity_shared_cache_enabled(&self) -> bool {
144 self.font_identity_shared_cache_enabled.load(Ordering::Acquire)
145 }
146
147 /// Hash an object by resolved content, not by its document-local object id.
148 /// Any missing reference, cycle, or depth overflow makes the identity
149 /// ineligible for the shared caches while remaining deterministic. ~keep
150 fn hash_pdf_object_resolved<H: std::hash::Hasher>(
151 &self,
152 obj: &Object,
153 hasher: &mut H,
154 traversal: &mut FontHashTraversal,
155 depth: usize,
156 ) -> bool {
157 use std::hash::Hash;
158 traversal.observe_depth(depth);
159 if depth >= FONT_IDENTITY_MAX_REFERENCE_DEPTH {
160 FONT_HASH_DEPTH_LIMIT_MARKER.hash(hasher);
161 obj.type_name().hash(hasher);
162 return false;
163 }
164
165 match obj {
166 Object::Reference(reference) => self.hash_pdf_reference_resolved(*reference, hasher, traversal, depth),
167 Object::Null
168 | Object::Boolean(_)
169 | Object::Integer(_)
170 | Object::Real(_)
171 | Object::String(_)
172 | Object::Name(_) => Self::hash_pdf_scalar(obj, hasher),
173 Object::Array(values) => self.hash_pdf_array_resolved(values, hasher, traversal, depth),
174 Object::Dictionary(dict) => {
175 7u8.hash(hasher);
176 self.hash_pdf_dictionary_resolved(dict, hasher, traversal, depth + 1)
177 }
178 Object::Stream { dict, data } => {
179 8u8.hash(hasher);
180 let cacheable = self.hash_pdf_dictionary_resolved(dict, hasher, traversal, depth + 1);
181 data.len().hash(hasher);
182 if !cacheable || !self.reserve_font_identity_hash_bytes(data.len()) {
183 FONT_HASH_BYTE_LIMIT_MARKER.hash(hasher);
184 return false;
185 }
186 data.as_ref().hash(hasher);
187 self.font_identity_shared_cache_enabled.load(Ordering::Acquire)
188 }
189 }
190 }
191
192 fn hash_pdf_reference_resolved<H: std::hash::Hasher>(
193 &self,
194 reference: ObjectRef,
195 hasher: &mut H,
196 traversal: &mut FontHashTraversal,
197 depth: usize,
198 ) -> bool {
199 use std::hash::Hash;
200 FONT_HASH_RESOLVED_REFERENCE_MARKER.hash(hasher);
201 if let Some(identity) = traversal.resolved_hashes.get(&reference) {
202 return Self::hash_memoized_font_identity(*identity, hasher, traversal, depth);
203 }
204 if traversal.resolving.contains(&reference) {
205 FONT_HASH_REFERENCE_CYCLE_MARKER.hash(hasher);
206 return false;
207 }
208 if traversal.resolved_references >= FONT_IDENTITY_MAX_RESOLVED_REFERENCES {
209 FONT_HASH_REFERENCE_LIMIT_MARKER.hash(hasher);
210 return false;
211 }
212 traversal.resolved_references += 1;
213 if self.font_identity_shared_cache_enabled.load(Ordering::Acquire)
214 && let Some(identity) = self
215 .font_reference_hash_cache
216 .lock_or_recover()
217 .get(&reference)
218 .copied()
219 {
220 if !Self::hash_memoized_font_identity(identity, hasher, traversal, depth) {
221 return false;
222 }
223 traversal.resolved_hashes.insert(reference, identity);
224 return true;
225 }
226 traversal.resolving.insert(reference);
227
228 let identity = self.resolve_font_reference_identity(reference, traversal, depth + 1);
229 traversal.resolving.remove(&reference);
230 identity.value.hash(hasher);
231 traversal.resolved_hashes.insert(reference, identity);
232 if identity.cacheable && self.font_identity_shared_cache_enabled.load(Ordering::Acquire) {
233 self.font_reference_hash_cache
234 .lock_or_recover()
235 .insert(reference, identity);
236 }
237 identity.cacheable
238 }
239
240 fn hash_memoized_font_identity<H: std::hash::Hasher>(
241 identity: FontIdentityHash,
242 hasher: &mut H,
243 traversal: &mut FontHashTraversal,
244 depth: usize,
245 ) -> bool {
246 use std::hash::Hash;
247 let Some(max_depth) = depth
248 .checked_add(1)
249 .and_then(|entry_depth| entry_depth.checked_add(identity.subtree_depth))
250 else {
251 FONT_HASH_DEPTH_LIMIT_MARKER.hash(hasher);
252 return false;
253 };
254 if max_depth >= FONT_IDENTITY_MAX_REFERENCE_DEPTH {
255 FONT_HASH_DEPTH_LIMIT_MARKER.hash(hasher);
256 return false;
257 }
258 traversal.observe_depth(max_depth);
259 identity.value.hash(hasher);
260 identity.cacheable
261 }
262
263 fn resolve_font_reference_identity(
264 &self,
265 reference: ObjectRef,
266 traversal: &mut FontHashTraversal,
267 depth: usize,
268 ) -> FontIdentityHash {
269 use std::hash::{Hash, Hasher};
270 let mut hasher = std::collections::hash_map::DefaultHasher::new();
271 traversal.begin_reference(depth);
272 let cacheable = match self.load_object(reference) {
273 Ok(resolved) => self.hash_pdf_object_resolved(&resolved, &mut hasher, traversal, depth),
274 Err(_) => {
275 FONT_HASH_UNRESOLVED_REFERENCE_MARKER.hash(&mut hasher);
276 false
277 }
278 };
279 let subtree_depth = traversal.end_reference();
280 FontIdentityHash {
281 value: hasher.finish(),
282 cacheable,
283 subtree_depth,
284 }
285 }
286
287 fn hash_pdf_array_resolved<H: std::hash::Hasher>(
288 &self,
289 values: &[Object],
290 hasher: &mut H,
291 traversal: &mut FontHashTraversal,
292 depth: usize,
293 ) -> bool {
294 use std::hash::Hash;
295 6u8.hash(hasher);
296 values.len().hash(hasher);
297 let mut cacheable = true;
298 for value in values {
299 cacheable &= self.hash_pdf_object_resolved(value, hasher, traversal, depth + 1);
300 }
301 cacheable
302 }
303
304 fn hash_pdf_scalar<H: std::hash::Hasher>(obj: &Object, hasher: &mut H) -> bool {
305 use std::hash::Hash;
306 match obj {
307 Object::Null => 0u8.hash(hasher),
308 Object::Boolean(value) => {
309 1u8.hash(hasher);
310 value.hash(hasher);
311 }
312 Object::Integer(value) => {
313 2u8.hash(hasher);
314 value.hash(hasher);
315 }
316 Object::Real(value) => {
317 3u8.hash(hasher);
318 value.to_bits().hash(hasher);
319 }
320 Object::String(value) => {
321 4u8.hash(hasher);
322 value.hash(hasher);
323 }
324 Object::Name(value) => {
325 5u8.hash(hasher);
326 value.hash(hasher);
327 }
328 _ => return false,
329 }
330 true
331 }
332
333 fn hash_pdf_dictionary_resolved<H: std::hash::Hasher>(
334 &self,
335 dict: &HashMap<String, Object>,
336 hasher: &mut H,
337 traversal: &mut FontHashTraversal,
338 depth: usize,
339 ) -> bool {
340 use std::hash::Hash;
341 let mut keys: Vec<&str> = dict
342 .keys()
343 .map(String::as_str)
344 .filter(|key| FONT_IDENTITY_CONSUMED_DICTIONARY_FIELDS.contains(key))
345 .collect();
346 keys.sort_unstable();
347 keys.len().hash(hasher);
348 let mut cacheable = true;
349 for key in keys {
350 key.hash(hasher);
351 let value_cacheable = dict
352 .get(key)
353 .is_some_and(|value| self.hash_pdf_object_resolved(value, hasher, traversal, depth));
354 cacheable &= value_cacheable;
355 }
356 cacheable
357 }
358
359 pub(super) fn font_identity_hash_cheap(font_obj: &Object) -> u64 {
360 use std::hash::{Hash, Hasher};
361 let mut hasher = std::collections::hash_map::DefaultHasher::new();
362
363 if let Some(d) = font_obj.as_dict() {
364 if let Some(Object::Name(n)) = d.get("BaseFont") {
365 1u8.hash(&mut hasher);
366 n.hash(&mut hasher);
367 }
368 if let Some(Object::Name(n)) = d.get("Subtype") {
369 2u8.hash(&mut hasher);
370 n.hash(&mut hasher);
371 }
372 if let Some(enc) = d.get("Encoding") {
373 3u8.hash(&mut hasher);
374 match enc {
375 Object::Name(n) => n.hash(&mut hasher),
376 Object::Reference(_) => b"enc_ref".hash(&mut hasher),
377 Object::Dictionary(_) => b"enc_dict".hash(&mut hasher),
378 _ => {}
379 }
380 }
381 if let Some(to_unicode) = d.get("ToUnicode") {
382 4u8.hash(&mut hasher);
383 to_unicode.type_name().hash(&mut hasher);
384 }
385 if d.get("FontDescriptor").is_some() {
386 5u8.hash(&mut hasher);
387 }
388 if let Some(Object::Array(arr)) = d.get("DescendantFonts") {
389 6u8.hash(&mut hasher);
390 arr.len().hash(&mut hasher);
391 }
392 // Width metrics: two non-subset fonts can share
393 // BaseFont + Subtype + Encoding yet ship different glyph widths —
394 // Standard-14 fonts may carry producer-specific /Widths overrides
395 // (§9.6.2.2), and differently-optimized embeds of the same named
396 // font diverge similarly. Without folding widths into the key,
397 // such fonts collide on the cross-document cache and the second
398 // document gets the first's advances. The cheap seed covers direct
399 // simple-font widths; the document-aware identity resolves indirect
400 // widths and every descendant CIDFont metric. ~keep
401 if let Some(Object::Integer(first_char)) = d.get("FirstChar") {
402 7u8.hash(&mut hasher);
403 first_char.hash(&mut hasher);
404 }
405 if let Some(Object::Integer(last_char)) = d.get("LastChar") {
406 8u8.hash(&mut hasher);
407 last_char.hash(&mut hasher);
408 }
409 if let Some(Object::Array(widths)) = d.get("Widths") {
410 9u8.hash(&mut hasher);
411 (widths.len() as u64).hash(&mut hasher);
412 for w in widths {
413 match w {
414 Object::Integer(i) => i.hash(&mut hasher),
415 // Bit-pattern hash so equal widths hash equally
416 // (these are glyph advances, never NaN in practice). ~keep
417 Object::Real(r) => r.to_bits().hash(&mut hasher),
418 _ => 0u8.hash(&mut hasher),
419 }
420 }
421 }
422 }
423 hasher.finish()
424 }
425
426 /// Whether a font dictionary describes a font that is *document-local* and
427 /// therefore must never be served from / inserted into the cross-document
428 /// global font cache (Layer 6), even if its cheap identity hash collides
429 /// with a font in another document.
430 ///
431 /// Type 3 fonts (PDF 32000-1 §9.6.5) define their glyphs as streams of PDF
432 /// graphics operators in a `/CharProcs` dictionary whose procedures
433 /// reference the *owning document's* resources (XObjects, ColorSpaces,
434 /// ExtGState, …). Two Type 3 fonts from different documents that happen to
435 /// share `/Name` + `/Encoding` shape are NOT interchangeable: serving one
436 /// document's parsed `FontInfo` for the other yields wrong glyphs. Such
437 /// fonts carry no subset prefix, so the cheap hash cannot distinguish them
438 /// — this predicate gates them out of the global cache instead.
439 pub(super) fn font_is_document_local(font_obj: &Object) -> bool {
440 let dict = match font_obj.as_dict() {
441 Some(d) => d,
442 None => return false,
443 };
444
445 if dict.get("Subtype").and_then(|s| s.as_name()) == Some("Type3") {
446 return true;
447 }
448
449 // Subset fonts carry a document-specific glyph subset and ToUnicode
450 // CMap, so they are unsafe to share across documents even when the
451 // BaseFont name collides. A subset BaseFont is tagged with exactly six
452 // uppercase letters and a '+' per ISO 32000-1:2008 §9.6.4
453 // (e.g. `AAAAAA+ArialUnicodeMS`). ~keep
454 match dict.get("BaseFont").and_then(|b| b.as_name()) {
455 Some(base_font) => Self::is_subset_basefont(base_font),
456 // A non-Type3 font is required by the spec to carry /BaseFont; if it
457 // is absent we cannot prove the font is shareable, so fail safe and
458 // treat it as document-local rather than risk poisoning the cache. ~keep
459 None => true,
460 }
461 }
462
463 /// Detect a PDF subset-font tag on a `/BaseFont` name: exactly six uppercase
464 /// ASCII letters followed by `+`, per ISO 32000-1:2008 §9.6.4 (e.g.
465 /// `AAAAAA+ArialUnicodeMS`). `is_ascii_uppercase` is precisely A–Z, so
466 /// multibyte (CJK) names never satisfy the test and are treated as full
467 /// fonts — correct, since subset tags are by definition ASCII A–Z.
468 fn is_subset_basefont(base_font: &str) -> bool {
469 let bytes = base_font.as_bytes();
470 bytes.len() > 7 && bytes[6] == b'+' && bytes[..6].iter().all(|b| b.is_ascii_uppercase())
471 }
472
473 /// Run `TextExtractor::share_truetype_cmaps` and, in test builds, add the
474 /// number of fonts it actually mutated to `donation_apply_count`. Kept as
475 /// one call site so every `load_fonts` branch reports through it. ~keep
476 fn share_truetype_cmaps_counted(&self, extractor: &mut crate::extractors::TextExtractor<'_>) {
477 let applied = extractor.share_truetype_cmaps();
478 #[cfg(test)]
479 self.donation_apply_count.fetch_add(applied, Ordering::Relaxed);
480 #[cfg(not(test))]
481 let _ = applied;
482 }
483
484 /// Load fonts from a Resources dictionary into the extractor.
485 pub(crate) fn load_fonts(
486 &self,
487 resources: &Object,
488 extractor: &mut crate::extractors::TextExtractor<'_>,
489 ) -> Result<()> {
490 use crate::fonts::FontInfo;
491
492 let resources_obj = if let Some(res_ref) = resources.as_reference() {
493 self.load_object(res_ref)?
494 } else {
495 resources.clone()
496 };
497
498 let resources_dict = match resources_obj.as_dict() {
499 Some(d) => d,
500 None => {
501 tracing::warn!(target: LOG_TARGET,
502 "Resources is not a dictionary (type: {}), treating as empty",
503 resources_obj.type_name()
504 );
505 return Ok(());
506 }
507 };
508
509 if let Some(font_obj) = resources_dict.get("Font") {
510 let font_dict_ref = font_obj.as_reference();
511 let font_dict_obj = if let Some(font_ref) = font_dict_ref {
512 self.load_object(font_ref)?
513 } else {
514 font_obj.clone()
515 };
516
517 // Layer 2: Check font set cache for the /Font dictionary.
518 // Pages sharing the same /Font dict skip the entire per-font loop. ~keep
519 if let Some(font_dict_ref) = font_dict_ref {
520 let cached_set_opt = self.font_set_cache.lock_or_recover().get(&font_dict_ref).cloned();
521 if let Some(cached_set) = cached_set_opt {
522 let to_add = self
523 .font_set_donated_cache
524 .lock_or_recover()
525 .get(&font_dict_ref)
526 .cloned()
527 .unwrap_or(cached_set);
528 for (name, font_arc) in &to_add {
529 extractor.add_font_shared(name.clone(), Arc::clone(font_arc));
530 }
531 self.share_truetype_cmaps_counted(extractor);
532 return Ok(());
533 }
534 }
535
536 if let Some(font_dict) = font_dict_obj.as_dict() {
537 // Compute font fingerprint from (name → ObjectRef) pairs.
538 // Hash the MAPPING between font names and their object refs,
539 // not just the sets separately. This prevents false cache hits
540 // when two font dicts have the same set of refs and names but
541 // different name-to-ref assignments. ~keep
542 //
543 // A font written inline instead of as a reference has no object id
544 // to hash, so a dictionary holding one leaves nothing in the key but
545 // the resource names — and `/F1 /F2` names every page and every Form
546 // XObject of a Word-produced PDF alike. The first dictionary to
547 // arrive would then answer for all of them, which makes the text a
548 // property of the visit order. There is no stable key here, so there
549 // is no entry: the per-font loop below already declines to cache a
550 // direct font object for the same reason. ~keep
551 let fingerprint = {
552 use std::hash::{Hash, Hasher};
553 let mut name_ref_pairs: Vec<(&str, Option<ObjectRef>)> = font_dict
554 .iter()
555 .map(|(name, fo)| (name.as_str(), fo.as_reference()))
556 .collect();
557 name_ref_pairs.sort_by(|a, b| a.0.cmp(b.0));
558 name_ref_pairs.iter().all(|(_, obj_ref)| obj_ref.is_some()).then(|| {
559 let mut hasher = std::collections::hash_map::DefaultHasher::new();
560 for (name, obj_ref) in &name_ref_pairs {
561 name.hash(&mut hasher);
562 if let Some(r) = obj_ref {
563 r.id.hash(&mut hasher);
564 r.generation.hash(&mut hasher);
565 }
566 }
567 hasher.finish()
568 })
569 };
570
571 let cached_fingerprint_opt =
572 fingerprint.and_then(|key| self.font_fingerprint_cache.lock_or_recover().get(&key).cloned());
573 if let Some(cached_set) = cached_fingerprint_opt {
574 let to_add = fingerprint
575 .and_then(|key| self.font_fingerprint_donated_cache.lock_or_recover().get(&key).cloned())
576 .unwrap_or(cached_set);
577 for (name, font_arc) in &to_add {
578 extractor.add_font_shared(name.clone(), Arc::clone(font_arc));
579 }
580 self.share_truetype_cmaps_counted(extractor);
581 return Ok(());
582 }
583
584 // Layer 4: Name-based font set cache with spot-check verification.
585 // Pages in the same document often use the same font names mapped to
586 // different ObjectRefs but identical base fonts (e.g., 764 pages each
587 // creating T1_0→Helvetica, T1_1→Times-Roman with unique object numbers).
588 // Cache the resolved font set by sorted font names, then on subsequent
589 // pages verify ONE font via load+hash to confirm the mapping is the same. ~keep
590 let name_hash = {
591 use std::hash::{Hash, Hasher};
592 let mut font_names: Vec<&str> = font_dict.keys().map(|k| k.as_str()).collect();
593 font_names.sort();
594 let mut hasher = std::collections::hash_map::DefaultHasher::new();
595 font_names.hash(&mut hasher);
596 hasher.finish()
597 };
598
599 let cached_name_set = self.font_name_set_cache.lock_or_recover().get(&name_hash).cloned();
600 // Sort font entries by name for deterministic processing order.
601 // HashMap iteration order is randomized per-process, which causes
602 // non-deterministic text extraction when font CMap sharing depends
603 // on the order fonts are loaded. ~keep
604 let mut sorted_font_entries: Vec<(&String, &Object)> = font_dict.iter().collect();
605 sorted_font_entries.sort_by_key(|(name, _)| name.as_str());
606
607 if let Some((cached_set, check_hash)) = cached_name_set {
608 // Verify the cached font set by computing a combined identity hash
609 // over ALL reference fonts in the current Resources dict (sorted by
610 // name). This prevents false cache hits when pages reuse the same
611 // font key names but embed different per-page subsets — a single-font
612 // spot-check is insufficient because it only guards one entry
613 // lets differing sibling fonts (F2, F3 …) slip through unchecked.
614 // Fixes the regression described above. ~keep
615 if self.font_set_identity_hash(&sorted_font_entries) == Some(check_hash) {
616 let donated_opt = self
617 .font_name_set_donated_cache
618 .lock_or_recover()
619 .get(&name_hash)
620 .cloned();
621 match &donated_opt {
622 Some(donated) => {
623 for (name, font_arc) in donated.iter() {
624 extractor.add_font_shared(name.clone(), Arc::clone(font_arc));
625 }
626 }
627 None => {
628 for (name, font_arc) in cached_set.iter() {
629 extractor.add_font_shared(name.clone(), Arc::clone(font_arc));
630 }
631 }
632 }
633 self.share_truetype_cmaps_counted(extractor);
634 return Ok(());
635 }
636 }
637
638 // What every cache below stores is what THIS /Font dictionary
639 // resolves to, collected here as the loop resolves it.
640 //
641 // Reading it back off the extractor afterwards is what made a page's
642 // text depend on the order the pages were read. The extractor also
643 // holds the fonts of the page that owns it and of any enclosing Form
644 // XObject, so its font set is a union belonging to whichever page
645 // reached this dictionary first, and a later page that shares the
646 // dictionary inherits that page's fonts under its own resource
647 // names. Reading it back after cmap donation adds the same problem
648 // again, because donation rewrites a font using whatever other fonts
649 // stand beside it. Donation still runs, per page, over that page's
650 // own set; it just does not reach the cache. ~keep
651 let mut resolved: Vec<(String, Arc<FontInfo>)> = Vec::with_capacity(sorted_font_entries.len());
652
653 let mut all_from_cache = true;
654
655 for (name, font_obj) in &sorted_font_entries {
656 if let Some(font_ref) = font_obj.as_reference() {
657 let cached_font_opt = self.font_cache.lock_or_recover().get(&font_ref).cloned();
658 if let Some(cached) = cached_font_opt {
659 resolved.push(((*name).clone(), Arc::clone(&cached)));
660 extractor.add_font_shared((*name).clone(), cached);
661 continue;
662 }
663 all_from_cache = false;
664 let font = self.load_object(font_ref)?;
665
666 // Resolve and hash every indirect semantic subtree that
667 // `FontInfo::from_dict` consumes. PDF object numbers are
668 // document-local and cannot identify shared cache entries. ~keep
669 let identity = self.font_identity_hash_details(&font);
670 let id_hash = identity.value;
671
672 // Type 3 fonts and subset fonts must not cross
673 // PdfDocument boundaries via the global cache — their
674 // glyph procs / glyph-subset + ToUnicode mappings are
675 // document-specific. The per-document Layer 4/5 caches
676 // below stay safe to use. ~keep
677 let is_document_local = Self::font_is_document_local(&font);
678
679 // Layer 5: Per-font identity cache — skip from_dict when a
680 // structurally identical font was already parsed elsewhere. ~keep
681 let cached_identity_opt = identity
682 .cacheable
683 .then(|| self.font_identity_cache.lock_or_recover().get(&id_hash).cloned())
684 .flatten();
685 if let Some(cached) = cached_identity_opt {
686 self.font_cache.lock_or_recover().insert(font_ref, Arc::clone(&cached));
687 resolved.push(((*name).clone(), Arc::clone(&cached)));
688 extractor.add_font_shared((*name).clone(), cached);
689 continue;
690 }
691
692 // Layer 6: Global cross-document font cache — reuse fonts
693 // parsed by previous PdfDocument instances in this process.
694 // Skipped entirely for document-local fonts. ~keep
695 if identity.cacheable
696 && !is_document_local
697 && let Some(cached) = crate::fonts::global_cache::global_font_cache_get(id_hash)
698 {
699 self.font_identity_cache
700 .lock_or_recover()
701 .insert(id_hash, Arc::clone(&cached));
702 self.font_cache.lock_or_recover().insert(font_ref, Arc::clone(&cached));
703 resolved.push(((*name).clone(), Arc::clone(&cached)));
704 extractor.add_font_shared((*name).clone(), cached);
705 continue;
706 }
707
708 match FontInfo::from_dict(&font, self) {
709 Ok(font_info) => {
710 let arc = Arc::new(font_info);
711 // Populate the document-level caches always; the
712 // global cross-document cache only for fonts that
713 // are safe to share across documents. ~keep
714 if identity.cacheable && !is_document_local {
715 crate::fonts::global_cache::global_font_cache_insert(id_hash, Arc::clone(&arc));
716 }
717 if identity.cacheable {
718 self.font_identity_cache
719 .lock_or_recover()
720 .insert(id_hash, Arc::clone(&arc));
721 }
722 self.font_cache.lock_or_recover().insert(font_ref, Arc::clone(&arc));
723 resolved.push(((*name).clone(), Arc::clone(&arc)));
724 extractor.add_font_shared((*name).clone(), arc);
725 }
726 Err(error) => {
727 tracing::warn!(
728 target: crate::LOG_TARGET_ROOT,
729 operation = "load_font_resource",
730 error_code = error.telemetry_code(),
731 error_offset = ?error.telemetry_offset(),
732 "using fallback font encoding"
733 );
734 continue;
735 }
736 }
737 } else {
738 // Direct font object — parse without caching (no stable key) ~keep
739 all_from_cache = false;
740 let font = *font_obj;
741 match FontInfo::from_dict(font, self) {
742 Ok(font_info) => {
743 let arc = Arc::new(font_info);
744 resolved.push(((*name).clone(), Arc::clone(&arc)));
745 extractor.add_font_shared((*name).clone(), arc);
746 }
747 Err(error) => {
748 tracing::warn!(
749 target: crate::LOG_TARGET_ROOT,
750 operation = "load_font_resource",
751 error_code = error.telemetry_code(),
752 error_offset = ?error.telemetry_offset(),
753 "using fallback font encoding"
754 );
755 continue;
756 }
757 }
758 }
759 }
760
761 // Always re-share TrueType CMaps after loading fonts. Cached fonts
762 // may lack donated CMaps because Arc::make_mut creates a per-extractor
763 // clone that is not written back to per-font cache. A donor font added
764 // in a later load_fonts call (e.g. an XObject font donating to a
765 // page-level font already in the extractor) requires sharing to run
766 // again even when all fonts came from cache. ~keep
767 self.share_truetype_cmaps_counted(extractor);
768
769 // Donate within THIS dictionary's own set and cache that alongside
770 // the pre-donation set, under the same keys. See
771 // `donate_truetype_cmaps_within_set` for why this is safe to cache
772 // (it never reads any font outside `resolved`). ~keep
773 let donated = Self::donate_truetype_cmaps_within_set(&resolved);
774 #[cfg(test)]
775 self.donation_call_count.fetch_add(1, Ordering::Relaxed);
776
777 if let Some(fdr) = font_dict_ref {
778 self.font_set_cache.lock_or_recover().insert(fdr, resolved.clone());
779 self.font_set_donated_cache
780 .lock_or_recover()
781 .insert(fdr, donated.clone());
782 }
783 if let Some(key) = fingerprint {
784 self.font_fingerprint_cache
785 .lock_or_recover()
786 .insert(key, resolved.clone());
787 self.font_fingerprint_donated_cache
788 .lock_or_recover()
789 .insert(key, donated.clone());
790 }
791
792 // Layer 4 is keyed by font NAMES alone, so it carries a combined
793 // identity hash over every reference font in the Resources dict
794 // (sorted by name): a hit requires all of them to match, not one.
795 // This prevents false positives when pages reuse the same font key
796 // names with different per-page subsets. ~keep
797 if !all_from_cache && let Some(combined_check_hash) = self.font_set_identity_hash(&sorted_font_entries)
798 {
799 self.font_name_set_cache
800 .lock_or_recover()
801 .insert(name_hash, (Arc::new(resolved), combined_check_hash));
802 self.font_name_set_donated_cache
803 .lock_or_recover()
804 .insert(name_hash, Arc::new(donated));
805 }
806
807 return Ok(());
808 }
809 }
810
811 Ok(())
812 }
813
814 /// Donate TrueType cmaps among the fonts THIS dictionary resolves, and only
815 /// those — never against fonts a sibling dictionary (another page's own
816 /// `/Font` dict, or a Form XObject's) happens to have already added to the
817 /// extractor. That makes the result a pure function of `resolved`, so it is
818 /// safe to cache under the same key the pre-donation caches use: a cache hit
819 /// still depends only on this dictionary's own resources, never on which
820 /// page or XObject was read before it.
821 ///
822 /// Donation whose donor lives outside `resolved` is intentionally left out
823 /// here. `TextExtractor::share_truetype_cmaps` still runs after every
824 /// `load_fonts` call to cover that case; it is a no-op for any font this
825 /// function already gave a cmap, so caching the self-contained part removes
826 /// the redundant `Arc::make_mut` clone for the common case without changing
827 /// cross-dictionary behavior. ~keep
828 fn donate_truetype_cmaps_within_set(
829 resolved: &[(String, Arc<crate::fonts::FontInfo>)],
830 ) -> Vec<(String, Arc<crate::fonts::FontInfo>)> {
831 let mut donated: Vec<(String, Arc<crate::fonts::FontInfo>)> = resolved.to_vec();
832 let best_cmaps =
833 crate::fonts::unicode_decode::best_truetype_cmaps(donated.iter().map(|(_, font)| font.as_ref()));
834 if best_cmaps.is_empty() {
835 return donated;
836 }
837
838 for (_, font_arc) in donated.iter_mut() {
839 if font_arc.truetype_cmap().is_some() {
840 continue;
841 }
842 if font_arc.subtype != "Type0" {
843 continue;
844 }
845 let is_identity = matches!(&font_arc.encoding, crate::fonts::Encoding::Identity)
846 || matches!(&font_arc.encoding, crate::fonts::Encoding::Standard(n) if n.contains("Identity"));
847 if !is_identity {
848 continue;
849 }
850 let stripped = crate::fonts::unicode_decode::strip_subset_prefix(&font_arc.base_font);
851 if let Some((donor_cmap, _)) = best_cmaps.get(stripped) {
852 Arc::make_mut(font_arc).set_truetype_cmap(Some(donor_cmap.clone()));
853 }
854 }
855
856 donated
857 }
858
859 /// Number of times `donate_truetype_cmaps_within_set` actually ran, i.e. a
860 /// cache miss did real donation work rather than reusing a cached result.
861 /// Test-only: proves donation runs once per font set across repeated page
862 /// reads instead of once per page.
863 #[cfg(test)]
864 pub(super) fn donation_call_count(&self) -> usize {
865 self.donation_call_count.load(Ordering::Relaxed)
866 }
867
868 /// Sum of `TextExtractor::share_truetype_cmaps`'s return value across every
869 /// `load_fonts` call on this document. Test-only: proves a `/Font`
870 /// dictionary already served from the donated-set cache does not redo the
871 /// `Arc::make_mut` mutation `share_truetype_cmaps` applies on a real donation.
872 #[cfg(test)]
873 pub(super) fn donation_apply_count(&self) -> usize {
874 self.donation_apply_count.load(Ordering::Relaxed)
875 }
876
877 /// Public wrapper for `load_fonts` (normally pub(crate)).
878 /// Loads font dictionaries from a resources object into a TextExtractor.
879 pub fn load_fonts_public(
880 &self,
881 resources: &Object,
882 extractor: &mut crate::extractors::TextExtractor<'_>,
883 ) -> Result<()> {
884 self.load_fonts(resources, extractor)
885 }
886
887 /// Per-page mapping of PDF font-resource names (e.g. `"F75"`) to their
888 /// canonical face name (e.g. `"TeXGyreTermesX-Regular"`, with any
889 /// subset-prefix `ABCDEF+` stripped).
890 ///
891 /// Used by the layout-preserving DOCX writer so each text span can be
892 /// emitted with the actual face name in `<w:rFonts>` instead of a
893 /// PDF-internal resource id. The vector is `pages × map`; `map[i]`
894 /// covers all fonts referenced by page `i`'s Resources.
895 pub fn page_font_face_lookups(&self) -> Result<Vec<std::collections::HashMap<String, String>>> {
896 use std::collections::HashMap;
897 let n = self.page_count()?;
898 let mut out: Vec<HashMap<String, String>> = Vec::with_capacity(n);
899 for page_idx in 0..n {
900 let mut lookup: HashMap<String, String> = HashMap::new();
901 // Inline get_page → Resources so this works without `rendering`. ~keep
902 let resources = match self.get_page(page_idx) {
903 Ok(page) => match page.as_dict() {
904 Some(d) => {
905 let r = d
906 .get("Resources")
907 .cloned()
908 .unwrap_or(Object::Dictionary(std::collections::HashMap::new()));
909 if let Some(rref) = r.as_reference() {
910 self.load_object(rref)
911 .unwrap_or(Object::Dictionary(std::collections::HashMap::new()))
912 } else {
913 r
914 }
915 }
916 None => {
917 out.push(lookup);
918 continue;
919 }
920 },
921 Err(_) => {
922 out.push(lookup);
923 continue;
924 }
925 };
926 let mut extractor = crate::extractors::TextExtractor::new();
927 if self.load_fonts_public(&resources, &mut extractor).is_ok() {
928 for (resource_name, info) in extractor.get_font_set() {
929 let canonical = info
930 .base_font
931 .split_once('+')
932 .map(|(_, rest)| rest)
933 .unwrap_or(info.base_font.as_str())
934 .to_string();
935 lookup.insert(resource_name, canonical);
936 }
937 }
938 out.push(lookup);
939 }
940 Ok(out)
941 }
942
943 /// Extract every embedded font program (TrueType / OpenType bytes) used
944 /// anywhere in the document, deduplicated by `BaseFont` name.
945 ///
946 /// Walks every page's font dictionary, loads each font via the same path
947 /// `extract_text` uses, and returns the unique set of fonts that have
948 /// embedded `FontFile2`/`FontFile3` streams. The `String` is the base
949 /// font name (with any subset prefix like `ABCDEF+` stripped) and the
950 /// `Vec<u8>` is the raw font program — directly suitable for re-embedding
951 /// into another container (DOCX `word/fonts/`, another PDF, etc.).
952 ///
953 /// Fonts without embedded data (standard 14, missing FontFile streams)
954 /// are skipped — there's nothing to extract.
955 pub fn extract_embedded_fonts(&self) -> Result<Vec<(String, Vec<u8>)>> {
956 use std::collections::HashMap;
957 let mut by_name: HashMap<String, Vec<u8>> = HashMap::new();
958
959 let n = self.page_count()?;
960 for page_idx in 0..n {
961 // Inline get_page_resources so this works without `rendering`. ~keep
962 let resources = match self.get_page(page_idx) {
963 Ok(page) => match page.as_dict() {
964 Some(d) => {
965 let r = d
966 .get("Resources")
967 .cloned()
968 .unwrap_or(Object::Dictionary(std::collections::HashMap::new()));
969 if let Some(rref) = r.as_reference() {
970 self.load_object(rref)
971 .unwrap_or_else(|_| Object::Dictionary(std::collections::HashMap::new()))
972 } else {
973 r
974 }
975 }
976 None => continue,
977 },
978 Err(_) => continue,
979 };
980 let mut extractor = crate::extractors::TextExtractor::new();
981 if self.load_fonts_public(&resources, &mut extractor).is_err() {
982 continue;
983 }
984 for (_resource_name, font_arc) in extractor.get_font_set() {
985 let Some(data) = font_arc.embedded_font_data.as_ref() else {
986 continue;
987 };
988 if data.is_empty() {
989 continue;
990 }
991 // Subset-prefix stripping: PDF font subsets carry a 6-letter
992 // prefix followed by `+`, e.g. `ABCDEF+Calibri-Bold`. The
993 // prefix is meaningless to consumers — strip it for dedup. ~keep
994 let base = font_arc.base_font.as_str();
995 let canonical = base.split_once('+').map(|(_, rest)| rest).unwrap_or(base);
996 // When several subsets share a base name, `get_font_set()` yields
997 // them in HashMap order, so `or_insert` kept a NONDETERMINISTIC
998 // one - the returned bytes changed run to run for the same PDF.
999 //
1000 // Choose by a TOTAL ORDER instead: largest program, ties broken
1001 // bytewise. Size is only a heuristic for "the richer subset" - a
1002 // program's byte count also grows with hinting and auxiliary
1003 // tables, so a larger subset is not necessarily a superset of a
1004 // smaller one. What the total order does guarantee is the property
1005 // callers actually depend on: the same PDF always yields the same
1006 // bytes. ~keep
1007 match by_name.entry(canonical.to_string()) {
1008 std::collections::hash_map::Entry::Vacant(v) => {
1009 v.insert(data.as_ref().clone());
1010 }
1011 std::collections::hash_map::Entry::Occupied(mut o) => {
1012 let cand = data.as_ref();
1013 let cur = o.get();
1014 if (cand.len(), cand.as_slice()) > (cur.len(), cur.as_slice()) {
1015 *o.get_mut() = cand.clone();
1016 }
1017 }
1018 }
1019 }
1020 }
1021
1022 let mut out: Vec<(String, Vec<u8>)> = by_name.into_iter().collect();
1023 out.sort_by(|a, b| a.0.cmp(&b.0));
1024 Ok(out)
1025 }
1026
1027 /// Like [`Self::extract_embedded_fonts`] but additionally returns a
1028 /// per-font Unicode → GID map reconstructed from the source PDF's
1029 /// `/ToUnicode` CMap and the font's CID/byte→GID table.
1030 ///
1031 /// CFF font subsets in PDFs (the typical Word/LibreOffice output)
1032 /// often ship without a Unicode cmap because CIDs encode the
1033 /// glyph stream directly. The font program parses fine but
1034 /// `EmbeddedFont::glyph_lookup` is empty; downstream font
1035 /// registration treats the font as unusable and falls back to
1036 /// Helvetica.
1037 ///
1038 /// The map returned here lets office_oxide / xberg-native-pdf write
1039 /// pipelines call [`crate::writer::EmbeddedFont::extend_glyph_lookup`]
1040 /// to re-populate the missing Unicode→GID entries from the
1041 /// source-PDF's own `/ToUnicode`. Result: CFF subset fonts
1042 /// register and render with the source typeface program instead
1043 /// of base-14 Helvetica.
1044 pub fn extract_embedded_fonts_with_unicode_maps(
1045 &self,
1046 ) -> Result<Vec<(String, Vec<u8>, std::collections::HashMap<u32, u16>)>> {
1047 let with_widths = self.extract_embedded_fonts_with_unicode_maps_and_widths()?;
1048 Ok(with_widths
1049 .into_iter()
1050 .map(|(name, data, uni, _widths)| (name, data, uni))
1051 .collect())
1052 }
1053
1054 /// Like [`Self::extract_embedded_fonts_with_unicode_maps`] but also
1055 /// returns the per-glyph widths from the source PDF's `/W` array
1056 /// (in 1/1000 em units, keyed by GID). Required for re-embedding
1057 /// CFF font subsets whose synthetic OpenType wrapper carries no
1058 /// `hmtx` table — without this, ttf-parser returns 0 for every
1059 /// glyph advance and the round-trip writer emits a `/W` of zeros.
1060 pub fn extract_embedded_fonts_with_unicode_maps_and_widths(
1061 &self,
1062 ) -> Result<
1063 Vec<(
1064 String,
1065 Vec<u8>,
1066 std::collections::HashMap<u32, u16>,
1067 std::collections::HashMap<u16, u16>,
1068 )>,
1069 > {
1070 use std::collections::HashMap;
1071 let mut by_name: HashMap<String, (Vec<u8>, HashMap<u32, u16>, HashMap<u16, u16>)> = HashMap::new();
1072
1073 let n = self.page_count()?;
1074 for page_idx in 0..n {
1075 let resources = match self.get_page(page_idx) {
1076 Ok(page) => match page.as_dict() {
1077 Some(d) => {
1078 let r = d
1079 .get("Resources")
1080 .cloned()
1081 .unwrap_or(Object::Dictionary(std::collections::HashMap::new()));
1082 if let Some(rref) = r.as_reference() {
1083 self.load_object(rref)
1084 .unwrap_or_else(|_| Object::Dictionary(std::collections::HashMap::new()))
1085 } else {
1086 r
1087 }
1088 }
1089 None => continue,
1090 },
1091 Err(_) => continue,
1092 };
1093 let mut extractor = crate::extractors::TextExtractor::new();
1094 if self.load_fonts_public(&resources, &mut extractor).is_err() {
1095 continue;
1096 }
1097 for (_resource_name, font_arc) in extractor.get_font_set() {
1098 let Some(data) = font_arc.embedded_font_data.as_ref() else {
1099 continue;
1100 };
1101 if data.is_empty() {
1102 continue;
1103 }
1104 let base = font_arc.base_font.as_str();
1105 let canonical = base.split_once('+').map(|(_, rest)| rest).unwrap_or(base);
1106
1107 // Build Unicode → GID via ToUnicode CMap + GID resolver.
1108 //
1109 // We must consult the ToUnicode CMap *directly* rather than
1110 // going through `char_to_unicode`. `char_to_unicode` falls
1111 // through to a CID-as-Unicode fallback when the ToUnicode
1112 // CMap has no entry for a given code (Identity-H + Adobe-
1113 // Identity ordering, source font without a Unicode cmap).
1114 // That fallback returns spurious mappings like
1115 // U+0069 'i' → GID 105 (because CID 105 has no real
1116 // ToUnicode entry; the CID-as-Unicode path yields 'i'
1117 // for code=105 and the embedded TTF has no cmap to set us
1118 // straight). The spurious entries overwrite the real ones
1119 // we collected from CIDs that *do* have ToUnicode
1120 // entries (e.g. CID 0x4C → 'i', GID 76 for a
1121 // MicrosoftSansSerif subset) — which then makes the
1122 // injected cmap point Unicode codepoints at the wrong
1123 // glyph slots and the DOCX round-trip renders broken
1124 // lowercase letters. ~keep
1125 let mut uni_to_gid: HashMap<u32, u16> = HashMap::new();
1126 let to_unicode_cmap = font_arc.to_unicode.as_ref().and_then(|lazy| lazy.get());
1127 for code in 0u32..=0xFFFF {
1128 // Require an authoritative ToUnicode entry. If the
1129 // font has no ToUnicode CMap at all we conservatively
1130 // skip injection — the fallback chain would only
1131 // produce the misleading identity mapping. ~keep
1132 let unicode_str = match to_unicode_cmap.as_ref().and_then(|cmap| cmap.get(&code)) {
1133 Some(s) if !s.is_empty() && s.as_ref() != "\u{FFFD}" => s.into_owned(),
1134 _ => continue,
1135 };
1136 let cp = match unicode_str.chars().next() {
1137 Some(c) => c as u32,
1138 None => continue,
1139 };
1140 // Bare C0 controls (other than the legitimate
1141 // whitespace handled in char_to_unicode) never name
1142 // a real glyph — drop them so we don't inject a
1143 // cmap entry that points U+0000..U+001F at random
1144 // GIDs. ~keep
1145 if matches!(cp, 0x00..=0x08 | 0x0B..=0x0C | 0x0E..=0x1F) {
1146 continue;
1147 }
1148 // Only emit a Unicode→GID mapping when we have a
1149 // real byte/CID → GID resolver from the source PDF.
1150 // Falling back to identity for simple fonts whose
1151 // CFF encoding parser couldn't extract a mapping
1152 // produces a synthetic cmap that points Unicode at
1153 // the wrong CFF charset positions: the round-trip
1154 // emits Type0+Identity-H+CIDFontType0 and the
1155 // viewer reads `glyph_at_charset[byte_code]`,
1156 // which only equals the source glyph when CFF
1157 // charset == StandardEncoding byte order — rarely
1158 // true for subsetted CFF. Without a real mapping
1159 // we leave the font un-patched, and office_oxide
1160 // falls back to base-14 Helvetica via
1161 // `EmbeddedFont::has_usable_unicode_cmap`. ~keep
1162 let gid_opt = if let Some(ref map) = font_arc.cff_gid_map {
1163 if code <= 0xFF {
1164 map.get(&(code as u8)).copied()
1165 } else {
1166 None
1167 }
1168 } else if let Some(ref cid_map) = font_arc.cid_to_gid_map {
1169 if code <= 0xFFFF {
1170 Some(cid_map.get_gid(code as u16))
1171 } else {
1172 None
1173 }
1174 } else {
1175 None
1176 };
1177 if let Some(gid) = gid_opt {
1178 uni_to_gid.insert(cp, gid);
1179 }
1180 }
1181
1182 // Build GID → width from the source PDF's /W array.
1183 // For CIDFontType0+Identity-H: CID == GID directly.
1184 // For CIDFontType2: CID → GID via CIDToGIDMap.
1185 // For simple CFF (cff_gid_map): byte-code → GID. ~keep
1186 let mut gid_to_width: HashMap<u16, u16> = HashMap::new();
1187 if let Some(ref cid_widths) = font_arc.cid_widths {
1188 if font_arc.cid_font_type.as_deref() == Some("CIDFontType0") {
1189 for (&cid, &w) in cid_widths {
1190 gid_to_width.insert(cid, w.round() as u16);
1191 }
1192 } else if let Some(ref cid_map) = font_arc.cid_to_gid_map {
1193 for (&cid, &w) in cid_widths {
1194 let gid = cid_map.get_gid(cid);
1195 gid_to_width.insert(gid, w.round() as u16);
1196 }
1197 } else {
1198 for (&cid, &w) in cid_widths {
1199 gid_to_width.insert(cid, w.round() as u16);
1200 }
1201 }
1202 } else if let Some(ref cff_map) = font_arc.cff_gid_map {
1203 // Simple CFF font: width-by-byte-code in font_arc.widths. ~keep
1204 if let (Some(widths), Some(first)) = (font_arc.widths.as_ref(), font_arc.first_char) {
1205 for (i, w) in widths.iter().enumerate() {
1206 let byte = first + i as u32;
1207 if byte > 0xFF {
1208 break;
1209 }
1210 if let Some(&gid) = cff_map.get(&(byte as u8)) {
1211 gid_to_width.insert(gid, w.round() as u16);
1212 }
1213 }
1214 }
1215 }
1216
1217 let entry = by_name
1218 .entry(canonical.to_string())
1219 .or_insert_with(|| (data.as_ref().clone(), HashMap::new(), HashMap::new()));
1220 // Same total-order choice as `extract_embedded_fonts`: largest
1221 // program, ties broken bytewise, rather than whichever HashMap
1222 // order surfaced first. (Size is a heuristic for the richer
1223 // subset, not a proof of superset - see the note there.)
1224 //
1225 // KNOWN GAP, deliberately left for a follow-up: the maps below
1226 // still merge across ALL subsets while the emitted program is now
1227 // a single chosen one, so a GID in the maps need not exist in the
1228 // program we hand back. Worse, when two subsets disagree about a
1229 // codepoint's GID - which subsets of one base font routinely do -
1230 // `or_insert` keeps whichever arrived first, so the maps carry the
1231 // very HashMap-order nondeterminism this fix removes from the
1232 // program. Fixing it means binding the maps to the chosen subset
1233 // instead of merging; that is a behaviour change (coverage may
1234 // shrink where subsets are disjoint) and belongs in its own PR. ~keep
1235 let cand = data.as_ref();
1236 if (cand.len(), cand.as_slice()) > (entry.0.len(), entry.0.as_slice()) {
1237 entry.0 = cand.clone();
1238 }
1239 for (cp, gid) in uni_to_gid {
1240 entry.1.entry(cp).or_insert(gid);
1241 }
1242 for (gid, w) in gid_to_width {
1243 entry.2.entry(gid).or_insert(w);
1244 }
1245 }
1246 }
1247
1248 let mut out: Vec<(String, Vec<u8>, HashMap<u32, u16>, HashMap<u16, u16>)> = by_name
1249 .into_iter()
1250 .map(|(name, (data, cmap, widths))| (name, data, cmap, widths))
1251 .collect();
1252 out.sort_by(|a, b| a.0.cmp(&b.0));
1253 Ok(out)
1254 }
1255}