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