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
146pub 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 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 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 let spans = [
799 TextSpan {
800 len: 6, shaping: TextShapingStyle::default(),
802 paint: TextPaintStyle::default(),
803 },
804 TextSpan {
805 len: 5, 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 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 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}