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