Another project
1use core::ops::Range;
2use std::sync::Arc;
3
4use parley::fontique::{Blob, Collection, CollectionOptions, SourceCache};
5use parley::{
6 Alignment, AlignmentOptions, FontContext, FontFamily, GlyphRun, Layout, LayoutContext,
7 LineHeight as ParleyLineHeight, PositionedLayoutItem, StyleProperty,
8};
9
10use crate::fonts::{MONO_DATA, MONO_FAMILY, SANS_DATA, SANS_FAMILY, family_for, parley_weight};
11use crate::{FontFace, FontWeight};
12
13#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
14pub struct GlyphId(u32);
15
16impl GlyphId {
17 #[must_use]
18 pub const fn new(value: u32) -> Self {
19 Self(value)
20 }
21
22 #[must_use]
23 pub const fn raw(self) -> u32 {
24 self.0
25 }
26}
27
28#[derive(
29 Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
30)]
31#[serde(transparent)]
32pub struct SourceByteIndex(usize);
33
34impl SourceByteIndex {
35 #[must_use]
36 pub const fn new(value: usize) -> Self {
37 Self(value)
38 }
39
40 #[must_use]
41 pub const fn value(self) -> usize {
42 self.0
43 }
44}
45
46#[derive(Copy, Clone, Debug, PartialEq, PartialOrd)]
47pub struct MaxWidth(f32);
48
49impl MaxWidth {
50 #[must_use]
51 pub fn new(width_px: f32) -> Option<Self> {
52 if width_px > 0.0 && width_px.is_finite() {
53 Some(Self(width_px))
54 } else {
55 None
56 }
57 }
58
59 #[must_use]
60 pub const fn px(self) -> f32 {
61 self.0
62 }
63}
64
65#[derive(Copy, Clone, Debug, PartialEq)]
66pub struct ShapeRequest {
67 pub face: FontFace,
68 pub size_px: f32,
69 pub weight: FontWeight,
70 pub line_height_px: f32,
71 pub letter_spacing_px: f32,
72 pub max_width: Option<MaxWidth>,
73}
74
75#[derive(Clone, Debug, PartialEq)]
76pub struct ShapedText {
77 pub face: FontFace,
78 pub font_size_px: f32,
79 pub lines: Vec<ShapedLine>,
80 pub width_px: f32,
81 pub height_px: f32,
82}
83
84#[derive(Clone, Debug, PartialEq)]
85pub struct ShapedLine {
86 pub runs: Vec<ShapedRun>,
87 pub baseline_px: f32,
88 pub ascent_px: f32,
89 pub descent_px: f32,
90 pub advance_px: f32,
91 pub trailing_whitespace_px: f32,
92 pub source_range: Range<SourceByteIndex>,
93}
94
95impl ShapedLine {
96 #[must_use]
97 pub fn visible_advance_px(&self) -> f32 {
98 (self.advance_px - self.trailing_whitespace_px).max(0.0)
99 }
100}
101
102#[derive(Clone, Debug, PartialEq)]
103pub struct ShapedRun {
104 pub glyphs: Vec<ShapedGlyph>,
105 pub origin_x_px: f32,
106 pub advance_px: f32,
107 pub is_rtl: bool,
108 pub source_range: Range<SourceByteIndex>,
109}
110
111#[derive(Copy, Clone, Debug, PartialEq)]
112pub struct ShapedGlyph {
113 pub id: GlyphId,
114 pub x_px: f32,
115 pub y_px: f32,
116 pub advance_px: f32,
117 pub cluster: SourceByteIndex,
118}
119
120pub struct Shaper {
121 fonts: FontContext,
122 layout: LayoutContext<()>,
123}
124
125impl Shaper {
126 #[must_use]
127 pub fn new() -> Self {
128 let mut collection = Collection::new(CollectionOptions {
129 shared: false,
130 system_fonts: false,
131 });
132 register(&mut collection, SANS_DATA, SANS_FAMILY);
133 register(&mut collection, MONO_DATA, MONO_FAMILY);
134 Self {
135 fonts: FontContext {
136 collection,
137 source_cache: SourceCache::default(),
138 },
139 layout: LayoutContext::new(),
140 }
141 }
142
143 pub fn shape(&mut self, text: &str, request: ShapeRequest) -> ShapedText {
144 let family = family_for(request.face);
145 let max_advance = request.max_width.map(MaxWidth::px);
146 let mut builder = self.layout.ranged_builder(&mut self.fonts, text, 1.0, true);
147 builder.push_default(StyleProperty::FontFamily(FontFamily::named(family)));
148 builder.push_default(StyleProperty::FontSize(request.size_px));
149 builder.push_default(StyleProperty::FontWeight(parley_weight(request.weight)));
150 builder.push_default(StyleProperty::Brush(()));
151 if request.line_height_px > 0.0 {
152 builder.push_default(StyleProperty::LineHeight(ParleyLineHeight::Absolute(
153 request.line_height_px,
154 )));
155 }
156 if request.letter_spacing_px != 0.0 {
157 builder.push_default(StyleProperty::LetterSpacing(request.letter_spacing_px));
158 }
159 let mut layout: Layout<()> = builder.build(text);
160 layout.break_all_lines(max_advance);
161 layout.align(Alignment::Start, AlignmentOptions::default());
162 ShapedText::from_layout(request.face, request.size_px, &layout)
163 }
164}
165
166impl Default for Shaper {
167 fn default() -> Self {
168 Self::new()
169 }
170}
171
172impl core::fmt::Debug for Shaper {
173 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
174 f.debug_struct("Shaper").finish_non_exhaustive()
175 }
176}
177
178impl ShapedText {
179 fn from_layout(face: FontFace, font_size_px: f32, layout: &Layout<()>) -> Self {
180 let lines = layout.lines().map(ShapedLine::from_line).collect();
181 Self {
182 face,
183 font_size_px,
184 lines,
185 width_px: layout.width().max(0.0),
186 height_px: layout.height().max(0.0),
187 }
188 }
189
190 #[must_use]
191 pub fn line_count(&self) -> usize {
192 self.lines.len()
193 }
194
195 #[must_use]
196 pub fn glyph_count(&self) -> usize {
197 self.lines
198 .iter()
199 .flat_map(|line| line.runs.iter())
200 .map(|run| run.glyphs.len())
201 .sum()
202 }
203}
204
205impl ShapedLine {
206 fn from_line(line: parley::Line<'_, ()>) -> Self {
207 let metrics = line.metrics();
208 let runs = line
209 .items()
210 .filter_map(|item| match item {
211 PositionedLayoutItem::GlyphRun(run) => Some(ShapedRun::from_glyph_run(&run)),
212 PositionedLayoutItem::InlineBox(_) => None,
213 })
214 .collect();
215 Self {
216 runs,
217 baseline_px: metrics.baseline.max(0.0),
218 ascent_px: metrics.ascent.max(0.0),
219 descent_px: metrics.descent.max(0.0),
220 advance_px: metrics.advance.max(0.0),
221 trailing_whitespace_px: metrics.trailing_whitespace.max(0.0),
222 source_range: byte_range(line.text_range()),
223 }
224 }
225}
226
227impl ShapedRun {
228 fn from_glyph_run(run: &GlyphRun<'_, ()>) -> Self {
229 let parley_run = run.run();
230 let glyphs = parley_run
231 .visual_clusters()
232 .flat_map(|cluster| {
233 let byte = SourceByteIndex::new(cluster.text_range().start);
234 cluster.glyphs().map(move |g| (byte, g))
235 })
236 .scan(0.0_f32, |cursor, (byte, g)| {
237 let item = ShapedGlyph {
238 id: GlyphId::new(g.id),
239 x_px: *cursor + g.x,
240 y_px: g.y,
241 advance_px: g.advance.max(0.0),
242 cluster: byte,
243 };
244 *cursor += g.advance;
245 Some(item)
246 })
247 .collect();
248 Self {
249 glyphs,
250 origin_x_px: run.offset(),
251 advance_px: parley_run.advance().max(0.0),
252 is_rtl: parley_run.is_rtl(),
253 source_range: byte_range(parley_run.text_range()),
254 }
255 }
256}
257
258fn byte_range(r: Range<usize>) -> Range<SourceByteIndex> {
259 SourceByteIndex::new(r.start)..SourceByteIndex::new(r.end)
260}
261
262fn register(collection: &mut Collection, data: &'static [u8], family: &'static str) {
263 let blob = Blob::new(Arc::new(data));
264 let registered = collection.register_fonts(blob, None);
265 assert!(
266 !registered.is_empty(),
267 "bundled font `{family}` failed to register; binary asset is broken",
268 );
269}
270
271#[cfg(test)]
272mod tests {
273 use super::{
274 FontFace, FontWeight, MaxWidth, ShapeRequest, ShapedText, Shaper, SourceByteIndex,
275 };
276
277 fn req(face: FontFace, size_px: f32, max_width: Option<MaxWidth>) -> ShapeRequest {
278 ShapeRequest {
279 face,
280 size_px,
281 weight: FontWeight::Regular,
282 line_height_px: 0.0,
283 letter_spacing_px: 0.0,
284 max_width,
285 }
286 }
287
288 fn shape(text: &str, request: ShapeRequest) -> ShapedText {
289 Shaper::new().shape(text, request)
290 }
291
292 #[test]
293 fn max_width_rejects_zero_and_negative_and_non_finite() {
294 assert!(MaxWidth::new(0.0).is_none());
295 assert!(MaxWidth::new(-1.0).is_none());
296 assert!(MaxWidth::new(f32::NAN).is_none());
297 assert!(MaxWidth::new(f32::INFINITY).is_none());
298 assert!(MaxWidth::new(0.5).is_some());
299 }
300
301 #[test]
302 fn empty_text_yields_one_empty_line_with_positive_height() {
303 let out = shape("", req(FontFace::Sans, 13.0, None));
304 assert_eq!(out.line_count(), 1);
305 assert_eq!(out.glyph_count(), 0);
306 assert!(out.lines[0].runs.is_empty());
307 assert!(out.height_px > 0.0);
308 assert!(out.width_px.abs() < f32::EPSILON);
309 }
310
311 #[test]
312 fn shaped_text_carries_request_face_and_size() {
313 let out = shape("hi", req(FontFace::Mono, 15.0, None));
314 assert_eq!(out.face, FontFace::Mono);
315 assert!((out.font_size_px - 15.0).abs() < 0.01);
316 }
317
318 #[test]
319 fn ascii_run_emits_one_glyph_per_char() {
320 let text = "Sketch";
321 let out = shape(text, req(FontFace::Sans, 13.0, None));
322 assert_eq!(out.line_count(), 1);
323 assert_eq!(out.glyph_count(), text.chars().count());
324 let line = &out.lines[0];
325 assert_eq!(
326 line.source_range,
327 SourceByteIndex::new(0)..SourceByteIndex::new(text.len()),
328 );
329 assert_eq!(line.runs.len(), 1);
330 assert!(!line.runs[0].is_rtl);
331 assert!(out.width_px > 0.0);
332 }
333
334 #[test]
335 fn ascii_clusters_track_byte_offsets_left_to_right() {
336 let out = shape("AB", req(FontFace::Sans, 13.0, None));
337 let glyphs = &out.lines[0].runs[0].glyphs;
338 assert_eq!(glyphs.len(), 2);
339 assert_eq!(glyphs[0].cluster, SourceByteIndex::new(0));
340 assert_eq!(glyphs[1].cluster, SourceByteIndex::new(1));
341 assert!(glyphs[1].x_px > glyphs[0].x_px);
342 }
343
344 #[test]
345 fn line_metrics_expose_positive_ascent_and_descent() {
346 let out = shape("Ag", req(FontFace::Sans, 13.0, None));
347 let line = &out.lines[0];
348 assert!(line.ascent_px > 0.0);
349 assert!(line.descent_px > 0.0);
350 assert!(line.baseline_px > 0.0);
351 }
352
353 #[test]
354 fn glyph_y_offsets_lie_within_line_metrics() {
355 let out = shape("Ag", req(FontFace::Sans, 13.0, None));
356 let line = &out.lines[0];
357 let max_dy = line.descent_px + 0.5;
358 let min_dy = -line.ascent_px - 0.5;
359 line.runs
360 .iter()
361 .flat_map(|r| r.glyphs.iter())
362 .for_each(|glyph| {
363 let dy = glyph.y_px;
364 assert!(
365 dy >= min_dy && dy <= max_dy,
366 "glyph dy {dy} outside [{min_dy}, {max_dy}]",
367 );
368 });
369 }
370
371 #[test]
372 fn arabic_run_marks_rtl() {
373 let out = shape("مرحبا", req(FontFace::Sans, 13.0, None));
374 assert_eq!(out.line_count(), 1);
375 let runs = &out.lines[0].runs;
376 assert!(!runs.is_empty());
377 assert!(runs.iter().all(|r| r.is_rtl));
378 assert!(runs.iter().any(|r| !r.glyphs.is_empty()));
379 }
380
381 #[test]
382 fn mixed_direction_runs_partition_source_bytes_without_overlap() {
383 let text = "abc مرحبا def";
384 let out = shape(text, req(FontFace::Sans, 13.0, None));
385 assert_eq!(out.line_count(), 1);
386 let runs = &out.lines[0].runs;
387 assert!(runs.iter().any(|r| !r.is_rtl), "expected an LTR run");
388 assert!(runs.iter().any(|r| r.is_rtl), "expected an RTL run");
389 let mut spans: Vec<_> = runs
390 .iter()
391 .map(|r| (r.source_range.start.value(), r.source_range.end.value()))
392 .collect();
393 spans.sort_unstable();
394 assert_eq!(spans.first().map(|s| s.0), Some(0));
395 assert_eq!(spans.last().map(|s| s.1), Some(text.len()));
396 spans.windows(2).for_each(|pair| {
397 let (_, prev_end) = pair[0];
398 let (next_start, _) = pair[1];
399 assert!(
400 next_start >= prev_end,
401 "runs overlap: prev end {prev_end} > next start {next_start}",
402 );
403 });
404 }
405
406 #[test]
407 fn multi_glyph_cluster_keeps_shared_source_byte() {
408 let text = "i\u{0307}\u{0301}";
409 let out = shape(text, req(FontFace::Sans, 13.0, None));
410 let glyphs = &out.lines[0].runs[0].glyphs;
411 assert!(
412 glyphs.len() >= 2,
413 "expected multi-glyph cluster, got {}",
414 glyphs.len(),
415 );
416 assert!(glyphs.iter().all(|g| g.cluster == SourceByteIndex::new(0)));
417 }
418
419 #[test]
420 fn precomposed_combining_mark_collapses_to_single_glyph() {
421 let out = shape("e\u{0301}", req(FontFace::Sans, 13.0, None));
422 let glyphs = &out.lines[0].runs[0].glyphs;
423 assert_eq!(glyphs.len(), 1);
424 assert_eq!(glyphs[0].cluster, SourceByteIndex::new(0));
425 }
426
427 #[test]
428 fn long_text_wraps_when_max_width_set() {
429 let text = "the quick brown fox jumps over the lazy dog";
430 let single = shape(text, req(FontFace::Sans, 13.0, None));
431 assert_eq!(single.line_count(), 1);
432 let cap = MaxWidth::new(60.0);
433 let wrapped = shape(text, req(FontFace::Sans, 13.0, cap));
434 assert!(
435 wrapped.line_count() >= 2,
436 "expected wrap into multiple lines, got {}",
437 wrapped.line_count(),
438 );
439 let total: usize = wrapped
440 .lines
441 .iter()
442 .flat_map(|l| l.runs.iter())
443 .map(|r| r.glyphs.len())
444 .sum();
445 assert_eq!(total, single.glyph_count());
446 }
447
448 #[test]
449 fn trailing_whitespace_surfaces_on_line() {
450 let bare = shape("abc", req(FontFace::Mono, 13.0, None));
451 let trailed = shape("abc ", req(FontFace::Mono, 13.0, None));
452 let bare_line = &bare.lines[0];
453 let trailed_line = &trailed.lines[0];
454 assert!(bare_line.trailing_whitespace_px < 0.5);
455 assert!(trailed_line.trailing_whitespace_px > bare_line.trailing_whitespace_px);
456 assert!(trailed_line.advance_px > trailed_line.visible_advance_px());
457 assert!(
458 (trailed_line.visible_advance_px() - bare_line.advance_px).abs() < 0.5,
459 "visible advance with trailing spaces should match bare advance",
460 );
461 }
462
463 #[test]
464 fn explicit_newline_breaks_into_separate_lines() {
465 let out = shape("a\nb", req(FontFace::Sans, 13.0, None));
466 assert_eq!(out.line_count(), 2);
467 assert!(
468 out.lines
469 .iter()
470 .all(|l| l.runs.iter().any(|r| !r.glyphs.is_empty()))
471 );
472 }
473
474 #[test]
475 fn tab_character_emits_one_glyph_per_codepoint() {
476 let out = shape("a\tb", req(FontFace::Mono, 13.0, None));
477 assert_eq!(out.line_count(), 1);
478 assert_eq!(out.glyph_count(), 3);
479 }
480
481 #[test]
482 fn mono_face_advances_uniformly() {
483 let out = shape("iWiW", req(FontFace::Mono, 12.0, None));
484 let glyphs = &out.lines[0].runs[0].glyphs;
485 assert_eq!(glyphs.len(), 4);
486 let advances: Vec<f32> = glyphs.iter().map(|g| g.advance_px).collect();
487 let first = advances[0];
488 assert!(
489 advances.iter().all(|a| (a - first).abs() < 1e-3),
490 "monospace advances should match: {advances:?}",
491 );
492 }
493
494 #[test]
495 fn sans_collapses_fi_ligature_mono_does_not() {
496 let sans = shape("fi", req(FontFace::Sans, 13.0, None));
497 let mono = shape("fi", req(FontFace::Mono, 13.0, None));
498 assert_eq!(sans.glyph_count(), 1, "DejaVu Sans should ligate fi");
499 assert_eq!(mono.glyph_count(), 2, "DejaVu Sans Mono should not ligate");
500 }
501
502 #[test]
503 fn trailing_newline_emits_empty_terminal_line() {
504 let out = shape("a\n", req(FontFace::Sans, 13.0, None));
505 assert_eq!(out.line_count(), 2);
506 assert_eq!(out.glyph_count(), 1);
507 assert!(out.lines[1].runs.is_empty());
508 }
509
510 #[test]
511 fn sub_glyph_max_width_does_not_split_a_glyph() {
512 let cap = MaxWidth::new(0.5);
513 assert!(cap.is_some());
514 let out = shape("abc", req(FontFace::Sans, 13.0, cap));
515 assert_eq!(out.line_count(), 1);
516 assert_eq!(out.glyph_count(), 3);
517 }
518
519 #[test]
520 fn explicit_line_height_drives_line_advance() {
521 let mut request = req(FontFace::Sans, 13.0, None);
522 let baseline = shape("a", request);
523 request.line_height_px = 48.0;
524 let tall = shape("a", request);
525 assert!(
526 tall.height_px > baseline.height_px + 20.0,
527 "tall line height must stretch bounds: baseline {} vs tall {}",
528 baseline.height_px,
529 tall.height_px,
530 );
531 }
532
533 #[test]
534 fn positive_letter_spacing_widens_advance() {
535 let bare = req(FontFace::Sans, 13.0, None);
536 let mut spaced = bare;
537 spaced.letter_spacing_px = 4.0;
538 let bare_out = shape("abc", bare);
539 let spaced_out = shape("abc", spaced);
540 assert!(
541 spaced_out.width_px > bare_out.width_px + 6.0,
542 "letter spacing must widen advance: bare {} vs spaced {}",
543 bare_out.width_px,
544 spaced_out.width_px,
545 );
546 }
547
548 #[test]
549 fn rtl_run_emits_glyphs_visual_left_to_right_with_descending_source_bytes() {
550 let out = shape("مرحبا", req(FontFace::Sans, 13.0, None));
551 let line = &out.lines[0];
552 assert_eq!(line.runs.len(), 1);
553 let run = &line.runs[0];
554 assert!(run.is_rtl);
555 assert!(run.glyphs.len() >= 2);
556 let dxs: Vec<f32> = run.glyphs.iter().map(|g| g.x_px).collect();
557 dxs.windows(2)
558 .for_each(|p| assert!(p[1] >= p[0], "non-monotonic visual dx: {dxs:?}"));
559 let bytes: Vec<usize> = run.glyphs.iter().map(|g| g.cluster.value()).collect();
560 let last_idx = bytes.len() - 1;
561 assert!(
562 bytes[0] > bytes[last_idx],
563 "expected RTL visual-leftmost glyph to map to later source byte: {bytes:?}",
564 );
565 }
566}