1use std::{collections::BTreeSet, fmt};
4
5use font_subset::{Font, FontCategory, OwnedFont, ParseError};
6
7use super::{EmbeddedFont, EmbeddedFontFace, FontEmbedder, FontMetrics};
8
9#[derive(Debug)]
11#[non_exhaustive]
12pub enum SubsettingError {
13 Parse(ParseError),
15 NoFontFamilyName,
17 NoSubsetting,
19 NoEmbedding,
21 NotMonospace,
23 UnsupportedFontCategories(Vec<FontCategory>),
25 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
75pub struct FontFace {
77 inner: OwnedFont,
78 advance_width: u16,
79}
80
81impl 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 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 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#[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 pub fn new(font: FontFace) -> Result<Self, SubsettingError> {
194 Self::from_faces(font, None)
195 }
196
197 #[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 => { }
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 => { }
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, iter, path::Path};
299
300 use assert_matches::assert_matches;
301 use styled_str::styled;
302 use test_casing::test_casing;
303
304 use super::*;
305 use crate::{
306 Transcript, UserInput,
307 svg::{Template, TemplateOptions},
308 };
309
310 fn read_font(path: &str) -> FontFace {
311 const FONTS_DIR: &str = "../docs/src/assets/fonts";
313
314 let bytes = fs::read(Path::new(FONTS_DIR).join(path)).unwrap();
315 FontFace::new(bytes.into()).unwrap()
316 }
317
318 fn roboto_mono() -> FontFace {
319 read_font("RobotoMono.ttf")
320 }
321
322 fn roboto_mono_italic() -> FontFace {
323 read_font("RobotoMono-Italic.ttf")
324 }
325
326 fn fira_mono() -> FontFace {
327 read_font("FiraMono-Regular.ttf")
328 }
329
330 fn fira_mono_bold() -> FontFace {
331 read_font("FiraMono-Bold.ttf")
332 }
333
334 fn test_subsetting_font(subsetter: FontSubsetter, pure_svg: bool) -> String {
335 let font_family = subsetter.family_name.clone();
336 let mut transcript = Transcript::new();
337 transcript.add_interaction(
338 UserInput::command("test"),
339 iter::repeat_n(
340 styled!("[[bold on blue]]H[[/]]ello, [[italic green]]world[[/]]! "),
341 10,
342 )
343 .collect(),
344 );
345
346 let options = TemplateOptions::default().with_font_subsetting(subsetter);
347 let options = options.validated().unwrap();
348 let mut buffer = vec![];
349 let template = if pure_svg {
350 Template::pure_svg(options)
351 } else {
352 Template::new(options)
353 };
354 template.render(&transcript, &mut buffer).unwrap();
355 let buffer = String::from_utf8(buffer).unwrap();
356
357 assert!(buffer.contains("@font-face"), "{buffer}");
358 assert!(
359 buffer.contains(&format!("font-family: \"{font_family}\";")),
360 "{buffer}"
361 );
362 assert!(
363 buffer.contains("src: url(\"data:font/woff2;base64,"),
364 "{buffer}"
365 );
366 assert!(
367 buffer.contains(&format!("font: 14px \"{font_family}\", monospace;")),
368 "{buffer}"
369 );
370
371 buffer
372 }
373
374 #[test_casing(2, [false, true])]
375 fn subsetting_font(pure_svg: bool) {
376 let subsetter = FontSubsetter::new(roboto_mono()).unwrap();
377 assert_eq!(subsetter.family_name, "Roboto Mono");
378 let buffer = test_subsetting_font(subsetter, pure_svg);
379
380 if pure_svg {
381 assert!(
383 buffer.contains(r#"<rect x="10" y="28.5" width="8.4" height="18.5" class="fg4"/>"#),
384 "{buffer}"
385 );
386 assert!(
387 buffer.contains(
388 r#"<rect x="127.62" y="28.5" width="8.4" height="18.5" class="fg4"/>"#
389 ),
390 "{buffer}"
391 );
392 }
393 }
394
395 #[test_casing(2, [false, true])]
396 #[allow(clippy::float_cmp)] fn subsetting_font_with_aux_italic_font(pure_svg: bool) {
398 let subsetter =
399 FontSubsetter::from_faces(roboto_mono(), Some(roboto_mono_italic())).unwrap();
400 assert_eq!(subsetter.family_name, "Roboto Mono");
401 assert_matches!(&subsetter.additional_faces, Some(AuxFontFaces::Italic(_)));
402 assert_eq!(subsetter.metrics.bold_spacing, 0.0);
403 assert_ne!(subsetter.metrics.italic_spacing, 0.0);
404
405 let buffer = test_subsetting_font(subsetter, pure_svg);
406 let font_faces = buffer
407 .lines()
408 .filter(|line| line.trim_start().starts_with("@font-face"))
409 .count();
410 assert_eq!(font_faces, 2, "{buffer}");
411
412 assert!(
413 buffer.contains(".bold,.prompt { font-weight: bold; }"),
414 "{buffer}"
415 );
416 assert!(
417 buffer.contains(".italic { font-style: italic; letter-spacing: 0.0132em; }"),
418 "{buffer}"
419 );
420 }
421
422 #[test_casing(2, [false, true])]
423 #[allow(clippy::float_cmp)] fn subsetting_font_with_aux_bold_font(pure_svg: bool) {
425 let subsetter = FontSubsetter::from_faces(fira_mono(), Some(fira_mono_bold())).unwrap();
426 assert_eq!(subsetter.family_name, "Fira Mono");
427 assert_matches!(&subsetter.additional_faces, Some(AuxFontFaces::Bold(_)));
428 assert_eq!(subsetter.metrics.bold_spacing, 0.0);
430 assert_eq!(subsetter.metrics.italic_spacing, 0.0);
431
432 let buffer = test_subsetting_font(subsetter, pure_svg);
433 let font_faces = buffer
434 .lines()
435 .filter(|line| line.trim_start().starts_with("@font-face"))
436 .count();
437 assert_eq!(font_faces, 2, "{buffer}");
438
439 assert!(
440 buffer.contains(".bold,.prompt { font-weight: bold; }"),
441 "{buffer}"
442 );
443 assert!(
444 buffer.contains(".italic { font-style: italic; }"),
445 "{buffer}"
446 );
447 }
448}