Skip to main content

pdf_manip/
unicode_font.rs

1// Copyright (c) 2026 Innovation Trigger B.V.
2//
3// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
4// the PDFluent Commercial Licence. See the LICENSE file in this repository --
5// that file travels with the copy you received, which a URL does not.
6
7//! Embedding a caller-supplied Unicode font as a Type0 composite
8//! font, so a replacement can contain characters the original font has no
9//! glyph for.
10//!
11//! # Why this exists
12//!
13//! The Phase 1B replacement path re-encodes through the *original* font's
14//! reverse map, and its fallback ([`FontFallback::InjectStandard`]) injects
15//! Helvetica/WinAnsi. Both are bounded by what the document already had:
16//! WinAnsi tops out at U+00FF. That is fine for replacing a word in an
17//! English document and useless for writing Polish, Greek, Cyrillic, CJK or
18//! any other script the source never contained.
19//!
20//! A Type0 font with `Identity-H` encoding removes that ceiling: character
21//! codes are 2-byte glyph indices into an embedded font program, so the
22//! reachable set is whatever the supplied font covers.
23//!
24//! # Why the caller supplies the font
25//!
26//! Embedding glyphs redistributes the font. Rather than bundle one and
27//! inherit its licence terms (and its size — a full CJK face is tens of
28//! megabytes), the caller passes the bytes of a font they are licensed to
29//! embed. That also lets them pick a face per language, which a bundled
30//! default could not do. A bundled default can be layered on later without
31//! changing this API.
32//!
33//! # What is written
34//!
35//! ```text
36//! Type0 (/Encoding /Identity-H)
37//!  └── DescendantFonts[0] → CIDFontType2
38//!        ├── /CIDToGIDMap /Identity     CID == GID, no extra indirection
39//!        ├── /W                          per-glyph advances
40//!        └── /FontDescriptor → /FontFile2 (subsetted font program)
41//!  └── /ToUnicode                        CMap so the text stays extractable
42//! ```
43//!
44//! The `/ToUnicode` map is not optional here. Without it a translated
45//! document renders correctly and yields nothing on copy, search or
46//! screen-reader — which for a translation workflow is a silent, total loss
47//! of the actual product.
48//!
49//! # Subsetting and glyph identity
50//!
51//! Glyphs are remapped to a compact range via [`subsetter::GlyphRemapper`],
52//! so the embedded program carries only what is used. Because `Identity-H`
53//! ties the character code to the glyph index, the codes written into the
54//! content stream are the **new** (post-remap) indices, never the original
55//! ones. Getting that pairing wrong produces a file that opens cleanly and
56//! renders the wrong glyphs, so the two always travel together in
57//! [`UnicodeEncoder`].
58
59use crate::error::{ManipError, Result};
60
61/// Which outline format the font program carries.
62///
63/// This decides the whole descendant-font shape in the PDF, so it is read once
64/// from the program and carried along rather than re-derived at write time.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Outlines {
67    /// `glyf` outlines — a `CIDFontType2` descendant with `/FontFile2`.
68    TrueType,
69    /// CFF outlines — a `CIDFontType0` descendant with `/FontFile3`.
70    Cff,
71}
72use lopdf::{dictionary, Dictionary, Document, Object, ObjectId, Stream};
73use std::collections::BTreeMap;
74use std::sync::Arc;
75
76/// Resource name for the injected Unicode font.
77pub(crate) const UNICODE_FONT_RESOURCE: &str = "F__Uni";
78
79/// A caller-supplied font, validated as embeddable.
80///
81/// Cheap to clone (the program bytes sit behind an [`Arc`]).
82#[derive(Clone)]
83pub struct UnicodeFont {
84    data: Arc<Vec<u8>>,
85    units_per_em: u16,
86    /// Decides the descendant-font shape; see [`Outlines`].
87    outlines: Outlines,
88    /// Short stable identity, used for equality and diagnostics rather than
89    /// comparing whole font programs.
90    fingerprint: [u8; 8],
91    postscript_name: String,
92    /// Variation axis settings to instance to before subsetting. Empty for a
93    /// static font.
94    ///
95    /// A variable font embedded as-is renders at its *default* instance, and
96    /// that default is not always the weight a reader expects: Noto Sans SC
97    /// defaults to wght 100 (Thin), so body text embedded from it comes out
98    /// hairline. Defaulting this to Regular absorbs that trap instead of
99    /// leaving every caller to discover it from a printed page.
100    variations: Vec<([u8; 4], f32)>,
101}
102
103/// The `wght` variation axis.
104const WGHT: [u8; 4] = *b"wght";
105/// Conventional "Regular" weight.
106const REGULAR_WEIGHT: f32 = 400.0;
107
108impl std::fmt::Debug for UnicodeFont {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        // Never dump the program bytes: a font is megabytes and this type
111        // ends up inside error and option structs that get logged.
112        f.debug_struct("UnicodeFont")
113            .field("name", &self.postscript_name)
114            .field("bytes", &self.data.len())
115            .field("fingerprint", &hex8(&self.fingerprint))
116            .finish()
117    }
118}
119
120impl PartialEq for UnicodeFont {
121    fn eq(&self, other: &Self) -> bool {
122        self.fingerprint == other.fingerprint
123    }
124}
125impl Eq for UnicodeFont {}
126
127#[cfg(feature = "serde")]
128impl serde::Serialize for UnicodeFont {
129    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
130        use serde::ser::SerializeStruct;
131        // A font program is not useful in a report; identity is.
132        let mut st = s.serialize_struct("UnicodeFont", 2)?;
133        st.serialize_field("name", &self.postscript_name)?;
134        st.serialize_field("fingerprint", &hex8(&self.fingerprint))?;
135        st.end()
136    }
137}
138
139fn hex8(bytes: &[u8; 8]) -> String {
140    bytes.iter().map(|b| format!("{b:02x}")).collect()
141}
142
143impl UnicodeFont {
144    /// Validate and take ownership of a font program.
145    ///
146    /// Rejects fonts this writer cannot embed correctly rather than
147    /// producing a file that opens but renders wrongly:
148    ///
149    /// - the program must parse as OpenType/TrueType;
150    /// - it must carry outlines in a format we can embed: TrueType (`glyf`)
151    ///   becomes a `CIDFontType2`/`FontFile2` descendant, CFF becomes
152    ///   `CIDFontType0`/`FontFile3`. A program with neither has nothing to
153    ///   embed and is refused.
154    pub fn from_bytes(data: Vec<u8>) -> Result<Self> {
155        let face = ttf_parser::Face::parse(&data, 0)
156            .map_err(|e| ManipError::Other(format!("not a usable font program: {e}")))?;
157
158        // Both flavours are embeddable, but they take different shapes in the
159        // PDF, so decide here and carry the answer rather than guessing later.
160        let outlines = if face.tables().glyf.is_some() {
161            Outlines::TrueType
162        } else if face.tables().cff.is_some() {
163            Outlines::Cff
164        } else {
165            return Err(ManipError::Other(
166                "font carries neither TrueType (glyf) nor CFF outlines, so there is \
167                 nothing to embed"
168                    .to_string(),
169            ));
170        };
171
172        let units_per_em = face.units_per_em();
173        if units_per_em == 0 {
174            return Err(ManipError::Other(
175                "font declares unitsPerEm = 0, cannot scale widths".to_string(),
176            ));
177        }
178
179        let postscript_name = face
180            .names()
181            .into_iter()
182            .find(|n| n.name_id == ttf_parser::name_id::POST_SCRIPT_NAME)
183            .and_then(|n| n.to_string())
184            .unwrap_or_else(|| "EmbeddedFont".to_string());
185
186        let fingerprint = {
187            use sha2::{Digest, Sha256};
188            let digest = Sha256::digest(&data);
189            let mut fp = [0u8; 8];
190            fp.copy_from_slice(&digest[..8]);
191            fp
192        };
193
194        // Aim a variable font at Regular unless its own default already is.
195        // Clamped to the axis range so a family that stops below 400 gets its
196        // heaviest available weight rather than an out-of-range request.
197        let variations = face
198            .variation_axes()
199            .into_iter()
200            .find(|a| a.tag.to_bytes() == WGHT)
201            .filter(|a| (a.def_value - REGULAR_WEIGHT).abs() > f32::EPSILON)
202            .map(|a| vec![(WGHT, REGULAR_WEIGHT.clamp(a.min_value, a.max_value))])
203            .unwrap_or_default();
204
205        Ok(Self {
206            data: Arc::new(data),
207            units_per_em,
208            outlines,
209            fingerprint,
210            postscript_name,
211            variations,
212        })
213    }
214
215    /// Pin a variation axis, e.g. `wght` 700 for bold.
216    ///
217    /// Overrides the automatic Regular targeting. No-op on a static font.
218    #[must_use]
219    pub fn with_variation(mut self, axis: &[u8; 4], value: f32) -> Self {
220        self.variations.retain(|(t, _)| t != axis);
221        self.variations.push((*axis, value));
222        self
223    }
224
225    /// Whether this font is variable and will be instanced before embedding.
226    #[must_use]
227    pub fn is_variable(&self) -> bool {
228        ttf_parser::Face::parse(&self.data, 0)
229            .map(|f| !f.variation_axes().is_empty())
230            .unwrap_or(false)
231    }
232
233    /// Characters in `text` this font has no glyph for, in first-seen order
234    /// and without duplicates.
235    ///
236    /// Callers use this to choose a font *before* staging an edit; the
237    /// encoder reports the same condition as an error at encode time.
238    #[must_use]
239    pub fn missing_chars(&self, text: &str) -> Vec<char> {
240        let Ok(face) = ttf_parser::Face::parse(&self.data, 0) else {
241            return text.chars().collect();
242        };
243        let mut missing = Vec::new();
244        for ch in text.chars() {
245            if face.glyph_index(ch).is_none() && !missing.contains(&ch) {
246                missing.push(ch);
247            }
248        }
249        missing
250    }
251
252    /// Whether this font covers every character in `text`.
253    #[must_use]
254    pub fn covers(&self, text: &str) -> bool {
255        self.missing_chars(text).is_empty()
256    }
257
258    /// PostScript name as declared by the font.
259    #[must_use]
260    pub fn name(&self) -> &str {
261        &self.postscript_name
262    }
263}
264
265/// Accumulates glyph usage for one page, then writes the font.
266///
267/// Encoding and embedding are deliberately one object: the character codes
268/// written into the content stream are post-subset glyph indices, so they are
269/// only meaningful next to the font program produced by the same remapper.
270pub struct UnicodeEncoder {
271    font: UnicodeFont,
272    remapper: subsetter::GlyphRemapper,
273    /// new GID → the text it came from, for `/ToUnicode`.
274    to_unicode: BTreeMap<u16, String>,
275    /// new GID → advance in font units.
276    advances: BTreeMap<u16, u16>,
277}
278
279impl UnicodeEncoder {
280    /// Start accumulating for `font`.
281    #[must_use]
282    pub fn new(font: UnicodeFont) -> Self {
283        Self {
284            font,
285            // `GlyphRemapper::new` always seeds .notdef at new GID 0, which is
286            // what a CID font needs at CID 0 anyway.
287            remapper: subsetter::GlyphRemapper::new(),
288            to_unicode: BTreeMap::new(),
289            advances: BTreeMap::new(),
290        }
291    }
292
293    /// Encode `text` as `Identity-H` character codes (2 bytes per glyph,
294    /// big-endian), registering every glyph it uses.
295    ///
296    /// Fails if the font has no glyph for a character, naming the characters
297    /// rather than silently substituting `.notdef` — a row of blank boxes in
298    /// a translated document is worse than a refusal the caller can act on.
299    pub fn encode(&mut self, text: &str) -> Result<Vec<u8>> {
300        let face = ttf_parser::Face::parse(&self.font.data, 0)
301            .map_err(|e| ManipError::Other(format!("font became unparseable: {e}")))?;
302
303        let missing: Vec<char> = text
304            .chars()
305            .filter(|c| face.glyph_index(*c).is_none())
306            .collect();
307        if !missing.is_empty() {
308            let shown: String = missing
309                .iter()
310                .take(8)
311                .map(|c| format!("'{c}' (U+{:04X})", *c as u32))
312                .collect::<Vec<_>>()
313                .join(", ");
314            return Err(ManipError::Other(format!(
315                "font '{}' has no glyph for {shown}{}",
316                self.font.postscript_name,
317                if missing.len() > 8 {
318                    format!(" and {} more", missing.len() - 8)
319                } else {
320                    String::new()
321                }
322            )));
323        }
324
325        let mut bytes = Vec::with_capacity(text.len() * 2);
326        for ch in text.chars() {
327            // Unwrap-free: the missing-glyph check above already ran.
328            let Some(old_gid) = face.glyph_index(ch) else {
329                continue;
330            };
331            let new_gid = self.remapper.remap(old_gid.0);
332
333            // Insert once per glyph, never append: a character that occurs
334            // twice maps to the same glyph id both times, and appending would
335            // make its /ToUnicode entry "aa". The page still renders, but
336            // every copy, search and screen-reader pass yields doubled text.
337            self.to_unicode
338                .entry(new_gid)
339                .or_insert_with(|| ch.to_string());
340            if let Some(adv) = face.glyph_hor_advance(old_gid) {
341                self.advances.insert(new_gid, adv);
342            }
343
344            bytes.extend_from_slice(&new_gid.to_be_bytes());
345        }
346        Ok(bytes)
347    }
348
349    /// Advance width of `text` in em units (1.0 = the font size), using this
350    /// font's own metrics.
351    ///
352    /// Used to decide whether a replacement fits where the original sat. The
353    /// substitute font is never exactly as wide as the one it replaces, so a
354    /// translated block needs measuring rather than assuming.
355    #[must_use]
356    pub fn width_em(&self, text: &str) -> f64 {
357        let Ok(face) = ttf_parser::Face::parse(&self.font.data, 0) else {
358            return 0.0;
359        };
360        let upem = f64::from(self.font.units_per_em);
361        text.chars()
362            .filter_map(|c| face.glyph_index(c))
363            .filter_map(|g| face.glyph_hor_advance(g))
364            .map(|a| f64::from(a) / upem)
365            .sum()
366    }
367
368    /// Whether anything was encoded, i.e. whether a font needs writing.
369    #[must_use]
370    pub fn is_empty(&self) -> bool {
371        self.to_unicode.is_empty()
372    }
373
374    /// Subset the program, write the Type0 font, and register it in the
375    /// page's resources. Returns the resource name to use in `Tf`.
376    pub fn embed(self, doc: &mut Document, page_num: u32) -> Result<String> {
377        if self.is_empty() {
378            return Err(ManipError::Other(
379                "nothing encoded; no font to embed".to_string(),
380            ));
381        }
382
383        let subset = if self.font.variations.is_empty() {
384            subsetter::subset(&self.font.data, 0, &self.remapper)
385        } else {
386            // Instancing collapses the variable font to one weight; without
387            // this the embedded program renders at the family's default,
388            // which for several Noto CJK builds is Thin.
389            let coords: Vec<(subsetter::Tag, f32)> = self
390                .font
391                .variations
392                .iter()
393                .map(|(tag, value)| (subsetter::Tag::new(tag), *value))
394                .collect();
395            subsetter::subset_with_variations(&self.font.data, 0, &coords, &self.remapper)
396        }
397        .map_err(|e| ManipError::Other(format!("font subsetting failed: {e}")))?;
398
399        // Metrics come from the ORIGINAL face: subsetting rewrites glyph ids,
400        // and reading head/hhea from the subset would be equivalent but adds a
401        // parse that can fail for no benefit.
402        let face = ttf_parser::Face::parse(&self.font.data, 0)
403            .map_err(|e| ManipError::Other(format!("font became unparseable: {e}")))?;
404
405        let upem = f64::from(self.font.units_per_em);
406        let to_pdf = |v: f64| (v * 1000.0 / upem).round() as i64;
407
408        let descriptor_id = self.write_descriptor(doc, &subset, &face, to_pdf)?;
409        let cid_font_id = self.write_cid_font(doc, descriptor_id, to_pdf);
410        let to_unicode_id = self.write_to_unicode(doc)?;
411
412        let base_font = self.subset_base_font_name();
413        let type0 = dictionary! {
414            "Type" => Object::Name(b"Font".to_vec()),
415            "Subtype" => Object::Name(b"Type0".to_vec()),
416            "BaseFont" => Object::Name(base_font.into_bytes()),
417            "Encoding" => Object::Name(b"Identity-H".to_vec()),
418            "DescendantFonts" => Object::Array(vec![Object::Reference(cid_font_id)]),
419            "ToUnicode" => Object::Reference(to_unicode_id),
420        };
421        let type0_id = doc.add_object(Object::Dictionary(type0));
422
423        register_page_font(doc, page_num, UNICODE_FONT_RESOURCE, type0_id).ok_or_else(|| {
424            ManipError::Other(format!(
425                "could not register the Unicode font in the resources of page {page_num}"
426            ))
427        })?;
428
429        Ok(UNICODE_FONT_RESOURCE.to_string())
430    }
431
432    /// `ABCDEF+Name` — the spec wants a 6-uppercase-letter tag on a subsetted
433    /// font. Derived from the fingerprint so the same font subsets to the same
434    /// tag across runs (byte-identical output for byte-identical input).
435    fn subset_base_font_name(&self) -> String {
436        let tag: String = self.font.fingerprint[..6]
437            .iter()
438            .map(|b| char::from(b'A' + (b % 26)))
439            .collect();
440        let clean: String = self
441            .font
442            .postscript_name
443            .chars()
444            .filter(|c| c.is_ascii_alphanumeric() || *c == '-')
445            .collect();
446        format!("{tag}+{clean}")
447    }
448
449    fn write_descriptor(
450        &self,
451        doc: &mut Document,
452        subset: &[u8],
453        face: &ttf_parser::Face,
454        to_pdf: impl Fn(f64) -> i64,
455    ) -> Result<ObjectId> {
456        // The stream dictionary differs per flavour, and PDF/A checks it.
457        //
458        // /Length1 is the uncompressed length of a TrueType program and belongs
459        // only on /FontFile2. A /FontFile3 stream instead needs its own
460        // /Subtype naming the format it carries: the subsetter emits an OTTO
461        // (CFF-flavoured OpenType) container, so that is /OpenType — bare CFF
462        // would be /CIDFontType0C.
463        //
464        // Leaving the /Subtype off is not cosmetic. veraPDF rejects the file
465        // under PDF/A-2 clause 6.2.11.4.1 with "Invalid subtype of the embedded
466        // font stream", while every reader still opens and draws it — so
467        // without the conformance test this would have shipped looking fine.
468        let stream_dict = match self.font.outlines {
469            Outlines::TrueType => {
470                dictionary! { "Length1" => Object::Integer(subset.len() as i64) }
471            }
472            Outlines::Cff => {
473                dictionary! { "Subtype" => Object::Name(b"OpenType".to_vec()) }
474            }
475        };
476        let mut stream = Stream::new(stream_dict, subset.to_vec());
477        // Ignore compression failure: an uncompressed program is valid, just
478        // larger, and is strictly better than failing the whole edit.
479        let _ = stream.compress();
480        let file_id = doc.add_object(Object::Stream(stream));
481
482        let bbox = face.global_bounding_box();
483        let mut descriptor = dictionary! {
484            "Type" => Object::Name(b"FontDescriptor".to_vec()),
485            "FontName" => Object::Name(self.subset_base_font_name().into_bytes()),
486            // Symbolic: Identity-H codes are glyph indices, not a Latin
487            // character set, so the nonsymbolic flag would be a false claim.
488            "Flags" => Object::Integer(4),
489            "FontBBox" => Object::Array(vec![
490                Object::Integer(to_pdf(f64::from(bbox.x_min))),
491                Object::Integer(to_pdf(f64::from(bbox.y_min))),
492                Object::Integer(to_pdf(f64::from(bbox.x_max))),
493                Object::Integer(to_pdf(f64::from(bbox.y_max))),
494            ]),
495            "ItalicAngle" => Object::Real(face.italic_angle()),
496            "Ascent" => Object::Integer(to_pdf(f64::from(face.ascender()))),
497            "Descent" => Object::Integer(to_pdf(f64::from(face.descender()))),
498            "CapHeight" => Object::Integer(
499                face.capital_height()
500                    .map_or_else(|| to_pdf(f64::from(face.ascender())), |h| to_pdf(f64::from(h)))
501            ),
502            // No StemV in an OpenType file. It is required by the spec and
503            // used only as a rendering hint, so a conventional mid value is
504            // the honest choice; deriving a fake precise number would not be.
505            "StemV" => Object::Integer(80),
506        };
507        // Where the program hangs depends on its outline format. A CFF program
508        // under /FontFile2 produces a file that opens and shows nothing.
509        match self.font.outlines {
510            Outlines::TrueType => {
511                descriptor.set("FontFile2", Object::Reference(file_id));
512            }
513            Outlines::Cff => {
514                descriptor.set("FontFile3", Object::Reference(file_id));
515            }
516        }
517        Ok(doc.add_object(Object::Dictionary(descriptor)))
518    }
519
520    fn write_cid_font(
521        &self,
522        doc: &mut Document,
523        descriptor_id: ObjectId,
524        to_pdf: impl Fn(f64) -> i64,
525    ) -> ObjectId {
526        // CIDFontType2 wraps TrueType outlines, CIDFontType0 wraps CFF. Naming
527        // the wrong one is the classic way to produce a PDF that every reader
528        // accepts and no reader draws.
529        let subtype: &[u8] = match self.font.outlines {
530            Outlines::TrueType => b"CIDFontType2",
531            Outlines::Cff => b"CIDFontType0",
532        };
533        let mut cid_font = dictionary! {
534            "Type" => Object::Name(b"Font".to_vec()),
535            "Subtype" => Object::Name(subtype.to_vec()),
536            "BaseFont" => Object::Name(self.subset_base_font_name().into_bytes()),
537            "CIDSystemInfo" => dictionary! {
538                "Registry" => Object::string_literal("Adobe"),
539                "Ordering" => Object::string_literal("Identity"),
540                "Supplement" => Object::Integer(0),
541            },
542            "FontDescriptor" => Object::Reference(descriptor_id),
543
544            "DW" => Object::Integer(1000),
545            "W" => Object::Array(self.build_w_array(to_pdf)),
546        };
547
548        // /CIDToGIDMap is a CIDFontType2 key. On a CIDFontType0 the CFF charset
549        // already carries the CID-to-glyph relation, so the key does not belong
550        // there — but leaving it out of the TrueType case breaks that font, as
551        // the existing Identity-H test caught the moment it went missing.
552        if self.font.outlines == Outlines::TrueType {
553            cid_font.set("CIDToGIDMap", Object::Name(b"Identity".to_vec()));
554        }
555        doc.add_object(Object::Dictionary(cid_font))
556    }
557
558    /// `/W` in the `c [w1 w2 …]` form, coalescing consecutive CIDs into one
559    /// run so the array stays small on documents with many glyphs.
560    fn build_w_array(&self, to_pdf: impl Fn(f64) -> i64) -> Vec<Object> {
561        let mut out = Vec::new();
562        let mut run_start: Option<u16> = None;
563        let mut run: Vec<Object> = Vec::new();
564        let mut prev: Option<u16> = None;
565
566        for (&gid, &adv) in &self.advances {
567            let contiguous = prev.is_some_and(|p| gid == p + 1);
568            if !contiguous {
569                if let Some(start) = run_start.take() {
570                    out.push(Object::Integer(i64::from(start)));
571                    out.push(Object::Array(std::mem::take(&mut run)));
572                }
573                run_start = Some(gid);
574            }
575            run.push(Object::Integer(to_pdf(f64::from(adv))));
576            prev = Some(gid);
577        }
578        if let Some(start) = run_start {
579            out.push(Object::Integer(i64::from(start)));
580            out.push(Object::Array(run));
581        }
582        out
583    }
584
585    /// A `/ToUnicode` CMap mapping each CID back to the text it renders, so
586    /// the result stays selectable, searchable and machine-readable.
587    fn write_to_unicode(&self, doc: &mut Document) -> Result<ObjectId> {
588        let mut cmap = String::from(
589            "/CIDInit /ProcSet findresource begin\n\
590             12 dict begin\n\
591             begincmap\n\
592             /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
593             /CMapName /Adobe-Identity-UCS def\n\
594             /CMapType 2 def\n\
595             1 begincodespacerange\n<0000> <FFFF>\n endcodespacerange\n",
596        );
597
598        // bfchar takes at most 100 entries per block.
599        let entries: Vec<(u16, &String)> = self.to_unicode.iter().map(|(g, s)| (*g, s)).collect();
600        for chunk in entries.chunks(100) {
601            cmap.push_str(&format!("{} beginbfchar\n", chunk.len()));
602            for (gid, text) in chunk {
603                let utf16: String = text
604                    .encode_utf16()
605                    .map(|u| format!("{u:04X}"))
606                    .collect::<Vec<_>>()
607                    .join("");
608                cmap.push_str(&format!("<{gid:04X}> <{utf16}>\n"));
609            }
610            cmap.push_str("endbfchar\n");
611        }
612
613        cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
614
615        let mut stream = Stream::new(Dictionary::new(), cmap.into_bytes());
616        let _ = stream.compress();
617        Ok(doc.add_object(Object::Stream(stream)))
618    }
619}
620
621/// Put `font_id` in the page's `/Resources /Font` under `name`.
622///
623/// Returns `None` when the resources could not be reached or written, so the
624/// caller can refuse to emit a `Tf` for a font that is not actually there —
625/// the failure mode that silently breaks text extraction.
626fn register_page_font(
627    doc: &mut Document,
628    page_num: u32,
629    name: &str,
630    font_id: ObjectId,
631) -> Option<()> {
632    let pages = doc.get_pages();
633    let &page_id = pages.get(&page_num)?;
634
635    let resources_entry = doc
636        .get_object(page_id)
637        .ok()
638        .and_then(|o| o.as_dict().ok())
639        .and_then(|d| d.get(b"Resources").ok())
640        .cloned();
641
642    match resources_entry {
643        // Resources live in their own object: reach through the reference.
644        Some(Object::Reference(res_id)) => {
645            let font_entry = doc
646                .get_object(res_id)
647                .ok()
648                .and_then(|o| o.as_dict().ok())
649                .and_then(|d| d.get(b"Font").ok())
650                .cloned();
651
652            match font_entry {
653                Some(Object::Reference(font_dict_id)) => {
654                    let dict = doc.get_object_mut(font_dict_id).ok()?.as_dict_mut().ok()?;
655                    dict.set(name, Object::Reference(font_id));
656                }
657                Some(Object::Dictionary(mut d)) => {
658                    d.set(name, Object::Reference(font_id));
659                    let res = doc.get_object_mut(res_id).ok()?.as_dict_mut().ok()?;
660                    res.set("Font", Object::Dictionary(d));
661                }
662                _ => {
663                    let mut d = Dictionary::new();
664                    d.set(name, Object::Reference(font_id));
665                    let res = doc.get_object_mut(res_id).ok()?.as_dict_mut().ok()?;
666                    res.set("Font", Object::Dictionary(d));
667                }
668            }
669        }
670        // Resources inline on the page dictionary.
671        Some(Object::Dictionary(mut res)) => match res.get(b"Font").ok().cloned() {
672            Some(Object::Reference(font_dict_id)) => {
673                let dict = doc.get_object_mut(font_dict_id).ok()?.as_dict_mut().ok()?;
674                dict.set(name, Object::Reference(font_id));
675            }
676            Some(Object::Dictionary(mut d)) => {
677                d.set(name, Object::Reference(font_id));
678                res.set("Font", Object::Dictionary(d));
679                let page = doc.get_object_mut(page_id).ok()?.as_dict_mut().ok()?;
680                page.set("Resources", Object::Dictionary(res));
681            }
682            _ => {
683                let mut d = Dictionary::new();
684                d.set(name, Object::Reference(font_id));
685                res.set("Font", Object::Dictionary(d));
686                let page = doc.get_object_mut(page_id).ok()?.as_dict_mut().ok()?;
687                page.set("Resources", Object::Dictionary(res));
688            }
689        },
690        // No resources at all: create the whole chain.
691        _ => {
692            let mut fonts = Dictionary::new();
693            fonts.set(name, Object::Reference(font_id));
694            let mut res = Dictionary::new();
695            res.set("Font", Object::Dictionary(fonts));
696            let page = doc.get_object_mut(page_id).ok()?.as_dict_mut().ok()?;
697            page.set("Resources", Object::Dictionary(res));
698        }
699    }
700
701    Some(())
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    /// A real font with wide coverage, from the host. Skips rather than fails
709    /// when absent so the suite stays green on machines without it.
710    fn host_font() -> Option<Vec<u8>> {
711        for path in [
712            "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
713            "/System/Library/Fonts/Supplemental/Arial.ttf",
714            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
715            "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
716        ] {
717            if let Ok(data) = std::fs::read(path) {
718                return Some(data);
719            }
720        }
721        None
722    }
723
724    #[test]
725    fn rejects_a_non_font() {
726        let err = UnicodeFont::from_bytes(b"this is not a font".to_vec()).unwrap_err();
727        assert!(err.to_string().contains("not a usable font program"));
728    }
729
730    #[test]
731    fn reports_the_characters_a_font_cannot_write() {
732        let Some(data) = host_font() else { return };
733        let font = UnicodeFont::from_bytes(data).unwrap();
734        // Latin is present in every candidate face above.
735        assert!(font.covers("Hello"));
736        // A private-use codepoint is in none of them.
737        let missing = font.missing_chars("A\u{E000}B");
738        assert_eq!(missing, vec!['\u{E000}']);
739    }
740
741    #[test]
742    fn encodes_two_bytes_per_glyph_and_registers_them() {
743        let Some(data) = host_font() else { return };
744        let font = UnicodeFont::from_bytes(data).unwrap();
745        let mut enc = UnicodeEncoder::new(font);
746
747        let bytes = enc.encode("AB").unwrap();
748        assert_eq!(bytes.len(), 4, "Identity-H is 2 bytes per glyph");
749        assert!(!enc.is_empty());
750
751        // Same character twice must reuse one glyph id, or the subset grows
752        // with every repetition.
753        let again = enc.encode("A").unwrap();
754        assert_eq!(&bytes[0..2], &again[..]);
755    }
756
757    #[test]
758    fn refuses_characters_the_font_lacks_instead_of_writing_blanks() {
759        let Some(data) = host_font() else { return };
760        let font = UnicodeFont::from_bytes(data).unwrap();
761        let mut enc = UnicodeEncoder::new(font);
762        let err = enc.encode("A\u{E000}").unwrap_err().to_string();
763        assert!(
764            err.contains("U+E000"),
765            "error should name the character: {err}"
766        );
767    }
768
769    #[test]
770    fn a_repeated_character_maps_back_to_one_character() {
771        // Regression: the first implementation appended to the /ToUnicode
772        // entry on every occurrence, so "aa" round-tripped as "aaaa".
773        // Rendering looked perfect; only extraction was wrong, which is the
774        // failure mode a structural assertion cannot see.
775        let Some(data) = host_font() else { return };
776        let font = UnicodeFont::from_bytes(data).unwrap();
777        let mut enc = UnicodeEncoder::new(font);
778        enc.encode("aaa").unwrap();
779        enc.encode("a").unwrap();
780
781        let mapped = enc
782            .to_unicode
783            .values()
784            .find(|s| s.starts_with('a'))
785            .expect("the glyph for 'a' should be registered");
786        assert_eq!(
787            mapped, "a",
788            "one glyph must map back to exactly one character, got {mapped:?}"
789        );
790    }
791
792    #[test]
793    fn a_variable_font_is_aimed_at_regular_not_its_own_default() {
794        // Noto Sans SC's variable build defaults to wght 100 (Thin), so
795        // embedding it as-is gives hairline body text that looks like a
796        // rendering fault rather than a font choice. Measured on the real
797        // file; the axis default is data, not an assumption.
798        let Some(data) = host_font() else { return };
799        let font = UnicodeFont::from_bytes(data).unwrap();
800
801        if !font.is_variable() {
802            // Static face: nothing to instance, and nothing to assert.
803            assert!(font.variations.is_empty());
804            return;
805        }
806        for (axis, value) in &font.variations {
807            if axis == &WGHT {
808                assert!(
809                    (*value - REGULAR_WEIGHT).abs() < f32::EPSILON,
810                    "a variable font should be pinned to Regular, got {value}"
811                );
812            }
813        }
814    }
815
816    #[test]
817    fn an_explicit_variation_overrides_the_automatic_one() {
818        let Some(data) = host_font() else { return };
819        let font = UnicodeFont::from_bytes(data)
820            .unwrap()
821            .with_variation(b"wght", 700.0);
822        let weight = font
823            .variations
824            .iter()
825            .find(|(a, _)| a == &WGHT)
826            .map(|(_, v)| *v);
827        assert_eq!(weight, Some(700.0), "caller's weight must win");
828    }
829
830    #[test]
831    fn w_array_coalesces_consecutive_glyphs() {
832        let Some(data) = host_font() else { return };
833        let font = UnicodeFont::from_bytes(data).unwrap();
834        let mut enc = UnicodeEncoder::new(font);
835        enc.encode("Hello world").unwrap();
836        let w = enc.build_w_array(|v| v.round() as i64);
837        // Alternating "start [widths…]" pairs.
838        assert_eq!(w.len() % 2, 0);
839        assert!(matches!(w.first(), Some(Object::Integer(_))));
840        assert!(matches!(w.get(1), Some(Object::Array(_))));
841    }
842
843    #[test]
844    fn subset_tag_is_six_uppercase_letters_and_stable() {
845        let Some(data) = host_font() else { return };
846        let font = UnicodeFont::from_bytes(data).unwrap();
847        let a = UnicodeEncoder::new(font.clone()).subset_base_font_name();
848        let b = UnicodeEncoder::new(font).subset_base_font_name();
849        assert_eq!(a, b, "same font must give the same tag across runs");
850        let tag = a.split('+').next().unwrap();
851        assert_eq!(tag.len(), 6);
852        assert!(tag.chars().all(|c| c.is_ascii_uppercase()));
853    }
854}