1use crate::error::{LayoutError, Result};
6use crate::font::{FontManager, MultilingualTextSegment, TextDirection, explicit_direction_levels};
7use crate::output::{
8 Color, FieldKind, FieldSource, FontId, GroupElement, MediaId, SourceSpan, StructureId,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct TabStop {
14 pub pos_pt: f64,
15 pub align: TabAlign,
16 pub leader: Option<TabLeader>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Align {
22 Start,
23 Center,
24 End,
25 Justify,
26 Distribute,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum TabAlign {
32 Left,
33 Center,
34 Right,
35 Decimal,
36 Bar,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TabLeader {
42 None,
43 Dot,
44 Hyphen,
45 Underscore,
46 Heavy,
47 MiddleDot,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum Underline {
53 Single,
54 Words,
55 Double,
56 Thick,
57 Dotted,
58 Dash,
59 DotDash,
60 DotDotDash,
61 Wave,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum LineSpacing {
67 Single,
68 Multiple(f64),
70 Exact(f64),
71 AtLeast(f64),
72}
73
74#[derive(Debug, Clone)]
76#[non_exhaustive]
77pub enum InlineItem {
78 Text(TextSegment),
80 MultilingualText(MultilingualTextSegment),
82 HyphenatedText {
84 segment: TextSegment,
85 language: String,
86 },
87 Tab,
89 LineBreak,
91 PageBreak,
93 ColumnBreak,
95 Image {
97 width: f64,
98 height: f64,
99 media_id: MediaId,
100 },
101 Group {
103 width: f64,
104 height: f64,
105 baseline: Option<f64>,
108 group: GroupElement,
109 },
110 Figure {
112 item: Box<InlineItem>,
113 alternate_text: String,
114 structure_id: Option<StructureId>,
115 },
116 Marker(TextSegment),
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub enum NoteStream {
128 Footnote,
130 Endnote,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct NoteRef {
137 pub stream: NoteStream,
138 pub id: i32,
139}
140
141#[derive(Debug, Clone)]
143pub struct TextSegment {
144 pub text: String,
145 pub direction: TextDirection,
147 pub source: Option<SourceSpan>,
149 pub font_id: FontId,
150 pub font_size: f64,
151 pub glyph_ids: Vec<u16>,
152 pub advances: Vec<f64>,
153 pub width: f64,
154 pub ascent: f64,
155 pub descent: f64,
156 pub line_gap: f64,
158 pub color: Color,
159 pub bold: bool,
160 pub italic: bool,
161 pub underline: Option<Underline>,
163 pub strike: bool,
165 pub dstrike: bool,
167 pub highlight: Option<Color>,
169 pub baseline_offset: f64,
171 pub hyperlink_url: Option<String>,
173 pub field_kind: Option<FieldKind>,
175 pub field_source: Option<FieldSource>,
177 pub note: Option<NoteRef>,
179 pub note_reference_source: Option<SourceSpan>,
181}
182
183#[derive(Debug, Clone)]
185#[non_exhaustive]
186pub enum LineItem {
187 Text(TextSegment),
188 MultilingualText(MultilingualTextSegment),
190 Tab {
191 width: f64,
192 leader: Option<TextSegment>,
194 align: TabAlign,
197 gap: f64,
201 },
202 Image {
203 width: f64,
204 height: f64,
205 media_id: MediaId,
206 },
207 Group {
208 width: f64,
209 height: f64,
210 baseline: Option<f64>,
212 group: GroupElement,
213 },
214 Figure {
216 item: Box<LineItem>,
217 alternate_text: String,
218 structure_id: Option<StructureId>,
219 },
220 Marker(TextSegment),
221}
222
223impl LineItem {
224 pub fn width(&self) -> f64 {
225 match self {
226 LineItem::Text(seg) => seg.width,
227 LineItem::MultilingualText(seg) => seg.width(),
228 LineItem::Tab { width, .. } => *width,
229 LineItem::Image { width, .. } => *width,
230 LineItem::Group { width, .. } => *width,
231 LineItem::Figure { item, .. } => item.width(),
232 LineItem::Marker(seg) => seg.width,
233 }
234 }
235}
236
237#[non_exhaustive]
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum ForcedBreakKind {
241 Line,
242 Page,
243 Column,
244}
245
246#[derive(Debug, Clone)]
248pub struct LayoutLine {
249 pub items: Vec<LineItem>,
250 pub width: f64,
255 pub ascent: f64,
257 pub descent: f64,
259 pub line_gap: f64,
261 pub height: f64,
263 pub indent_left: f64,
265 pub available_width: f64,
267 pub is_last: bool,
269 pub forced_break_after: Option<ForcedBreakKind>,
271}
272
273impl LayoutLine {
274 pub fn baseline_offset(&self) -> f64 {
276 let leading = self.height - self.ascent - self.descent;
277 self.ascent + if leading >= 0.0 { leading / 2.0 } else { 0.0 }
278 }
279
280 pub fn hanging_space_counts(&self) -> (usize, usize) {
291 let is_plain_space = |item: &LineItem| matches!(item, LineItem::Text(segment) if is_space_run(&segment.text));
292 let plain_end = self
293 .items
294 .iter()
295 .rev()
296 .take_while(|item| is_plain_space(item))
297 .count();
298 if plain_end > 0 && plain_end < self.items.len() {
299 return (0, plain_end);
300 }
301 let last_content = self
302 .items
303 .iter()
304 .filter_map(|item| match item {
305 LineItem::MultilingualText(span) if !is_space_run(span.text()) => {
306 Some(span.logical_index())
307 }
308 _ => None,
309 })
310 .max();
311 let hangs = |item: &LineItem| match (item, last_content) {
312 (LineItem::MultilingualText(span), Some(last)) => {
313 is_space_run(span.text()) && span.logical_index() > last
314 }
315 _ => false,
316 };
317 let start = self.items.iter().take_while(|item| hangs(item)).count();
318 let end = self.items[start..]
319 .iter()
320 .rev()
321 .take_while(|item| hangs(item))
322 .count();
323 (start, end)
324 }
325}
326
327fn is_space_run(text: &str) -> bool {
330 !text.is_empty() && text.chars().all(|character| character == ' ')
331}
332
333#[derive(Debug, Clone)]
335pub struct LineBreakParams {
336 pub available_width: f64,
338 pub ind_left: f64,
340 pub ind_right: f64,
342 pub ind_first_line: f64,
344 pub ind_hanging: f64,
346 pub tab_stops: Vec<TabStop>,
348 pub line_spacing: LineSpacing,
350 pub jc: Option<Align>,
352 pub wrap: bool,
354 pub line_prefix_widths: Vec<f64>,
360 pub line_suffix_widths: Vec<f64>,
362 pub default_tab_interval_pt: f64,
367 pub clamp_tabs_past_margin: bool,
373}
374
375impl Default for LineBreakParams {
376 fn default() -> Self {
377 LineBreakParams {
378 available_width: 468.0, line_prefix_widths: Vec::new(),
380 line_suffix_widths: Vec::new(),
381 ind_left: 0.0,
382 ind_right: 0.0,
383 ind_first_line: 0.0,
384 ind_hanging: 0.0,
385 tab_stops: Vec::new(),
386 line_spacing: LineSpacing::Single,
387 jc: None,
388 wrap: true,
389 default_tab_interval_pt: 36.0,
390 clamp_tabs_past_margin: false,
391 }
392 }
393}
394
395pub fn break_into_lines(
402 items: &[InlineItem],
403 params: &LineBreakParams,
404 fm: &FontManager,
405) -> Result<Vec<LayoutLine>> {
406 if items.is_empty() {
407 return Ok(vec![LayoutLine {
409 items: Vec::new(),
410 width: 0.0,
411 ascent: 0.0,
412 descent: 0.0,
413 line_gap: 0.0,
414 height: compute_line_height(0.0, 0.0, 0.0, 0.0, params),
415 indent_left: line_indent_at(params, 0, true),
416 available_width: line_width_at(params, 0, true),
417 is_last: true,
418 forced_break_after: None,
419 }]);
420 }
421
422 let mut lines: Vec<LayoutLine> = Vec::new();
423 let mut line = LineState::new(params, 0, true);
424
425 let mut font_ctx: Option<(FontId, f64)> = None;
427 for item in items {
429 if let InlineItem::Text(seg)
430 | InlineItem::HyphenatedText { segment: seg, .. }
431 | InlineItem::Marker(seg) = item
432 {
433 font_ctx = Some((seg.font_id, seg.font_size));
434 break;
435 }
436 if let InlineItem::MultilingualText(seg) = item {
437 font_ctx = Some((seg.font_id(), seg.base().font_size));
438 break;
439 }
440 }
441
442 let mut segments = std::collections::VecDeque::from(build_breakable_segments(items, fm)?);
444
445 while let Some(seg) = segments.pop_front() {
446 match seg {
447 BreakableSegment::Items(seg_items) if matches!(seg_items[..], [InlineItem::Tab]) => {
448 line.settle_tab(fm);
449 let mut tab = line.next_tab(params);
450 if params.wrap && tab.wraps && !line.items.is_empty() {
451 lines.push(line.break_line(params, fm, false, None));
453 tab = line.next_tab(params);
454 }
455 line.push_tab(tab, fm, font_ctx);
456 }
457 BreakableSegment::Items(seg_items) => {
458 if params.wrap
459 && !line.items.is_empty()
460 && line.width_with(&seg_items, fm) > line.limit + 0.01
461 && line.width_with(&seg_items, fm) - hanging_space_width(&seg_items, fm)?
462 > line.limit + 0.01
463 {
464 lines.push(line.break_line(params, fm, false, None));
465 }
466
467 for item in &seg_items {
469 if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
471 font_ctx = Some((seg.font_id, seg.font_size));
472 } else if let InlineItem::MultilingualText(seg) = item {
473 font_ctx = Some((seg.font_id(), seg.base().font_size));
474 }
475 line.push(item, fm);
476 }
477 }
478 BreakableSegment::Hyphenated(boxed) => {
479 let HyphenatedSegment {
480 segment,
481 break_points,
482 } = *boxed;
483 let hanging = terminal_u0020_width(&segment, fm)?;
484 let whole = InlineItem::Text(segment);
485 if !params.wrap
486 || line.width_with(std::slice::from_ref(&whole), fm) <= line.limit + 0.01
487 || line.width_with(std::slice::from_ref(&whole), fm) - hanging
488 <= line.limit + 0.01
489 {
490 font_ctx = Some((segment_font_id(&whole), segment_font_size(&whole)));
491 line.push(&whole, fm);
492 continue;
493 }
494 let InlineItem::Text(segment) = whole else {
495 unreachable!("built as text above")
496 };
497
498 if let Some(FittingHyphenation {
499 prefix,
500 hyphen,
501 remainder,
502 remaining_points,
503 }) =
504 fitting_hyphenation(&segment, &break_points, line.used_width(), line.limit, fm)?
505 {
506 for text in [prefix, hyphen] {
507 let item = InlineItem::Text(text);
508 font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
509 line.push(&item, fm);
510 }
511 lines.push(line.break_line(params, fm, false, None));
512 segments.push_front(BreakableSegment::Hyphenated(Box::new(
513 HyphenatedSegment {
514 segment: remainder,
515 break_points: remaining_points,
516 },
517 )));
518 } else if !line.items.is_empty() {
519 lines.push(line.break_line(params, fm, false, None));
520 segments.push_front(BreakableSegment::Hyphenated(Box::new(
521 HyphenatedSegment {
522 segment,
523 break_points,
524 },
525 )));
526 } else {
527 let item = InlineItem::Text(segment);
528 font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
529 line.push(&item, fm);
530 }
531 }
532 BreakableSegment::ForcedBreak(break_kind) => {
533 let is_last = matches!(break_kind, ForcedBreakKind::Page | ForcedBreakKind::Column);
534 lines.push(line.break_line(params, fm, is_last, Some(break_kind)));
535 }
536 }
537 }
538
539 lines.push(line.break_line(params, fm, true, None));
541
542 for line in &mut lines {
543 split_hanging_spaces(&mut line.items, fm)?;
544 }
545
546 Ok(lines)
547}
548
549fn split_hanging_spaces(items: &mut Vec<LineItem>, fm: &FontManager) -> Result<()> {
552 let Some(LineItem::Text(segment)) = items.last() else {
553 return Ok(());
554 };
555 if segment.field_kind.is_some() || segment.note.is_some() || is_space_run(&segment.text) {
556 return Ok(());
557 }
558 let Some(spaces) = trailing_spaces(segment, fm)? else {
559 return Ok(());
560 };
561 let Some(LineItem::Text(ink)) = items.last_mut() else {
562 unreachable!("the last item is text");
563 };
564 let glyphs = ink.glyph_ids.len() - spaces;
565 let advances = ink.advances.split_off(glyphs);
566 let width = advances.iter().sum::<f64>();
567 ink.width -= width;
568 let source = ink.source.as_mut().map(|source| {
569 source.char_end -= spaces as u32;
570 SourceSpan {
571 char_start: source.char_end,
572 char_end: source.char_end + spaces as u32,
573 ..*source
574 }
575 });
576 let hanging = TextSegment {
577 text: ink.text.split_off(ink.text.len() - spaces),
578 glyph_ids: ink.glyph_ids.split_off(glyphs),
579 advances,
580 width,
581 source,
582 hyperlink_url: ink.hyperlink_url.clone(),
583 ..*ink
584 };
585 items.push(LineItem::Text(hanging));
586 Ok(())
587}
588
589struct LineState {
591 index: usize,
594 start: f64,
596 available: f64,
598 limit: f64,
601 items: Vec<LineItem>,
602 width: f64,
603 ascent: f64,
604 descent: f64,
605 natural_height: f64,
606 font_size: f64,
607 pending: Option<PendingTab>,
609}
610
611impl LineState {
612 fn new(params: &LineBreakParams, index: usize, is_first_line: bool) -> Self {
613 let available = line_width_at(params, index, is_first_line);
614 LineState {
615 index,
616 start: line_indent_at(params, index, is_first_line),
617 available,
618 limit: available,
619 items: Vec::new(),
620 width: 0.0,
621 ascent: 0.0,
622 descent: 0.0,
623 natural_height: 0.0,
624 font_size: 0.0,
625 pending: None,
626 }
627 }
628
629 fn push(&mut self, item: &InlineItem, fm: &FontManager) {
630 let (width, ascent, descent, natural_height, font_size) = item_metrics(item);
631 self.ascent = self.ascent.max(ascent);
632 self.descent = self.descent.max(descent);
633 self.natural_height = self.natural_height.max(natural_height);
634 self.font_size = self.font_size.max(font_size);
635 match self.pending.as_mut() {
636 Some(tab) => {
637 tab.add(item, width, fm);
638 self.width = tab.start + tab.width() + tab.following;
639 }
640 None => self.width += width,
641 }
642 self.items.push(inline_to_line_item(item));
643 }
644
645 fn width_with(&self, items: &[InlineItem], fm: &FontManager) -> f64 {
647 match self.pending {
648 Some(mut tab) => {
649 for item in items {
650 tab.add(item, inline_item_width(item), fm);
651 }
652 tab.visible_line_width()
653 }
654 None => self.width + items.iter().map(inline_item_width).sum::<f64>(),
655 }
656 }
657
658 fn used_width(&self) -> f64 {
661 self.width - self.pending.map_or(0.0, |tab| tab.width())
662 }
663
664 fn next_tab(&self, params: &LineBreakParams) -> ResolvedTab {
672 let x = self.start + self.width;
673 let line_end = self.start + self.available;
674 let margin = params.available_width;
675 let hanging = (params.ind_hanging > 0.0).then_some(TabStop {
676 pos_pt: params.ind_left,
677 align: TabAlign::Left,
678 leader: None,
679 });
680 let stops = || {
681 params
682 .tab_stops
683 .iter()
684 .copied()
685 .filter(|stop| stop.align != TabAlign::Bar)
686 };
687 let next = match (
688 stops().find(|stop| stop.pos_pt > x),
689 hanging.filter(|stop| stop.pos_pt > x),
690 ) {
691 (Some(listed), Some(hanging)) if hanging.pos_pt < listed.pos_pt => Some(hanging),
692 (listed, hanging) => listed.or(hanging),
693 };
694 if let Some(stop) = next {
695 let past_margin = stop.pos_pt > margin;
702 let pos = if past_margin && params.clamp_tabs_past_margin {
703 line_end
704 } else {
705 stop.pos_pt
706 };
707 let reach = if !params.clamp_tabs_past_margin && pos >= margin {
708 f64::INFINITY
709 } else if pos > line_end {
710 margin.max(line_end)
711 } else {
712 line_end
713 };
714 return ResolvedTab {
715 stop: pos,
716 align: stop.align,
717 leader: leader_char(stop.leader),
718 wraps: params.clamp_tabs_past_margin
721 && stop.align == TabAlign::Left
722 && pos > reach - 0.01,
723 reach,
724 shift_limit: (params.clamp_tabs_past_margin || pos < margin)
725 .then_some(margin.max(line_end)),
726 };
727 }
728 let interval = if params.default_tab_interval_pt > 0.0 {
729 params.default_tab_interval_pt
730 } else {
731 36.0
732 };
733 let last = stops()
734 .chain(hanging)
735 .map(|stop| stop.pos_pt)
736 .fold(f64::NEG_INFINITY, f64::max);
737 let pos = ((x.max(last) / interval).floor() + 1.0) * interval;
738 ResolvedTab {
739 stop: pos,
740 align: TabAlign::Left,
741 leader: None,
742 wraps: pos > self.start + self.limit - 0.01,
745 reach: line_end,
746 shift_limit: None,
747 }
748 }
749
750 fn push_tab(&mut self, tab: ResolvedTab, fm: &FontManager, font_ctx: Option<(FontId, f64)>) {
751 self.limit = self.limit.max(tab.reach - self.start);
752 let start = self.width;
753 let stop = tab.stop - self.start;
754 if matches!(
755 tab.align,
756 TabAlign::Right | TabAlign::Center | TabAlign::Decimal
757 ) {
758 let pending = PendingTab {
759 index: self.items.len(),
760 start,
761 stop,
762 align: tab.align,
763 leader: tab.leader,
764 following: 0.0,
765 trailing: 0.0,
766 aligned: 0.0,
767 aligned_found: false,
768 in_number: false,
769 shift_limit: tab.shift_limit.map(|limit| limit - self.start),
770 font_ctx,
771 };
772 self.width = start + pending.width();
773 self.pending = Some(pending);
774 self.items.push(LineItem::Tab {
776 width: 0.0,
777 leader: None,
778 align: tab.align,
779 gap: 0.0,
780 });
781 } else {
782 let width = (stop - start).max(0.0);
783 let leader = tab
784 .leader
785 .and_then(|ch| shape_leader(fm, font_ctx, ch, width));
786 self.items.push(LineItem::Tab {
787 width,
788 leader,
789 align: TabAlign::Left,
790 gap: width,
791 });
792 self.width += width;
793 }
794 }
795
796 fn settle_tab(&mut self, fm: &FontManager) {
798 if let Some(tab) = self.pending.take() {
799 let width = tab.width();
800 let leader = tab
801 .leader
802 .and_then(|ch| shape_leader(fm, tab.font_ctx, ch, width));
803 self.items[tab.index] = LineItem::Tab {
804 width,
805 leader,
806 align: tab.align,
807 gap: tab.gap(),
808 };
809 }
810 }
811
812 fn break_line(
814 &mut self,
815 params: &LineBreakParams,
816 fm: &FontManager,
817 is_last: bool,
818 forced_break_after: Option<ForcedBreakKind>,
819 ) -> LayoutLine {
820 self.settle_tab(fm);
821 let next = LineState::new(params, self.index + 1, false);
822 let line = std::mem::replace(self, next);
823 let line_gap = effective_line_gap(line.ascent, line.descent, line.natural_height);
824 LayoutLine {
825 height: compute_line_height(
826 line.ascent,
827 line.descent,
828 line_gap,
829 line.font_size,
830 params,
831 ),
832 items: line.items,
833 width: line.width,
834 ascent: line.ascent,
835 descent: line.descent,
836 line_gap,
837 indent_left: line.start,
838 available_width: line.available,
839 is_last,
840 forced_break_after,
841 }
842 }
843}
844
845struct ResolvedTab {
847 stop: f64,
848 align: TabAlign,
849 leader: Option<char>,
850 wraps: bool,
853 reach: f64,
855 shift_limit: Option<f64>,
857}
858
859#[derive(Debug, Clone, Copy)]
862struct PendingTab {
863 index: usize,
865 start: f64,
866 stop: f64,
867 align: TabAlign,
868 leader: Option<char>,
869 following: f64,
871 trailing: f64,
874 aligned: f64,
876 aligned_found: bool,
877 in_number: bool,
879 shift_limit: Option<f64>,
880 font_ctx: Option<(FontId, f64)>,
881}
882
883impl PendingTab {
884 fn add(&mut self, item: &InlineItem, width: f64, fm: &FontManager) {
885 self.following += width;
886 self.trailing = match item {
887 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. }
888 if segment.text.chars().all(char::is_whitespace) =>
889 {
890 self.trailing + width
891 }
892 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => {
893 trailing_space_width(segment, fm)
894 }
895 _ => 0.0,
896 };
897 if self.align == TabAlign::Decimal && !self.aligned_found {
898 match decimal_alignment_offset(item, &mut self.in_number, fm) {
899 Some(offset) => {
900 self.aligned += offset;
901 self.aligned_found = true;
902 }
903 None => self.aligned += width,
904 }
905 }
906 }
907
908 fn width(&self) -> f64 {
911 self.gap().max(0.0)
912 }
913
914 fn gap(&self) -> f64 {
916 let visible = self.following - self.trailing;
917 let before_stop = match self.align {
918 TabAlign::Center => visible / 2.0,
919 TabAlign::Decimal if self.aligned_found => self.aligned,
920 TabAlign::Decimal => self.aligned - self.trailing,
921 _ => visible,
922 };
923 let mut width = self.stop - self.start - before_stop;
924 if let Some(limit) = self.shift_limit {
925 width = width.min(limit - self.start - visible);
926 }
927 width
928 }
929
930 fn visible_line_width(&self) -> f64 {
933 self.start + self.width() + self.following - self.trailing
934 }
935}
936
937fn decimal_alignment_offset(
943 item: &InlineItem,
944 in_number: &mut bool,
945 fm: &FontManager,
946) -> Option<f64> {
947 let segment = match item {
948 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment,
949 _ => return in_number.then_some(0.0),
950 };
951 for (index, ch) in segment.text.char_indices() {
952 if ch == '.' || (*in_number && !ch.is_ascii_digit() && ch != ',') {
953 return Some(text_prefix_width(segment, index, fm));
954 }
955 *in_number |= ch.is_ascii_digit();
956 }
957 None
958}
959
960fn trailing_space_width(segment: &TextSegment, fm: &FontManager) -> f64 {
962 let trimmed = segment.text.trim_end_matches(char::is_whitespace);
963 if trimmed.len() == segment.text.len() {
964 return 0.0;
965 }
966 segment.width - text_prefix_width(segment, trimmed.len(), fm)
967}
968
969fn text_prefix_width(segment: &TextSegment, byte_index: usize, fm: &FontManager) -> f64 {
971 if byte_index == 0 {
972 return 0.0;
973 }
974 let prefix = &segment.text[..byte_index];
975 if segment.advances.len() == segment.text.chars().count() {
976 return segment.advances[..prefix.chars().count()].iter().sum();
977 }
978 fm.shape_text(segment.font_id, prefix, segment.font_size)
979 .map_or(segment.width, |shaped| shaped.width)
980}
981
982fn leader_char(leader: Option<TabLeader>) -> Option<char> {
983 leader.and_then(|leader| match leader {
984 TabLeader::Dot => Some('.'),
985 TabLeader::Hyphen => Some('-'),
986 TabLeader::Underscore => Some('_'),
987 TabLeader::MiddleDot => Some('\u{00B7}'),
988 TabLeader::Heavy => Some('_'),
989 TabLeader::None => None,
990 })
991}
992
993pub fn break_multilingual_into_lines(
995 items: &[InlineItem],
996 params: &LineBreakParams,
997 fm: &FontManager,
998 base_direction: TextDirection,
999) -> Result<Vec<LayoutLine>> {
1000 let mut lines = break_into_lines(items, params, fm)?;
1001 let mut paragraph_text = String::new();
1002 let mut line_maps = Vec::with_capacity(lines.len());
1003 let mut has_text = false;
1004 for line in &lines {
1005 let line_start = paragraph_text.len();
1006 let mut positions = Vec::new();
1007 for (index, item) in line.items.iter().enumerate() {
1008 let (text, direction, shaped_level) = match item {
1009 LineItem::Text(segment) | LineItem::Marker(segment) => {
1010 (segment.text.as_str(), segment.direction, None)
1011 }
1012 LineItem::MultilingualText(segment) => (
1013 segment.text(),
1014 segment.base().direction,
1015 Some(unicode_bidi::Level::new(segment.bidi_level()).map_err(|_| {
1016 LayoutError::Layout("rich text carried an invalid bidi level".to_owned())
1017 })?),
1018 ),
1019 LineItem::Tab { .. } => ("\t", TextDirection::Auto, None),
1020 LineItem::Image { .. } | LineItem::Group { .. } | LineItem::Figure { .. } => {
1021 ("\u{fffc}", TextDirection::Auto, None)
1022 }
1023 };
1024 has_text |= !text.is_empty();
1025 let start = paragraph_text.len();
1026 paragraph_text.push_str(text);
1027 let end = paragraph_text.len();
1028 positions.push((
1029 index,
1030 start,
1031 end,
1032 direction,
1033 text.chars().all(char::is_whitespace),
1034 shaped_level,
1035 ));
1036 }
1037 line_maps.push((line_start..paragraph_text.len(), positions));
1038 }
1039 if !has_text {
1040 return Ok(lines);
1041 }
1042 let paragraph_level = match base_direction {
1043 TextDirection::Auto => None,
1044 TextDirection::LeftToRight => Some(unicode_bidi::Level::ltr()),
1045 TextDirection::RightToLeft => Some(unicode_bidi::Level::rtl()),
1046 };
1047 let bidi = unicode_bidi::BidiInfo::new(¶graph_text, paragraph_level);
1048 let [paragraph] = bidi.paragraphs.as_slice() else {
1049 return Err(LayoutError::Layout(
1050 "multilingual line layout requires one bidi paragraph".to_owned(),
1051 ));
1052 };
1053 for (line, (line_range, mapped_positions)) in lines.iter_mut().zip(line_maps) {
1054 let positions = mapped_positions
1055 .iter()
1056 .map(|(index, _, _, _, _, _)| *index)
1057 .collect::<Vec<_>>();
1058 if positions.is_empty() {
1059 continue;
1060 }
1061 let adjusted_levels = bidi.reordered_levels(paragraph, line_range);
1062 let levels = mapped_positions
1063 .into_iter()
1064 .map(|(_, start, end, direction, whitespace, shaped_level)| {
1065 let adjusted = adjusted_levels.get(start).copied().ok_or_else(|| {
1066 LayoutError::Layout(
1067 "multilingual line range exceeded its bidi paragraph".to_owned(),
1068 )
1069 })?;
1070 Ok(if whitespace {
1071 adjusted
1072 } else if let Some(shaped_level) = shaped_level {
1073 shaped_level
1074 } else if direction == TextDirection::Auto {
1075 adjusted
1076 } else {
1077 explicit_direction_levels(
1078 ¶graph_text[start..end],
1079 direction,
1080 paragraph.level,
1081 )?
1082 .first()
1083 .copied()
1084 .unwrap_or(adjusted)
1085 })
1086 })
1087 .collect::<Result<Vec<_>>>()?;
1088 let visual_order = unicode_bidi::BidiInfo::reorder_visual(&levels);
1089 let logical_items = positions
1090 .iter()
1091 .zip(&levels)
1092 .map(|(index, level)| match &line.items[*index] {
1093 LineItem::MultilingualText(segment) => Ok(LineItem::MultilingualText(
1094 multilingual_segment_with_level(segment, *level)?,
1095 )),
1096 item => Ok(item.clone()),
1097 })
1098 .collect::<Result<Vec<_>>>()?;
1099 for (visual_slot, logical_index) in positions.into_iter().zip(visual_order) {
1100 line.items[visual_slot] = logical_items[logical_index].clone();
1101 }
1102 }
1103 Ok(lines)
1104}
1105
1106fn multilingual_segment_with_level(
1107 segment: &MultilingualTextSegment,
1108 level: unicode_bidi::Level,
1109) -> Result<MultilingualTextSegment> {
1110 let parity_changed = segment.bidi_level() % 2 != level.number() % 2;
1111 let cluster_order = if parity_changed {
1112 (0..segment.clusters().len()).rev().collect::<Vec<_>>()
1113 } else {
1114 (0..segment.clusters().len()).collect::<Vec<_>>()
1115 };
1116 let mut glyph_ids = Vec::with_capacity(segment.glyph_ids().len());
1117 let mut x_advances = Vec::with_capacity(segment.x_advances().len());
1118 let mut y_advances = Vec::with_capacity(segment.y_advances().len());
1119 let mut x_offsets = Vec::with_capacity(segment.x_offsets().len());
1120 let mut y_offsets = Vec::with_capacity(segment.y_offsets().len());
1121 let mut clusters = Vec::with_capacity(segment.clusters().len());
1122 for index in cluster_order {
1123 let cluster = &segment.clusters()[index];
1124 let glyph_range = cluster.glyph_start as usize..cluster.glyph_end as usize;
1125 let glyph_start = glyph_ids.len() as u32;
1126 glyph_ids.extend_from_slice(&segment.glyph_ids()[glyph_range.clone()]);
1127 x_advances.extend_from_slice(&segment.x_advances()[glyph_range.clone()]);
1128 y_advances.extend_from_slice(&segment.y_advances()[glyph_range.clone()]);
1129 x_offsets.extend_from_slice(&segment.x_offsets()[glyph_range.clone()]);
1130 y_offsets.extend_from_slice(&segment.y_offsets()[glyph_range]);
1131 clusters.push(crate::font::GlyphCluster {
1132 glyph_start,
1133 glyph_end: glyph_ids.len() as u32,
1134 char_start: cluster.char_start,
1135 char_end: cluster.char_end,
1136 });
1137 }
1138 let mut base = segment.base().clone();
1139 base.glyph_ids = glyph_ids;
1140 base.advances = x_advances.clone();
1141
1142 MultilingualTextSegment::new(
1143 base,
1144 segment.logical_index(),
1145 segment.language().map(str::to_owned),
1146 segment.script(),
1147 if level.is_rtl() {
1148 TextDirection::RightToLeft
1149 } else {
1150 TextDirection::LeftToRight
1151 },
1152 level.number(),
1153 x_advances,
1154 y_advances,
1155 x_offsets,
1156 y_offsets,
1157 clusters,
1158 segment.break_after(),
1159 )
1160}
1161
1162#[derive(Debug)]
1165enum BreakableSegment {
1166 Items(Vec<InlineItem>),
1168 Hyphenated(Box<HyphenatedSegment>),
1170 ForcedBreak(ForcedBreakKind),
1172}
1173
1174#[derive(Debug)]
1175struct HyphenatedSegment {
1176 segment: TextSegment,
1177 break_points: Vec<usize>,
1178}
1179
1180struct FittingHyphenation {
1181 prefix: TextSegment,
1182 hyphen: TextSegment,
1183 remainder: TextSegment,
1184 remaining_points: Vec<usize>,
1185}
1186
1187pub(crate) fn multilingual_break_allowed(before: char, after: char) -> bool {
1189 const OPENING: &[char] = &['(', '[', '{', '〈', '《', '「', '『', '【', '〔', '〖'];
1190 const CLOSING_OR_NONSTARTER: &[char] = &[
1191 ')', ']', '}', '〉', '》', '」', '』', '】', '〕', '〗', '、', '。', ',', '.', '!',
1192 '?', ':', ';', '%', '‰',
1193 ];
1194 !OPENING.contains(&before) && !CLOSING_OR_NONSTARTER.contains(&after)
1195}
1196
1197fn build_breakable_segments(
1203 items: &[InlineItem],
1204 fm: &FontManager,
1205) -> Result<Vec<BreakableSegment>> {
1206 let mut segments = Vec::new();
1207 let mut current_group: Vec<InlineItem> = Vec::new();
1208
1209 for item in items {
1210 match item {
1211 InlineItem::LineBreak => {
1212 if !current_group.is_empty() {
1213 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1214 }
1215 segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Line));
1216 }
1217 InlineItem::PageBreak => {
1218 if !current_group.is_empty() {
1219 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1220 }
1221 segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Page));
1222 }
1223 InlineItem::ColumnBreak => {
1224 if !current_group.is_empty() {
1225 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1226 }
1227 segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Column));
1228 }
1229 InlineItem::Tab => {
1230 if !current_group.is_empty() {
1232 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1233 }
1234 segments.push(BreakableSegment::Items(vec![item.clone()]));
1235 }
1236 InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => {
1237 if seg.text.is_empty() {
1238 if !current_group.is_empty() {
1239 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1240 }
1241 segments.push(BreakableSegment::Items(vec![item.clone()]));
1242 continue;
1243 }
1244 let breaks = split_text_at_break_opportunities(seg);
1246 let spacing =
1247 if breaks.len() == 1 && breaks[0].start == 0 && breaks[0].end == seg.text.len()
1248 {
1249 0.0
1250 } else {
1251 let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
1252 if original.advances.len() == seg.advances.len()
1253 && !original.advances.is_empty()
1254 {
1255 (seg.width - original.width) / original.advances.len() as f64
1256 } else {
1257 0.0
1258 }
1259 };
1260
1261 for tb in &breaks {
1262 let chunk = &seg.text[tb.start..tb.end];
1263 if chunk.is_empty() {
1264 continue;
1265 }
1266
1267 if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
1269 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1270 }
1271
1272 let sub_item = split_text_subsegment(seg, tb.start, tb.end, spacing, fm)?;
1274 let language = match item {
1275 InlineItem::HyphenatedText { language, .. } => Some(language.as_str()),
1276 _ => None,
1277 };
1278 let break_points = language.map_or_else(Vec::new, |language| {
1279 hyphenation_opportunities(language, chunk)
1280 });
1281 if break_points.is_empty() {
1282 current_group.push(sub_item);
1283 } else {
1284 if !current_group.is_empty() {
1285 segments
1286 .push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1287 }
1288 let InlineItem::Text(segment) = sub_item else {
1289 unreachable!("split text always returns text")
1290 };
1291 segments.push(BreakableSegment::Hyphenated(Box::new(HyphenatedSegment {
1292 segment,
1293 break_points,
1294 })));
1295 }
1296
1297 if tb.is_break {
1298 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1299 }
1300 }
1301
1302 if !current_group.is_empty() {
1304 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1305 }
1306 }
1307 InlineItem::MultilingualText(segment) => {
1308 current_group.push(InlineItem::MultilingualText(segment.clone()));
1309 if segment.break_after() {
1310 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
1311 }
1312 }
1313 InlineItem::Marker(_)
1314 | InlineItem::Image { .. }
1315 | InlineItem::Group { .. }
1316 | InlineItem::Figure { .. } => {
1317 current_group.push(item.clone());
1318 }
1319 }
1320 }
1321
1322 if !current_group.is_empty() {
1323 segments.push(BreakableSegment::Items(current_group));
1324 }
1325
1326 Ok(segments)
1327}
1328
1329fn split_text_subsegment(
1333 seg: &TextSegment,
1334 byte_start: usize,
1335 byte_end: usize,
1336 spacing: f64,
1337 fm: &FontManager,
1338) -> Result<InlineItem> {
1339 if byte_start == 0 && byte_end == seg.text.len() {
1341 return Ok(InlineItem::Text(seg.clone()));
1342 }
1343
1344 let sub_text = seg.text[byte_start..byte_end].to_string();
1345 let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
1346 for advance in &mut shaped.advances {
1347 *advance += spacing;
1348 }
1349 shaped.width += spacing * shaped.advances.len() as f64;
1350
1351 let source = seg.source.map(|source| {
1352 let start = seg.text[..byte_start].chars().count() as u32;
1353 let end = seg.text[..byte_end].chars().count() as u32;
1354 SourceSpan {
1355 node: source.node,
1356 char_start: source.char_start + start,
1357 char_end: source.char_start + end,
1358 }
1359 });
1360
1361 Ok(InlineItem::Text(TextSegment {
1362 text: sub_text,
1363 direction: seg.direction,
1364 source,
1365 font_id: seg.font_id,
1366 font_size: seg.font_size,
1367 glyph_ids: shaped.glyph_ids,
1368 advances: shaped.advances,
1369 width: shaped.width,
1370 ascent: seg.ascent,
1371 descent: seg.descent,
1372 line_gap: seg.line_gap,
1373 color: seg.color,
1374 bold: seg.bold,
1375 italic: seg.italic,
1376 underline: seg.underline,
1377 strike: seg.strike,
1378 dstrike: seg.dstrike,
1379 highlight: seg.highlight,
1380 baseline_offset: seg.baseline_offset,
1381 hyperlink_url: seg.hyperlink_url.clone(),
1382 field_kind: seg.field_kind,
1383 field_source: seg.field_source,
1384 note: seg.note,
1385 note_reference_source: seg.note_reference_source,
1386 }))
1387}
1388
1389fn supported_hyphenation_language(language: &str) -> Option<hypher::Lang> {
1390 let primary = language.split('-').next()?;
1391 if primary.eq_ignore_ascii_case("en") {
1392 Some(hypher::Lang::English)
1393 } else if primary.eq_ignore_ascii_case("fr") {
1394 Some(hypher::Lang::French)
1395 } else if primary.eq_ignore_ascii_case("de") {
1396 Some(hypher::Lang::German)
1397 } else if primary.eq_ignore_ascii_case("es") {
1398 Some(hypher::Lang::Spanish)
1399 } else {
1400 None
1401 }
1402}
1403
1404fn hyphenation_opportunities(language: &str, text: &str) -> Vec<usize> {
1405 let Some(language) = supported_hyphenation_language(language) else {
1406 return Vec::new();
1407 };
1408 let word_end = text
1409 .char_indices()
1410 .take_while(|(_, character)| character.is_alphabetic())
1411 .map(|(offset, character)| offset + character.len_utf8())
1412 .last()
1413 .unwrap_or(0);
1414 if word_end == 0 || text[word_end..].chars().any(char::is_alphabetic) {
1415 return Vec::new();
1416 }
1417 let word = &text[..word_end];
1418 let syllables = hypher::hyphenate(word, language).collect::<Vec<_>>();
1419 let mut offset = 0usize;
1420 syllables
1421 .iter()
1422 .take(syllables.len().saturating_sub(1))
1423 .map(|syllable| {
1424 offset += syllable.len();
1425 offset
1426 })
1427 .collect()
1428}
1429
1430fn fitting_hyphenation(
1431 segment: &TextSegment,
1432 break_points: &[usize],
1433 current_width: f64,
1434 available_width: f64,
1435 fm: &FontManager,
1436) -> Result<Option<FittingHyphenation>> {
1437 let spacing = text_segment_spacing(segment, fm)?;
1438 let hyphen = generated_hyphen(segment, spacing, fm)?;
1439 for &break_point in break_points.iter().rev() {
1440 let InlineItem::Text(prefix) = split_text_subsegment(segment, 0, break_point, spacing, fm)?
1441 else {
1442 unreachable!("split text always returns text")
1443 };
1444 if current_width + prefix.width + hyphen.width > available_width + 0.01 {
1445 continue;
1446 }
1447 let InlineItem::Text(remainder) =
1448 split_text_subsegment(segment, break_point, segment.text.len(), spacing, fm)?
1449 else {
1450 unreachable!("split text always returns text")
1451 };
1452 let remaining_points = break_points
1453 .iter()
1454 .copied()
1455 .filter(|point| *point > break_point)
1456 .map(|point| point - break_point)
1457 .collect();
1458 return Ok(Some(FittingHyphenation {
1459 prefix,
1460 hyphen,
1461 remainder,
1462 remaining_points,
1463 }));
1464 }
1465 Ok(None)
1466}
1467
1468fn text_segment_spacing(segment: &TextSegment, fm: &FontManager) -> Result<f64> {
1469 let original = fm.shape_text(segment.font_id, &segment.text, segment.font_size)?;
1470 Ok(
1471 if original.advances.len() == segment.advances.len() && !original.advances.is_empty() {
1472 (segment.width - original.width) / original.advances.len() as f64
1473 } else {
1474 0.0
1475 },
1476 )
1477}
1478
1479fn generated_hyphen(segment: &TextSegment, spacing: f64, fm: &FontManager) -> Result<TextSegment> {
1480 let mut shaped = fm.shape_text(segment.font_id, "-", segment.font_size)?;
1481 for advance in &mut shaped.advances {
1482 *advance += spacing;
1483 }
1484 shaped.width += spacing * shaped.advances.len() as f64;
1485 Ok(TextSegment {
1486 text: "-".to_owned(),
1487 direction: segment.direction,
1488 source: None,
1489 font_id: segment.font_id,
1490 font_size: segment.font_size,
1491 glyph_ids: shaped.glyph_ids,
1492 advances: shaped.advances,
1493 width: shaped.width,
1494 ascent: segment.ascent,
1495 descent: segment.descent,
1496 line_gap: segment.line_gap,
1497 color: segment.color,
1498 bold: segment.bold,
1499 italic: segment.italic,
1500 underline: segment.underline,
1501 strike: segment.strike,
1502 dstrike: segment.dstrike,
1503 highlight: segment.highlight,
1504 baseline_offset: segment.baseline_offset,
1505 hyperlink_url: segment.hyperlink_url.clone(),
1506 field_kind: segment.field_kind,
1507 field_source: segment.field_source,
1508 note: segment.note,
1509 note_reference_source: segment.note_reference_source,
1510 })
1511}
1512
1513fn segment_font_id(item: &InlineItem) -> FontId {
1514 match item {
1515 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment.font_id,
1516 _ => unreachable!("called only for text"),
1517 }
1518}
1519
1520fn segment_font_size(item: &InlineItem) -> f64 {
1521 match item {
1522 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment.font_size,
1523 _ => unreachable!("called only for text"),
1524 }
1525}
1526
1527struct TextBreakInfo {
1528 start: usize,
1530 end: usize,
1531 is_break: bool,
1533}
1534
1535fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
1536 use unicode_linebreak::{BreakOpportunity, linebreaks};
1537
1538 let text = &seg.text;
1539 if text.is_empty() {
1540 return vec![];
1541 }
1542
1543 let mut breaks = Vec::new();
1544 let mut last_start = 0;
1545
1546 for (byte_pos, opportunity) in linebreaks(text) {
1547 if byte_pos == 0 {
1548 continue;
1549 }
1550
1551 let is_break = matches!(
1552 opportunity,
1553 BreakOpportunity::Allowed | BreakOpportunity::Mandatory
1554 );
1555
1556 breaks.push(TextBreakInfo {
1557 start: last_start,
1558 end: byte_pos,
1559 is_break,
1560 });
1561 last_start = byte_pos;
1562 }
1563
1564 if breaks.is_empty() {
1566 breaks.push(TextBreakInfo {
1567 start: 0,
1568 end: text.len(),
1569 is_break: true,
1570 });
1571 }
1572
1573 breaks
1574}
1575
1576fn hanging_space_width(items: &[InlineItem], fm: &FontManager) -> Result<f64> {
1581 let mut width = 0.0;
1582 for item in items.iter().rev() {
1583 match item {
1584 InlineItem::MultilingualText(segment) if is_space_run(segment.text()) => {
1585 width += segment.width();
1586 }
1587 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => {
1588 width += terminal_u0020_width(segment, fm)?;
1589 if !is_space_run(&segment.text) {
1590 break;
1591 }
1592 }
1593 _ => break,
1594 }
1595 }
1596 Ok(width)
1597}
1598
1599fn terminal_u0020_width(segment: &TextSegment, fm: &FontManager) -> Result<f64> {
1601 Ok(trailing_spaces(segment, fm)?.map_or(0.0, |spaces| {
1602 segment.advances[segment.advances.len() - spaces..]
1603 .iter()
1604 .sum()
1605 }))
1606}
1607
1608fn trailing_spaces(segment: &TextSegment, fm: &FontManager) -> Result<Option<usize>> {
1616 if segment.field_kind.is_some() || segment.note.is_some() {
1617 return Ok(None);
1618 }
1619 let spaces = segment.text.len() - segment.text.trim_end_matches(' ').len();
1620 let glyphs = segment.glyph_ids.len();
1621 if spaces == 0
1622 || glyphs < spaces
1623 || segment.advances.len() != glyphs
1624 || segment.text.chars().any(|character| {
1625 matches!(
1626 unicode_bidi::bidi_class(character),
1627 unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL
1628 )
1629 })
1630 {
1631 return Ok(None);
1632 }
1633 let space = fm.shape_text(segment.font_id, " ", segment.font_size)?;
1634 let [space_glyph] = space.glyph_ids[..] else {
1635 return Ok(None);
1636 };
1637 Ok(segment.glyph_ids[glyphs - spaces..]
1638 .iter()
1639 .all(|glyph| *glyph == space_glyph)
1640 .then_some(spaces))
1641}
1642
1643fn inline_item_width(item: &InlineItem) -> f64 {
1644 match item {
1645 InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => seg.width,
1646 InlineItem::MultilingualText(seg) => seg.width(),
1647 InlineItem::Tab => 36.0, InlineItem::Image { width, .. } => *width,
1649 InlineItem::Group { width, .. } => *width,
1650 InlineItem::Figure { item, .. } => inline_item_width(item),
1651 InlineItem::Marker(seg) => seg.width,
1652 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
1653 }
1654}
1655
1656fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
1657 match item {
1659 InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => (
1660 seg.width,
1661 seg.ascent,
1662 seg.descent,
1663 seg.ascent + seg.descent + seg.line_gap,
1664 seg.font_size,
1665 ),
1666 InlineItem::MultilingualText(seg) => (
1667 seg.width(),
1668 seg.base().ascent,
1669 seg.base().descent,
1670 seg.base().ascent + seg.base().descent + seg.base().line_gap,
1671 seg.base().font_size,
1672 ),
1673 InlineItem::Marker(seg) => (
1674 seg.width,
1675 seg.ascent,
1676 seg.descent,
1677 seg.ascent + seg.descent + seg.line_gap,
1678 0.0,
1679 ),
1680 InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
1681 InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
1682 InlineItem::Group {
1683 width,
1684 height,
1685 baseline,
1686 ..
1687 } => {
1688 let baseline = normalized_group_baseline(*height, *baseline).unwrap_or(*height);
1689 (*width, baseline, *height - baseline, *height, 0.0)
1690 }
1691 InlineItem::Figure { item, .. } => item_metrics(item),
1692 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
1693 (0.0, 0.0, 0.0, 0.0, 0.0)
1694 }
1695 }
1696}
1697
1698fn normalized_group_baseline(height: f64, baseline: Option<f64>) -> Option<f64> {
1699 if !height.is_finite() {
1700 return None;
1701 }
1702 let upper = height.max(0.0);
1703 baseline
1704 .filter(|value| value.is_finite())
1705 .map(|value| value.clamp(0.0, upper))
1706}
1707
1708fn inline_to_line_item(item: &InlineItem) -> LineItem {
1709 match item {
1710 InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => {
1711 LineItem::Text(seg.clone())
1712 }
1713 InlineItem::MultilingualText(seg) => LineItem::MultilingualText(seg.clone()),
1714 InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
1715 InlineItem::Tab => LineItem::Tab {
1717 width: 0.0,
1718 leader: None,
1719 align: TabAlign::Left,
1720 gap: 0.0,
1721 },
1722 InlineItem::Image {
1723 width,
1724 height,
1725 media_id,
1726 } => LineItem::Image {
1727 width: *width,
1728 height: *height,
1729 media_id: *media_id,
1730 },
1731 InlineItem::Group {
1732 width,
1733 height,
1734 baseline,
1735 group,
1736 } => LineItem::Group {
1737 width: *width,
1738 height: *height,
1739 baseline: normalized_group_baseline(*height, *baseline),
1740 group: group.clone(),
1741 },
1742 InlineItem::Figure {
1743 item,
1744 alternate_text,
1745 structure_id,
1746 } => LineItem::Figure {
1747 item: Box::new(inline_to_line_item(item)),
1748 alternate_text: alternate_text.clone(),
1749 structure_id: *structure_id,
1750 },
1751 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
1752 width: 0.0,
1753 leader: None,
1754 align: TabAlign::Left,
1755 gap: 0.0,
1756 },
1757 }
1758}
1759
1760fn shape_leader(
1762 fm: &FontManager,
1763 font_ctx: Option<(FontId, f64)>,
1764 leader_char: char,
1765 tab_width: f64,
1766) -> Option<TextSegment> {
1767 let (font_id, font_size) = font_ctx?;
1768 if tab_width < 1.0 {
1769 return None;
1770 }
1771
1772 let single = String::from(leader_char);
1774 let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
1775 if shaped.glyph_ids.is_empty() {
1776 return None;
1777 }
1778 let char_advance = shaped.advances[0];
1779 if char_advance < 0.5 {
1780 return None;
1781 }
1782
1783 let spacing = match leader_char {
1785 '.' | '\u{00B7}' => char_advance * 0.5,
1786 _ => char_advance * 0.15,
1787 };
1788 let step = char_advance + spacing;
1789 let count = ((tab_width - spacing) / step).floor() as usize;
1790 if count == 0 {
1791 return None;
1792 }
1793
1794 let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
1796 let mut glyph_ids = Vec::with_capacity(count);
1797 let mut advances = Vec::with_capacity(count);
1798 for i in 0..count {
1799 glyph_ids.push(shaped.glyph_ids[0]);
1800 if i + 1 < count {
1801 advances.push(char_advance + spacing);
1802 } else {
1803 advances.push(char_advance);
1804 }
1805 }
1806
1807 let metrics = fm.metrics(font_id, font_size).ok()?;
1808
1809 Some(TextSegment {
1810 text: leader_text,
1811 direction: TextDirection::Auto,
1812 source: None,
1813 font_id,
1814 font_size,
1815 glyph_ids,
1816 advances,
1817 width: tab_width, ascent: metrics.ascent,
1819 descent: metrics.descent,
1820 line_gap: metrics.line_gap,
1821 color: Color::BLACK,
1822 bold: false,
1823 italic: false,
1824 underline: None,
1825 strike: false,
1826 dstrike: false,
1827 highlight: None,
1828 baseline_offset: 0.0,
1829 hyperlink_url: None,
1830 field_kind: None,
1831 field_source: None,
1832 note: None,
1833 note_reference_source: None,
1834 })
1835}
1836
1837fn compute_first_line_width(params: &LineBreakParams) -> f64 {
1838 if params.ind_hanging > 0.0 {
1839 params.available_width - params.ind_left - params.ind_right + params.ind_hanging
1841 } else {
1842 params.available_width - params.ind_left - params.ind_right - params.ind_first_line
1843 }
1844}
1845
1846fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
1847 params.available_width - params.ind_left - params.ind_right
1848}
1849
1850fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 {
1852 params
1853 .line_prefix_widths
1854 .get(line_index)
1855 .copied()
1856 .unwrap_or(0.0)
1857}
1858
1859fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 {
1861 params
1862 .line_suffix_widths
1863 .get(line_index)
1864 .copied()
1865 .unwrap_or(0.0)
1866}
1867
1868fn line_width_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
1870 let base = if is_first_line {
1871 compute_first_line_width(params)
1872 } else {
1873 compute_subsequent_line_width(params)
1874 };
1875 (base - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)).max(0.0)
1876}
1877
1878fn line_indent_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
1880 let base = if is_first_line {
1881 first_line_indent(params)
1882 } else {
1883 subsequent_line_indent(params)
1884 };
1885 base + line_prefix_width(params, line_index)
1886}
1887
1888fn first_line_indent(params: &LineBreakParams) -> f64 {
1889 if params.ind_hanging > 0.0 {
1890 params.ind_left - params.ind_hanging
1891 } else {
1892 params.ind_left + params.ind_first_line
1893 }
1894}
1895
1896fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
1897 params.ind_left
1898}
1899
1900fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
1902 (natural_height - ascent - descent).max(0.0)
1903}
1904
1905fn compute_line_height(
1906 ascent: f64,
1907 descent: f64,
1908 line_gap: f64,
1909 font_size: f64,
1910 params: &LineBreakParams,
1911) -> f64 {
1912 let natural = ascent + descent + line_gap;
1913 let natural = if natural < 1.0 { 12.0 } else { natural }; let font_size = if font_size < 1.0 { 12.0 } else { font_size };
1915
1916 match params.line_spacing {
1917 LineSpacing::Single => natural,
1918 LineSpacing::Multiple(factor) => font_size * factor,
1919 LineSpacing::Exact(points) => points,
1920 LineSpacing::AtLeast(points) => natural.max(points),
1921 }
1922}
1923
1924#[cfg(test)]
1925mod tests {
1926 use super::*;
1927
1928 fn make_text_segment(text: &str, width: f64) -> TextSegment {
1929 TextSegment {
1930 text: text.to_string(),
1931 direction: TextDirection::Auto,
1932 source: None,
1933 font_id: FontId(0),
1934 font_size: 12.0,
1935 glyph_ids: vec![],
1936 advances: vec![],
1937 width,
1938 ascent: 10.0,
1939 descent: 3.0,
1940 line_gap: 0.0,
1941 color: Color::BLACK,
1942 bold: false,
1943 italic: false,
1944 underline: None,
1945 strike: false,
1946 dstrike: false,
1947 highlight: None,
1948 baseline_offset: 0.0,
1949 hyperlink_url: None,
1950 field_kind: None,
1951 field_source: None,
1952 note: None,
1953 note_reference_source: None,
1954 }
1955 }
1956
1957 fn deterministic_font_manager() -> FontManager {
1958 FontManager::new_deterministic().expect("bundled fonts should load")
1959 }
1960
1961 fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
1962 let font_id = fm
1963 .resolve_font(Some("Carlito"), false, false)
1964 .expect("bundled Carlito should resolve");
1965 let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
1966 let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
1967 for advance in &mut shaped.advances {
1968 *advance += spacing;
1969 }
1970 shaped.width += spacing * shaped.advances.len() as f64;
1971 TextSegment {
1972 text: text.to_owned(),
1973 direction: TextDirection::Auto,
1974 source: None,
1975 font_id,
1976 font_size: 30.0,
1977 glyph_ids: shaped.glyph_ids,
1978 advances: shaped.advances,
1979 width: shaped.width,
1980 ascent: metrics.ascent,
1981 descent: metrics.descent,
1982 line_gap: metrics.line_gap,
1983 color: Color::BLACK,
1984 bold: false,
1985 italic: false,
1986 underline: None,
1987 strike: false,
1988 dstrike: false,
1989 highlight: None,
1990 baseline_offset: 0.0,
1991 hyperlink_url: None,
1992 field_kind: None,
1993 field_source: None,
1994 note: None,
1995 note_reference_source: None,
1996 }
1997 }
1998
1999 #[test]
2000 fn automatic_hyphenation_selects_the_farthest_fitting_break_and_has_no_source() {
2001 let mut fm = deterministic_font_manager();
2002 let node = crate::SourceNodeId::new(9).unwrap();
2003 let mut segment = shaped_text_segment(&mut fm, "representation", 0.0);
2004 segment.source = Some(SourceSpan {
2005 node,
2006 char_start: 20,
2007 char_end: 34,
2008 });
2009 let width = fm
2010 .shape_text(segment.font_id, "represen-", segment.font_size)
2011 .unwrap()
2012 .width
2013 + 0.01;
2014 let lines = break_into_lines(
2015 &[InlineItem::HyphenatedText {
2016 segment,
2017 language: "en-US".to_owned(),
2018 }],
2019 &LineBreakParams {
2020 available_width: width,
2021 ..Default::default()
2022 },
2023 &fm,
2024 )
2025 .unwrap();
2026 let first = lines[0]
2027 .items
2028 .iter()
2029 .filter_map(|item| match item {
2030 LineItem::Text(text) => Some(text),
2031 _ => None,
2032 })
2033 .collect::<Vec<_>>();
2034
2035 assert_eq!(
2036 first
2037 .iter()
2038 .map(|text| text.text.as_str())
2039 .collect::<String>(),
2040 "represen-"
2041 );
2042 assert_eq!(first.last().unwrap().source, None);
2043 assert_eq!(first[0].source.unwrap().char_start, 20);
2044 assert_eq!(first[0].source.unwrap().char_end, 28);
2045 }
2046
2047 #[test]
2048 fn liang_candidates_map_supported_regional_languages_only() {
2049 assert_eq!(
2050 hyphenation_opportunities("en-US", "representation"),
2051 vec![3, 5, 8, 10]
2052 );
2053 assert_eq!(
2054 hyphenation_opportunities("fr-CA", "représentation"),
2055 vec![2, 6, 9, 11]
2056 );
2057 assert!(!hyphenation_opportunities("de-AT", "Silbentrennung").is_empty());
2058 assert!(!hyphenation_opportunities("es-MX", "representación").is_empty());
2059 assert!(hyphenation_opportunities("it-IT", "rappresentazione").is_empty());
2060 }
2061
2062 #[test]
2063 fn unwrapped_hyphenated_text_never_emits_a_conditional_hyphen() {
2064 let mut fm = deterministic_font_manager();
2065 let segment = shaped_text_segment(&mut fm, "representation", 0.0);
2066 let lines = break_into_lines(
2067 &[InlineItem::HyphenatedText {
2068 segment,
2069 language: "en-US".to_owned(),
2070 }],
2071 &LineBreakParams {
2072 available_width: 20.0,
2073 wrap: false,
2074 ..Default::default()
2075 },
2076 &fm,
2077 )
2078 .unwrap();
2079 assert_eq!(lines.len(), 1);
2080 let text = lines[0]
2081 .items
2082 .iter()
2083 .filter_map(|item| match item {
2084 LineItem::Text(text) => Some(text.text.as_str()),
2085 _ => None,
2086 })
2087 .collect::<String>();
2088
2089 assert_eq!(text, "representation");
2090 }
2091
2092 #[test]
2093 fn mixed_direction_line_uses_uax9_visual_order_without_changing_logical_text() {
2094 let mut fm = deterministic_font_manager();
2095 let mut segment = shaped_text_segment(&mut fm, "abc אבג 123", 0.0);
2096 segment.source = Some(SourceSpan {
2097 node: crate::SourceNodeId::new(7).unwrap(),
2098 char_start: 50,
2099 char_end: 61,
2100 });
2101 let rich = fm
2102 .shape_multilingual_text(segment, Some("he-IL"), TextDirection::Auto, false)
2103 .unwrap();
2104 let logical_text = rich.iter().map(|span| span.text()).collect::<String>();
2105 let sources = rich
2106 .iter()
2107 .map(|span| span.base().source.expect("logical span keeps source"))
2108 .collect::<Vec<_>>();
2109 assert_eq!(sources.first().unwrap().char_start, 50);
2110 assert_eq!(sources.last().unwrap().char_end, 61);
2111 assert!(
2112 sources
2113 .windows(2)
2114 .all(|pair| pair[0].char_end == pair[1].char_start)
2115 );
2116 let items = rich
2117 .into_iter()
2118 .map(InlineItem::MultilingualText)
2119 .collect::<Vec<_>>();
2120 let lines = break_multilingual_into_lines(
2121 &items,
2122 &LineBreakParams {
2123 available_width: 1_000.0,
2124 ..Default::default()
2125 },
2126 &fm,
2127 TextDirection::Auto,
2128 )
2129 .unwrap();
2130 let visual_text = lines[0]
2131 .items
2132 .iter()
2133 .filter_map(|item| match item {
2134 LineItem::MultilingualText(span) => Some(span.text()),
2135 _ => None,
2136 })
2137 .collect::<String>();
2138 assert_eq!(logical_text, "abc אבג 123");
2139 assert_eq!(visual_text, "abc 123 אבג");
2140 assert_eq!(
2141 lines[0]
2142 .items
2143 .iter()
2144 .filter_map(|item| match item {
2145 LineItem::MultilingualText(span) => {
2146 Some((span.text(), span.logical_index(), span.bidi_level()))
2147 }
2148 _ => None,
2149 })
2150 .collect::<Vec<_>>(),
2151 vec![
2152 ("abc", 0, 0),
2153 (" ", 1, 0),
2154 ("123", 4, 2),
2155 (" ", 3, 1),
2156 ("אבג", 2, 1)
2157 ]
2158 );
2159 }
2160
2161 #[test]
2162 fn explicit_run_directions_reorder_with_the_rtl_paragraph_once() {
2163 let mut fm = deterministic_font_manager();
2164 let mut arabic = shaped_text_segment(&mut fm, "العربية ", 0.0);
2165 arabic.direction = TextDirection::RightToLeft;
2166 let mut latin = shaped_text_segment(&mut fm, "ABC 123", 0.0);
2167 latin.direction = TextDirection::LeftToRight;
2168 let rich = fm
2169 .shape_multilingual_paragraph(
2170 vec![
2171 (arabic, Some("ar-SA".to_owned())),
2172 (latin, Some("en-US".to_owned())),
2173 ],
2174 TextDirection::RightToLeft,
2175 false,
2176 )
2177 .unwrap();
2178 let lines = break_multilingual_into_lines(
2179 &rich
2180 .into_iter()
2181 .map(InlineItem::MultilingualText)
2182 .collect::<Vec<_>>(),
2183 &LineBreakParams {
2184 available_width: 1_000.0,
2185 ..Default::default()
2186 },
2187 &fm,
2188 TextDirection::RightToLeft,
2189 )
2190 .unwrap();
2191
2192 let visual = lines[0]
2193 .items
2194 .iter()
2195 .filter_map(|item| match item {
2196 LineItem::MultilingualText(span) => {
2197 Some((span.text(), span.bidi_level(), span.base().direction))
2198 }
2199 _ => None,
2200 })
2201 .collect::<Vec<_>>();
2202 assert_eq!(
2203 visual.iter().map(|span| span.0).collect::<String>(),
2204 "ABC 123 العربية",
2205 "{visual:?}"
2206 );
2207 }
2208
2209 #[test]
2210 fn explicit_rtl_run_retains_numeric_levels_and_line_local_whitespace_reset() {
2211 let mut fm = deterministic_font_manager();
2212 let node = crate::SourceNodeId::new(13).unwrap();
2213 let mut segment = shaped_text_segment(&mut fm, "אבג 123 ", 0.0);
2214 segment.direction = TextDirection::RightToLeft;
2215 segment.source = Some(SourceSpan {
2216 node,
2217 char_start: 40,
2218 char_end: 50,
2219 });
2220 let rich = fm
2221 .shape_multilingual_paragraph(
2222 vec![(segment, Some("he-IL".to_owned()))],
2223 TextDirection::LeftToRight,
2224 false,
2225 )
2226 .unwrap();
2227 assert!(
2228 rich.iter()
2229 .any(|span| span.text() == "123" && span.bidi_level() == 2),
2230 "numeric span must retain its higher even level: {:?}",
2231 rich.iter()
2232 .map(|span| (span.text(), span.bidi_level()))
2233 .collect::<Vec<_>>()
2234 );
2235
2236 let lines = break_multilingual_into_lines(
2237 &rich
2238 .into_iter()
2239 .map(InlineItem::MultilingualText)
2240 .collect::<Vec<_>>(),
2241 &LineBreakParams {
2242 available_width: 1_000.0,
2243 ..Default::default()
2244 },
2245 &fm,
2246 TextDirection::LeftToRight,
2247 )
2248 .unwrap();
2249 let spans = lines[0]
2250 .items
2251 .iter()
2252 .filter_map(|item| match item {
2253 LineItem::MultilingualText(span) => Some(span),
2254 _ => None,
2255 })
2256 .collect::<Vec<_>>();
2257 assert!(spans.iter().any(|span| {
2258 span.text().chars().all(char::is_whitespace) && span.bidi_level() == 0
2259 }));
2260 assert!(
2261 spans
2262 .iter()
2263 .all(|span| span.base().source.unwrap().node == node)
2264 );
2265 }
2266
2267 #[test]
2268 fn inline_object_participates_in_rtl_visual_order_without_changing_text_source() {
2269 let mut fm = deterministic_font_manager();
2270 let node = crate::SourceNodeId::new(14).unwrap();
2271 let mut segment = shaped_text_segment(&mut fm, "אבג", 0.0);
2272 segment.source = Some(SourceSpan {
2273 node,
2274 char_start: 8,
2275 char_end: 11,
2276 });
2277 let mut rich = fm
2278 .shape_multilingual_text(segment, Some("he-IL"), TextDirection::RightToLeft, false)
2279 .unwrap();
2280 assert_eq!(rich.len(), 1);
2281 let items = vec![
2282 InlineItem::MultilingualText(rich.remove(0)),
2283 InlineItem::Image {
2284 width: 12.0,
2285 height: 12.0,
2286 media_id: MediaId(77),
2287 },
2288 ];
2289
2290 let lines = break_multilingual_into_lines(
2291 &items,
2292 &LineBreakParams {
2293 available_width: 1_000.0,
2294 ..Default::default()
2295 },
2296 &fm,
2297 TextDirection::RightToLeft,
2298 )
2299 .unwrap();
2300
2301 assert!(matches!(
2302 lines[0].items[0],
2303 LineItem::Image {
2304 media_id: MediaId(77),
2305 ..
2306 }
2307 ));
2308 let LineItem::MultilingualText(text) = &lines[0].items[1] else {
2309 panic!("RTL text must paint after the inline object")
2310 };
2311 assert_eq!(text.text(), "אבג");
2312 assert_eq!(
2313 text.base().source.unwrap(),
2314 SourceSpan {
2315 node,
2316 char_start: 8,
2317 char_end: 11,
2318 }
2319 );
2320 }
2321
2322 #[test]
2323 fn explicit_rtl_hyphenatable_latin_spans_keep_their_natural_even_levels() {
2324 let mut fm = deterministic_font_manager();
2325 let mut letters = shaped_text_segment(&mut fm, "ABC ", 0.0);
2326 letters.direction = TextDirection::RightToLeft;
2327 let mut digits = shaped_text_segment(&mut fm, "123", 0.0);
2328 digits.direction = TextDirection::RightToLeft;
2329 let lines = break_multilingual_into_lines(
2330 &[
2331 InlineItem::HyphenatedText {
2332 segment: letters,
2333 language: "en-US".to_owned(),
2334 },
2335 InlineItem::HyphenatedText {
2336 segment: digits,
2337 language: "en-US".to_owned(),
2338 },
2339 ],
2340 &LineBreakParams {
2341 available_width: 1_000.0,
2342 ..Default::default()
2343 },
2344 &fm,
2345 TextDirection::LeftToRight,
2346 )
2347 .unwrap();
2348 let visual = lines[0]
2349 .items
2350 .iter()
2351 .filter_map(|item| match item {
2352 LineItem::Text(segment) => Some(segment.text.as_str()),
2353 _ => None,
2354 })
2355 .collect::<String>();
2356 assert_eq!(visual, "ABC 123");
2357 }
2358
2359 #[test]
2360 fn one_styled_run_applies_line_local_l1_before_l2_without_losing_source() {
2361 let mut fm = deterministic_font_manager();
2362 let node = crate::SourceNodeId::new(9).unwrap();
2363 let mut segment = shaped_text_segment(&mut fm, "אבג אבג", 0.0);
2364 segment.source = Some(SourceSpan {
2365 node,
2366 char_start: 20,
2367 char_end: 29,
2368 });
2369 let rich = fm
2370 .shape_multilingual_paragraph(vec![(segment, None)], TextDirection::LeftToRight, false)
2371 .unwrap();
2372 assert_eq!(
2373 rich.iter().map(|span| span.text()).collect::<Vec<_>>(),
2374 ["אבג", " ", "אבג"]
2375 );
2376 let first_line_width = rich[0].width() + rich[1].width() + 0.01;
2377 let items = rich
2378 .into_iter()
2379 .map(InlineItem::MultilingualText)
2380 .collect::<Vec<_>>();
2381
2382 let lines = break_multilingual_into_lines(
2383 &items,
2384 &LineBreakParams {
2385 available_width: first_line_width,
2386 ..Default::default()
2387 },
2388 &fm,
2389 TextDirection::LeftToRight,
2390 )
2391 .unwrap();
2392 let spans = lines
2393 .iter()
2394 .flat_map(|line| &line.items)
2395 .filter_map(|item| match item {
2396 LineItem::MultilingualText(span) => Some((
2397 span.text(),
2398 span.logical_index(),
2399 span.bidi_level(),
2400 span.direction(),
2401 span.base().source.unwrap(),
2402 )),
2403 _ => None,
2404 })
2405 .collect::<Vec<_>>();
2406 assert_eq!(
2407 spans
2408 .iter()
2409 .map(|(text, index, level, direction, source)| (
2410 *text,
2411 *index,
2412 *level,
2413 *direction,
2414 source.char_start..source.char_end,
2415 ))
2416 .collect::<Vec<_>>(),
2417 [
2418 ("אבג", 0, 1, TextDirection::RightToLeft, 20..23),
2419 (" ", 1, 0, TextDirection::LeftToRight, 23..26),
2420 ("אבג", 2, 1, TextDirection::RightToLeft, 26..29),
2421 ]
2422 );
2423 assert!(spans.iter().all(|(_, _, _, _, source)| source.node == node));
2424 let first_line = lines[0]
2425 .items
2426 .iter()
2427 .filter_map(|item| match item {
2428 LineItem::MultilingualText(span) => Some(span.text()),
2429 _ => None,
2430 })
2431 .collect::<String>();
2432
2433 assert_eq!(first_line, "אבג ");
2434 }
2435
2436 #[test]
2437 fn cjk_prohibited_punctuation_never_starts_or_ends_a_line() {
2438 let mut fm = deterministic_font_manager();
2439 let mut segment = shaped_text_segment(&mut fm, "〈中〉、你好世界", 0.0);
2440 segment.source = Some(SourceSpan {
2441 node: crate::SourceNodeId::new(8).unwrap(),
2442 char_start: 5,
2443 char_end: 13,
2444 });
2445 let rich = fm
2446 .shape_multilingual_text(segment, Some("zh-CN"), TextDirection::LeftToRight, false)
2447 .unwrap();
2448 let items = rich
2449 .into_iter()
2450 .map(InlineItem::MultilingualText)
2451 .collect::<Vec<_>>();
2452 let lines = break_multilingual_into_lines(
2453 &items,
2454 &LineBreakParams {
2455 available_width: 35.0,
2456 ..Default::default()
2457 },
2458 &fm,
2459 TextDirection::LeftToRight,
2460 )
2461 .unwrap();
2462 let line_text = lines
2463 .iter()
2464 .map(|line| {
2465 line.items
2466 .iter()
2467 .filter_map(|item| match item {
2468 LineItem::MultilingualText(span) => Some(span.text()),
2469 _ => None,
2470 })
2471 .collect::<String>()
2472 })
2473 .collect::<Vec<_>>();
2474 assert_eq!(line_text, ["〈中〉、", "你", "好", "世", "界"]);
2475 assert!(
2476 line_text
2477 .iter()
2478 .all(|text| { !text.starts_with(['〉', '、']) && !text.ends_with('〈') })
2479 );
2480 }
2481
2482 #[test]
2483 fn rich_lines_never_start_with_a_comma_a_space_or_a_hyphen() {
2484 let mut fm = deterministic_font_manager();
2485 let text = "Repainted in 3 weeks while still in service, or re-coated next spring";
2486 let segment = shaped_text_segment(&mut fm, text, 0.0);
2487 let items = fm
2488 .shape_multilingual_paragraph(vec![(segment, None)], TextDirection::LeftToRight, false)
2489 .unwrap()
2490 .into_iter()
2491 .map(InlineItem::MultilingualText)
2492 .collect::<Vec<_>>();
2493 let rich_text = |line: &LayoutLine| {
2494 line.items
2495 .iter()
2496 .filter_map(|item| match item {
2497 LineItem::MultilingualText(span) => Some(span.text()),
2498 _ => None,
2499 })
2500 .collect::<String>()
2501 };
2502
2503 for width in (300..=1000).step_by(5) {
2504 let lines = break_multilingual_into_lines(
2505 &items,
2506 &LineBreakParams {
2507 available_width: f64::from(width),
2508 ..Default::default()
2509 },
2510 &fm,
2511 TextDirection::LeftToRight,
2512 )
2513 .unwrap();
2514 let texts = lines.iter().map(rich_text).collect::<Vec<_>>();
2515 assert_eq!(texts.concat(), text);
2516 for line_text in &texts[1..] {
2517 assert!(
2518 !line_text.starts_with([',', ' ', '-']),
2519 "{width}: {texts:?}"
2520 );
2521 }
2522 for line in &lines {
2523 let hanging = line
2524 .items
2525 .iter()
2526 .rev()
2527 .map_while(|item| match item {
2528 LineItem::MultilingualText(span) if span.text().trim().is_empty() => {
2529 Some(span.width())
2530 }
2531 _ => None,
2532 })
2533 .sum::<f64>();
2534 assert!(
2535 line.width - hanging <= line.available_width + 0.01,
2536 "{width}: {texts:?}"
2537 );
2538 }
2539 }
2540 }
2541
2542 #[test]
2543 fn a_rich_line_lets_the_space_after_its_last_word_hang() {
2544 let mut fm = deterministic_font_manager();
2545 let first = "Repainted in 3 weeks while still in service,";
2546 let segment = shaped_text_segment(&mut fm, &format!("{first} or re-coated"), 0.0);
2547 let ink = shaped_text_segment(&mut fm, first, 0.0).width;
2548 let items = fm
2549 .shape_multilingual_paragraph(vec![(segment, None)], TextDirection::LeftToRight, false)
2550 .unwrap()
2551 .into_iter()
2552 .map(InlineItem::MultilingualText)
2553 .collect::<Vec<_>>();
2554
2555 let lines = break_multilingual_into_lines(
2557 &items,
2558 &LineBreakParams {
2559 available_width: ink + 1.0,
2560 ..Default::default()
2561 },
2562 &fm,
2563 TextDirection::LeftToRight,
2564 )
2565 .unwrap();
2566
2567 let LineItem::MultilingualText(last) = lines[0].items.last().unwrap() else {
2568 panic!("the first line ends with rich text");
2569 };
2570 assert_eq!(last.text(), " ");
2571 assert!((lines[0].width - last.width() - ink).abs() < 0.001);
2572 assert!(lines[0].width > lines[0].available_width);
2573 let LineItem::MultilingualText(next) = &lines[1].items[0] else {
2574 panic!("the second line starts with rich text");
2575 };
2576 assert_eq!(next.text(), "or");
2577 }
2578
2579 #[test]
2580 fn a_plain_line_lets_the_spaces_after_its_last_word_hang() {
2581 let mut fm = deterministic_font_manager();
2582 let plain_text = |line: &LayoutLine| {
2583 line.items
2584 .iter()
2585 .map(|item| match item {
2586 LineItem::Text(segment) => segment.text.as_str(),
2587 _ => panic!("plain lines hold plain text"),
2588 })
2589 .collect::<String>()
2590 };
2591 let service = "Repainted in 3 weeks while still in service,";
2595 let office = "Repainted in 3 weeks while still in office,";
2596 let cases = [
2597 (service, " ", format!("{service} "), "or "),
2598 (service, " ", format!("{service} "), "or "),
2599 (
2600 service,
2601 "\u{a0} ",
2602 "Repainted in 3 weeks while still in ".to_owned(),
2603 "service,",
2604 ),
2605 (office, " ", format!("{office} "), "or "),
2606 ];
2607 for (first, spaces, first_line, next_start) in cases {
2608 let ink = shaped_text_segment(&mut fm, first, 0.0).width;
2609 let text = format!("{first}{spaces}or re-coated");
2610 let items = [InlineItem::Text(shaped_text_segment(&mut fm, &text, 0.0))];
2611 let lines = break_into_lines(
2612 &items,
2613 &LineBreakParams {
2614 available_width: ink + 1.0,
2615 ..Default::default()
2616 },
2617 &fm,
2618 )
2619 .unwrap();
2620
2621 assert_eq!(plain_text(&lines[0]), first_line, "{text:?}");
2622 assert!(plain_text(&lines[1]).starts_with(next_start), "{text:?}");
2623 assert_eq!(lines[0].hanging_space_counts(), (0, 1), "{text:?}");
2625 let LineItem::Text(last) = lines[0].items.last().unwrap() else {
2626 unreachable!("plain lines hold plain text");
2627 };
2628 assert_eq!(last.text, first_line[first_line.trim_end().len()..]);
2629 assert_eq!(last.glyph_ids.len(), last.text.chars().count());
2630 assert!(lines[0].width - last.width <= lines[0].available_width + 0.01);
2631 }
2632 let ligated = shaped_text_segment(&mut fm, office, 0.0);
2633 assert!(ligated.glyph_ids.len() < office.chars().count());
2634 }
2635
2636 #[test]
2637 fn hanging_spaces_sit_at_the_visual_end_of_their_direction() {
2638 let mut fm = deterministic_font_manager();
2639 let cases = [
2640 ("שלום עולם זה טקסט ארוך", TextDirection::RightToLeft, (1, 0)),
2641 (
2642 "one two three four five",
2643 TextDirection::LeftToRight,
2644 (0, 1),
2645 ),
2646 (
2648 "one\u{3000}two\u{3000}three",
2649 TextDirection::LeftToRight,
2650 (0, 0),
2651 ),
2652 ];
2653 for (text, direction, wrapped) in cases {
2654 let segment = shaped_text_segment(&mut fm, text, 0.0);
2655 let items = fm
2656 .shape_multilingual_paragraph(vec![(segment, None)], direction, false)
2657 .unwrap()
2658 .into_iter()
2659 .map(InlineItem::MultilingualText)
2660 .collect::<Vec<_>>();
2661 let lines = break_multilingual_into_lines(
2662 &items,
2663 &LineBreakParams {
2664 available_width: 150.0,
2665 ..Default::default()
2666 },
2667 &fm,
2668 direction,
2669 )
2670 .unwrap();
2671 assert!(lines.len() > 1, "{text}");
2672 for line in &lines[..lines.len() - 1] {
2673 assert_eq!(line.hanging_space_counts(), wrapped, "{text}");
2674 }
2675 assert_eq!(lines.last().unwrap().hanging_space_counts(), (0, 0));
2676 }
2677 }
2678
2679 #[test]
2680 fn legacy_stable_source_fixture_compiles_unchanged_public_shapes() {
2681 let segment = make_text_segment("legacy", 42.0);
2682 let _inline = InlineItem::Text(segment.clone());
2683 let _line = LineItem::Text(segment.clone());
2684 let _params = LineBreakParams {
2685 available_width: 468.0,
2686 ind_left: 0.0,
2687 ind_right: 0.0,
2688 ind_first_line: 0.0,
2689 ind_hanging: 0.0,
2690 tab_stops: Vec::new(),
2691 line_spacing: LineSpacing::Single,
2692 jc: None,
2693 wrap: true,
2694 line_prefix_widths: Vec::new(),
2695 line_suffix_widths: Vec::new(),
2696 default_tab_interval_pt: 36.0,
2697 clamp_tabs_past_margin: false,
2698 };
2699 let _shaped = crate::ShapedText {
2700 glyph_ids: segment.glyph_ids.clone(),
2701 advances: segment.advances.clone(),
2702 width: segment.width,
2703 };
2704 let _run = crate::GlyphRun {
2705 origin: crate::Point { x: 0.0, y: 0.0 },
2706 font_id: segment.font_id,
2707 font_size: segment.font_size,
2708 glyph_ids: segment.glyph_ids,
2709 advances: segment.advances,
2710 text: segment.text,
2711 source: segment.source,
2712 color: segment.color,
2713 bold: segment.bold,
2714 italic: segment.italic,
2715 field_kind: segment.field_kind,
2716 field_source: segment.field_source,
2717 note: segment.note,
2718 note_reference_source: segment.note_reference_source,
2719 tab_aligned: None,
2720 };
2721 }
2722
2723 #[test]
2724 fn empty_paragraph_gets_one_line() {
2725 let fm = deterministic_font_manager();
2726 let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
2727 assert_eq!(lines.len(), 1);
2728 assert!(lines[0].is_last);
2729 assert!(lines[0].items.is_empty());
2730 }
2731
2732 #[test]
2733 fn single_word_fits_one_line() {
2734 let fm = deterministic_font_manager();
2735 let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
2736 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2737 assert_eq!(lines.len(), 1);
2738 assert!(lines[0].is_last);
2739 }
2740
2741 #[test]
2742 fn words_wrap_to_multiple_lines() {
2743 let fm = deterministic_font_manager();
2744 let mut items = vec![
2746 InlineItem::Text(make_text_segment("Word1", 200.0)),
2747 InlineItem::Text(make_text_segment("Word2", 200.0)),
2748 ];
2749 items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
2750
2751 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2752 assert!(lines.len() >= 2);
2753 }
2754
2755 #[test]
2756 fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
2757 let mut fm = deterministic_font_manager();
2758 let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
2759 let spacing = 0.4;
2760 let segment = shaped_text_segment(&mut fm, text, spacing);
2761 assert_ne!(segment.glyph_ids.len(), text.chars().count());
2762
2763 let lines = break_into_lines(
2764 &[InlineItem::Text(segment)],
2765 &LineBreakParams {
2766 available_width: 260.0,
2767 ..LineBreakParams::default()
2768 },
2769 &fm,
2770 )
2771 .expect("wrap ligature-bearing text");
2772 assert!(lines.len() > 1);
2773
2774 let mut rendered_text = String::new();
2775 for text_segment in lines.iter().flat_map(|line| {
2776 line.items.iter().filter_map(|item| match item {
2777 LineItem::Text(segment) => Some(segment),
2778 _ => None,
2779 })
2780 }) {
2781 rendered_text.push_str(&text_segment.text);
2782 let exact = fm
2783 .shape_text(
2784 text_segment.font_id,
2785 &text_segment.text,
2786 text_segment.font_size,
2787 )
2788 .expect("reshape emitted chunk");
2789 assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
2790 assert_eq!(text_segment.advances.len(), exact.advances.len());
2791 for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
2792 assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
2793 }
2794 }
2795 assert_eq!(rendered_text, text);
2796 }
2797
2798 #[test]
2799 fn line_splitting_preserves_contiguous_unicode_source_ranges() {
2800 let mut fm = deterministic_font_manager();
2801 let node = crate::SourceNodeId::new(7).expect("a non-zero source id");
2802 let mut segment = shaped_text_segment(&mut fm, "ab 🚀界 cd", 0.0);
2803 segment.source = Some(crate::SourceSpan {
2804 node,
2805 char_start: 11,
2806 char_end: 19,
2807 });
2808
2809 let lines = break_into_lines(
2810 &[InlineItem::Text(segment)],
2811 &LineBreakParams {
2812 available_width: 55.0,
2813 ..LineBreakParams::default()
2814 },
2815 &fm,
2816 )
2817 .expect("split mixed Unicode text");
2818 let sourced = lines
2819 .iter()
2820 .flat_map(|line| &line.items)
2821 .filter_map(|item| match item {
2822 LineItem::Text(segment) => segment.source,
2823 _ => None,
2824 })
2825 .collect::<Vec<_>>();
2826
2827 assert!(sourced.len() > 1, "the fixture must cross a line boundary");
2828 assert_eq!(sourced.first().expect("first range").char_start, 11);
2829 assert_eq!(sourced.last().expect("last range").char_end, 19);
2830 for pair in sourced.windows(2) {
2831 assert_eq!(pair[0].node, node);
2832 assert_eq!(pair[0].char_end, pair[1].char_start);
2833 }
2834 }
2835
2836 #[test]
2837 fn forced_line_break() {
2838 let fm = deterministic_font_manager();
2839 let items = vec![
2840 InlineItem::Text(make_text_segment("Before", 50.0)),
2841 InlineItem::LineBreak,
2842 InlineItem::Text(make_text_segment("After", 50.0)),
2843 ];
2844 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2845 assert!(lines.len() >= 2);
2846 }
2847
2848 #[test]
2849 fn line_page_and_column_breaks_retain_their_kind() {
2850 let fm = deterministic_font_manager();
2851 let items = vec![
2852 InlineItem::LineBreak,
2853 InlineItem::PageBreak,
2854 InlineItem::ColumnBreak,
2855 InlineItem::Text(make_text_segment("after", 30.0)),
2856 ];
2857 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2858
2859 assert_eq!(
2860 lines
2861 .iter()
2862 .map(|line| line.forced_break_after)
2863 .collect::<Vec<_>>(),
2864 [
2865 Some(ForcedBreakKind::Line),
2866 Some(ForcedBreakKind::Page),
2867 Some(ForcedBreakKind::Column),
2868 None,
2869 ]
2870 );
2871 }
2872
2873 #[test]
2874 fn line_height_exact() {
2875 let params = LineBreakParams {
2876 line_spacing: LineSpacing::Exact(24.0),
2877 ..Default::default()
2878 };
2879 let h = compute_line_height(10.0, 3.0, 5.0, 12.0, ¶ms);
2880 assert!((h - 24.0).abs() < 0.01);
2881 }
2882
2883 #[test]
2884 fn line_height_auto() {
2885 let params = LineBreakParams {
2886 line_spacing: LineSpacing::Multiple(2.0),
2887 ..Default::default()
2888 };
2889 let h = compute_line_height(10.0, 3.0, 2.0, 15.0, ¶ms);
2890 assert!((h - 30.0).abs() < 0.01); }
2892
2893 #[test]
2894 fn first_line_indent() {
2895 let params = LineBreakParams {
2896 ind_first_line: 36.0,
2897 ..Default::default()
2898 };
2899 let first_w = compute_first_line_width(¶ms);
2900 let subseq_w = compute_subsequent_line_width(¶ms);
2901 assert!(first_w < subseq_w);
2902 }
2903
2904 #[test]
2905 fn hanging_indent() {
2906 let params = LineBreakParams {
2907 ind_left: 36.0,
2908 ind_hanging: 36.0,
2909 ..Default::default()
2910 };
2911 let first_indent = super::first_line_indent(¶ms);
2912 let subseq_indent = super::subsequent_line_indent(¶ms);
2913 assert!(first_indent < subseq_indent);
2914 }
2915
2916 fn text_item(text: &str, width: f64) -> InlineItem {
2917 InlineItem::Text(make_text_segment(text, width))
2918 }
2919
2920 fn even_text_item(text: &str, advance: f64) -> InlineItem {
2922 let count = text.chars().count();
2923 let mut segment = make_text_segment(text, advance * count as f64);
2924 segment.glyph_ids = vec![0; count];
2925 segment.advances = vec![advance; count];
2926 InlineItem::Text(segment)
2927 }
2928
2929 fn stop(pos_pt: f64, align: TabAlign) -> TabStop {
2930 TabStop {
2931 pos_pt,
2932 align,
2933 leader: None,
2934 }
2935 }
2936
2937 fn with_stops(tab_stops: Vec<TabStop>) -> LineBreakParams {
2938 LineBreakParams {
2939 tab_stops,
2940 ..Default::default()
2941 }
2942 }
2943
2944 fn tab_widths(line: &LayoutLine) -> Vec<f64> {
2945 line.items
2946 .iter()
2947 .filter_map(|item| match item {
2948 LineItem::Tab { width, .. } => Some(*width),
2949 _ => None,
2950 })
2951 .collect()
2952 }
2953
2954 fn tab_widths_by_line(items: &[InlineItem], params: &LineBreakParams) -> Vec<Vec<f64>> {
2956 break_into_lines(items, params, &deterministic_font_manager())
2957 .unwrap()
2958 .iter()
2959 .map(tab_widths)
2960 .collect()
2961 }
2962
2963 fn assert_widths(actual: &[f64], expected: &[f64]) {
2964 assert_eq!(actual.len(), expected.len(), "{actual:?} != {expected:?}");
2965 for (actual_width, expected_width) in actual.iter().zip(expected) {
2966 assert!(
2967 (actual_width - expected_width).abs() < 0.01,
2968 "{actual:?} != {expected:?}"
2969 );
2970 }
2971 }
2972
2973 #[test]
2974 fn tab_stop_resolution() {
2975 let widths = tab_widths_by_line(
2976 &[
2977 text_item("abc", 36.0),
2978 InlineItem::Tab,
2979 text_item("x", 10.0),
2980 ],
2981 &with_stops(vec![stop(72.0, TabAlign::Left)]),
2982 );
2983 assert_widths(&widths[0], &[36.0]);
2984 }
2985
2986 #[test]
2987 fn default_tab_stops() {
2988 let items = [
2989 text_item("a", 10.0),
2990 InlineItem::Tab,
2991 text_item("b", 5.0),
2992 InlineItem::Tab,
2993 text_item("c", 5.0),
2994 ];
2995 assert_widths(
2998 &tab_widths_by_line(&items, &LineBreakParams::default())[0],
2999 &[26.0, 31.0],
3000 );
3001
3002 let params = LineBreakParams {
3004 default_tab_interval_pt: 72.0,
3005 ..Default::default()
3006 };
3007 assert_widths(&tab_widths_by_line(&items, ¶ms)[0], &[62.0, 67.0]);
3008 }
3009
3010 #[test]
3011 fn tab_stop_with_dot_leader() {
3012 let mut fm = deterministic_font_manager();
3013 let title = shaped_text_segment(&mut fm, "Title", 0.0);
3014 let number = shaped_text_segment(&mut fm, "12", 0.0);
3015 let (title_width, number_width) = (title.width, number.width);
3016 let params = with_stops(vec![TabStop {
3017 pos_pt: 400.0,
3018 align: TabAlign::Right,
3019 leader: Some(TabLeader::Dot),
3020 }]);
3021 let lines = break_into_lines(
3022 &[
3023 InlineItem::Text(title),
3024 InlineItem::Tab,
3025 InlineItem::Text(number),
3026 ],
3027 ¶ms,
3028 &fm,
3029 )
3030 .unwrap();
3031
3032 let expected = 400.0 - title_width - number_width;
3034 let LineItem::Tab {
3035 width,
3036 leader: Some(leader),
3037 align: TabAlign::Right,
3038 ..
3039 } = &lines[0].items[1]
3040 else {
3041 panic!("a right stop with a dot leader shapes its leader");
3042 };
3043 assert!((width - expected).abs() < 0.01);
3044 assert!((leader.width - expected).abs() < 0.01);
3045 assert!(!leader.glyph_ids.is_empty());
3046 assert!(leader.text.chars().all(|ch| ch == '.'));
3047 assert!((lines[0].width - 400.0).abs() < 0.01);
3048 }
3049
3050 #[test]
3051 fn tab_stops_are_measured_from_the_zero_indent() {
3052 let params = LineBreakParams {
3053 ind_left: 36.0,
3054 tab_stops: vec![stop(150.0, TabAlign::Left)],
3055 ..Default::default()
3056 };
3057 let items = [
3058 text_item("Title", 26.5),
3059 InlineItem::Tab,
3060 text_item("12", 18.0),
3061 ];
3062 let widths = tab_widths_by_line(&items, ¶ms);
3063 assert_widths(&widths[0], &[150.0 - 36.0 - 26.5]);
3064
3065 let params = LineBreakParams {
3068 ind_left: 20.0,
3069 ..Default::default()
3070 };
3071 let widths = tab_widths_by_line(&items, ¶ms);
3072 assert_widths(&widths[0], &[72.0 - 20.0 - 26.5]);
3073 }
3074
3075 #[test]
3076 fn centre_and_decimal_stops_align_the_text_after_them() {
3077 let centred = tab_widths_by_line(
3078 &[
3079 text_item("T", 10.0),
3080 InlineItem::Tab,
3081 text_item("ABCDEF", 50.0),
3082 ],
3083 &with_stops(vec![stop(150.0, TabAlign::Center)]),
3084 );
3085 assert_widths(¢red[0], &[150.0 - 10.0 - 25.0]);
3086
3087 for (text, before) in [
3090 ("123.45", 3),
3091 ("12345e", 5),
3092 ("-12.5", 3),
3093 ("12,5e", 4),
3094 ("Ab12cd.5", 4),
3095 (".5", 0),
3096 ("abc", 3),
3097 ] {
3098 let widths = tab_widths_by_line(
3099 &[
3100 text_item("T", 10.0),
3101 InlineItem::Tab,
3102 even_text_item(text, 6.0),
3103 ],
3104 &with_stops(vec![stop(150.0, TabAlign::Decimal)]),
3105 );
3106 assert_widths(&widths[0], &[150.0 - 10.0 - 6.0 * before as f64]);
3107 }
3108
3109 let widths = tab_widths_by_line(
3111 &[
3112 text_item("T", 10.0),
3113 InlineItem::Tab,
3114 even_text_item("12", 6.0),
3115 even_text_item("34kg", 6.0),
3116 ],
3117 &with_stops(vec![stop(150.0, TabAlign::Decimal)]),
3118 );
3119 assert_widths(&widths[0], &[150.0 - 10.0 - 24.0]);
3120 }
3121
3122 #[test]
3123 fn text_too_wide_for_its_stop_starts_where_the_tab_does() {
3124 for align in [TabAlign::Right, TabAlign::Center, TabAlign::Decimal] {
3125 let widths = tab_widths_by_line(
3126 &[
3127 text_item("Title", 26.5),
3128 InlineItem::Tab,
3129 text_item("wide", 136.0),
3130 ],
3131 &with_stops(vec![stop(60.0, align)]),
3132 );
3133 assert_widths(&widths[0], &[0.0]);
3134 }
3135 }
3136
3137 #[test]
3138 fn bar_stops_neither_stop_a_tab_nor_clear_default_stops() {
3139 let widths = tab_widths_by_line(
3140 &[text_item("T", 10.0), InlineItem::Tab, text_item("12", 18.0)],
3141 &with_stops(vec![stop(75.0, TabAlign::Bar)]),
3142 );
3143 assert_widths(&widths[0], &[26.0]);
3144 }
3145
3146 #[test]
3147 fn default_stops_start_after_the_last_explicit_stop() {
3148 let params = with_stops(vec![
3149 stop(20.0, TabAlign::Left),
3150 stop(150.0, TabAlign::Left),
3151 ]);
3152 let widths = tab_widths_by_line(
3153 &[
3154 text_item("T", 10.0),
3155 InlineItem::Tab,
3156 text_item("A", 10.0),
3157 InlineItem::Tab,
3158 text_item("B", 10.0),
3159 InlineItem::Tab,
3160 text_item("C", 10.0),
3161 ],
3162 ¶ms,
3163 );
3164 assert_widths(&widths[0], &[10.0, 120.0, 20.0]);
3166 }
3167
3168 #[test]
3169 fn a_hanging_indent_is_a_stop_for_the_first_line() {
3170 let params = LineBreakParams {
3173 ind_left: 72.0,
3174 ind_hanging: 72.0,
3175 ..Default::default()
3176 };
3177 let items = [
3178 text_item("1.", 12.0),
3179 InlineItem::Tab,
3180 text_item("Item", 30.0),
3181 ];
3182 assert_widths(&tab_widths_by_line(&items, ¶ms)[0], &[60.0]);
3183
3184 let params = LineBreakParams {
3186 tab_stops: vec![stop(36.0, TabAlign::Left)],
3187 ..params
3188 };
3189 assert_widths(&tab_widths_by_line(&items, ¶ms)[0], &[24.0]);
3190 }
3191
3192 #[test]
3193 fn a_tab_with_no_default_stop_left_moves_to_the_next_line() {
3194 let params = LineBreakParams {
3195 available_width: 432.0,
3196 ..Default::default()
3197 };
3198 let widths = tab_widths_by_line(
3199 &[
3200 text_item("x", 423.5),
3201 InlineItem::Tab,
3202 text_item("12", 18.0),
3203 ],
3204 ¶ms,
3205 );
3206 assert_eq!(widths.len(), 2);
3207 assert_widths(&widths[0], &[]);
3208 assert_widths(&widths[1], &[36.0]);
3209 }
3210
3211 #[test]
3212 fn stops_past_the_right_margin_follow_the_compatibility_mode() {
3213 let items = [text_item("T", 13.0), InlineItem::Tab, text_item("12", 18.0)];
3214 let params = |align, clamp, ind_right| LineBreakParams {
3215 available_width: 432.0,
3216 ind_right,
3217 tab_stops: vec![stop(500.0, align)],
3218 clamp_tabs_past_margin: clamp,
3219 ..Default::default()
3220 };
3221 let widths = tab_widths_by_line(&items, ¶ms(TabAlign::Right, false, 0.0));
3224 assert_widths(&widths[0], &[500.0 - 31.0]);
3225 let widths = tab_widths_by_line(&items, ¶ms(TabAlign::Right, true, 0.0));
3226 assert_widths(&widths[0], &[432.0 - 31.0]);
3227 let widths = tab_widths_by_line(&items, ¶ms(TabAlign::Right, true, 100.0));
3228 assert_widths(&widths[0], &[332.0 - 31.0]);
3229
3230 let long = [
3234 text_item("T", 13.0),
3235 InlineItem::Tab,
3236 text_item("aa ", 20.0),
3237 text_item("bb", 20.0),
3238 ];
3239 let widths = tab_widths_by_line(&long, ¶ms(TabAlign::Left, false, 0.0));
3240 assert_eq!(widths.len(), 1);
3241 assert_widths(&widths[0], &[487.0]);
3242 let widths = tab_widths_by_line(&long, ¶ms(TabAlign::Left, true, 0.0));
3243 assert_eq!(widths.len(), 3);
3244 assert_widths(&widths[0], &[]);
3245 assert_widths(&widths[1], &[432.0]);
3246 assert_widths(&widths[2], &[]);
3247 let at_end = LineBreakParams {
3249 tab_stops: vec![stop(432.0, TabAlign::Left)],
3250 ..params(TabAlign::Left, true, 0.0)
3251 };
3252 assert_eq!(tab_widths_by_line(&long, &at_end).len(), 3);
3253
3254 let mut fm = deterministic_font_manager();
3257 let word = shaped_text_segment(&mut fm, "word ", 0.0);
3258 let bare = fm
3259 .shape_text(word.font_id, "word", word.font_size)
3260 .unwrap()
3261 .width;
3262 let mut wrapped = vec![text_item("A", 10.0), InlineItem::Tab];
3263 wrapped.extend((0..40).map(|_| InlineItem::Text(word.clone())));
3264 let lines = break_into_lines(
3265 &wrapped,
3266 &LineBreakParams {
3267 available_width: 432.0,
3268 tab_stops: vec![stop(432.0, TabAlign::Right)],
3269 clamp_tabs_past_margin: true,
3270 ..Default::default()
3271 },
3272 &fm,
3273 )
3274 .unwrap();
3275 let words = lines[0].items.len() - 2 - lines[0].hanging_space_counts().1;
3277 let tab = tab_widths(&lines[0])[0];
3278 let end = 10.0 + tab + (words - 1) as f64 * word.width + bare;
3279 assert!(
3280 (end - 432.0).abs() < 0.01,
3281 "{words} words, tab {tab}, {} {bare}, end {end}",
3282 word.width
3283 );
3284
3285 let params = LineBreakParams {
3288 available_width: 432.0,
3289 ind_right: 100.0,
3290 tab_stops: vec![stop(400.0, TabAlign::Left)],
3291 ..Default::default()
3292 };
3293 let widths = tab_widths_by_line(&items, ¶ms);
3294 assert_eq!(widths.len(), 1);
3295 assert_widths(&widths[0], &[387.0]);
3296 }
3297
3298 #[test]
3299 fn text_after_a_centre_stop_is_pushed_back_inside_the_margin() {
3300 let items = [
3301 text_item("T", 13.0),
3302 InlineItem::Tab,
3303 text_item("CENTERED", 66.6),
3304 ];
3305 let params = |pos, clamp| LineBreakParams {
3306 available_width: 432.0,
3307 tab_stops: vec![stop(pos, TabAlign::Center)],
3308 clamp_tabs_past_margin: clamp,
3309 ..Default::default()
3310 };
3311 for clamp in [false, true] {
3312 let widths = tab_widths_by_line(&items, ¶ms(420.0, clamp));
3313 assert_widths(&widths[0], &[432.0 - 13.0 - 66.6]);
3314 }
3315 let widths = tab_widths_by_line(&items, ¶ms(432.0, false));
3317 assert_widths(&widths[0], &[432.0 - 13.0 - 33.3]);
3318 let widths = tab_widths_by_line(&items, ¶ms(432.0, true));
3319 assert_widths(&widths[0], &[432.0 - 13.0 - 66.6]);
3320 }
3321
3322 #[test]
3323 fn the_eleven_line_tests_pass_with_owned_types() {
3324 assert!(LineBreakParams::default().wrap);
3325 empty_paragraph_gets_one_line();
3326 single_word_fits_one_line();
3327 words_wrap_to_multiple_lines();
3328 forced_line_break();
3329 line_height_exact();
3330 line_height_auto();
3331 first_line_indent();
3332 hanging_indent();
3333 tab_stop_resolution();
3334 default_tab_stops();
3335 tab_stop_with_dot_leader();
3336 }
3337
3338 #[test]
3339 fn line_spacing_variants_preserve_existing_height_rules() {
3340 let height = |line_spacing| {
3341 compute_line_height(
3342 10.0,
3343 3.0,
3344 2.0,
3345 11.0,
3346 &LineBreakParams {
3347 line_spacing,
3348 ..Default::default()
3349 },
3350 )
3351 };
3352
3353 assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
3354 assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
3355 assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
3356 assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
3357 assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
3358 }
3359
3360 #[test]
3361 fn mixed_font_line_uses_tallest_full_natural_advance() {
3362 let fm = deterministic_font_manager();
3363 let mut first = make_text_segment("first", 20.0);
3364 first.ascent = 10.0;
3365 first.descent = 2.0;
3366 first.line_gap = 4.0;
3367 let mut second = make_text_segment("second", 20.0);
3368 second.ascent = 8.0;
3369 second.descent = 5.0;
3370 second.line_gap = 1.0;
3371
3372 let lines = break_into_lines(
3373 &[InlineItem::Text(first), InlineItem::Text(second)],
3374 &LineBreakParams::default(),
3375 &fm,
3376 )
3377 .expect("lay out mixed-font line");
3378
3379 assert_eq!(lines.len(), 1);
3380 assert!((lines[0].ascent - 10.0).abs() < 0.01);
3381 assert!((lines[0].descent - 5.0).abs() < 0.01);
3382 assert!((lines[0].line_gap - 1.0).abs() < 0.01);
3383 assert!((lines[0].height - 16.0).abs() < 0.01);
3384 assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
3385 }
3386
3387 #[test]
3388 fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
3389 let fm = deterministic_font_manager();
3390 let mut first = make_text_segment("first", 20.0);
3391 first.font_size = 12.0;
3392 first.line_gap = 4.0;
3393 let mut second = make_text_segment("second", 20.0);
3394 second.font_size = 20.0;
3395 second.line_gap = 1.0;
3396
3397 let lines = break_into_lines(
3398 &[InlineItem::Text(first), InlineItem::Text(second)],
3399 &LineBreakParams {
3400 line_spacing: LineSpacing::Multiple(1.25),
3401 ..LineBreakParams::default()
3402 },
3403 &fm,
3404 )
3405 .expect("lay out percentage-spaced mixed-size line");
3406
3407 assert_eq!(lines.len(), 1);
3408 assert!((lines[0].height - 25.0).abs() < 0.01);
3409 }
3410
3411 #[test]
3412 fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
3413 let positive = LayoutLine {
3414 items: Vec::new(),
3415 width: 0.0,
3416 ascent: 10.0,
3417 descent: 3.0,
3418 line_gap: 5.0,
3419 height: 18.0,
3420 indent_left: 0.0,
3421 available_width: 100.0,
3422 is_last: true,
3423 forced_break_after: None,
3424 };
3425 let below_natural = LayoutLine {
3426 height: 8.0,
3427 ..positive.clone()
3428 };
3429
3430 assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
3431 assert!((below_natural.height - 8.0).abs() < 0.01);
3432 assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
3433 }
3434
3435 #[test]
3436 fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
3437 let fm = deterministic_font_manager();
3438 let zero_gap = make_text_segment("zero", 20.0);
3439 let mut empty = make_text_segment("", 0.0);
3440 empty.line_gap = 4.0;
3441
3442 let zero_gap_line = break_into_lines(
3443 &[InlineItem::Text(zero_gap)],
3444 &LineBreakParams::default(),
3445 &fm,
3446 )
3447 .expect("lay out zero-gap line");
3448 let empty_line =
3449 break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
3450 .expect("lay out styled empty line");
3451
3452 assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
3453 assert!((empty_line[0].height - 17.0).abs() < 0.01);
3454 }
3455
3456 #[test]
3457 fn wrap_false_only_breaks_on_an_explicit_break() {
3458 let fm = deterministic_font_manager();
3459 let params = LineBreakParams {
3460 available_width: 100.0,
3461 wrap: false,
3462 ..Default::default()
3463 };
3464
3465 for forced_break in [
3466 InlineItem::LineBreak,
3467 InlineItem::PageBreak,
3468 InlineItem::ColumnBreak,
3469 ] {
3470 let items = vec![
3471 InlineItem::Text(make_text_segment("one", 80.0)),
3472 InlineItem::Text(make_text_segment("two", 80.0)),
3473 forced_break,
3474 InlineItem::Text(make_text_segment("three", 80.0)),
3475 InlineItem::Text(make_text_segment("four", 80.0)),
3476 ];
3477 let lines = break_into_lines(&items, ¶ms, &fm).unwrap();
3478
3479 assert_eq!(lines.len(), 2);
3480 assert!((lines[0].width - 160.0).abs() < 0.01);
3481 assert!((lines[1].width - 160.0).abs() < 0.01);
3482 }
3483 }
3484
3485 #[test]
3486 fn staged_image_types_use_media_id_instead_of_embed_id() {
3487 let media_id = crate::MediaId::from_bytes(b"image");
3488 let item = inline_to_line_item(&InlineItem::Image {
3489 width: 10.0,
3490 height: 20.0,
3491 media_id,
3492 });
3493 let LineItem::Image {
3494 media_id: actual, ..
3495 } = item
3496 else {
3497 panic!("image should remain an image");
3498 };
3499 assert_eq!(actual, media_id);
3500 }
3501
3502 #[test]
3503 fn group_inline_item_breaks_and_positions_like_an_image() {
3504 use crate::{GroupElement, PositionedElement, Transform};
3505
3506 let group = GroupElement {
3507 transform: Transform::IDENTITY,
3508 clip: None,
3509 opacity: 1.0,
3510 effects: Vec::new(),
3511 children: vec![PositionedElement::FilledRect {
3512 rect: crate::Rect {
3513 x: 2.0,
3514 y: 3.0,
3515 width: 4.0,
3516 height: 5.0,
3517 },
3518 color: crate::Color::BLACK,
3519 }],
3520 };
3521 let items = vec![InlineItem::Group {
3522 width: 80.0,
3523 height: 40.0,
3524 baseline: None,
3525 group: group.clone(),
3526 }];
3527 let lines = break_into_lines(
3528 &items,
3529 &LineBreakParams {
3530 available_width: 80.0,
3531 ..Default::default()
3532 },
3533 &deterministic_font_manager(),
3534 )
3535 .expect("group line breaking");
3536
3537 assert_eq!(lines.len(), 1);
3538 assert_eq!(lines[0].width, 80.0);
3539 assert_eq!(lines[0].height, 40.0);
3540 let LineItem::Group {
3541 width,
3542 height,
3543 group: actual,
3544 ..
3545 } = &lines[0].items[0]
3546 else {
3547 panic!("inline group should remain a group line item");
3548 };
3549 assert_eq!((*width, *height), (80.0, 40.0));
3550 assert_eq!(actual, &group);
3551 }
3552
3553 #[test]
3554 fn baseline_aware_inline_groups_contribute_exact_ascent_and_descent() {
3555 use crate::{GroupElement, Transform};
3556
3557 let group = GroupElement {
3558 transform: Transform::IDENTITY,
3559 clip: None,
3560 opacity: 1.0,
3561 effects: Vec::new(),
3562 children: Vec::new(),
3563 };
3564 let lines = break_into_lines(
3565 &[
3566 InlineItem::Group {
3567 width: 20.0,
3568 height: 18.0,
3569 baseline: None,
3570 group: group.clone(),
3571 },
3572 InlineItem::Group {
3573 width: 20.0,
3574 height: 18.0,
3575 baseline: Some(11.0),
3576 group,
3577 },
3578 ],
3579 &LineBreakParams {
3580 available_width: 100.0,
3581 ..Default::default()
3582 },
3583 &deterministic_font_manager(),
3584 )
3585 .expect("group line breaking");
3586
3587 assert_eq!(lines.len(), 1);
3588 assert_eq!(lines[0].ascent, 18.0);
3589 assert_eq!(lines[0].descent, 7.0);
3590 let LineItem::Group {
3591 baseline: Some(baseline),
3592 ..
3593 } = &lines[0].items[1]
3594 else {
3595 panic!("baseline should survive line breaking");
3596 };
3597 assert_eq!(*baseline, 11.0);
3598 }
3599
3600 #[test]
3601 fn group_baselines_are_normalized_before_pagination() {
3602 use crate::{GroupElement, Transform};
3603
3604 for (height, baseline, expected) in [
3605 (18.0, Some(-4.0), Some(0.0)),
3606 (18.0, Some(30.0), Some(18.0)),
3607 (18.0, Some(f64::NAN), None),
3608 (-18.0, Some(4.0), Some(0.0)),
3609 (f64::NAN, Some(4.0), None),
3610 ] {
3611 let item = InlineItem::Group {
3612 width: 20.0,
3613 height,
3614 baseline,
3615 group: GroupElement {
3616 transform: Transform::IDENTITY,
3617 clip: None,
3618 opacity: 1.0,
3619 effects: Vec::new(),
3620 children: Vec::new(),
3621 },
3622 };
3623 let LineItem::Group { baseline, .. } = inline_to_line_item(&item) else {
3624 panic!("inline group should remain a group line item");
3625 };
3626 assert_eq!(baseline, expected);
3627 }
3628 }
3629}