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::{DecorationRect, PositionedGlyph, ShapedGlyph, TextAlignment, TextDecoration};
6use std::sync::Arc;
7
8use super::types::{LayoutResult, Line};
9
10/// Compute per-line [`DecorationRect`]s from a [`TextDecoration`] and layout
11/// data.
12///
13/// For each non-empty line the function derives the x-span from the first and
14/// last glyph positions and applies the decoration relative to the line's
15/// baseline.  Empty lines produce no output.
16pub(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
73/// Returns `true` if `c` is a CJK fullwidth punctuation character that should
74/// be allowed to hang into the margin.
75///
76/// Covers the most common CJK sentence-ending, clause-separating, and quoting
77/// punctuation per CSS Text Module Level 3 ยง3 "Hanging Punctuation" and JIS X
78/// 4051 ยง4.2.
79pub(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}
93/// Apply hanging-punctuation post-processing to a laid-out result.
94///
95/// For each line:
96/// - If the last (rightmost) glyph is a hanging-punctuation character, shift
97///   it rightward by half its advance so it overhangs into the right margin.
98/// - If the first (leftmost) glyph is a hanging-punctuation character, shift
99///   it leftward by half its advance so it overhangs into the left margin.
100///
101/// The `source_text` slice is used to identify codepoints from glyph cluster
102/// byte offsets.
103pub(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}
137/// Convert a list of exclusive break-glyph indices (as returned by
138/// [`crate::knuth_plass::optimal_breaks`]) into `(start, end)` line ranges.
139///
140/// `flat_len` is the total number of glyphs.
141pub(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}
161/// Count whitespace glyphs that sit *between* non-whitespace glyphs (i.e.
162/// internal gaps eligible for justification expansion). Leading/trailing
163/// whitespace is excluded.
164pub(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}
173/// Compute the line's starting X offset and per-gap justification expansion.
174///
175/// Returns `(x_offset, justify_extra_per_gap)`.
176pub(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}
201/// Apply ellipsis truncation to the last line of a [`LayoutResult`].
202///
203/// If the last line's total advance width exceeds `trunc.max_width`, glyphs are
204/// removed from the end until the remaining advance plus `trunc.ellipsis_advance`
205/// fits within `max_width`.  A synthetic ellipsis [`PositionedGlyph`] is then
206/// appended and `ParagraphMetrics::truncated` is set to `true`.
207///
208/// If the last line already fits, the result is returned unchanged.
209pub(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    /// Build a run whose glyphs correspond 1:1 to the chars of `text`, each
308    /// with advance `adv` (whitespace flagged automatically). Cluster offsets
309    /// are byte offsets into `text`.
310    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        // Regression test for the `layout_with_options` tab-stop handler:
691        // the source character for each positioned glyph is now read from that
692        // glyph's own `cluster` byte offset (`result.glyphs[gi].cluster`), so
693        // second-and-later lines โ€” whose glyphs start at a non-zero absolute
694        // index โ€” resolve the correct source character. A prior version walked
695        // the flat shaped-run list with a line-local index and silently missed
696        // tab stops on every line after the first.
697        //
698        // `text` forces a mandatory break right after '\n' so line 2 starts
699        // at a non-zero glyph offset, and contains two consecutive tabs so
700        // the tab-stop cascade (each stop computed from the previous one)
701        // gives an unambiguous signal that both tabs on line 2 were
702        // correctly recognised as `\t`.
703        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        // Line 2 glyphs, in source order: 'b', 'b', '\t', '\t', 'c', 'c'.
720        assert_eq!(line2.len(), 6, "line 2 should have 6 glyphs");
721        let tab2_idx = line2.glyph_start + 3;
722        // The second tab must be positioned at the first tab's snapped stop
723        // (80.0), proving both tabs on this second line were identified as
724        // `\t` and the snap correctly cascaded. With the pre-fix line-local
725        // index, neither tab on line 2 is recognised as `\t` at all, and
726        // the second tab keeps its untouched natural (non-cascaded)
727        // position instead.
728        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        // Regression test: tab stops must be recognised even when `result.glyphs`
736        // are emitted in UAX#9 L2 *visual* order rather than logical order.
737        //
738        // `text` is pure Hebrew (all embedding level 1, so the line is fully
739        // reversed) with two consecutive TABs. In logical order the glyphs are
740        //   ื(0) ื(2) ื(4) \t(6) \t(7) ื‘(8)
741        // but after L2 reversal the array (visual) order is
742        //   ื‘(8) \t(7) \t(6) ื(4) ื(2) ื(0)
743        // so the two TAB glyphs land at visual indices 1 and 2 โ€” indices whose
744        // *logical* characters ('ื', 'ื') are not tabs at all. Reading the source
745        // character from each positioned glyph's own `cluster` byte offset is the
746        // only way to see them. The two-tab cascade gives an unambiguous signal:
747        // the second TAB in visual order snaps to the first TAB's stop (80.0).
748        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        // Guard: this test only discriminates the visual-vs-logical bug if the
759        // line was actually emitted in reversed (visual) order. The first glyph
760        // must be the final Hebrew letter ื‘ at byte offset 8, and the two TABs
761        // must land at visual indices 1 and 2 โ€” positions whose *logical*
762        // characters are Hebrew letters, not tabs. If bidi did not reverse the
763        // line these guards fail loudly rather than letting the test pass for
764        // the wrong reason.
765        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        // Locate the two TAB glyphs by their cluster byte offset, in array
771        // (visual) order.
772        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        // The second TAB in visual order must sit exactly at the first TAB's
789        // snapped stop (80.0). Under a logical-index lookup the tab at this
790        // visual position is misread as a Hebrew letter and never snapped.
791        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    /// Build a synthetic LayoutResult with known positions for hit-test testing.
920    ///
921    /// Three glyphs on a single line, each 10px wide, baseline_y = 16.
922    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    // --- Incremental relayout API tests ---
1257
1258    #[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        // Produce an initial layout result to use as the cache.
1295        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        // Engine is clean โ€” layout_if_dirty should return the cached result.
1307        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        // Engine should still be clean (no dirty was set, none was cleared).
1323        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        // Mark a range dirty to force relayout.
1337        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        // Dirty markers should be cleared after relayout.
1358        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        // Engine is clean but no cached result โ€” layout_fn must be called.
1374        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    // --- layout_uax14 explicit UAX #14 path ---
1393
1394    #[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        // 11 chars ร— 10px = 110px; max_width = 60px โ†’ wraps after "Hello "
1413        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    // ----- LayoutResult atlas / rasterisation helpers -----
1427
1428    /// Build a minimal `LayoutResult` from explicit `PositionedGlyph` values.
1429    /// `ParagraphMetrics` is constructed with zeroed fields since we only test
1430    /// the helpers and not the metrics themselves.
1431    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        // glyph 65 appears twice; glyph 66 appears once.
1466        // unique_glyphs_for_atlas should return exactly 2 entries.
1467        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        // same glyph ID but different font sizes should be treated as distinct pairs.
1513        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        // sdf_glyph_set is an alias; its output must be identical to unique_glyphs_for_atlas.
1579        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}