Skip to main content

oxitext_layout/engine/
functions.rs

1//! Auto-generated module
2//!
3//! ๐Ÿค– Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use oxitext_core::{
6    DecorationRect, PositionedGlyph, ShapedGlyph, ShapedRun, TextAlignment, TextDecoration,
7};
8use std::sync::Arc;
9
10use super::types::{LayoutResult, Line};
11
12/// Compute per-line [`DecorationRect`]s from a [`TextDecoration`] and layout
13/// data.
14///
15/// For each non-empty line the function derives the x-span from the first and
16/// last glyph positions and applies the decoration relative to the line's
17/// baseline.  Empty lines produce no output.
18pub(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
75/// Returns `true` if `c` is a CJK fullwidth punctuation character that should
76/// be allowed to hang into the margin.
77///
78/// Covers the most common CJK sentence-ending, clause-separating, and quoting
79/// punctuation per CSS Text Module Level 3 ยง3 "Hanging Punctuation" and JIS X
80/// 4051 ยง4.2.
81pub(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}
95/// Apply hanging-punctuation post-processing to a laid-out result.
96///
97/// For each line:
98/// - If the last (rightmost) glyph is a hanging-punctuation character, shift
99///   it rightward by half its advance so it overhangs into the right margin.
100/// - If the first (leftmost) glyph is a hanging-punctuation character, shift
101///   it leftward by half its advance so it overhangs into the left margin.
102///
103/// The `source_text` slice is used to identify codepoints from glyph cluster
104/// byte offsets.
105pub(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}
139/// Convert a list of exclusive break-glyph indices (as returned by
140/// [`crate::knuth_plass::optimal_breaks`]) into `(start, end)` line ranges.
141///
142/// `flat_len` is the total number of glyphs.
143pub(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}
163/// Count whitespace glyphs that sit *between* non-whitespace glyphs (i.e.
164/// internal gaps eligible for justification expansion). Leading/trailing
165/// whitespace is excluded.
166pub(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}
175/// Compute the line's starting X offset and per-gap justification expansion.
176///
177/// Returns `(x_offset, justify_extra_per_gap)`.
178pub(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}
203/// Apply ellipsis truncation to the last line of a [`LayoutResult`].
204///
205/// If the last line's total advance width exceeds `trunc.max_width`, glyphs are
206/// removed from the end until the remaining advance plus `trunc.ellipsis_advance`
207/// fits within `max_width`.  A synthetic ellipsis [`PositionedGlyph`] is then
208/// appended and `ParagraphMetrics::truncated` is set to `true`.
209///
210/// If the last line already fits, the result is returned unchanged.
211pub(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}
299/// Returns the UTF-8 byte cluster offset for the `glyph_idx`-th glyph within
300/// the line (0-based within the line), by walking the shaped runs.
301///
302/// `line_glyph_start` is the absolute glyph index of the line's first glyph,
303/// used only to compute relative positions; we linearly flatten the runs and
304/// pick the `glyph_idx`-th element.
305///
306/// Returns `None` if the index is out of range.
307pub(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}
323/// Returns the `x_advance` for the `glyph_idx`-th glyph (0-based) within the
324/// flat run list.
325pub(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    /// Build a run whose glyphs correspond 1:1 to the chars of `text`, each
352    /// with advance `adv` (whitespace flagged automatically). Cluster offsets
353    /// are byte offsets into `text`.
354    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        // Regression test for the `layout_with_options` tab-stop handler:
735        // `find_cluster_for_positioned_glyph`/`advance_for_glyph` walk
736        // `shaped_runs` from its very first glyph (they ignore the
737        // `line_glyph_start` argument), so callers must pass the glyph's
738        // *absolute* index within `shaped_runs`/`result.glyphs`, not an
739        // index relative to the line. Passing the line-local index made
740        // every line after the first resolve the wrong source character,
741        // silently missing tab stops.
742        //
743        // `text` forces a mandatory break right after '\n' so line 2 starts
744        // at a non-zero glyph offset, and contains two consecutive tabs so
745        // the tab-stop cascade (each stop computed from the previous one)
746        // gives an unambiguous signal that both tabs on line 2 were
747        // correctly recognised as `\t`.
748        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        // Line 2 glyphs, in source order: 'b', 'b', '\t', '\t', 'c', 'c'.
765        assert_eq!(line2.len(), 6, "line 2 should have 6 glyphs");
766        let tab2_idx = line2.glyph_start + 3;
767        // The second tab must be positioned at the first tab's snapped stop
768        // (80.0), proving both tabs on this second line were identified as
769        // `\t` and the snap correctly cascaded. With the pre-fix line-local
770        // index, neither tab on line 2 is recognised as `\t` at all, and
771        // the second tab keeps its untouched natural (non-cascaded)
772        // position instead.
773        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    /// Build a synthetic LayoutResult with known positions for hit-test testing.
901    ///
902    /// Three glyphs on a single line, each 10px wide, baseline_y = 16.
903    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    // --- Incremental relayout API tests ---
1238
1239    #[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        // Produce an initial layout result to use as the cache.
1276        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        // Engine is clean โ€” layout_if_dirty should return the cached result.
1288        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        // Engine should still be clean (no dirty was set, none was cleared).
1304        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        // Mark a range dirty to force relayout.
1318        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        // Dirty markers should be cleared after relayout.
1339        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        // Engine is clean but no cached result โ€” layout_fn must be called.
1355        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    // --- layout_uax14 explicit UAX #14 path ---
1374
1375    #[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        // 11 chars ร— 10px = 110px; max_width = 60px โ†’ wraps after "Hello "
1394        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    // ----- LayoutResult atlas / rasterisation helpers -----
1408
1409    /// Build a minimal `LayoutResult` from explicit `PositionedGlyph` values.
1410    /// `ParagraphMetrics` is constructed with zeroed fields since we only test
1411    /// the helpers and not the metrics themselves.
1412    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        // glyph 65 appears twice; glyph 66 appears once.
1447        // unique_glyphs_for_atlas should return exactly 2 entries.
1448        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        // same glyph ID but different font sizes should be treated as distinct pairs.
1494        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        // sdf_glyph_set is an alias; its output must be identical to unique_glyphs_for_atlas.
1560        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}