Skip to main content

rightkit_pdf/
font.rs

1//! PDF editor font system (Dispatch B, lane B2): catalog discovery, embedding/subsetting
2//! permission checks, glyph subsetting, & Type0/CID/ToUnicode assembly — `docs/PDF-EDITOR-NOW.md`
3//! §7.
4//!
5//! Owns everything the frozen `compose_candidate(source, commands, fonts: &dyn PdfFontResolver)`
6//! seam (`docs/PDF-EDITOR-DISPATCH.md`) needs on the font side: a concrete `PdfFontResolver`
7//! ([`FontCatalog`]) that `pdf_compose.rs` (lane C1) can construct and pass in, plus
8//! [`subset_for_pdf`], which `pdf_compose.rs` calls once it has decided (§7.3) that a selected
9//! catalog font must be embedded rather than reused from the source PDF. Wire/shared types
10//! (`PdfFontCatalogEntry`, `PdfFontSource`, `ResolvedPdfFont`, `GlyphId`, `PdfFontResolver`,
11//! `PdfEditError`) live in `pdf_model.rs` (lane A1); this file only implements behavior against
12//! them.
13//!
14//! ## §7.1 dependency roles used here
15//! - `fontdb` enumerates system fonts (family/style/source) without eagerly reading every font's
16//!   bytes.
17//! - `ttf-parser` reads metrics (`hmtx`), glyph indices (`cmap`), & the OS/2 `fsType` embedding/
18//!   subsetting permission bits (`Face::permissions`/`is_outline_embedding_allowed`/
19//!   `is_subsetting_allowed` — names verified against 0.25 docs per §7.1's note and used here
20//!   exactly as `pdf_model.rs`'s doc comments and §7.4's `subset_for_pdf` example show them).
21//! - `subsetter` (`subsetter::subset` + `GlyphRemapper`) produces the actual PDF-oriented TrueType
22//!   glyph subset embedded as `FontFile2`.
23//!
24//! ## Bundled fonts
25//! §7.2 says the catalog "includes bundled fonts such as Source Serif 4 plus eligible system
26//! fonts." Source Serif 4's WOFF2 bytes are compiled into this crate and decoded in memory with
27//! `wuff` before `ttf-parser`/`subsetter` inspect them. Installed apps therefore never depend on
28//! repository paths or separately packaged native resources.
29
30use std::collections::BTreeMap;
31use std::sync::OnceLock;
32
33use crate::model::{
34    GlyphId, PdfEditError, PdfFontCatalogEntry, PdfFontResolver, PdfFontSource, ResolvedPdfFont,
35};
36
37// -------------------------------------------------------------------------------------------
38// §7.2 — Catalog
39// -------------------------------------------------------------------------------------------
40
41/// One bundled font compiled into this crate (or injected directly for tests via
42/// [`FontCatalog::from_parts`]).
43#[derive(Clone, Debug)]
44struct BundledFontEntry {
45    id: String,
46    family: String,
47    postscript_name: Option<String>,
48    style: String,
49    bytes: Vec<u8>,
50    face_index: u32,
51}
52
53/// Resolves the font catalog (§7.2): bundled fonts plus system fonts discovered via `fontdb`.
54/// Implements [`PdfFontResolver`] so `pdf_compose.rs` (lane C1) can build the frozen
55/// `compose_candidate` seam against a real resolver.
56pub struct FontCatalog {
57    db: fontdb::Database,
58    bundled: Vec<BundledFontEntry>,
59}
60
61impl FontCatalog {
62    /// Builds a catalog from embedded bundled fonts plus installed system fonts. System-font
63    /// discovery is host-dependent; prefer [`FontCatalog::from_parts`] in deterministic tests.
64    pub fn discover() -> FontCatalog {
65        let mut db = fontdb::Database::new();
66        db.load_system_fonts();
67        let bundled = discover_bundled_fonts();
68        FontCatalog { db, bundled }
69    }
70
71    /// Process-wide cached catalog for `list_pdf_fonts` — `fontdb::Database::load_system_fonts`
72    /// walks the host font directories, so this only pays that cost once per process, matching the
73    /// warm-cache convention `system_fonts.rs` already uses for `list_system_fonts`.
74    pub fn shared() -> &'static FontCatalog {
75        static CATALOG: OnceLock<FontCatalog> = OnceLock::new();
76        CATALOG.get_or_init(FontCatalog::discover)
77    }
78
79    /// Builds a catalog from already-known bundled entries and an already-populated `fontdb`
80    /// database, with no scanning of its own. Used by tests that need fully deterministic font
81    /// bytes instead of whatever happens to be installed on the host running the test.
82    #[cfg(test)]
83    fn from_parts(db: fontdb::Database, bundled: Vec<BundledFontEntry>) -> FontCatalog {
84        FontCatalog { db, bundled }
85    }
86
87    /// Every catalog entry (§7.2's `list_pdf_fonts` payload): bundled fonts first, then system
88    /// fonts grouped by (family, style) so a family with multiple weights/styles yields one entry
89    /// per style rather than one per physical font file.
90    pub fn list_entries(&self) -> Vec<PdfFontCatalogEntry> {
91        let mut entries: Vec<PdfFontCatalogEntry> = Vec::new();
92
93        for bundled in &self.bundled {
94            let (embeddable, subsettable) = self
95                .parse_permissions(&bundled.bytes, bundled.face_index)
96                .unwrap_or((false, false));
97            entries.push(PdfFontCatalogEntry {
98                id: bundled.id.clone(),
99                family: bundled.family.clone(),
100                postscript_name: bundled.postscript_name.clone(),
101                styles: vec![bundled.style.clone()],
102                embeddable,
103                subsettable,
104                source: PdfFontSource::Bundled,
105            });
106        }
107
108        // Group system faces by family so `styles` aggregates every style fontdb reports for that
109        // family instead of duplicating one catalog row per face.
110        let mut by_family: BTreeMap<String, (String, Option<String>, Vec<String>)> =
111            BTreeMap::new();
112        let mut first_id_by_family: BTreeMap<String, fontdb::ID> = BTreeMap::new();
113
114        for face in self.db.faces() {
115            let Some((family, _)) = face
116                .families
117                .iter()
118                .find(|(_, lang)| *lang == fontdb::Language::English_UnitedStates)
119                .or_else(|| face.families.first())
120                .cloned()
121            else {
122                continue;
123            };
124            let style = format_style(face.style, face.weight, face.stretch);
125            let entry = by_family.entry(family.clone()).or_insert_with(|| {
126                (
127                    family.clone(),
128                    Some(face.post_script_name.clone()),
129                    Vec::new(),
130                )
131            });
132            if !entry.2.contains(&style) {
133                entry.2.push(style);
134            }
135            first_id_by_family.entry(family).or_insert(face.id);
136        }
137
138        for (family, (_, postscript_name, styles)) in by_family {
139            let Some(&face_id) = first_id_by_family.get(&family) else {
140                continue;
141            };
142            let (embeddable, subsettable) = self
143                .parse_permissions_by_id(face_id)
144                .unwrap_or((false, false));
145            entries.push(PdfFontCatalogEntry {
146                id: system_font_id(&family),
147                family,
148                postscript_name,
149                styles,
150                embeddable,
151                subsettable,
152                source: PdfFontSource::System,
153            });
154        }
155
156        entries
157    }
158
159    fn parse_permissions(&self, bytes: &[u8], face_index: u32) -> Option<(bool, bool)> {
160        let face = ttf_parser::Face::parse(bytes, face_index).ok()?;
161        Some(face_embedding_permissions(&face))
162    }
163
164    fn parse_permissions_by_id(&self, id: fontdb::ID) -> Option<(bool, bool)> {
165        self.db
166            .with_face_data(id, |bytes, index| {
167                ttf_parser::Face::parse(bytes, index)
168                    .ok()
169                    .map(|face| face_embedding_permissions(&face))
170            })
171            .flatten()
172    }
173}
174
175impl PdfFontResolver for FontCatalog {
176    fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
177        if let Some(bundled) = self.bundled.iter().find(|entry| entry.id == id) {
178            let (embeddable, subsettable) = self
179                .parse_permissions(&bundled.bytes, bundled.face_index)
180                .unwrap_or((false, false));
181            return Ok(ResolvedPdfFont {
182                id: bundled.id.clone(),
183                family: bundled.family.clone(),
184                bytes: bundled.bytes.clone(),
185                face_index: bundled.face_index,
186                embeddable,
187                subsettable,
188            });
189        }
190
191        // System lookup mirrors `list_entries`' id scheme (`system_font_id(family)`), so a catalog
192        // id round-trips back to the first face fontdb reports for that family.
193        for face in self.db.faces() {
194            let Some((family, _)) = face.families.first().cloned() else {
195                continue;
196            };
197            if system_font_id(&family) != id {
198                continue;
199            }
200            let resolved = self.db.with_face_data(face.id, |bytes, face_index| {
201                let (embeddable, subsettable) = ttf_parser::Face::parse(bytes, face_index)
202                    .ok()
203                    .map(|f| face_embedding_permissions(&f))
204                    .unwrap_or((false, false));
205                ResolvedPdfFont {
206                    id: id.to_string(),
207                    family: family.clone(),
208                    bytes: bytes.to_vec(),
209                    face_index,
210                    embeddable,
211                    subsettable,
212                }
213            });
214            if let Some(resolved) = resolved {
215                return Ok(resolved);
216            }
217        }
218
219        Err(PdfEditError::UnsupportedEncoding)
220    }
221
222    fn glyphs_for_text(
223        &self,
224        font: &ResolvedPdfFont,
225        text: &str,
226    ) -> Result<Vec<GlyphId>, PdfEditError> {
227        glyph_ids_for_text(&font.bytes, font.face_index, text)
228    }
229}
230
231/// The one implementation of `text` → glyph ids through a font program's cmap: parse `bytes` at
232/// `face_index`, then map every char, failing with [`PdfEditError::MissingGlyph`] on the first char
233/// the font has no glyph for (the all-or-nothing contract §7.3 rule 3 relies on). `pub(crate)` so
234/// `pdf_compose.rs`'s test-only `PdfFontResolver` mocks share the real algorithm instead of
235/// reimplementing — and silently drifting from — it.
236pub(crate) fn glyph_ids_for_text(
237    bytes: &[u8],
238    face_index: u32,
239    text: &str,
240) -> Result<Vec<GlyphId>, PdfEditError> {
241    let face = ttf_parser::Face::parse(bytes, face_index)
242        .map_err(|_| PdfEditError::UnsupportedEncoding)?;
243    text.chars()
244        .map(|c| {
245            face.glyph_index(c)
246                .map(|g| GlyphId(g.0))
247                .ok_or(PdfEditError::MissingGlyph)
248        })
249        .collect()
250}
251
252/// §7.4's `is_outline_embedding_allowed`/`is_subsetting_allowed` + `permissions()` gate, factored
253/// out so both catalog listing (§7.2's `embeddable`/`subsettable` columns) and [`subset_for_pdf`]
254/// (§7.4's hard reject) agree on exactly one definition. Returns `(embeddable, subsettable)`.
255fn face_embedding_permissions(face: &ttf_parser::Face) -> (bool, bool) {
256    let restricted = matches!(
257        face.permissions(),
258        Some(ttf_parser::Permissions::Restricted)
259    );
260    let embeddable = !restricted && face.is_outline_embedding_allowed();
261    let subsettable = !restricted && face.is_subsetting_allowed();
262    (embeddable, subsettable)
263}
264
265fn format_style(style: fontdb::Style, weight: fontdb::Weight, stretch: fontdb::Stretch) -> String {
266    let mut parts = Vec::new();
267    if stretch != fontdb::Stretch::Normal {
268        parts.push(format!("{stretch:?}"));
269    }
270    match weight.0 {
271        400 => {}
272        700 => parts.push("Bold".to_string()),
273        other => parts.push(format!("{other}")),
274    }
275    match style {
276        fontdb::Style::Normal => {}
277        fontdb::Style::Italic => parts.push("Italic".to_string()),
278        fontdb::Style::Oblique => parts.push("Oblique".to_string()),
279    }
280    if parts.is_empty() {
281        "Regular".to_string()
282    } else {
283        parts.join(" ")
284    }
285}
286
287/// Stable catalog id for a system family. Deliberately not the raw family string (§7.2: "Font ID,
288/// not family label, crosses Save boundary") — this keeps ids filesystem/JSON-key friendly and
289/// collision-free-in-practice for typical family names.
290fn system_font_id(family: &str) -> String {
291    let slug: String = family
292        .chars()
293        .map(|c| {
294            if c.is_ascii_alphanumeric() {
295                c.to_ascii_lowercase()
296            } else {
297                '-'
298            }
299        })
300        .collect();
301    format!("system-{slug}")
302}
303
304/// Compressed web-font bytes become part of `rightkit-pdf` itself. `include_bytes!` also makes
305/// Cargo track the font as an input, while `wuff` reconstructs SFNT bytes once per process when
306/// [`FontCatalog::shared`] initializes.
307const SOURCE_SERIF_4_WOFF2: &[u8] = include_bytes!("../assets/SourceSerif4-Variable.woff2");
308
309fn bundled_font_id(family: &str, postscript_name: Option<&str>) -> String {
310    // Frontend PDF edit state persists this exact id. Source Serif 4's variable WOFF2 metadata
311    // uses a PostScript name that varies between upstream releases, so key it by stable family.
312    if family.eq_ignore_ascii_case("Source Serif 4") {
313        return "source-serif-4".to_string();
314    }
315    postscript_name
316        .unwrap_or(family)
317        .to_ascii_lowercase()
318        .replace(' ', "-")
319}
320
321fn discover_bundled_fonts() -> Vec<BundledFontEntry> {
322    let Some(bytes) = wuff::decompress_woff2(SOURCE_SERIF_4_WOFF2).ok() else {
323        return Vec::new();
324    };
325    let Ok(face) = ttf_parser::Face::parse(&bytes, 0) else {
326        return Vec::new();
327    };
328    let family = face
329        .names()
330        .into_iter()
331        .find(|name| name.name_id == ttf_parser::name_id::FAMILY && name.is_unicode())
332        .and_then(|name| name.to_string())
333        .unwrap_or_else(|| "Source Serif 4".to_string());
334    let postscript_name = face
335        .names()
336        .into_iter()
337        .find(|name| name.name_id == ttf_parser::name_id::POST_SCRIPT_NAME && name.is_unicode())
338        .and_then(|name| name.to_string());
339    let style = if face.is_bold() && face.is_italic() {
340        "Bold Italic"
341    } else if face.is_bold() {
342        "Bold"
343    } else if face.is_italic() {
344        "Italic"
345    } else {
346        "Regular"
347    }
348    .to_string();
349
350    vec![BundledFontEntry {
351        id: bundled_font_id(&family, postscript_name.as_deref()),
352        family,
353        postscript_name,
354        style,
355        bytes,
356        face_index: 0,
357    }]
358}
359
360// -------------------------------------------------------------------------------------------
361// §7.4 — Embedded Type0/CID/ToUnicode subset
362// -------------------------------------------------------------------------------------------
363
364/// One glyph's advance width for the embedded `/W` array, in `/1000` text-space units (matching
365/// every other width convention in this codebase — see `pdf_model.rs::TextState::glyph_quad`).
366#[derive(Clone, Copy, Debug, PartialEq)]
367pub struct PdfCidWidth {
368    pub cid: u32,
369    pub width_1000: f64,
370}
371
372/// Result of [`subset_for_pdf`]: everything `pdf_compose.rs` (lane C1) needs to emit §7.4's
373/// `Type0`/`CIDFontType2`/`FontFile2`/`ToUnicode` object graph for one embedded font subset.
374///
375/// This implementation assigns CIDs to equal the subset font's own new (post-subsetting) glyph
376/// ids — i.e. `/CIDToGIDMap /Identity` is always correct for a subset this function produced,
377/// so callers never need a separate CIDToGIDMap stream. `/Encoding /Identity-H` (§7.4) then maps
378/// each 2-byte code directly to that CID/GID.
379#[derive(Clone, Debug)]
380pub struct EmbeddedPdfFontSubset {
381    /// Subset TrueType font program bytes for the `FontFile2` stream.
382    pub font_program: Vec<u8>,
383    /// `/W` array entries, one per CID actually used by `text`. Order matches first-appearance
384    /// order in `text`, not sorted — `pdf_compose.rs` is free to sort/compact before emitting.
385    pub widths: Vec<PdfCidWidth>,
386    /// `(cid, unicode)` pairs for the generated `ToUnicode` CMap — see [`build_to_unicode_cmap`].
387    pub to_unicode: Vec<(u32, String)>,
388    /// Number of glyphs in `font_program` (subset `maxp.numGlyphs`), for callers that want to
389    /// sanity-check `/CIDToGIDMap /Identity` bounds before composing. Not yet read by any caller
390    /// in this crate — part of the struct's documented API surface for a future consumer.
391    #[allow(dead_code)]
392    pub glyph_count: u16,
393}
394
395/// Subsets `bytes` (a resolved catalog font's program, `face_index` `0` for a single-face file) to
396/// exactly the glyphs `text` needs, per §7.4's `subset_for_pdf`. Rejects up front (before touching
397/// the subsetter) when the font's OS/2 `fsType` forbids outline embedding or subsetting — §7.3
398/// rule 3: "If selected font cannot embed or lacks a glyph, fail before candidate creation."
399pub fn subset_for_pdf(
400    bytes: &[u8],
401    face_index: u32,
402    text: &str,
403) -> Result<EmbeddedPdfFontSubset, PdfEditError> {
404    let face = ttf_parser::Face::parse(bytes, face_index)
405        .map_err(|_| PdfEditError::UnsupportedEncoding)?;
406
407    let (embeddable, subsettable) = face_embedding_permissions(&face);
408    if !embeddable || !subsettable {
409        return Err(PdfEditError::FontEmbeddingDenied);
410    }
411
412    let units_per_em = face.units_per_em() as f64;
413    if units_per_em <= 0.0 {
414        return Err(PdfEditError::UnsupportedEncoding);
415    }
416
417    // First-appearance-ordered, de-duplicated (char, old_gid) pairs. Missing a glyph fails the
418    // whole subset — §7.3 rule 3, and matches `PdfFontResolver::glyphs_for_text`'s all-or-nothing
419    // contract so a caller that already validated glyph coverage via that trait method never hits
420    // a surprise here.
421    let mut seen_glyphs: Vec<u16> = Vec::new();
422    let mut char_glyphs: Vec<(char, u16)> = Vec::new();
423    for c in text.chars() {
424        let gid = face.glyph_index(c).ok_or(PdfEditError::MissingGlyph)?.0;
425        if !seen_glyphs.contains(&gid) {
426            seen_glyphs.push(gid);
427        }
428        char_glyphs.push((c, gid));
429    }
430
431    let mut remapper = subsetter::GlyphRemapper::new();
432    // `old_gid -> new_gid`, populated in the exact order we call `remap`, which is also the order
433    // `subsetter::subset` uses to build the output font — safe to rely on regardless of whatever
434    // internal storage `GlyphRemapper` uses, since we only ever read back values we ourselves
435    // pushed.
436    let mut old_to_new: BTreeMap<u16, u16> = BTreeMap::new();
437    for &old_gid in &seen_glyphs {
438        let new_gid = remapper.remap(old_gid);
439        old_to_new.insert(old_gid, new_gid);
440    }
441
442    let font_program = subsetter::subset(bytes, face_index, &remapper)
443        .map_err(|_| PdfEditError::UnsupportedEncoding)?;
444
445    let mut widths = Vec::with_capacity(seen_glyphs.len());
446    for &old_gid in &seen_glyphs {
447        let new_gid = old_to_new[&old_gid];
448        let advance = face
449            .glyph_hor_advance(ttf_parser::GlyphId(old_gid))
450            .unwrap_or(0);
451        widths.push(PdfCidWidth {
452            cid: new_gid as u32,
453            width_1000: advance as f64 * 1000.0 / units_per_em,
454        });
455    }
456
457    // ToUnicode: first char that mapped to each new CID wins (mirrors `widths`' first-appearance
458    // convention above); later duplicate chars sharing a glyph don't need a second CMap entry.
459    let mut to_unicode: Vec<(u32, String)> = Vec::new();
460    let mut cid_has_unicode: BTreeMap<u32, ()> = BTreeMap::new();
461    for (c, old_gid) in &char_glyphs {
462        let new_gid = old_to_new[old_gid] as u32;
463        if cid_has_unicode.insert(new_gid, ()).is_none() {
464            to_unicode.push((new_gid, c.to_string()));
465        }
466    }
467
468    Ok(EmbeddedPdfFontSubset {
469        font_program,
470        widths,
471        to_unicode,
472        glyph_count: seen_glyphs.len() as u16 + 1, // +1 for glyph 0 (.notdef), always present
473    })
474}
475
476/// Builds a generated Unicode `ToUnicode` CMap (§7.4) as PDF stream text, using `bfchar` entries —
477/// simplest correct form for a subset whose CIDs (per this module's `/CIDToGIDMap /Identity`
478/// convention) are small, sparse, non-contiguous glyph ids, where `bfrange` compaction buys little.
479/// `entries` need not be sorted; output is sorted by `cid` for a deterministic, diff-friendly
480/// stream.
481pub fn build_to_unicode_cmap(entries: &[(u32, String)]) -> String {
482    let mut sorted = entries.to_vec();
483    sorted.sort_by_key(|(cid, _)| *cid);
484
485    let mut out = String::new();
486    out.push_str("/CIDInit /ProcSet findresource begin\n");
487    out.push_str("12 dict begin\nbegincmap\n");
488    out.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
489    out.push_str("/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n");
490    out.push_str("1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n");
491    out.push_str(&format!("{} beginbfchar\n", sorted.len()));
492    for (cid, unicode) in &sorted {
493        out.push_str(&format!(
494            "<{:04X}> <{}>\n",
495            cid,
496            unicode_to_utf16be_hex(unicode)
497        ));
498    }
499    out.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
500    out
501}
502
503fn unicode_to_utf16be_hex(text: &str) -> String {
504    let mut hex = String::new();
505    for unit in text.encode_utf16() {
506        hex.push_str(&format!("{unit:04X}"));
507    }
508    hex
509}
510
511// -------------------------------------------------------------------------------------------
512// Tests (§14.1 font fixtures)
513// -------------------------------------------------------------------------------------------
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::test_fixtures::build_test_ttf;
519
520    // ---------------------------------------------------------------------------------------
521    // §14.1 fixtures
522    // ---------------------------------------------------------------------------------------
523
524    const FS_TYPE_INSTALLABLE: u16 = 0x0000;
525    const FS_TYPE_RESTRICTED: u16 = 0x0002; // bit1: Restricted License embedding
526    const FS_TYPE_NO_SUBSETTING: u16 = 0x0100; // bit8: No subsetting
527
528    fn installable_font() -> Vec<u8> {
529        build_test_ttf(FS_TYPE_INSTALLABLE, &[('A', 1), ('B', 2), ('C', 3)], 600)
530    }
531
532    #[test]
533    fn installable_font_parses_and_reports_embeddable_subsettable() {
534        let bytes = installable_font();
535        let face = ttf_parser::Face::parse(&bytes, 0).expect("valid synthetic font");
536        let (embeddable, subsettable) = face_embedding_permissions(&face);
537        assert!(embeddable);
538        assert!(subsettable);
539    }
540
541    #[test]
542    fn restricted_font_is_not_embeddable() {
543        let bytes = build_test_ttf(FS_TYPE_RESTRICTED, &[('A', 1)], 600);
544        let face = ttf_parser::Face::parse(&bytes, 0).expect("valid synthetic font");
545        let (embeddable, subsettable) = face_embedding_permissions(&face);
546        assert!(!embeddable);
547        assert!(!subsettable);
548    }
549
550    #[test]
551    fn subset_for_pdf_rejects_embedding_restricted_font() {
552        let bytes = build_test_ttf(FS_TYPE_RESTRICTED, &[('A', 1)], 600);
553        let err = subset_for_pdf(&bytes, 0, "A").expect_err("restricted font must reject");
554        assert!(matches!(err, PdfEditError::FontEmbeddingDenied));
555    }
556
557    #[test]
558    fn subset_for_pdf_rejects_subset_disallowed_font() {
559        let bytes = build_test_ttf(FS_TYPE_NO_SUBSETTING, &[('A', 1)], 600);
560        let err = subset_for_pdf(&bytes, 0, "A").expect_err("no-subsetting font must reject");
561        assert!(matches!(err, PdfEditError::FontEmbeddingDenied));
562    }
563
564    #[test]
565    fn subset_for_pdf_reports_missing_glyph() {
566        let bytes = installable_font(); // only maps A, B, C
567        let err = subset_for_pdf(&bytes, 0, "AZ").expect_err("Z has no glyph");
568        assert!(matches!(err, PdfEditError::MissingGlyph));
569    }
570
571    #[test]
572    fn subset_for_pdf_succeeds_for_covered_text() {
573        let bytes = installable_font();
574        let subset = subset_for_pdf(&bytes, 0, "ABA").expect("A and B are covered");
575        // "ABA" uses 2 distinct glyphs (A, B); each gets exactly one /W entry.
576        assert_eq!(subset.widths.len(), 2);
577        assert!(subset
578            .widths
579            .iter()
580            .all(|w| (w.width_1000 - 600.0).abs() < 1e-6));
581        // ToUnicode has exactly one entry per distinct CID, not per character occurrence.
582        assert_eq!(subset.to_unicode.len(), 2);
583        let mut chars: Vec<&str> = subset
584            .to_unicode
585            .iter()
586            .map(|(_, unicode)| unicode.as_str())
587            .collect();
588        chars.sort();
589        assert_eq!(chars, vec!["A", "B"]);
590        // Subset font program itself must still be a valid, parseable sfnt.
591        ttf_parser::Face::parse(&subset.font_program, 0).expect("subset output is valid TTF");
592    }
593
594    #[test]
595    fn subset_for_pdf_glyphs_for_text_agrees_with_resolver_trait() {
596        let bytes = installable_font();
597        let resolved = ResolvedPdfFont {
598            id: "test-font".to_string(),
599            family: "Test Font".to_string(),
600            bytes: bytes.clone(),
601            face_index: 0,
602            embeddable: true,
603            subsettable: true,
604        };
605        let catalog = FontCatalog::from_parts(fontdb::Database::new(), Vec::new());
606        let glyphs = catalog
607            .glyphs_for_text(&resolved, "AB")
608            .expect("A and B are covered");
609        assert_eq!(glyphs, vec![GlyphId(1), GlyphId(2)]);
610
611        let err = catalog
612            .glyphs_for_text(&resolved, "Z")
613            .expect_err("Z is not covered");
614        assert!(matches!(err, PdfEditError::MissingGlyph));
615    }
616
617    #[test]
618    fn build_to_unicode_cmap_round_trips_generated_mapping() {
619        let subset = subset_for_pdf(&installable_font(), 0, "AB").expect("covered text");
620        let cmap = build_to_unicode_cmap(&subset.to_unicode);
621
622        assert!(cmap.contains("beginbfchar"));
623        assert!(cmap.contains("endbfchar"));
624        assert!(cmap.contains("/CMapType 2 def"));
625
626        // Round-trip check: every (cid, "A"/"B") pair must appear as `<CID_HEX> <UTF16BE_HEX>`.
627        for (cid, unicode) in &subset.to_unicode {
628            let expected = format!("<{cid:04X}> <{}>", unicode_to_utf16be_hex(unicode));
629            assert!(
630                cmap.contains(&expected),
631                "missing {expected} in generated CMap:\n{cmap}"
632            );
633        }
634    }
635
636    #[test]
637    fn build_to_unicode_cmap_is_sorted_by_cid_and_deterministic() {
638        let entries = vec![
639            (5u32, "b".to_string()),
640            (1u32, "a".to_string()),
641            (3u32, "c".to_string()),
642        ];
643        let cmap_a = build_to_unicode_cmap(&entries);
644        let cmap_b = build_to_unicode_cmap(&entries);
645        assert_eq!(cmap_a, cmap_b);
646        let pos_1 = cmap_a.find("<0001>").expect("cid 1 present");
647        let pos_3 = cmap_a.find("<0003>").expect("cid 3 present");
648        let pos_5 = cmap_a.find("<0005>").expect("cid 5 present");
649        assert!(pos_1 < pos_3 && pos_3 < pos_5);
650    }
651
652    #[test]
653    fn unicode_to_utf16be_hex_encodes_bmp_text() {
654        assert_eq!(unicode_to_utf16be_hex("A"), "0041");
655        assert_eq!(unicode_to_utf16be_hex("AB"), "00410042");
656    }
657
658    #[test]
659    fn font_catalog_from_parts_lists_bundled_entry_with_permissions() {
660        let bundled = BundledFontEntry {
661            id: "test-serif".to_string(),
662            family: "Test Serif".to_string(),
663            postscript_name: Some("TestSerif-Regular".to_string()),
664            style: "Regular".to_string(),
665            bytes: installable_font(),
666            face_index: 0,
667        };
668        let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled]);
669        let entries = catalog.list_entries();
670        assert_eq!(entries.len(), 1);
671        let entry = &entries[0];
672        assert_eq!(entry.id, "test-serif");
673        assert_eq!(entry.source, PdfFontSource::Bundled);
674        assert!(entry.embeddable);
675        assert!(entry.subsettable);
676    }
677
678    #[test]
679    fn font_catalog_resolve_returns_bundled_font_bytes() {
680        let bytes = installable_font();
681        let bundled = BundledFontEntry {
682            id: "test-serif".to_string(),
683            family: "Test Serif".to_string(),
684            postscript_name: None,
685            style: "Regular".to_string(),
686            bytes: bytes.clone(),
687            face_index: 0,
688        };
689        let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled]);
690        let resolved = catalog
691            .resolve("test-serif")
692            .expect("bundled font resolves");
693        assert_eq!(resolved.bytes, bytes);
694        assert!(resolved.embeddable);
695    }
696
697    #[test]
698    fn font_catalog_resolve_unknown_id_is_unsupported_encoding() {
699        let catalog = FontCatalog::from_parts(fontdb::Database::new(), Vec::new());
700        let err = catalog
701            .resolve("does-not-exist")
702            .expect_err("unknown id must fail");
703        assert!(matches!(err, PdfEditError::UnsupportedEncoding));
704    }
705
706    #[test]
707    fn bundled_fonts_are_available_without_runtime_files() {
708        assert_eq!(discover_bundled_fonts().len(), 1);
709    }
710
711    #[test]
712    fn source_serif_4_woff2_is_discovered_resolved_parseable_and_subsettable() {
713        let bundled = discover_bundled_fonts()
714            .into_iter()
715            .find(|font| font.id == "source-serif-4")
716            .expect("embedded Source Serif 4 WOFF2 is available");
717        let face = ttf_parser::Face::parse(&bundled.bytes, bundled.face_index)
718            .expect("decoded Source Serif 4 is parseable SFNT");
719        assert!(face.glyph_index('ç').is_some());
720        assert!(face.glyph_index('ã').is_some());
721
722        let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled.clone()]);
723        let entry = catalog
724            .list_entries()
725            .into_iter()
726            .find(|entry| entry.id == "source-serif-4")
727            .expect("Source Serif 4 catalog entry");
728        assert_eq!(entry.source, PdfFontSource::Bundled);
729        assert!(entry.embeddable);
730        assert!(entry.subsettable);
731
732        let resolved = catalog
733            .resolve("source-serif-4")
734            .expect("stable frontend font id resolves");
735        assert_eq!(resolved.bytes, bundled.bytes);
736
737        let correction = "A correção mantém ação, informação e português.";
738        catalog
739            .glyphs_for_text(&resolved, correction)
740            .expect("Portuguese correction text has complete glyph coverage");
741        let subset = subset_for_pdf(&resolved.bytes, resolved.face_index, correction)
742            .expect("Source Serif 4 subset is embeddable");
743        ttf_parser::Face::parse(&subset.font_program, 0)
744            .expect("Source Serif 4 subset output is parseable SFNT");
745        assert!(subset.widths.len() > 10);
746        assert!(subset.to_unicode.iter().any(|(_, unicode)| unicode == "ç"));
747        assert!(subset.to_unicode.iter().any(|(_, unicode)| unicode == "ã"));
748    }
749
750    #[test]
751    fn bundled_font_id_stabilizes_source_serif_metadata() {
752        assert_eq!(
753            bundled_font_id("Source Serif 4", Some("SourceSerif4-Roman")),
754            "source-serif-4"
755        );
756        assert_eq!(
757            bundled_font_id("Test Serif", Some("TestSerif-Regular")),
758            "testserif-regular"
759        );
760    }
761
762    #[test]
763    fn system_font_id_is_stable_and_slugified() {
764        assert_eq!(system_font_id("Arial"), "system-arial");
765        assert_eq!(system_font_id("Source Serif 4"), "system-source-serif-4");
766        assert_eq!(system_font_id("Arial"), system_font_id("Arial"));
767    }
768}