term_transcript/svg/
subset.rs

1//! Font embedder / subsetter based on the `font-subset` library.
2
3use std::{collections::BTreeSet, fmt};
4
5use font_subset::{Font, FontCategory, OwnedFont, ParseError};
6
7use super::{EmbeddedFont, EmbeddedFontFace, FontEmbedder, FontMetrics};
8
9/// Errors produced by [`FontSubsetter`].
10#[derive(Debug)]
11#[non_exhaustive]
12pub enum SubsettingError {
13    /// Error parsing the font file.
14    Parse(ParseError),
15    /// No font family name entry in the font.
16    NoFontFamilyName,
17    /// Subsetting is disallowed by the font permissions.
18    NoSubsetting,
19    /// Embedding is disallowed by the font permissions.
20    NoEmbedding,
21    /// The provided font is not monospace (doesn't have single glyph advance width).
22    NotMonospace,
23    /// Unsupported font categories in the provided font faces.
24    UnsupportedFontCategories(Vec<FontCategory>),
25    /// The font misses glyphs for some chars used in the transcript.
26    MissingChars(String),
27}
28
29impl From<ParseError> for SubsettingError {
30    fn from(err: ParseError) -> Self {
31        Self::Parse(err)
32    }
33}
34
35impl fmt::Display for SubsettingError {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Self::Parse(err) => write!(formatter, "error parsing font: {err}"),
39            Self::NoFontFamilyName => formatter.write_str("no family name in font file"),
40            Self::NoSubsetting => {
41                formatter.write_str("subsetting is disallowed by font permissions")
42            }
43            Self::NoEmbedding => formatter.write_str("embedding is disallowed by font permissions"),
44            Self::NotMonospace => formatter.write_str("provided font is not monospace"),
45            Self::UnsupportedFontCategories(categories) => {
46                write!(
47                    formatter,
48                    "unsupported font categories in the provided font faces: {categories:?}"
49                )
50            }
51            Self::MissingChars(chars) => {
52                write!(
53                    formatter,
54                    "font misses glyphs for chars used in transcript: {chars}"
55                )
56            }
57        }
58    }
59}
60
61impl std::error::Error for SubsettingError {
62    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
63        match self {
64            Self::Parse(err) => Some(err),
65            Self::NoFontFamilyName
66            | Self::NoSubsetting
67            | Self::NoEmbedding
68            | Self::NotMonospace
69            | Self::UnsupportedFontCategories(_)
70            | Self::MissingChars(_) => None,
71        }
72    }
73}
74
75#[derive(Debug)]
76enum AuxFontFaces {
77    Bold(OwnedFont),
78    Italic(OwnedFont),
79}
80
81/// Font embedder / subsetter based on the `font-subset` library.
82#[derive(Debug)]
83pub struct FontSubsetter {
84    family_name: String,
85    metrics: FontMetrics,
86    regular_face: OwnedFont,
87    additional_faces: Option<AuxFontFaces>,
88}
89
90impl FontSubsetter {
91    /// Initializes the subsetter with the specified font bytes.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if parsing font bytes fails.
96    pub fn new(
97        font_bytes: Box<[u8]>,
98        second_face: Option<Box<[u8]>>,
99    ) -> Result<Self, SubsettingError> {
100        use self::FontCategory::{Bold, Italic, Regular};
101
102        let first_face = OwnedFont::new(font_bytes)?;
103        let family_name = Self::check(first_face.get())?.to_owned();
104        let mut metrics = Self::convert_metrics(&first_face.get().metrics())?;
105
106        let second_face = second_face
107            .map(|bytes| {
108                let font = OwnedFont::new(bytes)?;
109                Self::check(font.get())?;
110                Ok::<_, SubsettingError>(font)
111            })
112            .transpose()?;
113
114        let first_cat = first_face.get().category();
115        let (regular, aux) = if let Some(second) = second_face {
116            let second_cat = second.get().category();
117            match (first_cat, second_cat) {
118                (Regular, Bold) => (first_face, Some(AuxFontFaces::Bold(second))),
119                (Regular, Italic) => (first_face, Some(AuxFontFaces::Italic(second))),
120                (Bold, Regular) => (second, Some(AuxFontFaces::Bold(first_face))),
121                (Italic, Regular) => (second, Some(AuxFontFaces::Italic(first_face))),
122                _ => {
123                    return Err(SubsettingError::UnsupportedFontCategories(vec![
124                        first_cat, second_cat,
125                    ]))
126                }
127            }
128        } else {
129            if first_cat != Regular {
130                return Err(SubsettingError::UnsupportedFontCategories(vec![first_cat]));
131            }
132            (first_face, None)
133        };
134
135        match &aux {
136            Some(AuxFontFaces::Bold(font)) => {
137                metrics.bold_spacing = Self::letter_spacing(&metrics, font.get())?;
138            }
139            Some(AuxFontFaces::Italic(font)) => {
140                metrics.italic_spacing = Self::letter_spacing(&metrics, font.get())?;
141            }
142            None => { /* do nothing */ }
143        }
144
145        Ok(Self {
146            family_name,
147            metrics,
148            regular_face: regular,
149            additional_faces: aux,
150        })
151    }
152
153    fn convert_metrics(metrics: &font_subset::FontMetrics) -> Result<FontMetrics, SubsettingError> {
154        Ok(FontMetrics {
155            units_per_em: metrics.units_per_em,
156            advance_width: metrics
157                .monospace_advance_width
158                .ok_or(SubsettingError::NotMonospace)?,
159            ascent: metrics.ascent,
160            descent: metrics.descent,
161            bold_spacing: 0.0,
162            italic_spacing: 0.0,
163        })
164    }
165
166    fn letter_spacing(base_metrics: &FontMetrics, font: &Font<'_>) -> Result<f64, SubsettingError> {
167        let aux_advance_width = font
168            .metrics()
169            .monospace_advance_width
170            .ok_or(SubsettingError::NotMonospace)?;
171        let aux_advance_width = f64::from(aux_advance_width);
172        Ok((f64::from(base_metrics.advance_width) - aux_advance_width)
173            / f64::from(base_metrics.units_per_em))
174    }
175
176    /// Returns the font family name.
177    fn check<'font>(font: &'font Font<'_>) -> Result<&'font str, SubsettingError> {
178        let permissions = font.permissions();
179        if !permissions.allow_subsetting {
180            return Err(SubsettingError::NoSubsetting);
181        }
182        if permissions.embed_only_bitmaps {
183            return Err(SubsettingError::NoEmbedding);
184        }
185
186        font.naming()
187            .family
188            .ok_or(SubsettingError::NoFontFamilyName)
189    }
190
191    fn checked_subset<'a>(
192        font: &Font<'a>,
193        chars: &BTreeSet<char>,
194    ) -> Result<Font<'a>, SubsettingError> {
195        let missing_chars: String = chars
196            .iter()
197            .copied()
198            .filter(|ch| !font.contains_char(*ch))
199            .collect();
200        if !missing_chars.is_empty() {
201            return Err(SubsettingError::MissingChars(missing_chars));
202        }
203        font.subset(chars).map_err(Into::into)
204    }
205}
206
207impl FontEmbedder for FontSubsetter {
208    type Error = SubsettingError;
209
210    fn embed_font(&self, mut used_chars: BTreeSet<char>) -> Result<EmbeddedFont, Self::Error> {
211        used_chars.remove(&'\n');
212        let subset = Self::checked_subset(self.regular_face.get(), &used_chars)?;
213        let mut faces = vec![EmbeddedFontFace::woff2(subset.to_woff2())];
214        match &self.additional_faces {
215            Some(AuxFontFaces::Bold(face)) => {
216                let subset = Self::checked_subset(face.get(), &used_chars)?;
217                faces.push(EmbeddedFontFace {
218                    is_bold: Some(true),
219                    ..EmbeddedFontFace::woff2(subset.to_woff2())
220                });
221                faces[0].is_bold = Some(false);
222            }
223            Some(AuxFontFaces::Italic(face)) => {
224                let subset = Self::checked_subset(face.get(), &used_chars)?;
225                faces.push(EmbeddedFontFace {
226                    is_italic: Some(true),
227                    ..EmbeddedFontFace::woff2(subset.to_woff2())
228                });
229                faces[0].is_italic = Some(false);
230            }
231            None => { /* do nothing */ }
232        }
233
234        Ok(EmbeddedFont {
235            family_name: self.family_name.clone(),
236            metrics: self.metrics,
237            faces,
238        })
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use std::fs;
245
246    use assert_matches::assert_matches;
247    use test_casing::test_casing;
248
249    use super::*;
250    use crate::{
251        svg::{Template, TemplateOptions},
252        Transcript, UserInput,
253    };
254
255    fn roboto_mono() -> Box<[u8]> {
256        fs::read("../examples/fonts/RobotoMono-VariableFont_wght.ttf")
257            .unwrap()
258            .into()
259    }
260
261    fn roboto_mono_italic() -> Box<[u8]> {
262        fs::read("../examples/fonts/RobotoMono-Italic-VariableFont_wght.ttf")
263            .unwrap()
264            .into()
265    }
266
267    fn fira_mono() -> Box<[u8]> {
268        fs::read("../examples/fonts/FiraMono-Regular.ttf")
269            .unwrap()
270            .into()
271    }
272
273    fn fira_mono_bold() -> Box<[u8]> {
274        fs::read("../examples/fonts/FiraMono-Bold.ttf")
275            .unwrap()
276            .into()
277    }
278
279    fn test_subsetting_font(subsetter: FontSubsetter, pure_svg: bool) -> String {
280        let font_family = subsetter.family_name.clone();
281        let mut transcript = Transcript::new();
282        transcript.add_interaction(
283            UserInput::command("test"),
284            "\u{1b}[44m\u{1b}[1mH\u{1b}[0mello, \u{1b}[32m\u{1b}[3mworld\u{1b}[0m! ".repeat(10),
285        );
286
287        let options = TemplateOptions {
288            ..TemplateOptions::default().with_font_subsetting(subsetter)
289        };
290        let mut buffer = vec![];
291        let template = if pure_svg {
292            Template::pure_svg(options)
293        } else {
294            Template::new(options)
295        };
296        template.render(&transcript, &mut buffer).unwrap();
297        let buffer = String::from_utf8(buffer).unwrap();
298
299        assert!(buffer.contains("@font-face"), "{buffer}");
300        assert!(
301            buffer.contains(&format!("font-family: \"{font_family}\";")),
302            "{buffer}"
303        );
304        assert!(
305            buffer.contains("src: url(\"data:font/woff2;base64,"),
306            "{buffer}"
307        );
308        assert!(
309            buffer.contains(&format!("font: 14px \"{font_family}\", monospace;")),
310            "{buffer}"
311        );
312
313        buffer
314    }
315
316    #[test_casing(2, [false, true])]
317    fn subsetting_font(pure_svg: bool) {
318        let subsetter = FontSubsetter::new(roboto_mono(), None).unwrap();
319        assert_eq!(subsetter.family_name, "Roboto Mono");
320        let buffer = test_subsetting_font(subsetter, pure_svg);
321
322        if pure_svg {
323            // Check some background boxes.
324            assert!(
325                buffer.contains(
326                    r#"<rect x="10.0" y="27.33" width="8.4" height="18.46" class="fg4"/>"#
327                ),
328                "{buffer}"
329            );
330            assert!(
331                buffer.contains(
332                    r#"<rect x="127.62" y="27.33" width="8.4" height="18.46" class="fg4"/>"#
333                ),
334                "{buffer}"
335            );
336        }
337    }
338
339    #[test_casing(2, [false, true])]
340    #[allow(clippy::float_cmp)] // the entire point
341    fn subsetting_font_with_aux_italic_font(pure_svg: bool) {
342        let subsetter = FontSubsetter::new(roboto_mono(), Some(roboto_mono_italic())).unwrap();
343        assert_eq!(subsetter.family_name, "Roboto Mono");
344        assert_matches!(&subsetter.additional_faces, Some(AuxFontFaces::Italic(_)));
345        assert_eq!(subsetter.metrics.bold_spacing, 0.0);
346        assert_ne!(subsetter.metrics.italic_spacing, 0.0);
347
348        let buffer = test_subsetting_font(subsetter, pure_svg);
349        let font_faces = buffer
350            .lines()
351            .filter(|line| line.trim_start().starts_with("@font-face"))
352            .count();
353        assert_eq!(font_faces, 2, "{buffer}");
354
355        assert!(
356            buffer.contains(".bold,.prompt { font-weight: bold; }"),
357            "{buffer}"
358        );
359        assert!(
360            buffer.contains(".italic { font-style: italic; letter-spacing: 0.0132em; }"),
361            "{buffer}"
362        );
363    }
364
365    #[test_casing(2, [false, true])]
366    #[allow(clippy::float_cmp)] // the entire point
367    fn subsetting_font_with_aux_bold_font(pure_svg: bool) {
368        let subsetter = FontSubsetter::new(fira_mono(), Some(fira_mono_bold())).unwrap();
369        assert_eq!(subsetter.family_name, "Fira Mono");
370        assert_matches!(&subsetter.additional_faces, Some(AuxFontFaces::Bold(_)));
371        // Fira Mono Bold has the same advance width as the regular font face
372        assert_eq!(subsetter.metrics.bold_spacing, 0.0);
373        assert_eq!(subsetter.metrics.italic_spacing, 0.0);
374
375        let buffer = test_subsetting_font(subsetter, pure_svg);
376        let font_faces = buffer
377            .lines()
378            .filter(|line| line.trim_start().starts_with("@font-face"))
379            .count();
380        assert_eq!(font_faces, 2, "{buffer}");
381
382        assert!(
383            buffer.contains(".bold,.prompt { font-weight: bold; }"),
384            "{buffer}"
385        );
386        assert!(
387            buffer.contains(".italic { font-style: italic; }"),
388            "{buffer}"
389        );
390    }
391}