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, for_each_word_cluster, split_soft_hyphens, try_shy_break};
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: impl IntoIterator<Item = WordItem>, leading: f32) {
695 let line_width = self.column_width_pt();
696 let mut line: Vec<Word> = Vec::new();
697 let mut line_width_used = 0.0_f32;
698 let mut paragraph_emitted_line = false;
711 let mut last_was_hardbreak_flush = false;
712 let mut pending: Option<Word> = None;
717 let mut items = items.into_iter();
718
719 loop {
720 let word_owned: Word = if let Some(w) = pending.take() {
721 w
722 } else if let Some(item) = items.next() {
723 match item {
724 WordItem::Word(w) => w,
725 WordItem::HardBreak => {
726 if !line.is_empty() {
727 self.flush_line(&mut line, leading);
728 line.clear();
729 line_width_used = 0.0;
730 paragraph_emitted_line = true;
731 last_was_hardbreak_flush = true;
732 } else if last_was_hardbreak_flush {
733 self.cursor_y = self.text.size_pt.mul_add(leading, self.cursor_y);
738 } else if paragraph_emitted_line {
739 last_was_hardbreak_flush = true;
747 }
748 continue;
753 }
754 }
755 } else {
756 break;
757 };
758
759 let space_w = if line.is_empty() {
760 0.0
761 } else {
762 word_owned.space_before_pt
763 };
764
765 if line_width_used + space_w + word_owned.width_pt <= line_width {
767 line_width_used += space_w + word_owned.width_pt;
768 line.push(word_owned);
769 continue;
770 }
771
772 if !line.is_empty() && space_w <= f32::EPSILON {
773 line_width_used += word_owned.width_pt;
774 line.push(word_owned);
775 continue;
776 }
777
778 if !line.is_empty()
781 && let Some(ShyBreak { prefix, suffix }) = try_shy_break(
782 &word_owned,
783 line_width - line_width_used - space_w,
784 self.text.family.fallbacks,
785 )
786 {
787 line.push(prefix);
788 self.flush_line(&mut line, leading);
789 line.clear();
790 line_width_used = 0.0;
791 paragraph_emitted_line = true;
792 last_was_hardbreak_flush = false;
793 pending = Some(suffix);
794 continue;
795 }
796
797 if !line.is_empty() {
801 self.flush_line(&mut line, leading);
802 line.clear();
803 line_width_used = 0.0;
804 paragraph_emitted_line = true;
805 last_was_hardbreak_flush = false;
806 }
807
808 if word_owned.width_pt > line_width {
812 if let Some(ShyBreak { prefix, suffix }) =
813 try_shy_break(&word_owned, line_width, self.text.family.fallbacks)
814 {
815 line.push(prefix);
816 self.flush_line(&mut line, leading);
817 line.clear();
818 line_width_used = 0.0;
819 paragraph_emitted_line = true;
820 last_was_hardbreak_flush = false;
821 pending = Some(suffix);
822 continue;
823 }
824 self.flush_oversize_word(&word_owned, leading);
825 paragraph_emitted_line = true;
826 last_was_hardbreak_flush = false;
827 continue;
828 }
829
830 line_width_used = word_owned.width_pt;
832 line.push(word_owned);
833 }
834 if !line.is_empty() {
835 self.flush_line(&mut line, leading);
836 }
837 }
838
839 fn flush_line(&mut self, line: &mut [Word], leading: f32) {
843 let marker_size = self
849 .pending_marker
850 .as_ref()
851 .map_or(0.0_f32, |m| m.word.size_pt);
852 let marker_ascent = self.pending_marker.as_ref().map_or(0.0_f32, |m| {
853 m.word
854 .subruns
855 .iter()
856 .map(|sub| ascent(sub.font, m.word.size_pt))
857 .fold(0.0_f32, f32::max)
858 });
859 let max_size = line.iter().map(|w| w.size_pt).fold(marker_size, f32::max);
860 let max_ascent = line
861 .iter()
862 .flat_map(|w| w.subruns.iter().map(|sub| ascent(sub.font, w.size_pt)))
863 .fold(marker_ascent, f32::max);
864
865 if !self.page_has_content {
868 self.cursor_y = self.page.margin + max_ascent;
869 }
870 if self.cursor_y > self.page.height - self.page.margin {
873 self.start_new_page();
874 self.cursor_y = self.page.margin + max_ascent;
875 }
876
877 self.bind_pending_labels();
880 self.bind_pending_outline(self.cursor_y - max_ascent);
884
885 if let Some(marker) = self.pending_marker.take() {
890 let mut marker_x = marker.x_pt;
891 for sub in marker.word.subruns {
892 self.push_text_run(
893 TextRun {
894 x_pt: marker_x,
895 baseline_from_top_pt: self.cursor_y,
896 size_pt: marker.word.size_pt,
897 font: sub.font,
898 text: sub.text,
899 actual_text: None,
900 glyphs: sub.glyphs,
901 },
902 sub.advance_pt,
903 );
904 marker_x += sub.advance_pt;
905 }
906 }
907
908 let mut x = self.current_left_pt;
909 for (i, word) in line.iter_mut().enumerate() {
910 if i > 0 {
911 x += word.space_before_pt;
912 }
913 let subrun_count = word.subruns.len();
918 for (subrun_index, sub) in word.subruns.drain(..).enumerate() {
919 self.push_text_run(
920 TextRun {
921 x_pt: x,
922 baseline_from_top_pt: self.cursor_y,
923 size_pt: word.size_pt,
924 font: sub.font,
925 text: sub.text,
926 actual_text: if subrun_index + 1 == subrun_count {
927 word.actual_text.take()
928 } else {
929 word.actual_text.clone()
930 },
931 glyphs: sub.glyphs,
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_each_word_cluster(word, |cluster| {
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.into_subrun());
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 &mut [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 #[test]
1031 fn flowing_words_transfers_shaped_buffers_into_page_runs() {
1032 let style = TextStyle::default();
1033 let font = style.family.regular;
1034 let subruns = shape_with_fallback(font, style.family.fallbacks, style.size_pt, "a⨌b");
1035 assert!(subruns.len() > 1);
1036 let buffers: Vec<_> = subruns
1037 .iter()
1038 .map(|sub| (sub.text.as_ptr(), sub.glyphs.as_ptr()))
1039 .collect();
1040 let width_pt = subruns.iter().map(|sub| sub.advance_pt).sum();
1041 let actual_text = String::from("original text");
1042 let actual_text_buffer = actual_text.as_ptr();
1043 let word = Word {
1044 text: String::from("a⨌b"),
1045 actual_text: Some(actual_text),
1046 space_before_pt: 0.0,
1047 font,
1048 size_pt: style.size_pt,
1049 width_pt,
1050 subruns,
1051 shy_break_offsets: Vec::new(),
1052 };
1053 let mut state = LayoutState::new(PageStyle::default(), style);
1054 state.flow_words([WordItem::Word(word)], 1.2);
1055 let runs = &state.current_page.runs;
1056 assert_eq!(runs.len(), buffers.len());
1057 for (run, (text, glyphs)) in runs.iter().zip(buffers) {
1058 assert_eq!(run.text.as_ptr(), text);
1059 assert_eq!(run.glyphs.as_ptr(), glyphs);
1060 assert_eq!(run.actual_text.as_deref(), Some("original text"));
1061 }
1062 assert_eq!(
1063 runs.last().unwrap().actual_text.as_ref().unwrap().as_ptr(),
1064 actual_text_buffer
1065 );
1066 }
1067
1068 fn alloc_inline(doc: &mut Document, parent: NodeId, kind: NodeKind, text: &str) {
1069 let mut attrs = AttrMap::new();
1070 attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
1071 doc.alloc_child(
1072 parent,
1073 NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
1074 .with_attributes(attrs),
1075 );
1076 }
1077
1078 fn pin_helvetica(doc: &mut Document) {
1083 let mut attrs = AttrMap::new();
1084 attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
1085 attrs.insert(
1086 "set.arg.font".to_owned(),
1087 AttrValue::Str("Helvetica".to_owned()),
1088 );
1089 doc.alloc_child(
1090 doc.root,
1091 NodeSpec::new(
1092 NodeKind::Raw,
1093 SourceSpan::placeholder(PathBuf::from("test.mos")),
1094 )
1095 .with_attributes(attrs),
1096 );
1097 }
1098
1099 fn make_section(doc: &mut Document, level: i64, text: &str) -> NodeId {
1100 let mut attrs = AttrMap::new();
1101 attrs.insert("level".to_owned(), AttrValue::Int(level));
1102 let id = doc.alloc_child(
1103 doc.root,
1104 NodeSpec::new(
1105 NodeKind::Section,
1106 SourceSpan::placeholder(PathBuf::from("test.mos")),
1107 )
1108 .with_attributes(attrs),
1109 );
1110 alloc_inline(doc, id, NodeKind::Text, text);
1111 id
1112 }
1113
1114 fn make_paragraph(doc: &mut Document, text: &str) -> NodeId {
1115 let id = doc.alloc_child(
1116 doc.root,
1117 NodeSpec::new(
1118 NodeKind::Paragraph,
1119 SourceSpan::placeholder(PathBuf::from("test.mos")),
1120 ),
1121 );
1122 alloc_inline(doc, id, NodeKind::Text, text);
1123 id
1124 }
1125
1126 fn make_labelled_paragraph(doc: &mut Document, text: &str, label: &str) -> NodeId {
1127 let mut attrs = AttrMap::new();
1128 attrs.insert("label".to_owned(), AttrValue::Str(label.to_owned()));
1129 let id = doc.alloc_child(
1130 doc.root,
1131 NodeSpec::new(
1132 NodeKind::Paragraph,
1133 SourceSpan::placeholder(PathBuf::from("test.mos")),
1134 )
1135 .with_attributes(attrs),
1136 );
1137 alloc_inline(doc, id, NodeKind::Text, text);
1138 id
1139 }
1140
1141 fn make_raw_block(doc: &mut Document, text: &str) -> NodeId {
1142 let mut attrs = AttrMap::new();
1143 attrs.insert("raw.kind".to_owned(), AttrValue::Str("code".to_owned()));
1144 attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
1145 doc.alloc_child(
1146 doc.root,
1147 NodeSpec::new(
1148 NodeKind::Raw,
1149 SourceSpan::placeholder(PathBuf::from("test.mos")),
1150 )
1151 .with_attributes(attrs),
1152 )
1153 }
1154
1155 fn run_text<'a>(runs: &'a [TextRun], text: &str) -> &'a TextRun {
1156 runs.iter()
1157 .find(|run| run.text == text)
1158 .expect("run text not found")
1159 }
1160
1161 fn assert_runs_touch(left: &TextRun, right: &TextRun) {
1162 assert!(
1163 (left.baseline_from_top_pt - right.baseline_from_top_pt).abs() < 0.01,
1164 "runs are on different baselines: left={left:?}, right={right:?}"
1165 );
1166 let expected_x = left.x_pt + text_width(left.font, left.size_pt, &left.text);
1167 assert!(
1168 (right.x_pt - expected_x).abs() < 0.01,
1169 "expected {:?} to touch {:?}: expected x {expected_x:.3}, got {:.3}",
1170 right.text,
1171 left.text,
1172 right.x_pt
1173 );
1174 }
1175
1176 #[test]
1177 fn heading_then_paragraph_emits_runs_in_order() {
1178 let mut doc = Document::new(PathBuf::from("test.mos"));
1179 pin_helvetica(&mut doc);
1180 make_section(&mut doc, 1, "Hello");
1181 make_paragraph(&mut doc, "body");
1182 let result = LayoutEngine::new().layout(&doc);
1183 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1184 assert_eq!(result.graph.pages.len(), 1);
1185 let runs = &result.graph.pages[0].runs;
1186 assert!(runs.len() >= 2, "expected at least 2 runs, got {runs:?}");
1187 assert!(matches!(
1189 runs[0].font,
1190 Font::Base14(Base14Font::HelveticaBold)
1191 ));
1192 assert_eq!(runs[0].text, "Hello");
1193 let body_run = runs.iter().find(|r| r.text == "body").expect("body run");
1194 assert!(matches!(body_run.font, Font::Base14(Base14Font::Helvetica)));
1195 assert!(body_run.baseline_from_top_pt > runs[0].baseline_from_top_pt);
1196 }
1197
1198 #[test]
1199 fn long_paragraph_paginates() {
1200 let mut doc = Document::new(PathBuf::from("test.mos"));
1203 let mut text = String::new();
1207 for i in 0..1500 {
1208 let _ = write!(text, "word{i} ");
1209 }
1210 make_paragraph(&mut doc, text.trim());
1211 let result = LayoutEngine::new().layout(&doc);
1212 assert!(
1213 result.graph.pages.len() >= 2,
1214 "expected pagination, got {} page(s)",
1215 result.graph.pages.len()
1216 );
1217 }
1218
1219 #[test]
1220 fn page_boundary_signatures_are_stable_and_change_with_pagination() {
1221 fn lay_out(word_count: usize) -> LayoutResult {
1222 let mut doc = Document::new(PathBuf::from("test.mos"));
1223 let mut text = String::new();
1224 for i in 0..word_count {
1225 let _ = write!(text, "word{i} ");
1226 }
1227 make_paragraph(&mut doc, text.trim());
1228 LayoutEngine::new().layout(&doc)
1229 }
1230
1231 let first = lay_out(1500);
1234 let again = lay_out(1500);
1235 assert!(first.graph.pages.len() >= 2, "expected a multi-page layout");
1236 assert_eq!(
1237 first.page_boundary_signatures(),
1238 again.page_boundary_signatures(),
1239 );
1240 assert_eq!(
1241 first
1242 .page_boundary_signatures()
1243 .first_divergence(&again.page_boundary_signatures()),
1244 None,
1245 );
1246
1247 let longer = lay_out(1540);
1250 let base_sig = first.page_boundary_signatures();
1251 let longer_sig = longer.page_boundary_signatures();
1252 assert_ne!(base_sig, longer_sig);
1253 assert!(longer_sig.pages().len() >= base_sig.pages().len());
1254 assert!(base_sig.first_divergence(&longer_sig).is_some());
1255 }
1256
1257 #[test]
1258 fn label_pages_maps_a_first_block_to_page_one() {
1259 let mut doc = Document::new(PathBuf::from("test.mos"));
1260 make_labelled_paragraph(&mut doc, "Introduction", "intro");
1261 let result = LayoutEngine::new().layout(&doc);
1262 assert_eq!(result.label_pages.get("intro").copied(), Some(1));
1263 }
1264
1265 #[test]
1266 fn label_pages_records_the_start_page_after_a_break() {
1267 let mut doc = Document::new(PathBuf::from("test.mos"));
1271 let mut filler = String::new();
1272 for i in 0..1500 {
1273 let _ = write!(filler, "word{i} ");
1274 }
1275 make_paragraph(&mut doc, filler.trim());
1276 make_labelled_paragraph(&mut doc, "ZZUNIQUE", "tail");
1277
1278 let result = LayoutEngine::new().layout(&doc);
1279 assert!(
1280 result.graph.pages.len() >= 2,
1281 "expected a multi-page layout"
1282 );
1283
1284 let recorded = result.label_pages.get("tail").copied();
1285 let actual = result
1288 .graph
1289 .pages
1290 .iter()
1291 .find(|page| page.runs.iter().any(|run| run.text == "ZZUNIQUE"))
1292 .map(|page| page.number);
1293 assert_eq!(recorded, actual);
1294 assert!(recorded.is_some_and(|page| page >= 2), "{recorded:?}");
1295 }
1296
1297 #[test]
1298 fn label_pages_omits_unlabelled_blocks_and_keeps_first_occurrence() {
1299 let mut doc = Document::new(PathBuf::from("test.mos"));
1300 make_paragraph(&mut doc, "unlabelled");
1301 make_labelled_paragraph(&mut doc, "first", "dup");
1302 make_labelled_paragraph(&mut doc, "second", "dup");
1303 let result = LayoutEngine::new().layout(&doc);
1304 assert_eq!(result.label_pages.len(), 1);
1306 assert_eq!(result.label_pages.get("dup").copied(), Some(1));
1307 }
1308
1309 #[test]
1310 fn label_pages_omits_a_labelled_block_that_emits_no_content() {
1311 let mut doc = Document::new(PathBuf::from("test.mos"));
1312 make_labelled_paragraph(&mut doc, "", "ghost");
1314 let mut table_attrs = AttrMap::new();
1316 table_attrs.insert("label".to_owned(), AttrValue::Str("phantom".to_owned()));
1317 doc.alloc_child(
1318 doc.root,
1319 NodeSpec::new(
1320 NodeKind::Table,
1321 SourceSpan::placeholder(PathBuf::from("test.mos")),
1322 )
1323 .with_attributes(table_attrs),
1324 );
1325 make_labelled_paragraph(&mut doc, "real text", "real");
1327
1328 let result = LayoutEngine::new().layout(&doc);
1329 assert!(!result.label_pages.contains_key("ghost"));
1332 assert!(!result.label_pages.contains_key("phantom"));
1333 assert_eq!(result.label_pages.get("real").copied(), Some(1));
1334 assert_eq!(result.label_pages.len(), 1);
1335 }
1336
1337 fn make_numbered_section(doc: &mut Document, level: i64, number: &str, text: &str) -> NodeId {
1338 let mut attrs = AttrMap::new();
1339 attrs.insert("level".to_owned(), AttrValue::Int(level));
1340 attrs.insert("number".to_owned(), AttrValue::Str(number.to_owned()));
1341 let id = doc.alloc_child(
1342 doc.root,
1343 NodeSpec::new(
1344 NodeKind::Section,
1345 SourceSpan::placeholder(PathBuf::from("test.mos")),
1346 )
1347 .with_attributes(attrs),
1348 );
1349 alloc_inline(doc, id, NodeKind::Text, text);
1350 id
1351 }
1352
1353 #[test]
1354 fn outline_captures_headings_in_document_order() {
1355 let mut doc = Document::new(PathBuf::from("test.mos"));
1356 make_numbered_section(&mut doc, 1, "1", "Alpha");
1357 make_numbered_section(&mut doc, 2, "1.1", "Beta");
1358 make_numbered_section(&mut doc, 3, "1.1.1", "Gamma");
1359 let result = LayoutEngine::new().layout(&doc);
1360 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1361
1362 let outline = &result.graph.outline;
1363 assert_eq!(outline.len(), 3, "expected 3 entries, got {outline:?}");
1364 assert_eq!(
1365 outline.iter().map(|e| e.level).collect::<Vec<_>>(),
1366 vec![1, 2, 3],
1367 );
1368 assert_eq!(
1369 outline.iter().map(|e| e.title.as_str()).collect::<Vec<_>>(),
1370 vec!["1 Alpha", "1.1 Beta", "1.1.1 Gamma"],
1371 );
1372 for pair in outline.windows(2) {
1375 assert!(
1376 pair[1].page_index >= pair[0].page_index,
1377 "page_index went backwards: {outline:?}"
1378 );
1379 assert!(
1380 pair[1].top_from_top_pt > pair[0].top_from_top_pt,
1381 "positions not ascending: {outline:?}"
1382 );
1383 }
1384 }
1385
1386 #[test]
1387 fn outline_title_includes_section_number() {
1388 let mut doc = Document::new(PathBuf::from("test.mos"));
1389 make_numbered_section(&mut doc, 1, "1", "Foo");
1390 let result = LayoutEngine::new().layout(&doc);
1391 let outline = &result.graph.outline;
1392 assert_eq!(outline.len(), 1);
1393 assert_eq!(outline[0].title, "1 Foo");
1394 assert!(
1397 !outline[0].title.ends_with('.'),
1398 "bookmark title must not carry the render-only trailing dot"
1399 );
1400 }
1401
1402 #[test]
1403 fn heading_across_page_break_binds_start_page() {
1404 let mut doc = Document::new(PathBuf::from("test.mos"));
1408 let mut filler = String::new();
1409 for i in 0..1500 {
1410 let _ = write!(filler, "word{i} ");
1411 }
1412 make_paragraph(&mut doc, filler.trim());
1413 make_numbered_section(&mut doc, 1, "2", "ZZLATER");
1414
1415 let result = LayoutEngine::new().layout(&doc);
1416 assert!(
1417 result.graph.pages.len() >= 2,
1418 "expected a multi-page layout"
1419 );
1420
1421 let outline = &result.graph.outline;
1422 assert_eq!(outline.len(), 1, "expected one heading entry");
1423 let landing = result
1426 .graph
1427 .pages
1428 .iter()
1429 .find(|page| page.runs.iter().any(|run| run.text == "ZZLATER"))
1430 .map(|page| page.number)
1431 .expect("heading run must exist on some page");
1432 assert_eq!(
1433 outline[0].page_index,
1434 usize::try_from(landing - 1).unwrap_or(0)
1435 );
1436 assert!(
1437 outline[0].page_index >= 1,
1438 "heading after a full page must not bind to page 1"
1439 );
1440 }
1441
1442 #[test]
1443 fn document_without_headings_has_empty_outline() {
1444 let mut doc = Document::new(PathBuf::from("test.mos"));
1445 make_paragraph(&mut doc, "just a paragraph");
1446 make_paragraph(&mut doc, "another one");
1447 let result = LayoutEngine::new().layout(&doc);
1448 assert!(
1449 result.graph.outline.is_empty(),
1450 "heading-free doc must have no outline: {:?}",
1451 result.graph.outline
1452 );
1453 }
1454
1455 #[test]
1456 fn empty_heading_produces_no_outline_entry() {
1457 let mut doc = Document::new(PathBuf::from("test.mos"));
1458 make_section(&mut doc, 1, "");
1460 make_paragraph(&mut doc, "body");
1463 let result = LayoutEngine::new().layout(&doc);
1464 assert!(
1465 result.graph.outline.is_empty(),
1466 "empty heading must not create an outline entry: {:?}",
1467 result.graph.outline
1468 );
1469 }
1470
1471 #[test]
1472 fn emphasis_run_uses_oblique() {
1473 let mut doc = Document::new(PathBuf::from("test.mos"));
1474 pin_helvetica(&mut doc);
1475 let para = make_paragraph(&mut doc, "before");
1476 alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1477 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1478 let result = LayoutEngine::new().layout(&doc);
1479 let runs = &result.graph.pages[0].runs;
1480 let italic = runs
1481 .iter()
1482 .find(|r| r.text == "italic")
1483 .expect("italic run");
1484 assert!(matches!(
1485 italic.font,
1486 Font::Base14(Base14Font::HelveticaOblique)
1487 ));
1488 }
1489
1490 #[test]
1491 fn bold_italic_run_uses_bold_oblique() {
1492 let mut doc = Document::new(PathBuf::from("test.mos"));
1493 pin_helvetica(&mut doc);
1494 let para = make_paragraph(&mut doc, "before");
1495 alloc_inline(&mut doc, para, NodeKind::BoldItalic, "both");
1496 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1497 let result = LayoutEngine::new().layout(&doc);
1498 let runs = &result.graph.pages[0].runs;
1499 let both = runs
1500 .iter()
1501 .find(|r| r.text == "both")
1502 .expect("bold-italic run");
1503 assert!(matches!(
1504 both.font,
1505 Font::Base14(Base14Font::HelveticaBoldOblique)
1506 ));
1507 }
1508
1509 #[test]
1510 fn styled_and_code_punctuation_do_not_get_synthetic_glue() {
1511 let mut doc = Document::new(PathBuf::from("test.mos"));
1512 pin_helvetica(&mut doc);
1513 let para = make_empty_paragraph(&mut doc);
1514 alloc_inline(&mut doc, para, NodeKind::Text, "See ");
1515 alloc_inline(&mut doc, para, NodeKind::Strong, "bold");
1516 alloc_inline(&mut doc, para, NodeKind::Text, ". ");
1517 alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1518 alloc_inline(&mut doc, para, NodeKind::Text, ", ");
1519 alloc_inline(&mut doc, para, NodeKind::Raw, "code");
1520 alloc_inline(&mut doc, para, NodeKind::Text, "?");
1521
1522 let result = LayoutEngine::new().layout(&doc);
1523 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1524 let runs = &result.graph.pages[0].runs;
1525 assert_runs_touch(run_text(runs, "bold"), run_text(runs, "."));
1526 assert_runs_touch(run_text(runs, "italic"), run_text(runs, ","));
1527 assert_runs_touch(run_text(runs, "code"), run_text(runs, "?"));
1528 }
1529
1530 #[test]
1531 fn references_citations_and_brackets_do_not_get_synthetic_glue() {
1532 let mut doc = Document::new(PathBuf::from("test.mos"));
1533 pin_helvetica(&mut doc);
1534 let para = make_empty_paragraph(&mut doc);
1535 alloc_inline(&mut doc, para, NodeKind::Text, "See ");
1536 alloc_inline(&mut doc, para, NodeKind::Reference, "1.2");
1537 alloc_inline(&mut doc, para, NodeKind::Text, ", ");
1538 alloc_inline(&mut doc, para, NodeKind::Citation, "[1]");
1539 alloc_inline(&mut doc, para, NodeKind::Text, ". (");
1540 alloc_inline(&mut doc, para, NodeKind::Strong, "bold");
1541 alloc_inline(&mut doc, para, NodeKind::Text, ") [");
1542 alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1543 alloc_inline(&mut doc, para, NodeKind::Text, "]");
1544
1545 let result = LayoutEngine::new().layout(&doc);
1546 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1547 let runs = &result.graph.pages[0].runs;
1548 assert_runs_touch(run_text(runs, "1.2"), run_text(runs, ","));
1549 assert_runs_touch(run_text(runs, "[1]"), run_text(runs, "."));
1550 assert_runs_touch(run_text(runs, "("), run_text(runs, "bold"));
1551 assert_runs_touch(run_text(runs, "bold"), run_text(runs, ")"));
1552 assert_runs_touch(run_text(runs, "["), run_text(runs, "italic"));
1553 assert_runs_touch(run_text(runs, "italic"), run_text(runs, "]"));
1554 }
1555
1556 #[test]
1557 fn leading_and_trailing_ascii_whitespace_do_not_emit_empty_runs() {
1558 let mut doc = Document::new(PathBuf::from("test.mos"));
1559 pin_helvetica(&mut doc);
1560 make_paragraph(&mut doc, " \n\t foo \n");
1561
1562 let result = LayoutEngine::new().layout(&doc);
1563 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1564 let runs = &result.graph.pages[0].runs;
1565 assert_eq!(
1566 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
1567 vec!["foo"],
1568 "got {runs:?}"
1569 );
1570 }
1571
1572 #[test]
1573 fn multiple_ascii_whitespace_runs_collapse_to_one_glue() {
1574 let mut doc = Document::new(PathBuf::from("test.mos"));
1575 pin_helvetica(&mut doc);
1576 make_paragraph(&mut doc, "foo \n\t bar");
1577
1578 let result = LayoutEngine::new().layout(&doc);
1579 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1580 let runs = &result.graph.pages[0].runs;
1581 let foo = run_text(runs, "foo");
1582 let bar = run_text(runs, "bar");
1583 let foo_w = text_width(foo.font, foo.size_pt, "foo");
1584 let space_w = text_width(foo.font, foo.size_pt, " ");
1585 let gap = bar.x_pt - (foo.x_pt + foo_w);
1586 assert!(
1587 (gap - space_w).abs() < 0.01,
1588 "expected one collapsed space ({space_w:.3}pt), got {gap:.3}pt"
1589 );
1590 }
1591
1592 #[test]
1593 fn punctuation_without_source_space_does_not_wrap_alone() {
1594 let mut doc = Document::new(PathBuf::from("test.mos"));
1595 pin_helvetica(&mut doc);
1596 let font = Font::Base14(Base14Font::Helvetica);
1597 let bold = Font::Base14(Base14Font::HelveticaBold);
1598 let target =
1599 text_width(bold, BODY_SIZE_PT, "word") + text_width(font, BODY_SIZE_PT, ".") + 0.5;
1600 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
1601 let para = make_empty_paragraph(&mut doc);
1602 alloc_inline(&mut doc, para, NodeKind::Text, "lead ");
1603 alloc_inline(&mut doc, para, NodeKind::Strong, "word");
1604 alloc_inline(&mut doc, para, NodeKind::Text, ".");
1605
1606 let result = LayoutEngine::new().layout(&doc);
1607 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1608 let runs = &result.graph.pages[0].runs;
1609 let lead = run_text(runs, "lead");
1610 let word = run_text(runs, "word");
1611 let dot = run_text(runs, ".");
1612 assert!(word.baseline_from_top_pt > lead.baseline_from_top_pt);
1613 assert_runs_touch(word, dot);
1614 }
1615
1616 #[test]
1617 fn source_line_wrap_before_closing_punctuation_does_not_create_glue() {
1618 let mut doc = Document::new(PathBuf::from("test.mos"));
1619 let para = make_empty_paragraph(&mut doc);
1620 alloc_inline(&mut doc, para, NodeKind::Strong, "κόσμε");
1621 alloc_inline(&mut doc, para, NodeKind::Text, "\n. None");
1622
1623 let result = LayoutEngine::new().layout(&doc);
1624 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1625 let runs = &result.graph.pages[0].runs;
1626 assert_runs_touch(run_text(runs, "κόσμε"), run_text(runs, "."));
1627 }
1628
1629 #[test]
1630 fn hard_break_after_pending_source_space_drops_that_space() {
1631 let mut doc = Document::new(PathBuf::from("test.mos"));
1632 pin_helvetica(&mut doc);
1633 let para = make_empty_paragraph(&mut doc);
1634 alloc_inline(&mut doc, para, NodeKind::Text, "foo ");
1635 alloc_hardbreak(&mut doc, para);
1636 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
1637
1638 let result = LayoutEngine::new().layout(&doc);
1639 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1640 let runs = &result.graph.pages[0].runs;
1641 let foo = run_text(runs, "foo");
1642 let bar = run_text(runs, "bar");
1643 assert!((foo.x_pt - bar.x_pt).abs() < 0.01, "got {runs:?}");
1644 assert!(bar.baseline_from_top_pt > foo.baseline_from_top_pt);
1645 }
1646
1647 #[test]
1648 fn runs_stay_within_horizontal_margins() {
1649 let mut doc = Document::new(PathBuf::from("test.mos"));
1650 make_paragraph(
1651 &mut doc,
1652 "the quick brown fox jumps over the lazy dog the quick brown fox",
1653 );
1654 let result = LayoutEngine::new().layout(&doc);
1655 let runs = &result.graph.pages[0].runs;
1656 assert!(!runs.is_empty());
1657 let right = A4_WIDTH_PT - MARGIN_PT;
1658 for run in runs {
1659 assert!(run.x_pt >= MARGIN_PT - 1e-6, "x={}", run.x_pt);
1660 let end = run.x_pt + text_width(run.font, run.size_pt, &run.text);
1661 assert!(end <= right + 1e-3, "end={end} right={right}");
1662 }
1663 }
1664
1665 #[test]
1666 fn cyrillic_flows_through_embedded_default_without_substitution() {
1667 let mut doc = Document::new(PathBuf::from("test.mos"));
1672 make_paragraph(&mut doc, "Привет");
1673 let result = LayoutEngine::new().layout(&doc);
1674 assert!(
1675 result.diagnostics.is_empty(),
1676 "expected no diagnostics, got {:?}",
1677 result.diagnostics
1678 );
1679 let runs = &result.graph.pages[0].runs;
1680 let cyr = runs.iter().find(|r| r.text == "Привет").expect("cyr run");
1681 assert!(matches!(cyr.font, Font::Embedded(_)));
1682 assert!(!cyr.glyphs.is_empty(), "expected shaped glyphs");
1683 }
1684
1685 #[test]
1686 fn decomposed_text_is_normalized_before_shaping() {
1687 let mut doc = Document::new(PathBuf::from("test.mos"));
1688 make_paragraph(&mut doc, "S\u{0326}");
1689
1690 let result = LayoutEngine::new().layout(&doc);
1691
1692 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1693 let run = result.graph.pages[0]
1694 .runs
1695 .iter()
1696 .find(|r| r.text == "\u{0218}")
1697 .expect("normalized run");
1698 assert!(matches!(run.font, Font::Embedded(_)));
1699 assert!(!run.glyphs.is_empty(), "expected shaped glyphs");
1700 }
1701
1702 #[test]
1703 fn extended_latin_passes_through_without_warning() {
1704 let mut doc = Document::new(PathBuf::from("test.mos"));
1709 make_paragraph(&mut doc, "Łódź — Příliš");
1710 let result = LayoutEngine::new().layout(&doc);
1711 assert!(
1712 result.diagnostics.is_empty(),
1713 "expected no diagnostics, got {:?}",
1714 result.diagnostics
1715 );
1716 let text: String = result.graph.pages[0]
1717 .runs
1718 .iter()
1719 .map(|r| r.text.as_str())
1720 .collect::<Vec<_>>()
1721 .join(" ");
1722 assert!(text.contains("Łódź"), "got {text}");
1723 assert!(text.contains("Příliš"), "got {text}");
1724 }
1725
1726 #[test]
1727 fn cjk_and_emoji_flow_through_without_diagnostics() {
1728 let mut doc = Document::new(PathBuf::from("test.mos"));
1734 make_paragraph(&mut doc, "日本語 🦀");
1735 let result = LayoutEngine::new().layout(&doc);
1736 assert!(
1737 result.diagnostics.is_empty(),
1738 "uncovered glyphs should flow through without a diagnostic, got {:?}",
1739 result.diagnostics
1740 );
1741 }
1742
1743 #[test]
1744 fn winansi_chars_pass_through_without_warning() {
1745 let mut doc = Document::new(PathBuf::from("test.mos"));
1748 make_paragraph(&mut doc, "café §1 Straße");
1749 let result = LayoutEngine::new().layout(&doc);
1750 assert!(
1751 result.diagnostics.is_empty(),
1752 "expected no diagnostics, got {:?}",
1753 result.diagnostics
1754 );
1755 let text: String = result.graph.pages[0]
1756 .runs
1757 .iter()
1758 .map(|r| r.text.as_str())
1759 .collect::<Vec<_>>()
1760 .join(" ");
1761 assert!(text.contains("café"), "got {text}");
1762 assert!(text.contains("Straße"), "got {text}");
1763 }
1764
1765 #[test]
1766 fn empty_document_emits_one_blank_page() {
1767 let doc = Document::new(PathBuf::from("test.mos"));
1768 let result = LayoutEngine::new().layout(&doc);
1769 assert_eq!(result.graph.pages.len(), 1);
1770 assert!(result.graph.pages[0].runs.is_empty());
1771 }
1772
1773 #[test]
1774 fn raw_inline_uses_courier() {
1775 let mut doc = Document::new(PathBuf::from("test.mos"));
1776 pin_helvetica(&mut doc);
1777 let para = make_paragraph(&mut doc, "before");
1778 alloc_inline(&mut doc, para, NodeKind::Raw, "code");
1779 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1780 let result = LayoutEngine::new().layout(&doc);
1781 let runs = &result.graph.pages[0].runs;
1782 let code_run = runs.iter().find(|r| r.text == "code").expect("code run");
1783 assert!(matches!(code_run.font, Font::Base14(Base14Font::Courier)));
1784 assert!(matches!(
1787 runs.iter().find(|r| r.text == "before").unwrap().font,
1788 Font::Base14(Base14Font::Helvetica)
1789 ));
1790 }
1791
1792 #[test]
1793 fn raw_block_tabs_render_as_spaces() {
1794 let mut doc = Document::new(PathBuf::from("test.mos"));
1795 make_raw_block(&mut doc, "\tprintln(\"hello\");");
1796
1797 let result = LayoutEngine::new().layout(&doc);
1798
1799 let rendered = result.graph.pages[0]
1800 .runs
1801 .iter()
1802 .map(|run| run.text.as_str())
1803 .collect::<String>();
1804 assert!(
1805 !rendered.contains('\t'),
1806 "raw block tabs should be expanded before shaping: {rendered:?}"
1807 );
1808 assert!(
1809 rendered.contains(" println"),
1810 "expected four-space tab expansion, got {rendered:?}"
1811 );
1812 assert!(
1813 result.graph.pages[0]
1814 .runs
1815 .iter()
1816 .any(|run| run.actual_text.as_deref() == Some("\tprintln(\"hello\");")),
1817 "raw block tabs should retain their original text for extraction"
1818 );
1819 }
1820
1821 #[test]
1822 fn raw_block_leading_blank_line_preserves_spacing() {
1823 let mut doc = Document::new(PathBuf::from("test.mos"));
1824 make_raw_block(&mut doc, "\ncode");
1825
1826 let result = LayoutEngine::new().layout(&doc);
1827
1828 let first_run = result.graph.pages[0]
1829 .runs
1830 .first()
1831 .expect("raw block should emit text after the leading blank");
1832 let expected_baseline = BODY_SIZE_PT.mul_add(
1833 BODY_LEADING,
1834 MARGIN_PT + ascent(FontFamily::noto_sans().monospace, BODY_SIZE_PT),
1835 );
1836 assert!(
1837 (first_run.baseline_from_top_pt - expected_baseline).abs() < 0.01,
1838 "baseline {}, expected {expected_baseline}",
1839 first_run.baseline_from_top_pt
1840 );
1841 }
1842
1843 #[test]
1844 fn heading_levels_pick_distinct_sizes() {
1845 let mut doc = Document::new(PathBuf::from("test.mos"));
1846 make_section(&mut doc, 1, "H1");
1847 make_section(&mut doc, 2, "H2");
1848 make_section(&mut doc, 3, "H3");
1849 let result = LayoutEngine::new().layout(&doc);
1850 let runs = &result.graph.pages[0].runs;
1851 let h1 = runs.iter().find(|r| r.text == "H1").expect("H1 run");
1852 let h2 = runs.iter().find(|r| r.text == "H2").expect("H2 run");
1853 let h3 = runs.iter().find(|r| r.text == "H3").expect("H3 run");
1854 assert!((h1.size_pt - HEADING_SIZES_PT[0]).abs() < f32::EPSILON);
1855 assert!((h2.size_pt - HEADING_SIZES_PT[1]).abs() < f32::EPSILON);
1856 assert!((h3.size_pt - HEADING_SIZES_PT[2]).abs() < f32::EPSILON);
1857 assert!(h1.size_pt > h2.size_pt);
1859 assert!(h2.size_pt > h3.size_pt);
1860 assert!(h1.baseline_from_top_pt < h2.baseline_from_top_pt);
1862 assert!(h2.baseline_from_top_pt < h3.baseline_from_top_pt);
1863 }
1864
1865 #[test]
1866 fn heading_after_long_paragraph_paginates_correctly() {
1867 let mut doc = Document::new(PathBuf::from("test.mos"));
1872 pin_helvetica(&mut doc);
1873 let mut text = String::new();
1874 for i in 0..1500 {
1875 let _ = write!(text, "word{i} ");
1876 }
1877 make_paragraph(&mut doc, text.trim());
1878 make_section(&mut doc, 1, "After");
1879 let result = LayoutEngine::new().layout(&doc);
1880 assert!(
1881 result.graph.pages.len() >= 2,
1882 "expected pagination, got {} page(s)",
1883 result.graph.pages.len()
1884 );
1885 let mut heading_page: Option<u32> = None;
1887 let mut first_word_page: Option<u32> = None;
1888 for page in &result.graph.pages {
1889 for run in &page.runs {
1890 if run.text == "After"
1891 && matches!(run.font, Font::Base14(Base14Font::HelveticaBold))
1892 {
1893 heading_page = Some(page.number);
1894 }
1895 if run.text == "word0" && first_word_page.is_none() {
1896 first_word_page = Some(page.number);
1897 }
1898 }
1899 }
1900 let heading_page = heading_page.expect("heading run not emitted");
1901 let first_word_page = first_word_page.expect("first paragraph word not emitted");
1902 assert!(
1903 heading_page > first_word_page,
1904 "heading on page {heading_page}, first paragraph word on page {first_word_page}"
1905 );
1906 }
1907
1908 #[test]
1909 fn heading_with_number_attribute_emits_prefix_run() {
1910 let mut doc = Document::new(PathBuf::from("test.mos"));
1914 pin_helvetica(&mut doc);
1915 let mut attrs = AttrMap::new();
1916 attrs.insert("level".to_owned(), AttrValue::Int(2));
1917 attrs.insert("number".to_owned(), AttrValue::Str("2.1".to_owned()));
1918 let section = doc.alloc_child(
1919 doc.root,
1920 NodeSpec::new(
1921 NodeKind::Section,
1922 SourceSpan::placeholder(PathBuf::from("test.mos")),
1923 )
1924 .with_attributes(attrs),
1925 );
1926 alloc_inline(&mut doc, section, NodeKind::Text, "Background");
1927 let result = LayoutEngine::new().layout(&doc);
1928 let runs = &result.graph.pages[0].runs;
1929 assert!(matches!(
1930 runs[0].font,
1931 Font::Base14(Base14Font::HelveticaBold)
1932 ));
1933 assert_eq!(runs[0].text, "2.1.");
1934 assert!(runs.iter().any(|r| r.text == "Background"));
1935 let title = runs.iter().find(|r| r.text == "Background").unwrap();
1938 assert!((runs[0].baseline_from_top_pt - title.baseline_from_top_pt).abs() < 1e-3);
1939 }
1940
1941 #[test]
1942 fn numbered_heading_prefix_and_title_have_one_space_gap() {
1943 let mut doc = Document::new(PathBuf::from("test.mos"));
1944 pin_helvetica(&mut doc);
1945 let mut attrs = AttrMap::new();
1946 attrs.insert("level".to_owned(), AttrValue::Int(2));
1947 attrs.insert("number".to_owned(), AttrValue::Str("2.1".to_owned()));
1948 let section = doc.alloc_child(
1949 doc.root,
1950 NodeSpec::new(
1951 NodeKind::Section,
1952 SourceSpan::placeholder(PathBuf::from("test.mos")),
1953 )
1954 .with_attributes(attrs),
1955 );
1956 alloc_inline(&mut doc, section, NodeKind::Text, "Background");
1957
1958 let result = LayoutEngine::new().layout(&doc);
1959 let runs = &result.graph.pages[0].runs;
1960 let prefix = run_text(runs, "2.1.");
1961 let title = run_text(runs, "Background");
1962 let prefix_w = text_width(prefix.font, prefix.size_pt, &prefix.text);
1963 let expected_gap = text_width(prefix.font, prefix.size_pt, " ");
1964 let gap = title.x_pt - (prefix.x_pt + prefix_w);
1965 assert!(
1966 (gap - expected_gap).abs() < 0.01,
1967 "expected one heading space ({expected_gap:.3}pt), got {gap:.3}pt"
1968 );
1969 }
1970
1971 #[test]
1972 fn reference_node_renders_resolved_text() {
1973 let mut doc = Document::new(PathBuf::from("test.mos"));
1977 pin_helvetica(&mut doc);
1978 let para = make_paragraph(&mut doc, "see");
1979 let mut attrs = AttrMap::new();
1980 attrs.insert("label".to_owned(), AttrValue::Str("intro".to_owned()));
1981 attrs.insert("text".to_owned(), AttrValue::Str("1.2".to_owned()));
1982 doc.alloc_child(
1983 para,
1984 NodeSpec::new(
1985 NodeKind::Reference,
1986 SourceSpan::placeholder(PathBuf::from("test.mos")),
1987 )
1988 .with_attributes(attrs),
1989 );
1990 let result = LayoutEngine::new().layout(&doc);
1991 let runs = &result.graph.pages[0].runs;
1992 let reference = runs.iter().find(|r| r.text == "1.2").expect("ref run");
1993 assert!(matches!(
1994 reference.font,
1995 Font::Base14(Base14Font::Helvetica)
1996 ));
1997 }
1998
1999 fn alloc_hardbreak(doc: &mut Document, parent: NodeId) {
2002 doc.alloc_child(
2005 parent,
2006 NodeSpec::new(
2007 NodeKind::HardBreak,
2008 SourceSpan::placeholder(PathBuf::from("test.mos")),
2009 ),
2010 );
2011 }
2012
2013 fn make_empty_paragraph(doc: &mut Document) -> NodeId {
2014 doc.alloc_child(
2015 doc.root,
2016 NodeSpec::new(
2017 NodeKind::Paragraph,
2018 SourceSpan::placeholder(PathBuf::from("test.mos")),
2019 ),
2020 )
2021 }
2022
2023 #[test]
2024 fn nbsp_keeps_two_words_in_a_single_run() {
2025 let mut doc = Document::new(PathBuf::from("test.mos"));
2029 pin_helvetica(&mut doc);
2030 make_paragraph(&mut doc, "Mr.\u{A0}Smith");
2031 let result = LayoutEngine::new().layout(&doc);
2032 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2033 let runs = &result.graph.pages[0].runs;
2034 assert_eq!(runs.len(), 1, "expected one TextRun, got {runs:?}");
2035 assert_eq!(runs[0].text, "Mr.\u{A0}Smith");
2036 }
2037
2038 #[test]
2039 fn hard_break_advances_one_line_not_paragraph_spacing() {
2040 let mut doc = Document::new(PathBuf::from("test.mos"));
2041 pin_helvetica(&mut doc);
2042 let para = make_empty_paragraph(&mut doc);
2043 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2044 alloc_hardbreak(&mut doc, para);
2045 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2046
2047 let result = LayoutEngine::new().layout(&doc);
2048 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2049 let runs = &result.graph.pages[0].runs;
2050 let foo = runs.iter().find(|r| r.text == "foo").expect("foo run");
2051 let bar = runs.iter().find(|r| r.text == "bar").expect("bar run");
2052 let delta = bar.baseline_from_top_pt - foo.baseline_from_top_pt;
2053 let expected = BODY_SIZE_PT * BODY_LEADING;
2057 assert!(
2058 (delta - expected).abs() < 0.01,
2059 "expected inter-line delta {expected}, got {delta} (foo={}, bar={})",
2060 foo.baseline_from_top_pt,
2061 bar.baseline_from_top_pt
2062 );
2063 }
2064
2065 #[test]
2066 fn two_hard_breaks_produce_a_blank_line() {
2067 let mut doc = Document::new(PathBuf::from("test.mos"));
2068 pin_helvetica(&mut doc);
2069 let para = make_empty_paragraph(&mut doc);
2070 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2071 alloc_hardbreak(&mut doc, para);
2072 alloc_hardbreak(&mut doc, para);
2073 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2074
2075 let result = LayoutEngine::new().layout(&doc);
2076 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2077 let runs = &result.graph.pages[0].runs;
2078 let foo = runs.iter().find(|r| r.text == "foo").expect("foo run");
2079 let bar = runs.iter().find(|r| r.text == "bar").expect("bar run");
2080 let delta = bar.baseline_from_top_pt - foo.baseline_from_top_pt;
2081 let one_line = BODY_SIZE_PT * BODY_LEADING;
2084 let expected = 2.0 * one_line;
2085 assert!(
2086 (delta - expected).abs() < 0.01,
2087 "expected delta {expected} for two-line gap, got {delta}"
2088 );
2089 }
2090
2091 #[test]
2092 fn hard_break_at_paragraph_start_collapses_on_first_page_line() {
2093 let mut doc = Document::new(PathBuf::from("test.mos"));
2098 pin_helvetica(&mut doc);
2099 let para = make_empty_paragraph(&mut doc);
2100 alloc_hardbreak(&mut doc, para);
2101 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2102
2103 let result = LayoutEngine::new().layout(&doc);
2104 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2105 let runs = &result.graph.pages[0].runs;
2106 assert_eq!(runs.len(), 1, "expected one run, got {runs:?}");
2107 assert_eq!(runs[0].text, "foo");
2108 }
2109
2110 #[test]
2111 fn hard_break_after_oversize_word_does_not_add_blank_line() {
2112 let mut doc = Document::new(PathBuf::from("test.mos"));
2119 pin_helvetica(&mut doc);
2120 let para = make_empty_paragraph(&mut doc);
2121 let huge: String = "a".repeat(500);
2124 alloc_inline(&mut doc, para, NodeKind::Text, &huge);
2125 alloc_hardbreak(&mut doc, para);
2126 alloc_inline(&mut doc, para, NodeKind::Text, "next");
2127
2128 let result = LayoutEngine::new().layout(&doc);
2129 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2130 let runs = &result.graph.pages[0].runs;
2131 let next = runs.iter().find(|r| r.text == "next").expect("next run");
2132 let last_chunk_baseline = runs
2135 .iter()
2136 .filter(|r| !r.text.is_empty() && r.text.chars().all(|c| c == 'a'))
2137 .map(|r| r.baseline_from_top_pt)
2138 .fold(f32::NEG_INFINITY, f32::max);
2139 assert!(
2140 last_chunk_baseline.is_finite(),
2141 "did not find any oversize-chunk runs"
2142 );
2143 let one_line = BODY_SIZE_PT * BODY_LEADING;
2144 let delta = next.baseline_from_top_pt - last_chunk_baseline;
2145 assert!(
2146 (delta - one_line).abs() < 0.01,
2147 "expected one-line gap ({one_line:.3}pt) between last oversize chunk and `next`, got {delta:.3}pt -- hard break emitted extra blank?"
2148 );
2149 }
2150
2151 #[test]
2152 fn leading_hard_break_collapses_after_prior_page_content() {
2153 let layout_one = |with_leading_break: bool| {
2159 let mut doc = Document::new(PathBuf::from("test.mos"));
2160 pin_helvetica(&mut doc);
2161 make_paragraph(&mut doc, "first");
2162 let p2 = make_empty_paragraph(&mut doc);
2163 if with_leading_break {
2164 alloc_hardbreak(&mut doc, p2);
2165 }
2166 alloc_inline(&mut doc, p2, NodeKind::Text, "second");
2167 LayoutEngine::new().layout(&doc).graph.pages[0]
2168 .runs
2169 .iter()
2170 .find(|r| r.text == "second")
2171 .expect("second run")
2172 .baseline_from_top_pt
2173 };
2174 let actual = layout_one(true);
2175 let control = layout_one(false);
2176 assert!(
2177 (actual - control).abs() < 0.01,
2178 "leading hard break should collapse: control baseline {control:.3}pt, got {actual:.3}pt"
2179 );
2180 }
2181
2182 #[test]
2183 fn shy_only_piece_does_not_emit_phantom_word() {
2184 let mut doc = Document::new(PathBuf::from("test.mos"));
2190 pin_helvetica(&mut doc);
2191 make_paragraph(&mut doc, "foo \u{AD}\u{AD} bar");
2194 let result = LayoutEngine::new().layout(&doc);
2195 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2196 let runs = &result.graph.pages[0].runs;
2197 assert_eq!(
2198 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2199 vec!["foo", "bar"],
2200 "got {runs:?}"
2201 );
2202 let foo_w = text_width(runs[0].font, runs[0].size_pt, "foo");
2203 let space_w = text_width(runs[0].font, runs[0].size_pt, " ");
2204 let gap = runs[1].x_pt - (runs[0].x_pt + foo_w);
2205 assert!(
2206 (gap - space_w).abs() < 0.01,
2207 "expected one space gap ({space_w:.3}pt), got {gap:.3}pt -- phantom SHY-only word?"
2208 );
2209 }
2210
2211 #[test]
2212 fn soft_hyphen_is_stripped_from_emitted_runs() {
2213 let mut doc = Document::new(PathBuf::from("test.mos"));
2217 pin_helvetica(&mut doc);
2218 make_paragraph(&mut doc, "super\u{AD}cali\u{AD}fragil");
2219 let result = LayoutEngine::new().layout(&doc);
2220 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2221 let runs = &result.graph.pages[0].runs;
2222 assert_eq!(runs.len(), 1, "expected one run, got {runs:?}");
2223 assert_eq!(runs[0].text, "supercalifragil");
2224 assert!(
2225 !runs[0].text.contains('\u{AD}'),
2226 "SHY leaked into rendered text: {:?}",
2227 runs[0].text
2228 );
2229 }
2230
2231 fn pin_narrow_margin(doc: &mut Document, margin_pt: f32) {
2235 let mut attrs = AttrMap::new();
2236 attrs.insert("set".to_owned(), AttrValue::Str("page".to_owned()));
2237 attrs.insert(
2238 "set.arg.margin".to_owned(),
2239 AttrValue::Length(f64::from(margin_pt)),
2240 );
2241 doc.alloc_child(
2242 doc.root,
2243 NodeSpec::new(
2244 NodeKind::Raw,
2245 SourceSpan::placeholder(PathBuf::from("test.mos")),
2246 )
2247 .with_attributes(attrs),
2248 );
2249 }
2250
2251 #[test]
2252 fn shy_only_styled_child_preserves_break_opportunity() {
2253 let mut doc = Document::new(PathBuf::from("test.mos"));
2254 pin_helvetica(&mut doc);
2255 let font = Font::Base14(Base14Font::Helvetica);
2256 let target = text_width(font, BODY_SIZE_PT, "foo-") + 0.5;
2257 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2258 let para = make_empty_paragraph(&mut doc);
2259 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2260 alloc_inline(&mut doc, para, NodeKind::Emphasis, "\u{AD}");
2261 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2262
2263 let result = LayoutEngine::new().layout(&doc);
2264 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2265 let runs = &result.graph.pages[0].runs;
2266 assert_eq!(
2267 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2268 vec!["foo-", "bar"],
2269 "got {runs:?}"
2270 );
2271 assert!(
2272 run_text(runs, "bar").baseline_from_top_pt
2273 > run_text(runs, "foo-").baseline_from_top_pt
2274 );
2275 }
2276
2277 #[test]
2278 fn repeated_soft_hyphens_preserve_fallback_text_and_bounds() {
2279 let mut doc = Document::new(PathBuf::from("test.mos"));
2280 make_paragraph(&mut doc, &"a⨌b\u{ad}".repeat(64));
2281 let result = LayoutEngine::new().layout(&doc);
2282 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2283 let runs: Vec<_> = result
2284 .graph
2285 .pages
2286 .iter()
2287 .flat_map(|page| &page.runs)
2288 .collect();
2289 let text: String = runs.iter().map(|run| run.text.as_str()).collect();
2290 assert_eq!(text.replace('-', ""), "a⨌b".repeat(64));
2291 assert!(text.contains('-'));
2292 assert!(
2293 runs.iter()
2294 .any(|run| run.font == Font::Embedded(EmbeddedFontId::Math))
2295 );
2296 for run in runs {
2297 let end = run.x_pt + text_width(run.font, run.size_pt, &run.text);
2298 assert!(
2299 end <= A4_WIDTH_PT - MARGIN_PT + 0.01,
2300 "run overflows: {run:?}"
2301 );
2302 }
2303 }
2304
2305 #[test]
2306 fn styled_soft_hyphen_split_preserves_subrun_fonts() {
2307 let mut doc = Document::new(PathBuf::from("test.mos"));
2308 pin_helvetica(&mut doc);
2309 let regular = Font::Base14(Base14Font::Helvetica);
2310 let bold = Font::Base14(Base14Font::HelveticaBold);
2311 let target =
2312 text_width(regular, BODY_SIZE_PT, "pre") + text_width(bold, BODY_SIZE_PT, "su-") + 0.5;
2313 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2314 let para = make_empty_paragraph(&mut doc);
2315 alloc_inline(&mut doc, para, NodeKind::Text, "pre");
2316 alloc_inline(&mut doc, para, NodeKind::Strong, "su\u{AD}per");
2317
2318 let result = LayoutEngine::new().layout(&doc);
2319 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2320 let runs = &result.graph.pages[0].runs;
2321 assert_eq!(
2322 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2323 vec!["pre", "su-", "per"],
2324 "got {runs:?}"
2325 );
2326 assert!(matches!(
2327 run_text(runs, "pre").font,
2328 Font::Base14(Base14Font::Helvetica)
2329 ));
2330 assert!(matches!(
2331 run_text(runs, "su-").font,
2332 Font::Base14(Base14Font::HelveticaBold)
2333 ));
2334 assert!(matches!(
2335 run_text(runs, "per").font,
2336 Font::Base14(Base14Font::HelveticaBold)
2337 ));
2338 assert_runs_touch(run_text(runs, "pre"), run_text(runs, "su-"));
2339 assert!(
2340 run_text(runs, "per").baseline_from_top_pt > run_text(runs, "su-").baseline_from_top_pt
2341 );
2342 }
2343
2344 #[test]
2345 fn shy_breaks_word_when_line_overflows() {
2346 let mut doc = Document::new(PathBuf::from("test.mos"));
2351 pin_helvetica(&mut doc);
2352 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - 25.0) / 2.0);
2355 make_paragraph(&mut doc, "su\u{AD}per");
2356 let result = LayoutEngine::new().layout(&doc);
2357 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2358 let runs = &result.graph.pages[0].runs;
2359 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
2360 assert_eq!(texts, vec!["su-", "per"], "got {runs:?}");
2361 assert!(runs[1].baseline_from_top_pt > runs[0].baseline_from_top_pt);
2362 for r in runs {
2363 assert!(
2364 !r.text.contains('\u{AD}'),
2365 "SHY leaked into rendered text: {:?}",
2366 r.text
2367 );
2368 }
2369 }
2370
2371 #[test]
2372 fn shy_picks_latest_fitting_break() {
2373 let mut doc = Document::new(PathBuf::from("test.mos"));
2377 pin_helvetica(&mut doc);
2378 let font = Font::Base14(Base14Font::Helvetica);
2379 let target = text_width(font, BODY_SIZE_PT, "supercali-") + 2.0;
2380 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2381 make_paragraph(&mut doc, "super\u{AD}cali\u{AD}fragil");
2382 let result = LayoutEngine::new().layout(&doc);
2383 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2384 let runs = &result.graph.pages[0].runs;
2385 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
2386 assert_eq!(texts, vec!["supercali-", "fragil"], "got {runs:?}");
2387 }
2388
2389 #[test]
2390 fn shy_at_zero_or_end_offset_is_ignored() {
2391 let mut doc = Document::new(PathBuf::from("test.mos"));
2397 pin_helvetica(&mut doc);
2398 let font = Font::Base14(Base14Font::Helvetica);
2399 let target = text_width(font, BODY_SIZE_PT, "fo");
2401 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2402 make_paragraph(&mut doc, "\u{AD}foo\u{AD}");
2403 let result = LayoutEngine::new().layout(&doc);
2404 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2405 let runs = &result.graph.pages[0].runs;
2406 for r in runs {
2409 assert_ne!(r.text, "-", "bare hyphen run from boundary SHY");
2410 assert!(
2411 !r.text.ends_with('-'),
2412 "trailing hyphen from boundary SHY: {:?}",
2413 r.text
2414 );
2415 }
2416 let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
2417 assert_eq!(joined, "foo", "all clusters together still spell foo");
2418 }
2419
2420 #[test]
2421 fn shy_falls_back_to_oversize_when_no_break_fits() {
2422 let mut doc = Document::new(PathBuf::from("test.mos"));
2427 pin_helvetica(&mut doc);
2428 let font = Font::Base14(Base14Font::Helvetica);
2429 let target = text_width(font, BODY_SIZE_PT, "s") + 0.5;
2431 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2432 make_paragraph(&mut doc, "super\u{AD}cali");
2433 let result = LayoutEngine::new().layout(&doc);
2434 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2435 let runs = &result.graph.pages[0].runs;
2436 for r in runs {
2440 assert!(
2441 !r.text.ends_with('-'),
2442 "oversize fallback emitted hyphen: {:?}",
2443 r.text
2444 );
2445 }
2446 let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
2447 assert_eq!(joined, "supercali");
2448 }
2449
2450 #[test]
2451 fn set_blocks_are_skipped() {
2452 let mut doc = Document::new(PathBuf::from("test.mos"));
2453 let mut attrs = AttrMap::new();
2454 attrs.insert("set".to_owned(), AttrValue::Str("page".to_owned()));
2455 doc.alloc_child(
2456 doc.root,
2457 NodeSpec::new(
2458 NodeKind::Raw,
2459 SourceSpan::placeholder(PathBuf::from("test.mos")),
2460 )
2461 .with_attributes(attrs),
2462 );
2463 make_paragraph(&mut doc, "body");
2464 let result = LayoutEngine::new().layout(&doc);
2465 let runs = &result.graph.pages[0].runs;
2466 assert_eq!(runs.len(), 1);
2467 assert_eq!(runs[0].text, "body");
2468 }
2469}