Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(text): parley shaping, line layout, bidi runs

Lewis: May this revision serve well! <lu5a@proton.me>

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