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