Skip to main content

pdfrum_font/encoding/
mod.rs

1//! Simple-font encodings: the nine predefined character sets, the
2//! `/Differences` overlay, and the Adobe Glyph List that ties glyph names to
3//! Unicode.
4//!
5//! A simple font maps a one-byte character code to a *glyph name*, and only
6//! then to a glyph. Which names a code may take comes from a base encoding —
7//! one of five the specification defines plus four PDFium adds — overlaid by
8//! the font dictionary's own `/Differences` array. Both halves are pure data,
9//! so this module is tables plus the small resolution functions.
10
11mod agl;
12mod differences;
13mod tables;
14
15pub use agl::{adobe_name_from_unicode, unicode_from_adobe_name};
16pub use differences::load_differences;
17
18/// A base encoding: which predefined table a character code is read through
19/// before `/Differences` is applied.
20///
21/// Nine values, not the specification's four. `Builtin` means "the font's own
22/// encoding vector", which has no table at all and is why every table lookup
23/// is fallible; `AdobeSymbol`, `ZapfDingbats` and `MsSymbol` are the symbolic
24/// sets PDFium selects by font name or charmap rather than by `/Encoding`.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
26pub enum FontEncoding {
27    /// The font program's own encoding vector. No table; `unicodes` and
28    /// `char_name` both yield nothing.
29    #[default]
30    Builtin,
31    /// `/WinAnsiEncoding` — Windows code page 1252.
32    WinAnsi,
33    /// `/MacRomanEncoding`.
34    MacRoman,
35    /// `/MacExpertEncoding`. Reachable only through a non-TrueType font's
36    /// `/BaseEncoding`; the name form rewrites it to WinAnsi.
37    MacExpert,
38    /// Adobe standard encoding, the default for a non-symbolic font.
39    Standard,
40    /// The Symbol font's own character set.
41    AdobeSymbol,
42    /// The ZapfDingbats font's own character set.
43    ZapfDingbats,
44    /// `/PDFDocEncoding`. Its name table starts at code 24, not 32.
45    PdfDoc,
46    /// The Microsoft symbol charmap `(3, 0)`. Has a code→Unicode table but no
47    /// glyph names.
48    MsSymbol,
49}
50
51impl FontEncoding {
52    /// The 256-entry code→Unicode table, when this encoding has one.
53    ///
54    /// `Builtin` is the only encoding without one — PDFium returns an empty
55    /// span for it, and every caller tests emptiness.
56    #[must_use]
57    pub fn unicodes(self) -> Option<&'static [u16; 256]> {
58        Some(match self {
59            Self::Builtin => return None,
60            Self::WinAnsi => &tables::ADOBE_WIN_ANSI_ENCODING,
61            Self::MacRoman => &tables::MAC_ROMAN_ENCODING,
62            Self::MacExpert => &tables::MAC_EXPERT_ENCODING,
63            Self::Standard => &tables::STANDARD_ENCODING,
64            Self::AdobeSymbol => &tables::ADOBE_SYMBOL_ENCODING,
65            Self::ZapfDingbats => &tables::ZAPF_ENCODING,
66            Self::PdfDoc => &tables::PDF_DOC_ENCODING,
67            Self::MsSymbol => &tables::MS_SYMBOL_ENCODING,
68        })
69    }
70
71    /// The glyph name this encoding gives `code`, from its predefined
72    /// character set.
73    ///
74    /// The tables are offset: they start at code 32 for every encoding but
75    /// `PdfDoc`, which starts at 24, so a code below the start has no name.
76    /// `MsSymbol` and `Builtin` have no name table at all
77    /// (`CharNameFromPredefinedCharSet`, the former working note).
78    #[must_use]
79    pub fn char_name(self, code: u8) -> Option<&'static str> {
80        let (table, first): (&[Option<&'static str>], u8) = match self {
81            Self::Standard => (&tables::STANDARD_ENCODING_NAMES, 32),
82            Self::WinAnsi => (&tables::ADOBE_WIN_ANSI_ENCODING_NAMES, 32),
83            Self::MacRoman => (&tables::MAC_ROMAN_ENCODING_NAMES, 32),
84            Self::MacExpert => (&tables::MAC_EXPERT_ENCODING_NAMES, 32),
85            Self::PdfDoc => (&tables::PDF_DOC_ENCODING_NAMES, 24),
86            Self::AdobeSymbol => (&tables::ADOBE_SYMBOL_ENCODING_NAMES, 32),
87            Self::ZapfDingbats => (&tables::ZAPF_ENCODING_NAMES, 32),
88            Self::MsSymbol | Self::Builtin => return None,
89        };
90        let index = usize::from(code.checked_sub(first)?);
91        table.get(index).copied().flatten()
92    }
93
94    /// The four `/Encoding` and `/BaseEncoding` names PDFium recognises.
95    ///
96    /// Anything else — including `/StandardEncoding` — leaves the encoding
97    /// unchanged, which is why this returns `Option` rather than a default
98    /// (`GetPredefinedEncoding`, the former working note).
99    #[must_use]
100    pub fn from_pdf_name(name: &[u8]) -> Option<Self> {
101        Some(match name {
102            b"WinAnsiEncoding" => Self::WinAnsi,
103            b"MacRomanEncoding" => Self::MacRoman,
104            b"MacExpertEncoding" => Self::MacExpert,
105            b"PDFDocEncoding" => Self::PdfDoc,
106            _ => return None,
107        })
108    }
109}
110
111/// The `fxge`-level encodings a font *face*'s charmap may declare, which are a
112/// different set from `FontEncoding` and are reverse-mapped through the raw
113/// tables (`CharCodeFromUnicodeForEncoding`, the former working note).
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub enum FaceEncoding {
116    /// The charmap is Unicode: a code *is* its character.
117    Unicode,
118    /// Latin-1, read through the WinAnsi table. The standard-14 encoder in
119    /// `pdfrum-edit` (`EmbeddedFont::encode`) is its production caller.
120    Latin1,
121    /// Apple Roman, read through the MacRoman table.
122    AppleRoman,
123    /// Adobe custom, read through the PDFDoc table.
124    AdobeCustom,
125    /// Microsoft symbol.
126    Symbol,
127    /// Anything else: reverse lookup always yields 0.
128    Other,
129}
130
131impl FaceEncoding {
132    /// Find the character code this face encoding gives `unicode`, or 0.
133    ///
134    /// A linear scan returning the *first* index, and **0 on a miss** — which
135    /// is indistinguishable from a real hit at code 0, exactly as `PDF_FindCode`
136    /// leaves it. Callers test `!= 0`, so code 0 is unreachable through this
137    /// route by construction.
138    #[must_use]
139    pub fn charcode_from_unicode(self, unicode: u16) -> u32 {
140        let table: &[u16; 256] = match self {
141            // The identity arm: a Unicode charmap needs no table.
142            Self::Unicode => return u32::from(unicode),
143            Self::Latin1 => &tables::ADOBE_WIN_ANSI_ENCODING,
144            Self::AppleRoman => &tables::MAC_ROMAN_ENCODING,
145            Self::AdobeCustom => &tables::PDF_DOC_ENCODING,
146            Self::Symbol => &tables::MS_SYMBOL_ENCODING,
147            Self::Other => return 0,
148        };
149        table
150            .iter()
151            .position(|&u| u == unicode)
152            .and_then(|i| u32::try_from(i).ok())
153            .unwrap_or(0)
154    }
155}
156
157/// The Unicode an Apple Roman character code stands for
158/// (`UnicodeFromAppleRomanCharCode`, the former working note).
159#[must_use]
160pub fn unicode_from_apple_roman(code: u8) -> u16 {
161    tables::MAC_ROMAN_ENCODING
162        .get(usize::from(code))
163        .copied()
164        .unwrap_or(0)
165}
166
167/// The glyph name for `charcode`, merging `/Differences` over the base
168/// encoding's predefined set.
169///
170/// **`/Differences` always wins**, including over a symbolic font's own set,
171/// and including when the base encoding is `Builtin` — which is the only way a
172/// `Builtin` font names a glyph at all (`GetAdobeCharName`, the former working note).
173#[must_use]
174pub fn adobe_char_name(
175    base: FontEncoding,
176    differences: &[Option<crate::ids::GlyphName>; 256],
177    charcode: u32,
178) -> Option<&[u8]> {
179    let code = u8::try_from(charcode).ok()?;
180    if let Some(name) = differences.get(usize::from(code)).and_then(Option::as_ref) {
181        // An empty name is not a name: PDFium tests `!IsEmpty()`.
182        if !name.as_bytes().is_empty() {
183            return Some(name.as_bytes());
184        }
185    }
186    base.char_name(code).map(str::as_bytes)
187}
188
189#[cfg(test)]
190mod tests {
191    // Test fixtures are fixed-size arrays with known contents.
192    #![allow(clippy::indexing_slicing)]
193    use super::*;
194    use crate::ids::GlyphName;
195
196    const NO_DIFFS: [Option<GlyphName>; 256] = [const { None }; 256];
197
198    #[test]
199    fn builtin_is_the_only_encoding_without_a_unicode_table() {
200        assert!(FontEncoding::Builtin.unicodes().is_none());
201        for e in [
202            FontEncoding::WinAnsi,
203            FontEncoding::MacRoman,
204            FontEncoding::MacExpert,
205            FontEncoding::Standard,
206            FontEncoding::AdobeSymbol,
207            FontEncoding::ZapfDingbats,
208            FontEncoding::PdfDoc,
209            FontEncoding::MsSymbol,
210        ] {
211            assert!(e.unicodes().is_some(), "{e:?}");
212        }
213    }
214
215    #[test]
216    fn name_tables_start_at_32_except_pdfdoc_which_starts_at_24() {
217        // Code 31 has no name in a 32-based table...
218        assert_eq!(FontEncoding::Standard.char_name(31), None);
219        assert_eq!(FontEncoding::Standard.char_name(32), Some("space"));
220        // ...but PDFDoc's table reaches down to 24.
221        assert_eq!(FontEncoding::PdfDoc.char_name(23), None);
222        assert!(FontEncoding::PdfDoc.char_name(24).is_some());
223    }
224
225    #[test]
226    fn ms_symbol_and_builtin_have_no_glyph_names() {
227        for c in [0u8, 32, 65, 255] {
228            assert_eq!(FontEncoding::MsSymbol.char_name(c), None);
229            assert_eq!(FontEncoding::Builtin.char_name(c), None);
230        }
231        // MsSymbol does have a *unicode* table, though.
232        assert!(FontEncoding::MsSymbol.unicodes().is_some());
233    }
234
235    #[test]
236    fn only_four_encoding_names_are_recognised() {
237        assert_eq!(
238            FontEncoding::from_pdf_name(b"WinAnsiEncoding"),
239            Some(FontEncoding::WinAnsi)
240        );
241        assert_eq!(
242            FontEncoding::from_pdf_name(b"MacRomanEncoding"),
243            Some(FontEncoding::MacRoman)
244        );
245        assert_eq!(
246            FontEncoding::from_pdf_name(b"MacExpertEncoding"),
247            Some(FontEncoding::MacExpert)
248        );
249        assert_eq!(
250            FontEncoding::from_pdf_name(b"PDFDocEncoding"),
251            Some(FontEncoding::PdfDoc)
252        );
253        // `/StandardEncoding` is deliberately NOT in the table: naming it is a
254        // no-op that leaves the encoding at whatever it already was.
255        assert_eq!(FontEncoding::from_pdf_name(b"StandardEncoding"), None);
256        assert_eq!(FontEncoding::from_pdf_name(b"Identity-H"), None);
257        assert_eq!(FontEncoding::from_pdf_name(b""), None);
258    }
259
260    #[test]
261    fn differences_win_over_the_predefined_set() {
262        let mut diffs = NO_DIFFS;
263        diffs[65] = Some(GlyphName::from("mycustomglyph"));
264        assert_eq!(
265            adobe_char_name(FontEncoding::WinAnsi, &diffs, 65),
266            Some(&b"mycustomglyph"[..])
267        );
268        // A code the differences do not cover still reads the base set.
269        assert_eq!(
270            adobe_char_name(FontEncoding::WinAnsi, &diffs, 66),
271            Some(&b"B"[..])
272        );
273    }
274
275    #[test]
276    fn differences_are_the_only_names_a_builtin_font_has() {
277        let mut diffs = NO_DIFFS;
278        diffs[1] = Some(GlyphName::from("gee"));
279        assert_eq!(
280            adobe_char_name(FontEncoding::Builtin, &diffs, 1),
281            Some(&b"gee"[..])
282        );
283        assert_eq!(adobe_char_name(FontEncoding::Builtin, &diffs, 2), None);
284    }
285
286    #[test]
287    fn a_charcode_above_255_never_has_a_name() {
288        assert_eq!(adobe_char_name(FontEncoding::WinAnsi, &NO_DIFFS, 256), None);
289        assert_eq!(
290            adobe_char_name(FontEncoding::WinAnsi, &NO_DIFFS, u32::MAX),
291            None
292        );
293    }
294
295    #[test]
296    fn the_face_encoding_reverse_map_uses_the_right_table() {
297        // Unicode is the identity arm.
298        assert_eq!(FaceEncoding::Unicode.charcode_from_unicode(0x20AC), 0x20AC);
299        // WinAnsi puts the Euro sign at 0x80.
300        assert_eq!(FaceEncoding::Latin1.charcode_from_unicode(0x20AC), 0x80);
301        // A miss is 0, indistinguishable from a hit at code 0.
302        assert_eq!(FaceEncoding::Latin1.charcode_from_unicode(0x4E00), 0);
303        assert_eq!(FaceEncoding::Other.charcode_from_unicode(0x41), 0);
304    }
305
306    #[test]
307    fn apple_roman_reads_the_mac_roman_table() {
308        assert_eq!(unicode_from_apple_roman(b'A'), u16::from(b'A'));
309        assert_eq!(unicode_from_apple_roman(0xA5), 0x2022); // bullet
310    }
311
312    #[test]
313    fn every_name_table_round_trips_through_the_glyph_list() {
314        // Wherever an encoding defines both a name and a unicode for a code,
315        // the Adobe Glyph List must agree.
316        // Disagreements are real in a handful of places where PDFium's tables
317        // predate AGL revisions, so this counts rather than asserting zero.
318        let mut checked = 0usize;
319        let mut agreed = 0usize;
320        for e in [
321            FontEncoding::Standard,
322            FontEncoding::WinAnsi,
323            FontEncoding::MacRoman,
324            FontEncoding::PdfDoc,
325            FontEncoding::AdobeSymbol,
326            FontEncoding::ZapfDingbats,
327        ] {
328            let Some(unicodes) = e.unicodes() else {
329                continue;
330            };
331            for code in 0u8..=255 {
332                let (Some(name), u) = (e.char_name(code), unicodes[usize::from(code)]) else {
333                    continue;
334                };
335                if u == 0 {
336                    continue;
337                }
338                checked += 1;
339                if unicode_from_adobe_name(name.as_bytes()) == u {
340                    agreed += 1;
341                }
342            }
343        }
344        assert!(checked > 1000, "expected a broad sweep, got {checked}");
345        // The two tables agree on 83% of the names they both define. The
346        // residue is not a transcription error: PDFium's encoding tables and
347        // the Adobe Glyph List genuinely disagree, mostly in the Symbol and
348        // ZapfDingbats sets, where PDFium maps glyph names to the private-use
349        // codepoints the original fonts used while the AGL maps them to the
350        // standard mathematical and dingbat blocks. A *drop* below this ratio
351        // would mean the extraction broke; the exact value is measured, not
352        // chosen.
353        assert!(
354            agreed * 100 / checked >= 80,
355            "{agreed} of {checked} names agreed with the glyph list"
356        );
357    }
358}