Skip to main content

rpdfium_doc/
ba_font_map.rs

1// Derived from PDFium's cba_fontmap.cpp
2// Original: Copyright 2014 The PDFium Authors
3// Licensed under BSD-3-Clause / Apache-2.0
4// See pdfium-upstream/LICENSE for the original license.
5
6//! Form field font mapping (`/DR` + `/DA` resolution).
7//!
8//! `BaFontMap` resolves the fonts available for form field appearance streams
9//! by parsing the interactive form's default resource dictionary (`/DR`) and
10//! the field's default appearance string (`/DA`).
11
12use std::collections::HashMap;
13
14use rpdfium_core::{Name, PdfSource};
15use rpdfium_parser::{Object, ObjectStore};
16
17/// A single entry in the form font map.
18#[derive(Debug, Clone)]
19pub struct BaFontMapEntry {
20    /// The font resource name (e.g., "Helv", "Cour", "ZaDb").
21    pub font_name: String,
22    /// Charset identifier (0 = ANSI, 1 = Symbol, etc.).
23    pub charset: u8,
24}
25
26/// Font map for form field appearance streams.
27///
28/// Resolves font resources from the interactive form's `/DR` dictionary
29/// and the field's `/DA` (default appearance) string.
30#[derive(Debug, Clone)]
31pub struct BaFontMap {
32    /// Available font entries from `/DR`.
33    entries: Vec<BaFontMapEntry>,
34    /// Default font name extracted from `/DA`.
35    default_font: Option<String>,
36    /// Default font size extracted from `/DA`.
37    default_size: f32,
38}
39
40impl BaFontMap {
41    /// Build a font map from the form's `/DR` dictionary and `/DA` string.
42    ///
43    /// `dr_dict` is the Font sub-dictionary from `/DR` → `/Font`.
44    /// `da_string` is the default appearance string (e.g., "0 g /Helv 12 Tf").
45    pub fn from_resources<S: PdfSource>(
46        dr_dict: Option<&HashMap<Name, Object>>,
47        da_string: Option<&str>,
48        store: &ObjectStore<S>,
49    ) -> Self {
50        let mut entries = Vec::new();
51
52        // Parse /DR → /Font entries
53        if let Some(font_dict) = dr_dict {
54            for (name, obj) in font_dict {
55                let font_name = name.as_str().into_owned();
56
57                // Try to extract charset from the font dictionary
58                let charset = if let Ok(resolved) = store.deep_resolve(obj) {
59                    extract_charset(resolved)
60                } else {
61                    0 // default ANSI
62                };
63
64                entries.push(BaFontMapEntry { font_name, charset });
65            }
66        }
67
68        // Parse /DA string
69        let (default_font, default_size) = if let Some(da) = da_string {
70            parse_default_appearance_font(da)
71        } else {
72            (None, 0.0)
73        };
74
75        Self {
76            entries,
77            default_font,
78            default_size,
79        }
80    }
81
82    /// Return the default font name from `/DA`.
83    pub fn default_font_name(&self) -> Option<&str> {
84        self.default_font.as_deref()
85    }
86
87    /// Return the default font size from `/DA`.
88    pub fn default_font_size(&self) -> f32 {
89        self.default_size
90    }
91
92    /// Return the number of available fonts.
93    pub fn font_count(&self) -> usize {
94        self.entries.len()
95    }
96
97    /// Return the font name at the given index.
98    pub fn font_name(&self, index: usize) -> Option<&str> {
99        self.entries.get(index).map(|e| e.font_name.as_str())
100    }
101
102    /// Upstream-aligned alias for [`font_name`](Self::font_name).
103    #[inline]
104    pub fn get_font_name(&self, index: usize) -> Option<&str> {
105        self.font_name(index)
106    }
107
108    /// Return the charset of the font at the given index.
109    pub fn charset(&self, index: usize) -> Option<u8> {
110        self.entries.get(index).map(|e| e.charset)
111    }
112
113    /// Upstream-aligned alias for [`charset`](Self::charset).
114    #[inline]
115    pub fn get_charset(&self, index: usize) -> Option<u8> {
116        self.charset(index)
117    }
118
119    /// Find a font entry by name.
120    pub fn find_font(&self, name: &str) -> Option<&BaFontMapEntry> {
121        self.entries.iter().find(|e| e.font_name == name)
122    }
123
124    /// Return all font entries.
125    pub fn entries(&self) -> &[BaFontMapEntry] {
126        &self.entries
127    }
128
129    /// Find a font entry by name, falling back to standard font name aliases.
130    ///
131    /// Tries the exact name first, then checks common abbreviation/alias mappings:
132    /// - "Helv" ↔ "Helvetica"
133    /// - "Cour" ↔ "Courier"
134    /// - "TiRo" ↔ "TimesNewRoman" / "Times-Roman"
135    /// - "ZaDb" ↔ "ZapfDingbats"
136    pub fn find_font_or_fallback(&self, name: &str) -> Option<&BaFontMapEntry> {
137        // Try exact match first
138        if let Some(entry) = self.find_font(name) {
139            return Some(entry);
140        }
141
142        // Try aliases
143        let aliases = match name {
144            "Helv" | "Helvetica" => &["Helv", "Helvetica", "Helvetica-Bold", "Arial"][..],
145            "Cour" | "Courier" => &["Cour", "Courier", "Courier-Bold"][..],
146            "TiRo" | "TimesNewRoman" | "Times-Roman" => {
147                &["TiRo", "TimesNewRoman", "Times-Roman", "Times"][..]
148            }
149            "ZaDb" | "ZapfDingbats" => &["ZaDb", "ZapfDingbats"][..],
150            "Symb" | "Symbol" => &["Symb", "Symbol"][..],
151            _ => &[][..],
152        };
153
154        for alias in aliases {
155            if let Some(entry) = self.find_font(alias) {
156                return Some(entry);
157            }
158        }
159
160        None
161    }
162}
163
164/// Parse the font name and size from a `/DA` string.
165///
166/// DA strings look like: `"0 g /Helv 12 Tf"` or `"/Cour 10 Tf 0 0 0 rg"`
167pub fn parse_default_appearance_font(da: &str) -> (Option<String>, f32) {
168    let mut font_name: Option<String> = None;
169    let mut font_size = 0.0_f32;
170    let mut last_number: Option<f32> = None;
171
172    for token in da.split_whitespace() {
173        if let Some(stripped) = token.strip_prefix('/') {
174            font_name = Some(stripped.to_string());
175            last_number = None;
176        } else if token == "Tf" {
177            if let Some(size) = last_number {
178                font_size = size;
179            }
180            last_number = None;
181        } else if let Ok(n) = token.parse::<f32>() {
182            last_number = Some(n);
183        } else {
184            last_number = None;
185        }
186    }
187
188    (font_name, font_size)
189}
190
191/// Detect charset from a font dictionary's encoding or flags.
192fn extract_charset(obj: &Object) -> u8 {
193    let dict = match obj {
194        Object::Dictionary(d) => d,
195        Object::Stream { dict, .. } => dict,
196        _ => return 0,
197    };
198
199    // Check /Encoding for Symbol or ZapfDingbats
200    if let Some(enc_obj) = dict.get(&Name::encoding()) {
201        if let Some(name) = enc_obj.as_name() {
202            let s = name.as_str();
203            if s.contains("Symbol") {
204                return 2; // Symbol charset
205            }
206        }
207    }
208
209    // Check /BaseFont for known symbol fonts
210    if let Some(bf_obj) = dict.get(&Name::base_font()) {
211        if let Some(name) = bf_obj.as_name() {
212            let s = name.as_str();
213            if s.contains("Symbol") {
214                return 2;
215            }
216            if s.contains("ZapfDingbats") {
217                return 2;
218            }
219        }
220    }
221
222    0 // ANSI default
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_parse_da_font_basic() {
231        let (name, size) = parse_default_appearance_font("0 g /Helv 12 Tf");
232        assert_eq!(name.as_deref(), Some("Helv"));
233        assert_eq!(size, 12.0);
234    }
235
236    #[test]
237    fn test_parse_da_font_courier() {
238        let (name, size) = parse_default_appearance_font("/Cour 10 Tf 0 0 0 rg");
239        assert_eq!(name.as_deref(), Some("Cour"));
240        assert_eq!(size, 10.0);
241    }
242
243    #[test]
244    fn test_parse_da_no_font() {
245        let (name, size) = parse_default_appearance_font("0 g");
246        assert!(name.is_none());
247        assert_eq!(size, 0.0);
248    }
249
250    #[test]
251    fn test_parse_da_empty() {
252        let (name, size) = parse_default_appearance_font("");
253        assert!(name.is_none());
254        assert_eq!(size, 0.0);
255    }
256
257    #[test]
258    fn test_parse_da_zero_size() {
259        let (name, size) = parse_default_appearance_font("/Helv 0 Tf");
260        assert_eq!(name.as_deref(), Some("Helv"));
261        assert_eq!(size, 0.0);
262    }
263
264    fn build_store() -> ObjectStore<Vec<u8>> {
265        let pdf = build_minimal_pdf();
266        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
267    }
268
269    fn build_minimal_pdf() -> Vec<u8> {
270        let mut pdf = Vec::new();
271        pdf.extend_from_slice(b"%PDF-1.4\n");
272        let obj1_offset = pdf.len();
273        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
274        let obj2_offset = pdf.len();
275        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
276        let xref_offset = pdf.len();
277        pdf.extend_from_slice(b"xref\n0 3\n");
278        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
279        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
280        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
281        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
282        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
283        pdf
284    }
285
286    #[test]
287    fn test_empty_font_map() {
288        let store = build_store();
289        let map = BaFontMap::from_resources(None, None, &store);
290        assert_eq!(map.font_count(), 0);
291        assert!(map.default_font_name().is_none());
292        assert_eq!(map.default_font_size(), 0.0);
293    }
294
295    #[test]
296    fn test_font_map_with_dr() {
297        let store = build_store();
298
299        let mut font_dict = HashMap::new();
300        // Simple font entry
301        let mut helv_dict = HashMap::new();
302        helv_dict.insert(Name::base_font(), Object::Name(Name::from("Helvetica")));
303        font_dict.insert(Name::from("Helv"), Object::Dictionary(helv_dict));
304
305        let mut cour_dict = HashMap::new();
306        cour_dict.insert(Name::base_font(), Object::Name(Name::from("Courier")));
307        font_dict.insert(Name::from("Cour"), Object::Dictionary(cour_dict));
308
309        let map = BaFontMap::from_resources(Some(&font_dict), Some("/Helv 12 Tf"), &store);
310        assert_eq!(map.font_count(), 2);
311        assert_eq!(map.default_font_name(), Some("Helv"));
312        assert_eq!(map.default_font_size(), 12.0);
313    }
314
315    #[test]
316    fn test_font_map_find_font() {
317        let store = build_store();
318
319        let mut font_dict = HashMap::new();
320        let helv_dict = HashMap::new();
321        font_dict.insert(Name::from("Helv"), Object::Dictionary(helv_dict));
322
323        let map = BaFontMap::from_resources(Some(&font_dict), None, &store);
324        assert!(map.find_font("Helv").is_some());
325        assert!(map.find_font("Missing").is_none());
326    }
327
328    #[test]
329    fn test_font_map_standard_fonts() {
330        let store = build_store();
331
332        let standard_names = ["Helv", "Cour", "TiRo", "ZaDb"];
333        let mut font_dict = HashMap::new();
334        for name in &standard_names {
335            font_dict.insert(Name::from(*name), Object::Dictionary(HashMap::new()));
336        }
337
338        let map = BaFontMap::from_resources(Some(&font_dict), None, &store);
339        assert_eq!(map.font_count(), 4);
340        for (i, name) in standard_names.iter().enumerate() {
341            assert_eq!(map.font_name(i).is_some(), true);
342            // Entries may be in any order since HashMap doesn't preserve order
343            assert!(map.find_font(name).is_some());
344        }
345    }
346
347    #[test]
348    fn test_font_map_symbol_charset() {
349        let store = build_store();
350
351        let mut font_dict = HashMap::new();
352        let mut zadb_dict = HashMap::new();
353        zadb_dict.insert(Name::base_font(), Object::Name(Name::from("ZapfDingbats")));
354        font_dict.insert(Name::from("ZaDb"), Object::Dictionary(zadb_dict));
355
356        let map = BaFontMap::from_resources(Some(&font_dict), None, &store);
357        let entry = map.find_font("ZaDb").unwrap();
358        assert_eq!(entry.charset, 2); // Symbol
359    }
360
361    #[test]
362    fn test_get_font_name_out_of_bounds() {
363        let store = build_store();
364        let map = BaFontMap::from_resources(None, None, &store);
365        assert!(map.font_name(0).is_none());
366        assert!(map.charset(0).is_none());
367    }
368
369    #[test]
370    fn test_find_font_or_fallback_exact() {
371        let store = build_store();
372        let mut font_dict = HashMap::new();
373        font_dict.insert(Name::from("Helv"), Object::Dictionary(HashMap::new()));
374        let map = BaFontMap::from_resources(Some(&font_dict), None, &store);
375        assert!(map.find_font_or_fallback("Helv").is_some());
376    }
377
378    #[test]
379    fn test_find_font_or_fallback_alias() {
380        let store = build_store();
381        let mut font_dict = HashMap::new();
382        font_dict.insert(Name::from("Helv"), Object::Dictionary(HashMap::new()));
383        let map = BaFontMap::from_resources(Some(&font_dict), None, &store);
384        // Search for "Helvetica" should find "Helv"
385        assert!(map.find_font_or_fallback("Helvetica").is_some());
386        assert_eq!(
387            map.find_font_or_fallback("Helvetica").unwrap().font_name,
388            "Helv"
389        );
390    }
391
392    #[test]
393    fn test_find_font_or_fallback_no_match() {
394        let store = build_store();
395        let mut font_dict = HashMap::new();
396        font_dict.insert(Name::from("Helv"), Object::Dictionary(HashMap::new()));
397        let map = BaFontMap::from_resources(Some(&font_dict), None, &store);
398        assert!(map.find_font_or_fallback("UnknownFont").is_none());
399    }
400
401    #[test]
402    fn test_find_font_or_fallback_zadb() {
403        let store = build_store();
404        let mut font_dict = HashMap::new();
405        font_dict.insert(Name::from("ZaDb"), Object::Dictionary(HashMap::new()));
406        let map = BaFontMap::from_resources(Some(&font_dict), None, &store);
407        assert!(map.find_font_or_fallback("ZapfDingbats").is_some());
408    }
409
410    /// Upstream: TEST_F(BAFontMapTest, DefaultFont)
411    ///
412    /// Without any font resources, the font map should still parse the /DA
413    /// string and extract the font name and size. The upstream test verifies
414    /// that CPDF_BAFontMap generates a default Helvetica font; here we verify
415    /// that from_resources correctly parses the DA string even with no /DR fonts.
416    #[test]
417    fn test_ba_font_map_default_font() {
418        let store = build_store();
419
420        // No /DR font dictionary, only a /DA string referencing /F1
421        let map = BaFontMap::from_resources(None, Some("0 0 0 rg /F1 12 Tf"), &store);
422
423        // No font entries from /DR
424        assert_eq!(map.font_count(), 0);
425
426        // DA string is still parsed: font name = "F1", size = 12
427        assert_eq!(map.default_font_name(), Some("F1"));
428        assert_eq!(map.default_font_size(), 12.0);
429    }
430
431    /// Upstream: TEST_F(BAFontMapTest, Bug853238)
432    ///
433    /// When the AcroForm /DR has a font entry matching the /DA font name,
434    /// the font map should include that entry. The upstream test verifies
435    /// that CPDF_BAFontMap resolves F1 as Times-Roman from the /DR dictionary.
436    #[test]
437    fn test_ba_font_map_bug_853238() {
438        let store = build_store();
439
440        // Build /DR → /Font → /F1 → { /Type /Font, /Subtype /Type1, /BaseFont /Times-Roman }
441        let mut f1_dict = HashMap::new();
442        f1_dict.insert(Name::r#type(), Object::Name(Name::from("Font")));
443        f1_dict.insert(Name::subtype(), Object::Name(Name::from("Type1")));
444        f1_dict.insert(Name::base_font(), Object::Name(Name::from("Times-Roman")));
445
446        let mut font_dict = HashMap::new();
447        font_dict.insert(Name::from("F1"), Object::Dictionary(f1_dict));
448
449        let map = BaFontMap::from_resources(Some(&font_dict), Some("0 0 0 rg /F1 12 Tf"), &store);
450
451        // Should have one font entry: F1
452        assert_eq!(map.font_count(), 1);
453        assert!(map.find_font("F1").is_some());
454
455        // DA string parsed correctly
456        assert_eq!(map.default_font_name(), Some("F1"));
457        assert_eq!(map.default_font_size(), 12.0);
458
459        // The font entry should have ANSI charset (Times-Roman is not Symbol)
460        let entry = map.find_font("F1").unwrap();
461        assert_eq!(entry.charset, 0);
462    }
463}