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