Skip to main content

blockworx_text/
shaper.rs

1//! [`Shaper`]: a text engine of our own, so a host without one can still be
2//! told where every glyph lands.
3
4use std::cell::RefCell;
5
6use blockworx_geom::{WorldPx, vec2};
7use blockworx_paint::text::{Glyph, GlyphId, Layout, TextLayout};
8use blockworx_paint::{Font, FontChoice};
9
10use crate::cache::LayoutCache;
11use crate::metrics::Metrics;
12use crate::rows;
13
14/// The character a shaper reports for a byte it cannot read back.
15const UNKNOWN: char = '\u{FFFD}';
16
17/// One typeface, shaped by `harfrust` and broken into rows by the desktop
18/// engine's rules — the whole of what [`TextLayout`] asks of a host, with no
19/// toolkit under it.
20pub struct Shaper {
21    typeface: FontChoice,
22    metrics: Metrics,
23    font: skrifa::FontRef<'static>,
24    /// The parsed layout tables. Cached per face, as a layout engine caches
25    /// them: building a shaper from them is cheap, parsing them is not.
26    data: harfrust::ShaperData,
27    laid_out: RefCell<LayoutCache>,
28}
29
30impl Shaper {
31    /// # Panics
32    ///
33    /// If the typeface's embedded bytes are not a font this can read.
34    #[expect(clippy::expect_used)]
35    #[must_use]
36    pub fn new(typeface: FontChoice) -> Self {
37        let font =
38            skrifa::FontRef::from_index(typeface.bytes(), 0).expect("embedded font is valid");
39        let data = harfrust::ShaperData::new(&font);
40        Self {
41            typeface,
42            metrics: Metrics::of(typeface),
43            font,
44            data,
45            laid_out: RefCell::default(),
46        }
47    }
48
49    /// The metrics this engine measures a row by.
50    #[must_use]
51    pub fn metrics(&self) -> Metrics {
52        self.metrics
53    }
54
55    /// The glyphs one paragraph of `text` shapes to, in visual order —
56    /// identity only, for a consumer that has the positions already.
57    #[must_use]
58    pub fn glyph_ids(&self, text: &str) -> Vec<GlyphId> {
59        self.shape(text).into_iter().map(|glyph| glyph.id).collect()
60    }
61
62    fn shape(&self, text: &str) -> Vec<Shaped> {
63        let shaper = self.data.shaper(&self.font).build();
64        let mut buffer = harfrust::UnicodeBuffer::new();
65        buffer.set_flags(
66            harfrust::BufferFlags::BEGINNING_OF_TEXT | harfrust::BufferFlags::END_OF_TEXT,
67        );
68        buffer.push_str(text);
69        buffer.guess_segment_properties();
70        let shaped = shaper.shape(buffer, harfrust::ShapeOptions::new());
71        std::iter::zip(shaped.glyph_infos(), shaped.glyph_positions())
72            .map(|(info, pos)| Shaped {
73                // harfrust guarantees a shaped `glyph_id` fits in a `u16`.
74                id: GlyphId(info.glyph_id as u16),
75                cluster: info.cluster as usize,
76                advance: pos.x_advance as f32,
77            })
78            .collect()
79    }
80}
81
82impl TextLayout for Shaper {
83    fn typeface(&self) -> FontChoice {
84        self.typeface
85    }
86
87    fn layout(&self, text: &str, font: &Font, wrap: WorldPx) -> Layout {
88        if let Some(laid_out) = self.laid_out.borrow().get(text, font, wrap) {
89            return laid_out.clone();
90        }
91        let paragraphs = text
92            .split('\n')
93            .map(|paragraph| place(&self.shape(paragraph), paragraph, self.metrics, font))
94            .collect();
95        let laid_out = rows::rows_of(paragraphs, wrap, self.metrics, font);
96        self.laid_out
97            .borrow_mut()
98            .put(text, font, wrap, laid_out.clone());
99        laid_out
100    }
101}
102
103/// One glyph as the shaper reports it, in font units.
104struct Shaped {
105    id: GlyphId,
106    /// Where in the paragraph the characters this glyph stands for begin.
107    cluster: usize,
108    advance: f32,
109}
110
111/// The shaped run placed along one endless row, one glyph per *character*:
112/// a cluster the shaper folded into fewer glyphs than it has characters is
113/// padded out with zero-advance continuations, so cursor arithmetic above
114/// this can count characters.
115fn place(shaped: &[Shaped], text: &str, metrics: Metrics, font: &Font) -> Vec<Glyph> {
116    let mut run = Placing::over(text, metrics, font);
117    for glyph in shaped {
118        run.place(glyph);
119    }
120    run.finish()
121}
122
123/// The pen walking a shaped run, and the cluster it is in the middle of.
124struct Placing<'a> {
125    text: &'a str,
126    scale: f32,
127    ascent: f32,
128    pen: f32,
129    placed: Vec<Glyph>,
130    /// Where in `text` the cluster being placed begins.
131    cluster: Option<usize>,
132    glyphs_in_cluster: usize,
133}
134
135impl<'a> Placing<'a> {
136    fn over(text: &'a str, metrics: Metrics, font: &Font) -> Self {
137        Self {
138            text,
139            scale: metrics.scale(font),
140            ascent: metrics.ascent(font),
141            pen: 0.0,
142            placed: Vec::new(),
143            cluster: None,
144            glyphs_in_cluster: 0,
145        }
146    }
147
148    fn place(&mut self, shaped: &Shaped) {
149        if self.cluster.is_some_and(|at| at != shaped.cluster) {
150            self.pad(shaped.cluster);
151            self.glyphs_in_cluster = 0;
152        }
153        let chr = self
154            .text
155            .get(shaped.cluster..)
156            .and_then(|rest| rest.chars().next())
157            .unwrap_or(UNKNOWN);
158        let advance = shaped.advance * self.scale;
159        self.placed.push(Glyph {
160            chr,
161            id: Some(shaped.id),
162            pos: vec2(self.pen, self.ascent),
163            advance,
164            ascent: self.ascent,
165        });
166        self.pen += advance;
167        self.cluster = Some(shaped.cluster);
168        self.glyphs_in_cluster += 1;
169    }
170
171    /// A continuation for every character of the cluster just closed that the
172    /// shaper gave no glyph of its own.
173    fn pad(&mut self, end: usize) {
174        let text = self.text;
175        let Some(folded) = self.cluster.and_then(|at| text.get(at..end)) else {
176            return;
177        };
178        for chr in folded.chars().skip(self.glyphs_in_cluster) {
179            self.placed.push(Glyph {
180                chr,
181                id: None,
182                pos: vec2(self.pen, self.ascent),
183                advance: 0.0,
184                ascent: self.ascent,
185            });
186        }
187    }
188
189    fn finish(mut self) -> Vec<Glyph> {
190        self.pad(self.text.len());
191        self.placed
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn shaper() -> Shaper {
200        Shaper::new(FontChoice::Basic)
201    }
202
203    fn font() -> Font {
204        Font::canvas(12.0)
205    }
206
207    /// The row structure of a layout: what each row ends with, and the
208    /// characters on it.
209    fn rows_of(laid_out: &Layout) -> Vec<(String, bool)> {
210        laid_out
211            .rows
212            .iter()
213            .map(|row| {
214                (
215                    row.glyphs.iter().map(|glyph| glyph.chr).collect(),
216                    row.ends_with_newline,
217                )
218            })
219            .collect()
220    }
221
222    /// A width that fits `text` exactly, so a longer run must break.
223    fn width_of(shaper: &Shaper, text: &str) -> WorldPx {
224        WorldPx::new(shaper.layout(text, &font(), WorldPx::UNBOUNDED).size.x)
225    }
226
227    #[test]
228    fn an_unwrapped_run_is_one_row() {
229        let shaper = shaper();
230        let laid_out = shaper.layout("the quick brown fox", &font(), WorldPx::UNBOUNDED);
231        assert_eq!(
232            rows_of(&laid_out),
233            vec![("the quick brown fox".to_owned(), false)]
234        );
235        assert!(laid_out.size.x > 0.0 && laid_out.size.y > 0.0);
236    }
237
238    #[test]
239    fn a_paragraph_closes_the_row_it_ends() {
240        let shaper = shaper();
241        let laid_out = shaper.layout("one\ntwo", &font(), WorldPx::UNBOUNDED);
242        assert_eq!(
243            rows_of(&laid_out),
244            vec![("one".to_owned(), true), ("two".to_owned(), false)]
245        );
246        assert!(
247            (laid_out.size.y - 2.0 * laid_out.rows[0].height).abs() < 1e-3,
248            "two rows stand two rows tall"
249        );
250    }
251
252    #[test]
253    fn a_trailing_newline_keeps_an_empty_row() {
254        let shaper = shaper();
255        let laid_out = shaper.layout("one\n", &font(), WorldPx::UNBOUNDED);
256        assert_eq!(
257            rows_of(&laid_out),
258            vec![("one".to_owned(), true), (String::new(), false)]
259        );
260    }
261
262    #[test]
263    fn an_empty_run_is_one_empty_row_of_full_height() {
264        let shaper = shaper();
265        let laid_out = shaper.layout("", &font(), WorldPx::UNBOUNDED);
266        assert_eq!(rows_of(&laid_out), vec![(String::new(), false)]);
267        assert!((laid_out.size.y - shaper.metrics.row_height(&font())).abs() < 1e-3);
268        assert_eq!(laid_out.size.x, 0.0);
269    }
270
271    #[test]
272    fn a_run_wider_than_the_wrap_breaks_at_a_space() {
273        let shaper = shaper();
274        let wrap = width_of(&shaper, "the quick ");
275        let laid_out = shaper.layout("the quick brown fox", &font(), wrap);
276        let rows = rows_of(&laid_out);
277        assert!(
278            rows.len() > 1,
279            "a wrapped run takes more than one row: {rows:?}"
280        );
281        assert!(
282            rows.iter().all(|(_, ends)| !ends),
283            "no row of one paragraph closes it: {rows:?}"
284        );
285        let whole: String = rows.iter().map(|(text, _)| text.as_str()).collect();
286        assert_eq!(whole, "the quick brown fox", "wrapping drops nothing");
287        assert!(
288            laid_out.size.x <= wrap.get() + 1e-3,
289            "no row is wider than the wrap: {} vs {}",
290            laid_out.size.x,
291            wrap.get()
292        );
293    }
294
295    #[test]
296    fn a_word_wider_than_the_wrap_breaks_per_character() {
297        let shaper = shaper();
298        let wrap = width_of(&shaper, "iiii");
299        let laid_out = shaper.layout("mmmmmmmmmmmm", &font(), wrap);
300        let rows = rows_of(&laid_out);
301        assert!(
302            rows.len() > 2,
303            "a word with nowhere to break is cut up: {rows:?}"
304        );
305        let whole: String = rows.iter().map(|(text, _)| text.as_str()).collect();
306        assert_eq!(whole, "mmmmmmmmmmmm");
307    }
308
309    #[test]
310    fn wrapping_still_closes_the_paragraphs() {
311        let shaper = shaper();
312        let wrap = width_of(&shaper, "the quick ");
313        let laid_out = shaper.layout("the quick brown fox\nagain", &font(), wrap);
314        let rows = rows_of(&laid_out);
315        let closed: Vec<bool> = rows.iter().map(|(_, ends)| *ends).collect();
316        let last_of_the_first = rows.len() - 2;
317        assert!(
318            closed[last_of_the_first],
319            "the last row of the first paragraph closes it: {rows:?}"
320        );
321        assert_eq!(
322            closed.iter().filter(|ends| **ends).count(),
323            1,
324            "and it is the only one: {rows:?}"
325        );
326    }
327
328    #[test]
329    fn glyphs_march_rightwards_within_a_row() {
330        let shaper = shaper();
331        let wrap = width_of(&shaper, "waffle iron ");
332        let laid_out = shaper.layout(
333            "waffle iron for the office\nand a second paragraph",
334            &font(),
335            wrap,
336        );
337        assert!(laid_out.rows.len() > 2, "the run wrapped and broke");
338        for row in &laid_out.rows {
339            assert!(
340                row.glyphs
341                    .windows(2)
342                    .all(|pair| pair[0].pos.x <= pair[1].pos.x),
343                "glyph x never goes backwards: {:?}",
344                row.glyphs.iter().map(|g| g.pos.x).collect::<Vec<_>>()
345            );
346            assert!(
347                row.glyphs.first().is_none_or(|glyph| glyph.pos.x == 0.0),
348                "every row starts at its own left edge"
349            );
350        }
351    }
352
353    #[test]
354    fn a_ligature_is_one_glyph_and_a_continuation() {
355        // Roboto's `liga` shapes "fi" into a single glyph, which the layout
356        // reports as `'f'` plus a zero-advance `'i'`.
357        let shaper = shaper();
358        let laid_out = shaper.layout("Config", &font(), WorldPx::UNBOUNDED);
359        let glyphs = &laid_out.rows[0].glyphs;
360        assert_eq!(
361            glyphs.iter().map(|glyph| glyph.chr).collect::<String>(),
362            "Config",
363            "one glyph per character"
364        );
365        let heads = glyphs.iter().filter(|glyph| glyph.id.is_some()).count();
366        assert_eq!(heads, 5, "C o <fi> g is five glyphs: {glyphs:?}");
367        let continuation = glyphs
368            .iter()
369            .find(|glyph| glyph.id.is_none())
370            .expect("the ligature leaves a continuation");
371        assert_eq!(continuation.chr, 'i');
372        assert_eq!(continuation.advance, 0.0, "a continuation carries no pen");
373        assert!(
374            glyphs
375                .iter()
376                .all(|glyph| glyph.advance > 0.0 || glyph.id.is_none()),
377            "only a continuation stands still"
378        );
379    }
380
381    #[test]
382    fn a_face_that_substitutes_nothing_keeps_every_character_its_own_glyph() {
383        // Iosevka ships `calt` but no default-on code ligatures.
384        let shaper = Shaper::new(FontChoice::Monospace);
385        let laid_out = shaper.layout("a -> b", &font(), WorldPx::UNBOUNDED);
386        assert!(
387            laid_out.rows[0]
388                .glyphs
389                .iter()
390                .all(|glyph| glyph.id.is_some()),
391            "no continuations: {:?}",
392            laid_out.rows[0].glyphs
393        );
394    }
395
396    #[test]
397    fn a_run_laid_out_twice_comes_back_the_same() {
398        let shaper = shaper();
399        let wrap = WorldPx::new(40.0);
400        let text = "the quick brown fox\njumps";
401        let once = shaper.layout(text, &font(), wrap);
402        assert!(
403            shaper.laid_out.borrow().get(text, &font(), wrap).is_some(),
404            "the run was remembered"
405        );
406        let twice = shaper.layout(text, &font(), wrap);
407        // Every field of a layout is `Debug`, so this compares all of them.
408        assert_eq!(format!("{once:?}"), format!("{twice:?}"));
409    }
410
411    #[test]
412    fn a_layout_is_filed_under_its_size_and_its_wrap() {
413        let shaper = shaper();
414        let text = "the quick brown fox";
415        let small = shaper.layout(text, &Font::canvas(8.0), WorldPx::UNBOUNDED);
416        let large = shaper.layout(text, &Font::canvas(16.0), WorldPx::UNBOUNDED);
417        assert!(
418            large.size.x > small.size.x,
419            "a bigger font is not served the smaller layout"
420        );
421        let wrapped = shaper.layout(text, &font(), width_of(&shaper, "the quick "));
422        let unwrapped = shaper.layout(text, &font(), WorldPx::UNBOUNDED);
423        assert!(
424            wrapped.rows.len() > unwrapped.rows.len(),
425            "a wrap is not served the unwrapped layout"
426        );
427    }
428
429    #[test]
430    fn the_engine_answers_for_the_typeface_it_was_built_from() {
431        for choice in FontChoice::ALL {
432            let shaper = Shaper::new(choice);
433            assert_eq!(shaper.typeface(), choice);
434            assert!(
435                !shaper.glyph_ids("Ab").is_empty(),
436                "{} shapes something",
437                choice.display_name()
438            );
439        }
440    }
441}