Skip to main content

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}