1use oxitext_core::{
6 DecorationRect, PositionedGlyph, ShapedGlyph, ShapedRun, TextAlignment, TextDecoration,
7};
8use std::sync::Arc;
9
10use super::types::{LayoutResult, Line};
11
12pub(super) fn compute_decoration_rects(
19 lines: &[Line],
20 glyphs: &[PositionedGlyph],
21 decoration: TextDecoration,
22) -> Vec<DecorationRect> {
23 let mut out = Vec::with_capacity(lines.len());
24 for line in lines {
25 let gs = line.glyph_start;
26 let ge = line.glyph_end.min(glyphs.len());
27 if gs >= ge {
28 continue;
29 }
30 let x_start = glyphs[gs].pos.0;
31 let last = &glyphs[ge - 1];
32 let x_end = last.pos.0 + last.advance_x;
33 let width = (x_end - x_start).max(0.0);
34 if width == 0.0 {
35 continue;
36 }
37 let baseline_y = line.metrics.baseline_y;
38 let ascent = line.metrics.ascent;
39 let rect = match decoration {
40 TextDecoration::Underline {
41 color,
42 thickness,
43 offset,
44 } => DecorationRect {
45 x: x_start,
46 y: baseline_y + offset,
47 width,
48 height: thickness,
49 color,
50 },
51 TextDecoration::Overline {
52 color,
53 thickness,
54 offset,
55 } => DecorationRect {
56 x: x_start,
57 y: baseline_y - ascent - offset,
58 width,
59 height: thickness,
60 color,
61 },
62 TextDecoration::Strikethrough { color, thickness } => DecorationRect {
63 x: x_start,
64 y: baseline_y - ascent * 0.5,
65 width,
66 height: thickness,
67 color,
68 },
69 };
70 out.push(rect);
71 }
72 out
73}
74
75pub(super) fn is_hanging_punctuation(c: char) -> bool {
82 matches!(
83 c,
84 '\u{3001}'
85 | '\u{3002}'
86 | '\u{FF01}'
87 | '\u{FF02}'
88 | '\u{FF0C}'
89 | '\u{FF0E}'
90 | '\u{FF1A}'
91 | '\u{FF1B}'
92 | '\u{FF1F}'
93 )
94}
95pub(super) fn apply_hanging_punctuation(result: &mut LayoutResult, source_text: &str) {
106 for line in &result.lines {
107 let gs = line.glyph_start;
108 let ge = line.glyph_end;
109 if gs >= ge {
110 continue;
111 }
112 let last_gi = ge - 1;
113 {
114 let cluster_off = result.glyphs[last_gi].cluster as usize;
115 let ch = source_text
116 .get(cluster_off..)
117 .and_then(|s| s.chars().next());
118 if let Some(c) = ch {
119 if is_hanging_punctuation(c) {
120 let half_adv = result.glyphs[last_gi].advance_x * 0.5;
121 result.glyphs[last_gi].pos.0 += half_adv;
122 }
123 }
124 }
125 {
126 let cluster_off = result.glyphs[gs].cluster as usize;
127 let ch = source_text
128 .get(cluster_off..)
129 .and_then(|s| s.chars().next());
130 if let Some(c) = ch {
131 if is_hanging_punctuation(c) {
132 let half_adv = result.glyphs[gs].advance_x * 0.5;
133 result.glyphs[gs].pos.0 -= half_adv;
134 }
135 }
136 }
137 }
138}
139pub(super) fn build_ranges_from_kp_breaks(
144 kp_breaks: &[usize],
145 flat_len: usize,
146 line_ranges: &mut Vec<(usize, usize)>,
147) {
148 if flat_len == 0 {
149 line_ranges.push((0, 0));
150 return;
151 }
152 let mut prev = 0usize;
153 for &bp in kp_breaks {
154 if bp > prev {
155 line_ranges.push((prev, bp));
156 }
157 prev = bp;
158 }
159 if prev <= flat_len {
160 line_ranges.push((prev, flat_len));
161 }
162}
163pub(super) fn count_internal_ws_gaps<'a>(glyphs: impl Iterator<Item = &'a ShapedGlyph>) -> usize {
167 let collected: Vec<&ShapedGlyph> = glyphs.collect();
168 let first_vis = collected.iter().position(|g| !g.is_whitespace);
169 let last_vis = collected.iter().rposition(|g| !g.is_whitespace);
170 match (first_vis, last_vis) {
171 (Some(f), Some(l)) if l > f => collected[f..=l].iter().filter(|g| g.is_whitespace).count(),
172 _ => 0,
173 }
174}
175pub(super) fn compute_alignment(
179 alignment: TextAlignment,
180 line_width: f32,
181 max_width: f32,
182 wrap: bool,
183 is_last_line: bool,
184 internal_ws_gaps: usize,
185) -> (f32, f32) {
186 if !wrap || max_width <= 0.0 {
187 return (0.0, 0.0);
188 }
189 let slack = (max_width - line_width).max(0.0);
190 match alignment {
191 TextAlignment::Left => (0.0, 0.0),
192 TextAlignment::Right => (slack, 0.0),
193 TextAlignment::Center => (slack * 0.5, 0.0),
194 TextAlignment::Justify => {
195 if is_last_line || internal_ws_gaps == 0 || slack <= 0.0 {
196 (0.0, 0.0)
197 } else {
198 (0.0, slack / internal_ws_gaps as f32)
199 }
200 }
201 }
202}
203pub(super) fn apply_truncation(
212 mut result: LayoutResult,
213 trunc: &crate::options::TruncationMode,
214) -> LayoutResult {
215 let last_line_idx = match result.lines.len().checked_sub(1) {
216 Some(i) => i,
217 None => return result,
218 };
219 let line = &result.lines[last_line_idx];
220 let gs = line.glyph_start;
221 let ge = line.glyph_end;
222 if gs >= ge {
223 return result;
224 }
225 let total_advance = {
226 let first_x = result.glyphs[gs].pos.0;
227 let mut last_x = first_x;
228 let mut last_adv = 0.0f32;
229 for gi in gs..ge {
230 if gi + 1 < ge {
231 last_adv = result.glyphs[gi + 1].pos.0 - result.glyphs[gi].pos.0;
232 }
233 last_x = result.glyphs[gi].pos.0;
234 }
235 (last_x - first_x) + last_adv.max(0.0)
236 };
237 if total_advance <= trunc.max_width {
238 return result;
239 }
240 let ellipsis_adv = trunc.ellipsis_advance;
241 let mut keep_end = ge;
242 while keep_end > gs {
243 let kept_advance = if keep_end > gs {
244 let kgs = gs;
245 let kge = keep_end;
246 let first_x = result.glyphs[kgs].pos.0;
247 let mut last_x = first_x;
248 let mut last_a = 0.0f32;
249 for gi in kgs..kge {
250 if gi + 1 < kge {
251 last_a = result.glyphs[gi + 1].pos.0 - result.glyphs[gi].pos.0;
252 }
253 last_x = result.glyphs[gi].pos.0;
254 }
255 (last_x - first_x) + last_a.max(0.0)
256 } else {
257 0.0
258 };
259 if kept_advance + ellipsis_adv <= trunc.max_width {
260 break;
261 }
262 keep_end -= 1;
263 }
264 let ellipsis_x = if keep_end > gs {
265 let last_kept = &result.glyphs[keep_end - 1];
266 let adv = if keep_end < ge {
267 result.glyphs[keep_end].pos.0 - last_kept.pos.0
268 } else {
269 0.0
270 };
271 last_kept.pos.0 + adv.max(0.0)
272 } else if gs < result.glyphs.len() {
273 result.glyphs[gs].pos.0
274 } else {
275 0.0
276 };
277 let ellipsis_y = result.glyphs[gs].pos.1;
278 let line_font_size = result.glyphs[gs].font_size;
279 let ellipsis_font = Arc::clone(&result.glyphs[gs].font_data);
280 result.glyphs.truncate(keep_end);
281 result.glyphs.push(PositionedGlyph {
282 gid: trunc.ellipsis_glyph_id,
283 font_data: ellipsis_font,
284 pos: (ellipsis_x, ellipsis_y),
285 font_size: line_font_size,
286 advance_x: ellipsis_adv,
287 cluster: u32::MAX,
288 });
289 result.lines[last_line_idx].glyph_end = result.glyphs.len();
290 result.metrics.truncated = true;
291 let new_width: f32 = result
292 .lines
293 .iter()
294 .map(|l| l.metrics.width)
295 .fold(0.0_f32, f32::max);
296 result.metrics.total_width = new_width.max(ellipsis_x + ellipsis_adv);
297 result
298}
299pub(super) fn find_cluster_for_positioned_glyph(
308 line_local_idx: usize,
309 runs: &[ShapedRun],
310 _line_glyph_start: usize,
311) -> Option<usize> {
312 let mut count = 0usize;
313 for run in runs {
314 for g in &run.glyphs {
315 if count == line_local_idx {
316 return Some(g.cluster as usize);
317 }
318 count += 1;
319 }
320 }
321 None
322}
323pub(super) fn advance_for_glyph(
326 line_local_idx: usize,
327 runs: &[ShapedRun],
328 _line_glyph_start: usize,
329) -> f32 {
330 let mut count = 0usize;
331 for run in runs {
332 for g in &run.glyphs {
333 if count == line_local_idx {
334 return g.x_advance;
335 }
336 count += 1;
337 }
338 }
339 0.0
340}
341#[cfg(test)]
342mod tests {
343 use super::super::types::{
344 BreakingStrategy, LayoutEngine, LayoutResult, Line, LineMetrics, ParagraphMetrics,
345 };
346 use super::*;
347 use oxitext_core::{
348 FontVerticalMetrics, LayoutConstraints, ShapedGlyph, ShapedRun, TextAlignment,
349 };
350 use std::sync::Arc;
351 fn run_from_text(text: &str, adv: f32) -> ShapedRun {
355 let mut glyphs = Vec::new();
356 for (byte_idx, ch) in text.char_indices() {
357 glyphs.push(ShapedGlyph {
358 gid: 1,
359 x_advance: adv,
360 cluster: byte_idx as u32,
361 is_whitespace: ch.is_whitespace(),
362 ..Default::default()
363 });
364 }
365 ShapedRun {
366 glyphs: glyphs.into(),
367 font_data: Arc::from(&[][..]),
368 }
369 }
370 #[test]
371 fn single_line_when_fits() {
372 let text = "hello world";
373 let run = run_from_text(text, 10.0);
374 let c = LayoutConstraints {
375 max_width: 1000.0,
376 font_size: 16.0,
377 };
378 let mut engine = LayoutEngine::new();
379 let res = engine
380 .layout(text, &[run], &c, TextAlignment::Left, None)
381 .expect("layout");
382 assert_eq!(res.lines.len(), 1, "everything fits on one line");
383 assert_eq!(res.glyphs.len(), text.chars().count());
384 }
385 #[test]
386 fn wraps_at_space_not_mid_word() {
387 let text = "hello world";
388 let run = run_from_text(text, 10.0);
389 let c = LayoutConstraints {
390 max_width: 70.0,
391 font_size: 16.0,
392 };
393 let mut engine = LayoutEngine::new();
394 let res = engine
395 .layout(text, &[run], &c, TextAlignment::Left, None)
396 .expect("layout");
397 assert_eq!(res.lines.len(), 2, "should wrap into two lines");
398 let first = &res.lines[0];
399 assert!(first.len() >= 5, "first line keeps the whole word 'hello'");
400 let second_first = &res.glyphs[res.lines[1].glyph_start];
401 assert!(
402 (second_first.pos.0 - 0.0).abs() < 1e-3,
403 "wrapped line starts at x=0"
404 );
405 }
406 #[test]
407 fn mandatory_break_on_newline() {
408 let text = "a\nb";
409 let run = run_from_text(text, 10.0);
410 let c = LayoutConstraints {
411 max_width: 1000.0,
412 font_size: 16.0,
413 };
414 let mut engine = LayoutEngine::new();
415 let res = engine
416 .layout(text, &[run], &c, TextAlignment::Left, None)
417 .expect("layout");
418 assert_eq!(res.lines.len(), 2, "newline forces a second line");
419 }
420 #[test]
421 fn center_alignment_offsets_line() {
422 let text = "ab";
423 let run = run_from_text(text, 10.0);
424 let c = LayoutConstraints {
425 max_width: 100.0,
426 font_size: 16.0,
427 };
428 let mut engine = LayoutEngine::new();
429 let res = engine
430 .layout(text, &[run], &c, TextAlignment::Center, None)
431 .expect("layout");
432 let first = &res.glyphs[0];
433 assert!(
434 (first.pos.0 - 40.0).abs() < 1e-3,
435 "centered start x should be 40, got {}",
436 first.pos.0
437 );
438 }
439 #[test]
440 fn right_alignment_offsets_line() {
441 let text = "ab";
442 let run = run_from_text(text, 10.0);
443 let c = LayoutConstraints {
444 max_width: 100.0,
445 font_size: 16.0,
446 };
447 let mut engine = LayoutEngine::new();
448 let res = engine
449 .layout(text, &[run], &c, TextAlignment::Right, None)
450 .expect("layout");
451 let first = &res.glyphs[0];
452 assert!(
453 (first.pos.0 - 80.0).abs() < 1e-3,
454 "right start x should be 80, got {}",
455 first.pos.0
456 );
457 }
458 #[test]
459 fn baselines_increase_per_line() {
460 let text = "a\nb\nc";
461 let run = run_from_text(text, 10.0);
462 let c = LayoutConstraints {
463 max_width: 1000.0,
464 font_size: 16.0,
465 };
466 let mut engine = LayoutEngine::new();
467 let res = engine
468 .layout(text, &[run], &c, TextAlignment::Left, None)
469 .expect("layout");
470 assert_eq!(res.lines.len(), 3);
471 assert!(res.lines[1].metrics.baseline_y > res.lines[0].metrics.baseline_y);
472 assert!(res.lines[2].metrics.baseline_y > res.lines[1].metrics.baseline_y);
473 }
474 #[test]
475 fn font_metrics_drive_line_height() {
476 let text = "a\nb";
477 let run = run_from_text(text, 10.0);
478 let c = LayoutConstraints {
479 max_width: 1000.0,
480 font_size: 100.0,
481 };
482 let metrics = FontVerticalMetrics {
483 units_per_em: 1000,
484 ascender: 800,
485 descender: -200,
486 line_gap: 0,
487 };
488 let mut engine = LayoutEngine::new();
489 let res = engine
490 .layout(text, &[run], &c, TextAlignment::Left, Some(&metrics))
491 .expect("layout");
492 let dy = res.lines[1].metrics.baseline_y - res.lines[0].metrics.baseline_y;
493 assert!(
494 (dy - 100.0).abs() < 1e-3,
495 "line advance should equal 100, got {dy}"
496 );
497 }
498 #[test]
499 fn empty_text_yields_one_empty_line() {
500 let text = "";
501 let run = run_from_text(text, 10.0);
502 let c = LayoutConstraints::default();
503 let mut engine = LayoutEngine::new();
504 let res = engine
505 .layout(text, &[run], &c, TextAlignment::Left, None)
506 .expect("layout");
507 assert_eq!(res.glyphs.len(), 0);
508 assert_eq!(res.lines.len(), 1);
509 assert!(res.lines[0].is_empty());
510 }
511 #[test]
512 fn justify_expands_internal_gaps() {
513 let text = "a b c";
514 let run = run_from_text(text, 10.0);
515 let c = LayoutConstraints {
516 max_width: 100.0,
517 font_size: 16.0,
518 };
519 let mut engine = LayoutEngine::new();
520 let res = engine
521 .layout(text, &[run], &c, TextAlignment::Justify, None)
522 .expect("layout");
523 let g0 = &res.glyphs[0];
524 assert!((g0.pos.0 - 0.0).abs() < 1e-3);
525 }
526 #[test]
527 fn unbreakable_token_sets_overflow() {
528 let text = "aaaaaaaa";
529 let run = run_from_text(text, 20.0);
530 let c = LayoutConstraints {
531 max_width: 50.0,
532 font_size: 16.0,
533 };
534 let mut engine = LayoutEngine::new();
535 let res = engine
536 .layout(text, &[run], &c, TextAlignment::Left, None)
537 .expect("layout");
538 assert!(
539 res.metrics.overflow,
540 "expected overflow flag for unbreakable token"
541 );
542 assert!(res.lines.len() > 1, "long token hard-wraps across lines");
543 }
544 #[test]
545 fn bidi_hebrew_is_visually_reversed() {
546 let text = "AB\u{05D0}\u{05D1}";
547 let run = run_from_text(text, 10.0);
548 let c = LayoutConstraints {
549 max_width: 1000.0,
550 font_size: 16.0,
551 };
552 let mut engine = LayoutEngine::new();
553 let res = engine
554 .layout(text, &[run], &c, TextAlignment::Left, None)
555 .expect("layout");
556 assert_eq!(res.glyphs.len(), 4, "4 glyphs total");
557 assert_eq!(res.lines.len(), 1, "one line");
558 for (i, g) in res.glyphs.iter().enumerate() {
559 let expected_x = (i as f32) * 10.0;
560 assert!(
561 (g.pos.0 - expected_x).abs() < 1e-3,
562 "glyph {} x should be {}, got {}",
563 i,
564 expected_x,
565 g.pos.0
566 );
567 }
568 }
569 #[test]
570 fn bidi_ltr_regression() {
571 let text = "hello";
572 let run = run_from_text(text, 10.0);
573 let c = LayoutConstraints {
574 max_width: 1000.0,
575 font_size: 16.0,
576 };
577 let mut engine = LayoutEngine::new();
578 let res = engine
579 .layout(text, &[run], &c, TextAlignment::Left, None)
580 .expect("layout");
581 for (i, g) in res.glyphs.iter().enumerate() {
582 let expected_x = (i as f32) * 10.0;
583 assert!(
584 (g.pos.0 - expected_x).abs() < 1e-3,
585 "glyph {} x should be {}, got {}",
586 i,
587 expected_x,
588 g.pos.0
589 );
590 }
591 }
592 #[test]
593 fn kp_single_line_when_fits() {
594 let text = "hello world";
595 let run = run_from_text(text, 10.0);
596 let c = LayoutConstraints {
597 max_width: 1000.0,
598 font_size: 16.0,
599 };
600 let mut engine = LayoutEngine::new();
601 let res = engine
602 .layout_with_strategy(
603 text,
604 &[run],
605 &c,
606 TextAlignment::Left,
607 None,
608 BreakingStrategy::KnuthPlass,
609 )
610 .expect("layout");
611 assert_eq!(res.lines.len(), 1, "KP: everything fits on one line");
612 assert_eq!(res.glyphs.len(), text.chars().count());
613 }
614 #[test]
615 fn kp_wraps_long_text() {
616 let text = "aaa bb ccc d eeeee";
617 let run = run_from_text(text, 10.0);
618 let c = LayoutConstraints {
619 max_width: 60.0,
620 font_size: 16.0,
621 };
622 let mut engine = LayoutEngine::new();
623 let res = engine
624 .layout_with_strategy(
625 text,
626 &[run],
627 &c,
628 TextAlignment::Left,
629 None,
630 BreakingStrategy::KnuthPlass,
631 )
632 .expect("layout");
633 assert!(res.lines.len() > 1, "KP: must produce multiple lines");
634 assert_eq!(res.glyphs.len(), text.chars().count(), "all glyphs present");
635 }
636 #[test]
637 fn kp_mandatory_break_honoured() {
638 let text = "hello\nworld";
639 let run = run_from_text(text, 10.0);
640 let c = LayoutConstraints {
641 max_width: 1000.0,
642 font_size: 16.0,
643 };
644 let mut engine = LayoutEngine::new();
645 let res = engine
646 .layout_with_strategy(
647 text,
648 &[run],
649 &c,
650 TextAlignment::Left,
651 None,
652 BreakingStrategy::KnuthPlass,
653 )
654 .expect("layout");
655 assert_eq!(res.lines.len(), 2, "KP: newline forces a second line");
656 }
657 #[test]
658 fn vertical_layout_positions_glyphs_top_to_bottom() {
659 let text = "abc";
660 let run = run_from_text(text, 10.0);
661 let mut engine = LayoutEngine::new();
662 let res = engine
663 .layout_vertical(text, &[run], 0.0, 16.0, None)
664 .expect("vertical layout");
665 assert!(!res.glyphs.is_empty());
666 for w in res.glyphs.windows(2) {
667 assert!(
668 w[1].pos.1 >= w[0].pos.1,
669 "vertical y must increase: {} >= {}",
670 w[1].pos.1,
671 w[0].pos.1
672 );
673 }
674 }
675 #[test]
676 fn vertical_layout_column_break_on_max_height() {
677 let text = "abcde";
678 let run = run_from_text(text, 16.0);
679 let mut engine = LayoutEngine::new();
680 let res = engine
681 .layout_vertical(text, &[run], 48.0, 16.0, None)
682 .expect("vertical layout");
683 assert!(
684 res.lines.len() >= 2,
685 "expected >= 2 columns, got {}",
686 res.lines.len()
687 );
688 if res.lines.len() >= 2 {
689 let first_col_x = res.glyphs[res.lines[0].glyph_start].pos.0;
690 let second_col_x = res.glyphs[res.lines[1].glyph_start].pos.0;
691 assert!(
692 second_col_x > first_col_x,
693 "second column x ({}) must be > first column x ({})",
694 second_col_x,
695 first_col_x
696 );
697 }
698 }
699 #[test]
700 fn vertical_layout_metrics_have_positive_dimensions() {
701 let text = "hello";
702 let run = run_from_text(text, 10.0);
703 let mut engine = LayoutEngine::new();
704 let res = engine
705 .layout_vertical(text, &[run], 0.0, 16.0, None)
706 .expect("vertical layout");
707 assert!(
708 res.metrics.total_height > 0.0,
709 "total_height must be positive"
710 );
711 assert!(
712 res.metrics.total_width > 0.0,
713 "total_width must be positive"
714 );
715 }
716 #[test]
717 fn layout_with_tab_stops() {
718 let ts = crate::options::TabStops::with_interval(80.0);
719 assert!(
720 (ts.next_stop(10.0) - 80.0).abs() < 1.0,
721 "next stop from 10 should be 80"
722 );
723 assert!(
724 (ts.next_stop(0.0) - 80.0).abs() < 1.0,
725 "next stop from 0 should be 80"
726 );
727 assert!(
728 (ts.next_stop(80.0) - 160.0).abs() < 1.0,
729 "next stop from 80 should be 160"
730 );
731 }
732 #[test]
733 fn truncation_mode_basic() {
734 let trunc = crate::options::TruncationMode {
735 max_width: 50.0,
736 ellipsis_advance: 10.0,
737 ellipsis_glyph_id: 0,
738 };
739 assert_eq!(trunc.max_width, 50.0);
740 assert_eq!(trunc.ellipsis_advance, 10.0);
741 assert_eq!(trunc.ellipsis_glyph_id, 0);
742 }
743 #[test]
744 fn layout_options_builder() {
745 let opts = crate::options::LayoutOptions::builder()
746 .alignment(oxitext_core::TextAlignment::Center)
747 .paragraph_spacing(12.0)
748 .build();
749 assert_eq!(opts.paragraph_spacing, 12.0);
750 assert_eq!(opts.alignment, oxitext_core::TextAlignment::Center);
751 }
752 #[test]
753 fn truncation_applied_on_overflow() {
754 let text = "hello world";
755 let run = run_from_text(text, 10.0);
756 let mut engine = LayoutEngine::new();
757 let trunc = crate::options::TruncationMode {
758 max_width: 60.0,
759 ellipsis_advance: 10.0,
760 ellipsis_glyph_id: 0,
761 };
762 let opts = crate::options::LayoutOptions::builder()
763 .truncation(trunc)
764 .build();
765 let res = engine
766 .layout_with_options(text, &[run], 10000.0, &opts, None, 16.0)
767 .expect("layout_with_options");
768 let last = res.glyphs.last().expect("at least one glyph");
769 assert_eq!(last.gid, 0, "last glyph should be ellipsis (gid 0)");
770 assert!(res.metrics.truncated, "metrics.truncated should be true");
771 }
772 #[test]
773 fn no_truncation_when_fits() {
774 let text = "hi";
775 let run = run_from_text(text, 10.0);
776 let mut engine = LayoutEngine::new();
777 let trunc = crate::options::TruncationMode {
778 max_width: 200.0,
779 ellipsis_advance: 10.0,
780 ellipsis_glyph_id: 0,
781 };
782 let opts = crate::options::LayoutOptions::builder()
783 .truncation(trunc)
784 .build();
785 let res = engine
786 .layout_with_options(text, &[run], 10000.0, &opts, None, 16.0)
787 .expect("layout_with_options");
788 assert!(!res.metrics.truncated, "short text should not be truncated");
789 assert_eq!(res.glyphs.len(), 2, "all glyphs present");
790 }
791 #[test]
792 fn layout_paragraphs_offsets_y() {
793 let text1 = "ab";
794 let text2 = "cd";
795 let run1 = run_from_text(text1, 10.0);
796 let run2 = run_from_text(text2, 10.0);
797 let mut engine = LayoutEngine::new();
798 let runs1 = [run1];
799 let runs2 = [run2];
800 let c = LayoutConstraints {
801 max_width: 1000.0,
802 font_size: 16.0,
803 };
804 let opts = crate::options::LayoutOptions::builder()
805 .alignment(TextAlignment::Left)
806 .build();
807 let res = engine
808 .layout_paragraphs(
809 &[text1, text2],
810 &[runs1.as_slice(), runs2.as_slice()],
811 &c,
812 20.0,
813 &opts,
814 None,
815 )
816 .expect("layout_paragraphs");
817 assert!(res.lines.len() >= 2, "should have at least 2 lines");
818 let y0 = res.lines[0].metrics.baseline_y;
819 let y1 = res.lines[1].metrics.baseline_y;
820 assert!(
821 y1 > y0,
822 "second paragraph must be below first: y0={y0} y1={y1}"
823 );
824 }
825 #[test]
826 fn zwj_suppresses_break() {
827 let text = "a\u{200D}b";
828 let run = run_from_text(text, 10.0);
829 let c = LayoutConstraints {
830 max_width: 1.0,
831 font_size: 16.0,
832 };
833 let mut engine = LayoutEngine::new();
834 let res = engine
835 .layout(text, &[run], &c, TextAlignment::Left, None)
836 .expect("layout");
837 assert_eq!(res.glyphs.len(), 3, "a + ZWJ + b = 3 glyphs");
838 }
839 #[test]
840 fn zwnj_allows_break() {
841 let text = "a\u{200C}b";
842 let run = run_from_text(text, 10.0);
843 let c = LayoutConstraints {
844 max_width: 15.0,
845 font_size: 16.0,
846 };
847 let mut engine = LayoutEngine::new();
848 let res = engine
849 .layout(text, &[run], &c, TextAlignment::Left, None)
850 .expect("layout");
851 assert!(res.glyphs.len() == 3, "a + ZWNJ + b = 3 glyphs");
852 assert!(!res.lines.is_empty());
853 }
854 fn make_hit_test_result() -> LayoutResult {
858 use std::sync::Arc;
859 let font: Arc<[u8]> = Arc::from(&[][..]);
860 let glyphs = vec![
861 PositionedGlyph {
862 gid: 1,
863 font_data: Arc::clone(&font),
864 pos: (0.0, 16.0),
865 font_size: 16.0,
866 advance_x: 10.0,
867 cluster: 0,
868 },
869 PositionedGlyph {
870 gid: 2,
871 font_data: Arc::clone(&font),
872 pos: (10.0, 16.0),
873 font_size: 16.0,
874 advance_x: 10.0,
875 cluster: 1,
876 },
877 PositionedGlyph {
878 gid: 3,
879 font_data: Arc::clone(&font),
880 pos: (20.0, 16.0),
881 font_size: 16.0,
882 advance_x: 10.0,
883 cluster: 2,
884 },
885 ];
886 let lines = vec![Line {
887 glyph_start: 0,
888 glyph_end: 3,
889 metrics: LineMetrics {
890 ascent: 12.8,
891 descent: 3.2,
892 leading: 0.0,
893 baseline_y: 16.0,
894 width: 30.0,
895 },
896 }];
897 LayoutResult {
898 glyphs,
899 lines,
900 metrics: ParagraphMetrics {
901 total_height: 22.4,
902 total_width: 30.0,
903 line_count: 1,
904 overflow: false,
905 truncated: false,
906 },
907 decorations: Vec::new(),
908 inline_objects: Vec::new(),
909 }
910 }
911 #[test]
912 fn hit_test_finds_correct_glyph() {
913 let res = make_hit_test_result();
914 let hit = res.hit_test(5.0, 16.0).expect("hit_test returned None");
915 assert_eq!(hit.0, 0, "should be on line 0");
916 assert_eq!(hit.1, 0, "glyph index in line should be 0 (first glyph)");
917 assert_eq!(hit.2, 0, "cluster should be 0");
918 let hit = res.hit_test(15.0, 16.0).expect("hit_test returned None");
919 assert_eq!(hit.1, 1, "glyph index in line should be 1");
920 assert_eq!(hit.2, 1, "cluster should be 1");
921 let hit = res.hit_test(25.0, 16.0).expect("hit_test returned None");
922 assert_eq!(hit.1, 2, "glyph index in line should be 2");
923 assert_eq!(hit.2, 2, "cluster should be 2");
924 }
925 #[test]
926 fn hit_test_out_of_bounds_clamps() {
927 let res = make_hit_test_result();
928 let hit = res.hit_test(-100.0, 16.0).expect("hit_test returned None");
929 assert_eq!(hit.1, 0, "far-left hit should clamp to glyph 0");
930 let hit = res.hit_test(99999.0, 16.0).expect("hit_test returned None");
931 assert_eq!(hit.1, 2, "far-right hit should clamp to glyph 2");
932 }
933 #[test]
934 fn hit_test_y_outside_all_lines_picks_nearest() {
935 let res = make_hit_test_result();
936 let hit = res.hit_test(5.0, -100.0).expect("hit_test returned None");
937 assert_eq!(hit.0, 0, "y far above should still return line 0");
938 let hit = res.hit_test(5.0, 99999.0).expect("hit_test returned None");
939 assert_eq!(hit.0, 0, "y far below should still return line 0");
940 }
941 #[test]
942 fn hit_test_empty_layout_returns_none() {
943 let res = LayoutResult {
944 glyphs: vec![],
945 lines: vec![],
946 metrics: ParagraphMetrics {
947 total_height: 0.0,
948 total_width: 0.0,
949 line_count: 0,
950 overflow: false,
951 truncated: false,
952 },
953 decorations: Vec::new(),
954 inline_objects: Vec::new(),
955 };
956 assert!(
957 res.hit_test(0.0, 0.0).is_none(),
958 "empty layout should return None"
959 );
960 }
961 #[test]
962 fn hanging_punctuation_flag_in_options() {
963 let opts = crate::options::LayoutOptions::builder().build();
964 assert!(
965 !opts.hanging_punctuation,
966 "hanging_punctuation should default to false"
967 );
968 let opts_on = crate::options::LayoutOptions::builder()
969 .hanging_punctuation(true)
970 .build();
971 assert!(
972 opts_on.hanging_punctuation,
973 "hanging_punctuation should be settable to true"
974 );
975 }
976 #[test]
977 fn hanging_punctuation_shifts_terminal_punct() {
978 let text = "abc\u{3002}";
979 let run = run_from_text(text, 10.0);
980 let mut engine = LayoutEngine::new();
981 let opts = crate::options::LayoutOptions::builder()
982 .hanging_punctuation(true)
983 .build();
984 let res_no_hang = engine
985 .layout_with_options(
986 text,
987 std::slice::from_ref(&run),
988 1000.0,
989 &crate::options::LayoutOptions::default(),
990 None,
991 16.0,
992 )
993 .expect("layout no-hang");
994 let res_hang = engine
995 .layout_with_options(text, std::slice::from_ref(&run), 1000.0, &opts, None, 16.0)
996 .expect("layout hang");
997 let last_no_hang = res_no_hang
998 .glyphs
999 .last()
1000 .expect("no-hang: last glyph")
1001 .pos
1002 .0;
1003 let last_hang = res_hang.glyphs.last().expect("hang: last glyph").pos.0;
1004 assert!(
1005 (last_hang - (last_no_hang + 5.0)).abs() < 1e-3,
1006 "hanging punct should shift last glyph right by half advance (5px); \
1007 no_hang={last_no_hang}, hang={last_hang}"
1008 );
1009 }
1010 #[test]
1011 fn test_external_break_points() {
1012 let text = "Hello there";
1013 let run = run_from_text(text, 8.0);
1014 let mut engine = LayoutEngine::new();
1015 let c_base = LayoutConstraints {
1016 max_width: 0.0,
1017 font_size: 16.0,
1018 };
1019 let base = engine
1020 .layout(
1021 text,
1022 std::slice::from_ref(&run),
1023 &c_base,
1024 TextAlignment::Left,
1025 None,
1026 )
1027 .expect("base layout");
1028 assert_eq!(base.lines.len(), 1, "no-wrap baseline should be 1 line");
1029 let c_narrow = LayoutConstraints {
1030 max_width: 50.0,
1031 font_size: 16.0,
1032 };
1033 let result = engine
1034 .layout_with_break_points(text, &[run], &c_narrow, TextAlignment::Left, None, &[5])
1035 .expect("layout_with_break_points");
1036 assert!(!result.lines.is_empty(), "should produce at least one line");
1037 assert_eq!(
1038 result.glyphs.len(),
1039 text.chars().count(),
1040 "all glyphs should be present"
1041 );
1042 assert!(!result.lines.is_empty());
1043 }
1044 #[test]
1045 fn external_break_points_single_word_no_wrap() {
1046 let text = "abcdef";
1047 let run = run_from_text(text, 10.0);
1048 let mut engine = LayoutEngine::new();
1049 let c = LayoutConstraints {
1050 max_width: 40.0,
1051 font_size: 16.0,
1052 };
1053 let result = engine
1054 .layout_with_break_points(text, &[run], &c, TextAlignment::Left, None, &[3])
1055 .expect("layout");
1056 assert!(
1057 result.lines.len() >= 2,
1058 "expected >= 2 lines, got {}",
1059 result.lines.len()
1060 );
1061 assert_eq!(result.glyphs.len(), 6, "all 6 glyphs present");
1062 assert!(
1063 !result.metrics.overflow,
1064 "external break should avoid hard-break overflow flag"
1065 );
1066 }
1067 #[test]
1068 fn external_break_points_empty_slice() {
1069 let text = "hello";
1070 let run = run_from_text(text, 10.0);
1071 let mut engine = LayoutEngine::new();
1072 let c = LayoutConstraints {
1073 max_width: 1000.0,
1074 font_size: 16.0,
1075 };
1076 let result = engine
1077 .layout_with_break_points(text, &[run], &c, TextAlignment::Left, None, &[])
1078 .expect("layout");
1079 assert_eq!(result.lines.len(), 1);
1080 assert_eq!(result.glyphs.len(), 5);
1081 }
1082 #[test]
1083 fn test_parallel_layout_left_align() {
1084 let text = "Hello world test text okay";
1085 let run = run_from_text(text, 6.0);
1086 let mut engine = LayoutEngine::new();
1087 let opts = crate::options::LayoutOptions::default();
1088 let result = engine
1089 .layout_with_options(text, &[run], 60.0, &opts, None, 16.0)
1090 .expect("layout_with_options");
1091 assert!(!result.glyphs.is_empty(), "glyphs should be non-empty");
1092 for (li, line) in result.lines.iter().enumerate() {
1093 if line.glyph_start < line.glyph_end {
1094 let first_x = result.glyphs[line.glyph_start].pos.0;
1095 assert!(
1096 first_x.abs() < 1.0,
1097 "left-aligned line {} first glyph x should be ~0, got {}",
1098 li,
1099 first_x
1100 );
1101 }
1102 }
1103 }
1104 #[test]
1105 fn test_parallel_layout_center_align() {
1106 let text = "hi";
1107 let run = run_from_text(text, 10.0);
1108 let mut engine = LayoutEngine::new();
1109 let c = LayoutConstraints {
1110 max_width: 100.0,
1111 font_size: 16.0,
1112 };
1113 let result = engine
1114 .layout(text, &[run], &c, TextAlignment::Center, None)
1115 .expect("layout center");
1116 assert!(!result.glyphs.is_empty());
1117 let first_x = result.glyphs[0].pos.0;
1118 assert!(
1119 (first_x - 40.0).abs() < 1e-3,
1120 "center-aligned first glyph x should be 40, got {first_x}"
1121 );
1122 }
1123 #[test]
1124 fn test_parallel_layout_right_align() {
1125 let text = "hi";
1126 let run = run_from_text(text, 10.0);
1127 let mut engine = LayoutEngine::new();
1128 let c = LayoutConstraints {
1129 max_width: 100.0,
1130 font_size: 16.0,
1131 };
1132 let result = engine
1133 .layout(text, &[run], &c, TextAlignment::Right, None)
1134 .expect("layout right");
1135 assert!(!result.glyphs.is_empty());
1136 let first_x = result.glyphs[0].pos.0;
1137 assert!(
1138 (first_x - 80.0).abs() < 1e-3,
1139 "right-aligned first glyph x should be 80, got {first_x}"
1140 );
1141 }
1142 #[test]
1143 fn test_multi_line_parallel_offsets() {
1144 let text = "abcd\nefgh\nijkl";
1145 let run = run_from_text(text, 10.0);
1146 let mut engine = LayoutEngine::new();
1147 let c = LayoutConstraints {
1148 max_width: 100.0,
1149 font_size: 16.0,
1150 };
1151 let result = engine
1152 .layout(text, &[run], &c, TextAlignment::Center, None)
1153 .expect("multi-line center");
1154 assert!(
1155 result.lines.len() >= 3,
1156 "should have 3 lines for \\n-separated text"
1157 );
1158 for line in &result.lines {
1159 if line.glyph_start < line.glyph_end {
1160 let x = result.glyphs[line.glyph_start].pos.0;
1161 assert!(
1162 x >= 0.0,
1163 "center-aligned line x should be non-negative, got {x}"
1164 );
1165 }
1166 }
1167 }
1168 #[test]
1169 #[ignore]
1170 fn bench_layout_10k_chars() {
1171 let text: String = "Hello world ".repeat(850);
1172 let run = run_from_text(&text, 8.0);
1173 let c = LayoutConstraints {
1174 max_width: 600.0,
1175 font_size: 16.0,
1176 };
1177 let mut engine = LayoutEngine::new();
1178 let start = std::time::Instant::now();
1179 let result = engine
1180 .layout(&text, &[run], &c, TextAlignment::Left, None)
1181 .expect("bench layout");
1182 let elapsed = start.elapsed();
1183 println!(
1184 "10K layout: {:?} ({} lines, {} glyphs)",
1185 elapsed,
1186 result.lines.len(),
1187 result.glyphs.len()
1188 );
1189 }
1190
1191 #[test]
1194 fn test_mark_dirty_sets_has_dirty() {
1195 let mut engine = LayoutEngine::new();
1196 assert!(!engine.has_dirty(), "fresh engine should not be dirty");
1197 engine.mark_dirty(0..5);
1198 assert!(
1199 engine.has_dirty(),
1200 "engine should be dirty after mark_dirty"
1201 );
1202 engine.clear_dirty();
1203 assert!(
1204 !engine.has_dirty(),
1205 "engine should be clean after clear_dirty"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_mark_dirty_accumulates_multiple_ranges() {
1211 let mut engine = LayoutEngine::new();
1212 engine.mark_dirty(0..3);
1213 engine.mark_dirty(10..20);
1214 engine.mark_dirty(30..40);
1215 assert!(engine.has_dirty());
1216 engine.clear_dirty();
1217 assert!(!engine.has_dirty());
1218 }
1219
1220 #[test]
1221 fn test_layout_if_dirty_returns_cached_when_clean() {
1222 let text = "hello";
1223 let run = run_from_text(text, 10.0);
1224 let c = LayoutConstraints {
1225 max_width: 1000.0,
1226 font_size: 16.0,
1227 };
1228 let mut engine = LayoutEngine::new();
1229 let initial = engine
1231 .layout(
1232 text,
1233 std::slice::from_ref(&run),
1234 &c,
1235 TextAlignment::Left,
1236 None,
1237 )
1238 .expect("initial layout");
1239 let initial_glyph_count = initial.glyphs.len();
1240
1241 let returned = engine.layout_if_dirty(Some(initial), |eng| {
1243 eng.layout(
1244 text,
1245 std::slice::from_ref(&run),
1246 &c,
1247 TextAlignment::Left,
1248 None,
1249 )
1250 .expect("relayout")
1251 });
1252 assert_eq!(
1253 returned.glyphs.len(),
1254 initial_glyph_count,
1255 "cached result should be returned unchanged when engine is clean"
1256 );
1257 assert!(!engine.has_dirty());
1259 }
1260
1261 #[test]
1262 fn test_layout_if_dirty_relayouts_when_dirty() {
1263 let text = "hello";
1264 let run = run_from_text(text, 10.0);
1265 let c = LayoutConstraints {
1266 max_width: 1000.0,
1267 font_size: 16.0,
1268 };
1269 let mut engine = LayoutEngine::new();
1270
1271 engine.mark_dirty(0..5);
1273 assert!(engine.has_dirty());
1274
1275 let relayout_called = std::cell::Cell::new(false);
1276 let _result = engine.layout_if_dirty(None, |eng| {
1277 relayout_called.set(true);
1278 eng.layout(
1279 text,
1280 std::slice::from_ref(&run),
1281 &c,
1282 TextAlignment::Left,
1283 None,
1284 )
1285 .expect("relayout")
1286 });
1287
1288 assert!(
1289 relayout_called.get(),
1290 "layout_fn should be called when dirty"
1291 );
1292 assert!(
1294 !engine.has_dirty(),
1295 "dirty should be cleared after layout_if_dirty"
1296 );
1297 }
1298
1299 #[test]
1300 fn test_layout_if_dirty_calls_fn_when_no_cached_even_if_clean() {
1301 let text = "hi";
1302 let run = run_from_text(text, 10.0);
1303 let c = LayoutConstraints {
1304 max_width: 500.0,
1305 font_size: 16.0,
1306 };
1307 let mut engine = LayoutEngine::new();
1308 let called = std::cell::Cell::new(false);
1310 let _result = engine.layout_if_dirty(None, |eng| {
1311 called.set(true);
1312 eng.layout(
1313 text,
1314 std::slice::from_ref(&run),
1315 &c,
1316 TextAlignment::Left,
1317 None,
1318 )
1319 .expect("layout")
1320 });
1321 assert!(
1322 called.get(),
1323 "layout_fn should be called when cached is None"
1324 );
1325 }
1326
1327 #[test]
1330 fn test_layout_uax14_explicit() {
1331 let text = "Hello World";
1332 let run = run_from_text(text, 10.0);
1333 let c = LayoutConstraints {
1334 max_width: 1000.0,
1335 font_size: 16.0,
1336 };
1337 let mut engine = LayoutEngine::new();
1338 let res = engine
1339 .layout_uax14(text, &[run], &c, TextAlignment::Left, None)
1340 .expect("layout_uax14");
1341 assert_eq!(res.glyphs.len(), text.chars().count(), "all glyphs present");
1342 assert!(!res.lines.is_empty(), "at least one line");
1343 }
1344
1345 #[test]
1346 fn test_layout_uax14_wraps_at_word_boundary() {
1347 let text = "Hello World";
1349 let run = run_from_text(text, 10.0);
1350 let c = LayoutConstraints {
1351 max_width: 60.0,
1352 font_size: 16.0,
1353 };
1354 let mut engine = LayoutEngine::new();
1355 let res = engine
1356 .layout_uax14(text, &[run], &c, TextAlignment::Left, None)
1357 .expect("layout_uax14 wrap");
1358 assert!(res.lines.len() >= 2, "should wrap to at least 2 lines");
1359 }
1360
1361 fn make_result(glyphs: Vec<oxitext_core::PositionedGlyph>) -> LayoutResult {
1367 let n = glyphs.len();
1368 let lines = if n == 0 {
1369 vec![]
1370 } else {
1371 vec![Line {
1372 glyph_start: 0,
1373 glyph_end: n,
1374 metrics: LineMetrics {
1375 ascent: 12.0,
1376 descent: 4.0,
1377 leading: 0.0,
1378 baseline_y: 12.0,
1379 width: 0.0,
1380 },
1381 }]
1382 };
1383 LayoutResult {
1384 glyphs,
1385 lines,
1386 metrics: ParagraphMetrics {
1387 total_height: 0.0,
1388 total_width: 0.0,
1389 line_count: 0,
1390 overflow: false,
1391 truncated: false,
1392 },
1393 decorations: Vec::new(),
1394 inline_objects: Vec::new(),
1395 }
1396 }
1397
1398 #[test]
1399 fn test_unique_glyphs_for_atlas_deduplicates() {
1400 let font: Arc<[u8]> = Arc::from(&[][..]);
1403 let g1 = oxitext_core::PositionedGlyph {
1404 gid: 65,
1405 font_data: Arc::clone(&font),
1406 pos: (0.0, 0.0),
1407 font_size: 16.0,
1408 advance_x: 10.0,
1409 cluster: 0,
1410 };
1411 let g2 = oxitext_core::PositionedGlyph {
1412 gid: 65,
1413 font_data: Arc::clone(&font),
1414 pos: (10.0, 0.0),
1415 font_size: 16.0,
1416 advance_x: 10.0,
1417 cluster: 1,
1418 };
1419 let g3 = oxitext_core::PositionedGlyph {
1420 gid: 66,
1421 font_data: Arc::clone(&font),
1422 pos: (20.0, 0.0),
1423 font_size: 16.0,
1424 advance_x: 10.0,
1425 cluster: 2,
1426 };
1427 let result = make_result(vec![g1, g2, g3]);
1428 let unique = result.unique_glyphs_for_atlas();
1429 assert_eq!(
1430 unique.len(),
1431 2,
1432 "expected 2 unique (gid, size) pairs, got {}",
1433 unique.len()
1434 );
1435 assert!(
1436 unique.contains(&(65, 16.0)),
1437 "pair (65, 16.0) must be present"
1438 );
1439 assert!(
1440 unique.contains(&(66, 16.0)),
1441 "pair (66, 16.0) must be present"
1442 );
1443 }
1444
1445 #[test]
1446 fn test_unique_glyphs_different_sizes_are_distinct() {
1447 let font: Arc<[u8]> = Arc::from(&[][..]);
1449 let g1 = oxitext_core::PositionedGlyph {
1450 gid: 65,
1451 font_data: Arc::clone(&font),
1452 pos: (0.0, 0.0),
1453 font_size: 16.0,
1454 advance_x: 10.0,
1455 cluster: 0,
1456 };
1457 let g2 = oxitext_core::PositionedGlyph {
1458 gid: 65,
1459 font_data: Arc::clone(&font),
1460 pos: (0.0, 20.0),
1461 font_size: 32.0,
1462 advance_x: 20.0,
1463 cluster: 1,
1464 };
1465 let result = make_result(vec![g1, g2]);
1466 let unique = result.unique_glyphs_for_atlas();
1467 assert_eq!(
1468 unique.len(),
1469 2,
1470 "different sizes must be counted separately"
1471 );
1472 }
1473
1474 #[test]
1475 fn test_rasterization_inputs_preserves_order() {
1476 let font: Arc<[u8]> = Arc::from(&[][..]);
1477 let glyphs: Vec<oxitext_core::PositionedGlyph> = vec![
1478 oxitext_core::PositionedGlyph {
1479 gid: 10,
1480 font_data: Arc::clone(&font),
1481 pos: (0.0, 1.0),
1482 font_size: 14.0,
1483 advance_x: 8.0,
1484 cluster: 0,
1485 },
1486 oxitext_core::PositionedGlyph {
1487 gid: 20,
1488 font_data: Arc::clone(&font),
1489 pos: (8.0, 1.0),
1490 font_size: 14.0,
1491 advance_x: 8.0,
1492 cluster: 1,
1493 },
1494 oxitext_core::PositionedGlyph {
1495 gid: 30,
1496 font_data: Arc::clone(&font),
1497 pos: (16.0, 1.0),
1498 font_size: 14.0,
1499 advance_x: 8.0,
1500 cluster: 2,
1501 },
1502 ];
1503 let result = make_result(glyphs);
1504 let inputs = result.rasterization_inputs();
1505 assert_eq!(inputs.len(), 3, "one entry per glyph");
1506 assert_eq!(inputs[0], (10, 0.0, 1.0, 14.0));
1507 assert_eq!(inputs[1], (20, 8.0, 1.0, 14.0));
1508 assert_eq!(inputs[2], (30, 16.0, 1.0, 14.0));
1509 }
1510
1511 #[test]
1512 fn test_sdf_glyph_set_equals_unique_glyphs() {
1513 let font: Arc<[u8]> = Arc::from(&[][..]);
1515 let g1 = oxitext_core::PositionedGlyph {
1516 gid: 7,
1517 font_data: Arc::clone(&font),
1518 pos: (0.0, 0.0),
1519 font_size: 24.0,
1520 advance_x: 12.0,
1521 cluster: 0,
1522 };
1523 let result = make_result(vec![g1]);
1524 assert_eq!(result.sdf_glyph_set(), result.unique_glyphs_for_atlas());
1525 }
1526
1527 #[test]
1528 fn test_unique_glyphs_empty_layout() {
1529 let result = make_result(vec![]);
1530 assert!(
1531 result.unique_glyphs_for_atlas().is_empty(),
1532 "no glyphs โ empty set"
1533 );
1534 assert!(
1535 result.rasterization_inputs().is_empty(),
1536 "no glyphs โ empty inputs"
1537 );
1538 }
1539}