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/// OpenType font face that can be used [for subsetting](FontSubsetter).
76pub struct FontFace {
77    inner: OwnedFont,
78    advance_width: u16,
79}
80
81// Make `Debug` representation shorter.
82impl fmt::Debug for FontFace {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        let font = self.inner.get();
85        formatter
86            .debug_struct("FontFace")
87            .field("family_name", &self.family_name())
88            .field("category", &font.category())
89            .field("variation_axes", &font.variation_axes())
90            .field("metrics", &font.metrics())
91            .finish_non_exhaustive()
92    }
93}
94
95impl FontFace {
96    /// Reads an OpenType or WOFF2 font. This is effectively a re-export from the `font-subset` crate
97    /// that performs some additional checks ensuring that the font is fit for subsetting.
98    ///
99    /// # Errors
100    ///
101    /// Returns font parsing / validation errors.
102    pub fn new(bytes: Box<[u8]>) -> Result<Self, SubsettingError> {
103        let inner = OwnedFont::new(bytes)?;
104        let advance_width = Self::check(inner.get())?;
105        Ok(Self {
106            inner,
107            advance_width,
108        })
109    }
110
111    fn check(font: &Font<'_>) -> Result<u16, SubsettingError> {
112        let permissions = font.permissions();
113        if !permissions.allow_subsetting {
114            return Err(SubsettingError::NoSubsetting);
115        }
116        if permissions.embed_only_bitmaps {
117            return Err(SubsettingError::NoEmbedding);
118        }
119
120        font.naming()
121            .family
122            .ok_or(SubsettingError::NoFontFamilyName)?;
123        font.metrics()
124            .monospace_advance_width
125            .ok_or(SubsettingError::NotMonospace)
126    }
127
128    fn family_name(&self) -> &str {
129        // `unwrap()` is safe: checked in `check()` when creating `FontFace`
130        self.inner.get().naming().family.unwrap()
131    }
132
133    fn category(&self) -> FontCategory {
134        self.inner.get().category()
135    }
136
137    fn metrics(&self) -> FontMetrics {
138        let metrics = self.inner.get().metrics();
139        FontMetrics {
140            units_per_em: metrics.units_per_em,
141            advance_width: self.advance_width,
142            ascent: metrics.ascent,
143            descent: metrics.descent,
144            bold_spacing: 0.0,
145            italic_spacing: 0.0,
146        }
147    }
148
149    fn letter_spacing(&self, base_metrics: &FontMetrics) -> f64 {
150        (f64::from(base_metrics.advance_width) - f64::from(self.advance_width))
151            / f64::from(base_metrics.units_per_em)
152    }
153
154    fn checked_subset(&self, chars: &BTreeSet<char>) -> Result<Font<'_>, SubsettingError> {
155        let font = self.inner.get();
156        let missing_chars: String = chars
157            .iter()
158            .copied()
159            .filter(|ch| !font.contains_char(*ch))
160            .collect();
161        if !missing_chars.is_empty() {
162            return Err(SubsettingError::MissingChars(missing_chars));
163        }
164        font.subset(chars).map_err(Into::into)
165    }
166}
167
168#[derive(Debug)]
169enum AuxFontFaces {
170    Bold(FontFace),
171    Italic(FontFace),
172}
173
174/// Font embedder / subsetter based on the `font-subset` library.
175#[derive(Debug)]
176pub struct FontSubsetter {
177    family_name: String,
178    metrics: FontMetrics,
179    regular_face: FontFace,
180    additional_faces: Option<AuxFontFaces>,
181}
182
183impl FontSubsetter {
184    /// Initializes the subsetter with the specified font.
185    ///
186    /// # Arguments
187    ///
188    /// The font must have regular category. It may be variable (e.g., by weight).
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if parsing font bytes fails.
193    pub fn new(font: FontFace) -> Result<Self, SubsettingError> {
194        Self::from_faces(font, None)
195    }
196
197    /// Initializes the subsetter with the specified font.
198    ///
199    /// # Arguments
200    ///
201    /// Currently supports at most 2 input fonts. These may correspond to a regular + bold or regular + italic font faces
202    /// (can be provided in any order).
203    /// Each of the provided fonts may be variable (e.g., by weight).
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if parsing font bytes fails.
208    #[cfg_attr(feature = "tracing", tracing::instrument(ret, err))]
209    pub fn from_faces(
210        first_face: FontFace,
211        second_face: Option<FontFace>,
212    ) -> Result<Self, SubsettingError> {
213        use self::FontCategory::{Bold, Italic, Regular};
214
215        let first_cat = first_face.category();
216        let (regular, aux) = if let Some(second) = second_face {
217            let second_cat = second.category();
218            match (first_cat, second_cat) {
219                (Regular, Bold) => (first_face, Some(AuxFontFaces::Bold(second))),
220                (Regular, Italic) => (first_face, Some(AuxFontFaces::Italic(second))),
221                (Bold, Regular) => (second, Some(AuxFontFaces::Bold(first_face))),
222                (Italic, Regular) => (second, Some(AuxFontFaces::Italic(first_face))),
223                _ => {
224                    return Err(SubsettingError::UnsupportedFontCategories(vec![
225                        first_cat, second_cat,
226                    ]))
227                }
228            }
229        } else {
230            if first_cat != Regular {
231                return Err(SubsettingError::UnsupportedFontCategories(vec![first_cat]));
232            }
233            (first_face, None)
234        };
235
236        let family_name = regular.family_name().to_owned();
237        let mut metrics = regular.metrics();
238        match &aux {
239            Some(AuxFontFaces::Bold(font)) => {
240                metrics.bold_spacing = font.letter_spacing(&metrics);
241            }
242            Some(AuxFontFaces::Italic(font)) => {
243                metrics.italic_spacing = font.letter_spacing(&metrics);
244            }
245            None => { /* do nothing */ }
246        }
247
248        #[cfg(feature = "tracing")]
249        tracing::info!(?metrics, "using font metrics");
250
251        Ok(Self {
252            family_name,
253            metrics,
254            regular_face: regular,
255            additional_faces: aux,
256        })
257    }
258}
259
260impl FontEmbedder for FontSubsetter {
261    type Error = SubsettingError;
262
263    #[cfg_attr(feature = "tracing", tracing::instrument(skip(self), ret, err))]
264    fn embed_font(&self, mut used_chars: BTreeSet<char>) -> Result<EmbeddedFont, Self::Error> {
265        used_chars.remove(&'\n');
266        let subset = self.regular_face.checked_subset(&used_chars)?;
267        let mut faces = vec![EmbeddedFontFace::woff2(subset.to_woff2())];
268        match &self.additional_faces {
269            Some(AuxFontFaces::Bold(face)) => {
270                let subset = face.checked_subset(&used_chars)?;
271                faces.push(EmbeddedFontFace {
272                    is_bold: Some(true),
273                    ..EmbeddedFontFace::woff2(subset.to_woff2())
274                });
275                faces[0].is_bold = Some(false);
276            }
277            Some(AuxFontFaces::Italic(face)) => {
278                let subset = face.checked_subset(&used_chars)?;
279                faces.push(EmbeddedFontFace {
280                    is_italic: Some(true),
281                    ..EmbeddedFontFace::woff2(subset.to_woff2())
282                });
283                faces[0].is_italic = Some(false);
284            }
285            None => { /* do nothing */ }
286        }
287
288        Ok(EmbeddedFont {
289            family_name: self.family_name.clone(),
290            metrics: self.metrics,
291            faces,
292        })
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use std::{fs, path::Path};
299
300    use assert_matches::assert_matches;
301    use test_casing::test_casing;
302
303    use super::*;
304    use crate::{
305        svg::{Template, TemplateOptions},
306        Transcript, UserInput,
307    };
308
309    fn read_font(path: &str) -> FontFace {
310        const FONTS_DIR: &str = "../examples/fonts";
311
312        let bytes = fs::read(Path::new(FONTS_DIR).join(path)).unwrap();
313        FontFace::new(bytes.into()).unwrap()
314    }
315
316    fn roboto_mono() -> FontFace {
317        read_font("RobotoMono.ttf")
318    }
319
320    fn roboto_mono_italic() -> FontFace {
321        read_font("RobotoMono-Italic.ttf")
322    }
323
324    fn fira_mono() -> FontFace {
325        read_font("FiraMono-Regular.ttf")
326    }
327
328    fn fira_mono_bold() -> FontFace {
329        read_font("FiraMono-Bold.ttf")
330    }
331
332    fn test_subsetting_font(subsetter: FontSubsetter, pure_svg: bool) -> String {
333        let font_family = subsetter.family_name.clone();
334        let mut transcript = Transcript::new();
335        transcript.add_interaction(
336            UserInput::command("test"),
337            "\u{1b}[44m\u{1b}[1mH\u{1b}[0mello, \u{1b}[32m\u{1b}[3mworld\u{1b}[0m! ".repeat(10),
338        );
339
340        let options = TemplateOptions::default().with_font_subsetting(subsetter);
341        let options = options.validated().unwrap();
342        let mut buffer = vec![];
343        let template = if pure_svg {
344            Template::pure_svg(options)
345        } else {
346            Template::new(options)
347        };
348        template.render(&transcript, &mut buffer).unwrap();
349        let buffer = String::from_utf8(buffer).unwrap();
350
351        assert!(buffer.contains("@font-face"), "{buffer}");
352        assert!(
353            buffer.contains(&format!("font-family: \"{font_family}\";")),
354            "{buffer}"
355        );
356        assert!(
357            buffer.contains("src: url(\"data:font/woff2;base64,"),
358            "{buffer}"
359        );
360        assert!(
361            buffer.contains(&format!("font: 14px \"{font_family}\", monospace;")),
362            "{buffer}"
363        );
364
365        buffer
366    }
367
368    #[test_casing(2, [false, true])]
369    fn subsetting_font(pure_svg: bool) {
370        let subsetter = FontSubsetter::new(roboto_mono()).unwrap();
371        assert_eq!(subsetter.family_name, "Roboto Mono");
372        let buffer = test_subsetting_font(subsetter, pure_svg);
373
374        if pure_svg {
375            // Check some background boxes.
376            assert!(
377                buffer.contains(r#"<rect x="10" y="28.5" width="8.4" height="18.5" class="fg4"/>"#),
378                "{buffer}"
379            );
380            assert!(
381                buffer.contains(
382                    r#"<rect x="127.62" y="28.5" width="8.4" height="18.5" class="fg4"/>"#
383                ),
384                "{buffer}"
385            );
386        }
387    }
388
389    #[test_casing(2, [false, true])]
390    #[allow(clippy::float_cmp)] // the entire point
391    fn subsetting_font_with_aux_italic_font(pure_svg: bool) {
392        let subsetter =
393            FontSubsetter::from_faces(roboto_mono(), Some(roboto_mono_italic())).unwrap();
394        assert_eq!(subsetter.family_name, "Roboto Mono");
395        assert_matches!(&subsetter.additional_faces, Some(AuxFontFaces::Italic(_)));
396        assert_eq!(subsetter.metrics.bold_spacing, 0.0);
397        assert_ne!(subsetter.metrics.italic_spacing, 0.0);
398
399        let buffer = test_subsetting_font(subsetter, pure_svg);
400        let font_faces = buffer
401            .lines()
402            .filter(|line| line.trim_start().starts_with("@font-face"))
403            .count();
404        assert_eq!(font_faces, 2, "{buffer}");
405
406        assert!(
407            buffer.contains(".bold,.prompt { font-weight: bold; }"),
408            "{buffer}"
409        );
410        assert!(
411            buffer.contains(".italic { font-style: italic; letter-spacing: 0.0132em; }"),
412            "{buffer}"
413        );
414    }
415
416    #[test_casing(2, [false, true])]
417    #[allow(clippy::float_cmp)] // the entire point
418    fn subsetting_font_with_aux_bold_font(pure_svg: bool) {
419        let subsetter = FontSubsetter::from_faces(fira_mono(), Some(fira_mono_bold())).unwrap();
420        assert_eq!(subsetter.family_name, "Fira Mono");
421        assert_matches!(&subsetter.additional_faces, Some(AuxFontFaces::Bold(_)));
422        // Fira Mono Bold has the same advance width as the regular font face
423        assert_eq!(subsetter.metrics.bold_spacing, 0.0);
424        assert_eq!(subsetter.metrics.italic_spacing, 0.0);
425
426        let buffer = test_subsetting_font(subsetter, pure_svg);
427        let font_faces = buffer
428            .lines()
429            .filter(|line| line.trim_start().starts_with("@font-face"))
430            .count();
431        assert_eq!(font_faces, 2, "{buffer}");
432
433        assert!(
434            buffer.contains(".bold,.prompt { font-weight: bold; }"),
435            "{buffer}"
436        );
437        assert!(
438            buffer.contains(".italic { font-style: italic; }"),
439            "{buffer}"
440        );
441    }
442}