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 },
193 Image {
194 width: f64,
195 height: f64,
196 media_id: MediaId,
197 },
198 Group {
199 width: f64,
200 height: f64,
201 baseline: Option<f64>,
203 group: GroupElement,
204 },
205 Figure {
207 item: Box<LineItem>,
208 alternate_text: String,
209 structure_id: Option<StructureId>,
210 },
211 Marker(TextSegment),
212}
213
214impl LineItem {
215 pub fn width(&self) -> f64 {
216 match self {
217 LineItem::Text(seg) => seg.width,
218 LineItem::MultilingualText(seg) => seg.width(),
219 LineItem::Tab { width, .. } => *width,
220 LineItem::Image { width, .. } => *width,
221 LineItem::Group { width, .. } => *width,
222 LineItem::Figure { item, .. } => item.width(),
223 LineItem::Marker(seg) => seg.width,
224 }
225 }
226}
227
228#[non_exhaustive]
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum ForcedBreakKind {
232 Line,
233 Page,
234 Column,
235}
236
237#[derive(Debug, Clone)]
239pub struct LayoutLine {
240 pub items: Vec<LineItem>,
241 pub width: f64,
243 pub ascent: f64,
245 pub descent: f64,
247 pub line_gap: f64,
249 pub height: f64,
251 pub indent_left: f64,
253 pub available_width: f64,
255 pub is_last: bool,
257 pub forced_break_after: Option<ForcedBreakKind>,
259}
260
261impl LayoutLine {
262 pub fn baseline_offset(&self) -> f64 {
264 let leading = self.height - self.ascent - self.descent;
265 self.ascent + if leading >= 0.0 { leading / 2.0 } else { 0.0 }
266 }
267}
268
269#[derive(Debug, Clone)]
271pub struct LineBreakParams {
272 pub available_width: f64,
274 pub ind_left: f64,
276 pub ind_right: f64,
278 pub ind_first_line: f64,
280 pub ind_hanging: f64,
282 pub tab_stops: Vec<TabStop>,
284 pub line_spacing: LineSpacing,
286 pub jc: Option<Align>,
288 pub wrap: bool,
290 pub line_prefix_widths: Vec<f64>,
296 pub line_suffix_widths: Vec<f64>,
298}
299
300impl Default for LineBreakParams {
301 fn default() -> Self {
302 LineBreakParams {
303 available_width: 468.0, line_prefix_widths: Vec::new(),
305 line_suffix_widths: Vec::new(),
306 ind_left: 0.0,
307 ind_right: 0.0,
308 ind_first_line: 0.0,
309 ind_hanging: 0.0,
310 tab_stops: Vec::new(),
311 line_spacing: LineSpacing::Single,
312 jc: None,
313 wrap: true,
314 }
315 }
316}
317
318pub fn break_into_lines(
320 items: &[InlineItem],
321 params: &LineBreakParams,
322 fm: &FontManager,
323) -> Result<Vec<LayoutLine>> {
324 if items.is_empty() {
325 return Ok(vec![LayoutLine {
327 items: Vec::new(),
328 width: 0.0,
329 ascent: 0.0,
330 descent: 0.0,
331 line_gap: 0.0,
332 height: compute_line_height(0.0, 0.0, 0.0, 0.0, params),
333 indent_left: line_indent_at(params, 0, true),
334 available_width: line_width_at(params, 0, true),
335 is_last: true,
336 forced_break_after: None,
337 }]);
338 }
339
340 let mut lines: Vec<LayoutLine> = Vec::new();
341 let mut current_items: Vec<LineItem> = Vec::new();
342 let mut current_width: f64 = 0.0;
343 let mut current_ascent: f64 = 0.0;
344 let mut current_descent: f64 = 0.0;
345 let mut current_natural_height: f64 = 0.0;
346 let mut current_font_size: f64 = 0.0;
347 let mut line_index = 0usize;
350 let mut is_first_line = true;
351
352 let first_line_width = line_width_at(params, 0, true);
353
354 let mut line_avail = first_line_width;
355
356 let mut font_ctx: Option<(FontId, f64)> = None;
358 for item in items {
360 if let InlineItem::Text(seg)
361 | InlineItem::HyphenatedText { segment: seg, .. }
362 | InlineItem::Marker(seg) = item
363 {
364 font_ctx = Some((seg.font_id, seg.font_size));
365 break;
366 }
367 if let InlineItem::MultilingualText(seg) = item {
368 font_ctx = Some((seg.font_id(), seg.base().font_size));
369 break;
370 }
371 }
372
373 let mut segments = std::collections::VecDeque::from(build_breakable_segments(items, fm)?);
375
376 while let Some(seg) = segments.pop_front() {
377 match seg {
378 BreakableSegment::Items(seg_items) => {
379 let seg_width: f64 = seg_items.iter().map(inline_item_width).sum();
380
381 if params.wrap
382 && !current_items.is_empty()
383 && current_width + seg_width > line_avail + 0.01
384 {
385 let indent = line_indent_at(params, line_index, is_first_line);
387 let line_gap =
388 effective_line_gap(current_ascent, current_descent, current_natural_height);
389 lines.push(LayoutLine {
390 items: std::mem::take(&mut current_items),
391 width: current_width,
392 ascent: current_ascent,
393 descent: current_descent,
394 line_gap,
395 height: compute_line_height(
396 current_ascent,
397 current_descent,
398 line_gap,
399 current_font_size,
400 params,
401 ),
402 indent_left: indent,
403 available_width: line_avail,
404 is_last: false,
405 forced_break_after: None,
406 });
407 current_width = 0.0;
408 current_ascent = 0.0;
409 current_descent = 0.0;
410 current_natural_height = 0.0;
411 current_font_size = 0.0;
412 is_first_line = false;
413 line_index += 1;
414 line_avail = line_width_at(params, line_index, false);
415 }
416
417 for item in &seg_items {
419 let (w, a, d, natural_height, font_size) = item_metrics(item);
420 current_width += w;
421 if a > current_ascent {
422 current_ascent = a;
423 }
424 if d > current_descent {
425 current_descent = d;
426 }
427 current_natural_height = current_natural_height.max(natural_height);
428 current_font_size = current_font_size.max(font_size);
429 if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
431 font_ctx = Some((seg.font_id, seg.font_size));
432 } else if let InlineItem::MultilingualText(seg) = item {
433 font_ctx = Some((seg.font_id(), seg.base().font_size));
434 }
435 current_items.push(inline_to_line_item(
436 item,
437 current_width,
438 ¶ms.tab_stops,
439 fm,
440 font_ctx,
441 ));
442 }
443 }
444 BreakableSegment::Hyphenated(boxed) => {
445 let HyphenatedSegment {
446 segment,
447 break_points,
448 } = *boxed;
449 let fits = current_width + segment.width <= line_avail + 0.01;
450 if !params.wrap || fits {
451 let item = InlineItem::Text(segment);
452 let (w, a, d, natural_height, font_size) = item_metrics(&item);
453 current_width += w;
454 current_ascent = current_ascent.max(a);
455 current_descent = current_descent.max(d);
456 current_natural_height = current_natural_height.max(natural_height);
457 current_font_size = current_font_size.max(font_size);
458 font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
459 current_items.push(inline_to_line_item(
460 &item,
461 current_width,
462 ¶ms.tab_stops,
463 fm,
464 font_ctx,
465 ));
466 continue;
467 }
468
469 if let Some(FittingHyphenation {
470 prefix,
471 hyphen,
472 remainder,
473 remaining_points,
474 }) = fitting_hyphenation(&segment, &break_points, current_width, line_avail, fm)?
475 {
476 for text in [prefix, hyphen] {
477 let item = InlineItem::Text(text);
478 let (w, a, d, natural_height, font_size) = item_metrics(&item);
479 current_width += w;
480 current_ascent = current_ascent.max(a);
481 current_descent = current_descent.max(d);
482 current_natural_height = current_natural_height.max(natural_height);
483 current_font_size = current_font_size.max(font_size);
484 font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
485 current_items.push(inline_to_line_item(
486 &item,
487 current_width,
488 ¶ms.tab_stops,
489 fm,
490 font_ctx,
491 ));
492 }
493
494 let indent = line_indent_at(params, line_index, is_first_line);
495 let line_gap =
496 effective_line_gap(current_ascent, current_descent, current_natural_height);
497 lines.push(LayoutLine {
498 items: std::mem::take(&mut current_items),
499 width: current_width,
500 ascent: current_ascent,
501 descent: current_descent,
502 line_gap,
503 height: compute_line_height(
504 current_ascent,
505 current_descent,
506 line_gap,
507 current_font_size,
508 params,
509 ),
510 indent_left: indent,
511 available_width: line_avail,
512 is_last: false,
513 forced_break_after: None,
514 });
515 current_width = 0.0;
516 current_ascent = 0.0;
517 current_descent = 0.0;
518 current_natural_height = 0.0;
519 current_font_size = 0.0;
520 is_first_line = false;
521 line_index += 1;
522 line_avail = line_width_at(params, line_index, false);
523 segments.push_front(BreakableSegment::Hyphenated(Box::new(
524 HyphenatedSegment {
525 segment: remainder,
526 break_points: remaining_points,
527 },
528 )));
529 } else if !current_items.is_empty() {
530 let indent = line_indent_at(params, line_index, is_first_line);
531 let line_gap =
532 effective_line_gap(current_ascent, current_descent, current_natural_height);
533 lines.push(LayoutLine {
534 items: std::mem::take(&mut current_items),
535 width: current_width,
536 ascent: current_ascent,
537 descent: current_descent,
538 line_gap,
539 height: compute_line_height(
540 current_ascent,
541 current_descent,
542 line_gap,
543 current_font_size,
544 params,
545 ),
546 indent_left: indent,
547 available_width: line_avail,
548 is_last: false,
549 forced_break_after: None,
550 });
551 current_width = 0.0;
552 current_ascent = 0.0;
553 current_descent = 0.0;
554 current_natural_height = 0.0;
555 current_font_size = 0.0;
556 is_first_line = false;
557 line_index += 1;
558 line_avail = line_width_at(params, line_index, false);
559 segments.push_front(BreakableSegment::Hyphenated(Box::new(
560 HyphenatedSegment {
561 segment,
562 break_points,
563 },
564 )));
565 } else {
566 let item = InlineItem::Text(segment);
567 let (w, a, d, natural_height, font_size) = item_metrics(&item);
568 current_width += w;
569 current_ascent = current_ascent.max(a);
570 current_descent = current_descent.max(d);
571 current_natural_height = current_natural_height.max(natural_height);
572 current_font_size = current_font_size.max(font_size);
573 font_ctx = Some((segment_font_id(&item), segment_font_size(&item)));
574 current_items.push(inline_to_line_item(
575 &item,
576 current_width,
577 ¶ms.tab_stops,
578 fm,
579 font_ctx,
580 ));
581 }
582 }
583 BreakableSegment::ForcedBreak(break_kind) => {
584 let indent = line_indent_at(params, line_index, is_first_line);
585 let line_gap =
586 effective_line_gap(current_ascent, current_descent, current_natural_height);
587 lines.push(LayoutLine {
588 items: std::mem::take(&mut current_items),
589 width: current_width,
590 ascent: current_ascent,
591 descent: current_descent,
592 line_gap,
593 height: compute_line_height(
594 current_ascent,
595 current_descent,
596 line_gap,
597 current_font_size,
598 params,
599 ),
600 indent_left: indent,
601 available_width: line_avail,
602 is_last: matches!(break_kind, ForcedBreakKind::Page | ForcedBreakKind::Column),
603 forced_break_after: Some(break_kind),
604 });
605 current_width = 0.0;
606 current_ascent = 0.0;
607 current_descent = 0.0;
608 current_natural_height = 0.0;
609 current_font_size = 0.0;
610 is_first_line = false;
611 line_index += 1;
612 line_avail = line_width_at(params, line_index, false);
613 }
614 }
615 }
616
617 let indent = line_indent_at(params, line_index, is_first_line);
619 let line_gap = effective_line_gap(current_ascent, current_descent, current_natural_height);
620 lines.push(LayoutLine {
621 items: current_items,
622 width: current_width,
623 ascent: current_ascent,
624 descent: current_descent,
625 line_gap,
626 height: compute_line_height(
627 current_ascent,
628 current_descent,
629 line_gap,
630 current_font_size,
631 params,
632 ),
633 indent_left: indent,
634 available_width: line_avail,
635 is_last: true,
636 forced_break_after: None,
637 });
638
639 Ok(lines)
640}
641
642pub fn break_multilingual_into_lines(
644 items: &[InlineItem],
645 params: &LineBreakParams,
646 fm: &FontManager,
647 base_direction: TextDirection,
648) -> Result<Vec<LayoutLine>> {
649 let mut lines = break_into_lines(items, params, fm)?;
650 let mut paragraph_text = String::new();
651 let mut line_maps = Vec::with_capacity(lines.len());
652 let mut has_text = false;
653 for line in &lines {
654 let line_start = paragraph_text.len();
655 let mut positions = Vec::new();
656 for (index, item) in line.items.iter().enumerate() {
657 let (text, direction, shaped_level) = match item {
658 LineItem::Text(segment) | LineItem::Marker(segment) => {
659 (segment.text.as_str(), segment.direction, None)
660 }
661 LineItem::MultilingualText(segment) => (
662 segment.text(),
663 segment.base().direction,
664 Some(unicode_bidi::Level::new(segment.bidi_level()).map_err(|_| {
665 LayoutError::Layout("rich text carried an invalid bidi level".to_owned())
666 })?),
667 ),
668 LineItem::Tab { .. } => ("\t", TextDirection::Auto, None),
669 LineItem::Image { .. } | LineItem::Group { .. } | LineItem::Figure { .. } => {
670 ("\u{fffc}", TextDirection::Auto, None)
671 }
672 };
673 has_text |= !text.is_empty();
674 let start = paragraph_text.len();
675 paragraph_text.push_str(text);
676 let end = paragraph_text.len();
677 positions.push((
678 index,
679 start,
680 end,
681 direction,
682 text.chars().all(char::is_whitespace),
683 shaped_level,
684 ));
685 }
686 line_maps.push((line_start..paragraph_text.len(), positions));
687 }
688 if !has_text {
689 return Ok(lines);
690 }
691 let paragraph_level = match base_direction {
692 TextDirection::Auto => None,
693 TextDirection::LeftToRight => Some(unicode_bidi::Level::ltr()),
694 TextDirection::RightToLeft => Some(unicode_bidi::Level::rtl()),
695 };
696 let bidi = unicode_bidi::BidiInfo::new(¶graph_text, paragraph_level);
697 let [paragraph] = bidi.paragraphs.as_slice() else {
698 return Err(LayoutError::Layout(
699 "multilingual line layout requires one bidi paragraph".to_owned(),
700 ));
701 };
702 for (line, (line_range, mapped_positions)) in lines.iter_mut().zip(line_maps) {
703 let positions = mapped_positions
704 .iter()
705 .map(|(index, _, _, _, _, _)| *index)
706 .collect::<Vec<_>>();
707 if positions.is_empty() {
708 continue;
709 }
710 let adjusted_levels = bidi.reordered_levels(paragraph, line_range);
711 let levels = mapped_positions
712 .into_iter()
713 .map(|(_, start, end, direction, whitespace, shaped_level)| {
714 let adjusted = adjusted_levels.get(start).copied().ok_or_else(|| {
715 LayoutError::Layout(
716 "multilingual line range exceeded its bidi paragraph".to_owned(),
717 )
718 })?;
719 Ok(if whitespace {
720 adjusted
721 } else if let Some(shaped_level) = shaped_level {
722 shaped_level
723 } else if direction == TextDirection::Auto {
724 adjusted
725 } else {
726 explicit_direction_levels(
727 ¶graph_text[start..end],
728 direction,
729 paragraph.level,
730 )?
731 .first()
732 .copied()
733 .unwrap_or(adjusted)
734 })
735 })
736 .collect::<Result<Vec<_>>>()?;
737 let visual_order = unicode_bidi::BidiInfo::reorder_visual(&levels);
738 let logical_items = positions
739 .iter()
740 .zip(&levels)
741 .map(|(index, level)| match &line.items[*index] {
742 LineItem::MultilingualText(segment) => Ok(LineItem::MultilingualText(
743 multilingual_segment_with_level(segment, *level)?,
744 )),
745 item => Ok(item.clone()),
746 })
747 .collect::<Result<Vec<_>>>()?;
748 for (visual_slot, logical_index) in positions.into_iter().zip(visual_order) {
749 line.items[visual_slot] = logical_items[logical_index].clone();
750 }
751 }
752 Ok(lines)
753}
754
755fn multilingual_segment_with_level(
756 segment: &MultilingualTextSegment,
757 level: unicode_bidi::Level,
758) -> Result<MultilingualTextSegment> {
759 let parity_changed = segment.bidi_level() % 2 != level.number() % 2;
760 let cluster_order = if parity_changed {
761 (0..segment.clusters().len()).rev().collect::<Vec<_>>()
762 } else {
763 (0..segment.clusters().len()).collect::<Vec<_>>()
764 };
765 let mut glyph_ids = Vec::with_capacity(segment.glyph_ids().len());
766 let mut x_advances = Vec::with_capacity(segment.x_advances().len());
767 let mut y_advances = Vec::with_capacity(segment.y_advances().len());
768 let mut x_offsets = Vec::with_capacity(segment.x_offsets().len());
769 let mut y_offsets = Vec::with_capacity(segment.y_offsets().len());
770 let mut clusters = Vec::with_capacity(segment.clusters().len());
771 for index in cluster_order {
772 let cluster = &segment.clusters()[index];
773 let glyph_range = cluster.glyph_start as usize..cluster.glyph_end as usize;
774 let glyph_start = glyph_ids.len() as u32;
775 glyph_ids.extend_from_slice(&segment.glyph_ids()[glyph_range.clone()]);
776 x_advances.extend_from_slice(&segment.x_advances()[glyph_range.clone()]);
777 y_advances.extend_from_slice(&segment.y_advances()[glyph_range.clone()]);
778 x_offsets.extend_from_slice(&segment.x_offsets()[glyph_range.clone()]);
779 y_offsets.extend_from_slice(&segment.y_offsets()[glyph_range]);
780 clusters.push(crate::font::GlyphCluster {
781 glyph_start,
782 glyph_end: glyph_ids.len() as u32,
783 char_start: cluster.char_start,
784 char_end: cluster.char_end,
785 });
786 }
787 let mut base = segment.base().clone();
788 base.glyph_ids = glyph_ids;
789 base.advances = x_advances.clone();
790
791 MultilingualTextSegment::new(
792 base,
793 segment.logical_index(),
794 segment.language().map(str::to_owned),
795 segment.script(),
796 if level.is_rtl() {
797 TextDirection::RightToLeft
798 } else {
799 TextDirection::LeftToRight
800 },
801 level.number(),
802 x_advances,
803 y_advances,
804 x_offsets,
805 y_offsets,
806 clusters,
807 segment.break_after(),
808 )
809}
810
811#[derive(Debug)]
814enum BreakableSegment {
815 Items(Vec<InlineItem>),
817 Hyphenated(Box<HyphenatedSegment>),
819 ForcedBreak(ForcedBreakKind),
821}
822
823#[derive(Debug)]
824struct HyphenatedSegment {
825 segment: TextSegment,
826 break_points: Vec<usize>,
827}
828
829struct FittingHyphenation {
830 prefix: TextSegment,
831 hyphen: TextSegment,
832 remainder: TextSegment,
833 remaining_points: Vec<usize>,
834}
835
836pub(crate) fn multilingual_break_allowed(before: char, after: char) -> bool {
838 const OPENING: &[char] = &['(', '[', '{', '〈', '《', '「', '『', '【', '〔', '〖'];
839 const CLOSING_OR_NONSTARTER: &[char] = &[
840 ')', ']', '}', '〉', '》', '」', '』', '】', '〕', '〗', '、', '。', ',', '.', '!',
841 '?', ':', ';', '%', '‰',
842 ];
843 !OPENING.contains(&before) && !CLOSING_OR_NONSTARTER.contains(&after)
844}
845
846fn build_breakable_segments(
852 items: &[InlineItem],
853 fm: &FontManager,
854) -> Result<Vec<BreakableSegment>> {
855 let mut segments = Vec::new();
856 let mut current_group: Vec<InlineItem> = Vec::new();
857
858 for item in items {
859 match item {
860 InlineItem::LineBreak => {
861 if !current_group.is_empty() {
862 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
863 }
864 segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Line));
865 }
866 InlineItem::PageBreak => {
867 if !current_group.is_empty() {
868 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
869 }
870 segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Page));
871 }
872 InlineItem::ColumnBreak => {
873 if !current_group.is_empty() {
874 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
875 }
876 segments.push(BreakableSegment::ForcedBreak(ForcedBreakKind::Column));
877 }
878 InlineItem::Tab => {
879 if !current_group.is_empty() {
881 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
882 }
883 segments.push(BreakableSegment::Items(vec![item.clone()]));
884 }
885 InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => {
886 if seg.text.is_empty() {
887 if !current_group.is_empty() {
888 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
889 }
890 segments.push(BreakableSegment::Items(vec![item.clone()]));
891 continue;
892 }
893 let breaks = split_text_at_break_opportunities(seg);
895 let spacing =
896 if breaks.len() == 1 && breaks[0].start == 0 && breaks[0].end == seg.text.len()
897 {
898 0.0
899 } else {
900 let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
901 if original.advances.len() == seg.advances.len()
902 && !original.advances.is_empty()
903 {
904 (seg.width - original.width) / original.advances.len() as f64
905 } else {
906 0.0
907 }
908 };
909
910 for tb in &breaks {
911 let chunk = &seg.text[tb.start..tb.end];
912 if chunk.is_empty() {
913 continue;
914 }
915
916 if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
918 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
919 }
920
921 let sub_item = split_text_subsegment(seg, tb.start, tb.end, spacing, fm)?;
923 let language = match item {
924 InlineItem::HyphenatedText { language, .. } => Some(language.as_str()),
925 _ => None,
926 };
927 let break_points = language.map_or_else(Vec::new, |language| {
928 hyphenation_opportunities(language, chunk)
929 });
930 if break_points.is_empty() {
931 current_group.push(sub_item);
932 } else {
933 if !current_group.is_empty() {
934 segments
935 .push(BreakableSegment::Items(std::mem::take(&mut current_group)));
936 }
937 let InlineItem::Text(segment) = sub_item else {
938 unreachable!("split text always returns text")
939 };
940 segments.push(BreakableSegment::Hyphenated(Box::new(HyphenatedSegment {
941 segment,
942 break_points,
943 })));
944 }
945
946 if tb.is_break {
947 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
948 }
949 }
950
951 if !current_group.is_empty() {
953 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
954 }
955 }
956 InlineItem::MultilingualText(segment) => {
957 current_group.push(InlineItem::MultilingualText(segment.clone()));
958 if segment.break_after() {
959 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
960 }
961 }
962 InlineItem::Marker(_)
963 | InlineItem::Image { .. }
964 | InlineItem::Group { .. }
965 | InlineItem::Figure { .. } => {
966 current_group.push(item.clone());
967 }
968 }
969 }
970
971 if !current_group.is_empty() {
972 segments.push(BreakableSegment::Items(current_group));
973 }
974
975 Ok(segments)
976}
977
978fn split_text_subsegment(
982 seg: &TextSegment,
983 byte_start: usize,
984 byte_end: usize,
985 spacing: f64,
986 fm: &FontManager,
987) -> Result<InlineItem> {
988 if byte_start == 0 && byte_end == seg.text.len() {
990 return Ok(InlineItem::Text(seg.clone()));
991 }
992
993 let sub_text = seg.text[byte_start..byte_end].to_string();
994 let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
995 for advance in &mut shaped.advances {
996 *advance += spacing;
997 }
998 shaped.width += spacing * shaped.advances.len() as f64;
999
1000 let source = seg.source.map(|source| {
1001 let start = seg.text[..byte_start].chars().count() as u32;
1002 let end = seg.text[..byte_end].chars().count() as u32;
1003 SourceSpan {
1004 node: source.node,
1005 char_start: source.char_start + start,
1006 char_end: source.char_start + end,
1007 }
1008 });
1009
1010 Ok(InlineItem::Text(TextSegment {
1011 text: sub_text,
1012 direction: seg.direction,
1013 source,
1014 font_id: seg.font_id,
1015 font_size: seg.font_size,
1016 glyph_ids: shaped.glyph_ids,
1017 advances: shaped.advances,
1018 width: shaped.width,
1019 ascent: seg.ascent,
1020 descent: seg.descent,
1021 line_gap: seg.line_gap,
1022 color: seg.color,
1023 bold: seg.bold,
1024 italic: seg.italic,
1025 underline: seg.underline,
1026 strike: seg.strike,
1027 dstrike: seg.dstrike,
1028 highlight: seg.highlight,
1029 baseline_offset: seg.baseline_offset,
1030 hyperlink_url: seg.hyperlink_url.clone(),
1031 field_kind: seg.field_kind,
1032 field_source: seg.field_source,
1033 note: seg.note,
1034 }))
1035}
1036
1037fn supported_hyphenation_language(language: &str) -> Option<hypher::Lang> {
1038 let primary = language.split('-').next()?;
1039 if primary.eq_ignore_ascii_case("en") {
1040 Some(hypher::Lang::English)
1041 } else if primary.eq_ignore_ascii_case("fr") {
1042 Some(hypher::Lang::French)
1043 } else if primary.eq_ignore_ascii_case("de") {
1044 Some(hypher::Lang::German)
1045 } else if primary.eq_ignore_ascii_case("es") {
1046 Some(hypher::Lang::Spanish)
1047 } else {
1048 None
1049 }
1050}
1051
1052fn hyphenation_opportunities(language: &str, text: &str) -> Vec<usize> {
1053 let Some(language) = supported_hyphenation_language(language) else {
1054 return Vec::new();
1055 };
1056 let word_end = text
1057 .char_indices()
1058 .take_while(|(_, character)| character.is_alphabetic())
1059 .map(|(offset, character)| offset + character.len_utf8())
1060 .last()
1061 .unwrap_or(0);
1062 if word_end == 0 || text[word_end..].chars().any(char::is_alphabetic) {
1063 return Vec::new();
1064 }
1065 let word = &text[..word_end];
1066 let syllables = hypher::hyphenate(word, language).collect::<Vec<_>>();
1067 let mut offset = 0usize;
1068 syllables
1069 .iter()
1070 .take(syllables.len().saturating_sub(1))
1071 .map(|syllable| {
1072 offset += syllable.len();
1073 offset
1074 })
1075 .collect()
1076}
1077
1078fn fitting_hyphenation(
1079 segment: &TextSegment,
1080 break_points: &[usize],
1081 current_width: f64,
1082 available_width: f64,
1083 fm: &FontManager,
1084) -> Result<Option<FittingHyphenation>> {
1085 let spacing = text_segment_spacing(segment, fm)?;
1086 let hyphen = generated_hyphen(segment, spacing, fm)?;
1087 for &break_point in break_points.iter().rev() {
1088 let InlineItem::Text(prefix) = split_text_subsegment(segment, 0, break_point, spacing, fm)?
1089 else {
1090 unreachable!("split text always returns text")
1091 };
1092 if current_width + prefix.width + hyphen.width > available_width + 0.01 {
1093 continue;
1094 }
1095 let InlineItem::Text(remainder) =
1096 split_text_subsegment(segment, break_point, segment.text.len(), spacing, fm)?
1097 else {
1098 unreachable!("split text always returns text")
1099 };
1100 let remaining_points = break_points
1101 .iter()
1102 .copied()
1103 .filter(|point| *point > break_point)
1104 .map(|point| point - break_point)
1105 .collect();
1106 return Ok(Some(FittingHyphenation {
1107 prefix,
1108 hyphen,
1109 remainder,
1110 remaining_points,
1111 }));
1112 }
1113 Ok(None)
1114}
1115
1116fn text_segment_spacing(segment: &TextSegment, fm: &FontManager) -> Result<f64> {
1117 let original = fm.shape_text(segment.font_id, &segment.text, segment.font_size)?;
1118 Ok(
1119 if original.advances.len() == segment.advances.len() && !original.advances.is_empty() {
1120 (segment.width - original.width) / original.advances.len() as f64
1121 } else {
1122 0.0
1123 },
1124 )
1125}
1126
1127fn generated_hyphen(segment: &TextSegment, spacing: f64, fm: &FontManager) -> Result<TextSegment> {
1128 let mut shaped = fm.shape_text(segment.font_id, "-", segment.font_size)?;
1129 for advance in &mut shaped.advances {
1130 *advance += spacing;
1131 }
1132 shaped.width += spacing * shaped.advances.len() as f64;
1133 Ok(TextSegment {
1134 text: "-".to_owned(),
1135 direction: segment.direction,
1136 source: None,
1137 font_id: segment.font_id,
1138 font_size: segment.font_size,
1139 glyph_ids: shaped.glyph_ids,
1140 advances: shaped.advances,
1141 width: shaped.width,
1142 ascent: segment.ascent,
1143 descent: segment.descent,
1144 line_gap: segment.line_gap,
1145 color: segment.color,
1146 bold: segment.bold,
1147 italic: segment.italic,
1148 underline: segment.underline,
1149 strike: segment.strike,
1150 dstrike: segment.dstrike,
1151 highlight: segment.highlight,
1152 baseline_offset: segment.baseline_offset,
1153 hyperlink_url: segment.hyperlink_url.clone(),
1154 field_kind: segment.field_kind,
1155 field_source: segment.field_source,
1156 note: segment.note,
1157 })
1158}
1159
1160fn segment_font_id(item: &InlineItem) -> FontId {
1161 match item {
1162 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment.font_id,
1163 _ => unreachable!("called only for text"),
1164 }
1165}
1166
1167fn segment_font_size(item: &InlineItem) -> f64 {
1168 match item {
1169 InlineItem::Text(segment) | InlineItem::HyphenatedText { segment, .. } => segment.font_size,
1170 _ => unreachable!("called only for text"),
1171 }
1172}
1173
1174struct TextBreakInfo {
1175 start: usize,
1177 end: usize,
1178 is_break: bool,
1180}
1181
1182fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
1183 use unicode_linebreak::{BreakOpportunity, linebreaks};
1184
1185 let text = &seg.text;
1186 if text.is_empty() {
1187 return vec![];
1188 }
1189
1190 let mut breaks = Vec::new();
1191 let mut last_start = 0;
1192
1193 for (byte_pos, opportunity) in linebreaks(text) {
1194 if byte_pos == 0 {
1195 continue;
1196 }
1197
1198 let is_break = matches!(
1199 opportunity,
1200 BreakOpportunity::Allowed | BreakOpportunity::Mandatory
1201 );
1202
1203 breaks.push(TextBreakInfo {
1204 start: last_start,
1205 end: byte_pos,
1206 is_break,
1207 });
1208 last_start = byte_pos;
1209 }
1210
1211 if breaks.is_empty() {
1213 breaks.push(TextBreakInfo {
1214 start: 0,
1215 end: text.len(),
1216 is_break: true,
1217 });
1218 }
1219
1220 breaks
1221}
1222
1223fn inline_item_width(item: &InlineItem) -> f64 {
1224 match item {
1225 InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => seg.width,
1226 InlineItem::MultilingualText(seg) => seg.width(),
1227 InlineItem::Tab => 36.0, InlineItem::Image { width, .. } => *width,
1229 InlineItem::Group { width, .. } => *width,
1230 InlineItem::Figure { item, .. } => inline_item_width(item),
1231 InlineItem::Marker(seg) => seg.width,
1232 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
1233 }
1234}
1235
1236fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
1237 match item {
1239 InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => (
1240 seg.width,
1241 seg.ascent,
1242 seg.descent,
1243 seg.ascent + seg.descent + seg.line_gap,
1244 seg.font_size,
1245 ),
1246 InlineItem::MultilingualText(seg) => (
1247 seg.width(),
1248 seg.base().ascent,
1249 seg.base().descent,
1250 seg.base().ascent + seg.base().descent + seg.base().line_gap,
1251 seg.base().font_size,
1252 ),
1253 InlineItem::Marker(seg) => (
1254 seg.width,
1255 seg.ascent,
1256 seg.descent,
1257 seg.ascent + seg.descent + seg.line_gap,
1258 0.0,
1259 ),
1260 InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
1261 InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
1262 InlineItem::Group {
1263 width,
1264 height,
1265 baseline,
1266 ..
1267 } => {
1268 let baseline = normalized_group_baseline(*height, *baseline).unwrap_or(*height);
1269 (*width, baseline, *height - baseline, *height, 0.0)
1270 }
1271 InlineItem::Figure { item, .. } => item_metrics(item),
1272 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
1273 (0.0, 0.0, 0.0, 0.0, 0.0)
1274 }
1275 }
1276}
1277
1278fn normalized_group_baseline(height: f64, baseline: Option<f64>) -> Option<f64> {
1279 if !height.is_finite() {
1280 return None;
1281 }
1282 let upper = height.max(0.0);
1283 baseline
1284 .filter(|value| value.is_finite())
1285 .map(|value| value.clamp(0.0, upper))
1286}
1287
1288fn inline_to_line_item(
1289 item: &InlineItem,
1290 current_x: f64,
1291 tab_stops: &[TabStop],
1292 fm: &FontManager,
1293 font_ctx: Option<(FontId, f64)>,
1294) -> LineItem {
1295 match item {
1296 InlineItem::Text(seg) | InlineItem::HyphenatedText { segment: seg, .. } => {
1297 LineItem::Text(seg.clone())
1298 }
1299 InlineItem::MultilingualText(seg) => LineItem::MultilingualText(seg.clone()),
1300 InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
1301 InlineItem::Tab => {
1302 let (tab_width, leader_char) = resolve_tab_width(current_x, tab_stops);
1303 let leader = leader_char.and_then(|ch| shape_leader(fm, font_ctx, ch, tab_width));
1304 LineItem::Tab {
1305 width: tab_width,
1306 leader,
1307 }
1308 }
1309 InlineItem::Image {
1310 width,
1311 height,
1312 media_id,
1313 } => LineItem::Image {
1314 width: *width,
1315 height: *height,
1316 media_id: *media_id,
1317 },
1318 InlineItem::Group {
1319 width,
1320 height,
1321 baseline,
1322 group,
1323 } => LineItem::Group {
1324 width: *width,
1325 height: *height,
1326 baseline: normalized_group_baseline(*height, *baseline),
1327 group: group.clone(),
1328 },
1329 InlineItem::Figure {
1330 item,
1331 alternate_text,
1332 structure_id,
1333 } => LineItem::Figure {
1334 item: Box::new(inline_to_line_item(
1335 item, current_x, tab_stops, fm, font_ctx,
1336 )),
1337 alternate_text: alternate_text.clone(),
1338 structure_id: *structure_id,
1339 },
1340 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
1341 width: 0.0,
1342 leader: None,
1343 },
1344 }
1345}
1346
1347fn shape_leader(
1349 fm: &FontManager,
1350 font_ctx: Option<(FontId, f64)>,
1351 leader_char: char,
1352 tab_width: f64,
1353) -> Option<TextSegment> {
1354 let (font_id, font_size) = font_ctx?;
1355 if tab_width < 1.0 {
1356 return None;
1357 }
1358
1359 let single = String::from(leader_char);
1361 let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
1362 if shaped.glyph_ids.is_empty() {
1363 return None;
1364 }
1365 let char_advance = shaped.advances[0];
1366 if char_advance < 0.5 {
1367 return None;
1368 }
1369
1370 let spacing = match leader_char {
1372 '.' | '\u{00B7}' => char_advance * 0.5,
1373 _ => char_advance * 0.15,
1374 };
1375 let step = char_advance + spacing;
1376 let count = ((tab_width - spacing) / step).floor() as usize;
1377 if count == 0 {
1378 return None;
1379 }
1380
1381 let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
1383 let mut glyph_ids = Vec::with_capacity(count);
1384 let mut advances = Vec::with_capacity(count);
1385 for i in 0..count {
1386 glyph_ids.push(shaped.glyph_ids[0]);
1387 if i + 1 < count {
1388 advances.push(char_advance + spacing);
1389 } else {
1390 advances.push(char_advance);
1391 }
1392 }
1393
1394 let metrics = fm.metrics(font_id, font_size).ok()?;
1395
1396 Some(TextSegment {
1397 text: leader_text,
1398 direction: TextDirection::Auto,
1399 source: None,
1400 font_id,
1401 font_size,
1402 glyph_ids,
1403 advances,
1404 width: tab_width, ascent: metrics.ascent,
1406 descent: metrics.descent,
1407 line_gap: metrics.line_gap,
1408 color: Color::BLACK,
1409 bold: false,
1410 italic: false,
1411 underline: None,
1412 strike: false,
1413 dstrike: false,
1414 highlight: None,
1415 baseline_offset: 0.0,
1416 hyperlink_url: None,
1417 field_kind: None,
1418 field_source: None,
1419 note: None,
1420 })
1421}
1422
1423fn resolve_tab_width(current_x: f64, tab_stops: &[TabStop]) -> (f64, Option<char>) {
1425 for stop in tab_stops {
1427 let stop_pos = stop.pos_pt;
1428 if stop_pos > current_x {
1429 let width = match stop.align {
1430 TabAlign::Left => stop_pos - current_x,
1431 TabAlign::Center => (stop_pos - current_x).max(0.0),
1432 TabAlign::Right => (stop_pos - current_x).max(0.0),
1433 _ => stop_pos - current_x,
1434 };
1435 let leader = stop.leader.and_then(|l| match l {
1436 TabLeader::Dot => Some('.'),
1437 TabLeader::Hyphen => Some('-'),
1438 TabLeader::Underscore => Some('_'),
1439 TabLeader::MiddleDot => Some('\u{00B7}'),
1440 TabLeader::Heavy => Some('_'),
1441 TabLeader::None => None,
1442 });
1443 return (width, leader);
1444 }
1445 }
1446 let default_interval = 36.0;
1448 let next_stop = ((current_x / default_interval).floor() + 1.0) * default_interval;
1449 (next_stop - current_x, None)
1450}
1451
1452fn compute_first_line_width(params: &LineBreakParams) -> f64 {
1453 if params.ind_hanging > 0.0 {
1454 params.available_width - params.ind_left - params.ind_right + params.ind_hanging
1456 } else {
1457 params.available_width - params.ind_left - params.ind_right - params.ind_first_line
1458 }
1459}
1460
1461fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
1462 params.available_width - params.ind_left - params.ind_right
1463}
1464
1465fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 {
1467 params
1468 .line_prefix_widths
1469 .get(line_index)
1470 .copied()
1471 .unwrap_or(0.0)
1472}
1473
1474fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 {
1476 params
1477 .line_suffix_widths
1478 .get(line_index)
1479 .copied()
1480 .unwrap_or(0.0)
1481}
1482
1483fn line_width_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
1485 let base = if is_first_line {
1486 compute_first_line_width(params)
1487 } else {
1488 compute_subsequent_line_width(params)
1489 };
1490 (base - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)).max(0.0)
1491}
1492
1493fn line_indent_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
1495 let base = if is_first_line {
1496 first_line_indent(params)
1497 } else {
1498 subsequent_line_indent(params)
1499 };
1500 base + line_prefix_width(params, line_index)
1501}
1502
1503fn first_line_indent(params: &LineBreakParams) -> f64 {
1504 if params.ind_hanging > 0.0 {
1505 params.ind_left - params.ind_hanging
1506 } else {
1507 params.ind_left + params.ind_first_line
1508 }
1509}
1510
1511fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
1512 params.ind_left
1513}
1514
1515fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
1517 (natural_height - ascent - descent).max(0.0)
1518}
1519
1520fn compute_line_height(
1521 ascent: f64,
1522 descent: f64,
1523 line_gap: f64,
1524 font_size: f64,
1525 params: &LineBreakParams,
1526) -> f64 {
1527 let natural = ascent + descent + line_gap;
1528 let natural = if natural < 1.0 { 12.0 } else { natural }; let font_size = if font_size < 1.0 { 12.0 } else { font_size };
1530
1531 match params.line_spacing {
1532 LineSpacing::Single => natural,
1533 LineSpacing::Multiple(factor) => font_size * factor,
1534 LineSpacing::Exact(points) => points,
1535 LineSpacing::AtLeast(points) => natural.max(points),
1536 }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541 use super::*;
1542
1543 fn make_text_segment(text: &str, width: f64) -> TextSegment {
1544 TextSegment {
1545 text: text.to_string(),
1546 direction: TextDirection::Auto,
1547 source: None,
1548 font_id: FontId(0),
1549 font_size: 12.0,
1550 glyph_ids: vec![],
1551 advances: vec![],
1552 width,
1553 ascent: 10.0,
1554 descent: 3.0,
1555 line_gap: 0.0,
1556 color: Color::BLACK,
1557 bold: false,
1558 italic: false,
1559 underline: None,
1560 strike: false,
1561 dstrike: false,
1562 highlight: None,
1563 baseline_offset: 0.0,
1564 hyperlink_url: None,
1565 field_kind: None,
1566 field_source: None,
1567 note: None,
1568 }
1569 }
1570
1571 fn deterministic_font_manager() -> FontManager {
1572 FontManager::new_deterministic().expect("bundled fonts should load")
1573 }
1574
1575 fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
1576 let font_id = fm
1577 .resolve_font(Some("Carlito"), false, false)
1578 .expect("bundled Carlito should resolve");
1579 let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
1580 let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
1581 for advance in &mut shaped.advances {
1582 *advance += spacing;
1583 }
1584 shaped.width += spacing * shaped.advances.len() as f64;
1585 TextSegment {
1586 text: text.to_owned(),
1587 direction: TextDirection::Auto,
1588 source: None,
1589 font_id,
1590 font_size: 30.0,
1591 glyph_ids: shaped.glyph_ids,
1592 advances: shaped.advances,
1593 width: shaped.width,
1594 ascent: metrics.ascent,
1595 descent: metrics.descent,
1596 line_gap: metrics.line_gap,
1597 color: Color::BLACK,
1598 bold: false,
1599 italic: false,
1600 underline: None,
1601 strike: false,
1602 dstrike: false,
1603 highlight: None,
1604 baseline_offset: 0.0,
1605 hyperlink_url: None,
1606 field_kind: None,
1607 field_source: None,
1608 note: None,
1609 }
1610 }
1611
1612 #[test]
1613 fn automatic_hyphenation_selects_the_farthest_fitting_break_and_has_no_source() {
1614 let mut fm = deterministic_font_manager();
1615 let node = crate::SourceNodeId::new(9).unwrap();
1616 let mut segment = shaped_text_segment(&mut fm, "representation", 0.0);
1617 segment.source = Some(SourceSpan {
1618 node,
1619 char_start: 20,
1620 char_end: 34,
1621 });
1622 let width = fm
1623 .shape_text(segment.font_id, "represen-", segment.font_size)
1624 .unwrap()
1625 .width
1626 + 0.01;
1627 let lines = break_into_lines(
1628 &[InlineItem::HyphenatedText {
1629 segment,
1630 language: "en-US".to_owned(),
1631 }],
1632 &LineBreakParams {
1633 available_width: width,
1634 ..Default::default()
1635 },
1636 &fm,
1637 )
1638 .unwrap();
1639 let first = lines[0]
1640 .items
1641 .iter()
1642 .filter_map(|item| match item {
1643 LineItem::Text(text) => Some(text),
1644 _ => None,
1645 })
1646 .collect::<Vec<_>>();
1647
1648 assert_eq!(
1649 first
1650 .iter()
1651 .map(|text| text.text.as_str())
1652 .collect::<String>(),
1653 "represen-"
1654 );
1655 assert_eq!(first.last().unwrap().source, None);
1656 assert_eq!(first[0].source.unwrap().char_start, 20);
1657 assert_eq!(first[0].source.unwrap().char_end, 28);
1658 }
1659
1660 #[test]
1661 fn liang_candidates_map_supported_regional_languages_only() {
1662 assert_eq!(
1663 hyphenation_opportunities("en-US", "representation"),
1664 vec![3, 5, 8, 10]
1665 );
1666 assert_eq!(
1667 hyphenation_opportunities("fr-CA", "représentation"),
1668 vec![2, 6, 9, 11]
1669 );
1670 assert!(!hyphenation_opportunities("de-AT", "Silbentrennung").is_empty());
1671 assert!(!hyphenation_opportunities("es-MX", "representación").is_empty());
1672 assert!(hyphenation_opportunities("it-IT", "rappresentazione").is_empty());
1673 }
1674
1675 #[test]
1676 fn unwrapped_hyphenated_text_never_emits_a_conditional_hyphen() {
1677 let mut fm = deterministic_font_manager();
1678 let segment = shaped_text_segment(&mut fm, "representation", 0.0);
1679 let lines = break_into_lines(
1680 &[InlineItem::HyphenatedText {
1681 segment,
1682 language: "en-US".to_owned(),
1683 }],
1684 &LineBreakParams {
1685 available_width: 20.0,
1686 wrap: false,
1687 ..Default::default()
1688 },
1689 &fm,
1690 )
1691 .unwrap();
1692 assert_eq!(lines.len(), 1);
1693 let text = lines[0]
1694 .items
1695 .iter()
1696 .filter_map(|item| match item {
1697 LineItem::Text(text) => Some(text.text.as_str()),
1698 _ => None,
1699 })
1700 .collect::<String>();
1701
1702 assert_eq!(text, "representation");
1703 }
1704
1705 #[test]
1706 fn mixed_direction_line_uses_uax9_visual_order_without_changing_logical_text() {
1707 let mut fm = deterministic_font_manager();
1708 let mut segment = shaped_text_segment(&mut fm, "abc אבג 123", 0.0);
1709 segment.source = Some(SourceSpan {
1710 node: crate::SourceNodeId::new(7).unwrap(),
1711 char_start: 50,
1712 char_end: 61,
1713 });
1714 let rich = fm
1715 .shape_multilingual_text(segment, Some("he-IL"), TextDirection::Auto, false)
1716 .unwrap();
1717 let logical_text = rich.iter().map(|span| span.text()).collect::<String>();
1718 let sources = rich
1719 .iter()
1720 .map(|span| span.base().source.expect("logical span keeps source"))
1721 .collect::<Vec<_>>();
1722 assert_eq!(sources.first().unwrap().char_start, 50);
1723 assert_eq!(sources.last().unwrap().char_end, 61);
1724 assert!(
1725 sources
1726 .windows(2)
1727 .all(|pair| pair[0].char_end == pair[1].char_start)
1728 );
1729 let items = rich
1730 .into_iter()
1731 .map(InlineItem::MultilingualText)
1732 .collect::<Vec<_>>();
1733 let lines = break_multilingual_into_lines(
1734 &items,
1735 &LineBreakParams {
1736 available_width: 1_000.0,
1737 ..Default::default()
1738 },
1739 &fm,
1740 TextDirection::Auto,
1741 )
1742 .unwrap();
1743 let visual_text = lines[0]
1744 .items
1745 .iter()
1746 .filter_map(|item| match item {
1747 LineItem::MultilingualText(span) => Some(span.text()),
1748 _ => None,
1749 })
1750 .collect::<String>();
1751 assert_eq!(logical_text, "abc אבג 123");
1752 assert_eq!(visual_text, "abc 123 אבג");
1753 assert_eq!(
1754 lines[0]
1755 .items
1756 .iter()
1757 .filter_map(|item| match item {
1758 LineItem::MultilingualText(span) => {
1759 Some((span.text(), span.logical_index(), span.bidi_level()))
1760 }
1761 _ => None,
1762 })
1763 .collect::<Vec<_>>(),
1764 vec![
1765 ("abc", 0, 0),
1766 (" ", 1, 0),
1767 ("123", 4, 2),
1768 (" ", 3, 1),
1769 ("אבג", 2, 1)
1770 ]
1771 );
1772 }
1773
1774 #[test]
1775 fn explicit_run_directions_reorder_with_the_rtl_paragraph_once() {
1776 let mut fm = deterministic_font_manager();
1777 let mut arabic = shaped_text_segment(&mut fm, "العربية ", 0.0);
1778 arabic.direction = TextDirection::RightToLeft;
1779 let mut latin = shaped_text_segment(&mut fm, "ABC 123", 0.0);
1780 latin.direction = TextDirection::LeftToRight;
1781 let rich = fm
1782 .shape_multilingual_paragraph(
1783 vec![
1784 (arabic, Some("ar-SA".to_owned())),
1785 (latin, Some("en-US".to_owned())),
1786 ],
1787 TextDirection::RightToLeft,
1788 false,
1789 )
1790 .unwrap();
1791 let lines = break_multilingual_into_lines(
1792 &rich
1793 .into_iter()
1794 .map(InlineItem::MultilingualText)
1795 .collect::<Vec<_>>(),
1796 &LineBreakParams {
1797 available_width: 1_000.0,
1798 ..Default::default()
1799 },
1800 &fm,
1801 TextDirection::RightToLeft,
1802 )
1803 .unwrap();
1804
1805 let visual = lines[0]
1806 .items
1807 .iter()
1808 .filter_map(|item| match item {
1809 LineItem::MultilingualText(span) => {
1810 Some((span.text(), span.bidi_level(), span.base().direction))
1811 }
1812 _ => None,
1813 })
1814 .collect::<Vec<_>>();
1815 assert_eq!(
1816 visual.iter().map(|span| span.0).collect::<String>(),
1817 "ABC 123 العربية",
1818 "{visual:?}"
1819 );
1820 }
1821
1822 #[test]
1823 fn explicit_rtl_run_retains_numeric_levels_and_line_local_whitespace_reset() {
1824 let mut fm = deterministic_font_manager();
1825 let node = crate::SourceNodeId::new(13).unwrap();
1826 let mut segment = shaped_text_segment(&mut fm, "אבג 123 ", 0.0);
1827 segment.direction = TextDirection::RightToLeft;
1828 segment.source = Some(SourceSpan {
1829 node,
1830 char_start: 40,
1831 char_end: 50,
1832 });
1833 let rich = fm
1834 .shape_multilingual_paragraph(
1835 vec![(segment, Some("he-IL".to_owned()))],
1836 TextDirection::LeftToRight,
1837 false,
1838 )
1839 .unwrap();
1840 assert!(
1841 rich.iter()
1842 .any(|span| span.text() == "123" && span.bidi_level() == 2),
1843 "numeric span must retain its higher even level: {:?}",
1844 rich.iter()
1845 .map(|span| (span.text(), span.bidi_level()))
1846 .collect::<Vec<_>>()
1847 );
1848
1849 let lines = break_multilingual_into_lines(
1850 &rich
1851 .into_iter()
1852 .map(InlineItem::MultilingualText)
1853 .collect::<Vec<_>>(),
1854 &LineBreakParams {
1855 available_width: 1_000.0,
1856 ..Default::default()
1857 },
1858 &fm,
1859 TextDirection::LeftToRight,
1860 )
1861 .unwrap();
1862 let spans = lines[0]
1863 .items
1864 .iter()
1865 .filter_map(|item| match item {
1866 LineItem::MultilingualText(span) => Some(span),
1867 _ => None,
1868 })
1869 .collect::<Vec<_>>();
1870 assert!(spans.iter().any(|span| {
1871 span.text().chars().all(char::is_whitespace) && span.bidi_level() == 0
1872 }));
1873 assert!(
1874 spans
1875 .iter()
1876 .all(|span| span.base().source.unwrap().node == node)
1877 );
1878 }
1879
1880 #[test]
1881 fn inline_object_participates_in_rtl_visual_order_without_changing_text_source() {
1882 let mut fm = deterministic_font_manager();
1883 let node = crate::SourceNodeId::new(14).unwrap();
1884 let mut segment = shaped_text_segment(&mut fm, "אבג", 0.0);
1885 segment.source = Some(SourceSpan {
1886 node,
1887 char_start: 8,
1888 char_end: 11,
1889 });
1890 let mut rich = fm
1891 .shape_multilingual_text(segment, Some("he-IL"), TextDirection::RightToLeft, false)
1892 .unwrap();
1893 assert_eq!(rich.len(), 1);
1894 let items = vec![
1895 InlineItem::MultilingualText(rich.remove(0)),
1896 InlineItem::Image {
1897 width: 12.0,
1898 height: 12.0,
1899 media_id: MediaId(77),
1900 },
1901 ];
1902
1903 let lines = break_multilingual_into_lines(
1904 &items,
1905 &LineBreakParams {
1906 available_width: 1_000.0,
1907 ..Default::default()
1908 },
1909 &fm,
1910 TextDirection::RightToLeft,
1911 )
1912 .unwrap();
1913
1914 assert!(matches!(
1915 lines[0].items[0],
1916 LineItem::Image {
1917 media_id: MediaId(77),
1918 ..
1919 }
1920 ));
1921 let LineItem::MultilingualText(text) = &lines[0].items[1] else {
1922 panic!("RTL text must paint after the inline object")
1923 };
1924 assert_eq!(text.text(), "אבג");
1925 assert_eq!(
1926 text.base().source.unwrap(),
1927 SourceSpan {
1928 node,
1929 char_start: 8,
1930 char_end: 11,
1931 }
1932 );
1933 }
1934
1935 #[test]
1936 fn explicit_rtl_hyphenatable_latin_spans_keep_their_natural_even_levels() {
1937 let mut fm = deterministic_font_manager();
1938 let mut letters = shaped_text_segment(&mut fm, "ABC ", 0.0);
1939 letters.direction = TextDirection::RightToLeft;
1940 let mut digits = shaped_text_segment(&mut fm, "123", 0.0);
1941 digits.direction = TextDirection::RightToLeft;
1942 let lines = break_multilingual_into_lines(
1943 &[
1944 InlineItem::HyphenatedText {
1945 segment: letters,
1946 language: "en-US".to_owned(),
1947 },
1948 InlineItem::HyphenatedText {
1949 segment: digits,
1950 language: "en-US".to_owned(),
1951 },
1952 ],
1953 &LineBreakParams {
1954 available_width: 1_000.0,
1955 ..Default::default()
1956 },
1957 &fm,
1958 TextDirection::LeftToRight,
1959 )
1960 .unwrap();
1961 let visual = lines[0]
1962 .items
1963 .iter()
1964 .filter_map(|item| match item {
1965 LineItem::Text(segment) => Some(segment.text.as_str()),
1966 _ => None,
1967 })
1968 .collect::<String>();
1969 assert_eq!(visual, "ABC 123");
1970 }
1971
1972 #[test]
1973 fn one_styled_run_applies_line_local_l1_before_l2_without_losing_source() {
1974 let mut fm = deterministic_font_manager();
1975 let node = crate::SourceNodeId::new(9).unwrap();
1976 let mut segment = shaped_text_segment(&mut fm, "אבג אבג", 0.0);
1977 segment.source = Some(SourceSpan {
1978 node,
1979 char_start: 20,
1980 char_end: 29,
1981 });
1982 let rich = fm
1983 .shape_multilingual_paragraph(vec![(segment, None)], TextDirection::LeftToRight, false)
1984 .unwrap();
1985 assert_eq!(
1986 rich.iter().map(|span| span.text()).collect::<Vec<_>>(),
1987 ["אבג", " ", "אבג"]
1988 );
1989 let first_line_width = rich[0].width() + rich[1].width() + 0.01;
1990 let items = rich
1991 .into_iter()
1992 .map(InlineItem::MultilingualText)
1993 .collect::<Vec<_>>();
1994
1995 let lines = break_multilingual_into_lines(
1996 &items,
1997 &LineBreakParams {
1998 available_width: first_line_width,
1999 ..Default::default()
2000 },
2001 &fm,
2002 TextDirection::LeftToRight,
2003 )
2004 .unwrap();
2005 let spans = lines
2006 .iter()
2007 .flat_map(|line| &line.items)
2008 .filter_map(|item| match item {
2009 LineItem::MultilingualText(span) => Some((
2010 span.text(),
2011 span.logical_index(),
2012 span.bidi_level(),
2013 span.direction(),
2014 span.base().source.unwrap(),
2015 )),
2016 _ => None,
2017 })
2018 .collect::<Vec<_>>();
2019 assert_eq!(
2020 spans
2021 .iter()
2022 .map(|(text, index, level, direction, source)| (
2023 *text,
2024 *index,
2025 *level,
2026 *direction,
2027 source.char_start..source.char_end,
2028 ))
2029 .collect::<Vec<_>>(),
2030 [
2031 ("אבג", 0, 1, TextDirection::RightToLeft, 20..23),
2032 (" ", 1, 0, TextDirection::LeftToRight, 23..26),
2033 ("אבג", 2, 1, TextDirection::RightToLeft, 26..29),
2034 ]
2035 );
2036 assert!(spans.iter().all(|(_, _, _, _, source)| source.node == node));
2037 let first_line = lines[0]
2038 .items
2039 .iter()
2040 .filter_map(|item| match item {
2041 LineItem::MultilingualText(span) => Some(span.text()),
2042 _ => None,
2043 })
2044 .collect::<String>();
2045
2046 assert_eq!(first_line, "אבג ");
2047 }
2048
2049 #[test]
2050 fn cjk_prohibited_punctuation_never_starts_or_ends_a_line() {
2051 let mut fm = deterministic_font_manager();
2052 let mut segment = shaped_text_segment(&mut fm, "〈中〉、你好世界", 0.0);
2053 segment.source = Some(SourceSpan {
2054 node: crate::SourceNodeId::new(8).unwrap(),
2055 char_start: 5,
2056 char_end: 13,
2057 });
2058 let rich = fm
2059 .shape_multilingual_text(segment, Some("zh-CN"), TextDirection::LeftToRight, false)
2060 .unwrap();
2061 let items = rich
2062 .into_iter()
2063 .map(InlineItem::MultilingualText)
2064 .collect::<Vec<_>>();
2065 let lines = break_multilingual_into_lines(
2066 &items,
2067 &LineBreakParams {
2068 available_width: 35.0,
2069 ..Default::default()
2070 },
2071 &fm,
2072 TextDirection::LeftToRight,
2073 )
2074 .unwrap();
2075 let line_text = lines
2076 .iter()
2077 .map(|line| {
2078 line.items
2079 .iter()
2080 .filter_map(|item| match item {
2081 LineItem::MultilingualText(span) => Some(span.text()),
2082 _ => None,
2083 })
2084 .collect::<String>()
2085 })
2086 .collect::<Vec<_>>();
2087 assert_eq!(line_text, ["〈中〉、", "你", "好", "世", "界"]);
2088 assert!(
2089 line_text
2090 .iter()
2091 .all(|text| { !text.starts_with(['〉', '、']) && !text.ends_with('〈') })
2092 );
2093 }
2094
2095 #[test]
2096 fn legacy_stable_source_fixture_compiles_unchanged_public_shapes() {
2097 let segment = make_text_segment("legacy", 42.0);
2098 let _inline = InlineItem::Text(segment.clone());
2099 let _line = LineItem::Text(segment.clone());
2100 let _params = LineBreakParams {
2101 available_width: 468.0,
2102 ind_left: 0.0,
2103 ind_right: 0.0,
2104 ind_first_line: 0.0,
2105 ind_hanging: 0.0,
2106 tab_stops: Vec::new(),
2107 line_spacing: LineSpacing::Single,
2108 jc: None,
2109 wrap: true,
2110 line_prefix_widths: Vec::new(),
2111 line_suffix_widths: Vec::new(),
2112 };
2113 let _shaped = crate::ShapedText {
2114 glyph_ids: segment.glyph_ids.clone(),
2115 advances: segment.advances.clone(),
2116 width: segment.width,
2117 };
2118 let _run = crate::GlyphRun {
2119 origin: crate::Point { x: 0.0, y: 0.0 },
2120 font_id: segment.font_id,
2121 font_size: segment.font_size,
2122 glyph_ids: segment.glyph_ids,
2123 advances: segment.advances,
2124 text: segment.text,
2125 source: segment.source,
2126 color: segment.color,
2127 bold: segment.bold,
2128 italic: segment.italic,
2129 field_kind: segment.field_kind,
2130 field_source: segment.field_source,
2131 note: segment.note,
2132 };
2133 }
2134
2135 #[test]
2136 fn empty_paragraph_gets_one_line() {
2137 let fm = deterministic_font_manager();
2138 let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
2139 assert_eq!(lines.len(), 1);
2140 assert!(lines[0].is_last);
2141 assert!(lines[0].items.is_empty());
2142 }
2143
2144 #[test]
2145 fn single_word_fits_one_line() {
2146 let fm = deterministic_font_manager();
2147 let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
2148 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2149 assert_eq!(lines.len(), 1);
2150 assert!(lines[0].is_last);
2151 }
2152
2153 #[test]
2154 fn words_wrap_to_multiple_lines() {
2155 let fm = deterministic_font_manager();
2156 let mut items = vec![
2158 InlineItem::Text(make_text_segment("Word1", 200.0)),
2159 InlineItem::Text(make_text_segment("Word2", 200.0)),
2160 ];
2161 items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
2162
2163 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2164 assert!(lines.len() >= 2);
2165 }
2166
2167 #[test]
2168 fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
2169 let mut fm = deterministic_font_manager();
2170 let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
2171 let spacing = 0.4;
2172 let segment = shaped_text_segment(&mut fm, text, spacing);
2173 assert_ne!(segment.glyph_ids.len(), text.chars().count());
2174
2175 let lines = break_into_lines(
2176 &[InlineItem::Text(segment)],
2177 &LineBreakParams {
2178 available_width: 260.0,
2179 ..LineBreakParams::default()
2180 },
2181 &fm,
2182 )
2183 .expect("wrap ligature-bearing text");
2184 assert!(lines.len() > 1);
2185
2186 let mut rendered_text = String::new();
2187 for text_segment in lines.iter().flat_map(|line| {
2188 line.items.iter().filter_map(|item| match item {
2189 LineItem::Text(segment) => Some(segment),
2190 _ => None,
2191 })
2192 }) {
2193 rendered_text.push_str(&text_segment.text);
2194 let exact = fm
2195 .shape_text(
2196 text_segment.font_id,
2197 &text_segment.text,
2198 text_segment.font_size,
2199 )
2200 .expect("reshape emitted chunk");
2201 assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
2202 assert_eq!(text_segment.advances.len(), exact.advances.len());
2203 for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
2204 assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
2205 }
2206 }
2207 assert_eq!(rendered_text, text);
2208 }
2209
2210 #[test]
2211 fn line_splitting_preserves_contiguous_unicode_source_ranges() {
2212 let mut fm = deterministic_font_manager();
2213 let node = crate::SourceNodeId::new(7).expect("a non-zero source id");
2214 let mut segment = shaped_text_segment(&mut fm, "ab 🚀界 cd", 0.0);
2215 segment.source = Some(crate::SourceSpan {
2216 node,
2217 char_start: 11,
2218 char_end: 19,
2219 });
2220
2221 let lines = break_into_lines(
2222 &[InlineItem::Text(segment)],
2223 &LineBreakParams {
2224 available_width: 55.0,
2225 ..LineBreakParams::default()
2226 },
2227 &fm,
2228 )
2229 .expect("split mixed Unicode text");
2230 let sourced = lines
2231 .iter()
2232 .flat_map(|line| &line.items)
2233 .filter_map(|item| match item {
2234 LineItem::Text(segment) => segment.source,
2235 _ => None,
2236 })
2237 .collect::<Vec<_>>();
2238
2239 assert!(sourced.len() > 1, "the fixture must cross a line boundary");
2240 assert_eq!(sourced.first().expect("first range").char_start, 11);
2241 assert_eq!(sourced.last().expect("last range").char_end, 19);
2242 for pair in sourced.windows(2) {
2243 assert_eq!(pair[0].node, node);
2244 assert_eq!(pair[0].char_end, pair[1].char_start);
2245 }
2246 }
2247
2248 #[test]
2249 fn forced_line_break() {
2250 let fm = deterministic_font_manager();
2251 let items = vec![
2252 InlineItem::Text(make_text_segment("Before", 50.0)),
2253 InlineItem::LineBreak,
2254 InlineItem::Text(make_text_segment("After", 50.0)),
2255 ];
2256 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2257 assert!(lines.len() >= 2);
2258 }
2259
2260 #[test]
2261 fn line_page_and_column_breaks_retain_their_kind() {
2262 let fm = deterministic_font_manager();
2263 let items = vec![
2264 InlineItem::LineBreak,
2265 InlineItem::PageBreak,
2266 InlineItem::ColumnBreak,
2267 InlineItem::Text(make_text_segment("after", 30.0)),
2268 ];
2269 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
2270
2271 assert_eq!(
2272 lines
2273 .iter()
2274 .map(|line| line.forced_break_after)
2275 .collect::<Vec<_>>(),
2276 [
2277 Some(ForcedBreakKind::Line),
2278 Some(ForcedBreakKind::Page),
2279 Some(ForcedBreakKind::Column),
2280 None,
2281 ]
2282 );
2283 }
2284
2285 #[test]
2286 fn line_height_exact() {
2287 let params = LineBreakParams {
2288 line_spacing: LineSpacing::Exact(24.0),
2289 ..Default::default()
2290 };
2291 let h = compute_line_height(10.0, 3.0, 5.0, 12.0, ¶ms);
2292 assert!((h - 24.0).abs() < 0.01);
2293 }
2294
2295 #[test]
2296 fn line_height_auto() {
2297 let params = LineBreakParams {
2298 line_spacing: LineSpacing::Multiple(2.0),
2299 ..Default::default()
2300 };
2301 let h = compute_line_height(10.0, 3.0, 2.0, 15.0, ¶ms);
2302 assert!((h - 30.0).abs() < 0.01); }
2304
2305 #[test]
2306 fn first_line_indent() {
2307 let params = LineBreakParams {
2308 ind_first_line: 36.0,
2309 ..Default::default()
2310 };
2311 let first_w = compute_first_line_width(¶ms);
2312 let subseq_w = compute_subsequent_line_width(¶ms);
2313 assert!(first_w < subseq_w);
2314 }
2315
2316 #[test]
2317 fn hanging_indent() {
2318 let params = LineBreakParams {
2319 ind_left: 36.0,
2320 ind_hanging: 36.0,
2321 ..Default::default()
2322 };
2323 let first_indent = super::first_line_indent(¶ms);
2324 let subseq_indent = super::subsequent_line_indent(¶ms);
2325 assert!(first_indent < subseq_indent);
2326 }
2327
2328 #[test]
2329 fn tab_stop_resolution() {
2330 let stops = vec![TabStop {
2331 pos_pt: 72.0,
2332 align: TabAlign::Left,
2333 leader: None,
2334 }];
2335 let (w, leader) = resolve_tab_width(36.0, &stops);
2336 assert!((w - 36.0).abs() < 0.01);
2337 assert!(leader.is_none());
2338 }
2339
2340 #[test]
2341 fn default_tab_stops() {
2342 let (w, _) = resolve_tab_width(10.0, &[]);
2343 assert!((w - 26.0).abs() < 0.01); }
2345
2346 #[test]
2347 fn tab_stop_with_dot_leader() {
2348 let stops = vec![TabStop {
2349 pos_pt: 400.0,
2350 align: TabAlign::Right,
2351 leader: Some(TabLeader::Dot),
2352 }];
2353 let (w, leader) = resolve_tab_width(100.0, &stops);
2354 assert!((w - 300.0).abs() < 0.01);
2355 assert_eq!(leader, Some('.'));
2356 }
2357
2358 #[test]
2359 fn the_eleven_line_tests_pass_with_owned_types() {
2360 assert!(LineBreakParams::default().wrap);
2361 empty_paragraph_gets_one_line();
2362 single_word_fits_one_line();
2363 words_wrap_to_multiple_lines();
2364 forced_line_break();
2365 line_height_exact();
2366 line_height_auto();
2367 first_line_indent();
2368 hanging_indent();
2369 tab_stop_resolution();
2370 default_tab_stops();
2371 tab_stop_with_dot_leader();
2372 }
2373
2374 #[test]
2375 fn line_spacing_variants_preserve_existing_height_rules() {
2376 let height = |line_spacing| {
2377 compute_line_height(
2378 10.0,
2379 3.0,
2380 2.0,
2381 11.0,
2382 &LineBreakParams {
2383 line_spacing,
2384 ..Default::default()
2385 },
2386 )
2387 };
2388
2389 assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
2390 assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
2391 assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
2392 assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
2393 assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
2394 }
2395
2396 #[test]
2397 fn mixed_font_line_uses_tallest_full_natural_advance() {
2398 let fm = deterministic_font_manager();
2399 let mut first = make_text_segment("first", 20.0);
2400 first.ascent = 10.0;
2401 first.descent = 2.0;
2402 first.line_gap = 4.0;
2403 let mut second = make_text_segment("second", 20.0);
2404 second.ascent = 8.0;
2405 second.descent = 5.0;
2406 second.line_gap = 1.0;
2407
2408 let lines = break_into_lines(
2409 &[InlineItem::Text(first), InlineItem::Text(second)],
2410 &LineBreakParams::default(),
2411 &fm,
2412 )
2413 .expect("lay out mixed-font line");
2414
2415 assert_eq!(lines.len(), 1);
2416 assert!((lines[0].ascent - 10.0).abs() < 0.01);
2417 assert!((lines[0].descent - 5.0).abs() < 0.01);
2418 assert!((lines[0].line_gap - 1.0).abs() < 0.01);
2419 assert!((lines[0].height - 16.0).abs() < 0.01);
2420 assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
2421 }
2422
2423 #[test]
2424 fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
2425 let fm = deterministic_font_manager();
2426 let mut first = make_text_segment("first", 20.0);
2427 first.font_size = 12.0;
2428 first.line_gap = 4.0;
2429 let mut second = make_text_segment("second", 20.0);
2430 second.font_size = 20.0;
2431 second.line_gap = 1.0;
2432
2433 let lines = break_into_lines(
2434 &[InlineItem::Text(first), InlineItem::Text(second)],
2435 &LineBreakParams {
2436 line_spacing: LineSpacing::Multiple(1.25),
2437 ..LineBreakParams::default()
2438 },
2439 &fm,
2440 )
2441 .expect("lay out percentage-spaced mixed-size line");
2442
2443 assert_eq!(lines.len(), 1);
2444 assert!((lines[0].height - 25.0).abs() < 0.01);
2445 }
2446
2447 #[test]
2448 fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
2449 let positive = LayoutLine {
2450 items: Vec::new(),
2451 width: 0.0,
2452 ascent: 10.0,
2453 descent: 3.0,
2454 line_gap: 5.0,
2455 height: 18.0,
2456 indent_left: 0.0,
2457 available_width: 100.0,
2458 is_last: true,
2459 forced_break_after: None,
2460 };
2461 let below_natural = LayoutLine {
2462 height: 8.0,
2463 ..positive.clone()
2464 };
2465
2466 assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
2467 assert!((below_natural.height - 8.0).abs() < 0.01);
2468 assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
2469 }
2470
2471 #[test]
2472 fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
2473 let fm = deterministic_font_manager();
2474 let zero_gap = make_text_segment("zero", 20.0);
2475 let mut empty = make_text_segment("", 0.0);
2476 empty.line_gap = 4.0;
2477
2478 let zero_gap_line = break_into_lines(
2479 &[InlineItem::Text(zero_gap)],
2480 &LineBreakParams::default(),
2481 &fm,
2482 )
2483 .expect("lay out zero-gap line");
2484 let empty_line =
2485 break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
2486 .expect("lay out styled empty line");
2487
2488 assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
2489 assert!((empty_line[0].height - 17.0).abs() < 0.01);
2490 }
2491
2492 #[test]
2493 fn wrap_false_only_breaks_on_an_explicit_break() {
2494 let fm = deterministic_font_manager();
2495 let params = LineBreakParams {
2496 available_width: 100.0,
2497 wrap: false,
2498 ..Default::default()
2499 };
2500
2501 for forced_break in [
2502 InlineItem::LineBreak,
2503 InlineItem::PageBreak,
2504 InlineItem::ColumnBreak,
2505 ] {
2506 let items = vec![
2507 InlineItem::Text(make_text_segment("one", 80.0)),
2508 InlineItem::Text(make_text_segment("two", 80.0)),
2509 forced_break,
2510 InlineItem::Text(make_text_segment("three", 80.0)),
2511 InlineItem::Text(make_text_segment("four", 80.0)),
2512 ];
2513 let lines = break_into_lines(&items, ¶ms, &fm).unwrap();
2514
2515 assert_eq!(lines.len(), 2);
2516 assert!((lines[0].width - 160.0).abs() < 0.01);
2517 assert!((lines[1].width - 160.0).abs() < 0.01);
2518 }
2519 }
2520
2521 #[test]
2522 fn tab_stops_use_point_positions_and_owned_leaders() {
2523 let mut fm = deterministic_font_manager();
2524 let font_id = fm
2525 .resolve_font(Some("Carlito"), false, false)
2526 .expect("bundled Carlito should resolve");
2527 let stop = TabStop {
2528 pos_pt: 72.25,
2529 align: TabAlign::Decimal,
2530 leader: Some(TabLeader::Dot),
2531 };
2532
2533 let item = inline_to_line_item(&InlineItem::Tab, 12.0, &[stop], &fm, Some((font_id, 12.0)));
2534
2535 let LineItem::Tab {
2536 width,
2537 leader: Some(leader),
2538 } = item
2539 else {
2540 panic!("owned dot leader should shape into a tab line item");
2541 };
2542 assert!((width - 60.25).abs() < 0.01);
2543 assert!((leader.width - 60.25).abs() < 0.01);
2544 assert!(!leader.glyph_ids.is_empty());
2545 assert!(leader.text.chars().all(|ch| ch == '.'));
2546 }
2547
2548 #[test]
2549 fn staged_image_types_use_media_id_instead_of_embed_id() {
2550 let media_id = crate::MediaId::from_bytes(b"image");
2551 let item = inline_to_line_item(
2552 &InlineItem::Image {
2553 width: 10.0,
2554 height: 20.0,
2555 media_id,
2556 },
2557 0.0,
2558 &[],
2559 &deterministic_font_manager(),
2560 None,
2561 );
2562 let LineItem::Image {
2563 media_id: actual, ..
2564 } = item
2565 else {
2566 panic!("image should remain an image");
2567 };
2568 assert_eq!(actual, media_id);
2569 }
2570
2571 #[test]
2572 fn group_inline_item_breaks_and_positions_like_an_image() {
2573 use crate::{GroupElement, PositionedElement, Transform};
2574
2575 let group = GroupElement {
2576 transform: Transform::IDENTITY,
2577 clip: None,
2578 opacity: 1.0,
2579 effects: Vec::new(),
2580 children: vec![PositionedElement::FilledRect {
2581 rect: crate::Rect {
2582 x: 2.0,
2583 y: 3.0,
2584 width: 4.0,
2585 height: 5.0,
2586 },
2587 color: crate::Color::BLACK,
2588 }],
2589 };
2590 let items = vec![InlineItem::Group {
2591 width: 80.0,
2592 height: 40.0,
2593 baseline: None,
2594 group: group.clone(),
2595 }];
2596 let lines = break_into_lines(
2597 &items,
2598 &LineBreakParams {
2599 available_width: 80.0,
2600 ..Default::default()
2601 },
2602 &deterministic_font_manager(),
2603 )
2604 .expect("group line breaking");
2605
2606 assert_eq!(lines.len(), 1);
2607 assert_eq!(lines[0].width, 80.0);
2608 assert_eq!(lines[0].height, 40.0);
2609 let LineItem::Group {
2610 width,
2611 height,
2612 group: actual,
2613 ..
2614 } = &lines[0].items[0]
2615 else {
2616 panic!("inline group should remain a group line item");
2617 };
2618 assert_eq!((*width, *height), (80.0, 40.0));
2619 assert_eq!(actual, &group);
2620 }
2621
2622 #[test]
2623 fn baseline_aware_inline_groups_contribute_exact_ascent_and_descent() {
2624 use crate::{GroupElement, Transform};
2625
2626 let group = GroupElement {
2627 transform: Transform::IDENTITY,
2628 clip: None,
2629 opacity: 1.0,
2630 effects: Vec::new(),
2631 children: Vec::new(),
2632 };
2633 let lines = break_into_lines(
2634 &[
2635 InlineItem::Group {
2636 width: 20.0,
2637 height: 18.0,
2638 baseline: None,
2639 group: group.clone(),
2640 },
2641 InlineItem::Group {
2642 width: 20.0,
2643 height: 18.0,
2644 baseline: Some(11.0),
2645 group,
2646 },
2647 ],
2648 &LineBreakParams {
2649 available_width: 100.0,
2650 ..Default::default()
2651 },
2652 &deterministic_font_manager(),
2653 )
2654 .expect("group line breaking");
2655
2656 assert_eq!(lines.len(), 1);
2657 assert_eq!(lines[0].ascent, 18.0);
2658 assert_eq!(lines[0].descent, 7.0);
2659 let LineItem::Group {
2660 baseline: Some(baseline),
2661 ..
2662 } = &lines[0].items[1]
2663 else {
2664 panic!("baseline should survive line breaking");
2665 };
2666 assert_eq!(*baseline, 11.0);
2667 }
2668
2669 #[test]
2670 fn group_baselines_are_normalized_before_pagination() {
2671 use crate::{GroupElement, Transform};
2672
2673 for (height, baseline, expected) in [
2674 (18.0, Some(-4.0), Some(0.0)),
2675 (18.0, Some(30.0), Some(18.0)),
2676 (18.0, Some(f64::NAN), None),
2677 (-18.0, Some(4.0), Some(0.0)),
2678 (f64::NAN, Some(4.0), None),
2679 ] {
2680 let item = InlineItem::Group {
2681 width: 20.0,
2682 height,
2683 baseline,
2684 group: GroupElement {
2685 transform: Transform::IDENTITY,
2686 clip: None,
2687 opacity: 1.0,
2688 effects: Vec::new(),
2689 children: Vec::new(),
2690 },
2691 };
2692 let LineItem::Group { baseline, .. } =
2693 inline_to_line_item(&item, 0.0, &[], &deterministic_font_manager(), None)
2694 else {
2695 panic!("inline group should remain a group line item");
2696 };
2697 assert_eq!(baseline, expected);
2698 }
2699 }
2700}