Skip to main content

mos_fonts/
family.rs

1use mos_core::{Diagnostic, SourceSpan, codes};
2
3use crate::{Base14Font, EmbeddedFontId, Font};
4
5/// A four-cut family: Regular, Bold, Italic, `BoldItalic`. The layout
6/// engine picks one slot per styled run (`*emphasis*` → italic,
7/// `**strong**` → bold, raw → fixed-width family, body → regular).
8///
9/// Build via [`FontFamily::resolve`], which understands Base14 family
10/// names and the bundled `"Noto Sans"` family. Unknown names fall back
11/// to Noto Sans and emit a `MOS0018` diagnostic.
12///
13/// # Examples
14///
15/// ```
16/// use mos_fonts::{Font, FontFamily, EmbeddedFontId};
17///
18/// let family = FontFamily::noto_sans();
19///
20/// assert_eq!(family.regular, Font::Embedded(EmbeddedFontId::Regular));
21/// ```
22#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
23pub struct FontFamily {
24    /// Default upright face. Used for body text.
25    pub regular: Font,
26    /// Bold face. Used for `**strong**` and headings.
27    pub bold: Font,
28    /// Italic / oblique face. Used for `*emphasis*`.
29    pub italic: Font,
30    /// Bold italic face. Used for `***bold italic***` constructs.
31    pub bold_italic: Font,
32    /// Monospace face. Used for `` `raw` `` runs. The four-slot family
33    /// concept is upright/styled-Latin; raw is its own typeface choice
34    /// that the layout engine pins independently of the family.
35    pub monospace: Font,
36    /// Per-glyph fallback chain shared by every style slot in this
37    /// family. When shaping against any of the style-slot faces above
38    /// yields `.notdef` for some cluster, [`crate::shape_with_fallback`]
39    /// retries that cluster against each embedded face in this slice in
40    /// order. The first face to cover the cluster wins the whole cluster
41    /// (cluster-granular replacement). Math fallback is therefore
42    /// upright even inside bold or italic text until style-aware fallback
43    /// chains exist. Empty chain = primary-only shaping (Base14 families
44    /// don't have an embedded fallback target).
45    pub fallbacks: &'static [EmbeddedFontId],
46}
47
48/// Per-glyph fallback chain for [`FontFamily::noto_sans`]. Math
49/// codepoints (`≤ ≥ √ ∂ ∑ ∆ ◊` …) outside Noto Sans's coverage
50/// route through Noto Sans Math via the cluster-granular retry in
51/// [`crate::shape_with_fallback`].
52const NOTO_SANS_FALLBACKS: &[EmbeddedFontId] = &[EmbeddedFontId::Math];
53
54impl FontFamily {
55    /// The bundled Noto Sans family: embedded TTFs, real designed
56    /// cuts for every style slot (no faux-bold or faux-italic). Raw
57    /// runs route through the bundled Noto Sans Mono Regular cut so
58    /// `` `Привет` `` and other non-WinAnsi raw content shape through
59    /// the same `rustybuzz` + `/ToUnicode` pipeline as body text
60    /// instead of dropping to the Base14 `?` substitution.
61    ///
62    /// Per-glyph fallback chain: `[Math]`. Codepoints not in Noto
63    /// Sans (math operators like `≤ ≥ √ ∂ ∑ ∆ ◊`) shape against
64    /// the bundled Noto Sans Math cut.
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use mos_fonts::{EmbeddedFontId, Font, FontFamily};
70    ///
71    /// let family = FontFamily::noto_sans();
72    ///
73    /// assert_eq!(family.monospace, Font::Embedded(EmbeddedFontId::Mono));
74    /// ```
75    #[must_use]
76    pub const fn noto_sans() -> Self {
77        Self {
78            regular: Font::Embedded(EmbeddedFontId::Regular),
79            bold: Font::Embedded(EmbeddedFontId::Bold),
80            italic: Font::Embedded(EmbeddedFontId::Italic),
81            bold_italic: Font::Embedded(EmbeddedFontId::BoldItalic),
82            monospace: Font::Embedded(EmbeddedFontId::Mono),
83            fallbacks: NOTO_SANS_FALLBACKS,
84        }
85    }
86
87    /// The Base14 Helvetica family. Used when the document explicitly
88    /// asks for `Helvetica`. Falls back through Courier for raw.
89    ///
90    /// Base14 has no per-glyph fallback target; the byte-encoded
91    /// content stream path can't splice in glyph IDs from a sibling
92    /// face. Non-WinAnsi codepoints silently substitute to `?` in
93    /// `mos-pdf::encode_base14_run`.
94    ///
95    /// # Examples
96    ///
97    /// ```
98    /// use mos_fonts::{Base14Font, Font, FontFamily};
99    ///
100    /// let family = FontFamily::helvetica();
101    ///
102    /// assert_eq!(family.regular, Font::Base14(Base14Font::Helvetica));
103    /// ```
104    #[must_use]
105    pub const fn helvetica() -> Self {
106        Self {
107            regular: Font::Base14(Base14Font::Helvetica),
108            bold: Font::Base14(Base14Font::HelveticaBold),
109            italic: Font::Base14(Base14Font::HelveticaOblique),
110            bold_italic: Font::Base14(Base14Font::HelveticaBoldOblique),
111            monospace: Font::Base14(Base14Font::Courier),
112            fallbacks: &[],
113        }
114    }
115
116    /// The Base14 Times Roman family. Used when the document asks
117    /// for `Times` or `Times-Roman`. No per-glyph fallback: see
118    /// [`Self::helvetica`].
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use mos_fonts::{Base14Font, Font, FontFamily};
124    ///
125    /// let family = FontFamily::times();
126    ///
127    /// assert_eq!(family.bold, Font::Base14(Base14Font::TimesBold));
128    /// ```
129    #[must_use]
130    pub const fn times() -> Self {
131        Self {
132            regular: Font::Base14(Base14Font::TimesRoman),
133            bold: Font::Base14(Base14Font::TimesBold),
134            italic: Font::Base14(Base14Font::TimesItalic),
135            bold_italic: Font::Base14(Base14Font::TimesBoldItalic),
136            monospace: Font::Base14(Base14Font::Courier),
137            fallbacks: &[],
138        }
139    }
140
141    /// The Base14 Courier family. Used when the document asks for
142    /// `Courier` as the body face. All four style slots route to a
143    /// Courier cut. No per-glyph fallback; see [`Self::helvetica`].
144    ///
145    /// # Examples
146    ///
147    /// ```
148    /// use mos_fonts::{Base14Font, Font, FontFamily};
149    ///
150    /// let family = FontFamily::courier();
151    ///
152    /// assert_eq!(family.italic, Font::Base14(Base14Font::CourierOblique));
153    /// ```
154    #[must_use]
155    pub const fn courier() -> Self {
156        Self {
157            regular: Font::Base14(Base14Font::Courier),
158            bold: Font::Base14(Base14Font::CourierBold),
159            italic: Font::Base14(Base14Font::CourierOblique),
160            bold_italic: Font::Base14(Base14Font::CourierBoldOblique),
161            monospace: Font::Base14(Base14Font::Courier),
162            fallbacks: &[],
163        }
164    }
165
166    /// Resolve a `#set text(font: ...)` name to a family.
167    ///
168    /// Matching is case-insensitive on the family component. Known
169    /// names: `Helvetica`, `Times`/`Times-Roman`/`Times Roman`,
170    /// `Courier`, `Noto Sans`. Anything else falls back to Noto Sans
171    /// and pushes a `MOS0018` notice so users don't silently get the
172    /// wrong typeface.
173    ///
174    /// # Examples
175    ///
176    /// ```
177    /// use mos_fonts::{Base14Font, Font, FontFamily};
178    ///
179    /// let mut diagnostics = Vec::new();
180    /// let family = FontFamily::resolve("Times", None, &mut diagnostics);
181    ///
182    /// assert_eq!(family.regular, Font::Base14(Base14Font::TimesRoman));
183    /// assert!(diagnostics.is_empty());
184    /// ```
185    pub fn resolve(
186        name: &str,
187        span: Option<SourceSpan>,
188        diagnostics: &mut Vec<Diagnostic>,
189    ) -> Self {
190        let normalised = name.trim().to_ascii_lowercase();
191        match normalised.as_str() {
192            "helvetica" => Self::helvetica(),
193            "times" | "times-roman" | "times roman" | "times new roman" => Self::times(),
194            "courier" => Self::courier(),
195            "noto sans" | "notosans" => Self::noto_sans(),
196            _ => {
197                diagnostics.push(Diagnostic::simple(
198                    &codes::MOS0018,
199                    span,
200                    format!(
201                        "unknown font family `{name}`; falling back to bundled Noto Sans \
202                         (known families: Helvetica, Times, Courier, Noto Sans)"
203                    ),
204                ));
205                Self::noto_sans()
206            }
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use mos_core::Severity;
214
215    use super::*;
216
217    #[test]
218    fn resolve_known_families_does_not_diagnose() {
219        let mut diags = Vec::new();
220        let fam = FontFamily::resolve("Helvetica", None, &mut diags);
221        assert!(diags.is_empty());
222        assert_eq!(fam.regular, Font::Base14(Base14Font::Helvetica));
223        let _ = FontFamily::resolve("Times", None, &mut diags);
224        let _ = FontFamily::resolve("Times-Roman", None, &mut diags);
225        let _ = FontFamily::resolve("Courier", None, &mut diags);
226        let _ = FontFamily::resolve("Noto Sans", None, &mut diags);
227        assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
228
229        // Mixed case and leading/trailing whitespace must resolve the
230        // same way the canonical spelling does: `resolve` normalises
231        // through `.trim().to_ascii_lowercase()` before matching.
232        let padded = FontFamily::resolve("  heLVETICA  ", None, &mut diags);
233        assert!(
234            diags.is_empty(),
235            "padded mixed-case Helvetica diagnosed: {diags:?}"
236        );
237        assert_eq!(padded.regular, Font::Base14(Base14Font::Helvetica));
238        let spaced = FontFamily::resolve("\tNoto Sans\n", None, &mut diags);
239        assert!(diags.is_empty(), "padded Noto Sans diagnosed: {diags:?}");
240        assert_eq!(spaced.regular, Font::Embedded(EmbeddedFontId::Regular));
241    }
242
243    #[test]
244    fn resolve_unknown_family_emits_mos0018_and_falls_back_to_noto() {
245        let mut diags = Vec::new();
246        let fam = FontFamily::resolve("Libertinus Serif", None, &mut diags);
247        assert_eq!(diags.len(), 1);
248        assert_eq!(diags[0].def().code(), codes::MOS0018.code());
249        assert_eq!(diags[0].severity(), Severity::Notice);
250        assert_eq!(fam.regular, Font::Embedded(EmbeddedFontId::Regular));
251    }
252}