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