1use crate::error::Result;
6use crate::font::FontManager;
7use crate::output::{Color, FieldKind, FontId, MediaId};
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)]
74pub enum InlineItem {
75 Text(TextSegment),
77 Tab,
79 LineBreak,
81 PageBreak,
83 ColumnBreak,
85 Image {
87 width: f64,
88 height: f64,
89 media_id: MediaId,
90 },
91 Marker(TextSegment),
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum NoteStream {
103 Footnote,
105 Endnote,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
111pub struct NoteRef {
112 pub stream: NoteStream,
113 pub id: i32,
114}
115
116#[derive(Debug, Clone)]
118pub struct TextSegment {
119 pub text: String,
120 pub font_id: FontId,
121 pub font_size: f64,
122 pub glyph_ids: Vec<u16>,
123 pub advances: Vec<f64>,
124 pub width: f64,
125 pub ascent: f64,
126 pub descent: f64,
127 pub line_gap: f64,
129 pub color: Color,
130 pub bold: bool,
131 pub italic: bool,
132 pub underline: Option<Underline>,
134 pub strike: bool,
136 pub dstrike: bool,
138 pub highlight: Option<Color>,
140 pub baseline_offset: f64,
142 pub hyperlink_url: Option<String>,
144 pub field_kind: Option<FieldKind>,
146 pub note: Option<NoteRef>,
148}
149
150#[derive(Debug, Clone)]
152pub enum LineItem {
153 Text(TextSegment),
154 Tab {
155 width: f64,
156 leader: Option<TextSegment>,
158 },
159 Image {
160 width: f64,
161 height: f64,
162 media_id: MediaId,
163 },
164 Marker(TextSegment),
165}
166
167impl LineItem {
168 pub fn width(&self) -> f64 {
169 match self {
170 LineItem::Text(seg) => seg.width,
171 LineItem::Tab { width, .. } => *width,
172 LineItem::Image { width, .. } => *width,
173 LineItem::Marker(seg) => seg.width,
174 }
175 }
176}
177
178#[derive(Debug, Clone)]
180pub struct LayoutLine {
181 pub items: Vec<LineItem>,
182 pub width: f64,
184 pub ascent: f64,
186 pub descent: f64,
188 pub line_gap: f64,
190 pub height: f64,
192 pub indent_left: f64,
194 pub available_width: f64,
196 pub is_last: bool,
198}
199
200impl LayoutLine {
201 pub fn baseline_offset(&self) -> f64 {
203 let leading = self.height - self.ascent - self.descent;
204 self.ascent + if leading >= 0.0 { leading / 2.0 } else { 0.0 }
205 }
206}
207
208#[derive(Debug, Clone)]
210pub struct LineBreakParams {
211 pub available_width: f64,
213 pub ind_left: f64,
215 pub ind_right: f64,
217 pub ind_first_line: f64,
219 pub ind_hanging: f64,
221 pub tab_stops: Vec<TabStop>,
223 pub line_spacing: LineSpacing,
225 pub jc: Option<Align>,
227 pub wrap: bool,
229 pub line_prefix_widths: Vec<f64>,
235 pub line_suffix_widths: Vec<f64>,
237}
238
239impl Default for LineBreakParams {
240 fn default() -> Self {
241 LineBreakParams {
242 available_width: 468.0, line_prefix_widths: Vec::new(),
244 line_suffix_widths: Vec::new(),
245 ind_left: 0.0,
246 ind_right: 0.0,
247 ind_first_line: 0.0,
248 ind_hanging: 0.0,
249 tab_stops: Vec::new(),
250 line_spacing: LineSpacing::Single,
251 jc: None,
252 wrap: true,
253 }
254 }
255}
256
257pub fn break_into_lines(
259 items: &[InlineItem],
260 params: &LineBreakParams,
261 fm: &FontManager,
262) -> Result<Vec<LayoutLine>> {
263 if items.is_empty() {
264 return Ok(vec![LayoutLine {
266 items: Vec::new(),
267 width: 0.0,
268 ascent: 0.0,
269 descent: 0.0,
270 line_gap: 0.0,
271 height: compute_line_height(0.0, 0.0, 0.0, 0.0, params),
272 indent_left: line_indent_at(params, 0, true),
273 available_width: line_width_at(params, 0, true),
274 is_last: true,
275 }]);
276 }
277
278 let mut lines: Vec<LayoutLine> = Vec::new();
279 let mut current_items: Vec<LineItem> = Vec::new();
280 let mut current_width: f64 = 0.0;
281 let mut current_ascent: f64 = 0.0;
282 let mut current_descent: f64 = 0.0;
283 let mut current_natural_height: f64 = 0.0;
284 let mut current_font_size: f64 = 0.0;
285 let mut line_index = 0usize;
288 let mut is_first_line = true;
289
290 let first_line_width = line_width_at(params, 0, true);
291
292 let mut line_avail = first_line_width;
293
294 let mut font_ctx: Option<(FontId, f64)> = None;
296 for item in items {
298 if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
299 font_ctx = Some((seg.font_id, seg.font_size));
300 break;
301 }
302 }
303
304 let segments = build_breakable_segments(items, fm)?;
306
307 for seg in &segments {
308 match seg {
309 BreakableSegment::Items(seg_items) => {
310 let seg_width: f64 = seg_items.iter().map(inline_item_width).sum();
311
312 if params.wrap
313 && !current_items.is_empty()
314 && current_width + seg_width > line_avail + 0.01
315 {
316 let indent = line_indent_at(params, line_index, is_first_line);
318 let line_gap =
319 effective_line_gap(current_ascent, current_descent, current_natural_height);
320 lines.push(LayoutLine {
321 items: std::mem::take(&mut current_items),
322 width: current_width,
323 ascent: current_ascent,
324 descent: current_descent,
325 line_gap,
326 height: compute_line_height(
327 current_ascent,
328 current_descent,
329 line_gap,
330 current_font_size,
331 params,
332 ),
333 indent_left: indent,
334 available_width: line_avail,
335 is_last: false,
336 });
337 current_width = 0.0;
338 current_ascent = 0.0;
339 current_descent = 0.0;
340 current_natural_height = 0.0;
341 current_font_size = 0.0;
342 is_first_line = false;
343 line_index += 1;
344 line_avail = line_width_at(params, line_index, false);
345 }
346
347 for item in seg_items {
349 let (w, a, d, natural_height, font_size) = item_metrics(item);
350 current_width += w;
351 if a > current_ascent {
352 current_ascent = a;
353 }
354 if d > current_descent {
355 current_descent = d;
356 }
357 current_natural_height = current_natural_height.max(natural_height);
358 current_font_size = current_font_size.max(font_size);
359 if let InlineItem::Text(seg) | InlineItem::Marker(seg) = item {
361 font_ctx = Some((seg.font_id, seg.font_size));
362 }
363 current_items.push(inline_to_line_item(
364 item,
365 current_width,
366 ¶ms.tab_stops,
367 fm,
368 font_ctx,
369 ));
370 }
371 }
372 BreakableSegment::ForcedBreak(break_type) => {
373 let indent = line_indent_at(params, line_index, is_first_line);
374 let line_gap =
375 effective_line_gap(current_ascent, current_descent, current_natural_height);
376 lines.push(LayoutLine {
377 items: std::mem::take(&mut current_items),
378 width: current_width,
379 ascent: current_ascent,
380 descent: current_descent,
381 line_gap,
382 height: compute_line_height(
383 current_ascent,
384 current_descent,
385 line_gap,
386 current_font_size,
387 params,
388 ),
389 indent_left: indent,
390 available_width: line_avail,
391 is_last: matches!(break_type, ForcedBreakType::Page | ForcedBreakType::Column),
392 });
393 current_width = 0.0;
394 current_ascent = 0.0;
395 current_descent = 0.0;
396 current_natural_height = 0.0;
397 current_font_size = 0.0;
398 is_first_line = false;
399 line_index += 1;
400 line_avail = line_width_at(params, line_index, false);
401 }
402 }
403 }
404
405 let indent = line_indent_at(params, line_index, is_first_line);
407 let line_gap = effective_line_gap(current_ascent, current_descent, current_natural_height);
408 lines.push(LayoutLine {
409 items: current_items,
410 width: current_width,
411 ascent: current_ascent,
412 descent: current_descent,
413 line_gap,
414 height: compute_line_height(
415 current_ascent,
416 current_descent,
417 line_gap,
418 current_font_size,
419 params,
420 ),
421 indent_left: indent,
422 available_width: line_avail,
423 is_last: true,
424 });
425
426 Ok(lines)
427}
428
429#[derive(Debug)]
432enum BreakableSegment {
433 Items(Vec<InlineItem>),
435 ForcedBreak(ForcedBreakType),
437}
438
439#[derive(Debug)]
440enum ForcedBreakType {
441 Line,
442 Page,
443 Column,
444}
445
446fn build_breakable_segments(
452 items: &[InlineItem],
453 fm: &FontManager,
454) -> Result<Vec<BreakableSegment>> {
455 let mut segments = Vec::new();
456 let mut current_group: Vec<InlineItem> = Vec::new();
457
458 for item in items {
459 match item {
460 InlineItem::LineBreak => {
461 if !current_group.is_empty() {
462 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
463 }
464 segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Line));
465 }
466 InlineItem::PageBreak => {
467 if !current_group.is_empty() {
468 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
469 }
470 segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Page));
471 }
472 InlineItem::ColumnBreak => {
473 if !current_group.is_empty() {
474 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
475 }
476 segments.push(BreakableSegment::ForcedBreak(ForcedBreakType::Column));
477 }
478 InlineItem::Tab => {
479 if !current_group.is_empty() {
481 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
482 }
483 segments.push(BreakableSegment::Items(vec![item.clone()]));
484 }
485 InlineItem::Text(seg) => {
486 if seg.text.is_empty() {
487 if !current_group.is_empty() {
488 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
489 }
490 segments.push(BreakableSegment::Items(vec![item.clone()]));
491 continue;
492 }
493 let breaks = split_text_at_break_opportunities(seg);
495
496 for tb in &breaks {
497 let chunk = &seg.text[tb.start..tb.end];
498 if chunk.is_empty() {
499 continue;
500 }
501
502 if !current_group.is_empty() && chunk.starts_with(|c: char| c.is_whitespace()) {
504 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
505 }
506
507 let sub_item = split_text_subsegment(seg, tb.start, tb.end, fm)?;
509 current_group.push(sub_item);
510
511 if tb.is_break {
512 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
513 }
514 }
515
516 if !current_group.is_empty() {
518 segments.push(BreakableSegment::Items(std::mem::take(&mut current_group)));
519 }
520 }
521 InlineItem::Marker(_) | InlineItem::Image { .. } => {
522 current_group.push(item.clone());
523 }
524 }
525 }
526
527 if !current_group.is_empty() {
528 segments.push(BreakableSegment::Items(current_group));
529 }
530
531 Ok(segments)
532}
533
534fn split_text_subsegment(
538 seg: &TextSegment,
539 byte_start: usize,
540 byte_end: usize,
541 fm: &FontManager,
542) -> Result<InlineItem> {
543 if byte_start == 0 && byte_end == seg.text.len() {
545 return Ok(InlineItem::Text(seg.clone()));
546 }
547
548 let sub_text = seg.text[byte_start..byte_end].to_string();
549 let mut shaped = fm.shape_text(seg.font_id, &sub_text, seg.font_size)?;
550 let original = fm.shape_text(seg.font_id, &seg.text, seg.font_size)?;
551 let spacing = if original.advances.len() == seg.advances.len() && !original.advances.is_empty()
552 {
553 (seg.width - original.width) / original.advances.len() as f64
554 } else {
555 0.0
556 };
557 for advance in &mut shaped.advances {
558 *advance += spacing;
559 }
560 shaped.width += spacing * shaped.advances.len() as f64;
561
562 Ok(InlineItem::Text(TextSegment {
563 text: sub_text,
564 font_id: seg.font_id,
565 font_size: seg.font_size,
566 glyph_ids: shaped.glyph_ids,
567 advances: shaped.advances,
568 width: shaped.width,
569 ascent: seg.ascent,
570 descent: seg.descent,
571 line_gap: seg.line_gap,
572 color: seg.color,
573 bold: seg.bold,
574 italic: seg.italic,
575 underline: seg.underline,
576 strike: seg.strike,
577 dstrike: seg.dstrike,
578 highlight: seg.highlight,
579 baseline_offset: seg.baseline_offset,
580 hyperlink_url: seg.hyperlink_url.clone(),
581 field_kind: seg.field_kind,
582 note: seg.note,
583 }))
584}
585
586struct TextBreakInfo {
587 start: usize,
589 end: usize,
590 is_break: bool,
592}
593
594fn split_text_at_break_opportunities(seg: &TextSegment) -> Vec<TextBreakInfo> {
595 use unicode_linebreak::{BreakOpportunity, linebreaks};
596
597 let text = &seg.text;
598 if text.is_empty() {
599 return vec![];
600 }
601
602 let mut breaks = Vec::new();
603 let mut last_start = 0;
604
605 for (byte_pos, opportunity) in linebreaks(text) {
606 if byte_pos == 0 {
607 continue;
608 }
609
610 let is_break = matches!(
611 opportunity,
612 BreakOpportunity::Allowed | BreakOpportunity::Mandatory
613 );
614
615 breaks.push(TextBreakInfo {
616 start: last_start,
617 end: byte_pos,
618 is_break,
619 });
620 last_start = byte_pos;
621 }
622
623 if breaks.is_empty() {
625 breaks.push(TextBreakInfo {
626 start: 0,
627 end: text.len(),
628 is_break: true,
629 });
630 }
631
632 breaks
633}
634
635fn inline_item_width(item: &InlineItem) -> f64 {
636 match item {
637 InlineItem::Text(seg) => seg.width,
638 InlineItem::Tab => 36.0, InlineItem::Image { width, .. } => *width,
640 InlineItem::Marker(seg) => seg.width,
641 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => 0.0,
642 }
643}
644
645fn item_metrics(item: &InlineItem) -> (f64, f64, f64, f64, f64) {
646 match item {
648 InlineItem::Text(seg) => (
649 seg.width,
650 seg.ascent,
651 seg.descent,
652 seg.ascent + seg.descent + seg.line_gap,
653 seg.font_size,
654 ),
655 InlineItem::Marker(seg) => (
656 seg.width,
657 seg.ascent,
658 seg.descent,
659 seg.ascent + seg.descent + seg.line_gap,
660 0.0,
661 ),
662 InlineItem::Tab => (36.0, 0.0, 0.0, 0.0, 0.0),
663 InlineItem::Image { width, height, .. } => (*width, *height, 0.0, *height, 0.0),
664 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => {
665 (0.0, 0.0, 0.0, 0.0, 0.0)
666 }
667 }
668}
669
670fn inline_to_line_item(
671 item: &InlineItem,
672 current_x: f64,
673 tab_stops: &[TabStop],
674 fm: &FontManager,
675 font_ctx: Option<(FontId, f64)>,
676) -> LineItem {
677 match item {
678 InlineItem::Text(seg) => LineItem::Text(seg.clone()),
679 InlineItem::Marker(seg) => LineItem::Marker(seg.clone()),
680 InlineItem::Tab => {
681 let (tab_width, leader_char) = resolve_tab_width(current_x, tab_stops);
682 let leader = leader_char.and_then(|ch| shape_leader(fm, font_ctx, ch, tab_width));
683 LineItem::Tab {
684 width: tab_width,
685 leader,
686 }
687 }
688 InlineItem::Image {
689 width,
690 height,
691 media_id,
692 } => LineItem::Image {
693 width: *width,
694 height: *height,
695 media_id: *media_id,
696 },
697 InlineItem::LineBreak | InlineItem::PageBreak | InlineItem::ColumnBreak => LineItem::Tab {
698 width: 0.0,
699 leader: None,
700 },
701 }
702}
703
704fn shape_leader(
706 fm: &FontManager,
707 font_ctx: Option<(FontId, f64)>,
708 leader_char: char,
709 tab_width: f64,
710) -> Option<TextSegment> {
711 let (font_id, font_size) = font_ctx?;
712 if tab_width < 1.0 {
713 return None;
714 }
715
716 let single = String::from(leader_char);
718 let shaped = fm.shape_text(font_id, &single, font_size).ok()?;
719 if shaped.glyph_ids.is_empty() {
720 return None;
721 }
722 let char_advance = shaped.advances[0];
723 if char_advance < 0.5 {
724 return None;
725 }
726
727 let spacing = match leader_char {
729 '.' | '\u{00B7}' => char_advance * 0.5,
730 _ => char_advance * 0.15,
731 };
732 let step = char_advance + spacing;
733 let count = ((tab_width - spacing) / step).floor() as usize;
734 if count == 0 {
735 return None;
736 }
737
738 let leader_text: String = std::iter::repeat_n(leader_char, count).collect();
740 let mut glyph_ids = Vec::with_capacity(count);
741 let mut advances = Vec::with_capacity(count);
742 for i in 0..count {
743 glyph_ids.push(shaped.glyph_ids[0]);
744 if i + 1 < count {
745 advances.push(char_advance + spacing);
746 } else {
747 advances.push(char_advance);
748 }
749 }
750
751 let metrics = fm.metrics(font_id, font_size).ok()?;
752
753 Some(TextSegment {
754 text: leader_text,
755 font_id,
756 font_size,
757 glyph_ids,
758 advances,
759 width: tab_width, ascent: metrics.ascent,
761 descent: metrics.descent,
762 line_gap: metrics.line_gap,
763 color: Color::BLACK,
764 bold: false,
765 italic: false,
766 underline: None,
767 strike: false,
768 dstrike: false,
769 highlight: None,
770 baseline_offset: 0.0,
771 hyperlink_url: None,
772 field_kind: None,
773 note: None,
774 })
775}
776
777fn resolve_tab_width(current_x: f64, tab_stops: &[TabStop]) -> (f64, Option<char>) {
779 for stop in tab_stops {
781 let stop_pos = stop.pos_pt;
782 if stop_pos > current_x {
783 let width = match stop.align {
784 TabAlign::Left => stop_pos - current_x,
785 TabAlign::Center => (stop_pos - current_x).max(0.0),
786 TabAlign::Right => (stop_pos - current_x).max(0.0),
787 _ => stop_pos - current_x,
788 };
789 let leader = stop.leader.and_then(|l| match l {
790 TabLeader::Dot => Some('.'),
791 TabLeader::Hyphen => Some('-'),
792 TabLeader::Underscore => Some('_'),
793 TabLeader::MiddleDot => Some('\u{00B7}'),
794 TabLeader::Heavy => Some('_'),
795 TabLeader::None => None,
796 });
797 return (width, leader);
798 }
799 }
800 let default_interval = 36.0;
802 let next_stop = ((current_x / default_interval).floor() + 1.0) * default_interval;
803 (next_stop - current_x, None)
804}
805
806fn compute_first_line_width(params: &LineBreakParams) -> f64 {
807 if params.ind_hanging > 0.0 {
808 params.available_width - params.ind_left - params.ind_right + params.ind_hanging
810 } else {
811 params.available_width - params.ind_left - params.ind_right - params.ind_first_line
812 }
813}
814
815fn compute_subsequent_line_width(params: &LineBreakParams) -> f64 {
816 params.available_width - params.ind_left - params.ind_right
817}
818
819fn line_prefix_width(params: &LineBreakParams, line_index: usize) -> f64 {
821 params
822 .line_prefix_widths
823 .get(line_index)
824 .copied()
825 .unwrap_or(0.0)
826}
827
828fn line_suffix_width(params: &LineBreakParams, line_index: usize) -> f64 {
830 params
831 .line_suffix_widths
832 .get(line_index)
833 .copied()
834 .unwrap_or(0.0)
835}
836
837fn line_width_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
839 let base = if is_first_line {
840 compute_first_line_width(params)
841 } else {
842 compute_subsequent_line_width(params)
843 };
844 (base - line_prefix_width(params, line_index) - line_suffix_width(params, line_index)).max(0.0)
845}
846
847fn line_indent_at(params: &LineBreakParams, line_index: usize, is_first_line: bool) -> f64 {
849 let base = if is_first_line {
850 first_line_indent(params)
851 } else {
852 subsequent_line_indent(params)
853 };
854 base + line_prefix_width(params, line_index)
855}
856
857fn first_line_indent(params: &LineBreakParams) -> f64 {
858 if params.ind_hanging > 0.0 {
859 params.ind_left - params.ind_hanging
860 } else {
861 params.ind_left + params.ind_first_line
862 }
863}
864
865fn subsequent_line_indent(params: &LineBreakParams) -> f64 {
866 params.ind_left
867}
868
869fn effective_line_gap(ascent: f64, descent: f64, natural_height: f64) -> f64 {
871 (natural_height - ascent - descent).max(0.0)
872}
873
874fn compute_line_height(
875 ascent: f64,
876 descent: f64,
877 line_gap: f64,
878 font_size: f64,
879 params: &LineBreakParams,
880) -> f64 {
881 let natural = ascent + descent + line_gap;
882 let natural = if natural < 1.0 { 12.0 } else { natural }; let font_size = if font_size < 1.0 { 12.0 } else { font_size };
884
885 match params.line_spacing {
886 LineSpacing::Single => natural,
887 LineSpacing::Multiple(factor) => font_size * factor,
888 LineSpacing::Exact(points) => points,
889 LineSpacing::AtLeast(points) => natural.max(points),
890 }
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896
897 fn make_text_segment(text: &str, width: f64) -> TextSegment {
898 TextSegment {
899 text: text.to_string(),
900 font_id: FontId(0),
901 font_size: 12.0,
902 glyph_ids: vec![],
903 advances: vec![],
904 width,
905 ascent: 10.0,
906 descent: 3.0,
907 line_gap: 0.0,
908 color: Color::BLACK,
909 bold: false,
910 italic: false,
911 underline: None,
912 strike: false,
913 dstrike: false,
914 highlight: None,
915 baseline_offset: 0.0,
916 hyperlink_url: None,
917 field_kind: None,
918 note: None,
919 }
920 }
921
922 fn deterministic_font_manager() -> FontManager {
923 FontManager::new_deterministic().expect("bundled fonts should load")
924 }
925
926 fn shaped_text_segment(fm: &mut FontManager, text: &str, spacing: f64) -> TextSegment {
927 let font_id = fm
928 .resolve_font(Some("Carlito"), false, false)
929 .expect("bundled Carlito should resolve");
930 let metrics = fm.metrics(font_id, 30.0).expect("Carlito metrics");
931 let mut shaped = fm.shape_text(font_id, text, 30.0).expect("shape text");
932 for advance in &mut shaped.advances {
933 *advance += spacing;
934 }
935 shaped.width += spacing * shaped.advances.len() as f64;
936 TextSegment {
937 text: text.to_owned(),
938 font_id,
939 font_size: 30.0,
940 glyph_ids: shaped.glyph_ids,
941 advances: shaped.advances,
942 width: shaped.width,
943 ascent: metrics.ascent,
944 descent: metrics.descent,
945 line_gap: metrics.line_gap,
946 color: Color::BLACK,
947 bold: false,
948 italic: false,
949 underline: None,
950 strike: false,
951 dstrike: false,
952 highlight: None,
953 baseline_offset: 0.0,
954 hyperlink_url: None,
955 field_kind: None,
956 note: None,
957 }
958 }
959
960 #[test]
961 fn empty_paragraph_gets_one_line() {
962 let fm = deterministic_font_manager();
963 let lines = break_into_lines(&[], &LineBreakParams::default(), &fm).unwrap();
964 assert_eq!(lines.len(), 1);
965 assert!(lines[0].is_last);
966 assert!(lines[0].items.is_empty());
967 }
968
969 #[test]
970 fn single_word_fits_one_line() {
971 let fm = deterministic_font_manager();
972 let items = vec![InlineItem::Text(make_text_segment("Hello", 50.0))];
973 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
974 assert_eq!(lines.len(), 1);
975 assert!(lines[0].is_last);
976 }
977
978 #[test]
979 fn words_wrap_to_multiple_lines() {
980 let fm = deterministic_font_manager();
981 let mut items = vec![
983 InlineItem::Text(make_text_segment("Word1", 200.0)),
984 InlineItem::Text(make_text_segment("Word2", 200.0)),
985 ];
986 items.push(InlineItem::Text(make_text_segment("Word3", 200.0)));
987
988 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
989 assert!(lines.len() >= 2);
990 }
991
992 #[test]
993 fn ligature_runs_reshape_each_break_chunk_without_duplicate_glyphs() {
994 let mut fm = deterministic_font_manager();
995 let text = "by providing opportunities to crawl in cluttered spaces and handle 3-dimensional objects";
996 let spacing = 0.4;
997 let segment = shaped_text_segment(&mut fm, text, spacing);
998 assert_ne!(segment.glyph_ids.len(), text.chars().count());
999
1000 let lines = break_into_lines(
1001 &[InlineItem::Text(segment)],
1002 &LineBreakParams {
1003 available_width: 260.0,
1004 ..LineBreakParams::default()
1005 },
1006 &fm,
1007 )
1008 .expect("wrap ligature-bearing text");
1009 assert!(lines.len() > 1);
1010
1011 let mut rendered_text = String::new();
1012 for text_segment in lines.iter().flat_map(|line| {
1013 line.items.iter().filter_map(|item| match item {
1014 LineItem::Text(segment) => Some(segment),
1015 _ => None,
1016 })
1017 }) {
1018 rendered_text.push_str(&text_segment.text);
1019 let exact = fm
1020 .shape_text(
1021 text_segment.font_id,
1022 &text_segment.text,
1023 text_segment.font_size,
1024 )
1025 .expect("reshape emitted chunk");
1026 assert_eq!(text_segment.glyph_ids, exact.glyph_ids);
1027 assert_eq!(text_segment.advances.len(), exact.advances.len());
1028 for (actual, unspaced) in text_segment.advances.iter().zip(exact.advances) {
1029 assert!((actual - (unspaced + spacing)).abs() < 1.0e-10);
1030 }
1031 }
1032 assert_eq!(rendered_text, text);
1033 }
1034
1035 #[test]
1036 fn forced_line_break() {
1037 let fm = deterministic_font_manager();
1038 let items = vec![
1039 InlineItem::Text(make_text_segment("Before", 50.0)),
1040 InlineItem::LineBreak,
1041 InlineItem::Text(make_text_segment("After", 50.0)),
1042 ];
1043 let lines = break_into_lines(&items, &LineBreakParams::default(), &fm).unwrap();
1044 assert!(lines.len() >= 2);
1045 }
1046
1047 #[test]
1048 fn line_height_exact() {
1049 let params = LineBreakParams {
1050 line_spacing: LineSpacing::Exact(24.0),
1051 ..Default::default()
1052 };
1053 let h = compute_line_height(10.0, 3.0, 5.0, 12.0, ¶ms);
1054 assert!((h - 24.0).abs() < 0.01);
1055 }
1056
1057 #[test]
1058 fn line_height_auto() {
1059 let params = LineBreakParams {
1060 line_spacing: LineSpacing::Multiple(2.0),
1061 ..Default::default()
1062 };
1063 let h = compute_line_height(10.0, 3.0, 2.0, 15.0, ¶ms);
1064 assert!((h - 30.0).abs() < 0.01); }
1066
1067 #[test]
1068 fn first_line_indent() {
1069 let params = LineBreakParams {
1070 ind_first_line: 36.0,
1071 ..Default::default()
1072 };
1073 let first_w = compute_first_line_width(¶ms);
1074 let subseq_w = compute_subsequent_line_width(¶ms);
1075 assert!(first_w < subseq_w);
1076 }
1077
1078 #[test]
1079 fn hanging_indent() {
1080 let params = LineBreakParams {
1081 ind_left: 36.0,
1082 ind_hanging: 36.0,
1083 ..Default::default()
1084 };
1085 let first_indent = super::first_line_indent(¶ms);
1086 let subseq_indent = super::subsequent_line_indent(¶ms);
1087 assert!(first_indent < subseq_indent);
1088 }
1089
1090 #[test]
1091 fn tab_stop_resolution() {
1092 let stops = vec![TabStop {
1093 pos_pt: 72.0,
1094 align: TabAlign::Left,
1095 leader: None,
1096 }];
1097 let (w, leader) = resolve_tab_width(36.0, &stops);
1098 assert!((w - 36.0).abs() < 0.01);
1099 assert!(leader.is_none());
1100 }
1101
1102 #[test]
1103 fn default_tab_stops() {
1104 let (w, _) = resolve_tab_width(10.0, &[]);
1105 assert!((w - 26.0).abs() < 0.01); }
1107
1108 #[test]
1109 fn tab_stop_with_dot_leader() {
1110 let stops = vec![TabStop {
1111 pos_pt: 400.0,
1112 align: TabAlign::Right,
1113 leader: Some(TabLeader::Dot),
1114 }];
1115 let (w, leader) = resolve_tab_width(100.0, &stops);
1116 assert!((w - 300.0).abs() < 0.01);
1117 assert_eq!(leader, Some('.'));
1118 }
1119
1120 #[test]
1121 fn the_eleven_line_tests_pass_with_owned_types() {
1122 assert!(LineBreakParams::default().wrap);
1123 empty_paragraph_gets_one_line();
1124 single_word_fits_one_line();
1125 words_wrap_to_multiple_lines();
1126 forced_line_break();
1127 line_height_exact();
1128 line_height_auto();
1129 first_line_indent();
1130 hanging_indent();
1131 tab_stop_resolution();
1132 default_tab_stops();
1133 tab_stop_with_dot_leader();
1134 }
1135
1136 #[test]
1137 fn line_spacing_variants_preserve_existing_height_rules() {
1138 let height = |line_spacing| {
1139 compute_line_height(
1140 10.0,
1141 3.0,
1142 2.0,
1143 11.0,
1144 &LineBreakParams {
1145 line_spacing,
1146 ..Default::default()
1147 },
1148 )
1149 };
1150
1151 assert!((height(LineSpacing::Single) - 15.0).abs() < 0.01);
1152 assert!((height(LineSpacing::Multiple(1.5)) - 16.5).abs() < 0.01);
1153 assert!((height(LineSpacing::Exact(8.25)) - 8.25).abs() < 0.01);
1154 assert!((height(LineSpacing::AtLeast(8.25)) - 15.0).abs() < 0.01);
1155 assert!((height(LineSpacing::AtLeast(18.5)) - 18.5).abs() < 0.01);
1156 }
1157
1158 #[test]
1159 fn mixed_font_line_uses_tallest_full_natural_advance() {
1160 let fm = deterministic_font_manager();
1161 let mut first = make_text_segment("first", 20.0);
1162 first.ascent = 10.0;
1163 first.descent = 2.0;
1164 first.line_gap = 4.0;
1165 let mut second = make_text_segment("second", 20.0);
1166 second.ascent = 8.0;
1167 second.descent = 5.0;
1168 second.line_gap = 1.0;
1169
1170 let lines = break_into_lines(
1171 &[InlineItem::Text(first), InlineItem::Text(second)],
1172 &LineBreakParams::default(),
1173 &fm,
1174 )
1175 .expect("lay out mixed-font line");
1176
1177 assert_eq!(lines.len(), 1);
1178 assert!((lines[0].ascent - 10.0).abs() < 0.01);
1179 assert!((lines[0].descent - 5.0).abs() < 0.01);
1180 assert!((lines[0].line_gap - 1.0).abs() < 0.01);
1181 assert!((lines[0].height - 16.0).abs() < 0.01);
1182 assert!((lines[0].baseline_offset() - 10.5).abs() < 0.01);
1183 }
1184
1185 #[test]
1186 fn multiple_spacing_uses_largest_text_point_size_on_each_line() {
1187 let fm = deterministic_font_manager();
1188 let mut first = make_text_segment("first", 20.0);
1189 first.font_size = 12.0;
1190 first.line_gap = 4.0;
1191 let mut second = make_text_segment("second", 20.0);
1192 second.font_size = 20.0;
1193 second.line_gap = 1.0;
1194
1195 let lines = break_into_lines(
1196 &[InlineItem::Text(first), InlineItem::Text(second)],
1197 &LineBreakParams {
1198 line_spacing: LineSpacing::Multiple(1.25),
1199 ..LineBreakParams::default()
1200 },
1201 &fm,
1202 )
1203 .expect("lay out percentage-spaced mixed-size line");
1204
1205 assert_eq!(lines.len(), 1);
1206 assert!((lines[0].height - 25.0).abs() < 0.01);
1207 }
1208
1209 #[test]
1210 fn positive_leading_is_split_and_below_natural_exact_spacing_is_not_clamped() {
1211 let positive = LayoutLine {
1212 items: Vec::new(),
1213 width: 0.0,
1214 ascent: 10.0,
1215 descent: 3.0,
1216 line_gap: 5.0,
1217 height: 18.0,
1218 indent_left: 0.0,
1219 available_width: 100.0,
1220 is_last: true,
1221 };
1222 let below_natural = LayoutLine {
1223 height: 8.0,
1224 ..positive.clone()
1225 };
1226
1227 assert!((positive.baseline_offset() - 12.5).abs() < 0.01);
1228 assert!((below_natural.height - 8.0).abs() < 0.01);
1229 assert!((below_natural.baseline_offset() - 10.0).abs() < 0.01);
1230 }
1231
1232 #[test]
1233 fn zero_gap_and_empty_segment_preserve_natural_height_rules() {
1234 let fm = deterministic_font_manager();
1235 let zero_gap = make_text_segment("zero", 20.0);
1236 let mut empty = make_text_segment("", 0.0);
1237 empty.line_gap = 4.0;
1238
1239 let zero_gap_line = break_into_lines(
1240 &[InlineItem::Text(zero_gap)],
1241 &LineBreakParams::default(),
1242 &fm,
1243 )
1244 .expect("lay out zero-gap line");
1245 let empty_line =
1246 break_into_lines(&[InlineItem::Text(empty)], &LineBreakParams::default(), &fm)
1247 .expect("lay out styled empty line");
1248
1249 assert!((zero_gap_line[0].height - 13.0).abs() < 0.01);
1250 assert!((empty_line[0].height - 17.0).abs() < 0.01);
1251 }
1252
1253 #[test]
1254 fn wrap_false_only_breaks_on_an_explicit_break() {
1255 let fm = deterministic_font_manager();
1256 let params = LineBreakParams {
1257 available_width: 100.0,
1258 wrap: false,
1259 ..Default::default()
1260 };
1261
1262 for forced_break in [
1263 InlineItem::LineBreak,
1264 InlineItem::PageBreak,
1265 InlineItem::ColumnBreak,
1266 ] {
1267 let items = vec![
1268 InlineItem::Text(make_text_segment("one", 80.0)),
1269 InlineItem::Text(make_text_segment("two", 80.0)),
1270 forced_break,
1271 InlineItem::Text(make_text_segment("three", 80.0)),
1272 InlineItem::Text(make_text_segment("four", 80.0)),
1273 ];
1274 let lines = break_into_lines(&items, ¶ms, &fm).unwrap();
1275
1276 assert_eq!(lines.len(), 2);
1277 assert!((lines[0].width - 160.0).abs() < 0.01);
1278 assert!((lines[1].width - 160.0).abs() < 0.01);
1279 }
1280 }
1281
1282 #[test]
1283 fn tab_stops_use_point_positions_and_owned_leaders() {
1284 let mut fm = deterministic_font_manager();
1285 let font_id = fm
1286 .resolve_font(Some("Carlito"), false, false)
1287 .expect("bundled Carlito should resolve");
1288 let stop = TabStop {
1289 pos_pt: 72.25,
1290 align: TabAlign::Decimal,
1291 leader: Some(TabLeader::Dot),
1292 };
1293
1294 let item = inline_to_line_item(&InlineItem::Tab, 12.0, &[stop], &fm, Some((font_id, 12.0)));
1295
1296 let LineItem::Tab {
1297 width,
1298 leader: Some(leader),
1299 } = item
1300 else {
1301 panic!("owned dot leader should shape into a tab line item");
1302 };
1303 assert!((width - 60.25).abs() < 0.01);
1304 assert!((leader.width - 60.25).abs() < 0.01);
1305 assert!(!leader.glyph_ids.is_empty());
1306 assert!(leader.text.chars().all(|ch| ch == '.'));
1307 }
1308
1309 #[test]
1310 fn staged_image_types_use_media_id_instead_of_embed_id() {
1311 let media_id = crate::MediaId::from_bytes(b"image");
1312 let item = inline_to_line_item(
1313 &InlineItem::Image {
1314 width: 10.0,
1315 height: 20.0,
1316 media_id,
1317 },
1318 0.0,
1319 &[],
1320 &deterministic_font_manager(),
1321 None,
1322 );
1323 let LineItem::Image {
1324 media_id: actual, ..
1325 } = item
1326 else {
1327 panic!("image should remain an image");
1328 };
1329 assert_eq!(actual, media_id);
1330 }
1331}