1use crate::error::Result;
6use crate::font::FontManager;
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 Tab,
80 LineBreak,
82 PageBreak,
84 ColumnBreak,
86 Image {
88 width: f64,
89 height: f64,
90 media_id: MediaId,
91 },
92 Group {
94 width: f64,
95 height: f64,
96 group: GroupElement,
97 },
98 Figure {
100 item: Box<InlineItem>,
101 alternate_text: String,
102 structure_id: Option<StructureId>,
103 },
104 Marker(TextSegment),
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
115pub enum NoteStream {
116 Footnote,
118 Endnote,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub struct NoteRef {
125 pub stream: NoteStream,
126 pub id: i32,
127}
128
129#[derive(Debug, Clone)]
131pub struct TextSegment {
132 pub text: String,
133 pub source: Option<SourceSpan>,
135 pub font_id: FontId,
136 pub font_size: f64,
137 pub glyph_ids: Vec<u16>,
138 pub advances: Vec<f64>,
139 pub width: f64,
140 pub ascent: f64,
141 pub descent: f64,
142 pub line_gap: f64,
144 pub color: Color,
145 pub bold: bool,
146 pub italic: bool,
147 pub underline: Option<Underline>,
149 pub strike: bool,
151 pub dstrike: bool,
153 pub highlight: Option<Color>,
155 pub baseline_offset: f64,
157 pub hyperlink_url: Option<String>,
159 pub field_kind: Option<FieldKind>,
161 pub note: Option<NoteRef>,
163}
164
165#[derive(Debug, Clone)]
167#[non_exhaustive]
168pub enum LineItem {
169 Text(TextSegment),
170 Tab {
171 width: f64,
172 leader: Option<TextSegment>,
174 },
175 Image {
176 width: f64,
177 height: f64,
178 media_id: MediaId,
179 },
180 Group {
181 width: f64,
182 height: f64,
183 group: GroupElement,
184 },
185 Figure {
187 item: Box<LineItem>,
188 alternate_text: String,
189 structure_id: Option<StructureId>,
190 },
191 Marker(TextSegment),
192}
193
194impl LineItem {
195 pub fn width(&self) -> f64 {
196 match self {
197 LineItem::Text(seg) => seg.width,
198 LineItem::Tab { width, .. } => *width,
199 LineItem::Image { width, .. } => *width,
200 LineItem::Group { width, .. } => *width,
201 LineItem::Figure { item, .. } => item.width(),
202 LineItem::Marker(seg) => seg.width,
203 }
204 }
205}
206
207#[derive(Debug, Clone)]
209pub struct LayoutLine {
210 pub items: Vec<LineItem>,
211 pub width: f64,
213 pub ascent: f64,
215 pub descent: f64,
217 pub line_gap: f64,
219 pub height: f64,
221 pub indent_left: f64,
223 pub available_width: f64,
225 pub is_last: bool,
227}
228
229impl LayoutLine {
230 pub fn baseline_offset(&self) -> f64 {
232 let leading = self.height - self.ascent - self.descent;
233 self.ascent + if leading >= 0.0 { leading / 2.0 } else { 0.0 }
234 }
235}
236
237#[derive(Debug, Clone)]
239pub struct LineBreakParams {
240 pub available_width: f64,
242 pub ind_left: f64,
244 pub ind_right: f64,
246 pub ind_first_line: f64,
248 pub ind_hanging: f64,
250 pub tab_stops: Vec<TabStop>,
252 pub line_spacing: LineSpacing,
254 pub jc: Option<Align>,
256 pub wrap: bool,
258 pub line_prefix_widths: Vec<f64>,
264 pub line_suffix_widths: Vec<f64>,
266}
267
268impl Default for LineBreakParams {
269 fn default() -> Self {
270 LineBreakParams {
271 available_width: 468.0, line_prefix_widths: Vec::new(),
273 line_suffix_widths: Vec::new(),
274 ind_left: 0.0,
275 ind_right: 0.0,
276 ind_first_line: 0.0,
277 ind_hanging: 0.0,
278 tab_stops: Vec::new(),
279 line_spacing: LineSpacing::Single,
280 jc: None,
281 wrap: true,
282 }
283 }
284}
285
286pub fn break_into_lines(
288 items: &[InlineItem],
289 params: &LineBreakParams,
290 fm: &FontManager,
291) -> Result<Vec<LayoutLine>> {
292 if items.is_empty() {
293 return Ok(vec![LayoutLine {
295 items: Vec::new(),
296 width: 0.0,
297 ascent: 0.0,
298 descent: 0.0,
299 line_gap: 0.0,
300 height: compute_line_height(0.0, 0.0, 0.0, 0.0, params),
301 indent_left: line_indent_at(params, 0, true),
302 available_width: line_width_at(params, 0, true),
303 is_last: true,
304 }]);
305 }
306
307 let mut lines: Vec<LayoutLine> = Vec::new();
308 let mut current_items: Vec<LineItem> = Vec::new();
309 let mut current_width: f64 = 0.0;
310 let mut current_ascent: f64 = 0.0;
311 let mut current_descent: f64 = 0.0;
312 let mut current_natural_height: f64 = 0.0;
313 let mut current_font_size: f64 = 0.0;
314 let mut line_index = 0usize;
317 let mut is_first_line = true;
318
319 let first_line_width = line_width_at(params, 0, true);
320
321 let mut line_avail = first_line_width;
322
323 let mut font_ctx: Option<(FontId, f64)> = None;
325 for item in items {
327 if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
328 font_ctx = Some((seg.font_id, seg.font_size));
329 break;
330 }
331 }
332
333 let segments = build_breakable_segments(items, fm)?;
335
336 for seg in &segments {
337 match seg {
338 BreakableSegment::Items(seg_items) => {
339 let seg_width: f64 = seg_items.iter().map(inline_item_width).sum();
340
341 if params.wrap
342 && !current_items.is_empty()
343 && current_width + seg_width > line_avail + 0.01
344 {
345 let indent = line_indent_at(params, line_index, is_first_line);
347 let line_gap =
348 effective_line_gap(current_ascent, current_descent, current_natural_height);
349 lines.push(LayoutLine {
350 items: std::mem::take(&mut current_items),
351 width: current_width,
352 ascent: current_ascent,
353 descent: current_descent,
354 line_gap,
355 height: compute_line_height(
356 current_ascent,
357 current_descent,
358 line_gap,
359 current_font_size,
360 params,
361 ),
362 indent_left: indent,
363 available_width: line_avail,
364 is_last: false,
365 });
366 current_width = 0.0;
367 current_ascent = 0.0;
368 current_descent = 0.0;
369 current_natural_height = 0.0;
370 current_font_size = 0.0;
371 is_first_line = false;
372 line_index += 1;
373 line_avail = line_width_at(params, line_index, false);
374 }
375
376 for item in seg_items {
378 let (w, a, d, natural_height, font_size) = item_metrics(item);
379 current_width += w;
380 if a > current_ascent {
381 current_ascent = a;
382 }
383 if d > current_descent {
384 current_descent = d;
385 }
386 current_natural_height = current_natural_height.max(natural_height);
387 current_font_size = current_font_size.max(font_size);
388 if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
390 font_ctx = Some((seg.font_id, seg.font_size));
391 }
392 current_items.push(inline_to_line_item(
393 item,
394 current_width,
395 ¶ms.tab_stops,
396 fm,
397 font_ctx,
398 ));
399 }
400 }
401 BreakableSegment::ForcedBreak(break_type) => {
402 let indent = line_indent_at(params, line_index, is_first_line);
403 let line_gap =
404 effective_line_gap(current_ascent, current_descent, current_natural_height);
405 lines.push(LayoutLine {
406 items: std::mem::take(&mut current_items),
407 width: current_width,
408 ascent: current_ascent,
409 descent: current_descent,
410 line_gap,
411 height: compute_line_height(
412 current_ascent,
413 current_descent,
414 line_gap,
415 current_font_size,
416 params,
417 ),
418 indent_left: indent,
419 available_width: line_avail,
420 is_last: matches!(break_type, ForcedBreakType::Page | ForcedBreakType::Column),
421 });
422 current_width = 0.0;
423 current_ascent = 0.0;
424 current_descent = 0.0;
425 current_natural_height = 0.0;
426 current_font_size = 0.0;
427 is_first_line = false;
428 line_index += 1;
429 line_avail = line_width_at(params, line_index, false);
430 }
431 }
432 }
433
434 let indent = line_indent_at(params, line_index, is_first_line);
436 let line_gap = effective_line_gap(current_ascent, current_descent, current_natural_height);
437 lines.push(LayoutLine {
438 items: current_items,
439 width: current_width,
440 ascent: current_ascent,
441 descent: current_descent,
442 line_gap,
443 height: compute_line_height(
444 current_ascent,
445 current_descent,
446 line_gap,
447 current_font_size,
448 params,
449 ),
450 indent_left: indent,
451 available_width: line_avail,
452 is_last: true,
453 });
454
455 Ok(lines)
456}
457
458#[derive(Debug)]
461enum BreakableSegment {
462 Items(Vec<InlineItem>),
464 ForcedBreak(ForcedBreakType),
466}
467
468#[derive(Debug)]
469enum ForcedBreakType {
470 Line,
471 Page,
472 Column,
473}
474
475fn build_breakable_segments(
481 items: &[InlineItem],
482 fm: &FontManager,
483) -> Result<Vec<BreakableSegment>> {
484 let mut segments = Vec::new();
485 let mut current_group: Vec<InlineItem> = Vec::new();
486
487 for item in items {
488 match item {
489 InlineItem::LineBreak => {
490 if !current_group.is_empty() {
491 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
492 }
493 segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Line));
494 }
495 InlineItem::PageBreak => {
496 if !current_group.is_empty() {
497 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
498 }
499 segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Page));
500 }
501 InlineItem::ColumnBreak => {
502 if !current_group.is_empty() {
503 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
504 }
505 segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Column));
506 }
507 InlineItem::Tab => {
508 if !current_group.is_empty() {
510 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
511 }
512 segments.push(BreakableSegment::Items(vec![item.clone()]));
513 }
514 InlineItem::Text(seg) => {
515 if seg.text.is_empty() {
516 if !current_group.is_empty() {
517 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
518 }
519 segments.push(BreakableSegment::Items(vec![item.clone()]));
520 continue;
521 }
522 let breaks = split_text_at_break_opportunities(seg);
524
525 for tb in &breaks {
526 let chunk = &seg.text[tb.start..tb.end];
527 if chunk.is_empty() {
528 continue;
529 }
530
531 if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
533 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
534 }
535
536 let sub_item = split_text_subsegment(seg, tb.start, tb.end, fm)?;
538 current_group.push(sub_item);
539
540 if tb.is_break {
541 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
542 }
543 }
544
545 if !current_group.is_empty() {
547 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
548 }
549 }
550 InlineItem::Marker(_)
551 | InlineItem::Image { .. }
552 | InlineItem::Group { .. }
553 | InlineItem::Figure { .. } => {
554 current_group.push(item.clone());
555 }
556 }
557 }
558
559 if !current_group.is_empty() {
560 segments.push(BreakableSegment::Items(current_group));
561 }
562
563 Ok(segments)
564}
565
566fn split_text_subsegment(
570 seg: &TextSegment,
571 byte_start: usize,
572 byte_end: usize,
573 fm: &FontManager,
574) -> Result<InlineItem> {
575 if byte_start == 0 && byte_end == seg.text.len() {
577 return Ok(InlineItem::Text(seg.clone()));
578 }
579
580 let sub_text = seg.text[byte_start..byte_end].to_string();
581 let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
582 let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
583 let spacing = if original.advances.len() == seg.advances.len() && !original.advances.is_empty()
584 {
585 (seg.width - original.width) / original.advances.len() as f64
586 } else {
587 0.0
588 };
589 for advance in &mut shaped.advances {
590 *advance += spacing;
591 }
592 shaped.width += spacing * shaped.advances.len() as f64;
593
594 let source = seg.source.map(|source| {
595 let start = seg.text[..byte_start].chars().count() as u32;
596 let end = seg.text[..byte_end].chars().count() as u32;
597 SourceSpan {
598 node: source.node,
599 char_start: source.char_start + start,
600 char_end: source.char_start + end,
601 }
602 });
603
604 Ok(InlineItem::Text(TextSegment {
605 text: sub_text,
606 source,
607 font_id: seg.font_id,
608 font_size: seg.font_size,
609 glyph_ids: shaped.glyph_ids,
610 advances: shaped.advances,
611 width: shaped.width,
612 ascent: seg.ascent,
613 descent: seg.descent,
614 line_gap: seg.line_gap,
615 color: seg.color,
616 bold: seg.bold,
617 italic: seg.italic,
618 underline: seg.underline,
619 strike: seg.strike,
620 dstrike: seg.dstrike,
621 highlight: seg.highlight,
622 baseline_offset: seg.baseline_offset,
623 hyperlink_url: seg.hyperlink_url.clone(),
624 field_kind: seg.field_kind,
625 note: seg.note,
626 }))
627}
628
629struct TextBreakInfo {
630 start: usize,
632 end: usize,
633 is_break: bool,
635}
636
637fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
638 use unicode_linebreak::{BreakOpportunity, linebreaks};
639
640 let text = &seg.text;
641 if text.is_empty() {
642 return vec![];
643 }
644
645 let mut breaks = Vec::new();
646 let mut last_start = 0;
647
648 for (byte_pos, opportunity) in linebreaks(text) {
649 if byte_pos == 0 {
650 continue;
651 }
652
653 let is_break = matches!(
654 opportunity,
655 BreakOpportunity::Allowed | BreakOpportunity::Mandatory
656 );
657
658 breaks.push(TextBreakInfo {
659 start: last_start,
660 end: byte_pos,
661 is_break,
662 });
663 last_start = byte_pos;
664 }
665
666 if breaks.is_empty() {
668 breaks.push(TextBreakInfo {
669 start: 0,
670 end: text.len(),
671 is_break: true,
672 });
673 }
674
675 breaks
676}
677
678fn inline_item_width(item: &InlineItem) -> f64 {
679 match item {
680 InlineItem::Text(seg) => seg.width,
681 InlineItem::Tab => 36.0, InlineItem::Image { width, .. } => *width,
683 InlineItem::Group { width, .. } => *width,
684 InlineItem::Figure { item, .. } => inline_item_width(item),
685 InlineItem::Marker(seg) => seg.width,
686 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
687 }
688}
689
690fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
691 match item {
693 InlineItem::Text(seg) => (
694 seg.width,
695 seg.ascent,
696 seg.descent,
697 seg.ascent + seg.descent + seg.line_gap,
698 seg.font_size,
699 ),
700 InlineItem::Marker(seg) => (
701 seg.width,
702 seg.ascent,
703 seg.descent,
704 seg.ascent + seg.descent + seg.line_gap,
705 0.0,
706 ),
707 InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
708 InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
709 InlineItem::Group { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
710 InlineItem::Figure { item, .. } => item_metrics(item),
711 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
712 (0.0, 0.0, 0.0, 0.0, 0.0)
713 }
714 }
715}
716
717fn inline_to_line_item(
718 item: &InlineItem,
719 current_x: f64,
720 tab_stops: &[TabStop],
721 fm: &FontManager,
722 font_ctx: Option<(FontId, f64)>,
723) -> LineItem {
724 match item {
725 InlineItem::Text(seg) => LineItem::Text(seg.clone()),
726 InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
727 InlineItem::Tab => {
728 let (tab_width, leader_char) = resolve_tab_width(current_x, tab_stops);
729 let leader = leader_char.and_then(|ch| shape_leader(fm, font_ctx, ch, tab_width));
730 LineItem::Tab {
731 width: tab_width,
732 leader,
733 }
734 }
735 InlineItem::Image {
736 width,
737 height,
738 media_id,
739 } => LineItem::Image {
740 width: *width,
741 height: *height,
742 media_id: *media_id,
743 },
744 InlineItem::Group {
745 width,
746 height,
747 group,
748 } => LineItem::Group {
749 width: *width,
750 height: *height,
751 group: group.clone(),
752 },
753 InlineItem::Figure {
754 item,
755 alternate_text,
756 structure_id,
757 } => LineItem::Figure {
758 item: Box::new(inline_to_line_item(
759 item, current_x, tab_stops, fm, font_ctx,
760 )),
761 alternate_text: alternate_text.clone(),
762 structure_id: *structure_id,
763 },
764 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
765 width: 0.0,
766 leader: None,
767 },
768 }
769}
770
771fn shape_leader(
773 fm: &FontManager,
774 font_ctx: Option<(FontId, f64)>,
775 leader_char: char,
776 tab_width: f64,
777) -> Option<TextSegment> {
778 let (font_id, font_size) = font_ctx?;
779 if tab_width < 1.0 {
780 return None;
781 }
782
783 let single = String::from(leader_char);
785 let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
786 if shaped.glyph_ids.is_empty() {
787 return None;
788 }
789 let char_advance = shaped.advances[0];
790 if char_advance < 0.5 {
791 return None;
792 }
793
794 let spacing = match leader_char {
796 '.' | '\u{00B7}' => char_advance * 0.5,
797 _ => char_advance * 0.15,
798 };
799 let step = char_advance + spacing;
800 let count = ((tab_width - spacing) / step).floor() as usize;
801 if count == 0 {
802 return None;
803 }
804
805 let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
807 let mut glyph_ids = Vec::with_capacity(count);
808 let mut advances = Vec::with_capacity(count);
809 for i in 0..count {
810 glyph_ids.push(shaped.glyph_ids[0]);
811 if i + 1 < count {
812 advances.push(char_advance + spacing);
813 } else {
814 advances.push(char_advance);
815 }
816 }
817
818 let metrics = fm.metrics(font_id, font_size).ok()?;
819
820 Some(TextSegment {
821 text: leader_text,
822 source: None,
823 font_id,
824 font_size,
825 glyph_ids,
826 advances,
827 width: tab_width, ascent: metrics.ascent,
829 descent: metrics.descent,
830 line_gap: metrics.line_gap,
831 color: Color::BLACK,
832 bold: false,
833 italic: false,
834 underline: None,
835 strike: false,
836 dstrike: false,
837 highlight: None,
838 baseline_offset: 0.0,
839 hyperlink_url: None,
840 field_kind: None,
841 note: None,
842 })
843}
844
845fn resolve_tab_width(current_x: f64, tab_stops: &[TabStop]) -> (f64, Option<char>) {
847 for stop in tab_stops {
849 let stop_pos = stop.pos_pt;
850 if stop_pos > current_x {
851 let width = match stop.align {
852 TabAlign::Left => stop_pos - current_x,
853 TabAlign::Center => (stop_pos - current_x).max(0.0),
854 TabAlign::Right => (stop_pos - current_x).max(0.0),
855 _ => stop_pos - current_x,
856 };
857 let leader = stop.leader.and_then(|l| match l {
858 TabLeader::Dot => Some('.'),
859 TabLeader::Hyphen => Some('-'),
860 TabLeader::Underscore => Some('_'),
861 TabLeader::MiddleDot => Some('\u{00B7}'),
862 TabLeader::Heavy => Some('_'),
863 TabLeader::None => None,
864 });
865 return (width, leader);
866 }
867 }
868 let default_interval = 36.0;
870 let next_stop = ((current_x / default_interval).floor() + 1.0) * default_interval;
871 (next_stop - current_x, None)
872}
873
874fn compute_first_line_width(params: &LineBreakParams) -> f64 {
875 if params.ind_hanging > 0.0 {
876 params.available_width - params.ind_left - params.ind_right + params.ind_hanging
878 } else {
879 params.available_width - params.ind_left - params.ind_right - params.ind_first_line
880 }
881}
882
883fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
884 params.available_width - params.ind_left - params.ind_right
885}
886
887fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 {
889 params
890 .line_prefix_widths
891 .get(line_index)
892 .copied()
893 .unwrap_or(0.0)
894}
895
896fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 {
898 params
899 .line_suffix_widths
900 .get(line_index)
901 .copied()
902 .unwrap_or(0.0)
903}
904
905fn line_width_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
907 let base = if is_first_line {
908 compute_first_line_width(params)
909 } else {
910 compute_subsequent_line_width(params)
911 };
912 (base - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)).max(0.0)
913}
914
915fn line_indent_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
917 let base = if is_first_line {
918 first_line_indent(params)
919 } else {
920 subsequent_line_indent(params)
921 };
922 base + line_prefix_width(params, line_index)
923}
924
925fn first_line_indent(params: &LineBreakParams) -> f64 {
926 if params.ind_hanging > 0.0 {
927 params.ind_left - params.ind_hanging
928 } else {
929 params.ind_left + params.ind_first_line
930 }
931}
932
933fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
934 params.ind_left
935}
936
937fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
939 (natural_height - ascent - descent).max(0.0)
940}
941
942fn compute_line_height(
943 ascent: f64,
944 descent: f64,
945 line_gap: f64,
946 font_size: f64,
947 params: &LineBreakParams,
948) -> f64 {
949 let natural = ascent + descent + line_gap;
950 let natural = if natural < 1.0 { 12.0 } else { natural }; let font_size = if font_size < 1.0 { 12.0 } else { font_size };
952
953 match params.line_spacing {
954 LineSpacing::Single => natural,
955 LineSpacing::Multiple(factor) => font_size * factor,
956 LineSpacing::Exact(points) => points,
957 LineSpacing::AtLeast(points) => natural.max(points),
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964
965 fn make_text_segment(text: &str, width: f64) -> TextSegment {
966 TextSegment {
967 text: text.to_string(),
968 source: None,
969 font_id: FontId(0),
970 font_size: 12.0,
971 glyph_ids: vec![],
972 advances: vec![],
973 width,
974 ascent: 10.0,
975 descent: 3.0,
976 line_gap: 0.0,
977 color: Color::BLACK,
978 bold: false,
979 italic: false,
980 underline: None,
981 strike: false,
982 dstrike: false,
983 highlight: None,
984 baseline_offset: 0.0,
985 hyperlink_url: None,
986 field_kind: None,
987 note: None,
988 }
989 }
990
991 fn deterministic_font_manager() -> FontManager {
992 FontManager::new_deterministic().expect("bundled fonts should load")
993 }
994
995 fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
996 let font_id = fm
997 .resolve_font(Some("Carlito"), false, false)
998 .expect("bundled Carlito should resolve");
999 let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
1000 let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
1001 for advance in &mut shaped.advances {
1002 *advance += spacing;
1003 }
1004 shaped.width += spacing * shaped.advances.len() as f64;
1005 TextSegment {
1006 text: text.to_owned(),
1007 source: None,
1008 font_id,
1009 font_size: 30.0,
1010 glyph_ids: shaped.glyph_ids,
1011 advances: shaped.advances,
1012 width: shaped.width,
1013 ascent: metrics.ascent,
1014 descent: metrics.descent,
1015 line_gap: metrics.line_gap,
1016 color: Color::BLACK,
1017 bold: false,
1018 italic: false,
1019 underline: None,
1020 strike: false,
1021 dstrike: false,
1022 highlight: None,
1023 baseline_offset: 0.0,
1024 hyperlink_url: None,
1025 field_kind: None,
1026 note: None,
1027 }
1028 }
1029
1030 #[test]
1031 fn empty_paragraph_gets_one_line() {
1032 let fm = deterministic_font_manager();
1033 let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
1034 assert_eq!(lines.len(), 1);
1035 assert!(lines[0].is_last);
1036 assert!(lines[0].items.is_empty());
1037 }
1038
1039 #[test]
1040 fn single_word_fits_one_line() {
1041 let fm = deterministic_font_manager();
1042 let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
1043 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1044 assert_eq!(lines.len(), 1);
1045 assert!(lines[0].is_last);
1046 }
1047
1048 #[test]
1049 fn words_wrap_to_multiple_lines() {
1050 let fm = deterministic_font_manager();
1051 let mut items = vec![
1053 InlineItem::Text(make_text_segment("Word1", 200.0)),
1054 InlineItem::Text(make_text_segment("Word2", 200.0)),
1055 ];
1056 items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
1057
1058 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1059 assert!(lines.len() >= 2);
1060 }
1061
1062 #[test]
1063 fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
1064 let mut fm = deterministic_font_manager();
1065 let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
1066 let spacing = 0.4;
1067 let segment = shaped_text_segment(&mut fm, text, spacing);
1068 assert_ne!(segment.glyph_ids.len(), text.chars().count());
1069
1070 let lines = break_into_lines(
1071 &[InlineItem::Text(segment)],
1072 &LineBreakParams {
1073 available_width: 260.0,
1074 ..LineBreakParams::default()
1075 },
1076 &fm,
1077 )
1078 .expect("wrap ligature-bearing text");
1079 assert!(lines.len() > 1);
1080
1081 let mut rendered_text = String::new();
1082 for text_segment in lines.iter().flat_map(|line| {
1083 line.items.iter().filter_map(|item| match item {
1084 LineItem::Text(segment) => Some(segment),
1085 _ => None,
1086 })
1087 }) {
1088 rendered_text.push_str(&text_segment.text);
1089 let exact = fm
1090 .shape_text(
1091 text_segment.font_id,
1092 &text_segment.text,
1093 text_segment.font_size,
1094 )
1095 .expect("reshape emitted chunk");
1096 assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
1097 assert_eq!(text_segment.advances.len(), exact.advances.len());
1098 for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
1099 assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
1100 }
1101 }
1102 assert_eq!(rendered_text, text);
1103 }
1104
1105 #[test]
1106 fn line_splitting_preserves_contiguous_unicode_source_ranges() {
1107 let mut fm = deterministic_font_manager();
1108 let node = crate::SourceNodeId::new(7).expect("a non-zero source id");
1109 let mut segment = shaped_text_segment(&mut fm, "ab 🚀界 cd", 0.0);
1110 segment.source = Some(crate::SourceSpan {
1111 node,
1112 char_start: 11,
1113 char_end: 19,
1114 });
1115
1116 let lines = break_into_lines(
1117 &[InlineItem::Text(segment)],
1118 &LineBreakParams {
1119 available_width: 55.0,
1120 ..LineBreakParams::default()
1121 },
1122 &fm,
1123 )
1124 .expect("split mixed Unicode text");
1125 let sourced = lines
1126 .iter()
1127 .flat_map(|line| &line.items)
1128 .filter_map(|item| match item {
1129 LineItem::Text(segment) => segment.source,
1130 _ => None,
1131 })
1132 .collect::<Vec<_>>();
1133
1134 assert!(sourced.len() > 1, "the fixture must cross a line boundary");
1135 assert_eq!(sourced.first().expect("first range").char_start, 11);
1136 assert_eq!(sourced.last().expect("last range").char_end, 19);
1137 for pair in sourced.windows(2) {
1138 assert_eq!(pair[0].node, node);
1139 assert_eq!(pair[0].char_end, pair[1].char_start);
1140 }
1141 }
1142
1143 #[test]
1144 fn forced_line_break() {
1145 let fm = deterministic_font_manager();
1146 let items = vec![
1147 InlineItem::Text(make_text_segment("Before", 50.0)),
1148 InlineItem::LineBreak,
1149 InlineItem::Text(make_text_segment("After", 50.0)),
1150 ];
1151 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1152 assert!(lines.len() >= 2);
1153 }
1154
1155 #[test]
1156 fn line_height_exact() {
1157 let params = LineBreakParams {
1158 line_spacing: LineSpacing::Exact(24.0),
1159 ..Default::default()
1160 };
1161 let h = compute_line_height(10.0, 3.0, 5.0, 12.0, ¶ms);
1162 assert!((h - 24.0).abs() < 0.01);
1163 }
1164
1165 #[test]
1166 fn line_height_auto() {
1167 let params = LineBreakParams {
1168 line_spacing: LineSpacing::Multiple(2.0),
1169 ..Default::default()
1170 };
1171 let h = compute_line_height(10.0, 3.0, 2.0, 15.0, ¶ms);
1172 assert!((h - 30.0).abs() < 0.01); }
1174
1175 #[test]
1176 fn first_line_indent() {
1177 let params = LineBreakParams {
1178 ind_first_line: 36.0,
1179 ..Default::default()
1180 };
1181 let first_w = compute_first_line_width(¶ms);
1182 let subseq_w = compute_subsequent_line_width(¶ms);
1183 assert!(first_w < subseq_w);
1184 }
1185
1186 #[test]
1187 fn hanging_indent() {
1188 let params = LineBreakParams {
1189 ind_left: 36.0,
1190 ind_hanging: 36.0,
1191 ..Default::default()
1192 };
1193 let first_indent = super::first_line_indent(¶ms);
1194 let subseq_indent = super::subsequent_line_indent(¶ms);
1195 assert!(first_indent < subseq_indent);
1196 }
1197
1198 #[test]
1199 fn tab_stop_resolution() {
1200 let stops = vec![TabStop {
1201 pos_pt: 72.0,
1202 align: TabAlign::Left,
1203 leader: None,
1204 }];
1205 let (w, leader) = resolve_tab_width(36.0, &stops);
1206 assert!((w - 36.0).abs() < 0.01);
1207 assert!(leader.is_none());
1208 }
1209
1210 #[test]
1211 fn default_tab_stops() {
1212 let (w, _) = resolve_tab_width(10.0, &[]);
1213 assert!((w - 26.0).abs() < 0.01); }
1215
1216 #[test]
1217 fn tab_stop_with_dot_leader() {
1218 let stops = vec![TabStop {
1219 pos_pt: 400.0,
1220 align: TabAlign::Right,
1221 leader: Some(TabLeader::Dot),
1222 }];
1223 let (w, leader) = resolve_tab_width(100.0, &stops);
1224 assert!((w - 300.0).abs() < 0.01);
1225 assert_eq!(leader, Some('.'));
1226 }
1227
1228 #[test]
1229 fn the_eleven_line_tests_pass_with_owned_types() {
1230 assert!(LineBreakParams::default().wrap);
1231 empty_paragraph_gets_one_line();
1232 single_word_fits_one_line();
1233 words_wrap_to_multiple_lines();
1234 forced_line_break();
1235 line_height_exact();
1236 line_height_auto();
1237 first_line_indent();
1238 hanging_indent();
1239 tab_stop_resolution();
1240 default_tab_stops();
1241 tab_stop_with_dot_leader();
1242 }
1243
1244 #[test]
1245 fn line_spacing_variants_preserve_existing_height_rules() {
1246 let height = |line_spacing| {
1247 compute_line_height(
1248 10.0,
1249 3.0,
1250 2.0,
1251 11.0,
1252 &LineBreakParams {
1253 line_spacing,
1254 ..Default::default()
1255 },
1256 )
1257 };
1258
1259 assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
1260 assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
1261 assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
1262 assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
1263 assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
1264 }
1265
1266 #[test]
1267 fn mixed_font_line_uses_tallest_full_natural_advance() {
1268 let fm = deterministic_font_manager();
1269 let mut first = make_text_segment("first", 20.0);
1270 first.ascent = 10.0;
1271 first.descent = 2.0;
1272 first.line_gap = 4.0;
1273 let mut second = make_text_segment("second", 20.0);
1274 second.ascent = 8.0;
1275 second.descent = 5.0;
1276 second.line_gap = 1.0;
1277
1278 let lines = break_into_lines(
1279 &[InlineItem::Text(first), InlineItem::Text(second)],
1280 &LineBreakParams::default(),
1281 &fm,
1282 )
1283 .expect("lay out mixed-font line");
1284
1285 assert_eq!(lines.len(), 1);
1286 assert!((lines[0].ascent - 10.0).abs() < 0.01);
1287 assert!((lines[0].descent - 5.0).abs() < 0.01);
1288 assert!((lines[0].line_gap - 1.0).abs() < 0.01);
1289 assert!((lines[0].height - 16.0).abs() < 0.01);
1290 assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
1291 }
1292
1293 #[test]
1294 fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
1295 let fm = deterministic_font_manager();
1296 let mut first = make_text_segment("first", 20.0);
1297 first.font_size = 12.0;
1298 first.line_gap = 4.0;
1299 let mut second = make_text_segment("second", 20.0);
1300 second.font_size = 20.0;
1301 second.line_gap = 1.0;
1302
1303 let lines = break_into_lines(
1304 &[InlineItem::Text(first), InlineItem::Text(second)],
1305 &LineBreakParams {
1306 line_spacing: LineSpacing::Multiple(1.25),
1307 ..LineBreakParams::default()
1308 },
1309 &fm,
1310 )
1311 .expect("lay out percentage-spaced mixed-size line");
1312
1313 assert_eq!(lines.len(), 1);
1314 assert!((lines[0].height - 25.0).abs() < 0.01);
1315 }
1316
1317 #[test]
1318 fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
1319 let positive = LayoutLine {
1320 items: Vec::new(),
1321 width: 0.0,
1322 ascent: 10.0,
1323 descent: 3.0,
1324 line_gap: 5.0,
1325 height: 18.0,
1326 indent_left: 0.0,
1327 available_width: 100.0,
1328 is_last: true,
1329 };
1330 let below_natural = LayoutLine {
1331 height: 8.0,
1332 ..positive.clone()
1333 };
1334
1335 assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
1336 assert!((below_natural.height - 8.0).abs() < 0.01);
1337 assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
1338 }
1339
1340 #[test]
1341 fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
1342 let fm = deterministic_font_manager();
1343 let zero_gap = make_text_segment("zero", 20.0);
1344 let mut empty = make_text_segment("", 0.0);
1345 empty.line_gap = 4.0;
1346
1347 let zero_gap_line = break_into_lines(
1348 &[InlineItem::Text(zero_gap)],
1349 &LineBreakParams::default(),
1350 &fm,
1351 )
1352 .expect("lay out zero-gap line");
1353 let empty_line =
1354 break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
1355 .expect("lay out styled empty line");
1356
1357 assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
1358 assert!((empty_line[0].height - 17.0).abs() < 0.01);
1359 }
1360
1361 #[test]
1362 fn wrap_false_only_breaks_on_an_explicit_break() {
1363 let fm = deterministic_font_manager();
1364 let params = LineBreakParams {
1365 available_width: 100.0,
1366 wrap: false,
1367 ..Default::default()
1368 };
1369
1370 for forced_break in [
1371 InlineItem::LineBreak,
1372 InlineItem::PageBreak,
1373 InlineItem::ColumnBreak,
1374 ] {
1375 let items = vec![
1376 InlineItem::Text(make_text_segment("one", 80.0)),
1377 InlineItem::Text(make_text_segment("two", 80.0)),
1378 forced_break,
1379 InlineItem::Text(make_text_segment("three", 80.0)),
1380 InlineItem::Text(make_text_segment("four", 80.0)),
1381 ];
1382 let lines = break_into_lines(&items, ¶ms, &fm).unwrap();
1383
1384 assert_eq!(lines.len(), 2);
1385 assert!((lines[0].width - 160.0).abs() < 0.01);
1386 assert!((lines[1].width - 160.0).abs() < 0.01);
1387 }
1388 }
1389
1390 #[test]
1391 fn tab_stops_use_point_positions_and_owned_leaders() {
1392 let mut fm = deterministic_font_manager();
1393 let font_id = fm
1394 .resolve_font(Some("Carlito"), false, false)
1395 .expect("bundled Carlito should resolve");
1396 let stop = TabStop {
1397 pos_pt: 72.25,
1398 align: TabAlign::Decimal,
1399 leader: Some(TabLeader::Dot),
1400 };
1401
1402 let item = inline_to_line_item(&InlineItem::Tab, 12.0, &[stop], &fm, Some((font_id, 12.0)));
1403
1404 let LineItem::Tab {
1405 width,
1406 leader: Some(leader),
1407 } = item
1408 else {
1409 panic!("owned dot leader should shape into a tab line item");
1410 };
1411 assert!((width - 60.25).abs() < 0.01);
1412 assert!((leader.width - 60.25).abs() < 0.01);
1413 assert!(!leader.glyph_ids.is_empty());
1414 assert!(leader.text.chars().all(|ch| ch == '.'));
1415 }
1416
1417 #[test]
1418 fn staged_image_types_use_media_id_instead_of_embed_id() {
1419 let media_id = crate::MediaId::from_bytes(b"image");
1420 let item = inline_to_line_item(
1421 &InlineItem::Image {
1422 width: 10.0,
1423 height: 20.0,
1424 media_id,
1425 },
1426 0.0,
1427 &[],
1428 &deterministic_font_manager(),
1429 None,
1430 );
1431 let LineItem::Image {
1432 media_id: actual, ..
1433 } = item
1434 else {
1435 panic!("image should remain an image");
1436 };
1437 assert_eq!(actual, media_id);
1438 }
1439
1440 #[test]
1441 fn group_inline_item_breaks_and_positions_like_an_image() {
1442 use crate::{GroupElement, PositionedElement, Transform};
1443
1444 let group = GroupElement {
1445 transform: Transform::IDENTITY,
1446 clip: None,
1447 opacity: 1.0,
1448 effects: Vec::new(),
1449 children: vec![PositionedElement::FilledRect {
1450 rect: crate::Rect {
1451 x: 2.0,
1452 y: 3.0,
1453 width: 4.0,
1454 height: 5.0,
1455 },
1456 color: crate::Color::BLACK,
1457 }],
1458 };
1459 let items = vec![InlineItem::Group {
1460 width: 80.0,
1461 height: 40.0,
1462 group: group.clone(),
1463 }];
1464 let lines = break_into_lines(
1465 &items,
1466 &LineBreakParams {
1467 available_width: 80.0,
1468 ..Default::default()
1469 },
1470 &deterministic_font_manager(),
1471 )
1472 .expect("group line breaking");
1473
1474 assert_eq!(lines.len(), 1);
1475 assert_eq!(lines[0].width, 80.0);
1476 assert_eq!(lines[0].height, 40.0);
1477 let LineItem::Group {
1478 width,
1479 height,
1480 group: actual,
1481 ..
1482 } = &lines[0].items[0]
1483 else {
1484 panic!("inline group should remain a group line item");
1485 };
1486 assert_eq!((*width, *height), (80.0, 40.0));
1487 assert_eq!(actual, &group);
1488 }
1489}