Skip to main content

pdfboss_write/
font.rs

1//! The fourteen standard fonts (ISO 32000 §9.6.2.2), with WinAnsi text
2//! encoding and AFM metrics from `pdfboss-encoding`. Text in these faces
3//! needs no embedded font program — every conforming reader carries them.
4
5use pdfboss_core::{Dict, Name, Object};
6
7use crate::error::{Error, Result};
8
9/// One of the fourteen standard fonts every PDF consumer provides.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum Standard14 {
12    /// Helvetica.
13    Helvetica,
14    /// Helvetica-Bold.
15    HelveticaBold,
16    /// Helvetica-Oblique.
17    HelveticaOblique,
18    /// Helvetica-BoldOblique.
19    HelveticaBoldOblique,
20    /// Times-Roman.
21    TimesRoman,
22    /// Times-Bold.
23    TimesBold,
24    /// Times-Italic.
25    TimesItalic,
26    /// Times-BoldItalic.
27    TimesBoldItalic,
28    /// Courier.
29    Courier,
30    /// Courier-Bold.
31    CourierBold,
32    /// Courier-Oblique.
33    CourierOblique,
34    /// Courier-BoldOblique.
35    CourierBoldOblique,
36    /// Symbol (font-specific encoding).
37    Symbol,
38    /// ZapfDingbats (font-specific encoding).
39    ZapfDingbats,
40}
41
42impl Standard14 {
43    /// All fourteen, in ISO 32000 listing order.
44    pub const ALL: [Standard14; 14] = [
45        Standard14::Helvetica,
46        Standard14::HelveticaBold,
47        Standard14::HelveticaOblique,
48        Standard14::HelveticaBoldOblique,
49        Standard14::TimesRoman,
50        Standard14::TimesBold,
51        Standard14::TimesItalic,
52        Standard14::TimesBoldItalic,
53        Standard14::Courier,
54        Standard14::CourierBold,
55        Standard14::CourierOblique,
56        Standard14::CourierBoldOblique,
57        Standard14::Symbol,
58        Standard14::ZapfDingbats,
59    ];
60
61    /// The PostScript base font name, e.g. `"Helvetica-Bold"`.
62    pub fn base_font(self) -> &'static str {
63        match self {
64            Standard14::Helvetica => "Helvetica",
65            Standard14::HelveticaBold => "Helvetica-Bold",
66            Standard14::HelveticaOblique => "Helvetica-Oblique",
67            Standard14::HelveticaBoldOblique => "Helvetica-BoldOblique",
68            Standard14::TimesRoman => "Times-Roman",
69            Standard14::TimesBold => "Times-Bold",
70            Standard14::TimesItalic => "Times-Italic",
71            Standard14::TimesBoldItalic => "Times-BoldItalic",
72            Standard14::Courier => "Courier",
73            Standard14::CourierBold => "Courier-Bold",
74            Standard14::CourierOblique => "Courier-Oblique",
75            Standard14::CourierBoldOblique => "Courier-BoldOblique",
76            Standard14::Symbol => "Symbol",
77            Standard14::ZapfDingbats => "ZapfDingbats",
78        }
79    }
80
81    /// Parses a PostScript base font name back to the variant. Exact names
82    /// only — aliases and subset-tagged forms are the reading side's
83    /// business, not the writer's.
84    pub fn from_base_font(name: &str) -> Option<Standard14> {
85        Standard14::ALL
86            .into_iter()
87            .find(|font| font.base_font() == name)
88    }
89
90    /// Whether this face encodes text as WinAnsi (the twelve text faces)
91    /// rather than a font-specific built-in encoding.
92    fn is_win_ansi(self) -> bool {
93        !matches!(self, Standard14::Symbol | Standard14::ZapfDingbats)
94    }
95
96    /// The WinAnsi code for `ch`, scanning codes in ascending order so a
97    /// duplicated character would resolve to its lowest code. Symbol and
98    /// ZapfDingbats have no encoding tables yet, so every character is an
99    /// [`Error::Unencodable`] there.
100    fn encode_char(self, ch: char) -> Result<u8> {
101        if !self.is_win_ansi() {
102            return Err(Error::Unencodable {
103                ch,
104                font: self.base_font(),
105            });
106        }
107        (0u8..=255)
108            .find(|&code| pdfboss_encoding::win_ansi(code) == Some(ch))
109            .ok_or(Error::Unencodable {
110                ch,
111                font: self.base_font(),
112            })
113    }
114
115    /// Encodes text to font code bytes: WinAnsi for the twelve text faces,
116    /// the font-specific built-in encoding for Symbol and ZapfDingbats.
117    /// A character without a code is an [`Error::Unencodable`]
118    /// (crate::Error::Unencodable) — never silently dropped or replaced.
119    /// Symbol and ZapfDingbats currently reject every character: their
120    /// encoding tables come with a later phase.
121    pub fn encode(self, text: &str) -> Result<Vec<u8>> {
122        text.chars().map(|ch| self.encode_char(ch)).collect()
123    }
124
125    /// Advance width of one character in units per 1000 of font size, from
126    /// the AFM metrics. `None` when the character has no code or metric.
127    pub fn width(self, ch: char) -> Option<f32> {
128        let code = self.encode_char(ch).ok()?;
129        let glyph = pdfboss_encoding::win_ansi_glyph_name(code)?;
130        pdfboss_encoding::standard_14_width(self.base_font(), glyph)
131    }
132
133    /// Width of a whole string at `size`, in text-space units. Errors on
134    /// unencodable characters, like [`encode`](Standard14::encode); an
135    /// encodable character whose glyph has no AFM metric is an
136    /// [`Error::Other`] naming the glyph. No kerning — the AFM kern pairs
137    /// are not bundled, so this is the sum of bare advance widths.
138    pub fn text_width(self, text: &str, size: f32) -> Result<f32> {
139        let mut sum = 0.0f32;
140        for ch in text.chars() {
141            let code = self.encode_char(ch)?;
142            let glyph = pdfboss_encoding::win_ansi_glyph_name(code).ok_or_else(|| {
143                Error::Other(format!("code {code:#04x} has no WinAnsi glyph name"))
144            })?;
145            let width =
146                pdfboss_encoding::standard_14_width(self.base_font(), glyph).ok_or_else(|| {
147                    Error::Other(format!(
148                        "{} has no metric for glyph {glyph:?}",
149                        self.base_font()
150                    ))
151                })?;
152            sum += width;
153        }
154        Ok(sum * size / 1000.0)
155    }
156
157    /// The font dictionary describing this face (`/Type /Font`,
158    /// `/Subtype /Type1`, `/BaseFont`, and `/Encoding /WinAnsiEncoding`
159    /// for the twelve text faces).
160    pub(crate) fn font_dict(self) -> Dict {
161        let mut dict = Dict::new();
162        dict.insert(Name("Type".into()), Object::Name(Name("Font".into())));
163        dict.insert(Name("Subtype".into()), Object::Name(Name("Type1".into())));
164        dict.insert(
165            Name("BaseFont".into()),
166            Object::Name(Name(self.base_font().into())),
167        );
168        if self.is_win_ansi() {
169            dict.insert(
170                Name("Encoding".into()),
171                Object::Name(Name("WinAnsiEncoding".into())),
172            );
173        }
174        dict
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use pdfboss_encoding::standard_14_width;
181
182    use super::*;
183    use crate::error::Error;
184
185    #[test]
186    fn base_font_round_trips_all_fourteen() {
187        for font in Standard14::ALL {
188            assert_eq!(Standard14::from_base_font(font.base_font()), Some(font));
189        }
190        assert_eq!(Standard14::Helvetica.base_font(), "Helvetica");
191        assert_eq!(
192            Standard14::HelveticaBoldOblique.base_font(),
193            "Helvetica-BoldOblique"
194        );
195        assert_eq!(Standard14::TimesRoman.base_font(), "Times-Roman");
196        assert_eq!(Standard14::TimesBoldItalic.base_font(), "Times-BoldItalic");
197        assert_eq!(Standard14::CourierOblique.base_font(), "Courier-Oblique");
198        assert_eq!(Standard14::Symbol.base_font(), "Symbol");
199        assert_eq!(Standard14::ZapfDingbats.base_font(), "ZapfDingbats");
200        assert_eq!(Standard14::from_base_font("helvetica"), None);
201        assert_eq!(Standard14::from_base_font("Arial"), None);
202        assert_eq!(Standard14::from_base_font(""), None);
203    }
204
205    #[test]
206    fn encode_ascii_and_win_ansi_specials() {
207        assert_eq!(Standard14::Helvetica.encode("Hi").unwrap(), b"Hi");
208        assert_eq!(Standard14::TimesRoman.encode("\u{E9}").unwrap(), [0xE9]);
209        assert_eq!(Standard14::Courier.encode("\u{20AC}").unwrap(), [0x80]);
210        assert_eq!(Standard14::Helvetica.encode("\u{201C}").unwrap(), [0x93]);
211    }
212
213    #[test]
214    fn encode_rejects_unencodable_chars() {
215        match Standard14::HelveticaBold.encode("\u{2318}").unwrap_err() {
216            Error::Unencodable { ch, font } => {
217                assert_eq!(ch, '\u{2318}');
218                assert_eq!(font, "Helvetica-Bold");
219            }
220            other => panic!("expected Unencodable, got {other:?}"),
221        }
222    }
223
224    #[test]
225    fn symbol_faces_reject_every_char_for_now() {
226        for font in [Standard14::Symbol, Standard14::ZapfDingbats] {
227            assert!(matches!(
228                font.encode("a"),
229                Err(Error::Unencodable { ch: 'a', .. })
230            ));
231            assert_eq!(font.width('a'), None);
232        }
233        assert_eq!(Standard14::Symbol.encode("").unwrap(), Vec::<u8>::new());
234    }
235
236    #[test]
237    fn width_matches_direct_afm_lookups() {
238        let font = Standard14::Helvetica;
239        for (ch, glyph) in [('H', "H"), ('e', "e"), ('l', "l"), ('o', "o")] {
240            assert_eq!(font.width(ch), standard_14_width("Helvetica", glyph));
241            assert!(font.width(ch).is_some());
242        }
243        assert_eq!(font.width('\u{2318}'), None); // unencodable
244        assert_eq!(font.width('\u{20AC}'), None); // pre-Euro AFMs carry no metric
245    }
246
247    #[test]
248    fn text_width_scales_by_size() {
249        let font = Standard14::TimesBold;
250        let sum: f32 = "Hello".chars().map(|ch| font.width(ch).unwrap()).sum();
251        assert_eq!(font.text_width("Hello", 12.0).unwrap(), sum * 12.0 / 1000.0);
252        assert_eq!(
253            font.text_width("Hello", 1000.0).unwrap(),
254            font.text_width("Hello", 500.0).unwrap() * 2.0
255        );
256        assert_eq!(font.text_width("", 12.0).unwrap(), 0.0);
257        assert!(matches!(
258            font.text_width("a\u{2318}", 12.0),
259            Err(Error::Unencodable { ch: '\u{2318}', .. })
260        ));
261        assert!(matches!(
262            font.text_width("\u{20AC}", 12.0),
263            Err(Error::Other(msg)) if msg.contains("Euro")
264        ));
265    }
266
267    #[test]
268    fn font_dict_win_ansi_and_symbol() {
269        let dict = Standard14::HelveticaOblique.font_dict();
270        assert_eq!(dict.len(), 4);
271        assert_eq!(dict.get_name("Type").map(|n| n.0.as_str()), Some("Font"));
272        assert_eq!(
273            dict.get_name("Subtype").map(|n| n.0.as_str()),
274            Some("Type1")
275        );
276        assert_eq!(
277            dict.get_name("BaseFont").map(|n| n.0.as_str()),
278            Some("Helvetica-Oblique")
279        );
280        assert_eq!(
281            dict.get_name("Encoding").map(|n| n.0.as_str()),
282            Some("WinAnsiEncoding")
283        );
284
285        let dict = Standard14::Symbol.font_dict();
286        assert_eq!(dict.len(), 3);
287        assert_eq!(
288            dict.get_name("BaseFont").map(|n| n.0.as_str()),
289            Some("Symbol")
290        );
291        assert!(dict.get("Encoding").is_none());
292    }
293}