Skip to main content

fret_render_text/
wrapper.rs

1use crate::parley_shaper::{ParleyShaper, ShapedLineLayout};
2use crate::wrapper_balance::balanced_word_wrap_width_px;
3use crate::wrapper_boundaries::hit_test_x;
4#[cfg(test)]
5use crate::wrapper_boundaries::is_grapheme_boundary;
6use crate::wrapper_paragraphs::{
7    wrap_none_ellipsis, wrap_with_newlines, wrap_with_newlines_measure_only,
8};
9use crate::wrapper_ranges::{
10    wrap_grapheme_range, wrap_grapheme_range_measure_only, wrap_word_break_range,
11    wrap_word_break_range_measure_only, wrap_word_range, wrap_word_range_measure_only,
12};
13use fret_core::{CaretAffinity, TextConstraints, TextInputRef, TextOverflow, TextWrap};
14use std::ops::Range;
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct WrappedLayout {
18    text_len: usize,
19    kept_end: usize,
20    line_ranges: Vec<Range<usize>>,
21    lines: Vec<ShapedLineLayout>,
22}
23
24impl WrappedLayout {
25    pub(crate) fn new(
26        text_len: usize,
27        kept_end: usize,
28        line_ranges: Vec<Range<usize>>,
29        lines: Vec<ShapedLineLayout>,
30    ) -> Self {
31        Self {
32            text_len,
33            kept_end,
34            line_ranges,
35            lines,
36        }
37    }
38
39    pub fn text_len(&self) -> usize {
40        self.text_len
41    }
42
43    pub fn kept_end(&self) -> usize {
44        self.kept_end
45    }
46
47    pub fn line_ranges(&self) -> &[Range<usize>] {
48        &self.line_ranges
49    }
50
51    pub fn lines(&self) -> &[ShapedLineLayout] {
52        &self.lines
53    }
54
55    pub fn into_parts(self) -> (usize, usize, Vec<Range<usize>>, Vec<ShapedLineLayout>) {
56        (self.text_len, self.kept_end, self.line_ranges, self.lines)
57    }
58
59    #[allow(dead_code)]
60    pub fn hit_test_x(&self, line_index: usize, x: f32) -> (usize, CaretAffinity) {
61        let Some(line) = self.lines.get(line_index) else {
62            return (0, CaretAffinity::Downstream);
63        };
64        let Some(range) = self.line_ranges.get(line_index) else {
65            return (0, CaretAffinity::Downstream);
66        };
67
68        let (idx_local, affinity) = hit_test_x(line.clusters(), x, range.len());
69        let mut idx = range.start.saturating_add(idx_local);
70        if idx > self.kept_end {
71            idx = self.kept_end;
72        }
73        (idx, affinity)
74    }
75}
76
77pub fn wrap_with_constraints(
78    shaper: &mut ParleyShaper,
79    input: TextInputRef<'_>,
80    constraints: TextConstraints,
81) -> WrappedLayout {
82    let scale = crate::effective_text_scale_factor(constraints.scale_factor);
83    let text_len = match input {
84        TextInputRef::Plain { text, .. } => text.len(),
85        TextInputRef::Attributed { text, .. } => text.len(),
86    };
87
88    let has_newlines = match input {
89        TextInputRef::Plain { text, .. } => text.contains('\n'),
90        TextInputRef::Attributed { text, .. } => text.contains('\n'),
91    };
92    if has_newlines {
93        return wrap_with_newlines(shaper, input, constraints, scale);
94    }
95
96    match constraints {
97        TextConstraints {
98            max_width: Some(max_width),
99            wrap: TextWrap::None,
100            overflow: TextOverflow::Ellipsis,
101            ..
102        } => {
103            let out = wrap_none_ellipsis(shaper, input, text_len, max_width.0 * scale, scale);
104            WrappedLayout::new(
105                text_len,
106                out.kept_end,
107                vec![Range {
108                    start: 0,
109                    end: out.kept_end,
110                }],
111                vec![out.line],
112            )
113        }
114        TextConstraints {
115            max_width: Some(max_width),
116            wrap: TextWrap::Word,
117            ..
118        } => wrap_word(shaper, input, text_len, max_width.0 * scale, scale),
119        TextConstraints {
120            max_width: Some(max_width),
121            wrap: TextWrap::Balance,
122            ..
123        } => wrap_word_balance(shaper, input, text_len, max_width.0 * scale, scale),
124        TextConstraints {
125            max_width: Some(max_width),
126            wrap: TextWrap::WordBreak,
127            ..
128        } => wrap_word_break(shaper, input, text_len, max_width.0 * scale, scale),
129        TextConstraints {
130            max_width: Some(max_width),
131            wrap: TextWrap::Grapheme,
132            ..
133        } => wrap_grapheme(shaper, input, text_len, max_width.0 * scale, scale),
134        _ => WrappedLayout::new(
135            text_len,
136            text_len,
137            vec![Range {
138                start: 0,
139                end: text_len,
140            }],
141            vec![shaper.shape_single_line(input, scale)],
142        ),
143    }
144}
145
146/// Wraps text for measurement only.
147///
148/// The returned `lines[*].glyphs()` is intentionally empty to avoid per-glyph work in layout.
149pub fn wrap_with_constraints_measure_only(
150    shaper: &mut ParleyShaper,
151    input: TextInputRef<'_>,
152    constraints: TextConstraints,
153) -> WrappedLayout {
154    let scale = crate::effective_text_scale_factor(constraints.scale_factor);
155    let text_len = match input {
156        TextInputRef::Plain { text, .. } => text.len(),
157        TextInputRef::Attributed { text, .. } => text.len(),
158    };
159
160    let has_newlines = match input {
161        TextInputRef::Plain { text, .. } => text.contains('\n'),
162        TextInputRef::Attributed { text, .. } => text.contains('\n'),
163    };
164    if has_newlines {
165        return wrap_with_newlines_measure_only(shaper, input, constraints, scale);
166    }
167
168    match constraints {
169        TextConstraints {
170            max_width: Some(max_width),
171            wrap: TextWrap::None,
172            overflow: TextOverflow::Ellipsis,
173            ..
174        } => {
175            let mut line = shaper.shape_single_line_metrics(input, scale);
176            line.set_width(max_width.0 * scale);
177            WrappedLayout::new(
178                text_len,
179                text_len,
180                vec![Range {
181                    start: 0,
182                    end: text_len,
183                }],
184                vec![line],
185            )
186        }
187        TextConstraints {
188            max_width: Some(max_width),
189            wrap: TextWrap::Word,
190            ..
191        } => wrap_word_measure_only(shaper, input, text_len, max_width.0 * scale, scale),
192        TextConstraints {
193            max_width: Some(max_width),
194            wrap: TextWrap::Balance,
195            ..
196        } => wrap_word_balance_measure_only(shaper, input, text_len, max_width.0 * scale, scale),
197        TextConstraints {
198            max_width: Some(max_width),
199            wrap: TextWrap::WordBreak,
200            ..
201        } => wrap_word_break_measure_only(shaper, input, text_len, max_width.0 * scale, scale),
202        TextConstraints {
203            max_width: Some(max_width),
204            wrap: TextWrap::Grapheme,
205            ..
206        } => wrap_grapheme_measure_only(shaper, input, text_len, max_width.0 * scale, scale),
207        _ => WrappedLayout::new(
208            text_len,
209            text_len,
210            vec![Range {
211                start: 0,
212                end: text_len,
213            }],
214            vec![shaper.shape_single_line_metrics(input, scale)],
215        ),
216    }
217}
218
219fn wrap_word_balance(
220    shaper: &mut ParleyShaper,
221    input: TextInputRef<'_>,
222    text_len: usize,
223    max_width_px: f32,
224    scale: f32,
225) -> WrappedLayout {
226    let width_px = balanced_word_wrap_width_px(shaper, input, text_len, max_width_px, scale);
227    wrap_word(shaper, input, text_len, width_px, scale)
228}
229
230fn wrap_word_balance_measure_only(
231    shaper: &mut ParleyShaper,
232    input: TextInputRef<'_>,
233    text_len: usize,
234    max_width_px: f32,
235    scale: f32,
236) -> WrappedLayout {
237    let width_px = balanced_word_wrap_width_px(shaper, input, text_len, max_width_px, scale);
238    wrap_word_measure_only(shaper, input, text_len, width_px, scale)
239}
240
241fn wrap_word(
242    shaper: &mut ParleyShaper,
243    input: TextInputRef<'_>,
244    text_len: usize,
245    max_width_px: f32,
246    scale: f32,
247) -> WrappedLayout {
248    let (text, base, spans) = match input {
249        TextInputRef::Plain { text, style } => (text, style, None),
250        TextInputRef::Attributed { text, base, spans } => (text, base, Some(spans)),
251    };
252
253    let (line_ranges, lines) =
254        wrap_word_range(shaper, text, base, spans, 0..text_len, max_width_px, scale);
255
256    WrappedLayout::new(text_len, text_len, line_ranges, lines)
257}
258
259fn wrap_word_break(
260    shaper: &mut ParleyShaper,
261    input: TextInputRef<'_>,
262    text_len: usize,
263    max_width_px: f32,
264    scale: f32,
265) -> WrappedLayout {
266    let (text, base, spans) = match input {
267        TextInputRef::Plain { text, style } => (text, style, None),
268        TextInputRef::Attributed { text, base, spans } => (text, base, Some(spans)),
269    };
270
271    let (line_ranges, lines) =
272        wrap_word_break_range(shaper, text, base, spans, 0..text_len, max_width_px, scale);
273
274    WrappedLayout::new(text_len, text_len, line_ranges, lines)
275}
276
277fn wrap_grapheme(
278    shaper: &mut ParleyShaper,
279    input: TextInputRef<'_>,
280    text_len: usize,
281    max_width_px: f32,
282    scale: f32,
283) -> WrappedLayout {
284    let (text, base, spans) = match input {
285        TextInputRef::Plain { text, style } => (text, style, None),
286        TextInputRef::Attributed { text, base, spans } => (text, base, Some(spans)),
287    };
288
289    let (line_ranges, lines) =
290        wrap_grapheme_range(shaper, text, base, spans, 0..text_len, max_width_px, scale);
291
292    WrappedLayout::new(text_len, text_len, line_ranges, lines)
293}
294
295pub(crate) fn wrap_word_measure_only(
296    shaper: &mut ParleyShaper,
297    input: TextInputRef<'_>,
298    text_len: usize,
299    max_width_px: f32,
300    scale: f32,
301) -> WrappedLayout {
302    let (text, base, spans) = match input {
303        TextInputRef::Plain { text, style } => (text, style, None),
304        TextInputRef::Attributed { text, base, spans } => (text, base, Some(spans)),
305    };
306
307    let (line_ranges, lines) =
308        wrap_word_range_measure_only(shaper, text, base, spans, 0..text_len, max_width_px, scale);
309
310    WrappedLayout::new(text_len, text_len, line_ranges, lines)
311}
312
313fn wrap_word_break_measure_only(
314    shaper: &mut ParleyShaper,
315    input: TextInputRef<'_>,
316    text_len: usize,
317    max_width_px: f32,
318    scale: f32,
319) -> WrappedLayout {
320    let (text, base, spans) = match input {
321        TextInputRef::Plain { text, style } => (text, style, None),
322        TextInputRef::Attributed { text, base, spans } => (text, base, Some(spans)),
323    };
324
325    let (line_ranges, lines) = wrap_word_break_range_measure_only(
326        shaper,
327        text,
328        base,
329        spans,
330        0..text_len,
331        max_width_px,
332        scale,
333    );
334
335    WrappedLayout::new(text_len, text_len, line_ranges, lines)
336}
337
338fn wrap_grapheme_measure_only(
339    shaper: &mut ParleyShaper,
340    input: TextInputRef<'_>,
341    text_len: usize,
342    max_width_px: f32,
343    scale: f32,
344) -> WrappedLayout {
345    let (text, base, spans) = match input {
346        TextInputRef::Plain { text, style } => (text, style, None),
347        TextInputRef::Attributed { text, base, spans } => (text, base, Some(spans)),
348    };
349
350    let (line_ranges, lines) = wrap_grapheme_range_measure_only(
351        shaper,
352        text,
353        base,
354        spans,
355        0..text_len,
356        max_width_px,
357        scale,
358    );
359
360    WrappedLayout::new(text_len, text_len, line_ranges, lines)
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use fret_core::{FontId, Px, TextPaintStyle, TextShapingStyle, TextSpan, TextStyle};
367    use serde::Deserialize;
368
369    fn shaper_with_bundled_fonts() -> ParleyShaper {
370        let mut shaper = ParleyShaper::new_without_system_fonts();
371        let added = shaper.add_fonts(fret_fonts::test_support::face_blobs(
372            fret_fonts::bootstrap_profile()
373                .faces
374                .iter()
375                .chain(fret_fonts_emoji::default_profile().faces.iter())
376                .chain(fret_fonts_cjk::default_profile().faces.iter()),
377        ));
378        assert!(added > 0, "expected bundled fonts to load");
379        shaper
380    }
381
382    fn is_forbidden_line_start_char(c: char) -> bool {
383        // Minimal “kinsoku”-style set: avoid starting a line with closing punctuation in CJK text.
384        // Keep this conservative; conformance fixtures can expand it as needed.
385        matches!(
386            c,
387            ',' | '。'
388                | '、'
389                | ':'
390                | ';'
391                | '!'
392                | '?'
393                | ')'
394                | '】'
395                | '》'
396                | '〉'
397                | '」'
398                | '』'
399                | '〕'
400                | ']'
401                | '}'
402                | '’'
403                | '”'
404        )
405    }
406
407    fn is_forbidden_line_end_char(c: char) -> bool {
408        // Avoid ending a line with opening punctuation in CJK text.
409        matches!(
410            c,
411            '(' | '【' | '《' | '〈' | '「' | '『' | '〔' | '[' | '{'
412        )
413    }
414
415    #[test]
416    fn none_ellipsis_adds_zero_len_cluster_at_cut_end() {
417        let mut shaper = shaper_with_bundled_fonts();
418        let base = TextStyle {
419            font: FontId::family("Inter"),
420            size: Px(16.0),
421            ..Default::default()
422        };
423
424        let text = "This is a long line that should truncate";
425        let spans = [TextSpan {
426            len: text.len(),
427            shaping: TextShapingStyle::default(),
428            paint: TextPaintStyle::default(),
429        }];
430
431        let constraints = TextConstraints {
432            max_width: Some(Px(80.0)),
433            wrap: TextWrap::None,
434            overflow: TextOverflow::Ellipsis,
435            align: fret_core::TextAlign::Start,
436            scale_factor: 1.0,
437        };
438
439        let wrapped = wrap_with_constraints(
440            &mut shaper,
441            TextInputRef::Attributed {
442                text,
443                base: &base,
444                spans: &spans,
445            },
446            constraints,
447        );
448
449        assert!(wrapped.kept_end < text.len());
450        assert!(
451            wrapped.lines[0]
452                .clusters()
453                .iter()
454                .any(|c| c.text_range() == (wrapped.kept_end..wrapped.kept_end)),
455            "expected a synthetic zero-length cluster for ellipsis mapping"
456        );
457
458        let (hit, _affinity) = wrapped.hit_test_x(0, 79.0);
459        assert_eq!(hit, wrapped.kept_end);
460    }
461
462    #[test]
463    fn none_ellipsis_truncates_single_line_and_respects_max_width() {
464        let mut shaper = shaper_with_bundled_fonts();
465        let base = TextStyle {
466            font: FontId::family("Inter"),
467            size: Px(16.0),
468            ..Default::default()
469        };
470
471        let text = "This is a long line that should truncate";
472        let constraints = TextConstraints {
473            max_width: Some(Px(80.0)),
474            wrap: TextWrap::None,
475            overflow: TextOverflow::Ellipsis,
476            align: fret_core::TextAlign::Start,
477            scale_factor: 1.0,
478        };
479
480        let wrapped =
481            wrap_with_constraints(&mut shaper, TextInputRef::plain(text, &base), constraints);
482
483        assert_eq!(wrapped.lines.len(), 1);
484        assert!(wrapped.kept_end < text.len());
485        assert!(
486            wrapped.lines[0].width() <= 80.0 + 0.5,
487            "expected truncated line width to fit within constraints, got {}",
488            wrapped.lines[0].width()
489        );
490    }
491
492    #[test]
493    fn none_ellipsis_does_not_split_zwj_emoji_grapheme_cluster() {
494        use std::collections::HashSet;
495        use unicode_segmentation::UnicodeSegmentation as _;
496
497        let mut shaper = shaper_with_bundled_fonts();
498        let base = TextStyle {
499            font: FontId::family("Inter"),
500            size: Px(16.0),
501            ..Default::default()
502        };
503
504        let emoji = "👩‍👩‍👧‍👦";
505        let text = format!("{emoji}{emoji}{emoji}{emoji}{emoji} hello");
506
507        let constraints = TextConstraints {
508            max_width: Some(Px(80.0)),
509            wrap: TextWrap::None,
510            overflow: TextOverflow::Ellipsis,
511            align: fret_core::TextAlign::Start,
512            scale_factor: 1.0,
513        };
514
515        let wrapped = wrap_with_constraints(
516            &mut shaper,
517            TextInputRef::plain(text.as_str(), &base),
518            constraints,
519        );
520        assert!(
521            wrapped.kept_end < text.len(),
522            "expected ellipsis to truncate the text"
523        );
524
525        let mut boundaries: HashSet<usize> = HashSet::new();
526        boundaries.insert(0);
527        let mut cursor = 0usize;
528        for g in text.graphemes(true) {
529            cursor = cursor.saturating_add(g.len());
530            boundaries.insert(cursor.min(text.len()));
531        }
532
533        assert!(
534            boundaries.contains(&wrapped.kept_end),
535            "expected ellipsis cut point to land on a grapheme boundary; kept_end={} text={text:?}",
536            wrapped.kept_end
537        );
538    }
539
540    #[test]
541    fn balance_keeps_line_count_and_avoids_shorter_last_line() {
542        let mut shaper = shaper_with_bundled_fonts();
543        let base = TextStyle {
544            font: FontId::family("Inter"),
545            size: Px(16.0),
546            ..Default::default()
547        };
548
549        let text =
550            "You haven't created any projects yet. Get started by creating your first project.";
551        let max_width = Px(240.0);
552
553        let word = wrap_with_constraints_measure_only(
554            &mut shaper,
555            TextInputRef::plain(text, &base),
556            TextConstraints {
557                max_width: Some(max_width),
558                wrap: TextWrap::Word,
559                overflow: TextOverflow::Clip,
560                align: fret_core::TextAlign::Start,
561                scale_factor: 1.0,
562            },
563        );
564        assert!(
565            word.lines.len() >= 2,
566            "expected the fixture text to wrap under the chosen width"
567        );
568        let word_last = word.lines.last().unwrap().width();
569
570        let balanced = wrap_with_constraints_measure_only(
571            &mut shaper,
572            TextInputRef::plain(text, &base),
573            TextConstraints {
574                max_width: Some(max_width),
575                wrap: TextWrap::Balance,
576                overflow: TextOverflow::Clip,
577                align: fret_core::TextAlign::Start,
578                scale_factor: 1.0,
579            },
580        );
581
582        assert_eq!(balanced.lines.len(), word.lines.len());
583        let balanced_last = balanced.lines.last().unwrap().width();
584        assert!(
585            balanced_last + 0.5 >= word_last,
586            "expected balanced wrap to avoid a shorter last line; word_last={word_last} balanced_last={balanced_last}"
587        );
588        assert!(
589            balanced
590                .lines
591                .iter()
592                .all(|l| l.width() <= max_width.0 + 0.5),
593            "expected balanced lines to respect max_width"
594        );
595    }
596
597    #[test]
598    fn none_ellipsis_does_not_split_keycap_grapheme_cluster() {
599        use std::collections::HashSet;
600        use unicode_segmentation::UnicodeSegmentation as _;
601
602        let mut shaper = shaper_with_bundled_fonts();
603        let base = TextStyle {
604            font: FontId::family("Inter"),
605            size: Px(16.0),
606            ..Default::default()
607        };
608
609        let keycap = "1️⃣";
610        let text = format!("{keycap}{keycap}{keycap}{keycap}{keycap}{keycap}{keycap} hello");
611
612        let constraints = TextConstraints {
613            max_width: Some(Px(70.0)),
614            wrap: TextWrap::None,
615            overflow: TextOverflow::Ellipsis,
616            align: fret_core::TextAlign::Start,
617            scale_factor: 1.0,
618        };
619
620        let wrapped = wrap_with_constraints(
621            &mut shaper,
622            TextInputRef::plain(text.as_str(), &base),
623            constraints,
624        );
625        assert!(
626            wrapped.kept_end < text.len(),
627            "expected ellipsis to truncate the text"
628        );
629
630        let mut boundaries: HashSet<usize> = HashSet::new();
631        boundaries.insert(0);
632        let mut cursor = 0usize;
633        for g in text.graphemes(true) {
634            cursor = cursor.saturating_add(g.len());
635            boundaries.insert(cursor.min(text.len()));
636        }
637
638        assert!(
639            boundaries.contains(&wrapped.kept_end),
640            "expected ellipsis cut point to land on a grapheme boundary; kept_end={} text={text:?}",
641            wrapped.kept_end
642        );
643    }
644
645    #[test]
646    fn none_ellipsis_does_not_split_regional_indicator_flag_grapheme_cluster() {
647        use std::collections::HashSet;
648        use unicode_segmentation::UnicodeSegmentation as _;
649
650        let mut shaper = shaper_with_bundled_fonts();
651        let base = TextStyle {
652            font: FontId::family("Inter"),
653            size: Px(16.0),
654            ..Default::default()
655        };
656
657        let flag = "🇺🇸";
658        let text = format!("{flag}{flag}{flag}{flag}{flag}{flag}{flag}{flag} hello");
659
660        let constraints = TextConstraints {
661            max_width: Some(Px(70.0)),
662            wrap: TextWrap::None,
663            overflow: TextOverflow::Ellipsis,
664            align: fret_core::TextAlign::Start,
665            scale_factor: 1.0,
666        };
667
668        let wrapped = wrap_with_constraints(
669            &mut shaper,
670            TextInputRef::plain(text.as_str(), &base),
671            constraints,
672        );
673        assert!(
674            wrapped.kept_end < text.len(),
675            "expected ellipsis to truncate the text"
676        );
677
678        let mut boundaries: HashSet<usize> = HashSet::new();
679        boundaries.insert(0);
680        let mut cursor = 0usize;
681        for g in text.graphemes(true) {
682            cursor = cursor.saturating_add(g.len());
683            boundaries.insert(cursor.min(text.len()));
684        }
685
686        assert!(
687            boundaries.contains(&wrapped.kept_end),
688            "expected ellipsis cut point to land on a grapheme boundary; kept_end={} text={text:?}",
689            wrapped.kept_end
690        );
691    }
692
693    #[test]
694    fn no_ellipsis_keeps_full_text() {
695        let mut shaper = shaper_with_bundled_fonts();
696        let base = TextStyle {
697            font: FontId::family("Inter"),
698            size: Px(16.0),
699            ..Default::default()
700        };
701
702        let text = "short";
703        let spans = [TextSpan {
704            len: text.len(),
705            shaping: TextShapingStyle::default(),
706            paint: TextPaintStyle::default(),
707        }];
708
709        let constraints = TextConstraints {
710            max_width: Some(Px(800.0)),
711            wrap: TextWrap::None,
712            overflow: TextOverflow::Ellipsis,
713            align: fret_core::TextAlign::Start,
714            scale_factor: 1.0,
715        };
716
717        let wrapped = wrap_with_constraints(
718            &mut shaper,
719            TextInputRef::Attributed {
720                text,
721                base: &base,
722                spans: &spans,
723            },
724            constraints,
725        );
726
727        assert_eq!(wrapped.kept_end, text.len());
728    }
729
730    #[test]
731    fn wrap_uses_scale_factor_below_one() {
732        let mut shaper = shaper_with_bundled_fonts();
733        let base = TextStyle {
734            font: FontId::family("Inter"),
735            size: Px(16.0),
736            ..Default::default()
737        };
738
739        let text = "hello world";
740        let constraints_1x = TextConstraints {
741            max_width: None,
742            wrap: TextWrap::None,
743            overflow: TextOverflow::Clip,
744            align: fret_core::TextAlign::Start,
745            scale_factor: 1.0,
746        };
747        let constraints_half = TextConstraints {
748            scale_factor: 0.5,
749            ..constraints_1x
750        };
751
752        let a = wrap_with_constraints(
753            &mut shaper,
754            TextInputRef::plain(text, &base),
755            constraints_1x,
756        );
757        let b = wrap_with_constraints(
758            &mut shaper,
759            TextInputRef::plain(text, &base),
760            constraints_half,
761        );
762
763        let Some(font_a) = a
764            .lines
765            .first()
766            .and_then(|l| l.glyphs().first())
767            .map(|g| g.font_size())
768        else {
769            panic!("expected shaped glyphs for scale=1.0");
770        };
771        let Some(font_b) = b
772            .lines
773            .first()
774            .and_then(|l| l.glyphs().first())
775            .map(|g| g.font_size())
776        else {
777            panic!("expected shaped glyphs for scale=0.5");
778        };
779
780        let ratio = font_b / font_a.max(1.0);
781        assert!(
782            (ratio - 0.5).abs() <= 0.15,
783            "expected shaped glyph font_size to scale with constraints.scale_factor; font_a={font_a} font_b={font_b} ratio={ratio}",
784        );
785    }
786
787    #[test]
788    fn word_wrap_produces_multiple_lines_and_full_coverage() {
789        let mut shaper = shaper_with_bundled_fonts();
790        let base = TextStyle {
791            font: FontId::family("Inter"),
792            size: Px(16.0),
793            ..Default::default()
794        };
795
796        let text = "hello world hello world hello world";
797        // Multiple spans to ensure wrapping remains correct across span boundaries.
798        let spans = [
799            TextSpan {
800                len: 6, // "hello "
801                shaping: TextShapingStyle::default(),
802                paint: TextPaintStyle::default(),
803            },
804            TextSpan {
805                len: 5, // "world"
806                shaping: TextShapingStyle::default(),
807                paint: TextPaintStyle::default(),
808            },
809            TextSpan {
810                len: text.len().saturating_sub(11),
811                shaping: TextShapingStyle::default(),
812                paint: TextPaintStyle::default(),
813            },
814        ];
815
816        let constraints = TextConstraints {
817            max_width: Some(Px(60.0)),
818            wrap: TextWrap::Word,
819            overflow: TextOverflow::Clip,
820            align: fret_core::TextAlign::Start,
821            scale_factor: 1.0,
822        };
823
824        let wrapped = wrap_with_constraints(
825            &mut shaper,
826            TextInputRef::Attributed {
827                text,
828                base: &base,
829                spans: &spans,
830            },
831            constraints,
832        );
833
834        assert!(wrapped.lines.len() > 1);
835        assert_eq!(wrapped.line_ranges.first().unwrap().start, 0);
836        assert_eq!(wrapped.line_ranges.last().unwrap().end, text.len());
837        for w in wrapped.line_ranges.windows(2) {
838            assert_eq!(w[0].end, w[1].start);
839        }
840    }
841
842    #[test]
843    fn parley_word_wrap_handles_long_plain_paragraph_under_resize_jitter() {
844        let mut shaper = shaper_with_bundled_fonts();
845        let base = TextStyle {
846            font: FontId::family("Fira Mono"),
847            size: Px(16.0),
848            ..Default::default()
849        };
850
851        let mut text = String::new();
852        for i in 0..500 {
853            if i > 0 {
854                text.push(' ');
855            }
856            text.push_str("word");
857            text.push_str(&(i % 97).to_string());
858        }
859
860        let widths = [60.0, 80.0, 120.0, 90.0, 70.0, 140.0, 60.0];
861        for w in widths {
862            let constraints = TextConstraints {
863                max_width: Some(Px(w)),
864                wrap: TextWrap::Word,
865                overflow: TextOverflow::Clip,
866                align: fret_core::TextAlign::Start,
867                scale_factor: 1.0,
868            };
869            let wrapped =
870                wrap_with_constraints(&mut shaper, TextInputRef::plain(&text, &base), constraints);
871
872            assert_eq!(wrapped.text_len, text.len());
873            assert!(!wrapped.line_ranges.is_empty());
874            assert_eq!(wrapped.line_ranges[0].start, 0);
875            assert_eq!(wrapped.line_ranges.last().unwrap().end, text.len());
876
877            for r in &wrapped.line_ranges {
878                assert!(text.is_char_boundary(r.start));
879                assert!(text.is_char_boundary(r.end));
880                assert!(r.start < r.end, "expected non-empty line range");
881            }
882            for win in wrapped.line_ranges.windows(2) {
883                assert_eq!(
884                    win[0].end, win[1].start,
885                    "expected contiguous coverage for a single-paragraph plain text wrap"
886                );
887            }
888        }
889    }
890
891    #[test]
892    fn word_wrap_does_not_break_single_token() {
893        let mut shaper = shaper_with_bundled_fonts();
894        let base = TextStyle {
895            font: FontId::family("Inter"),
896            size: Px(20.0),
897            ..Default::default()
898        };
899
900        let text = "Demo";
901        let constraints = TextConstraints {
902            max_width: Some(Px(1.0)),
903            wrap: TextWrap::Word,
904            overflow: TextOverflow::Clip,
905            align: fret_core::TextAlign::Start,
906            scale_factor: 1.0,
907        };
908
909        let wrapped = wrap_with_constraints_measure_only(
910            &mut shaper,
911            TextInputRef::plain(text, &base),
912            constraints,
913        );
914
915        assert_eq!(wrapped.lines.len(), 1);
916        assert!(
917            wrapped.lines[0].width() > 1.0,
918            "expected word-wrap to keep a single token unbroken and allow overflow"
919        );
920    }
921
922    #[test]
923    fn word_wrap_min_content_width_matches_longest_unbreakable_segment() {
924        let mut shaper = shaper_with_bundled_fonts();
925        let base = TextStyle {
926            font: FontId::family("Inter"),
927            size: Px(20.0),
928            ..Default::default()
929        };
930
931        // Under a near-zero wrap width, word-wrap should break at whitespace opportunities, but
932        // must not break within tokens. The resulting wrapped lines should therefore represent
933        // the "unbreakable segments" whose maximum width matches min-content semantics.
934        let text = "foo barbaz qux";
935        let wrapped = wrap_with_constraints_measure_only(
936            &mut shaper,
937            TextInputRef::plain(text, &base),
938            TextConstraints {
939                max_width: Some(Px(0.0)),
940                wrap: TextWrap::Word,
941                overflow: TextOverflow::Clip,
942                align: fret_core::TextAlign::Start,
943                scale_factor: 1.0,
944            },
945        );
946
947        assert!(
948            wrapped.lines.len() >= 2,
949            "expected near-zero word-wrap to produce multiple visual lines for spaced text"
950        );
951        assert_eq!(
952            wrapped.lines.len(),
953            wrapped.line_ranges.len(),
954            "expected line_ranges to match wrapped line count"
955        );
956
957        // Validate each produced line width against an independently shaped single-line slice
958        // matching the wrapped range. This avoids making assumptions about whether trailing
959        // whitespace is kept at soft wrap boundaries.
960        for (range, line) in wrapped.line_ranges.iter().zip(wrapped.lines.iter()) {
961            let slice = &text[range.clone()];
962            let expected = shaper.shape_single_line_metrics(TextInputRef::plain(slice, &base), 1.0);
963            let delta = (expected.width() - line.width()).abs();
964            assert!(
965                delta <= 0.75,
966                "expected wrapped line width to match shaped slice; slice={:?} expected={} actual={} delta={}",
967                slice,
968                expected.width(),
969                line.width(),
970                delta
971            );
972        }
973
974        let max_line_w = wrapped
975            .lines
976            .iter()
977            .map(|l| l.width())
978            .fold(0.0f32, f32::max);
979        assert!(
980            max_line_w > 0.0,
981            "expected non-zero min-content width for non-empty text"
982        );
983    }
984
985    #[test]
986    fn word_break_wrap_can_break_single_token() {
987        let mut shaper = shaper_with_bundled_fonts();
988        let base = TextStyle {
989            font: FontId::family("Inter"),
990            size: Px(20.0),
991            ..Default::default()
992        };
993
994        let text = "Demo";
995        let constraints = TextConstraints {
996            max_width: Some(Px(1.0)),
997            wrap: TextWrap::WordBreak,
998            overflow: TextOverflow::Clip,
999            align: fret_core::TextAlign::Start,
1000            scale_factor: 1.0,
1001        };
1002
1003        let wrapped = wrap_with_constraints_measure_only(
1004            &mut shaper,
1005            TextInputRef::plain(text, &base),
1006            constraints,
1007        );
1008
1009        assert!(
1010            wrapped.lines.len() > 1,
1011            "expected word-break wrap to split a single long token under tight constraints"
1012        );
1013    }
1014
1015    #[test]
1016    fn parley_word_wrap_handles_long_attributed_paragraph_under_resize_jitter() {
1017        let mut shaper = shaper_with_bundled_fonts();
1018        let base = TextStyle {
1019            font: FontId::family("Fira Mono"),
1020            size: Px(16.0),
1021            ..Default::default()
1022        };
1023
1024        let mut text = String::new();
1025        for i in 0..500 {
1026            if i > 0 {
1027                text.push(' ');
1028            }
1029            text.push_str("word");
1030            text.push_str(&(i % 97).to_string());
1031        }
1032
1033        let text_len = text.len();
1034        let mut spans: Vec<TextSpan> = Vec::new();
1035        let mut remaining = text_len;
1036        let mut toggle = false;
1037        while remaining > 0 {
1038            let take = remaining.min(if toggle { 17 } else { 31 });
1039            spans.push(TextSpan {
1040                len: take,
1041                shaping: TextShapingStyle::default(),
1042                paint: if toggle {
1043                    TextPaintStyle {
1044                        fg: Some(fret_core::Color {
1045                            r: 0.9,
1046                            g: 0.1,
1047                            b: 0.1,
1048                            a: 1.0,
1049                        }),
1050                        ..Default::default()
1051                    }
1052                } else {
1053                    TextPaintStyle::default()
1054                },
1055            });
1056            remaining = remaining.saturating_sub(take);
1057            toggle = !toggle;
1058        }
1059        assert_eq!(
1060            spans.iter().map(|s| s.len).sum::<usize>(),
1061            text_len,
1062            "spans must fully cover the text"
1063        );
1064
1065        let widths = [60.0, 80.0, 120.0, 90.0, 70.0, 140.0, 60.0];
1066        for w in widths {
1067            let constraints = TextConstraints {
1068                max_width: Some(Px(w)),
1069                wrap: TextWrap::Word,
1070                overflow: TextOverflow::Clip,
1071                align: fret_core::TextAlign::Start,
1072                scale_factor: 1.0,
1073            };
1074            let wrapped = wrap_with_constraints(
1075                &mut shaper,
1076                TextInputRef::Attributed {
1077                    text: text.as_str(),
1078                    base: &base,
1079                    spans: spans.as_slice(),
1080                },
1081                constraints,
1082            );
1083
1084            assert_eq!(wrapped.text_len, text_len);
1085            assert_eq!(wrapped.kept_end, text_len);
1086            assert!(!wrapped.line_ranges.is_empty());
1087            assert_eq!(wrapped.line_ranges[0].start, 0);
1088            assert_eq!(wrapped.line_ranges.last().unwrap().end, text_len);
1089            assert_eq!(wrapped.lines.len(), wrapped.line_ranges.len());
1090
1091            for r in &wrapped.line_ranges {
1092                assert!(text.is_char_boundary(r.start));
1093                assert!(text.is_char_boundary(r.end));
1094                assert!(r.start < r.end, "expected non-empty line range");
1095            }
1096            for win in wrapped.line_ranges.windows(2) {
1097                assert_eq!(
1098                    win[0].end, win[1].start,
1099                    "expected contiguous coverage for a single-paragraph attributed text wrap"
1100                );
1101            }
1102        }
1103    }
1104
1105    #[test]
1106    fn newlines_split_into_paragraphs_and_create_gaps_in_ranges() {
1107        let mut shaper = shaper_with_bundled_fonts();
1108        let base = TextStyle {
1109            font: FontId::family("Inter"),
1110            size: Px(16.0),
1111            ..Default::default()
1112        };
1113
1114        let text = "hello\nworld";
1115        let spans = [TextSpan {
1116            len: text.len(),
1117            shaping: TextShapingStyle::default(),
1118            paint: TextPaintStyle::default(),
1119        }];
1120
1121        let constraints = TextConstraints {
1122            max_width: Some(Px(40.0)),
1123            wrap: TextWrap::Word,
1124            overflow: TextOverflow::Clip,
1125            align: fret_core::TextAlign::Start,
1126            scale_factor: 1.0,
1127        };
1128
1129        let wrapped = wrap_with_constraints(
1130            &mut shaper,
1131            TextInputRef::Attributed {
1132                text,
1133                base: &base,
1134                spans: &spans,
1135            },
1136            constraints,
1137        );
1138
1139        assert!(wrapped.lines.len() >= 2);
1140        assert_eq!(wrapped.line_ranges.first().unwrap().start, 0);
1141        assert_eq!(
1142            wrapped.line_ranges.last().unwrap().end,
1143            text.len(),
1144            "last line should end at the full text length"
1145        );
1146
1147        assert!(
1148            wrapped
1149                .line_ranges
1150                .windows(2)
1151                .any(|w| w[0].end + 1 == w[1].start),
1152            "expected at least one paragraph boundary gap caused by a newline"
1153        );
1154    }
1155
1156    #[test]
1157    fn empty_lines_produce_lines_for_consecutive_newlines() {
1158        let mut shaper = shaper_with_bundled_fonts();
1159        let base = TextStyle {
1160            font: FontId::family("Inter"),
1161            size: Px(16.0),
1162            ..Default::default()
1163        };
1164
1165        let text = "\n";
1166        let constraints = TextConstraints {
1167            max_width: Some(Px(40.0)),
1168            wrap: TextWrap::Word,
1169            overflow: TextOverflow::Clip,
1170            align: fret_core::TextAlign::Start,
1171            scale_factor: 1.0,
1172        };
1173
1174        let wrapped =
1175            wrap_with_constraints(&mut shaper, TextInputRef::plain(text, &base), constraints);
1176        assert_eq!(wrapped.lines.len(), 2, "expected two empty paragraphs");
1177        assert_eq!(wrapped.line_ranges.len(), 2);
1178        assert_eq!(wrapped.line_ranges[0], 0..0);
1179        assert_eq!(wrapped.line_ranges[1], 1..1);
1180    }
1181
1182    #[test]
1183    fn strut_force_keeps_multiline_baseline_stable_across_fallback_glyphs() {
1184        let mut shaper = shaper_with_bundled_fonts();
1185        let base = TextStyle {
1186            font: FontId::family("Inter"),
1187            size: Px(16.0),
1188            strut_style: Some(fret_core::TextStrutStyle {
1189                force: true,
1190                line_height: Some(Px(18.0)),
1191                ..Default::default()
1192            }),
1193            ..Default::default()
1194        };
1195
1196        let text = "Settings\nSettings 😄\nSettings 漢字\n😀 你好";
1197        let constraints = TextConstraints {
1198            max_width: Some(Px(1000.0)),
1199            wrap: TextWrap::Word,
1200            overflow: TextOverflow::Clip,
1201            align: fret_core::TextAlign::Start,
1202            scale_factor: 1.0,
1203        };
1204
1205        let wrapped =
1206            wrap_with_constraints(&mut shaper, TextInputRef::plain(text, &base), constraints);
1207        assert_eq!(wrapped.lines.len(), 4, "expected one line per paragraph");
1208
1209        let first = &wrapped.lines[0];
1210        for (i, line) in wrapped.lines.iter().enumerate() {
1211            assert!(
1212                (line.line_height() - 18.0).abs() < 0.01,
1213                "expected fixed strut line_height=18px; line[{i}] line_height={}",
1214                line.line_height()
1215            );
1216            assert!(
1217                (line.baseline() - first.baseline()).abs() < 0.01,
1218                "expected strut baseline to be stable across fallback glyphs; line[{i}] baseline={} first={}",
1219                line.baseline(),
1220                first.baseline()
1221            );
1222        }
1223    }
1224
1225    #[test]
1226    fn wrap_measure_only_matches_line_ranges_and_sizes_for_word_wrap() {
1227        let mut shaper_full = shaper_with_bundled_fonts();
1228        let mut shaper_measure = shaper_with_bundled_fonts();
1229        let base = TextStyle {
1230            font: FontId::family("Inter"),
1231            size: Px(16.0),
1232            ..Default::default()
1233        };
1234
1235        let text = "hello world hello world hello world hello world hello world hello world";
1236        let spans = [TextSpan {
1237            len: text.len(),
1238            shaping: TextShapingStyle::default(),
1239            paint: TextPaintStyle::default(),
1240        }];
1241
1242        let constraints = TextConstraints {
1243            max_width: Some(Px(60.0)),
1244            wrap: TextWrap::Word,
1245            overflow: TextOverflow::Clip,
1246            align: fret_core::TextAlign::Start,
1247            scale_factor: 1.0,
1248        };
1249
1250        let full = wrap_with_constraints(
1251            &mut shaper_full,
1252            TextInputRef::Attributed {
1253                text,
1254                base: &base,
1255                spans: &spans,
1256            },
1257            constraints,
1258        );
1259        let measure = wrap_with_constraints_measure_only(
1260            &mut shaper_measure,
1261            TextInputRef::Attributed {
1262                text,
1263                base: &base,
1264                spans: &spans,
1265            },
1266            constraints,
1267        );
1268
1269        assert_eq!(full.line_ranges, measure.line_ranges);
1270        assert_eq!(full.lines.len(), measure.lines.len());
1271        for (a, b) in full.lines.iter().zip(measure.lines.iter()) {
1272            assert!((a.width() - b.width()).abs() < 0.01);
1273            assert!((a.line_height() - b.line_height()).abs() < 0.01);
1274        }
1275        assert!(measure.lines.iter().all(|l| l.glyphs().is_empty()));
1276    }
1277
1278    #[test]
1279    fn wrap_measure_only_matches_line_ranges_and_sizes_for_grapheme_wrap() {
1280        let mut shaper_full = shaper_with_bundled_fonts();
1281        let mut shaper_measure = shaper_with_bundled_fonts();
1282        let base = TextStyle {
1283            font: FontId::family("Fira Mono"),
1284            size: Px(16.0),
1285            ..Default::default()
1286        };
1287
1288        let text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1289        let constraints = TextConstraints {
1290            max_width: Some(Px(40.0)),
1291            wrap: TextWrap::Grapheme,
1292            overflow: TextOverflow::Clip,
1293            align: fret_core::TextAlign::Start,
1294            scale_factor: 1.0,
1295        };
1296
1297        let full = wrap_with_constraints(
1298            &mut shaper_full,
1299            TextInputRef::plain(text, &base),
1300            constraints,
1301        );
1302        let measure = wrap_with_constraints_measure_only(
1303            &mut shaper_measure,
1304            TextInputRef::plain(text, &base),
1305            constraints,
1306        );
1307
1308        assert_eq!(full.line_ranges, measure.line_ranges);
1309        assert_eq!(full.lines.len(), measure.lines.len());
1310        for (a, b) in full.lines.iter().zip(measure.lines.iter()) {
1311            assert!((a.width() - b.width()).abs() < 0.01);
1312            assert!((a.line_height() - b.line_height()).abs() < 0.01);
1313        }
1314        assert!(measure.lines.iter().all(|l| l.glyphs().is_empty()));
1315    }
1316
1317    #[test]
1318    fn grapheme_wrap_breaks_long_token_without_spaces() {
1319        let mut shaper = shaper_with_bundled_fonts();
1320        let base = TextStyle {
1321            font: FontId::family("Fira Mono"),
1322            size: Px(16.0),
1323            ..Default::default()
1324        };
1325
1326        let text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1327        let constraints = TextConstraints {
1328            max_width: Some(Px(40.0)),
1329            wrap: TextWrap::Grapheme,
1330            overflow: TextOverflow::Clip,
1331            align: fret_core::TextAlign::Start,
1332            scale_factor: 1.0,
1333        };
1334
1335        let wrapped =
1336            wrap_with_constraints(&mut shaper, TextInputRef::plain(text, &base), constraints);
1337        assert!(wrapped.lines.len() > 1);
1338        assert_eq!(wrapped.line_ranges.first().unwrap().start, 0);
1339        assert_eq!(wrapped.line_ranges.last().unwrap().end, text.len());
1340        for w in wrapped.line_ranges.windows(2) {
1341            assert_eq!(w[0].end, w[1].start);
1342        }
1343    }
1344
1345    #[test]
1346    fn grapheme_wrap_handles_cjk_string() {
1347        let mut shaper = shaper_with_bundled_fonts();
1348        let base = TextStyle {
1349            font: FontId::family("Noto Sans CJK SC"),
1350            size: Px(16.0),
1351            ..Default::default()
1352        };
1353
1354        let text = "你好世界你好世界你好世界你好世界你好世界";
1355        let constraints = TextConstraints {
1356            max_width: Some(Px(40.0)),
1357            wrap: TextWrap::Grapheme,
1358            overflow: TextOverflow::Clip,
1359            align: fret_core::TextAlign::Start,
1360            scale_factor: 1.0,
1361        };
1362
1363        let wrapped =
1364            wrap_with_constraints(&mut shaper, TextInputRef::plain(text, &base), constraints);
1365        assert!(wrapped.lines.len() > 1);
1366        assert_eq!(wrapped.line_ranges.first().unwrap().start, 0);
1367        assert_eq!(wrapped.line_ranges.last().unwrap().end, text.len());
1368        for w in wrapped.line_ranges.windows(2) {
1369            assert_eq!(w[0].end, w[1].start);
1370        }
1371    }
1372
1373    #[test]
1374    fn grapheme_wrap_does_not_split_zwj_clusters() {
1375        let mut shaper = shaper_with_bundled_fonts();
1376        let base = TextStyle {
1377            font: FontId::family("Noto Color Emoji"),
1378            size: Px(16.0),
1379            ..Default::default()
1380        };
1381
1382        let emoji = "👨‍👩‍👧‍👦";
1383        let text = format!("{emoji}{emoji}{emoji}{emoji}{emoji}");
1384        let constraints = TextConstraints {
1385            max_width: Some(Px(60.0)),
1386            wrap: TextWrap::Grapheme,
1387            overflow: TextOverflow::Clip,
1388            align: fret_core::TextAlign::Start,
1389            scale_factor: 1.0,
1390        };
1391
1392        let wrapped =
1393            wrap_with_constraints(&mut shaper, TextInputRef::plain(&text, &base), constraints);
1394        assert!(wrapped.lines.len() > 1);
1395        for r in &wrapped.line_ranges {
1396            assert!(
1397                is_grapheme_boundary(&text, r.start),
1398                "expected line start to be a grapheme boundary: {:?}",
1399                r
1400            );
1401            assert!(
1402                is_grapheme_boundary(&text, r.end),
1403                "expected line end to be a grapheme boundary: {:?}",
1404                r
1405            );
1406        }
1407    }
1408
1409    #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1410    #[serde(rename_all = "snake_case")]
1411    enum FixtureWrapMode {
1412        Word,
1413        WordBreak,
1414        Grapheme,
1415    }
1416
1417    #[derive(Debug, Clone, Deserialize)]
1418    struct WrapFixtureCase {
1419        id: String,
1420        text: String,
1421        font_family: String,
1422        wrap: FixtureWrapMode,
1423        max_width_px: f32,
1424        #[serde(default)]
1425        assert_no_forbidden_punct: bool,
1426        #[serde(default)]
1427        expected_line_ranges: Option<Vec<[usize; 2]>>,
1428    }
1429
1430    #[derive(Debug, Deserialize)]
1431    struct WrapFixtureSuite {
1432        schema_version: u32,
1433        cases: Vec<WrapFixtureCase>,
1434    }
1435
1436    fn wrap_mode_for_fixture(mode: FixtureWrapMode) -> TextWrap {
1437        match mode {
1438            FixtureWrapMode::Word => TextWrap::Word,
1439            FixtureWrapMode::WordBreak => TextWrap::WordBreak,
1440            FixtureWrapMode::Grapheme => TextWrap::Grapheme,
1441        }
1442    }
1443
1444    fn sanitize_line_ranges_for_fixture(ranges: &[std::ops::Range<usize>]) -> Vec<[usize; 2]> {
1445        ranges.iter().map(|r| [r.start, r.end]).collect()
1446    }
1447
1448    fn run_text_wrap_conformance_v1_fixtures() {
1449        let raw = include_str!(concat!(
1450            env!("CARGO_MANIFEST_DIR"),
1451            "/src/text/tests/fixtures/text_wrap_conformance_v1.json"
1452        ));
1453        let suite: WrapFixtureSuite =
1454            serde_json::from_str(raw).expect("wrap conformance fixtures JSON");
1455        assert_eq!(suite.schema_version, 2);
1456
1457        let mut shaper = shaper_with_bundled_fonts();
1458
1459        let mut failures: Vec<String> = Vec::new();
1460        for case in suite.cases {
1461            let style = TextStyle {
1462                font: FontId::family(case.font_family.clone()),
1463                size: Px(16.0),
1464                ..Default::default()
1465            };
1466            let constraints = TextConstraints {
1467                max_width: Some(Px(case.max_width_px)),
1468                wrap: wrap_mode_for_fixture(case.wrap),
1469                overflow: TextOverflow::Clip,
1470                align: fret_core::TextAlign::Start,
1471                scale_factor: 1.0,
1472            };
1473
1474            let wrapped = wrap_with_constraints(
1475                &mut shaper,
1476                TextInputRef::plain(&case.text, &style),
1477                constraints,
1478            );
1479
1480            let text_len = case.text.len();
1481            assert_eq!(
1482                wrapped.text_len, text_len,
1483                "case {}: expected wrapper text_len to match input length",
1484                case.id
1485            );
1486            assert!(
1487                !wrapped.line_ranges.is_empty(),
1488                "case {}: expected at least one line range",
1489                case.id
1490            );
1491
1492            for r in &wrapped.line_ranges {
1493                assert!(
1494                    r.start <= r.end && r.end <= text_len,
1495                    "case {}: invalid line range {r:?} for len={text_len}",
1496                    case.id
1497                );
1498            }
1499
1500            for w in wrapped.line_ranges.windows(2) {
1501                let prev = &w[0];
1502                let next = &w[1];
1503                assert!(
1504                    prev.end <= next.start,
1505                    "case {}: expected non-decreasing line ranges: prev={prev:?} next={next:?}",
1506                    case.id
1507                );
1508                if next.start > prev.end {
1509                    let gap = &case.text[prev.end..next.start];
1510                    assert!(
1511                        gap.chars().all(|ch| ch == '\n'),
1512                        "case {}: expected paragraph gaps to contain only newlines (gap={gap:?})",
1513                        case.id
1514                    );
1515                }
1516            }
1517
1518            if case.assert_no_forbidden_punct {
1519                for r in &wrapped.line_ranges {
1520                    if r.start < text_len {
1521                        let start_ch = case.text[r.start..]
1522                            .chars()
1523                            .next()
1524                            .expect("expected start char");
1525                        assert!(
1526                            !is_forbidden_line_start_char(start_ch),
1527                            "case {}: expected line not to start with forbidden punctuation: start={:?} range={:?}",
1528                            case.id,
1529                            start_ch,
1530                            r
1531                        );
1532                    }
1533
1534                    if r.end > r.start {
1535                        let line = &case.text[r.start..r.end];
1536                        let mut it = line.chars();
1537                        let Some(mut end_ch) = it.next_back() else {
1538                            continue;
1539                        };
1540                        while matches!(end_ch, '\n' | ' ') {
1541                            let Some(prev) = it.next_back() else {
1542                                break;
1543                            };
1544                            end_ch = prev;
1545                        }
1546
1547                        assert!(
1548                            !is_forbidden_line_end_char(end_ch),
1549                            "case {}: expected line not to end with forbidden punctuation: end={:?} range={:?}",
1550                            case.id,
1551                            end_ch,
1552                            r
1553                        );
1554                    }
1555                }
1556            }
1557
1558            let got = sanitize_line_ranges_for_fixture(&wrapped.line_ranges);
1559            match case.expected_line_ranges.as_ref() {
1560                None => failures.push(format!(
1561                    "case {}: missing expected_line_ranges; computed={got:?}",
1562                    case.id
1563                )),
1564                Some(expected) => {
1565                    if &got != expected {
1566                        failures.push(format!(
1567                            "case {}: line ranges mismatch: expected={expected:?} got={got:?}",
1568                            case.id
1569                        ));
1570                    }
1571                }
1572            }
1573        }
1574
1575        assert!(
1576            failures.is_empty(),
1577            "wrap conformance fixture failures:\n{}",
1578            failures.join("\n")
1579        );
1580    }
1581
1582    #[test]
1583    fn text_wrap_conformance_v1_fixtures() {
1584        run_text_wrap_conformance_v1_fixtures();
1585    }
1586
1587    #[cfg(target_arch = "wasm32")]
1588    mod wasm_wrap_conformance {
1589        use super::*;
1590        use wasm_bindgen_test::*;
1591
1592        #[wasm_bindgen_test]
1593        fn text_wrap_conformance_v1_fixtures_wasm() {
1594            run_text_wrap_conformance_v1_fixtures();
1595        }
1596    }
1597}