1#![doc(
11 html_logo_url = "https://mosaiclang.dev/assets/A4.svg",
12 html_favicon_url = "https://mosaiclang.dev/assets/A4.svg"
13)]
14
15pub use boundary::{PageBoundarySignature, PageGraphSignature};
16use mos_fonts::nfc_text;
17pub use mos_fonts::{
18 Base14Font, EmbeddedFontId, Font, FontFamily, ShapedGlyph, WordSubRun, ascent, descent,
19 glyph_width, shape_with_fallback, text_width,
20};
21pub use style::paper_size_pt;
22pub use types::{
23 A4_HEIGHT_PT, A4_WIDTH_PT, ImageHandle, ImagePlacement, LayoutResult, MARGIN_PT, OutlineEntry,
24 Page, PageGraph, PageStyle, TextRun, TextStyle,
25};
26
27use std::collections::BTreeMap;
28
29use mos_core::{AttrValue, Diagnostic, Document, Node, NodeKind};
30use style::resolve_styles;
31use support::{blank_page, expand_tabs, read_level, read_str_attr};
32use types::BODY_LEADING;
33use word::{ShyBreak, Word, WordItem, split_soft_hyphens, try_shy_break, word_clusters};
34
35#[doc(hidden)]
36pub mod bibliography;
37#[doc(hidden)]
38pub mod boundary;
39pub mod debug;
40#[doc(hidden)]
41pub mod image;
42#[doc(hidden)]
43pub mod list;
44#[doc(hidden)]
45pub mod style;
46#[doc(hidden)]
47pub mod support;
48#[doc(hidden)]
49pub mod types;
50#[doc(hidden)]
51pub mod word;
52
53const HEADING_SIZES_PT: [f32; 3] = [20.0, 16.0, 13.0];
56const HEADING_SPACE_BEFORE_PT: [f32; 3] = [16.0, 12.0, 10.0];
59const HEADING_SPACE_AFTER_PT: [f32; 3] = [10.0, 8.0, 6.0];
61const PARA_SPACE_AFTER_PT: f32 = 4.0;
63const LIST_MARKER_GUTTER_PT: f32 = 18.0;
71const RAW_BLOCK_TAB_WIDTH: usize = 4;
73
74#[derive(Copy, Clone, Debug, Default)]
86pub struct LayoutEngine;
87
88impl LayoutEngine {
89 #[must_use]
101 pub const fn new() -> Self {
102 Self
103 }
104
105 #[must_use]
123 pub fn layout(self, document: &Document) -> LayoutResult {
124 self.layout_impl(document, false)
125 }
126
127 #[must_use]
130 pub fn layout_with_debug(self, document: &Document) -> LayoutResult {
131 self.layout_impl(document, true)
132 }
133
134 fn layout_impl(self, document: &Document, capture_debug: bool) -> LayoutResult {
135 let Self = self;
136 let (page_style, text_style, mut diagnostics) = resolve_styles(document);
137 let mut state = LayoutState::new(page_style, text_style);
138 if capture_debug {
139 state.debug = Some(debug::Recorder::default());
140 }
141 state.diagnostics.append(&mut diagnostics);
142 let Some(root) = document.get(document.root) else {
143 return state.finish();
144 };
145 for child_id in &root.children {
146 let Some(node) = document.get(*child_id) else {
147 continue;
148 };
149 state.queue_label(node);
153 state.begin_debug_block(node);
154 match node.kind {
155 NodeKind::Section => state.layout_heading(document, node),
156 NodeKind::Paragraph => state.layout_paragraph(document, node),
157 NodeKind::Image => state.layout_image(*child_id, node),
158 NodeKind::Figure => state.layout_figure(document, node),
159 NodeKind::List => state.layout_list(document, node),
160 NodeKind::Bibliography => state.layout_bibliography(document, node),
161 NodeKind::Raw if node.attributes.contains_key("raw.kind") => {
162 state.layout_raw_block(node);
163 }
164 NodeKind::Raw if node.attributes.contains_key("set") => {}
167 _ => {
168 }
172 }
173 state.discard_unbound_labels();
178 state.end_debug_block();
179 }
180 state.finish()
181 }
182}
183
184struct LayoutState {
186 debug: Option<debug::Recorder>,
187 pages: Vec<Page>,
188 current_page: Page,
190 cursor_y: f32,
192 page_has_content: bool,
195 diagnostics: Vec<Diagnostic>,
196 page: PageStyle,
197 text: TextStyle,
198 image_handles: Vec<ImageHandle>,
202 current_left_pt: f32,
206 pending_marker: Option<PendingMarker>,
211 pending_labels: Vec<String>,
215 label_pages: BTreeMap<String, u32>,
218 pending_outline: Option<PendingOutline>,
223 outline: Vec<OutlineEntry>,
226}
227
228#[derive(Clone, Debug)]
229struct PendingOutline {
230 level: u8,
231 title: String,
232}
233
234#[derive(Clone, Debug)]
235struct PendingMarker {
236 x_pt: f32,
239 word: Word,
243}
244
245#[derive(Clone, Copy, Debug)]
246struct PendingSpace {
247 width_pt: f32,
248 from_line_break: bool,
249}
250
251impl LayoutState {
252 const fn new(page: PageStyle, text: TextStyle) -> Self {
253 Self {
254 debug: None,
255 pages: Vec::new(),
256 current_page: blank_page(1, page),
257 cursor_y: page.margin,
258 page_has_content: false,
259 diagnostics: Vec::new(),
260 page,
261 text,
262 image_handles: Vec::new(),
263 current_left_pt: page.margin,
264 pending_marker: None,
265 pending_labels: Vec::new(),
266 label_pages: BTreeMap::new(),
267 pending_outline: None,
268 outline: Vec::new(),
269 }
270 }
271
272 fn queue_label(&mut self, node: &Node) {
275 if let Some(AttrValue::Str(label)) = node.attributes.get("label") {
276 self.pending_labels.push(label.clone());
277 }
278 }
279
280 fn discard_unbound_labels(&mut self) {
287 self.pending_labels.clear();
288 }
289
290 fn bind_pending_labels(&mut self) {
295 if self.pending_labels.is_empty() {
296 return;
297 }
298 let page = self.current_page.number;
299 for label in self.pending_labels.drain(..) {
300 self.label_pages.entry(label).or_insert(page);
301 }
302 }
303
304 fn bind_pending_outline(&mut self, top_from_top_pt: f32) {
310 let Some(pending) = self.pending_outline.take() else {
311 return;
312 };
313 let page_index = usize::try_from(self.current_page.number.saturating_sub(1)).unwrap_or(0);
317 self.outline.push(OutlineEntry {
318 level: pending.level,
319 title: pending.title,
320 page_index,
321 top_from_top_pt,
322 });
323 }
324
325 fn column_width_pt(&self) -> f32 {
326 self.page.width - self.page.margin - self.current_left_pt
327 }
328
329 fn finish(mut self) -> LayoutResult {
330 if self.page_has_content || self.pages.is_empty() {
335 self.pages.push(self.current_page);
336 }
337 let debug = self.debug.map(|trace| trace.finish(&self.pages, self.page));
338 LayoutResult {
339 graph: PageGraph {
340 pages: self.pages,
341 images: self.image_handles,
342 outline: self.outline,
343 },
344 diagnostics: self.diagnostics,
345 debug,
346 label_pages: self.label_pages,
347 }
348 }
349
350 fn begin_debug_block(&mut self, node: &Node) {
351 if let Some(trace) = &mut self.debug {
352 trace.begin_block(node);
353 }
354 }
355
356 fn end_debug_block(&mut self) {
357 if let Some(trace) = &mut self.debug {
358 trace.end_block();
359 }
360 }
361
362 fn push_text_run(&mut self, run: TextRun, advance_pt: f32) {
363 if let Some(trace) = &mut self.debug {
364 trace.run(self.current_page.runs.len(), &run, advance_pt);
365 }
366 self.current_page.runs.push(run);
367 }
368
369 fn layout_heading(&mut self, document: &Document, section: &Node) {
370 let heading_level = read_level(section).unwrap_or(1).clamp(1, 3);
371 let level = usize::from(heading_level);
372 let size = HEADING_SIZES_PT[level - 1];
373 let space_before = HEADING_SPACE_BEFORE_PT[level - 1];
374 let space_after = HEADING_SPACE_AFTER_PT[level - 1];
375
376 if self.page_has_content {
377 self.cursor_y += space_before;
378 }
379 let bold = self.text.family.bold;
380 let mut words = self.collect_words(document, section, bold, size);
381 let title_text = words
387 .iter()
388 .filter_map(|item| match item {
389 WordItem::Word(w) => Some(w.text.as_str()),
390 WordItem::HardBreak => None,
391 })
392 .collect::<Vec<_>>()
393 .join(" ");
394 if !title_text.is_empty() {
395 let title = match read_str_attr(section, "number") {
396 Some(number) => format!("{number} {title_text}"),
397 None => title_text,
398 };
399 self.pending_outline = Some(PendingOutline {
400 level: heading_level,
401 title,
402 });
403 }
404 if let Some(number) = read_str_attr(section, "number") {
410 let prefix = format!("{number}.");
411 let subruns = shape_with_fallback(bold, self.text.family.fallbacks, size, &prefix);
412 let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
413 words.insert(
414 0,
415 WordItem::Word(Word {
416 text: prefix,
417 actual_text: None,
418 space_before_pt: 0.0,
419 font: bold,
420 size_pt: size,
421 width_pt,
422 subruns,
423 shy_break_offsets: Vec::new(),
424 }),
425 );
426 if let Some(WordItem::Word(first_title_word)) = words.get_mut(1)
427 && first_title_word.space_before_pt <= f32::EPSILON
428 {
429 first_title_word.space_before_pt = text_width(bold, size, " ");
430 }
431 }
432 self.flow_words(&words, BODY_LEADING);
433 self.cursor_y += space_after;
434 }
435
436 fn layout_paragraph(&mut self, document: &Document, paragraph: &Node) {
437 let size = self.text.size_pt;
438 let leading = self.text.leading;
439 let regular = self.text.family.regular;
440 let words = self.collect_words(document, paragraph, regular, size);
441 self.flow_words(&words, leading);
442 self.cursor_y += PARA_SPACE_AFTER_PT;
443 }
444
445 fn layout_raw_block(&mut self, raw: &Node) {
446 let Some(AttrValue::Str(text)) = raw.attributes.get("text") else {
447 return;
448 };
449 let size = self.text.size_pt;
450 let leading = self.text.leading;
451 let font = self.text.family.monospace;
452 let mut emitted = false;
453 for line in text.lines() {
454 if line.is_empty() {
455 if !self.page_has_content {
456 self.cursor_y = self.page.margin + ascent(font, size);
457 self.page_has_content = true;
458 }
459 self.cursor_y = size.mul_add(leading, self.cursor_y);
460 continue;
461 }
462 let expanded_line = expand_tabs(line, RAW_BLOCK_TAB_WIDTH);
463 let subruns = shape_with_fallback(
464 font,
465 self.text.family.fallbacks,
466 size,
467 expanded_line.as_ref(),
468 );
469 let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
470 let actual_text = (expanded_line.as_ref() != line).then(|| line.to_owned());
471 let word = Word {
472 text: expanded_line.into_owned(),
473 actual_text,
474 space_before_pt: 0.0,
475 font,
476 size_pt: size,
477 width_pt,
478 subruns,
479 shy_break_offsets: Vec::new(),
480 };
481 self.flow_words(&[WordItem::Word(word)], leading);
482 emitted = true;
483 }
484 if emitted {
485 self.cursor_y += PARA_SPACE_AFTER_PT;
486 }
487 }
488
489 fn collect_words(
498 &self,
499 document: &Document,
500 parent: &Node,
501 default_font: Font,
502 size: f32,
503 ) -> Vec<WordItem> {
504 let mut out: Vec<WordItem> = Vec::new();
505 let mut current_word: Option<Word> = None;
506 let mut pending_space: Option<PendingSpace> = None;
507 for child_id in &parent.children {
508 let Some(child) = document.get(*child_id) else {
509 continue;
510 };
511 if matches!(child.kind, NodeKind::HardBreak) {
512 Self::flush_pending_word(&mut current_word, &mut out);
513 out.push(WordItem::HardBreak);
514 pending_space = None;
515 continue;
516 }
517 let font = match child.kind {
518 NodeKind::Strong => self.text.family.bold,
519 NodeKind::Emphasis => self.text.family.italic,
520 NodeKind::BoldItalic => self.text.family.bold_italic,
521 NodeKind::Raw => self.text.family.monospace,
522 NodeKind::List | NodeKind::ListItem => continue,
526 _ => default_font,
527 };
528 let raw = match child.attributes.get("text") {
529 Some(AttrValue::Str(s)) => s.as_str(),
530 _ => continue,
531 };
532 self.collect_text_words(
533 raw,
534 font,
535 size,
536 &mut current_word,
537 &mut pending_space,
538 &mut out,
539 );
540 }
541 Self::flush_pending_word(&mut current_word, &mut out);
542 out
543 }
544
545 fn collect_text_words(
546 &self,
547 raw: &str,
548 font: Font,
549 size: f32,
550 current_word: &mut Option<Word>,
551 pending_space: &mut Option<PendingSpace>,
552 out: &mut Vec<WordItem>,
553 ) {
554 let mut word_start: Option<usize> = None;
555 for (idx, ch) in raw.char_indices() {
556 if ch.is_ascii_whitespace() {
557 if let Some(start) = word_start.take() {
558 self.append_word_piece(
559 &raw[start..idx],
560 font,
561 size,
562 current_word,
563 pending_space,
564 out,
565 );
566 }
567 let from_line_break = matches!(ch, '\n' | '\r');
568 let width_pt = text_width(font, size, " ");
569 if let Some(space) = pending_space {
570 space.width_pt = width_pt;
571 space.from_line_break |= from_line_break;
572 } else {
573 *pending_space = Some(PendingSpace {
574 width_pt,
575 from_line_break,
576 });
577 }
578 } else if word_start.is_none() {
579 word_start = Some(idx);
580 }
581 }
582 if let Some(start) = word_start {
583 self.append_word_piece(&raw[start..], font, size, current_word, pending_space, out);
584 }
585 }
586
587 fn append_word_piece(
588 &self,
589 piece: &str,
590 font: Font,
591 size: f32,
592 current_word: &mut Option<Word>,
593 pending_space: &mut Option<PendingSpace>,
594 out: &mut Vec<WordItem>,
595 ) {
596 let piece = nfc_text(piece);
597 let piece = piece.as_ref();
598 let (stripped, shy_offsets) = split_soft_hyphens(piece);
603 if stripped.is_empty() {
604 if pending_space.is_none()
605 && let Some(word) = current_word
606 {
607 let offset = word.text.len();
608 word.shy_break_offsets
609 .extend(shy_offsets.into_iter().map(|idx| offset + idx));
610 }
611 return;
612 }
613 let subruns = shape_with_fallback(font, self.text.family.fallbacks, size, &stripped);
614 let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
615 if let Some(space) = pending_space.take() {
616 if space.from_line_break && starts_with_closing_punctuation(&stripped) {
617 Self::append_to_current_word(
618 current_word,
619 stripped,
620 font,
621 size,
622 width_pt,
623 subruns,
624 shy_offsets,
625 );
626 return;
627 }
628 Self::flush_pending_word(current_word, out);
629 *current_word = Some(Word {
630 text: stripped,
631 actual_text: None,
632 space_before_pt: space.width_pt,
633 font,
634 size_pt: size,
635 width_pt,
636 subruns,
637 shy_break_offsets: shy_offsets,
638 });
639 return;
640 }
641 Self::append_to_current_word(
642 current_word,
643 stripped,
644 font,
645 size,
646 width_pt,
647 subruns,
648 shy_offsets,
649 );
650 }
651
652 fn append_to_current_word(
653 current_word: &mut Option<Word>,
654 text: String,
655 font: Font,
656 size: f32,
657 width_pt: f32,
658 subruns: Vec<WordSubRun>,
659 shy_offsets: Vec<usize>,
660 ) {
661 if let Some(word) = current_word {
662 let offset = word.text.len();
663 word.text.push_str(&text);
664 word.width_pt += width_pt;
665 word.subruns.extend(subruns);
666 word.shy_break_offsets
667 .extend(shy_offsets.into_iter().map(|idx| offset + idx));
668 return;
669 }
670 *current_word = Some(Word {
671 text,
672 actual_text: None,
673 space_before_pt: 0.0,
674 font,
675 size_pt: size,
676 width_pt,
677 subruns,
678 shy_break_offsets: shy_offsets,
679 });
680 }
681
682 fn flush_pending_word(current_word: &mut Option<Word>, out: &mut Vec<WordItem>) {
683 if let Some(word) = current_word.take() {
684 out.push(WordItem::Word(word));
685 }
686 }
687
688 fn flow_words(&mut self, items: &[WordItem], leading: f32) {
695 if items.is_empty() {
696 return;
697 }
698 let line_width = self.column_width_pt();
699 let mut line: Vec<Word> = Vec::new();
700 let mut line_width_used = 0.0_f32;
701 let mut paragraph_emitted_line = false;
714 let mut last_was_hardbreak_flush = false;
715 let mut pending: Option<Word> = None;
720 let mut item_idx = 0;
721
722 loop {
723 let word_owned: Word = if let Some(w) = pending.take() {
724 w
725 } else if item_idx < items.len() {
726 let item = &items[item_idx];
727 item_idx += 1;
728 match item {
729 WordItem::Word(w) => w.clone(),
730 WordItem::HardBreak => {
731 if !line.is_empty() {
732 self.flush_line(&line, leading);
733 line.clear();
734 line_width_used = 0.0;
735 paragraph_emitted_line = true;
736 last_was_hardbreak_flush = true;
737 } else if last_was_hardbreak_flush {
738 self.cursor_y = self.text.size_pt.mul_add(leading, self.cursor_y);
743 } else if paragraph_emitted_line {
744 last_was_hardbreak_flush = true;
752 }
753 continue;
758 }
759 }
760 } else {
761 break;
762 };
763
764 let space_w = if line.is_empty() {
765 0.0
766 } else {
767 word_owned.space_before_pt
768 };
769
770 if line_width_used + space_w + word_owned.width_pt <= line_width {
772 line_width_used += space_w + word_owned.width_pt;
773 line.push(word_owned);
774 continue;
775 }
776
777 if !line.is_empty() && space_w <= f32::EPSILON {
778 line_width_used += word_owned.width_pt;
779 line.push(word_owned);
780 continue;
781 }
782
783 if !line.is_empty()
786 && let Some(ShyBreak { prefix, suffix }) = try_shy_break(
787 &word_owned,
788 line_width - line_width_used - space_w,
789 self.text.family.fallbacks,
790 )
791 {
792 line.push(prefix);
793 self.flush_line(&line, leading);
794 line.clear();
795 line_width_used = 0.0;
796 paragraph_emitted_line = true;
797 last_was_hardbreak_flush = false;
798 pending = Some(suffix);
799 continue;
800 }
801
802 if !line.is_empty() {
806 self.flush_line(&line, leading);
807 line.clear();
808 line_width_used = 0.0;
809 paragraph_emitted_line = true;
810 last_was_hardbreak_flush = false;
811 }
812
813 if word_owned.width_pt > line_width {
817 if let Some(ShyBreak { prefix, suffix }) =
818 try_shy_break(&word_owned, line_width, self.text.family.fallbacks)
819 {
820 line.push(prefix);
821 self.flush_line(&line, leading);
822 line.clear();
823 line_width_used = 0.0;
824 paragraph_emitted_line = true;
825 last_was_hardbreak_flush = false;
826 pending = Some(suffix);
827 continue;
828 }
829 self.flush_oversize_word(&word_owned, leading);
830 paragraph_emitted_line = true;
831 last_was_hardbreak_flush = false;
832 continue;
833 }
834
835 line_width_used = word_owned.width_pt;
837 line.push(word_owned);
838 }
839 if !line.is_empty() {
840 self.flush_line(&line, leading);
841 }
842 }
843
844 fn flush_line(&mut self, line: &[Word], leading: f32) {
848 let marker_size = self
854 .pending_marker
855 .as_ref()
856 .map_or(0.0_f32, |m| m.word.size_pt);
857 let marker_ascent = self.pending_marker.as_ref().map_or(0.0_f32, |m| {
858 m.word
859 .subruns
860 .iter()
861 .map(|sub| ascent(sub.font, m.word.size_pt))
862 .fold(0.0_f32, f32::max)
863 });
864 let max_size = line.iter().map(|w| w.size_pt).fold(marker_size, f32::max);
865 let max_ascent = line
866 .iter()
867 .flat_map(|w| w.subruns.iter().map(|sub| ascent(sub.font, w.size_pt)))
868 .fold(marker_ascent, f32::max);
869
870 if !self.page_has_content {
873 self.cursor_y = self.page.margin + max_ascent;
874 }
875 if self.cursor_y > self.page.height - self.page.margin {
878 self.start_new_page();
879 self.cursor_y = self.page.margin + max_ascent;
880 }
881
882 self.bind_pending_labels();
885 self.bind_pending_outline(self.cursor_y - max_ascent);
889
890 if let Some(marker) = self.pending_marker.take() {
895 let mut marker_x = marker.x_pt;
896 for sub in marker.word.subruns {
897 self.push_text_run(
898 TextRun {
899 x_pt: marker_x,
900 baseline_from_top_pt: self.cursor_y,
901 size_pt: marker.word.size_pt,
902 font: sub.font,
903 text: sub.text,
904 actual_text: None,
905 glyphs: sub.glyphs,
906 },
907 sub.advance_pt,
908 );
909 marker_x += sub.advance_pt;
910 }
911 }
912
913 let mut x = self.current_left_pt;
914 for (i, word) in line.iter().enumerate() {
915 if i > 0 {
916 x += word.space_before_pt;
917 }
918 for sub in &word.subruns {
923 self.push_text_run(
924 TextRun {
925 x_pt: x,
926 baseline_from_top_pt: self.cursor_y,
927 size_pt: word.size_pt,
928 font: sub.font,
929 text: sub.text.clone(),
930 actual_text: word.actual_text.clone(),
931 glyphs: sub.glyphs.clone(),
932 },
933 sub.advance_pt,
934 );
935 x += sub.advance_pt;
936 }
937 }
938 if let Some(trace) = &mut self.debug {
939 trace.line(self.current_page.number, self.cursor_y);
940 }
941 self.page_has_content = true;
942 self.cursor_y = max_size.mul_add(leading, self.cursor_y);
943 }
944
945 fn flush_oversize_word(&mut self, word: &Word, leading: f32) {
950 let line_width = self.column_width_pt();
951 let mut chunk_text = String::with_capacity(word.text.len());
952 let mut chunk_width = 0.0_f32;
953 let mut chunk_subruns = Vec::new();
954 for cluster in word_clusters(word) {
955 if chunk_width + cluster.advance_pt > line_width && !chunk_subruns.is_empty() {
956 self.flush_oversize_chunk(
957 std::mem::take(&mut chunk_text),
958 chunk_width,
959 std::mem::take(&mut chunk_subruns),
960 word,
961 leading,
962 );
963 chunk_width = 0.0;
964 }
965 chunk_text.push_str(&cluster.text);
966 chunk_width += cluster.advance_pt;
967 chunk_subruns.push(cluster);
968 }
969 if !chunk_subruns.is_empty() {
970 self.flush_oversize_chunk(chunk_text, chunk_width, chunk_subruns, word, leading);
971 }
972 }
973
974 fn flush_oversize_chunk(
975 &mut self,
976 text: String,
977 width_pt: f32,
978 subruns: Vec<WordSubRun>,
979 source: &Word,
980 leading: f32,
981 ) {
982 self.flush_line(
983 &[Word {
984 text,
985 actual_text: None,
986 space_before_pt: 0.0,
987 font: source.font,
988 size_pt: source.size_pt,
989 width_pt,
990 subruns,
991 shy_break_offsets: Vec::new(),
992 }],
993 leading,
994 );
995 }
996
997 fn start_new_page(&mut self) {
998 let next_number = self.current_page.number + 1;
999 let finished =
1000 std::mem::replace(&mut self.current_page, blank_page(next_number, self.page));
1001 self.pages.push(finished);
1002 self.cursor_y = self.page.margin;
1003 self.page_has_content = false;
1004 }
1005}
1006
1007fn starts_with_closing_punctuation(text: &str) -> bool {
1008 matches!(
1009 text.chars().next(),
1010 Some('.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '”' | '’' | '»')
1011 )
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 #![allow(
1017 clippy::unwrap_used,
1018 clippy::expect_used,
1019 reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
1020 )]
1021 use std::fmt::Write as _;
1022 use std::path::PathBuf;
1023
1024 use mos_core::{AttrMap, AttrValue, Document, NodeId, NodeKind, NodeSpec, SourceSpan};
1025
1026 use crate::types::BODY_SIZE_PT;
1027
1028 use super::*;
1029
1030 fn alloc_inline(doc: &mut Document, parent: NodeId, kind: NodeKind, text: &str) {
1031 let mut attrs = AttrMap::new();
1032 attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
1033 doc.alloc_child(
1034 parent,
1035 NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
1036 .with_attributes(attrs),
1037 );
1038 }
1039
1040 fn pin_helvetica(doc: &mut Document) {
1045 let mut attrs = AttrMap::new();
1046 attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
1047 attrs.insert(
1048 "set.arg.font".to_owned(),
1049 AttrValue::Str("Helvetica".to_owned()),
1050 );
1051 doc.alloc_child(
1052 doc.root,
1053 NodeSpec::new(
1054 NodeKind::Raw,
1055 SourceSpan::placeholder(PathBuf::from("test.mos")),
1056 )
1057 .with_attributes(attrs),
1058 );
1059 }
1060
1061 fn make_section(doc: &mut Document, level: i64, text: &str) -> NodeId {
1062 let mut attrs = AttrMap::new();
1063 attrs.insert("level".to_owned(), AttrValue::Int(level));
1064 let id = doc.alloc_child(
1065 doc.root,
1066 NodeSpec::new(
1067 NodeKind::Section,
1068 SourceSpan::placeholder(PathBuf::from("test.mos")),
1069 )
1070 .with_attributes(attrs),
1071 );
1072 alloc_inline(doc, id, NodeKind::Text, text);
1073 id
1074 }
1075
1076 fn make_paragraph(doc: &mut Document, text: &str) -> NodeId {
1077 let id = doc.alloc_child(
1078 doc.root,
1079 NodeSpec::new(
1080 NodeKind::Paragraph,
1081 SourceSpan::placeholder(PathBuf::from("test.mos")),
1082 ),
1083 );
1084 alloc_inline(doc, id, NodeKind::Text, text);
1085 id
1086 }
1087
1088 fn make_labelled_paragraph(doc: &mut Document, text: &str, label: &str) -> NodeId {
1089 let mut attrs = AttrMap::new();
1090 attrs.insert("label".to_owned(), AttrValue::Str(label.to_owned()));
1091 let id = doc.alloc_child(
1092 doc.root,
1093 NodeSpec::new(
1094 NodeKind::Paragraph,
1095 SourceSpan::placeholder(PathBuf::from("test.mos")),
1096 )
1097 .with_attributes(attrs),
1098 );
1099 alloc_inline(doc, id, NodeKind::Text, text);
1100 id
1101 }
1102
1103 fn make_raw_block(doc: &mut Document, text: &str) -> NodeId {
1104 let mut attrs = AttrMap::new();
1105 attrs.insert("raw.kind".to_owned(), AttrValue::Str("code".to_owned()));
1106 attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
1107 doc.alloc_child(
1108 doc.root,
1109 NodeSpec::new(
1110 NodeKind::Raw,
1111 SourceSpan::placeholder(PathBuf::from("test.mos")),
1112 )
1113 .with_attributes(attrs),
1114 )
1115 }
1116
1117 fn run_text<'a>(runs: &'a [TextRun], text: &str) -> &'a TextRun {
1118 runs.iter()
1119 .find(|run| run.text == text)
1120 .expect("run text not found")
1121 }
1122
1123 fn assert_runs_touch(left: &TextRun, right: &TextRun) {
1124 assert!(
1125 (left.baseline_from_top_pt - right.baseline_from_top_pt).abs() < 0.01,
1126 "runs are on different baselines: left={left:?}, right={right:?}"
1127 );
1128 let expected_x = left.x_pt + text_width(left.font, left.size_pt, &left.text);
1129 assert!(
1130 (right.x_pt - expected_x).abs() < 0.01,
1131 "expected {:?} to touch {:?}: expected x {expected_x:.3}, got {:.3}",
1132 right.text,
1133 left.text,
1134 right.x_pt
1135 );
1136 }
1137
1138 #[test]
1139 fn heading_then_paragraph_emits_runs_in_order() {
1140 let mut doc = Document::new(PathBuf::from("test.mos"));
1141 pin_helvetica(&mut doc);
1142 make_section(&mut doc, 1, "Hello");
1143 make_paragraph(&mut doc, "body");
1144 let result = LayoutEngine::new().layout(&doc);
1145 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1146 assert_eq!(result.graph.pages.len(), 1);
1147 let runs = &result.graph.pages[0].runs;
1148 assert!(runs.len() >= 2, "expected at least 2 runs, got {runs:?}");
1149 assert!(matches!(
1151 runs[0].font,
1152 Font::Base14(Base14Font::HelveticaBold)
1153 ));
1154 assert_eq!(runs[0].text, "Hello");
1155 let body_run = runs.iter().find(|r| r.text == "body").expect("body run");
1156 assert!(matches!(body_run.font, Font::Base14(Base14Font::Helvetica)));
1157 assert!(body_run.baseline_from_top_pt > runs[0].baseline_from_top_pt);
1158 }
1159
1160 #[test]
1161 fn long_paragraph_paginates() {
1162 let mut doc = Document::new(PathBuf::from("test.mos"));
1165 let mut text = String::new();
1169 for i in 0..1500 {
1170 let _ = write!(text, "word{i} ");
1171 }
1172 make_paragraph(&mut doc, text.trim());
1173 let result = LayoutEngine::new().layout(&doc);
1174 assert!(
1175 result.graph.pages.len() >= 2,
1176 "expected pagination, got {} page(s)",
1177 result.graph.pages.len()
1178 );
1179 }
1180
1181 #[test]
1182 fn page_boundary_signatures_are_stable_and_change_with_pagination() {
1183 fn lay_out(word_count: usize) -> LayoutResult {
1184 let mut doc = Document::new(PathBuf::from("test.mos"));
1185 let mut text = String::new();
1186 for i in 0..word_count {
1187 let _ = write!(text, "word{i} ");
1188 }
1189 make_paragraph(&mut doc, text.trim());
1190 LayoutEngine::new().layout(&doc)
1191 }
1192
1193 let first = lay_out(1500);
1196 let again = lay_out(1500);
1197 assert!(first.graph.pages.len() >= 2, "expected a multi-page layout");
1198 assert_eq!(
1199 first.page_boundary_signatures(),
1200 again.page_boundary_signatures(),
1201 );
1202 assert_eq!(
1203 first
1204 .page_boundary_signatures()
1205 .first_divergence(&again.page_boundary_signatures()),
1206 None,
1207 );
1208
1209 let longer = lay_out(1540);
1212 let base_sig = first.page_boundary_signatures();
1213 let longer_sig = longer.page_boundary_signatures();
1214 assert_ne!(base_sig, longer_sig);
1215 assert!(longer_sig.pages().len() >= base_sig.pages().len());
1216 assert!(base_sig.first_divergence(&longer_sig).is_some());
1217 }
1218
1219 #[test]
1220 fn label_pages_maps_a_first_block_to_page_one() {
1221 let mut doc = Document::new(PathBuf::from("test.mos"));
1222 make_labelled_paragraph(&mut doc, "Introduction", "intro");
1223 let result = LayoutEngine::new().layout(&doc);
1224 assert_eq!(result.label_pages.get("intro").copied(), Some(1));
1225 }
1226
1227 #[test]
1228 fn label_pages_records_the_start_page_after_a_break() {
1229 let mut doc = Document::new(PathBuf::from("test.mos"));
1233 let mut filler = String::new();
1234 for i in 0..1500 {
1235 let _ = write!(filler, "word{i} ");
1236 }
1237 make_paragraph(&mut doc, filler.trim());
1238 make_labelled_paragraph(&mut doc, "ZZUNIQUE", "tail");
1239
1240 let result = LayoutEngine::new().layout(&doc);
1241 assert!(
1242 result.graph.pages.len() >= 2,
1243 "expected a multi-page layout"
1244 );
1245
1246 let recorded = result.label_pages.get("tail").copied();
1247 let actual = result
1250 .graph
1251 .pages
1252 .iter()
1253 .find(|page| page.runs.iter().any(|run| run.text == "ZZUNIQUE"))
1254 .map(|page| page.number);
1255 assert_eq!(recorded, actual);
1256 assert!(recorded.is_some_and(|page| page >= 2), "{recorded:?}");
1257 }
1258
1259 #[test]
1260 fn label_pages_omits_unlabelled_blocks_and_keeps_first_occurrence() {
1261 let mut doc = Document::new(PathBuf::from("test.mos"));
1262 make_paragraph(&mut doc, "unlabelled");
1263 make_labelled_paragraph(&mut doc, "first", "dup");
1264 make_labelled_paragraph(&mut doc, "second", "dup");
1265 let result = LayoutEngine::new().layout(&doc);
1266 assert_eq!(result.label_pages.len(), 1);
1268 assert_eq!(result.label_pages.get("dup").copied(), Some(1));
1269 }
1270
1271 #[test]
1272 fn label_pages_omits_a_labelled_block_that_emits_no_content() {
1273 let mut doc = Document::new(PathBuf::from("test.mos"));
1274 make_labelled_paragraph(&mut doc, "", "ghost");
1276 let mut table_attrs = AttrMap::new();
1278 table_attrs.insert("label".to_owned(), AttrValue::Str("phantom".to_owned()));
1279 doc.alloc_child(
1280 doc.root,
1281 NodeSpec::new(
1282 NodeKind::Table,
1283 SourceSpan::placeholder(PathBuf::from("test.mos")),
1284 )
1285 .with_attributes(table_attrs),
1286 );
1287 make_labelled_paragraph(&mut doc, "real text", "real");
1289
1290 let result = LayoutEngine::new().layout(&doc);
1291 assert!(!result.label_pages.contains_key("ghost"));
1294 assert!(!result.label_pages.contains_key("phantom"));
1295 assert_eq!(result.label_pages.get("real").copied(), Some(1));
1296 assert_eq!(result.label_pages.len(), 1);
1297 }
1298
1299 fn make_numbered_section(doc: &mut Document, level: i64, number: &str, text: &str) -> NodeId {
1300 let mut attrs = AttrMap::new();
1301 attrs.insert("level".to_owned(), AttrValue::Int(level));
1302 attrs.insert("number".to_owned(), AttrValue::Str(number.to_owned()));
1303 let id = doc.alloc_child(
1304 doc.root,
1305 NodeSpec::new(
1306 NodeKind::Section,
1307 SourceSpan::placeholder(PathBuf::from("test.mos")),
1308 )
1309 .with_attributes(attrs),
1310 );
1311 alloc_inline(doc, id, NodeKind::Text, text);
1312 id
1313 }
1314
1315 #[test]
1316 fn outline_captures_headings_in_document_order() {
1317 let mut doc = Document::new(PathBuf::from("test.mos"));
1318 make_numbered_section(&mut doc, 1, "1", "Alpha");
1319 make_numbered_section(&mut doc, 2, "1.1", "Beta");
1320 make_numbered_section(&mut doc, 3, "1.1.1", "Gamma");
1321 let result = LayoutEngine::new().layout(&doc);
1322 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1323
1324 let outline = &result.graph.outline;
1325 assert_eq!(outline.len(), 3, "expected 3 entries, got {outline:?}");
1326 assert_eq!(
1327 outline.iter().map(|e| e.level).collect::<Vec<_>>(),
1328 vec![1, 2, 3],
1329 );
1330 assert_eq!(
1331 outline.iter().map(|e| e.title.as_str()).collect::<Vec<_>>(),
1332 vec!["1 Alpha", "1.1 Beta", "1.1.1 Gamma"],
1333 );
1334 for pair in outline.windows(2) {
1337 assert!(
1338 pair[1].page_index >= pair[0].page_index,
1339 "page_index went backwards: {outline:?}"
1340 );
1341 assert!(
1342 pair[1].top_from_top_pt > pair[0].top_from_top_pt,
1343 "positions not ascending: {outline:?}"
1344 );
1345 }
1346 }
1347
1348 #[test]
1349 fn outline_title_includes_section_number() {
1350 let mut doc = Document::new(PathBuf::from("test.mos"));
1351 make_numbered_section(&mut doc, 1, "1", "Foo");
1352 let result = LayoutEngine::new().layout(&doc);
1353 let outline = &result.graph.outline;
1354 assert_eq!(outline.len(), 1);
1355 assert_eq!(outline[0].title, "1 Foo");
1356 assert!(
1359 !outline[0].title.ends_with('.'),
1360 "bookmark title must not carry the render-only trailing dot"
1361 );
1362 }
1363
1364 #[test]
1365 fn heading_across_page_break_binds_start_page() {
1366 let mut doc = Document::new(PathBuf::from("test.mos"));
1370 let mut filler = String::new();
1371 for i in 0..1500 {
1372 let _ = write!(filler, "word{i} ");
1373 }
1374 make_paragraph(&mut doc, filler.trim());
1375 make_numbered_section(&mut doc, 1, "2", "ZZLATER");
1376
1377 let result = LayoutEngine::new().layout(&doc);
1378 assert!(
1379 result.graph.pages.len() >= 2,
1380 "expected a multi-page layout"
1381 );
1382
1383 let outline = &result.graph.outline;
1384 assert_eq!(outline.len(), 1, "expected one heading entry");
1385 let landing = result
1388 .graph
1389 .pages
1390 .iter()
1391 .find(|page| page.runs.iter().any(|run| run.text == "ZZLATER"))
1392 .map(|page| page.number)
1393 .expect("heading run must exist on some page");
1394 assert_eq!(
1395 outline[0].page_index,
1396 usize::try_from(landing - 1).unwrap_or(0)
1397 );
1398 assert!(
1399 outline[0].page_index >= 1,
1400 "heading after a full page must not bind to page 1"
1401 );
1402 }
1403
1404 #[test]
1405 fn document_without_headings_has_empty_outline() {
1406 let mut doc = Document::new(PathBuf::from("test.mos"));
1407 make_paragraph(&mut doc, "just a paragraph");
1408 make_paragraph(&mut doc, "another one");
1409 let result = LayoutEngine::new().layout(&doc);
1410 assert!(
1411 result.graph.outline.is_empty(),
1412 "heading-free doc must have no outline: {:?}",
1413 result.graph.outline
1414 );
1415 }
1416
1417 #[test]
1418 fn empty_heading_produces_no_outline_entry() {
1419 let mut doc = Document::new(PathBuf::from("test.mos"));
1420 make_section(&mut doc, 1, "");
1422 make_paragraph(&mut doc, "body");
1425 let result = LayoutEngine::new().layout(&doc);
1426 assert!(
1427 result.graph.outline.is_empty(),
1428 "empty heading must not create an outline entry: {:?}",
1429 result.graph.outline
1430 );
1431 }
1432
1433 #[test]
1434 fn emphasis_run_uses_oblique() {
1435 let mut doc = Document::new(PathBuf::from("test.mos"));
1436 pin_helvetica(&mut doc);
1437 let para = make_paragraph(&mut doc, "before");
1438 alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1439 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1440 let result = LayoutEngine::new().layout(&doc);
1441 let runs = &result.graph.pages[0].runs;
1442 let italic = runs
1443 .iter()
1444 .find(|r| r.text == "italic")
1445 .expect("italic run");
1446 assert!(matches!(
1447 italic.font,
1448 Font::Base14(Base14Font::HelveticaOblique)
1449 ));
1450 }
1451
1452 #[test]
1453 fn bold_italic_run_uses_bold_oblique() {
1454 let mut doc = Document::new(PathBuf::from("test.mos"));
1455 pin_helvetica(&mut doc);
1456 let para = make_paragraph(&mut doc, "before");
1457 alloc_inline(&mut doc, para, NodeKind::BoldItalic, "both");
1458 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1459 let result = LayoutEngine::new().layout(&doc);
1460 let runs = &result.graph.pages[0].runs;
1461 let both = runs
1462 .iter()
1463 .find(|r| r.text == "both")
1464 .expect("bold-italic run");
1465 assert!(matches!(
1466 both.font,
1467 Font::Base14(Base14Font::HelveticaBoldOblique)
1468 ));
1469 }
1470
1471 #[test]
1472 fn styled_and_code_punctuation_do_not_get_synthetic_glue() {
1473 let mut doc = Document::new(PathBuf::from("test.mos"));
1474 pin_helvetica(&mut doc);
1475 let para = make_empty_paragraph(&mut doc);
1476 alloc_inline(&mut doc, para, NodeKind::Text, "See ");
1477 alloc_inline(&mut doc, para, NodeKind::Strong, "bold");
1478 alloc_inline(&mut doc, para, NodeKind::Text, ". ");
1479 alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1480 alloc_inline(&mut doc, para, NodeKind::Text, ", ");
1481 alloc_inline(&mut doc, para, NodeKind::Raw, "code");
1482 alloc_inline(&mut doc, para, NodeKind::Text, "?");
1483
1484 let result = LayoutEngine::new().layout(&doc);
1485 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1486 let runs = &result.graph.pages[0].runs;
1487 assert_runs_touch(run_text(runs, "bold"), run_text(runs, "."));
1488 assert_runs_touch(run_text(runs, "italic"), run_text(runs, ","));
1489 assert_runs_touch(run_text(runs, "code"), run_text(runs, "?"));
1490 }
1491
1492 #[test]
1493 fn references_citations_and_brackets_do_not_get_synthetic_glue() {
1494 let mut doc = Document::new(PathBuf::from("test.mos"));
1495 pin_helvetica(&mut doc);
1496 let para = make_empty_paragraph(&mut doc);
1497 alloc_inline(&mut doc, para, NodeKind::Text, "See ");
1498 alloc_inline(&mut doc, para, NodeKind::Reference, "1.2");
1499 alloc_inline(&mut doc, para, NodeKind::Text, ", ");
1500 alloc_inline(&mut doc, para, NodeKind::Citation, "[1]");
1501 alloc_inline(&mut doc, para, NodeKind::Text, ". (");
1502 alloc_inline(&mut doc, para, NodeKind::Strong, "bold");
1503 alloc_inline(&mut doc, para, NodeKind::Text, ") [");
1504 alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1505 alloc_inline(&mut doc, para, NodeKind::Text, "]");
1506
1507 let result = LayoutEngine::new().layout(&doc);
1508 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1509 let runs = &result.graph.pages[0].runs;
1510 assert_runs_touch(run_text(runs, "1.2"), run_text(runs, ","));
1511 assert_runs_touch(run_text(runs, "[1]"), run_text(runs, "."));
1512 assert_runs_touch(run_text(runs, "("), run_text(runs, "bold"));
1513 assert_runs_touch(run_text(runs, "bold"), run_text(runs, ")"));
1514 assert_runs_touch(run_text(runs, "["), run_text(runs, "italic"));
1515 assert_runs_touch(run_text(runs, "italic"), run_text(runs, "]"));
1516 }
1517
1518 #[test]
1519 fn leading_and_trailing_ascii_whitespace_do_not_emit_empty_runs() {
1520 let mut doc = Document::new(PathBuf::from("test.mos"));
1521 pin_helvetica(&mut doc);
1522 make_paragraph(&mut doc, " \n\t foo \n");
1523
1524 let result = LayoutEngine::new().layout(&doc);
1525 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1526 let runs = &result.graph.pages[0].runs;
1527 assert_eq!(
1528 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
1529 vec!["foo"],
1530 "got {runs:?}"
1531 );
1532 }
1533
1534 #[test]
1535 fn multiple_ascii_whitespace_runs_collapse_to_one_glue() {
1536 let mut doc = Document::new(PathBuf::from("test.mos"));
1537 pin_helvetica(&mut doc);
1538 make_paragraph(&mut doc, "foo \n\t bar");
1539
1540 let result = LayoutEngine::new().layout(&doc);
1541 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1542 let runs = &result.graph.pages[0].runs;
1543 let foo = run_text(runs, "foo");
1544 let bar = run_text(runs, "bar");
1545 let foo_w = text_width(foo.font, foo.size_pt, "foo");
1546 let space_w = text_width(foo.font, foo.size_pt, " ");
1547 let gap = bar.x_pt - (foo.x_pt + foo_w);
1548 assert!(
1549 (gap - space_w).abs() < 0.01,
1550 "expected one collapsed space ({space_w:.3}pt), got {gap:.3}pt"
1551 );
1552 }
1553
1554 #[test]
1555 fn punctuation_without_source_space_does_not_wrap_alone() {
1556 let mut doc = Document::new(PathBuf::from("test.mos"));
1557 pin_helvetica(&mut doc);
1558 let font = Font::Base14(Base14Font::Helvetica);
1559 let bold = Font::Base14(Base14Font::HelveticaBold);
1560 let target =
1561 text_width(bold, BODY_SIZE_PT, "word") + text_width(font, BODY_SIZE_PT, ".") + 0.5;
1562 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
1563 let para = make_empty_paragraph(&mut doc);
1564 alloc_inline(&mut doc, para, NodeKind::Text, "lead ");
1565 alloc_inline(&mut doc, para, NodeKind::Strong, "word");
1566 alloc_inline(&mut doc, para, NodeKind::Text, ".");
1567
1568 let result = LayoutEngine::new().layout(&doc);
1569 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1570 let runs = &result.graph.pages[0].runs;
1571 let lead = run_text(runs, "lead");
1572 let word = run_text(runs, "word");
1573 let dot = run_text(runs, ".");
1574 assert!(word.baseline_from_top_pt > lead.baseline_from_top_pt);
1575 assert_runs_touch(word, dot);
1576 }
1577
1578 #[test]
1579 fn source_line_wrap_before_closing_punctuation_does_not_create_glue() {
1580 let mut doc = Document::new(PathBuf::from("test.mos"));
1581 let para = make_empty_paragraph(&mut doc);
1582 alloc_inline(&mut doc, para, NodeKind::Strong, "κόσμε");
1583 alloc_inline(&mut doc, para, NodeKind::Text, "\n. None");
1584
1585 let result = LayoutEngine::new().layout(&doc);
1586 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1587 let runs = &result.graph.pages[0].runs;
1588 assert_runs_touch(run_text(runs, "κόσμε"), run_text(runs, "."));
1589 }
1590
1591 #[test]
1592 fn hard_break_after_pending_source_space_drops_that_space() {
1593 let mut doc = Document::new(PathBuf::from("test.mos"));
1594 pin_helvetica(&mut doc);
1595 let para = make_empty_paragraph(&mut doc);
1596 alloc_inline(&mut doc, para, NodeKind::Text, "foo ");
1597 alloc_hardbreak(&mut doc, para);
1598 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
1599
1600 let result = LayoutEngine::new().layout(&doc);
1601 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1602 let runs = &result.graph.pages[0].runs;
1603 let foo = run_text(runs, "foo");
1604 let bar = run_text(runs, "bar");
1605 assert!((foo.x_pt - bar.x_pt).abs() < 0.01, "got {runs:?}");
1606 assert!(bar.baseline_from_top_pt > foo.baseline_from_top_pt);
1607 }
1608
1609 #[test]
1610 fn runs_stay_within_horizontal_margins() {
1611 let mut doc = Document::new(PathBuf::from("test.mos"));
1612 make_paragraph(
1613 &mut doc,
1614 "the quick brown fox jumps over the lazy dog the quick brown fox",
1615 );
1616 let result = LayoutEngine::new().layout(&doc);
1617 let runs = &result.graph.pages[0].runs;
1618 assert!(!runs.is_empty());
1619 let right = A4_WIDTH_PT - MARGIN_PT;
1620 for run in runs {
1621 assert!(run.x_pt >= MARGIN_PT - 1e-6, "x={}", run.x_pt);
1622 let end = run.x_pt + text_width(run.font, run.size_pt, &run.text);
1623 assert!(end <= right + 1e-3, "end={end} right={right}");
1624 }
1625 }
1626
1627 #[test]
1628 fn cyrillic_flows_through_embedded_default_without_substitution() {
1629 let mut doc = Document::new(PathBuf::from("test.mos"));
1634 make_paragraph(&mut doc, "Привет");
1635 let result = LayoutEngine::new().layout(&doc);
1636 assert!(
1637 result.diagnostics.is_empty(),
1638 "expected no diagnostics, got {:?}",
1639 result.diagnostics
1640 );
1641 let runs = &result.graph.pages[0].runs;
1642 let cyr = runs.iter().find(|r| r.text == "Привет").expect("cyr run");
1643 assert!(matches!(cyr.font, Font::Embedded(_)));
1644 assert!(!cyr.glyphs.is_empty(), "expected shaped glyphs");
1645 }
1646
1647 #[test]
1648 fn decomposed_text_is_normalized_before_shaping() {
1649 let mut doc = Document::new(PathBuf::from("test.mos"));
1650 make_paragraph(&mut doc, "S\u{0326}");
1651
1652 let result = LayoutEngine::new().layout(&doc);
1653
1654 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1655 let run = result.graph.pages[0]
1656 .runs
1657 .iter()
1658 .find(|r| r.text == "\u{0218}")
1659 .expect("normalized run");
1660 assert!(matches!(run.font, Font::Embedded(_)));
1661 assert!(!run.glyphs.is_empty(), "expected shaped glyphs");
1662 }
1663
1664 #[test]
1665 fn extended_latin_passes_through_without_warning() {
1666 let mut doc = Document::new(PathBuf::from("test.mos"));
1671 make_paragraph(&mut doc, "Łódź — Příliš");
1672 let result = LayoutEngine::new().layout(&doc);
1673 assert!(
1674 result.diagnostics.is_empty(),
1675 "expected no diagnostics, got {:?}",
1676 result.diagnostics
1677 );
1678 let text: String = result.graph.pages[0]
1679 .runs
1680 .iter()
1681 .map(|r| r.text.as_str())
1682 .collect::<Vec<_>>()
1683 .join(" ");
1684 assert!(text.contains("Łódź"), "got {text}");
1685 assert!(text.contains("Příliš"), "got {text}");
1686 }
1687
1688 #[test]
1689 fn cjk_and_emoji_flow_through_without_diagnostics() {
1690 let mut doc = Document::new(PathBuf::from("test.mos"));
1696 make_paragraph(&mut doc, "日本語 🦀");
1697 let result = LayoutEngine::new().layout(&doc);
1698 assert!(
1699 result.diagnostics.is_empty(),
1700 "uncovered glyphs should flow through without a diagnostic, got {:?}",
1701 result.diagnostics
1702 );
1703 }
1704
1705 #[test]
1706 fn winansi_chars_pass_through_without_warning() {
1707 let mut doc = Document::new(PathBuf::from("test.mos"));
1710 make_paragraph(&mut doc, "café §1 Straße");
1711 let result = LayoutEngine::new().layout(&doc);
1712 assert!(
1713 result.diagnostics.is_empty(),
1714 "expected no diagnostics, got {:?}",
1715 result.diagnostics
1716 );
1717 let text: String = result.graph.pages[0]
1718 .runs
1719 .iter()
1720 .map(|r| r.text.as_str())
1721 .collect::<Vec<_>>()
1722 .join(" ");
1723 assert!(text.contains("café"), "got {text}");
1724 assert!(text.contains("Straße"), "got {text}");
1725 }
1726
1727 #[test]
1728 fn empty_document_emits_one_blank_page() {
1729 let doc = Document::new(PathBuf::from("test.mos"));
1730 let result = LayoutEngine::new().layout(&doc);
1731 assert_eq!(result.graph.pages.len(), 1);
1732 assert!(result.graph.pages[0].runs.is_empty());
1733 }
1734
1735 #[test]
1736 fn raw_inline_uses_courier() {
1737 let mut doc = Document::new(PathBuf::from("test.mos"));
1738 pin_helvetica(&mut doc);
1739 let para = make_paragraph(&mut doc, "before");
1740 alloc_inline(&mut doc, para, NodeKind::Raw, "code");
1741 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1742 let result = LayoutEngine::new().layout(&doc);
1743 let runs = &result.graph.pages[0].runs;
1744 let code_run = runs.iter().find(|r| r.text == "code").expect("code run");
1745 assert!(matches!(code_run.font, Font::Base14(Base14Font::Courier)));
1746 assert!(matches!(
1749 runs.iter().find(|r| r.text == "before").unwrap().font,
1750 Font::Base14(Base14Font::Helvetica)
1751 ));
1752 }
1753
1754 #[test]
1755 fn raw_block_tabs_render_as_spaces() {
1756 let mut doc = Document::new(PathBuf::from("test.mos"));
1757 make_raw_block(&mut doc, "\tprintln(\"hello\");");
1758
1759 let result = LayoutEngine::new().layout(&doc);
1760
1761 let rendered = result.graph.pages[0]
1762 .runs
1763 .iter()
1764 .map(|run| run.text.as_str())
1765 .collect::<String>();
1766 assert!(
1767 !rendered.contains('\t'),
1768 "raw block tabs should be expanded before shaping: {rendered:?}"
1769 );
1770 assert!(
1771 rendered.contains(" println"),
1772 "expected four-space tab expansion, got {rendered:?}"
1773 );
1774 assert!(
1775 result.graph.pages[0]
1776 .runs
1777 .iter()
1778 .any(|run| run.actual_text.as_deref() == Some("\tprintln(\"hello\");")),
1779 "raw block tabs should retain their original text for extraction"
1780 );
1781 }
1782
1783 #[test]
1784 fn raw_block_leading_blank_line_preserves_spacing() {
1785 let mut doc = Document::new(PathBuf::from("test.mos"));
1786 make_raw_block(&mut doc, "\ncode");
1787
1788 let result = LayoutEngine::new().layout(&doc);
1789
1790 let first_run = result.graph.pages[0]
1791 .runs
1792 .first()
1793 .expect("raw block should emit text after the leading blank");
1794 let expected_baseline = BODY_SIZE_PT.mul_add(
1795 BODY_LEADING,
1796 MARGIN_PT + ascent(FontFamily::noto_sans().monospace, BODY_SIZE_PT),
1797 );
1798 assert!(
1799 (first_run.baseline_from_top_pt - expected_baseline).abs() < 0.01,
1800 "baseline {}, expected {expected_baseline}",
1801 first_run.baseline_from_top_pt
1802 );
1803 }
1804
1805 #[test]
1806 fn heading_levels_pick_distinct_sizes() {
1807 let mut doc = Document::new(PathBuf::from("test.mos"));
1808 make_section(&mut doc, 1, "H1");
1809 make_section(&mut doc, 2, "H2");
1810 make_section(&mut doc, 3, "H3");
1811 let result = LayoutEngine::new().layout(&doc);
1812 let runs = &result.graph.pages[0].runs;
1813 let h1 = runs.iter().find(|r| r.text == "H1").expect("H1 run");
1814 let h2 = runs.iter().find(|r| r.text == "H2").expect("H2 run");
1815 let h3 = runs.iter().find(|r| r.text == "H3").expect("H3 run");
1816 assert!((h1.size_pt - HEADING_SIZES_PT[0]).abs() < f32::EPSILON);
1817 assert!((h2.size_pt - HEADING_SIZES_PT[1]).abs() < f32::EPSILON);
1818 assert!((h3.size_pt - HEADING_SIZES_PT[2]).abs() < f32::EPSILON);
1819 assert!(h1.size_pt > h2.size_pt);
1821 assert!(h2.size_pt > h3.size_pt);
1822 assert!(h1.baseline_from_top_pt < h2.baseline_from_top_pt);
1824 assert!(h2.baseline_from_top_pt < h3.baseline_from_top_pt);
1825 }
1826
1827 #[test]
1828 fn heading_after_long_paragraph_paginates_correctly() {
1829 let mut doc = Document::new(PathBuf::from("test.mos"));
1834 pin_helvetica(&mut doc);
1835 let mut text = String::new();
1836 for i in 0..1500 {
1837 let _ = write!(text, "word{i} ");
1838 }
1839 make_paragraph(&mut doc, text.trim());
1840 make_section(&mut doc, 1, "After");
1841 let result = LayoutEngine::new().layout(&doc);
1842 assert!(
1843 result.graph.pages.len() >= 2,
1844 "expected pagination, got {} page(s)",
1845 result.graph.pages.len()
1846 );
1847 let mut heading_page: Option<u32> = None;
1849 let mut first_word_page: Option<u32> = None;
1850 for page in &result.graph.pages {
1851 for run in &page.runs {
1852 if run.text == "After"
1853 && matches!(run.font, Font::Base14(Base14Font::HelveticaBold))
1854 {
1855 heading_page = Some(page.number);
1856 }
1857 if run.text == "word0" && first_word_page.is_none() {
1858 first_word_page = Some(page.number);
1859 }
1860 }
1861 }
1862 let heading_page = heading_page.expect("heading run not emitted");
1863 let first_word_page = first_word_page.expect("first paragraph word not emitted");
1864 assert!(
1865 heading_page > first_word_page,
1866 "heading on page {heading_page}, first paragraph word on page {first_word_page}"
1867 );
1868 }
1869
1870 #[test]
1871 fn heading_with_number_attribute_emits_prefix_run() {
1872 let mut doc = Document::new(PathBuf::from("test.mos"));
1876 pin_helvetica(&mut doc);
1877 let mut attrs = AttrMap::new();
1878 attrs.insert("level".to_owned(), AttrValue::Int(2));
1879 attrs.insert("number".to_owned(), AttrValue::Str("2.1".to_owned()));
1880 let section = doc.alloc_child(
1881 doc.root,
1882 NodeSpec::new(
1883 NodeKind::Section,
1884 SourceSpan::placeholder(PathBuf::from("test.mos")),
1885 )
1886 .with_attributes(attrs),
1887 );
1888 alloc_inline(&mut doc, section, NodeKind::Text, "Background");
1889 let result = LayoutEngine::new().layout(&doc);
1890 let runs = &result.graph.pages[0].runs;
1891 assert!(matches!(
1892 runs[0].font,
1893 Font::Base14(Base14Font::HelveticaBold)
1894 ));
1895 assert_eq!(runs[0].text, "2.1.");
1896 assert!(runs.iter().any(|r| r.text == "Background"));
1897 let title = runs.iter().find(|r| r.text == "Background").unwrap();
1900 assert!((runs[0].baseline_from_top_pt - title.baseline_from_top_pt).abs() < 1e-3);
1901 }
1902
1903 #[test]
1904 fn numbered_heading_prefix_and_title_have_one_space_gap() {
1905 let mut doc = Document::new(PathBuf::from("test.mos"));
1906 pin_helvetica(&mut doc);
1907 let mut attrs = AttrMap::new();
1908 attrs.insert("level".to_owned(), AttrValue::Int(2));
1909 attrs.insert("number".to_owned(), AttrValue::Str("2.1".to_owned()));
1910 let section = doc.alloc_child(
1911 doc.root,
1912 NodeSpec::new(
1913 NodeKind::Section,
1914 SourceSpan::placeholder(PathBuf::from("test.mos")),
1915 )
1916 .with_attributes(attrs),
1917 );
1918 alloc_inline(&mut doc, section, NodeKind::Text, "Background");
1919
1920 let result = LayoutEngine::new().layout(&doc);
1921 let runs = &result.graph.pages[0].runs;
1922 let prefix = run_text(runs, "2.1.");
1923 let title = run_text(runs, "Background");
1924 let prefix_w = text_width(prefix.font, prefix.size_pt, &prefix.text);
1925 let expected_gap = text_width(prefix.font, prefix.size_pt, " ");
1926 let gap = title.x_pt - (prefix.x_pt + prefix_w);
1927 assert!(
1928 (gap - expected_gap).abs() < 0.01,
1929 "expected one heading space ({expected_gap:.3}pt), got {gap:.3}pt"
1930 );
1931 }
1932
1933 #[test]
1934 fn reference_node_renders_resolved_text() {
1935 let mut doc = Document::new(PathBuf::from("test.mos"));
1939 pin_helvetica(&mut doc);
1940 let para = make_paragraph(&mut doc, "see");
1941 let mut attrs = AttrMap::new();
1942 attrs.insert("label".to_owned(), AttrValue::Str("intro".to_owned()));
1943 attrs.insert("text".to_owned(), AttrValue::Str("1.2".to_owned()));
1944 doc.alloc_child(
1945 para,
1946 NodeSpec::new(
1947 NodeKind::Reference,
1948 SourceSpan::placeholder(PathBuf::from("test.mos")),
1949 )
1950 .with_attributes(attrs),
1951 );
1952 let result = LayoutEngine::new().layout(&doc);
1953 let runs = &result.graph.pages[0].runs;
1954 let reference = runs.iter().find(|r| r.text == "1.2").expect("ref run");
1955 assert!(matches!(
1956 reference.font,
1957 Font::Base14(Base14Font::Helvetica)
1958 ));
1959 }
1960
1961 fn alloc_hardbreak(doc: &mut Document, parent: NodeId) {
1964 doc.alloc_child(
1967 parent,
1968 NodeSpec::new(
1969 NodeKind::HardBreak,
1970 SourceSpan::placeholder(PathBuf::from("test.mos")),
1971 ),
1972 );
1973 }
1974
1975 fn make_empty_paragraph(doc: &mut Document) -> NodeId {
1976 doc.alloc_child(
1977 doc.root,
1978 NodeSpec::new(
1979 NodeKind::Paragraph,
1980 SourceSpan::placeholder(PathBuf::from("test.mos")),
1981 ),
1982 )
1983 }
1984
1985 #[test]
1986 fn nbsp_keeps_two_words_in_a_single_run() {
1987 let mut doc = Document::new(PathBuf::from("test.mos"));
1991 pin_helvetica(&mut doc);
1992 make_paragraph(&mut doc, "Mr.\u{A0}Smith");
1993 let result = LayoutEngine::new().layout(&doc);
1994 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1995 let runs = &result.graph.pages[0].runs;
1996 assert_eq!(runs.len(), 1, "expected one TextRun, got {runs:?}");
1997 assert_eq!(runs[0].text, "Mr.\u{A0}Smith");
1998 }
1999
2000 #[test]
2001 fn hard_break_advances_one_line_not_paragraph_spacing() {
2002 let mut doc = Document::new(PathBuf::from("test.mos"));
2003 pin_helvetica(&mut doc);
2004 let para = make_empty_paragraph(&mut doc);
2005 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2006 alloc_hardbreak(&mut doc, para);
2007 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2008
2009 let result = LayoutEngine::new().layout(&doc);
2010 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2011 let runs = &result.graph.pages[0].runs;
2012 let foo = runs.iter().find(|r| r.text == "foo").expect("foo run");
2013 let bar = runs.iter().find(|r| r.text == "bar").expect("bar run");
2014 let delta = bar.baseline_from_top_pt - foo.baseline_from_top_pt;
2015 let expected = BODY_SIZE_PT * BODY_LEADING;
2019 assert!(
2020 (delta - expected).abs() < 0.01,
2021 "expected inter-line delta {expected}, got {delta} (foo={}, bar={})",
2022 foo.baseline_from_top_pt,
2023 bar.baseline_from_top_pt
2024 );
2025 }
2026
2027 #[test]
2028 fn two_hard_breaks_produce_a_blank_line() {
2029 let mut doc = Document::new(PathBuf::from("test.mos"));
2030 pin_helvetica(&mut doc);
2031 let para = make_empty_paragraph(&mut doc);
2032 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2033 alloc_hardbreak(&mut doc, para);
2034 alloc_hardbreak(&mut doc, para);
2035 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2036
2037 let result = LayoutEngine::new().layout(&doc);
2038 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2039 let runs = &result.graph.pages[0].runs;
2040 let foo = runs.iter().find(|r| r.text == "foo").expect("foo run");
2041 let bar = runs.iter().find(|r| r.text == "bar").expect("bar run");
2042 let delta = bar.baseline_from_top_pt - foo.baseline_from_top_pt;
2043 let one_line = BODY_SIZE_PT * BODY_LEADING;
2046 let expected = 2.0 * one_line;
2047 assert!(
2048 (delta - expected).abs() < 0.01,
2049 "expected delta {expected} for two-line gap, got {delta}"
2050 );
2051 }
2052
2053 #[test]
2054 fn hard_break_at_paragraph_start_collapses_on_first_page_line() {
2055 let mut doc = Document::new(PathBuf::from("test.mos"));
2060 pin_helvetica(&mut doc);
2061 let para = make_empty_paragraph(&mut doc);
2062 alloc_hardbreak(&mut doc, para);
2063 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2064
2065 let result = LayoutEngine::new().layout(&doc);
2066 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2067 let runs = &result.graph.pages[0].runs;
2068 assert_eq!(runs.len(), 1, "expected one run, got {runs:?}");
2069 assert_eq!(runs[0].text, "foo");
2070 }
2071
2072 #[test]
2073 fn hard_break_after_oversize_word_does_not_add_blank_line() {
2074 let mut doc = Document::new(PathBuf::from("test.mos"));
2081 pin_helvetica(&mut doc);
2082 let para = make_empty_paragraph(&mut doc);
2083 let huge: String = "a".repeat(500);
2086 alloc_inline(&mut doc, para, NodeKind::Text, &huge);
2087 alloc_hardbreak(&mut doc, para);
2088 alloc_inline(&mut doc, para, NodeKind::Text, "next");
2089
2090 let result = LayoutEngine::new().layout(&doc);
2091 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2092 let runs = &result.graph.pages[0].runs;
2093 let next = runs.iter().find(|r| r.text == "next").expect("next run");
2094 let last_chunk_baseline = runs
2097 .iter()
2098 .filter(|r| !r.text.is_empty() && r.text.chars().all(|c| c == 'a'))
2099 .map(|r| r.baseline_from_top_pt)
2100 .fold(f32::NEG_INFINITY, f32::max);
2101 assert!(
2102 last_chunk_baseline.is_finite(),
2103 "did not find any oversize-chunk runs"
2104 );
2105 let one_line = BODY_SIZE_PT * BODY_LEADING;
2106 let delta = next.baseline_from_top_pt - last_chunk_baseline;
2107 assert!(
2108 (delta - one_line).abs() < 0.01,
2109 "expected one-line gap ({one_line:.3}pt) between last oversize chunk and `next`, got {delta:.3}pt -- hard break emitted extra blank?"
2110 );
2111 }
2112
2113 #[test]
2114 fn leading_hard_break_collapses_after_prior_page_content() {
2115 let layout_one = |with_leading_break: bool| {
2121 let mut doc = Document::new(PathBuf::from("test.mos"));
2122 pin_helvetica(&mut doc);
2123 make_paragraph(&mut doc, "first");
2124 let p2 = make_empty_paragraph(&mut doc);
2125 if with_leading_break {
2126 alloc_hardbreak(&mut doc, p2);
2127 }
2128 alloc_inline(&mut doc, p2, NodeKind::Text, "second");
2129 LayoutEngine::new().layout(&doc).graph.pages[0]
2130 .runs
2131 .iter()
2132 .find(|r| r.text == "second")
2133 .expect("second run")
2134 .baseline_from_top_pt
2135 };
2136 let actual = layout_one(true);
2137 let control = layout_one(false);
2138 assert!(
2139 (actual - control).abs() < 0.01,
2140 "leading hard break should collapse: control baseline {control:.3}pt, got {actual:.3}pt"
2141 );
2142 }
2143
2144 #[test]
2145 fn shy_only_piece_does_not_emit_phantom_word() {
2146 let mut doc = Document::new(PathBuf::from("test.mos"));
2152 pin_helvetica(&mut doc);
2153 make_paragraph(&mut doc, "foo \u{AD}\u{AD} bar");
2156 let result = LayoutEngine::new().layout(&doc);
2157 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2158 let runs = &result.graph.pages[0].runs;
2159 assert_eq!(
2160 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2161 vec!["foo", "bar"],
2162 "got {runs:?}"
2163 );
2164 let foo_w = text_width(runs[0].font, runs[0].size_pt, "foo");
2165 let space_w = text_width(runs[0].font, runs[0].size_pt, " ");
2166 let gap = runs[1].x_pt - (runs[0].x_pt + foo_w);
2167 assert!(
2168 (gap - space_w).abs() < 0.01,
2169 "expected one space gap ({space_w:.3}pt), got {gap:.3}pt -- phantom SHY-only word?"
2170 );
2171 }
2172
2173 #[test]
2174 fn soft_hyphen_is_stripped_from_emitted_runs() {
2175 let mut doc = Document::new(PathBuf::from("test.mos"));
2179 pin_helvetica(&mut doc);
2180 make_paragraph(&mut doc, "super\u{AD}cali\u{AD}fragil");
2181 let result = LayoutEngine::new().layout(&doc);
2182 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2183 let runs = &result.graph.pages[0].runs;
2184 assert_eq!(runs.len(), 1, "expected one run, got {runs:?}");
2185 assert_eq!(runs[0].text, "supercalifragil");
2186 assert!(
2187 !runs[0].text.contains('\u{AD}'),
2188 "SHY leaked into rendered text: {:?}",
2189 runs[0].text
2190 );
2191 }
2192
2193 fn pin_narrow_margin(doc: &mut Document, margin_pt: f32) {
2197 let mut attrs = AttrMap::new();
2198 attrs.insert("set".to_owned(), AttrValue::Str("page".to_owned()));
2199 attrs.insert(
2200 "set.arg.margin".to_owned(),
2201 AttrValue::Length(f64::from(margin_pt)),
2202 );
2203 doc.alloc_child(
2204 doc.root,
2205 NodeSpec::new(
2206 NodeKind::Raw,
2207 SourceSpan::placeholder(PathBuf::from("test.mos")),
2208 )
2209 .with_attributes(attrs),
2210 );
2211 }
2212
2213 #[test]
2214 fn shy_only_styled_child_preserves_break_opportunity() {
2215 let mut doc = Document::new(PathBuf::from("test.mos"));
2216 pin_helvetica(&mut doc);
2217 let font = Font::Base14(Base14Font::Helvetica);
2218 let target = text_width(font, BODY_SIZE_PT, "foo-") + 0.5;
2219 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2220 let para = make_empty_paragraph(&mut doc);
2221 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2222 alloc_inline(&mut doc, para, NodeKind::Emphasis, "\u{AD}");
2223 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2224
2225 let result = LayoutEngine::new().layout(&doc);
2226 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2227 let runs = &result.graph.pages[0].runs;
2228 assert_eq!(
2229 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2230 vec!["foo-", "bar"],
2231 "got {runs:?}"
2232 );
2233 assert!(
2234 run_text(runs, "bar").baseline_from_top_pt
2235 > run_text(runs, "foo-").baseline_from_top_pt
2236 );
2237 }
2238
2239 #[test]
2240 fn styled_soft_hyphen_split_preserves_subrun_fonts() {
2241 let mut doc = Document::new(PathBuf::from("test.mos"));
2242 pin_helvetica(&mut doc);
2243 let regular = Font::Base14(Base14Font::Helvetica);
2244 let bold = Font::Base14(Base14Font::HelveticaBold);
2245 let target =
2246 text_width(regular, BODY_SIZE_PT, "pre") + text_width(bold, BODY_SIZE_PT, "su-") + 0.5;
2247 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2248 let para = make_empty_paragraph(&mut doc);
2249 alloc_inline(&mut doc, para, NodeKind::Text, "pre");
2250 alloc_inline(&mut doc, para, NodeKind::Strong, "su\u{AD}per");
2251
2252 let result = LayoutEngine::new().layout(&doc);
2253 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2254 let runs = &result.graph.pages[0].runs;
2255 assert_eq!(
2256 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2257 vec!["pre", "su-", "per"],
2258 "got {runs:?}"
2259 );
2260 assert!(matches!(
2261 run_text(runs, "pre").font,
2262 Font::Base14(Base14Font::Helvetica)
2263 ));
2264 assert!(matches!(
2265 run_text(runs, "su-").font,
2266 Font::Base14(Base14Font::HelveticaBold)
2267 ));
2268 assert!(matches!(
2269 run_text(runs, "per").font,
2270 Font::Base14(Base14Font::HelveticaBold)
2271 ));
2272 assert_runs_touch(run_text(runs, "pre"), run_text(runs, "su-"));
2273 assert!(
2274 run_text(runs, "per").baseline_from_top_pt > run_text(runs, "su-").baseline_from_top_pt
2275 );
2276 }
2277
2278 #[test]
2279 fn shy_breaks_word_when_line_overflows() {
2280 let mut doc = Document::new(PathBuf::from("test.mos"));
2285 pin_helvetica(&mut doc);
2286 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - 25.0) / 2.0);
2289 make_paragraph(&mut doc, "su\u{AD}per");
2290 let result = LayoutEngine::new().layout(&doc);
2291 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2292 let runs = &result.graph.pages[0].runs;
2293 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
2294 assert_eq!(texts, vec!["su-", "per"], "got {runs:?}");
2295 assert!(runs[1].baseline_from_top_pt > runs[0].baseline_from_top_pt);
2296 for r in runs {
2297 assert!(
2298 !r.text.contains('\u{AD}'),
2299 "SHY leaked into rendered text: {:?}",
2300 r.text
2301 );
2302 }
2303 }
2304
2305 #[test]
2306 fn shy_picks_latest_fitting_break() {
2307 let mut doc = Document::new(PathBuf::from("test.mos"));
2311 pin_helvetica(&mut doc);
2312 let font = Font::Base14(Base14Font::Helvetica);
2313 let target = text_width(font, BODY_SIZE_PT, "supercali-") + 2.0;
2314 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2315 make_paragraph(&mut doc, "super\u{AD}cali\u{AD}fragil");
2316 let result = LayoutEngine::new().layout(&doc);
2317 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2318 let runs = &result.graph.pages[0].runs;
2319 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
2320 assert_eq!(texts, vec!["supercali-", "fragil"], "got {runs:?}");
2321 }
2322
2323 #[test]
2324 fn shy_at_zero_or_end_offset_is_ignored() {
2325 let mut doc = Document::new(PathBuf::from("test.mos"));
2331 pin_helvetica(&mut doc);
2332 let font = Font::Base14(Base14Font::Helvetica);
2333 let target = text_width(font, BODY_SIZE_PT, "fo");
2335 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2336 make_paragraph(&mut doc, "\u{AD}foo\u{AD}");
2337 let result = LayoutEngine::new().layout(&doc);
2338 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2339 let runs = &result.graph.pages[0].runs;
2340 for r in runs {
2343 assert_ne!(r.text, "-", "bare hyphen run from boundary SHY");
2344 assert!(
2345 !r.text.ends_with('-'),
2346 "trailing hyphen from boundary SHY: {:?}",
2347 r.text
2348 );
2349 }
2350 let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
2351 assert_eq!(joined, "foo", "all clusters together still spell foo");
2352 }
2353
2354 #[test]
2355 fn shy_falls_back_to_oversize_when_no_break_fits() {
2356 let mut doc = Document::new(PathBuf::from("test.mos"));
2361 pin_helvetica(&mut doc);
2362 let font = Font::Base14(Base14Font::Helvetica);
2363 let target = text_width(font, BODY_SIZE_PT, "s") + 0.5;
2365 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2366 make_paragraph(&mut doc, "super\u{AD}cali");
2367 let result = LayoutEngine::new().layout(&doc);
2368 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2369 let runs = &result.graph.pages[0].runs;
2370 for r in runs {
2374 assert!(
2375 !r.text.ends_with('-'),
2376 "oversize fallback emitted hyphen: {:?}",
2377 r.text
2378 );
2379 }
2380 let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
2381 assert_eq!(joined, "supercali");
2382 }
2383
2384 #[test]
2385 fn set_blocks_are_skipped() {
2386 let mut doc = Document::new(PathBuf::from("test.mos"));
2387 let mut attrs = AttrMap::new();
2388 attrs.insert("set".to_owned(), AttrValue::Str("page".to_owned()));
2389 doc.alloc_child(
2390 doc.root,
2391 NodeSpec::new(
2392 NodeKind::Raw,
2393 SourceSpan::placeholder(PathBuf::from("test.mos")),
2394 )
2395 .with_attributes(attrs),
2396 );
2397 make_paragraph(&mut doc, "body");
2398 let result = LayoutEngine::new().layout(&doc);
2399 let runs = &result.graph.pages[0].runs;
2400 assert_eq!(runs.len(), 1);
2401 assert_eq!(runs[0].text, "body");
2402 }
2403}