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