1use 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
14const UNKNOWN: char = '\u{FFFD}';
16
17pub struct Shaper {
21 typeface: FontChoice,
22 metrics: Metrics,
23 font: skrifa::FontRef<'static>,
24 data: harfrust::ShaperData,
27 laid_out: RefCell<LayoutCache>,
28}
29
30impl Shaper {
31 #[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 #[must_use]
51 pub fn metrics(&self) -> Metrics {
52 self.metrics
53 }
54
55 #[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 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
103struct Shaped {
105 id: GlyphId,
106 cluster: usize,
108 advance: f32,
109}
110
111fn 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
123struct Placing<'a> {
125 text: &'a str,
126 scale: f32,
127 ascent: f32,
128 pen: f32,
129 placed: Vec<Glyph>,
130 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 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 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 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 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 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 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}