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