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}