mongol_norm/font.rs
1//! The font backend (Cargo feature `font`): shaping with the bundled mongfontbuilder Hudum font
2//! through harfrust, the Rust port of HarfBuzz, instead of the ported rules.
3//!
4//! See [`FontShaper`] for the contract. The glyph → written-unit table this reads is generated
5//! from the font's glyph names (`src/generated/font_hudum.rs`, by
6//! `python/scripts/gen_font_shaper.py`); the font itself is `fonts/hudum.otf`, pinned to an
7//! upstream commit by `fonts/mongfontbuilder.json`.
8
9use std::sync::OnceLock;
10
11use harfrust::{
12 shape, Buffer, ClusterLevel, Direction, Font, Language, Script, ShapeOptions, ShapePlan,
13 ShaperFont,
14};
15
16use crate::duplicates::collapse;
17use crate::generated::font_hudum::GLYPHS;
18use crate::redundant_zwj::drop_redundant_zwj;
19use crate::unicode::check_word_chars;
20use crate::{Error, Position, WrittenUnit};
21
22/// The bundled shaping font: mongfontbuilder's Hudum font without its outlines (GSUB, GDEF,
23/// cmap and the sfnt skeleton). `fonts/mongfontbuilder.json` names the commit it was built at.
24static FONT_DATA: &[u8] = include_bytes!("../fonts/hudum.otf");
25
26/// The parsed font, shared by every `FontShaper`: read-fonts caches the layout tables on it.
27static FONT: OnceLock<Font> = OnceLock::new();
28
29fn font() -> &'static Font {
30 FONT.get_or_init(|| Font::new(FONT_DATA, 0).expect("fonts/hudum.otf is a valid font"))
31}
32
33/// Characters HarfBuzz skips when it forms a ligature (marks to the font): the four FVS, ZWJ
34/// and ZWNJ. They never count as a letter of a ligature.
35fn is_skipped(c: char) -> bool {
36 matches!(
37 c as u32,
38 0x180B | 0x180C | 0x180D | 0x180F | 0x200D | 0x200C
39 )
40}
41
42/// The BCP 47 language harfrust maps to the font's one language system, `MNG `.
43fn language() -> Language {
44 Language::new("mn").expect("a non-empty language tag")
45}
46
47/// What a glyph of the bundled font contributes to a shaped run (the generated table's entry type).
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub(crate) enum Kind {
50 /// A letter variant or a ligature: `units` is what it shows.
51 Letters,
52 /// An MVS or NNBSP, in any of its drawings (the space of a separator that separates
53 /// nothing included — its second, silent glyph is `Silent`).
54 Mvs,
55 /// A nirugu.
56 Nirugu,
57 /// A ZWJ.
58 Zwj,
59 /// Ink or no ink, nothing the crate reports: an FVS (valid or not — the font draws an
60 /// invalid one as a placeholder), the silent half of an idle MVS, a composer marker.
61 Silent,
62 /// A glyph no shaped word may contain: `.notdef` (a character the font has no glyph for),
63 /// punctuation, the composer's building blocks.
64 Unsupported,
65}
66
67/// One entry of the generated glyph table.
68#[derive(Clone, Copy, Debug)]
69pub(crate) struct Glyph {
70 pub(crate) kind: Kind,
71 pub(crate) units: &'static [WrittenUnit],
72 /// The letters (code points) the glyph draws: 2+ for a ligature.
73 pub(crate) letters: u8,
74 pub(crate) form: Option<Position>,
75 pub(crate) joining: Option<Position>,
76}
77
78impl Glyph {
79 const fn control(kind: Kind, units: &'static [WrittenUnit]) -> Glyph {
80 Glyph {
81 kind,
82 units,
83 letters: 0,
84 form: None,
85 joining: None,
86 }
87 }
88
89 pub(crate) const fn letters(
90 units: &'static [WrittenUnit],
91 letters: u8,
92 form: Position,
93 joining: Position,
94 ) -> Glyph {
95 Glyph {
96 kind: Kind::Letters,
97 units,
98 letters,
99 form: Some(form),
100 joining: Some(joining),
101 }
102 }
103
104 pub(crate) const fn mvs() -> Glyph {
105 Glyph::control(Kind::Mvs, &[WrittenUnit::Mvs])
106 }
107
108 pub(crate) const fn nirugu() -> Glyph {
109 Glyph::control(Kind::Nirugu, &[WrittenUnit::Nirugu])
110 }
111
112 pub(crate) const fn zwj() -> Glyph {
113 Glyph::control(Kind::Zwj, &[WrittenUnit::Zwj])
114 }
115
116 pub(crate) const fn silent() -> Glyph {
117 Glyph::control(Kind::Silent, &[])
118 }
119
120 pub(crate) const fn unsupported() -> Glyph {
121 Glyph::control(Kind::Unsupported, &[])
122 }
123}
124
125/// One glyph of a shaped run, as [`FontShaper::shape_glyphs`] reports it.
126#[non_exhaustive]
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub struct ShapedGlyph {
129 /// The glyph id in `fonts/hudum.otf`; `fonts/hudum.glyphs.txt` names it.
130 pub glyph: u16,
131 /// The `char` index of the input character the glyph comes from — for a ligature, of its
132 /// first letter.
133 pub index: usize,
134 /// The written units the glyph shows; empty for a silent glyph (an FVS, the silent half of
135 /// an idle MVS, …).
136 pub units: &'static [WrittenUnit],
137 /// The position of the written form the glyph draws; `None` for a control.
138 pub form: Option<Position>,
139 /// The joining position the glyph serves. Differs from `form` where a position borrows
140 /// another's form — an isolated `N` is drawn with `N:init`, so `form` is `Init` and
141 /// `joining` is `Isol`.
142 pub joining: Option<Position>,
143 /// A ZWJ the font's ligature formation skipped: it sits between two letters of the
144 /// preceding ligature, HarfBuzz moved it after the ligature, and
145 /// [`FontShaper::shape`] drops it, as the redundant-ZWJ pass drops any joiner between two
146 /// joined letter units.
147 pub skipped: bool,
148}
149
150/// Shapes Hudum with the bundled mongfontbuilder font through HarfBuzz rules — the font backend
151/// of the crate (Cargo feature `font`), an alternative to the rule engine
152/// [`Shaper`](crate::Shaper).
153///
154/// harfrust (the Rust port of HarfBuzz) runs the Hudum font that mongfontbuilder — the
155/// reference implementation of UTN #57 — composes, with the OpenType rules the composer
156/// generates. The crate reads the written units off the glyphs the font chose. The font is
157/// bundled without its outlines (`fonts/hudum.otf`, 30 KB: GSUB, GDEF, cmap and the sfnt
158/// skeleton), and the glyph → written-unit table is generated from its glyph names.
159///
160/// Compared with the rule engine:
161/// * the rules are the font's, at the mongfontbuilder commit `fonts/mongfontbuilder.json`
162/// pins — not a port. Where the two disagree, this is what the font draws;
163/// * there is no `trace` or `shape_detailed`: the font's lookups are not the crate's rules.
164/// The glyph-level view is [`FontShaper::shape_glyphs`];
165/// * there is no `normalize`: the normalizer is built on the rule engine;
166/// * it costs harfrust and its crates, Rust 1.85, and a few microseconds per word.
167///
168/// # Output contract
169///
170/// [`FontShaper::shape`] is the same kind of sequence as [`Shaper::shape`](crate::Shaper::shape):
171/// `Mvs` and `Nirugu` verbatim, a `Zwj` only where it changes the ink, the duplicate encodings
172/// unified — the two post-passes are shared with the rule engine — so the two backends can
173/// be compared unit for unit. How the glyphs are read:
174/// * an FVS, valid or not, is never a unit. The font draws an invalid one as a visible
175/// placeholder; the crate follows the rule engine and the GB/T 25914-2023 unit streams and
176/// reports nothing;
177/// * an MVS that separates nothing is drawn as a space plus a silent glyph: one `Mvs`. An
178/// NNBSP is an `Mvs`, as in the rule engine;
179/// * a ZWJ between two letters the font ligates comes out of HarfBuzz after the ligature.
180/// [`FontShaper::shape_raw`] keeps it there; `shape` drops it (see [`ShapedGlyph::skipped`]).
181///
182/// # Errors
183///
184/// [`Error::NonMongolianChar`] on anything but Mongolian letters, FVS, MVS, NNBSP, nirugu and
185/// ZWJ — the rule engine's rule; [`Error::UnsupportedChar`] on a character of the block the
186/// font has no glyph for: a letter of another writing system (Todo, Sibe, Manchu, Ali Gali),
187/// an Ali Gali sign, an unassigned code point. The rule engine shapes those to nothing; the
188/// font would draw its `.notdef` box, so this backend refuses them instead.
189///
190/// # Example
191///
192/// ```
193/// use mongol_norm::{FontShaper, Locale, Shaper};
194///
195/// let font = FontShaper::new();
196/// let rules = Shaper::new(Locale::Mng);
197/// assert_eq!(font.shape_str("ᠰᠠᠢᠨ")?, "S+A+I+I+A");
198/// assert_eq!(font.shape("ᠰᠠᠢᠨ")?, rules.shape("ᠰᠠᠢᠨ")?);
199/// # Ok::<(), mongol_norm::Error>(())
200/// ```
201pub struct FontShaper {
202 language: Language,
203 plan: ShapePlan,
204}
205
206impl Default for FontShaper {
207 fn default() -> FontShaper {
208 FontShaper::new()
209 }
210}
211
212impl FontShaper {
213 /// A shaper over the bundled font.
214 ///
215 /// The font is parsed once per process and shared; a shaper holds its shape plan, so it is
216 /// cheap to keep and shapes without further setup.
217 pub fn new() -> FontShaper {
218 let language = language();
219 let plan = ShapePlan::new(
220 font(),
221 Direction::LeftToRight,
222 Some(Script::MONGOLIAN),
223 Some(&language),
224 &[],
225 );
226 FontShaper { language, plan }
227 }
228
229 /// Shape `text` into its written-unit sequence, on the same contract as
230 /// [`Shaper::shape`](crate::Shaper::shape): `Mvs` and `Nirugu` verbatim, a `Zwj` only where
231 /// it changes the ink, the duplicate encodings unified.
232 pub fn shape(&self, text: &str) -> Result<Vec<WrittenUnit>, Error> {
233 let glyphs = self.run(text)?;
234 let raw: Vec<WrittenUnit> = glyphs
235 .iter()
236 .filter(|glyph| !glyph.skipped)
237 .flat_map(|glyph| glyph.units.iter().copied())
238 .collect();
239 Ok(collapse(&drop_redundant_zwj(&raw)))
240 }
241
242 /// The written units of the glyphs the font chose, in glyph order, before the shared
243 /// post-passes: every ZWJ of the input (one the font ligated across comes after the
244 /// ligature) and every duplicate encoding is still there. The counterpart of
245 /// [`Shaper::shape_raw`](crate::Shaper::shape_raw), and like it not part of the public
246 /// contract — it is what the conformance suites compare against.
247 #[doc(hidden)]
248 pub fn shape_raw(&self, text: &str) -> Result<Vec<WrittenUnit>, Error> {
249 let glyphs = self.run(text)?;
250 Ok(glyphs
251 .iter()
252 .flat_map(|glyph| glyph.units.iter().copied())
253 .collect())
254 }
255
256 /// [`FontShaper::shape`] joined with `+` (`S+A+I+I+A`), the CLI's output format.
257 pub fn shape_str(&self, text: &str) -> Result<String, Error> {
258 let units = self.shape(text)?;
259 let mut out = String::new();
260 for unit in units {
261 if !out.is_empty() {
262 out.push('+');
263 }
264 out.push_str(unit.as_str());
265 }
266 Ok(out)
267 }
268
269 /// Do `a` and `b` render the same glyph sequence?
270 pub fn same_shape(&self, a: &str, b: &str) -> Result<bool, Error> {
271 Ok(self.shape(a)? == self.shape(b)?)
272 }
273
274 /// The glyphs the font chose for `text`, in the order HarfBuzz returns them, each with its
275 /// written units and positions — the debugging view of this backend.
276 pub fn shape_glyphs(&self, text: &str) -> Result<Vec<ShapedGlyph>, Error> {
277 self.run(text)
278 }
279
280 fn run(&self, text: &str) -> Result<Vec<ShapedGlyph>, Error> {
281 check_word_chars(text)?;
282 let chars: Vec<char> = text.chars().collect();
283
284 let mut buffer = Buffer::new();
285 // One cluster per character, never merged: the cluster of a glyph is the index of the
286 // character it comes from, which is what tells a skipped ZWJ from one after a ligature.
287 buffer.set_cluster_level(ClusterLevel::Characters);
288 for (index, &ch) in chars.iter().enumerate() {
289 buffer.push(ch as u32, index as u32);
290 }
291 buffer.set_direction(Direction::LeftToRight);
292 buffer.set_script(Some(Script::MONGOLIAN));
293 buffer.set_language(Some(self.language.clone()));
294 // `ShaperFont` borrows the shared font and reuses its cached layout data; it holds a
295 // non-`Sync` callback slot, so it is built per call rather than kept in the shaper.
296 let shaper_font = ShaperFont::new(font());
297 shape(
298 &shaper_font,
299 &mut buffer,
300 ShapeOptions::new().plan(Some(&self.plan)),
301 )
302 .expect("the plan was built for this direction and script");
303
304 let mut out = Vec::with_capacity(buffer.len());
305 // The letters of the last letter glyph: a ZWJ whose character lies strictly inside
306 // them was skipped by the ligature that drew them.
307 let mut span: Option<(usize, usize)> = None;
308 for info in buffer.glyph_infos() {
309 let id = info.glyph_id as usize;
310 let index = info.cluster as usize;
311 let glyph = GLYPHS.get(id).copied().unwrap_or(Glyph::unsupported());
312 match glyph.kind {
313 Kind::Unsupported => {
314 return Err(Error::UnsupportedChar {
315 ch: chars[index],
316 index,
317 })
318 }
319 Kind::Letters => span = Some((index, span_end(&chars, index, glyph.letters))),
320 _ => {}
321 }
322 let skipped = glyph.kind == Kind::Zwj
323 && span.is_some_and(|(start, end)| start < index && index < end);
324 out.push(ShapedGlyph {
325 glyph: id as u16,
326 index,
327 units: glyph.units,
328 form: glyph.form,
329 joining: glyph.joining,
330 skipped,
331 });
332 }
333 Ok(out)
334 }
335}
336
337/// The `char` index of the last of the `letters` characters a glyph starting at `start`
338/// draws. FVS and joiners do not count: the font skips them when it forms a ligature.
339fn span_end(chars: &[char], start: usize, letters: u8) -> usize {
340 let mut remaining = letters.max(1);
341 let mut end = start;
342 for (offset, &ch) in chars[start..].iter().enumerate() {
343 if is_skipped(ch) {
344 continue;
345 }
346 remaining -= 1;
347 end = start + offset;
348 if remaining == 0 {
349 break;
350 }
351 }
352 end
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::generated::font_hudum::GLYPH_COUNT;
359
360 #[test]
361 fn the_table_covers_the_font() {
362 assert_eq!(font().num_glyphs() as usize, GLYPH_COUNT);
363 assert_eq!(GLYPHS.len(), GLYPH_COUNT);
364 }
365
366 #[test]
367 fn every_letter_glyph_shows_at_least_one_unit() {
368 for (id, glyph) in GLYPHS.iter().enumerate() {
369 match glyph.kind {
370 Kind::Letters => {
371 assert!(!glyph.units.is_empty(), "glyph {id} shows nothing");
372 assert!(glyph.letters >= 1, "glyph {id} draws no letter");
373 }
374 Kind::Mvs | Kind::Nirugu | Kind::Zwj => assert_eq!(glyph.units.len(), 1),
375 Kind::Silent | Kind::Unsupported => assert!(glyph.units.is_empty()),
376 }
377 }
378 }
379
380 #[test]
381 fn shapers_are_shareable_between_threads() {
382 fn assert_send_sync<T: Send + Sync>() {}
383 assert_send_sync::<FontShaper>();
384 }
385
386 #[test]
387 fn span_end_skips_fvs_and_joiners() {
388 let chars: Vec<char> = "ᠪ\u{200D}\u{180B}ᠣᠯ".chars().collect();
389 assert_eq!(span_end(&chars, 0, 1), 0);
390 assert_eq!(span_end(&chars, 0, 2), 3);
391 assert_eq!(span_end(&chars, 0, 3), 4);
392 assert_eq!(span_end(&chars, 3, 1), 3);
393 // Asking for more letters than there are stops at the last one.
394 assert_eq!(span_end(&chars, 3, 5), 4);
395 }
396}