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 layout_with_options_tab_stops_resolve_correct_glyph_on_second_line() {
734 let text = "aa\nbb\t\tcc";
749 let run = run_from_text(text, 10.0);
750 let ts = crate::options::TabStops::with_interval(80.0);
751 let opts = crate::options::LayoutOptions::builder()
752 .tab_stops(ts)
753 .build();
754 let mut engine = LayoutEngine::new();
755 let res = engine
756 .layout_with_options(text, &[run], 10000.0, &opts, None, 16.0)
757 .expect("layout_with_options");
758 assert_eq!(
759 res.lines.len(),
760 2,
761 "mandatory break after '\\n' should yield exactly 2 lines"
762 );
763 let line2 = &res.lines[1];
764 assert_eq!(line2.len(), 6, "line 2 should have 6 glyphs");
766 let tab2_idx = line2.glyph_start + 3;
767 assert_eq!(
774 res.glyphs[tab2_idx].pos.0, 80.0,
775 "second tab on line 2 must snap forward from the first tab's stop"
776 );
777 }
778 #[test]
779 fn truncation_mode_basic() {
780 let trunc = crate::options::TruncationMode {
781 max_width: 50.0,
782 ellipsis_advance: 10.0,
783 ellipsis_glyph_id: 0,
784 };
785 assert_eq!(trunc.max_width, 50.0);
786 assert_eq!(trunc.ellipsis_advance, 10.0);
787 assert_eq!(trunc.ellipsis_glyph_id, 0);
788 }
789 #[test]
790 fn layout_options_builder() {
791 let opts = crate::options::LayoutOptions::builder()
792 .alignment(oxitext_core::TextAlignment::Center)
793 .paragraph_spacing(12.0)
794 .build();
795 assert_eq!(opts.paragraph_spacing, 12.0);
796 assert_eq!(opts.alignment, oxitext_core::TextAlignment::Center);
797 }
798 #[test]
799 fn truncation_applied_on_overflow() {
800 let text = "hello world";
801 let run = run_from_text(text, 10.0);
802 let mut engine = LayoutEngine::new();
803 let trunc = crate::options::TruncationMode {
804 max_width: 60.0,
805 ellipsis_advance: 10.0,
806 ellipsis_glyph_id: 0,
807 };
808 let opts = crate::options::LayoutOptions::builder()
809 .truncation(trunc)
810 .build();
811 let res = engine
812 .layout_with_options(text, &[run], 10000.0, &opts, None, 16.0)
813 .expect("layout_with_options");
814 let last = res.glyphs.last().expect("at least one glyph");
815 assert_eq!(last.gid, 0, "last glyph should be ellipsis (gid 0)");
816 assert!(res.metrics.truncated, "metrics.truncated should be true");
817 }
818 #[test]
819 fn no_truncation_when_fits() {
820 let text = "hi";
821 let run = run_from_text(text, 10.0);
822 let mut engine = LayoutEngine::new();
823 let trunc = crate::options::TruncationMode {
824 max_width: 200.0,
825 ellipsis_advance: 10.0,
826 ellipsis_glyph_id: 0,
827 };
828 let opts = crate::options::LayoutOptions::builder()
829 .truncation(trunc)
830 .build();
831 let res = engine
832 .layout_with_options(text, &[run], 10000.0, &opts, None, 16.0)
833 .expect("layout_with_options");
834 assert!(!res.metrics.truncated, "short text should not be truncated");
835 assert_eq!(res.glyphs.len(), 2, "all glyphs present");
836 }
837 #[test]
838 fn layout_paragraphs_offsets_y() {
839 let text1 = "ab";
840 let text2 = "cd";
841 let run1 = run_from_text(text1, 10.0);
842 let run2 = run_from_text(text2, 10.0);
843 let mut engine = LayoutEngine::new();
844 let runs1 = [run1];
845 let runs2 = [run2];
846 let c = LayoutConstraints {
847 max_width: 1000.0,
848 font_size: 16.0,
849 };
850 let opts = crate::options::LayoutOptions::builder()
851 .alignment(TextAlignment::Left)
852 .build();
853 let res = engine
854 .layout_paragraphs(
855 &[text1, text2],
856 &[runs1.as_slice(), runs2.as_slice()],
857 &c,
858 20.0,
859 &opts,
860 None,
861 )
862 .expect("layout_paragraphs");
863 assert!(res.lines.len() >= 2, "should have at least 2 lines");
864 let y0 = res.lines[0].metrics.baseline_y;
865 let y1 = res.lines[1].metrics.baseline_y;
866 assert!(
867 y1 > y0,
868 "second paragraph must be below first: y0={y0} y1={y1}"
869 );
870 }
871 #[test]
872 fn zwj_suppresses_break() {
873 let text = "a\u{200D}b";
874 let run = run_from_text(text, 10.0);
875 let c = LayoutConstraints {
876 max_width: 1.0,
877 font_size: 16.0,
878 };
879 let mut engine = LayoutEngine::new();
880 let res = engine
881 .layout(text, &[run], &c, TextAlignment::Left, None)
882 .expect("layout");
883 assert_eq!(res.glyphs.len(), 3, "a + ZWJ + b = 3 glyphs");
884 }
885 #[test]
886 fn zwnj_allows_break() {
887 let text = "a\u{200C}b";
888 let run = run_from_text(text, 10.0);
889 let c = LayoutConstraints {
890 max_width: 15.0,
891 font_size: 16.0,
892 };
893 let mut engine = LayoutEngine::new();
894 let res = engine
895 .layout(text, &[run], &c, TextAlignment::Left, None)
896 .expect("layout");
897 assert!(res.glyphs.len() == 3, "a + ZWNJ + b = 3 glyphs");
898 assert!(!res.lines.is_empty());
899 }
900 fn make_hit_test_result() -> LayoutResult {
904 use std::sync::Arc;
905 let font: Arc<[u8]> = Arc::from(&[][..]);
906 let glyphs = vec![
907 PositionedGlyph {
908 gid: 1,
909 font_data: Arc::clone(&font),
910 pos: (0.0, 16.0),
911 font_size: 16.0,
912 advance_x: 10.0,
913 cluster: 0,
914 },
915 PositionedGlyph {
916 gid: 2,
917 font_data: Arc::clone(&font),
918 pos: (10.0, 16.0),
919 font_size: 16.0,
920 advance_x: 10.0,
921 cluster: 1,
922 },
923 PositionedGlyph {
924 gid: 3,
925 font_data: Arc::clone(&font),
926 pos: (20.0, 16.0),
927 font_size: 16.0,
928 advance_x: 10.0,
929 cluster: 2,
930 },
931 ];
932 let lines = vec![Line {
933 glyph_start: 0,
934 glyph_end: 3,
935 metrics: LineMetrics {
936 ascent: 12.8,
937 descent: 3.2,
938 leading: 0.0,
939 baseline_y: 16.0,
940 width: 30.0,
941 },
942 }];
943 LayoutResult {
944 glyphs,
945 lines,
946 metrics: ParagraphMetrics {
947 total_height: 22.4,
948 total_width: 30.0,
949 line_count: 1,
950 overflow: false,
951 truncated: false,
952 },
953 decorations: Vec::new(),
954 inline_objects: Vec::new(),
955 }
956 }
957 #[test]
958 fn hit_test_finds_correct_glyph() {
959 let res = make_hit_test_result();
960 let hit = res.hit_test(5.0, 16.0).expect("hit_test returned None");
961 assert_eq!(hit.0, 0, "should be on line 0");
962 assert_eq!(hit.1, 0, "glyph index in line should be 0 (first glyph)");
963 assert_eq!(hit.2, 0, "cluster should be 0");
964 let hit = res.hit_test(15.0, 16.0).expect("hit_test returned None");
965 assert_eq!(hit.1, 1, "glyph index in line should be 1");
966 assert_eq!(hit.2, 1, "cluster should be 1");
967 let hit = res.hit_test(25.0, 16.0).expect("hit_test returned None");
968 assert_eq!(hit.1, 2, "glyph index in line should be 2");
969 assert_eq!(hit.2, 2, "cluster should be 2");
970 }
971 #[test]
972 fn hit_test_out_of_bounds_clamps() {
973 let res = make_hit_test_result();
974 let hit = res.hit_test(-100.0, 16.0).expect("hit_test returned None");
975 assert_eq!(hit.1, 0, "far-left hit should clamp to glyph 0");
976 let hit = res.hit_test(99999.0, 16.0).expect("hit_test returned None");
977 assert_eq!(hit.1, 2, "far-right hit should clamp to glyph 2");
978 }
979 #[test]
980 fn hit_test_y_outside_all_lines_picks_nearest() {
981 let res = make_hit_test_result();
982 let hit = res.hit_test(5.0, -100.0).expect("hit_test returned None");
983 assert_eq!(hit.0, 0, "y far above should still return line 0");
984 let hit = res.hit_test(5.0, 99999.0).expect("hit_test returned None");
985 assert_eq!(hit.0, 0, "y far below should still return line 0");
986 }
987 #[test]
988 fn hit_test_empty_layout_returns_none() {
989 let res = LayoutResult {
990 glyphs: vec![],
991 lines: vec![],
992 metrics: ParagraphMetrics {
993 total_height: 0.0,
994 total_width: 0.0,
995 line_count: 0,
996 overflow: false,
997 truncated: false,
998 },
999 decorations: Vec::new(),
1000 inline_objects: Vec::new(),
1001 };
1002 assert!(
1003 res.hit_test(0.0, 0.0).is_none(),
1004 "empty layout should return None"
1005 );
1006 }
1007 #[test]
1008 fn hanging_punctuation_flag_in_options() {
1009 let opts = crate::options::LayoutOptions::builder().build();
1010 assert!(
1011 !opts.hanging_punctuation,
1012 "hanging_punctuation should default to false"
1013 );
1014 let opts_on = crate::options::LayoutOptions::builder()
1015 .hanging_punctuation(true)
1016 .build();
1017 assert!(
1018 opts_on.hanging_punctuation,
1019 "hanging_punctuation should be settable to true"
1020 );
1021 }
1022 #[test]
1023 fn hanging_punctuation_shifts_terminal_punct() {
1024 let text = "abc\u{3002}";
1025 let run = run_from_text(text, 10.0);
1026 let mut engine = LayoutEngine::new();
1027 let opts = crate::options::LayoutOptions::builder()
1028 .hanging_punctuation(true)
1029 .build();
1030 let res_no_hang = engine
1031 .layout_with_options(
1032 text,
1033 std::slice::from_ref(&run),
1034 1000.0,
1035 &crate::options::LayoutOptions::default(),
1036 None,
1037 16.0,
1038 )
1039 .expect("layout no-hang");
1040 let res_hang = engine
1041 .layout_with_options(text, std::slice::from_ref(&run), 1000.0, &opts, None, 16.0)
1042 .expect("layout hang");
1043 let last_no_hang = res_no_hang
1044 .glyphs
1045 .last()
1046 .expect("no-hang: last glyph")
1047 .pos
1048 .0;
1049 let last_hang = res_hang.glyphs.last().expect("hang: last glyph").pos.0;
1050 assert!(
1051 (last_hang - (last_no_hang + 5.0)).abs() < 1e-3,
1052 "hanging punct should shift last glyph right by half advance (5px); \
1053 no_hang={last_no_hang}, hang={last_hang}"
1054 );
1055 }
1056 #[test]
1057 fn test_external_break_points() {
1058 let text = "Hello there";
1059 let run = run_from_text(text, 8.0);
1060 let mut engine = LayoutEngine::new();
1061 let c_base = LayoutConstraints {
1062 max_width: 0.0,
1063 font_size: 16.0,
1064 };
1065 let base = engine
1066 .layout(
1067 text,
1068 std::slice::from_ref(&run),
1069 &c_base,
1070 TextAlignment::Left,
1071 None,
1072 )
1073 .expect("base layout");
1074 assert_eq!(base.lines.len(), 1, "no-wrap baseline should be 1 line");
1075 let c_narrow = LayoutConstraints {
1076 max_width: 50.0,
1077 font_size: 16.0,
1078 };
1079 let result = engine
1080 .layout_with_break_points(text, &[run], &c_narrow, TextAlignment::Left, None, &[5])
1081 .expect("layout_with_break_points");
1082 assert!(!result.lines.is_empty(), "should produce at least one line");
1083 assert_eq!(
1084 result.glyphs.len(),
1085 text.chars().count(),
1086 "all glyphs should be present"
1087 );
1088 assert!(!result.lines.is_empty());
1089 }
1090 #[test]
1091 fn external_break_points_single_word_no_wrap() {
1092 let text = "abcdef";
1093 let run = run_from_text(text, 10.0);
1094 let mut engine = LayoutEngine::new();
1095 let c = LayoutConstraints {
1096 max_width: 40.0,
1097 font_size: 16.0,
1098 };
1099 let result = engine
1100 .layout_with_break_points(text, &[run], &c, TextAlignment::Left, None, &[3])
1101 .expect("layout");
1102 assert!(
1103 result.lines.len() >= 2,
1104 "expected >= 2 lines, got {}",
1105 result.lines.len()
1106 );
1107 assert_eq!(result.glyphs.len(), 6, "all 6 glyphs present");
1108 assert!(
1109 !result.metrics.overflow,
1110 "external break should avoid hard-break overflow flag"
1111 );
1112 }
1113 #[test]
1114 fn external_break_points_empty_slice() {
1115 let text = "hello";
1116 let run = run_from_text(text, 10.0);
1117 let mut engine = LayoutEngine::new();
1118 let c = LayoutConstraints {
1119 max_width: 1000.0,
1120 font_size: 16.0,
1121 };
1122 let result = engine
1123 .layout_with_break_points(text, &[run], &c, TextAlignment::Left, None, &[])
1124 .expect("layout");
1125 assert_eq!(result.lines.len(), 1);
1126 assert_eq!(result.glyphs.len(), 5);
1127 }
1128 #[test]
1129 fn test_parallel_layout_left_align() {
1130 let text = "Hello world test text okay";
1131 let run = run_from_text(text, 6.0);
1132 let mut engine = LayoutEngine::new();
1133 let opts = crate::options::LayoutOptions::default();
1134 let result = engine
1135 .layout_with_options(text, &[run], 60.0, &opts, None, 16.0)
1136 .expect("layout_with_options");
1137 assert!(!result.glyphs.is_empty(), "glyphs should be non-empty");
1138 for (li, line) in result.lines.iter().enumerate() {
1139 if line.glyph_start < line.glyph_end {
1140 let first_x = result.glyphs[line.glyph_start].pos.0;
1141 assert!(
1142 first_x.abs() < 1.0,
1143 "left-aligned line {} first glyph x should be ~0, got {}",
1144 li,
1145 first_x
1146 );
1147 }
1148 }
1149 }
1150 #[test]
1151 fn test_parallel_layout_center_align() {
1152 let text = "hi";
1153 let run = run_from_text(text, 10.0);
1154 let mut engine = LayoutEngine::new();
1155 let c = LayoutConstraints {
1156 max_width: 100.0,
1157 font_size: 16.0,
1158 };
1159 let result = engine
1160 .layout(text, &[run], &c, TextAlignment::Center, None)
1161 .expect("layout center");
1162 assert!(!result.glyphs.is_empty());
1163 let first_x = result.glyphs[0].pos.0;
1164 assert!(
1165 (first_x - 40.0).abs() < 1e-3,
1166 "center-aligned first glyph x should be 40, got {first_x}"
1167 );
1168 }
1169 #[test]
1170 fn test_parallel_layout_right_align() {
1171 let text = "hi";
1172 let run = run_from_text(text, 10.0);
1173 let mut engine = LayoutEngine::new();
1174 let c = LayoutConstraints {
1175 max_width: 100.0,
1176 font_size: 16.0,
1177 };
1178 let result = engine
1179 .layout(text, &[run], &c, TextAlignment::Right, None)
1180 .expect("layout right");
1181 assert!(!result.glyphs.is_empty());
1182 let first_x = result.glyphs[0].pos.0;
1183 assert!(
1184 (first_x - 80.0).abs() < 1e-3,
1185 "right-aligned first glyph x should be 80, got {first_x}"
1186 );
1187 }
1188 #[test]
1189 fn test_multi_line_parallel_offsets() {
1190 let text = "abcd\nefgh\nijkl";
1191 let run = run_from_text(text, 10.0);
1192 let mut engine = LayoutEngine::new();
1193 let c = LayoutConstraints {
1194 max_width: 100.0,
1195 font_size: 16.0,
1196 };
1197 let result = engine
1198 .layout(text, &[run], &c, TextAlignment::Center, None)
1199 .expect("multi-line center");
1200 assert!(
1201 result.lines.len() >= 3,
1202 "should have 3 lines for \\n-separated text"
1203 );
1204 for line in &result.lines {
1205 if line.glyph_start < line.glyph_end {
1206 let x = result.glyphs[line.glyph_start].pos.0;
1207 assert!(
1208 x >= 0.0,
1209 "center-aligned line x should be non-negative, got {x}"
1210 );
1211 }
1212 }
1213 }
1214 #[test]
1215 #[ignore]
1216 fn bench_layout_10k_chars() {
1217 let text: String = "Hello world ".repeat(850);
1218 let run = run_from_text(&text, 8.0);
1219 let c = LayoutConstraints {
1220 max_width: 600.0,
1221 font_size: 16.0,
1222 };
1223 let mut engine = LayoutEngine::new();
1224 let start = std::time::Instant::now();
1225 let result = engine
1226 .layout(&text, &[run], &c, TextAlignment::Left, None)
1227 .expect("bench layout");
1228 let elapsed = start.elapsed();
1229 println!(
1230 "10K layout: {:?} ({} lines, {} glyphs)",
1231 elapsed,
1232 result.lines.len(),
1233 result.glyphs.len()
1234 );
1235 }
1236
1237 #[test]
1240 fn test_mark_dirty_sets_has_dirty() {
1241 let mut engine = LayoutEngine::new();
1242 assert!(!engine.has_dirty(), "fresh engine should not be dirty");
1243 engine.mark_dirty(0..5);
1244 assert!(
1245 engine.has_dirty(),
1246 "engine should be dirty after mark_dirty"
1247 );
1248 engine.clear_dirty();
1249 assert!(
1250 !engine.has_dirty(),
1251 "engine should be clean after clear_dirty"
1252 );
1253 }
1254
1255 #[test]
1256 fn test_mark_dirty_accumulates_multiple_ranges() {
1257 let mut engine = LayoutEngine::new();
1258 engine.mark_dirty(0..3);
1259 engine.mark_dirty(10..20);
1260 engine.mark_dirty(30..40);
1261 assert!(engine.has_dirty());
1262 engine.clear_dirty();
1263 assert!(!engine.has_dirty());
1264 }
1265
1266 #[test]
1267 fn test_layout_if_dirty_returns_cached_when_clean() {
1268 let text = "hello";
1269 let run = run_from_text(text, 10.0);
1270 let c = LayoutConstraints {
1271 max_width: 1000.0,
1272 font_size: 16.0,
1273 };
1274 let mut engine = LayoutEngine::new();
1275 let initial = engine
1277 .layout(
1278 text,
1279 std::slice::from_ref(&run),
1280 &c,
1281 TextAlignment::Left,
1282 None,
1283 )
1284 .expect("initial layout");
1285 let initial_glyph_count = initial.glyphs.len();
1286
1287 let returned = engine.layout_if_dirty(Some(initial), |eng| {
1289 eng.layout(
1290 text,
1291 std::slice::from_ref(&run),
1292 &c,
1293 TextAlignment::Left,
1294 None,
1295 )
1296 .expect("relayout")
1297 });
1298 assert_eq!(
1299 returned.glyphs.len(),
1300 initial_glyph_count,
1301 "cached result should be returned unchanged when engine is clean"
1302 );
1303 assert!(!engine.has_dirty());
1305 }
1306
1307 #[test]
1308 fn test_layout_if_dirty_relayouts_when_dirty() {
1309 let text = "hello";
1310 let run = run_from_text(text, 10.0);
1311 let c = LayoutConstraints {
1312 max_width: 1000.0,
1313 font_size: 16.0,
1314 };
1315 let mut engine = LayoutEngine::new();
1316
1317 engine.mark_dirty(0..5);
1319 assert!(engine.has_dirty());
1320
1321 let relayout_called = std::cell::Cell::new(false);
1322 let _result = engine.layout_if_dirty(None, |eng| {
1323 relayout_called.set(true);
1324 eng.layout(
1325 text,
1326 std::slice::from_ref(&run),
1327 &c,
1328 TextAlignment::Left,
1329 None,
1330 )
1331 .expect("relayout")
1332 });
1333
1334 assert!(
1335 relayout_called.get(),
1336 "layout_fn should be called when dirty"
1337 );
1338 assert!(
1340 !engine.has_dirty(),
1341 "dirty should be cleared after layout_if_dirty"
1342 );
1343 }
1344
1345 #[test]
1346 fn test_layout_if_dirty_calls_fn_when_no_cached_even_if_clean() {
1347 let text = "hi";
1348 let run = run_from_text(text, 10.0);
1349 let c = LayoutConstraints {
1350 max_width: 500.0,
1351 font_size: 16.0,
1352 };
1353 let mut engine = LayoutEngine::new();
1354 let called = std::cell::Cell::new(false);
1356 let _result = engine.layout_if_dirty(None, |eng| {
1357 called.set(true);
1358 eng.layout(
1359 text,
1360 std::slice::from_ref(&run),
1361 &c,
1362 TextAlignment::Left,
1363 None,
1364 )
1365 .expect("layout")
1366 });
1367 assert!(
1368 called.get(),
1369 "layout_fn should be called when cached is None"
1370 );
1371 }
1372
1373 #[test]
1376 fn test_layout_uax14_explicit() {
1377 let text = "Hello World";
1378 let run = run_from_text(text, 10.0);
1379 let c = LayoutConstraints {
1380 max_width: 1000.0,
1381 font_size: 16.0,
1382 };
1383 let mut engine = LayoutEngine::new();
1384 let res = engine
1385 .layout_uax14(text, &[run], &c, TextAlignment::Left, None)
1386 .expect("layout_uax14");
1387 assert_eq!(res.glyphs.len(), text.chars().count(), "all glyphs present");
1388 assert!(!res.lines.is_empty(), "at least one line");
1389 }
1390
1391 #[test]
1392 fn test_layout_uax14_wraps_at_word_boundary() {
1393 let text = "Hello World";
1395 let run = run_from_text(text, 10.0);
1396 let c = LayoutConstraints {
1397 max_width: 60.0,
1398 font_size: 16.0,
1399 };
1400 let mut engine = LayoutEngine::new();
1401 let res = engine
1402 .layout_uax14(text, &[run], &c, TextAlignment::Left, None)
1403 .expect("layout_uax14 wrap");
1404 assert!(res.lines.len() >= 2, "should wrap to at least 2 lines");
1405 }
1406
1407 fn make_result(glyphs: Vec<oxitext_core::PositionedGlyph>) -> LayoutResult {
1413 let n = glyphs.len();
1414 let lines = if n == 0 {
1415 vec![]
1416 } else {
1417 vec![Line {
1418 glyph_start: 0,
1419 glyph_end: n,
1420 metrics: LineMetrics {
1421 ascent: 12.0,
1422 descent: 4.0,
1423 leading: 0.0,
1424 baseline_y: 12.0,
1425 width: 0.0,
1426 },
1427 }]
1428 };
1429 LayoutResult {
1430 glyphs,
1431 lines,
1432 metrics: ParagraphMetrics {
1433 total_height: 0.0,
1434 total_width: 0.0,
1435 line_count: 0,
1436 overflow: false,
1437 truncated: false,
1438 },
1439 decorations: Vec::new(),
1440 inline_objects: Vec::new(),
1441 }
1442 }
1443
1444 #[test]
1445 fn test_unique_glyphs_for_atlas_deduplicates() {
1446 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: (10.0, 0.0),
1461 font_size: 16.0,
1462 advance_x: 10.0,
1463 cluster: 1,
1464 };
1465 let g3 = oxitext_core::PositionedGlyph {
1466 gid: 66,
1467 font_data: Arc::clone(&font),
1468 pos: (20.0, 0.0),
1469 font_size: 16.0,
1470 advance_x: 10.0,
1471 cluster: 2,
1472 };
1473 let result = make_result(vec![g1, g2, g3]);
1474 let unique = result.unique_glyphs_for_atlas();
1475 assert_eq!(
1476 unique.len(),
1477 2,
1478 "expected 2 unique (gid, size) pairs, got {}",
1479 unique.len()
1480 );
1481 assert!(
1482 unique.contains(&(65, 16.0)),
1483 "pair (65, 16.0) must be present"
1484 );
1485 assert!(
1486 unique.contains(&(66, 16.0)),
1487 "pair (66, 16.0) must be present"
1488 );
1489 }
1490
1491 #[test]
1492 fn test_unique_glyphs_different_sizes_are_distinct() {
1493 let font: Arc<[u8]> = Arc::from(&[][..]);
1495 let g1 = oxitext_core::PositionedGlyph {
1496 gid: 65,
1497 font_data: Arc::clone(&font),
1498 pos: (0.0, 0.0),
1499 font_size: 16.0,
1500 advance_x: 10.0,
1501 cluster: 0,
1502 };
1503 let g2 = oxitext_core::PositionedGlyph {
1504 gid: 65,
1505 font_data: Arc::clone(&font),
1506 pos: (0.0, 20.0),
1507 font_size: 32.0,
1508 advance_x: 20.0,
1509 cluster: 1,
1510 };
1511 let result = make_result(vec![g1, g2]);
1512 let unique = result.unique_glyphs_for_atlas();
1513 assert_eq!(
1514 unique.len(),
1515 2,
1516 "different sizes must be counted separately"
1517 );
1518 }
1519
1520 #[test]
1521 fn test_rasterization_inputs_preserves_order() {
1522 let font: Arc<[u8]> = Arc::from(&[][..]);
1523 let glyphs: Vec<oxitext_core::PositionedGlyph> = vec![
1524 oxitext_core::PositionedGlyph {
1525 gid: 10,
1526 font_data: Arc::clone(&font),
1527 pos: (0.0, 1.0),
1528 font_size: 14.0,
1529 advance_x: 8.0,
1530 cluster: 0,
1531 },
1532 oxitext_core::PositionedGlyph {
1533 gid: 20,
1534 font_data: Arc::clone(&font),
1535 pos: (8.0, 1.0),
1536 font_size: 14.0,
1537 advance_x: 8.0,
1538 cluster: 1,
1539 },
1540 oxitext_core::PositionedGlyph {
1541 gid: 30,
1542 font_data: Arc::clone(&font),
1543 pos: (16.0, 1.0),
1544 font_size: 14.0,
1545 advance_x: 8.0,
1546 cluster: 2,
1547 },
1548 ];
1549 let result = make_result(glyphs);
1550 let inputs = result.rasterization_inputs();
1551 assert_eq!(inputs.len(), 3, "one entry per glyph");
1552 assert_eq!(inputs[0], (10, 0.0, 1.0, 14.0));
1553 assert_eq!(inputs[1], (20, 8.0, 1.0, 14.0));
1554 assert_eq!(inputs[2], (30, 16.0, 1.0, 14.0));
1555 }
1556
1557 #[test]
1558 fn test_sdf_glyph_set_equals_unique_glyphs() {
1559 let font: Arc<[u8]> = Arc::from(&[][..]);
1561 let g1 = oxitext_core::PositionedGlyph {
1562 gid: 7,
1563 font_data: Arc::clone(&font),
1564 pos: (0.0, 0.0),
1565 font_size: 24.0,
1566 advance_x: 12.0,
1567 cluster: 0,
1568 };
1569 let result = make_result(vec![g1]);
1570 assert_eq!(result.sdf_glyph_set(), result.unique_glyphs_for_atlas());
1571 }
1572
1573 #[test]
1574 fn test_unique_glyphs_empty_layout() {
1575 let result = make_result(vec![]);
1576 assert!(
1577 result.unique_glyphs_for_atlas().is_empty(),
1578 "no glyphs โ empty set"
1579 );
1580 assert!(
1581 result.rasterization_inputs().is_empty(),
1582 "no glyphs โ empty inputs"
1583 );
1584 }
1585}