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