1#![doc(
12 html_logo_url = "https://mosaic.kjanat.dev/assets/A4.svg",
13 html_favicon_url = "https://mosaic.kjanat.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, Page,
25 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
36mod boundary;
37mod image;
38mod list;
39mod style;
40mod support;
41mod types;
42mod word;
43
44const HEADING_SIZES_PT: [f32; 3] = [20.0, 16.0, 13.0];
47const HEADING_SPACE_BEFORE_PT: [f32; 3] = [16.0, 12.0, 10.0];
50const HEADING_SPACE_AFTER_PT: [f32; 3] = [10.0, 8.0, 6.0];
52const PARA_SPACE_AFTER_PT: f32 = 4.0;
54const LIST_MARKER_GUTTER_PT: f32 = 18.0;
62const RAW_BLOCK_TAB_WIDTH: usize = 4;
64
65#[derive(Debug, Default)]
77pub struct LayoutEngine;
78
79impl LayoutEngine {
80 #[must_use]
92 pub fn new() -> Self {
93 Self
94 }
95
96 pub fn layout(&mut self, document: &Document) -> LayoutResult {
114 let (page_style, text_style, mut diagnostics) = resolve_styles(document);
115 let mut state = LayoutState::new(page_style, text_style);
116 state.diagnostics.append(&mut diagnostics);
117 let Some(root) = document.get(document.root) else {
118 return state.finish();
119 };
120 for child_id in &root.children {
121 let Some(node) = document.get(*child_id) else {
122 continue;
123 };
124 state.queue_label(node);
128 match node.kind {
129 NodeKind::Section => state.layout_heading(document, node),
130 NodeKind::Paragraph => state.layout_paragraph(document, node),
131 NodeKind::Image => state.layout_image(*child_id, node),
132 NodeKind::Figure => state.layout_figure(document, node),
133 NodeKind::List => state.layout_list(document, node),
134 NodeKind::Raw if node.attributes.contains_key("raw.kind") => {
135 state.layout_raw_block(node);
136 }
137 NodeKind::Raw if node.attributes.contains_key("set") => {}
140 _ => {
141 }
145 }
146 state.discard_unbound_labels();
151 }
152 state.finish()
153 }
154}
155
156struct LayoutState {
158 pages: Vec<Page>,
159 current_page: Page,
161 cursor_y: f32,
163 page_has_content: bool,
166 diagnostics: Vec<Diagnostic>,
167 page: PageStyle,
168 text: TextStyle,
169 image_handles: Vec<ImageHandle>,
173 current_left_pt: f32,
177 pending_marker: Option<PendingMarker>,
182 pending_labels: Vec<String>,
186 label_pages: BTreeMap<String, u32>,
189}
190
191#[derive(Clone, Debug)]
192struct PendingMarker {
193 x_pt: f32,
196 word: Word,
200}
201
202impl LayoutState {
203 fn new(page: PageStyle, text: TextStyle) -> Self {
204 Self {
205 pages: Vec::new(),
206 current_page: blank_page(1, page),
207 cursor_y: page.margin_pt,
208 page_has_content: false,
209 diagnostics: Vec::new(),
210 page,
211 text,
212 image_handles: Vec::new(),
213 current_left_pt: page.margin_pt,
214 pending_marker: None,
215 pending_labels: Vec::new(),
216 label_pages: BTreeMap::new(),
217 }
218 }
219
220 fn queue_label(&mut self, node: &Node) {
223 if let Some(AttrValue::Str(label)) = node.attributes.get("label") {
224 self.pending_labels.push(label.clone());
225 }
226 }
227
228 fn discard_unbound_labels(&mut self) {
235 self.pending_labels.clear();
236 }
237
238 fn bind_pending_labels(&mut self) {
243 if self.pending_labels.is_empty() {
244 return;
245 }
246 let page = self.current_page.number;
247 for label in self.pending_labels.drain(..) {
248 self.label_pages.entry(label).or_insert(page);
249 }
250 }
251
252 fn column_width_pt(&self) -> f32 {
253 self.page.width_pt - self.page.margin_pt - self.current_left_pt
254 }
255
256 fn finish(mut self) -> LayoutResult {
257 if self.page_has_content || self.pages.is_empty() {
262 self.pages.push(self.current_page);
263 }
264 LayoutResult {
265 graph: PageGraph {
266 pages: self.pages,
267 images: self.image_handles,
268 },
269 diagnostics: self.diagnostics,
270 label_pages: self.label_pages,
271 }
272 }
273
274 fn layout_heading(&mut self, document: &Document, section: &Node) {
275 let level = usize::from(read_level(section).unwrap_or(1).clamp(1, 3));
276 let size = HEADING_SIZES_PT[level - 1];
277 let space_before = HEADING_SPACE_BEFORE_PT[level - 1];
278 let space_after = HEADING_SPACE_AFTER_PT[level - 1];
279
280 if self.page_has_content {
281 self.cursor_y += space_before;
282 }
283 let bold = self.text.family.bold;
284 let mut words = self.collect_words(document, section, bold, size);
285 if let Some(number) = read_str_attr(section, "number") {
291 let prefix = format!("{number}.");
292 let subruns = shape_with_fallback(bold, self.text.family.fallbacks, size, &prefix);
293 let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
294 words.insert(
295 0,
296 WordItem::Word(Word {
297 text: prefix,
298 actual_text: None,
299 font: bold,
300 size_pt: size,
301 width_pt,
302 subruns,
303 shy_break_offsets: Vec::new(),
304 }),
305 );
306 }
307 self.flow_words(&words, BODY_LEADING);
308 self.cursor_y += space_after;
309 }
310
311 fn layout_paragraph(&mut self, document: &Document, paragraph: &Node) {
312 let size = self.text.size_pt;
313 let leading = self.text.leading;
314 let regular = self.text.family.regular;
315 let words = self.collect_words(document, paragraph, regular, size);
316 self.flow_words(&words, leading);
317 self.cursor_y += PARA_SPACE_AFTER_PT;
318 }
319
320 fn layout_raw_block(&mut self, raw: &Node) {
321 let Some(AttrValue::Str(text)) = raw.attributes.get("text") else {
322 return;
323 };
324 let size = self.text.size_pt;
325 let leading = self.text.leading;
326 let font = self.text.family.monospace;
327 let mut emitted = false;
328 for line in text.lines() {
329 if line.is_empty() {
330 if !self.page_has_content {
331 self.cursor_y = self.page.margin_pt + ascent(font, size);
332 self.page_has_content = true;
333 }
334 self.cursor_y += size * leading;
335 continue;
336 }
337 let expanded_line = expand_tabs(line, RAW_BLOCK_TAB_WIDTH);
338 let subruns = shape_with_fallback(
339 font,
340 self.text.family.fallbacks,
341 size,
342 expanded_line.as_ref(),
343 );
344 let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
345 let actual_text = (expanded_line.as_ref() != line).then(|| line.to_owned());
346 let word = Word {
347 text: expanded_line.into_owned(),
348 actual_text,
349 font,
350 size_pt: size,
351 width_pt,
352 subruns,
353 shy_break_offsets: Vec::new(),
354 };
355 self.flow_words(&[WordItem::Word(word)], leading);
356 emitted = true;
357 }
358 if emitted {
359 self.cursor_y += PARA_SPACE_AFTER_PT;
360 }
361 }
362
363 fn collect_words(
373 &mut self,
374 document: &Document,
375 parent: &Node,
376 default_font: Font,
377 size: f32,
378 ) -> Vec<WordItem> {
379 let mut out: Vec<WordItem> = Vec::new();
380 for child_id in &parent.children {
381 let Some(child) = document.get(*child_id) else {
382 continue;
383 };
384 if matches!(child.kind, NodeKind::HardBreak) {
385 out.push(WordItem::HardBreak);
386 continue;
387 }
388 let font = match child.kind {
389 NodeKind::Strong => self.text.family.bold,
390 NodeKind::Emphasis => self.text.family.italic,
391 NodeKind::BoldItalic => self.text.family.bold_italic,
392 NodeKind::Raw => self.text.family.monospace,
393 NodeKind::List | NodeKind::ListItem => continue,
397 _ => default_font,
398 };
399 let raw = match child.attributes.get("text") {
400 Some(AttrValue::Str(s)) => s.as_str(),
401 _ => continue,
402 };
403 for piece in raw.split_ascii_whitespace() {
408 if piece.is_empty() {
409 continue;
410 }
411 let piece = nfc_text(piece);
412 let piece = piece.as_ref();
413 let (stripped, shy_offsets) = split_soft_hyphens(piece);
419 if stripped.is_empty() {
424 continue;
425 }
426 let subruns =
427 shape_with_fallback(font, self.text.family.fallbacks, size, &stripped);
428 let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
429 out.push(WordItem::Word(Word {
430 text: stripped,
431 actual_text: None,
432 font,
433 size_pt: size,
434 width_pt,
435 subruns,
436 shy_break_offsets: shy_offsets,
437 }));
438 }
439 }
440 out
441 }
442
443 fn flow_words(&mut self, items: &[WordItem], leading: f32) {
450 if items.is_empty() {
451 return;
452 }
453 let line_width = self.column_width_pt();
454 let mut line: Vec<Word> = Vec::new();
455 let mut line_width_used = 0.0_f32;
456 let mut paragraph_emitted_line = false;
469 let mut last_was_hardbreak_flush = false;
470 let mut pending: Option<Word> = None;
475 let mut item_idx = 0;
476
477 loop {
478 let word_owned: Word = if let Some(w) = pending.take() {
479 w
480 } else if item_idx < items.len() {
481 let item = &items[item_idx];
482 item_idx += 1;
483 match item {
484 WordItem::Word(w) => w.clone(),
485 WordItem::HardBreak => {
486 if !line.is_empty() {
487 self.flush_line(&line, leading);
488 line.clear();
489 line_width_used = 0.0;
490 paragraph_emitted_line = true;
491 last_was_hardbreak_flush = true;
492 } else if last_was_hardbreak_flush {
493 self.cursor_y += self.text.size_pt * leading;
498 } else if paragraph_emitted_line {
499 last_was_hardbreak_flush = true;
507 }
508 continue;
513 }
514 }
515 } else {
516 break;
517 };
518
519 let space_w = if line.is_empty() {
520 0.0
521 } else {
522 text_width(word_owned.font, word_owned.size_pt, " ")
523 };
524
525 if line_width_used + space_w + word_owned.width_pt <= line_width {
527 line_width_used += space_w + word_owned.width_pt;
528 line.push(word_owned);
529 continue;
530 }
531
532 if !line.is_empty()
535 && let Some(ShyBreak { prefix, suffix }) = try_shy_break(
536 &word_owned,
537 line_width - line_width_used - space_w,
538 self.text.family.fallbacks,
539 )
540 {
541 line.push(prefix);
542 self.flush_line(&line, leading);
543 line.clear();
544 line_width_used = 0.0;
545 paragraph_emitted_line = true;
546 last_was_hardbreak_flush = false;
547 pending = Some(suffix);
548 continue;
549 }
550
551 if !line.is_empty() {
555 self.flush_line(&line, leading);
556 line.clear();
557 line_width_used = 0.0;
558 paragraph_emitted_line = true;
559 last_was_hardbreak_flush = false;
560 }
561
562 if word_owned.width_pt > line_width {
566 if let Some(ShyBreak { prefix, suffix }) =
567 try_shy_break(&word_owned, line_width, self.text.family.fallbacks)
568 {
569 line.push(prefix);
570 self.flush_line(&line, leading);
571 line.clear();
572 line_width_used = 0.0;
573 paragraph_emitted_line = true;
574 last_was_hardbreak_flush = false;
575 pending = Some(suffix);
576 continue;
577 }
578 self.flush_oversize_word(&word_owned, leading);
579 paragraph_emitted_line = true;
580 last_was_hardbreak_flush = false;
581 continue;
582 }
583
584 line_width_used = word_owned.width_pt;
586 line.push(word_owned);
587 }
588 if !line.is_empty() {
589 self.flush_line(&line, leading);
590 }
591 }
592
593 fn flush_line(&mut self, line: &[Word], leading: f32) {
597 let marker_size = self
603 .pending_marker
604 .as_ref()
605 .map_or(0.0_f32, |m| m.word.size_pt);
606 let marker_ascent = self.pending_marker.as_ref().map_or(0.0_f32, |m| {
607 m.word
608 .subruns
609 .iter()
610 .map(|sub| ascent(sub.font, m.word.size_pt))
611 .fold(0.0_f32, f32::max)
612 });
613 let max_size = line.iter().map(|w| w.size_pt).fold(marker_size, f32::max);
614 let max_ascent = line
615 .iter()
616 .flat_map(|w| w.subruns.iter().map(|sub| ascent(sub.font, w.size_pt)))
617 .fold(marker_ascent, f32::max);
618
619 if !self.page_has_content {
622 self.cursor_y = self.page.margin_pt + max_ascent;
623 }
624 if self.cursor_y > self.page.height_pt - self.page.margin_pt {
627 self.start_new_page();
628 self.cursor_y = self.page.margin_pt + max_ascent;
629 }
630
631 self.bind_pending_labels();
634
635 if let Some(marker) = self.pending_marker.take() {
640 let mut marker_x = marker.x_pt;
641 for sub in marker.word.subruns {
642 self.current_page.runs.push(TextRun {
643 x_pt: marker_x,
644 baseline_from_top_pt: self.cursor_y,
645 size_pt: marker.word.size_pt,
646 font: sub.font,
647 text: sub.text,
648 actual_text: None,
649 glyphs: sub.glyphs,
650 });
651 marker_x += sub.advance_pt;
652 }
653 }
654
655 let mut x = self.current_left_pt;
656 for (i, word) in line.iter().enumerate() {
657 if i > 0 {
658 x += text_width(word.font, word.size_pt, " ");
659 }
660 for sub in &word.subruns {
665 self.current_page.runs.push(TextRun {
666 x_pt: x,
667 baseline_from_top_pt: self.cursor_y,
668 size_pt: word.size_pt,
669 font: sub.font,
670 text: sub.text.clone(),
671 actual_text: word.actual_text.clone(),
672 glyphs: sub.glyphs.clone(),
673 });
674 x += sub.advance_pt;
675 }
676 }
677 self.page_has_content = true;
678 self.cursor_y += max_size * leading;
679 }
680
681 fn flush_oversize_word(&mut self, word: &Word, leading: f32) {
686 let line_width = self.column_width_pt();
687 let mut chunk_text = String::with_capacity(word.text.len());
688 let mut chunk_width = 0.0_f32;
689 let mut chunk_subruns = Vec::new();
690 for cluster in word_clusters(word) {
691 if chunk_width + cluster.advance_pt > line_width && !chunk_subruns.is_empty() {
692 self.flush_oversize_chunk(
693 std::mem::take(&mut chunk_text),
694 chunk_width,
695 std::mem::take(&mut chunk_subruns),
696 word,
697 leading,
698 );
699 chunk_width = 0.0;
700 }
701 chunk_text.push_str(&cluster.text);
702 chunk_width += cluster.advance_pt;
703 chunk_subruns.push(cluster);
704 }
705 if !chunk_subruns.is_empty() {
706 self.flush_oversize_chunk(chunk_text, chunk_width, chunk_subruns, word, leading);
707 }
708 }
709
710 fn flush_oversize_chunk(
711 &mut self,
712 text: String,
713 width_pt: f32,
714 subruns: Vec<WordSubRun>,
715 source: &Word,
716 leading: f32,
717 ) {
718 self.flush_line(
719 &[Word {
720 text,
721 actual_text: None,
722 font: source.font,
723 size_pt: source.size_pt,
724 width_pt,
725 subruns,
726 shy_break_offsets: Vec::new(),
727 }],
728 leading,
729 );
730 }
731
732 fn start_new_page(&mut self) {
733 let next_number = self.current_page.number + 1;
734 let finished =
735 std::mem::replace(&mut self.current_page, blank_page(next_number, self.page));
736 self.pages.push(finished);
737 self.cursor_y = self.page.margin_pt;
738 self.page_has_content = false;
739 }
740}
741
742#[cfg(test)]
743mod tests {
744 #![allow(
745 clippy::unwrap_used,
746 clippy::expect_used,
747 reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
748 )]
749 use std::path::PathBuf;
750
751 use mos_core::{AttrMap, AttrValue, Document, NodeId, NodeKind, NodeSpec, SourceSpan};
752
753 use crate::types::BODY_SIZE_PT;
754
755 use super::*;
756
757 fn alloc_inline(doc: &mut Document, parent: NodeId, kind: NodeKind, text: &str) {
758 let mut attrs = AttrMap::new();
759 attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
760 doc.alloc_child(
761 parent,
762 NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
763 .with_attributes(attrs),
764 );
765 }
766
767 fn pin_helvetica(doc: &mut Document) {
772 let mut attrs = AttrMap::new();
773 attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
774 attrs.insert(
775 "set.arg.font".to_owned(),
776 AttrValue::Str("Helvetica".to_owned()),
777 );
778 doc.alloc_child(
779 doc.root,
780 NodeSpec::new(
781 NodeKind::Raw,
782 SourceSpan::placeholder(PathBuf::from("test.mos")),
783 )
784 .with_attributes(attrs),
785 );
786 }
787
788 fn make_section(doc: &mut Document, level: i64, text: &str) -> NodeId {
789 let mut attrs = AttrMap::new();
790 attrs.insert("level".to_owned(), AttrValue::Int(level));
791 let id = doc.alloc_child(
792 doc.root,
793 NodeSpec::new(
794 NodeKind::Section,
795 SourceSpan::placeholder(PathBuf::from("test.mos")),
796 )
797 .with_attributes(attrs),
798 );
799 alloc_inline(doc, id, NodeKind::Text, text);
800 id
801 }
802
803 fn make_paragraph(doc: &mut Document, text: &str) -> NodeId {
804 let id = doc.alloc_child(
805 doc.root,
806 NodeSpec::new(
807 NodeKind::Paragraph,
808 SourceSpan::placeholder(PathBuf::from("test.mos")),
809 ),
810 );
811 alloc_inline(doc, id, NodeKind::Text, text);
812 id
813 }
814
815 fn make_labelled_paragraph(doc: &mut Document, text: &str, label: &str) -> NodeId {
816 let mut attrs = AttrMap::new();
817 attrs.insert("label".to_owned(), AttrValue::Str(label.to_owned()));
818 let id = doc.alloc_child(
819 doc.root,
820 NodeSpec::new(
821 NodeKind::Paragraph,
822 SourceSpan::placeholder(PathBuf::from("test.mos")),
823 )
824 .with_attributes(attrs),
825 );
826 alloc_inline(doc, id, NodeKind::Text, text);
827 id
828 }
829
830 fn make_raw_block(doc: &mut Document, text: &str) -> NodeId {
831 let mut attrs = AttrMap::new();
832 attrs.insert("raw.kind".to_owned(), AttrValue::Str("code".to_owned()));
833 attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
834 doc.alloc_child(
835 doc.root,
836 NodeSpec::new(
837 NodeKind::Raw,
838 SourceSpan::placeholder(PathBuf::from("test.mos")),
839 )
840 .with_attributes(attrs),
841 )
842 }
843
844 #[test]
845 fn heading_then_paragraph_emits_runs_in_order() {
846 let mut doc = Document::new(PathBuf::from("test.mos"));
847 pin_helvetica(&mut doc);
848 make_section(&mut doc, 1, "Hello");
849 make_paragraph(&mut doc, "body");
850 let result = LayoutEngine::new().layout(&doc);
851 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
852 assert_eq!(result.graph.pages.len(), 1);
853 let runs = &result.graph.pages[0].runs;
854 assert!(runs.len() >= 2, "expected at least 2 runs, got {runs:?}");
855 assert!(matches!(
857 runs[0].font,
858 Font::Base14(Base14Font::HelveticaBold)
859 ));
860 assert_eq!(runs[0].text, "Hello");
861 let body_run = runs.iter().find(|r| r.text == "body").expect("body run");
862 assert!(matches!(body_run.font, Font::Base14(Base14Font::Helvetica)));
863 assert!(body_run.baseline_from_top_pt > runs[0].baseline_from_top_pt);
864 }
865
866 #[test]
867 fn long_paragraph_paginates() {
868 let mut doc = Document::new(PathBuf::from("test.mos"));
871 let mut text = String::new();
875 for i in 0..1500 {
876 text.push_str(&format!("word{i} "));
877 }
878 make_paragraph(&mut doc, text.trim());
879 let result = LayoutEngine::new().layout(&doc);
880 assert!(
881 result.graph.pages.len() >= 2,
882 "expected pagination, got {} page(s)",
883 result.graph.pages.len()
884 );
885 }
886
887 #[test]
888 fn page_boundary_signatures_are_stable_and_change_with_pagination() {
889 fn lay_out(word_count: usize) -> LayoutResult {
890 let mut doc = Document::new(PathBuf::from("test.mos"));
891 let mut text = String::new();
892 for i in 0..word_count {
893 text.push_str(&format!("word{i} "));
894 }
895 make_paragraph(&mut doc, text.trim());
896 LayoutEngine::new().layout(&doc)
897 }
898
899 let first = lay_out(1500);
902 let again = lay_out(1500);
903 assert!(first.graph.pages.len() >= 2, "expected a multi-page layout");
904 assert_eq!(
905 first.page_boundary_signatures(),
906 again.page_boundary_signatures(),
907 );
908 assert_eq!(
909 first
910 .page_boundary_signatures()
911 .first_divergence(&again.page_boundary_signatures()),
912 None,
913 );
914
915 let longer = lay_out(1540);
918 let base_sig = first.page_boundary_signatures();
919 let longer_sig = longer.page_boundary_signatures();
920 assert_ne!(base_sig, longer_sig);
921 assert!(longer_sig.pages().len() >= base_sig.pages().len());
922 assert!(base_sig.first_divergence(&longer_sig).is_some());
923 }
924
925 #[test]
926 fn label_pages_maps_a_first_block_to_page_one() {
927 let mut doc = Document::new(PathBuf::from("test.mos"));
928 make_labelled_paragraph(&mut doc, "Introduction", "intro");
929 let result = LayoutEngine::new().layout(&doc);
930 assert_eq!(result.label_pages.get("intro").copied(), Some(1));
931 }
932
933 #[test]
934 fn label_pages_records_the_start_page_after_a_break() {
935 let mut doc = Document::new(PathBuf::from("test.mos"));
939 let mut filler = String::new();
940 for i in 0..1500 {
941 filler.push_str(&format!("word{i} "));
942 }
943 make_paragraph(&mut doc, filler.trim());
944 make_labelled_paragraph(&mut doc, "ZZUNIQUE", "tail");
945
946 let result = LayoutEngine::new().layout(&doc);
947 assert!(
948 result.graph.pages.len() >= 2,
949 "expected a multi-page layout"
950 );
951
952 let recorded = result.label_pages.get("tail").copied();
953 let actual = result
956 .graph
957 .pages
958 .iter()
959 .find(|page| page.runs.iter().any(|run| run.text == "ZZUNIQUE"))
960 .map(|page| page.number);
961 assert_eq!(recorded, actual);
962 assert!(recorded.is_some_and(|page| page >= 2), "{recorded:?}");
963 }
964
965 #[test]
966 fn label_pages_omits_unlabelled_blocks_and_keeps_first_occurrence() {
967 let mut doc = Document::new(PathBuf::from("test.mos"));
968 make_paragraph(&mut doc, "unlabelled");
969 make_labelled_paragraph(&mut doc, "first", "dup");
970 make_labelled_paragraph(&mut doc, "second", "dup");
971 let result = LayoutEngine::new().layout(&doc);
972 assert_eq!(result.label_pages.len(), 1);
974 assert_eq!(result.label_pages.get("dup").copied(), Some(1));
975 }
976
977 #[test]
978 fn label_pages_omits_a_labelled_block_that_emits_no_content() {
979 let mut doc = Document::new(PathBuf::from("test.mos"));
980 make_labelled_paragraph(&mut doc, "", "ghost");
982 let mut table_attrs = AttrMap::new();
984 table_attrs.insert("label".to_owned(), AttrValue::Str("phantom".to_owned()));
985 doc.alloc_child(
986 doc.root,
987 NodeSpec::new(
988 NodeKind::Table,
989 SourceSpan::placeholder(PathBuf::from("test.mos")),
990 )
991 .with_attributes(table_attrs),
992 );
993 make_labelled_paragraph(&mut doc, "real text", "real");
995
996 let result = LayoutEngine::new().layout(&doc);
997 assert!(!result.label_pages.contains_key("ghost"));
1000 assert!(!result.label_pages.contains_key("phantom"));
1001 assert_eq!(result.label_pages.get("real").copied(), Some(1));
1002 assert_eq!(result.label_pages.len(), 1);
1003 }
1004
1005 #[test]
1006 fn emphasis_run_uses_oblique() {
1007 let mut doc = Document::new(PathBuf::from("test.mos"));
1008 pin_helvetica(&mut doc);
1009 let para = make_paragraph(&mut doc, "before");
1010 alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1011 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1012 let result = LayoutEngine::new().layout(&doc);
1013 let runs = &result.graph.pages[0].runs;
1014 let italic = runs
1015 .iter()
1016 .find(|r| r.text == "italic")
1017 .expect("italic run");
1018 assert!(matches!(
1019 italic.font,
1020 Font::Base14(Base14Font::HelveticaOblique)
1021 ));
1022 }
1023
1024 #[test]
1025 fn bold_italic_run_uses_bold_oblique() {
1026 let mut doc = Document::new(PathBuf::from("test.mos"));
1027 pin_helvetica(&mut doc);
1028 let para = make_paragraph(&mut doc, "before");
1029 alloc_inline(&mut doc, para, NodeKind::BoldItalic, "both");
1030 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1031 let result = LayoutEngine::new().layout(&doc);
1032 let runs = &result.graph.pages[0].runs;
1033 let both = runs
1034 .iter()
1035 .find(|r| r.text == "both")
1036 .expect("bold-italic run");
1037 assert!(matches!(
1038 both.font,
1039 Font::Base14(Base14Font::HelveticaBoldOblique)
1040 ));
1041 }
1042
1043 #[test]
1044 fn runs_stay_within_horizontal_margins() {
1045 let mut doc = Document::new(PathBuf::from("test.mos"));
1046 make_paragraph(
1047 &mut doc,
1048 "the quick brown fox jumps over the lazy dog the quick brown fox",
1049 );
1050 let result = LayoutEngine::new().layout(&doc);
1051 let runs = &result.graph.pages[0].runs;
1052 assert!(!runs.is_empty());
1053 let right = A4_WIDTH_PT - MARGIN_PT;
1054 for run in runs {
1055 assert!(run.x_pt >= MARGIN_PT - 1e-6, "x={}", run.x_pt);
1056 let end = run.x_pt + text_width(run.font, run.size_pt, &run.text);
1057 assert!(end <= right + 1e-3, "end={end} right={right}");
1058 }
1059 }
1060
1061 #[test]
1062 fn cyrillic_flows_through_embedded_default_without_substitution() {
1063 let mut doc = Document::new(PathBuf::from("test.mos"));
1068 make_paragraph(&mut doc, "Привет");
1069 let result = LayoutEngine::new().layout(&doc);
1070 assert!(
1071 result.diagnostics.is_empty(),
1072 "expected no diagnostics, got {:?}",
1073 result.diagnostics
1074 );
1075 let runs = &result.graph.pages[0].runs;
1076 let cyr = runs.iter().find(|r| r.text == "Привет").expect("cyr run");
1077 assert!(matches!(cyr.font, Font::Embedded(_)));
1078 assert!(!cyr.glyphs.is_empty(), "expected shaped glyphs");
1079 }
1080
1081 #[test]
1082 fn decomposed_text_is_normalized_before_shaping() {
1083 let mut doc = Document::new(PathBuf::from("test.mos"));
1084 make_paragraph(&mut doc, "S\u{0326}");
1085
1086 let result = LayoutEngine::new().layout(&doc);
1087
1088 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1089 let run = result.graph.pages[0]
1090 .runs
1091 .iter()
1092 .find(|r| r.text == "\u{0218}")
1093 .expect("normalized run");
1094 assert!(matches!(run.font, Font::Embedded(_)));
1095 assert!(!run.glyphs.is_empty(), "expected shaped glyphs");
1096 }
1097
1098 #[test]
1099 fn extended_latin_passes_through_without_warning() {
1100 let mut doc = Document::new(PathBuf::from("test.mos"));
1105 make_paragraph(&mut doc, "Łódź — Příliš");
1106 let result = LayoutEngine::new().layout(&doc);
1107 assert!(
1108 result.diagnostics.is_empty(),
1109 "expected no diagnostics, got {:?}",
1110 result.diagnostics
1111 );
1112 let text: String = result.graph.pages[0]
1113 .runs
1114 .iter()
1115 .map(|r| r.text.as_str())
1116 .collect::<Vec<_>>()
1117 .join(" ");
1118 assert!(text.contains("Łódź"), "got {text}");
1119 assert!(text.contains("Příliš"), "got {text}");
1120 }
1121
1122 #[test]
1123 fn cjk_and_emoji_flow_through_without_diagnostics() {
1124 let mut doc = Document::new(PathBuf::from("test.mos"));
1130 make_paragraph(&mut doc, "日本語 🦀");
1131 let result = LayoutEngine::new().layout(&doc);
1132 assert!(
1133 result.diagnostics.is_empty(),
1134 "uncovered glyphs should flow through without a diagnostic, got {:?}",
1135 result.diagnostics
1136 );
1137 }
1138
1139 #[test]
1140 fn winansi_chars_pass_through_without_warning() {
1141 let mut doc = Document::new(PathBuf::from("test.mos"));
1144 make_paragraph(&mut doc, "café §1 Straße");
1145 let result = LayoutEngine::new().layout(&doc);
1146 assert!(
1147 result.diagnostics.is_empty(),
1148 "expected no diagnostics, got {:?}",
1149 result.diagnostics
1150 );
1151 let text: String = result.graph.pages[0]
1152 .runs
1153 .iter()
1154 .map(|r| r.text.as_str())
1155 .collect::<Vec<_>>()
1156 .join(" ");
1157 assert!(text.contains("café"), "got {text}");
1158 assert!(text.contains("Straße"), "got {text}");
1159 }
1160
1161 #[test]
1162 fn empty_document_emits_one_blank_page() {
1163 let doc = Document::new(PathBuf::from("test.mos"));
1164 let result = LayoutEngine::new().layout(&doc);
1165 assert_eq!(result.graph.pages.len(), 1);
1166 assert!(result.graph.pages[0].runs.is_empty());
1167 }
1168
1169 #[test]
1170 fn raw_inline_uses_courier() {
1171 let mut doc = Document::new(PathBuf::from("test.mos"));
1172 pin_helvetica(&mut doc);
1173 let para = make_paragraph(&mut doc, "before");
1174 alloc_inline(&mut doc, para, NodeKind::Raw, "code");
1175 alloc_inline(&mut doc, para, NodeKind::Text, "after");
1176 let result = LayoutEngine::new().layout(&doc);
1177 let runs = &result.graph.pages[0].runs;
1178 let code_run = runs.iter().find(|r| r.text == "code").expect("code run");
1179 assert!(matches!(code_run.font, Font::Base14(Base14Font::Courier)));
1180 assert!(matches!(
1183 runs.iter().find(|r| r.text == "before").unwrap().font,
1184 Font::Base14(Base14Font::Helvetica)
1185 ));
1186 }
1187
1188 #[test]
1189 fn raw_block_tabs_render_as_spaces() {
1190 let mut doc = Document::new(PathBuf::from("test.mos"));
1191 make_raw_block(&mut doc, "\tprintln(\"hello\");");
1192
1193 let result = LayoutEngine::new().layout(&doc);
1194
1195 let rendered = result.graph.pages[0]
1196 .runs
1197 .iter()
1198 .map(|run| run.text.as_str())
1199 .collect::<String>();
1200 assert!(
1201 !rendered.contains('\t'),
1202 "raw block tabs should be expanded before shaping: {rendered:?}"
1203 );
1204 assert!(
1205 rendered.contains(" println"),
1206 "expected four-space tab expansion, got {rendered:?}"
1207 );
1208 assert!(
1209 result.graph.pages[0]
1210 .runs
1211 .iter()
1212 .any(|run| run.actual_text.as_deref() == Some("\tprintln(\"hello\");")),
1213 "raw block tabs should retain their original text for extraction"
1214 );
1215 }
1216
1217 #[test]
1218 fn raw_block_leading_blank_line_preserves_spacing() {
1219 let mut doc = Document::new(PathBuf::from("test.mos"));
1220 make_raw_block(&mut doc, "\ncode");
1221
1222 let result = LayoutEngine::new().layout(&doc);
1223
1224 let first_run = result.graph.pages[0]
1225 .runs
1226 .first()
1227 .expect("raw block should emit text after the leading blank");
1228 let expected_baseline = MARGIN_PT
1229 + ascent(FontFamily::noto_sans().monospace, BODY_SIZE_PT)
1230 + BODY_SIZE_PT * BODY_LEADING;
1231 assert!(
1232 (first_run.baseline_from_top_pt - expected_baseline).abs() < 0.01,
1233 "baseline {}, expected {expected_baseline}",
1234 first_run.baseline_from_top_pt
1235 );
1236 }
1237
1238 #[test]
1239 fn heading_levels_pick_distinct_sizes() {
1240 let mut doc = Document::new(PathBuf::from("test.mos"));
1241 make_section(&mut doc, 1, "H1");
1242 make_section(&mut doc, 2, "H2");
1243 make_section(&mut doc, 3, "H3");
1244 let result = LayoutEngine::new().layout(&doc);
1245 let runs = &result.graph.pages[0].runs;
1246 let h1 = runs.iter().find(|r| r.text == "H1").expect("H1 run");
1247 let h2 = runs.iter().find(|r| r.text == "H2").expect("H2 run");
1248 let h3 = runs.iter().find(|r| r.text == "H3").expect("H3 run");
1249 assert_eq!(h1.size_pt, HEADING_SIZES_PT[0]);
1250 assert_eq!(h2.size_pt, HEADING_SIZES_PT[1]);
1251 assert_eq!(h3.size_pt, HEADING_SIZES_PT[2]);
1252 assert!(h1.size_pt > h2.size_pt);
1254 assert!(h2.size_pt > h3.size_pt);
1255 assert!(h1.baseline_from_top_pt < h2.baseline_from_top_pt);
1257 assert!(h2.baseline_from_top_pt < h3.baseline_from_top_pt);
1258 }
1259
1260 #[test]
1261 fn heading_after_long_paragraph_paginates_correctly() {
1262 let mut doc = Document::new(PathBuf::from("test.mos"));
1267 pin_helvetica(&mut doc);
1268 let mut text = String::new();
1269 for i in 0..1500 {
1270 text.push_str(&format!("word{i} "));
1271 }
1272 make_paragraph(&mut doc, text.trim());
1273 make_section(&mut doc, 1, "After");
1274 let result = LayoutEngine::new().layout(&doc);
1275 assert!(
1276 result.graph.pages.len() >= 2,
1277 "expected pagination, got {} page(s)",
1278 result.graph.pages.len()
1279 );
1280 let mut heading_page: Option<u32> = None;
1282 let mut first_word_page: Option<u32> = None;
1283 for page in &result.graph.pages {
1284 for run in &page.runs {
1285 if run.text == "After"
1286 && matches!(run.font, Font::Base14(Base14Font::HelveticaBold))
1287 {
1288 heading_page = Some(page.number);
1289 }
1290 if run.text == "word0" && first_word_page.is_none() {
1291 first_word_page = Some(page.number);
1292 }
1293 }
1294 }
1295 let heading_page = heading_page.expect("heading run not emitted");
1296 let first_word_page = first_word_page.expect("first paragraph word not emitted");
1297 assert!(
1298 heading_page > first_word_page,
1299 "heading on page {heading_page}, first paragraph word on page {first_word_page}"
1300 );
1301 }
1302
1303 #[test]
1304 fn heading_with_number_attribute_emits_prefix_run() {
1305 let mut doc = Document::new(PathBuf::from("test.mos"));
1309 pin_helvetica(&mut doc);
1310 let mut attrs = AttrMap::new();
1311 attrs.insert("level".to_owned(), AttrValue::Int(2));
1312 attrs.insert("number".to_owned(), AttrValue::Str("2.1".to_owned()));
1313 let section = doc.alloc_child(
1314 doc.root,
1315 NodeSpec::new(
1316 NodeKind::Section,
1317 SourceSpan::placeholder(PathBuf::from("test.mos")),
1318 )
1319 .with_attributes(attrs),
1320 );
1321 alloc_inline(&mut doc, section, NodeKind::Text, "Background");
1322 let result = LayoutEngine::new().layout(&doc);
1323 let runs = &result.graph.pages[0].runs;
1324 assert!(matches!(
1325 runs[0].font,
1326 Font::Base14(Base14Font::HelveticaBold)
1327 ));
1328 assert_eq!(runs[0].text, "2.1.");
1329 assert!(runs.iter().any(|r| r.text == "Background"));
1330 let title = runs.iter().find(|r| r.text == "Background").unwrap();
1333 assert!((runs[0].baseline_from_top_pt - title.baseline_from_top_pt).abs() < 1e-3);
1334 }
1335
1336 #[test]
1337 fn reference_node_renders_resolved_text() {
1338 let mut doc = Document::new(PathBuf::from("test.mos"));
1342 pin_helvetica(&mut doc);
1343 let para = make_paragraph(&mut doc, "see");
1344 let mut attrs = AttrMap::new();
1345 attrs.insert("label".to_owned(), AttrValue::Str("intro".to_owned()));
1346 attrs.insert("text".to_owned(), AttrValue::Str("1.2".to_owned()));
1347 doc.alloc_child(
1348 para,
1349 NodeSpec::new(
1350 NodeKind::Reference,
1351 SourceSpan::placeholder(PathBuf::from("test.mos")),
1352 )
1353 .with_attributes(attrs),
1354 );
1355 let result = LayoutEngine::new().layout(&doc);
1356 let runs = &result.graph.pages[0].runs;
1357 let reference = runs.iter().find(|r| r.text == "1.2").expect("ref run");
1358 assert!(matches!(
1359 reference.font,
1360 Font::Base14(Base14Font::Helvetica)
1361 ));
1362 }
1363
1364 fn alloc_hardbreak(doc: &mut Document, parent: NodeId) {
1367 doc.alloc_child(
1370 parent,
1371 NodeSpec::new(
1372 NodeKind::HardBreak,
1373 SourceSpan::placeholder(PathBuf::from("test.mos")),
1374 ),
1375 );
1376 }
1377
1378 fn make_empty_paragraph(doc: &mut Document) -> NodeId {
1379 doc.alloc_child(
1380 doc.root,
1381 NodeSpec::new(
1382 NodeKind::Paragraph,
1383 SourceSpan::placeholder(PathBuf::from("test.mos")),
1384 ),
1385 )
1386 }
1387
1388 #[test]
1389 fn nbsp_keeps_two_words_in_a_single_run() {
1390 let mut doc = Document::new(PathBuf::from("test.mos"));
1394 pin_helvetica(&mut doc);
1395 make_paragraph(&mut doc, "Mr.\u{A0}Smith");
1396 let result = LayoutEngine::new().layout(&doc);
1397 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1398 let runs = &result.graph.pages[0].runs;
1399 assert_eq!(runs.len(), 1, "expected one TextRun, got {runs:?}");
1400 assert_eq!(runs[0].text, "Mr.\u{A0}Smith");
1401 }
1402
1403 #[test]
1404 fn hard_break_advances_one_line_not_paragraph_spacing() {
1405 let mut doc = Document::new(PathBuf::from("test.mos"));
1406 pin_helvetica(&mut doc);
1407 let para = make_empty_paragraph(&mut doc);
1408 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
1409 alloc_hardbreak(&mut doc, para);
1410 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
1411
1412 let result = LayoutEngine::new().layout(&doc);
1413 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1414 let runs = &result.graph.pages[0].runs;
1415 let foo = runs.iter().find(|r| r.text == "foo").expect("foo run");
1416 let bar = runs.iter().find(|r| r.text == "bar").expect("bar run");
1417 let delta = bar.baseline_from_top_pt - foo.baseline_from_top_pt;
1418 let expected = BODY_SIZE_PT * BODY_LEADING;
1422 assert!(
1423 (delta - expected).abs() < 0.01,
1424 "expected inter-line delta {expected}, got {delta} (foo={}, bar={})",
1425 foo.baseline_from_top_pt,
1426 bar.baseline_from_top_pt
1427 );
1428 }
1429
1430 #[test]
1431 fn two_hard_breaks_produce_a_blank_line() {
1432 let mut doc = Document::new(PathBuf::from("test.mos"));
1433 pin_helvetica(&mut doc);
1434 let para = make_empty_paragraph(&mut doc);
1435 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
1436 alloc_hardbreak(&mut doc, para);
1437 alloc_hardbreak(&mut doc, para);
1438 alloc_inline(&mut doc, para, NodeKind::Text, "bar");
1439
1440 let result = LayoutEngine::new().layout(&doc);
1441 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1442 let runs = &result.graph.pages[0].runs;
1443 let foo = runs.iter().find(|r| r.text == "foo").expect("foo run");
1444 let bar = runs.iter().find(|r| r.text == "bar").expect("bar run");
1445 let delta = bar.baseline_from_top_pt - foo.baseline_from_top_pt;
1446 let one_line = BODY_SIZE_PT * BODY_LEADING;
1449 let expected = 2.0 * one_line;
1450 assert!(
1451 (delta - expected).abs() < 0.01,
1452 "expected delta {expected} for two-line gap, got {delta}"
1453 );
1454 }
1455
1456 #[test]
1457 fn hard_break_at_paragraph_start_collapses_on_first_page_line() {
1458 let mut doc = Document::new(PathBuf::from("test.mos"));
1463 pin_helvetica(&mut doc);
1464 let para = make_empty_paragraph(&mut doc);
1465 alloc_hardbreak(&mut doc, para);
1466 alloc_inline(&mut doc, para, NodeKind::Text, "foo");
1467
1468 let result = LayoutEngine::new().layout(&doc);
1469 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1470 let runs = &result.graph.pages[0].runs;
1471 assert_eq!(runs.len(), 1, "expected one run, got {runs:?}");
1472 assert_eq!(runs[0].text, "foo");
1473 }
1474
1475 #[test]
1476 fn hard_break_after_oversize_word_does_not_add_blank_line() {
1477 let mut doc = Document::new(PathBuf::from("test.mos"));
1484 pin_helvetica(&mut doc);
1485 let para = make_empty_paragraph(&mut doc);
1486 let huge: String = "a".repeat(500);
1489 alloc_inline(&mut doc, para, NodeKind::Text, &huge);
1490 alloc_hardbreak(&mut doc, para);
1491 alloc_inline(&mut doc, para, NodeKind::Text, "next");
1492
1493 let result = LayoutEngine::new().layout(&doc);
1494 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1495 let runs = &result.graph.pages[0].runs;
1496 let next = runs.iter().find(|r| r.text == "next").expect("next run");
1497 let last_chunk_baseline = runs
1500 .iter()
1501 .filter(|r| !r.text.is_empty() && r.text.chars().all(|c| c == 'a'))
1502 .map(|r| r.baseline_from_top_pt)
1503 .fold(f32::NEG_INFINITY, f32::max);
1504 assert!(
1505 last_chunk_baseline.is_finite(),
1506 "did not find any oversize-chunk runs"
1507 );
1508 let one_line = BODY_SIZE_PT * BODY_LEADING;
1509 let delta = next.baseline_from_top_pt - last_chunk_baseline;
1510 assert!(
1511 (delta - one_line).abs() < 0.01,
1512 "expected one-line gap ({one_line:.3}pt) between last oversize chunk and `next`, got {delta:.3}pt -- hard break emitted extra blank?"
1513 );
1514 }
1515
1516 #[test]
1517 fn leading_hard_break_collapses_after_prior_page_content() {
1518 let layout_one = |with_leading_break: bool| {
1524 let mut doc = Document::new(PathBuf::from("test.mos"));
1525 pin_helvetica(&mut doc);
1526 make_paragraph(&mut doc, "first");
1527 let p2 = make_empty_paragraph(&mut doc);
1528 if with_leading_break {
1529 alloc_hardbreak(&mut doc, p2);
1530 }
1531 alloc_inline(&mut doc, p2, NodeKind::Text, "second");
1532 LayoutEngine::new().layout(&doc).graph.pages[0]
1533 .runs
1534 .iter()
1535 .find(|r| r.text == "second")
1536 .expect("second run")
1537 .baseline_from_top_pt
1538 };
1539 let actual = layout_one(true);
1540 let control = layout_one(false);
1541 assert!(
1542 (actual - control).abs() < 0.01,
1543 "leading hard break should collapse: control baseline {control:.3}pt, got {actual:.3}pt"
1544 );
1545 }
1546
1547 #[test]
1548 fn shy_only_piece_does_not_emit_phantom_word() {
1549 let mut doc = Document::new(PathBuf::from("test.mos"));
1555 pin_helvetica(&mut doc);
1556 make_paragraph(&mut doc, "foo \u{AD}\u{AD} bar");
1559 let result = LayoutEngine::new().layout(&doc);
1560 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1561 let runs = &result.graph.pages[0].runs;
1562 assert_eq!(
1563 runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
1564 vec!["foo", "bar"],
1565 "got {runs:?}"
1566 );
1567 let foo_w = text_width(runs[0].font, runs[0].size_pt, "foo");
1568 let space_w = text_width(runs[0].font, runs[0].size_pt, " ");
1569 let gap = runs[1].x_pt - (runs[0].x_pt + foo_w);
1570 assert!(
1571 (gap - space_w).abs() < 0.01,
1572 "expected one space gap ({space_w:.3}pt), got {gap:.3}pt -- phantom SHY-only word?"
1573 );
1574 }
1575
1576 #[test]
1577 fn soft_hyphen_is_stripped_from_emitted_runs() {
1578 let mut doc = Document::new(PathBuf::from("test.mos"));
1582 pin_helvetica(&mut doc);
1583 make_paragraph(&mut doc, "super\u{AD}cali\u{AD}fragil");
1584 let result = LayoutEngine::new().layout(&doc);
1585 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1586 let runs = &result.graph.pages[0].runs;
1587 assert_eq!(runs.len(), 1, "expected one run, got {runs:?}");
1588 assert_eq!(runs[0].text, "supercalifragil");
1589 assert!(
1590 !runs[0].text.contains('\u{AD}'),
1591 "SHY leaked into rendered text: {:?}",
1592 runs[0].text
1593 );
1594 }
1595
1596 fn pin_narrow_margin(doc: &mut Document, margin_pt: f32) {
1600 let mut attrs = AttrMap::new();
1601 attrs.insert("set".to_owned(), AttrValue::Str("page".to_owned()));
1602 attrs.insert(
1603 "set.arg.margin".to_owned(),
1604 AttrValue::Length(f64::from(margin_pt)),
1605 );
1606 doc.alloc_child(
1607 doc.root,
1608 NodeSpec::new(
1609 NodeKind::Raw,
1610 SourceSpan::placeholder(PathBuf::from("test.mos")),
1611 )
1612 .with_attributes(attrs),
1613 );
1614 }
1615
1616 #[test]
1617 fn shy_breaks_word_when_line_overflows() {
1618 let mut doc = Document::new(PathBuf::from("test.mos"));
1623 pin_helvetica(&mut doc);
1624 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - 25.0) / 2.0);
1627 make_paragraph(&mut doc, "su\u{AD}per");
1628 let result = LayoutEngine::new().layout(&doc);
1629 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1630 let runs = &result.graph.pages[0].runs;
1631 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
1632 assert_eq!(texts, vec!["su-", "per"], "got {runs:?}");
1633 assert!(runs[1].baseline_from_top_pt > runs[0].baseline_from_top_pt);
1634 for r in runs {
1635 assert!(
1636 !r.text.contains('\u{AD}'),
1637 "SHY leaked into rendered text: {:?}",
1638 r.text
1639 );
1640 }
1641 }
1642
1643 #[test]
1644 fn shy_picks_latest_fitting_break() {
1645 let mut doc = Document::new(PathBuf::from("test.mos"));
1649 pin_helvetica(&mut doc);
1650 let font = Font::Base14(Base14Font::Helvetica);
1651 let target = text_width(font, BODY_SIZE_PT, "supercali-") + 2.0;
1652 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
1653 make_paragraph(&mut doc, "super\u{AD}cali\u{AD}fragil");
1654 let result = LayoutEngine::new().layout(&doc);
1655 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1656 let runs = &result.graph.pages[0].runs;
1657 let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
1658 assert_eq!(texts, vec!["supercali-", "fragil"], "got {runs:?}");
1659 }
1660
1661 #[test]
1662 fn shy_at_zero_or_end_offset_is_ignored() {
1663 let mut doc = Document::new(PathBuf::from("test.mos"));
1669 pin_helvetica(&mut doc);
1670 let font = Font::Base14(Base14Font::Helvetica);
1671 let target = text_width(font, BODY_SIZE_PT, "fo");
1673 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
1674 make_paragraph(&mut doc, "\u{AD}foo\u{AD}");
1675 let result = LayoutEngine::new().layout(&doc);
1676 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1677 let runs = &result.graph.pages[0].runs;
1678 for r in runs {
1681 assert_ne!(r.text, "-", "bare hyphen run from boundary SHY");
1682 assert!(
1683 !r.text.ends_with('-'),
1684 "trailing hyphen from boundary SHY: {:?}",
1685 r.text
1686 );
1687 }
1688 let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
1689 assert_eq!(joined, "foo", "all clusters together still spell foo");
1690 }
1691
1692 #[test]
1693 fn shy_falls_back_to_oversize_when_no_break_fits() {
1694 let mut doc = Document::new(PathBuf::from("test.mos"));
1699 pin_helvetica(&mut doc);
1700 let font = Font::Base14(Base14Font::Helvetica);
1701 let target = text_width(font, BODY_SIZE_PT, "s") + 0.5;
1703 pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
1704 make_paragraph(&mut doc, "super\u{AD}cali");
1705 let result = LayoutEngine::new().layout(&doc);
1706 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1707 let runs = &result.graph.pages[0].runs;
1708 for r in runs {
1712 assert!(
1713 !r.text.ends_with('-'),
1714 "oversize fallback emitted hyphen: {:?}",
1715 r.text
1716 );
1717 }
1718 let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
1719 assert_eq!(joined, "supercali");
1720 }
1721
1722 #[test]
1723 fn set_blocks_are_skipped() {
1724 let mut doc = Document::new(PathBuf::from("test.mos"));
1725 let mut attrs = AttrMap::new();
1726 attrs.insert("set".to_owned(), AttrValue::Str("page".to_owned()));
1727 doc.alloc_child(
1728 doc.root,
1729 NodeSpec::new(
1730 NodeKind::Raw,
1731 SourceSpan::placeholder(PathBuf::from("test.mos")),
1732 )
1733 .with_attributes(attrs),
1734 );
1735 make_paragraph(&mut doc, "body");
1736 let result = LayoutEngine::new().layout(&doc);
1737 let runs = &result.graph.pages[0].runs;
1738 assert_eq!(runs.len(), 1);
1739 assert_eq!(runs[0].text, "body");
1740 }
1741}