Skip to main content

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}