Skip to main content

mos_layout/
lib.rs

1//! Layout engine for Mosaic.
2//!
3//! Walks a lowered [`Document`] into a [`PageGraph`] with greedy
4//! line-breaking: paper size and margins from `#set page(...)`, Base-14
5//! metrics for the core fonts, and the bundled Noto Sans shaped through
6//! `rustybuzz` for glyphs they lack. Knuth-Plass, hyphenation, and
7//! boundary-state reuse for incremental builds (§22.1, §22.3, §33) are
8//! out of scope here.
9
10#![doc(
11    html_logo_url = "https://mosaiclang.dev/assets/A4.svg",
12    html_favicon_url = "https://mosaiclang.dev/assets/A4.svg"
13)]
14
15pub use boundary::{PageBoundarySignature, PageGraphSignature};
16use mos_fonts::nfc_text;
17pub use mos_fonts::{
18    Base14Font, EmbeddedFontId, Font, FontFamily, ShapedGlyph, WordSubRun, ascent, descent,
19    glyph_width, shape_with_fallback, text_width,
20};
21pub use style::paper_size_pt;
22pub use types::{
23    A4_HEIGHT_PT, A4_WIDTH_PT, ImageHandle, ImagePlacement, LayoutResult, MARGIN_PT, OutlineEntry,
24    Page, PageGraph, PageStyle, TextRun, TextStyle,
25};
26
27use std::collections::BTreeMap;
28
29use mos_core::{AttrValue, Diagnostic, Document, Node, NodeKind};
30use style::resolve_styles;
31use support::{blank_page, expand_tabs, read_level, read_str_attr};
32use types::BODY_LEADING;
33use word::{ShyBreak, Word, WordItem, split_soft_hyphens, try_shy_break, word_clusters};
34
35#[doc(hidden)]
36pub mod bibliography;
37#[doc(hidden)]
38pub mod boundary;
39pub mod debug;
40#[doc(hidden)]
41pub mod image;
42#[doc(hidden)]
43pub mod list;
44#[doc(hidden)]
45pub mod style;
46#[doc(hidden)]
47pub mod support;
48#[doc(hidden)]
49pub mod types;
50#[doc(hidden)]
51pub mod word;
52
53/// Heading sizes by level (1-indexed). Anything beyond level 3 falls
54/// back to body size: counters and section numbering land in MVP 1.
55const HEADING_SIZES_PT: [f32; 3] = [20.0, 16.0, 13.0];
56/// Space above each heading level (skipped for the first block on a
57/// page).
58const HEADING_SPACE_BEFORE_PT: [f32; 3] = [16.0, 12.0, 10.0];
59/// Space below each heading level.
60const HEADING_SPACE_AFTER_PT: [f32; 3] = [10.0, 8.0, 6.0];
61/// Vertical gap between consecutive paragraphs.
62const PARA_SPACE_AFTER_PT: f32 = 4.0;
63/// Horizontal gutter reserved for the list marker (`•` for unordered,
64/// `1.` for ordered) on each nesting level. Doubles as the per-level
65/// indent step: nested items shift right by this many points before
66/// their own gutter is added. Sized to comfortably hold a one- or
67/// two-digit ordered marker at the default body size; lists with three-
68/// digit numbering will overflow the gutter visually until per-list
69/// gutter tuning lands.
70const LIST_MARKER_GUTTER_PT: f32 = 18.0;
71/// Number of columns represented by one tab in raw code/pre blocks.
72const RAW_BLOCK_TAB_WIDTH: usize = 4;
73
74/// The driver for MVP 0 layout.
75///
76/// # Examples
77///
78/// ```
79/// use mos_layout::LayoutEngine;
80///
81/// let engine = LayoutEngine::new();
82///
83/// assert_eq!(format!("{engine:?}"), "LayoutEngine");
84/// ```
85#[derive(Copy, Clone, Debug, Default)]
86pub struct LayoutEngine;
87
88impl LayoutEngine {
89    /// Construct a layout engine.
90    ///
91    /// # Examples
92    ///
93    /// ```
94    /// use mos_layout::LayoutEngine;
95    ///
96    /// let engine = LayoutEngine::new();
97    ///
98    /// assert_eq!(format!("{engine:?}"), "LayoutEngine");
99    /// ```
100    #[must_use]
101    pub const fn new() -> Self {
102        Self
103    }
104
105    /// Lay out `document` into a [`PageGraph`]. Never returns an
106    /// error in MVP 0: invalid blocks are skipped and surfaced as
107    /// diagnostics on `LayoutResult` instead.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use std::path::PathBuf;
113    ///
114    /// use mos_core::Document;
115    /// use mos_layout::LayoutEngine;
116    ///
117    /// let doc = Document::new(PathBuf::from("main.mos"));
118    /// let result = LayoutEngine::new().layout(&doc);
119    ///
120    /// assert_eq!(result.graph.pages.len(), 1);
121    /// ```
122    #[must_use]
123    pub fn layout(self, document: &Document) -> LayoutResult {
124        self.layout_impl(document, false)
125    }
126
127    /// Lay out a document while recording final geometry and source blocks.
128    /// The report is returned alongside the ordinary graph and diagnostics.
129    #[must_use]
130    pub fn layout_with_debug(self, document: &Document) -> LayoutResult {
131        self.layout_impl(document, true)
132    }
133
134    fn layout_impl(self, document: &Document, capture_debug: bool) -> LayoutResult {
135        let Self = self;
136        let (page_style, text_style, mut diagnostics) = resolve_styles(document);
137        let mut state = LayoutState::new(page_style, text_style);
138        if capture_debug {
139            state.debug = Some(debug::Recorder::default());
140        }
141        state.diagnostics.append(&mut diagnostics);
142        let Some(root) = document.get(document.root) else {
143            return state.finish();
144        };
145        for child_id in &root.children {
146            let Some(node) = document.get(*child_id) else {
147                continue;
148            };
149            // Queue this block's label (if any) so it binds to the page its
150            // first content actually lands on (issue #72), not the page the
151            // cursor happens to sit on before a break.
152            state.queue_label(node);
153            state.begin_debug_block(node);
154            match node.kind {
155                NodeKind::Section => state.layout_heading(document, node),
156                NodeKind::Paragraph => state.layout_paragraph(document, node),
157                NodeKind::Image => state.layout_image(*child_id, node),
158                NodeKind::Figure => state.layout_figure(document, node),
159                NodeKind::List => state.layout_list(document, node),
160                NodeKind::Bibliography => state.layout_bibliography(document, node),
161                NodeKind::Raw if node.attributes.contains_key("raw.kind") => {
162                    state.layout_raw_block(node);
163                }
164                // `#set` blocks are stashed as `Raw` children of the
165                // root; folded into styles by `resolve_styles` above.
166                NodeKind::Raw if node.attributes.contains_key("set") => {}
167                _ => {
168                    // Unknown top-level kinds (Table, Equation, etc.)
169                    // arrive in MVP 1+; ignore so MVP 0 doesn't panic
170                    // on forward-compatible input.
171                }
172            }
173            // This block is fully laid out. Any label still queued belongs to a
174            // block that emitted no content (an empty paragraph, an unsupported
175            // kind, a `#set` block); drop it so it never binds to a later
176            // block's page (issue #72).
177            state.discard_unbound_labels();
178            state.end_debug_block();
179        }
180        state.finish()
181    }
182}
183
184/// Mutable cursor + accumulator threaded through the layout.
185struct LayoutState {
186    debug: Option<debug::Recorder>,
187    pages: Vec<Page>,
188    /// In-progress page being filled.
189    current_page: Page,
190    /// Y position of the next baseline, measured from page top.
191    cursor_y: f32,
192    /// Whether `current_page` has had any block emitted yet (controls
193    /// `space_before` skipping).
194    page_has_content: bool,
195    diagnostics: Vec<Diagnostic>,
196    page: PageStyle,
197    text: TextStyle,
198    /// Image dedup table: resolved path → handle. Two `#image(...)`
199    /// directives that reference the same on-disk file share one
200    /// [`ImageHandle`] (and therefore one `XObject` in the emitted PDF).
201    image_handles: Vec<ImageHandle>,
202    /// Left edge of the current text column. Equals `page.margin`
203    /// at the top level; list layout pushes this rightward so item
204    /// text hangs into the gutter under its marker.
205    current_left_pt: f32,
206    /// Marker run to emit at the start of the next flushed line. Used
207    /// by list items to draw `•` / `1.` in the gutter to the left of
208    /// `current_left_pt` on the first line of each item. Cleared by
209    /// `flush_line` once the marker is committed to a page.
210    pending_marker: Option<PendingMarker>,
211    /// Labels of blocks dispatched but not yet committed to a page. Bound
212    /// to the page their first content lands on (issue #72): see
213    /// [`LayoutState::bind_pending_labels`].
214    pending_labels: Vec<String>,
215    /// Built result of label → 1-based start page. Emitted into the
216    /// [`PageGraph`] by [`LayoutState::finish`].
217    label_pages: BTreeMap<String, u32>,
218    /// Heading captured in `layout_heading` but not yet committed to a
219    /// page. Bound to the page (and glyph-top y) of the heading's first
220    /// flushed line by [`LayoutState::bind_pending_outline`], mirroring
221    /// the pending-label flow.
222    pending_outline: Option<PendingOutline>,
223    /// Bookmark/outline entries in document order. Emitted into the
224    /// [`PageGraph`] by [`LayoutState::finish`].
225    outline: Vec<OutlineEntry>,
226}
227
228#[derive(Clone, Debug)]
229struct PendingOutline {
230    level: u8,
231    title: String,
232}
233
234#[derive(Clone, Debug)]
235struct PendingMarker {
236    /// X position (page-relative, points from the page's left edge)
237    /// where the marker's left edge should sit.
238    x_pt: f32,
239    /// Pre-shaped marker word. Width is informational only: the
240    /// marker is drawn outside `current_left_pt` so it doesn't reserve
241    /// space in the text column.
242    word: Word,
243}
244
245#[derive(Clone, Copy, Debug)]
246struct PendingSpace {
247    width_pt: f32,
248    from_line_break: bool,
249}
250
251impl LayoutState {
252    const fn new(page: PageStyle, text: TextStyle) -> Self {
253        Self {
254            debug: None,
255            pages: Vec::new(),
256            current_page: blank_page(1, page),
257            cursor_y: page.margin,
258            page_has_content: false,
259            diagnostics: Vec::new(),
260            page,
261            text,
262            image_handles: Vec::new(),
263            current_left_pt: page.margin,
264            pending_marker: None,
265            pending_labels: Vec::new(),
266            label_pages: BTreeMap::new(),
267            pending_outline: None,
268            outline: Vec::new(),
269        }
270    }
271
272    /// Queue a block's `label` attribute (if present) for binding to the
273    /// page its first content commits to (issue #72).
274    fn queue_label(&mut self, node: &Node) {
275        if let Some(AttrValue::Str(label)) = node.attributes.get("label") {
276            self.pending_labels.push(label.clone());
277        }
278    }
279
280    /// Drop labels still queued after a block finished laying out. The block
281    /// (or its label) committed no content, so the label has no page; clearing
282    /// it keeps a labelled no-content block out of `label_pages` instead of
283    /// letting its label leak onto the next block that does emit content. Only
284    /// the just-dispatched block's label can be queued here, since
285    /// [`queue_label`](Self::queue_label) runs once per top-level block.
286    fn discard_unbound_labels(&mut self) {
287        self.pending_labels.clear();
288    }
289
290    /// Bind every queued label to the current page. Called at each
291    /// first-content-commit site *after* its page-break check, so a label
292    /// maps to where its target actually lands. First placement wins
293    /// (`or_insert`), matching the resolver's first-occurrence label rule.
294    fn bind_pending_labels(&mut self) {
295        if self.pending_labels.is_empty() {
296            return;
297        }
298        let page = self.current_page.number;
299        for label in self.pending_labels.drain(..) {
300            self.label_pages.entry(label).or_insert(page);
301        }
302    }
303
304    /// Bind a pending heading to the current page as an outline entry.
305    /// Called from `flush_line` after the page break check, so a heading
306    /// whose first line spills to the next page records that page. Cleared
307    /// on push so only the heading's first flushed line binds; wrapped
308    /// continuation lines of the same heading don't restamp it.
309    fn bind_pending_outline(&mut self, top_from_top_pt: f32) {
310        let Some(pending) = self.pending_outline.take() else {
311            return;
312        };
313        // `current_page.number` is 1-based; the PDF backend wants a
314        // 0-based index into `PageGraph::pages`, which is populated in
315        // page order, so page N is at index N-1.
316        let page_index = usize::try_from(self.current_page.number.saturating_sub(1)).unwrap_or(0);
317        self.outline.push(OutlineEntry {
318            level: pending.level,
319            title: pending.title,
320            page_index,
321            top_from_top_pt,
322        });
323    }
324
325    fn column_width_pt(&self) -> f32 {
326        self.page.width - self.page.margin - self.current_left_pt
327    }
328
329    fn finish(mut self) -> LayoutResult {
330        // Always emit the last page even if empty so the PDF is valid
331        // (a Pages tree with `Count 0` is illegal); only skip when an
332        // earlier page already accumulated content and the trailing
333        // page is genuinely blank.
334        if self.page_has_content || self.pages.is_empty() {
335            self.pages.push(self.current_page);
336        }
337        let debug = self.debug.map(|trace| trace.finish(&self.pages, self.page));
338        LayoutResult {
339            graph: PageGraph {
340                pages: self.pages,
341                images: self.image_handles,
342                outline: self.outline,
343            },
344            diagnostics: self.diagnostics,
345            debug,
346            label_pages: self.label_pages,
347        }
348    }
349
350    fn begin_debug_block(&mut self, node: &Node) {
351        if let Some(trace) = &mut self.debug {
352            trace.begin_block(node);
353        }
354    }
355
356    fn end_debug_block(&mut self) {
357        if let Some(trace) = &mut self.debug {
358            trace.end_block();
359        }
360    }
361
362    fn push_text_run(&mut self, run: TextRun, advance_pt: f32) {
363        if let Some(trace) = &mut self.debug {
364            trace.run(self.current_page.runs.len(), &run, advance_pt);
365        }
366        self.current_page.runs.push(run);
367    }
368
369    fn layout_heading(&mut self, document: &Document, section: &Node) {
370        let heading_level = read_level(section).unwrap_or(1).clamp(1, 3);
371        let level = usize::from(heading_level);
372        let size = HEADING_SIZES_PT[level - 1];
373        let space_before = HEADING_SPACE_BEFORE_PT[level - 1];
374        let space_after = HEADING_SPACE_AFTER_PT[level - 1];
375
376        if self.page_has_content {
377            self.cursor_y += space_before;
378        }
379        let bold = self.text.family.bold;
380        let mut words = self.collect_words(document, section, bold, size);
381        // Capture the bookmark/outline entry from the freshly-collected
382        // words, BEFORE the number prefix is inserted below: the title
383        // is composed as "<number> <text>" explicitly, so re-using the
384        // prefixed word stream would double the number (and its trailing
385        // dot). Skip no-text headings, mirroring the label behavior.
386        let title_text = words
387            .iter()
388            .filter_map(|item| match item {
389                WordItem::Word(w) => Some(w.text.as_str()),
390                WordItem::HardBreak => None,
391            })
392            .collect::<Vec<_>>()
393            .join(" ");
394        if !title_text.is_empty() {
395            let title = match read_str_attr(section, "number") {
396                Some(number) => format!("{number} {title_text}"),
397                None => title_text,
398            };
399            self.pending_outline = Some(PendingOutline {
400                level: heading_level,
401                title,
402            });
403        }
404        // Resolver-assigned section number is rendered as a leading
405        // word so it gets the same font/size as the title and flows
406        // through the existing line-break path. The trailing `.` is
407        // the conventional "1." style; `#set heading(numbering: ...)`
408        // (manifest §4) overrides it once `#set` is interpreted.
409        if let Some(number) = read_str_attr(section, "number") {
410            let prefix = format!("{number}.");
411            let subruns = shape_with_fallback(bold, self.text.family.fallbacks, size, &prefix);
412            let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
413            words.insert(
414                0,
415                WordItem::Word(Word {
416                    text: prefix,
417                    actual_text: None,
418                    space_before_pt: 0.0,
419                    font: bold,
420                    size_pt: size,
421                    width_pt,
422                    subruns,
423                    shy_break_offsets: Vec::new(),
424                }),
425            );
426            if let Some(WordItem::Word(first_title_word)) = words.get_mut(1)
427                && first_title_word.space_before_pt <= f32::EPSILON
428            {
429                first_title_word.space_before_pt = text_width(bold, size, " ");
430            }
431        }
432        self.flow_words(&words, BODY_LEADING);
433        self.cursor_y += space_after;
434    }
435
436    fn layout_paragraph(&mut self, document: &Document, paragraph: &Node) {
437        let size = self.text.size_pt;
438        let leading = self.text.leading;
439        let regular = self.text.family.regular;
440        let words = self.collect_words(document, paragraph, regular, size);
441        self.flow_words(&words, leading);
442        self.cursor_y += PARA_SPACE_AFTER_PT;
443    }
444
445    fn layout_raw_block(&mut self, raw: &Node) {
446        let Some(AttrValue::Str(text)) = raw.attributes.get("text") else {
447            return;
448        };
449        let size = self.text.size_pt;
450        let leading = self.text.leading;
451        let font = self.text.family.monospace;
452        let mut emitted = false;
453        for line in text.lines() {
454            if line.is_empty() {
455                if !self.page_has_content {
456                    self.cursor_y = self.page.margin + ascent(font, size);
457                    self.page_has_content = true;
458                }
459                self.cursor_y = size.mul_add(leading, self.cursor_y);
460                continue;
461            }
462            let expanded_line = expand_tabs(line, RAW_BLOCK_TAB_WIDTH);
463            let subruns = shape_with_fallback(
464                font,
465                self.text.family.fallbacks,
466                size,
467                expanded_line.as_ref(),
468            );
469            let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
470            let actual_text = (expanded_line.as_ref() != line).then(|| line.to_owned());
471            let word = Word {
472                text: expanded_line.into_owned(),
473                actual_text,
474                space_before_pt: 0.0,
475                font,
476                size_pt: size,
477                width_pt,
478                subruns,
479                shy_break_offsets: Vec::new(),
480            };
481            self.flow_words(&[WordItem::Word(word)], leading);
482            emitted = true;
483        }
484        if emitted {
485            self.cursor_y += PARA_SPACE_AFTER_PT;
486        }
487    }
488
489    /// Walk `parent`'s inline children and produce a flat list of
490    /// [`WordItem`]s. Source ASCII whitespace becomes explicit collapsed
491    /// glue on the following word; inline style/font boundaries alone
492    /// produce no glue and no break opportunity. A source line wrap before
493    /// closing punctuation is formatting only and attaches to the previous
494    /// word. U+00A0 NBSP is intentionally preserved inside the word. Each
495    /// visible fragment is shaped once here, and no-source-space fragments
496    /// merge into one unbreakable layout word even when their fonts differ.
497    fn collect_words(
498        &self,
499        document: &Document,
500        parent: &Node,
501        default_font: Font,
502        size: f32,
503    ) -> Vec<WordItem> {
504        let mut out: Vec<WordItem> = Vec::new();
505        let mut current_word: Option<Word> = None;
506        let mut pending_space: Option<PendingSpace> = None;
507        for child_id in &parent.children {
508            let Some(child) = document.get(*child_id) else {
509                continue;
510            };
511            if matches!(child.kind, NodeKind::HardBreak) {
512                Self::flush_pending_word(&mut current_word, &mut out);
513                out.push(WordItem::HardBreak);
514                pending_space = None;
515                continue;
516            }
517            let font = match child.kind {
518                NodeKind::Strong => self.text.family.bold,
519                NodeKind::Emphasis => self.text.family.italic,
520                NodeKind::BoldItalic => self.text.family.bold_italic,
521                NodeKind::Raw => self.text.family.monospace,
522                // Nested list blocks under a `ListItem` are laid out
523                // separately by `layout_list`; skip them here so they
524                // don't leak into the parent item's word stream.
525                NodeKind::List | NodeKind::ListItem => continue,
526                _ => default_font,
527            };
528            let raw = match child.attributes.get("text") {
529                Some(AttrValue::Str(s)) => s.as_str(),
530                _ => continue,
531            };
532            self.collect_text_words(
533                raw,
534                font,
535                size,
536                &mut current_word,
537                &mut pending_space,
538                &mut out,
539            );
540        }
541        Self::flush_pending_word(&mut current_word, &mut out);
542        out
543    }
544
545    fn collect_text_words(
546        &self,
547        raw: &str,
548        font: Font,
549        size: f32,
550        current_word: &mut Option<Word>,
551        pending_space: &mut Option<PendingSpace>,
552        out: &mut Vec<WordItem>,
553    ) {
554        let mut word_start: Option<usize> = None;
555        for (idx, ch) in raw.char_indices() {
556            if ch.is_ascii_whitespace() {
557                if let Some(start) = word_start.take() {
558                    self.append_word_piece(
559                        &raw[start..idx],
560                        font,
561                        size,
562                        current_word,
563                        pending_space,
564                        out,
565                    );
566                }
567                let from_line_break = matches!(ch, '\n' | '\r');
568                let width_pt = text_width(font, size, " ");
569                if let Some(space) = pending_space {
570                    space.width_pt = width_pt;
571                    space.from_line_break |= from_line_break;
572                } else {
573                    *pending_space = Some(PendingSpace {
574                        width_pt,
575                        from_line_break,
576                    });
577                }
578            } else if word_start.is_none() {
579                word_start = Some(idx);
580            }
581        }
582        if let Some(start) = word_start {
583            self.append_word_piece(&raw[start..], font, size, current_word, pending_space, out);
584        }
585    }
586
587    fn append_word_piece(
588        &self,
589        piece: &str,
590        font: Font,
591        size: f32,
592        current_word: &mut Option<Word>,
593        pending_space: &mut Option<PendingSpace>,
594        out: &mut Vec<WordItem>,
595    ) {
596        let piece = nfc_text(piece);
597        let piece = piece.as_ref();
598        // Strip U+00AD before shaping so SHY never renders as a visible
599        // hyphen on either the embedded or Base-14 path. Offsets remain
600        // attached to the merged layout word for the current greedy SHY
601        // breaker and later Knuth-Plass penalties.
602        let (stripped, shy_offsets) = split_soft_hyphens(piece);
603        if stripped.is_empty() {
604            if pending_space.is_none()
605                && let Some(word) = current_word
606            {
607                let offset = word.text.len();
608                word.shy_break_offsets
609                    .extend(shy_offsets.into_iter().map(|idx| offset + idx));
610            }
611            return;
612        }
613        let subruns = shape_with_fallback(font, self.text.family.fallbacks, size, &stripped);
614        let width_pt: f32 = subruns.iter().map(|s| s.advance_pt).sum();
615        if let Some(space) = pending_space.take() {
616            if space.from_line_break && starts_with_closing_punctuation(&stripped) {
617                Self::append_to_current_word(
618                    current_word,
619                    stripped,
620                    font,
621                    size,
622                    width_pt,
623                    subruns,
624                    shy_offsets,
625                );
626                return;
627            }
628            Self::flush_pending_word(current_word, out);
629            *current_word = Some(Word {
630                text: stripped,
631                actual_text: None,
632                space_before_pt: space.width_pt,
633                font,
634                size_pt: size,
635                width_pt,
636                subruns,
637                shy_break_offsets: shy_offsets,
638            });
639            return;
640        }
641        Self::append_to_current_word(
642            current_word,
643            stripped,
644            font,
645            size,
646            width_pt,
647            subruns,
648            shy_offsets,
649        );
650    }
651
652    fn append_to_current_word(
653        current_word: &mut Option<Word>,
654        text: String,
655        font: Font,
656        size: f32,
657        width_pt: f32,
658        subruns: Vec<WordSubRun>,
659        shy_offsets: Vec<usize>,
660    ) {
661        if let Some(word) = current_word {
662            let offset = word.text.len();
663            word.text.push_str(&text);
664            word.width_pt += width_pt;
665            word.subruns.extend(subruns);
666            word.shy_break_offsets
667                .extend(shy_offsets.into_iter().map(|idx| offset + idx));
668            return;
669        }
670        *current_word = Some(Word {
671            text,
672            actual_text: None,
673            space_before_pt: 0.0,
674            font,
675            size_pt: size,
676            width_pt,
677            subruns,
678            shy_break_offsets: shy_offsets,
679        });
680    }
681
682    fn flush_pending_word(current_word: &mut Option<Word>, out: &mut Vec<WordItem>) {
683        if let Some(word) = current_word.take() {
684            out.push(WordItem::Word(word));
685        }
686    }
687
688    /// Greedy line-break `items` and emit text runs onto the page,
689    /// paginating as we go. `leading` is the line-height multiplier
690    /// applied per line. `WordItem::HardBreak` forces a line flush
691    /// at its position (and produces a blank line when two hard
692    /// breaks are adjacent or one lands mid-paragraph with no words
693    /// behind it).
694    fn flow_words(&mut self, items: &[WordItem], leading: f32) {
695        if items.is_empty() {
696            return;
697        }
698        let line_width = self.column_width_pt();
699        let mut line: Vec<Word> = Vec::new();
700        let mut line_width_used = 0.0_f32;
701        // Paragraph-local state so hard-break collapsing follows
702        // block-boundary semantics rather than page-state ones:
703        // * `paragraph_emitted_line` is true once anything in this
704        //   paragraph has emitted vertical space (a flushed line, a
705        //   wrapped overflow, or an oversize chunk).
706        // * `last_was_hardbreak_flush` is true only after a hard
707        //   break flushed (or stacked onto) a line. Stacked hard
708        //   breaks emit blank lines; a hard break following an
709        //   implicit break (oversize / soft wrap) is absorbed without
710        //   adding a blank line, matching the author's natural
711        //   reading of "force a break here" when the line just
712        //   ended on its own.
713        let mut paragraph_emitted_line = false;
714        let mut last_was_hardbreak_flush = false;
715        // Suffix produced by a SHY split takes priority over the
716        // next `items` entry. A single source word with several SHYs
717        // may break twice (`super\-cali\-fragil\-istic` on a narrow
718        // column), so the suffix re-enters the same dispatch loop.
719        let mut pending: Option<Word> = None;
720        let mut item_idx = 0;
721
722        loop {
723            let word_owned: Word = if let Some(w) = pending.take() {
724                w
725            } else if item_idx < items.len() {
726                let item = &items[item_idx];
727                item_idx += 1;
728                match item {
729                    WordItem::Word(w) => w.clone(),
730                    WordItem::HardBreak => {
731                        if !line.is_empty() {
732                            self.flush_line(&line, leading);
733                            line.clear();
734                            line_width_used = 0.0;
735                            paragraph_emitted_line = true;
736                            last_was_hardbreak_flush = true;
737                        } else if last_was_hardbreak_flush {
738                            // Stacked hard breaks: emit a blank line
739                            // and remain in the "just hard-broke"
740                            // state so a third break emits another
741                            // blank.
742                            self.cursor_y = self.text.size_pt.mul_add(leading, self.cursor_y);
743                        } else if paragraph_emitted_line {
744                            // First hard break after an implicit
745                            // break (oversize chunk or soft-wrap
746                            // flush). The cursor already advanced
747                            // past the previous line, so this break
748                            // is absorbed silently. Promote the
749                            // state so a *second* stacked hard
750                            // break still produces a blank line.
751                            last_was_hardbreak_flush = true;
752                        }
753                        // else: paragraph hasn't emitted anything
754                        // yet -- leading hard breaks collapse
755                        // silently regardless of whether prior
756                        // blocks have painted on the page.
757                        continue;
758                    }
759                }
760            } else {
761                break;
762            };
763
764            let space_w = if line.is_empty() {
765                0.0
766            } else {
767                word_owned.space_before_pt
768            };
769
770            // Word fits on the current line: append and continue.
771            if line_width_used + space_w + word_owned.width_pt <= line_width {
772                line_width_used += space_w + word_owned.width_pt;
773                line.push(word_owned);
774                continue;
775            }
776
777            if !line.is_empty() && space_w <= f32::EPSILON {
778                line_width_used += word_owned.width_pt;
779                line.push(word_owned);
780                continue;
781            }
782
783            // Word does not fit. First try a SHY break that lets us
784            // keep filling the current partially-occupied line.
785            if !line.is_empty()
786                && let Some(ShyBreak { prefix, suffix }) = try_shy_break(
787                    &word_owned,
788                    line_width - line_width_used - space_w,
789                    self.text.family.fallbacks,
790                )
791            {
792                line.push(prefix);
793                self.flush_line(&line, leading);
794                line.clear();
795                line_width_used = 0.0;
796                paragraph_emitted_line = true;
797                last_was_hardbreak_flush = false;
798                pending = Some(suffix);
799                continue;
800            }
801
802            // Either no SHY fit the current line, or the line was
803            // already empty. Flush any in-progress line and decide
804            // what to do on a fresh empty line.
805            if !line.is_empty() {
806                self.flush_line(&line, leading);
807                line.clear();
808                line_width_used = 0.0;
809                paragraph_emitted_line = true;
810                last_was_hardbreak_flush = false;
811            }
812
813            // On the now-empty line: if the word still doesn't fit
814            // the full column, try a SHY break against the empty
815            // line before falling back to cluster chopping.
816            if word_owned.width_pt > line_width {
817                if let Some(ShyBreak { prefix, suffix }) =
818                    try_shy_break(&word_owned, line_width, self.text.family.fallbacks)
819                {
820                    line.push(prefix);
821                    self.flush_line(&line, leading);
822                    line.clear();
823                    line_width_used = 0.0;
824                    paragraph_emitted_line = true;
825                    last_was_hardbreak_flush = false;
826                    pending = Some(suffix);
827                    continue;
828                }
829                self.flush_oversize_word(&word_owned, leading);
830                paragraph_emitted_line = true;
831                last_was_hardbreak_flush = false;
832                continue;
833            }
834
835            // Word fits the empty line as-is (a plain soft wrap).
836            line_width_used = word_owned.width_pt;
837            line.push(word_owned);
838        }
839        if !line.is_empty() {
840            self.flush_line(&line, leading);
841        }
842    }
843
844    /// Emit one line worth of words at `cursor_y`, advancing past it.
845    /// Computes the line's typographic metrics from `line` itself so
846    /// the caller doesn't have to track them in parallel.
847    fn flush_line(&mut self, line: &[Word], leading: f32) {
848        // The marker participates in the line's vertical metrics so a
849        // taller marker still gets the right baseline. In practice the
850        // marker uses the body face at body size, but folding it in
851        // costs nothing and avoids surprises if list layout grows the
852        // ability to override marker size later.
853        let marker_size = self
854            .pending_marker
855            .as_ref()
856            .map_or(0.0_f32, |m| m.word.size_pt);
857        let marker_ascent = self.pending_marker.as_ref().map_or(0.0_f32, |m| {
858            m.word
859                .subruns
860                .iter()
861                .map(|sub| ascent(sub.font, m.word.size_pt))
862                .fold(0.0_f32, f32::max)
863        });
864        let max_size = line.iter().map(|w| w.size_pt).fold(marker_size, f32::max);
865        let max_ascent = line
866            .iter()
867            .flat_map(|w| w.subruns.iter().map(|sub| ascent(sub.font, w.size_pt)))
868            .fold(marker_ascent, f32::max);
869
870        // First line on a page: drop the baseline by the line's
871        // ascent so the glyph tops sit at the top margin.
872        if !self.page_has_content {
873            self.cursor_y = self.page.margin + max_ascent;
874        }
875        // Page break if the baseline would fall below the bottom
876        // margin. Descent is small and absorbed by the bottom margin.
877        if self.cursor_y > self.page.height - self.page.margin {
878            self.start_new_page();
879            self.cursor_y = self.page.margin + max_ascent;
880        }
881
882        // The page is now settled for this line; bind any labels waiting on
883        // their first content to it (issue #72).
884        self.bind_pending_labels();
885        // A pending heading binds to this line too: `cursor_y` is the
886        // just-locked baseline, so `cursor_y - max_ascent` is the glyph
887        // tops measured from the page top (the outline destination).
888        self.bind_pending_outline(self.cursor_y - max_ascent);
889
890        // Marker (`•` / `1.` …) is drawn in the gutter to the left of
891        // `current_left_pt` once the baseline is locked. Consumed on
892        // emit so subsequent wrapped lines of the same item don't
893        // restamp the marker.
894        if let Some(marker) = self.pending_marker.take() {
895            let mut marker_x = marker.x_pt;
896            for sub in marker.word.subruns {
897                self.push_text_run(
898                    TextRun {
899                        x_pt: marker_x,
900                        baseline_from_top_pt: self.cursor_y,
901                        size_pt: marker.word.size_pt,
902                        font: sub.font,
903                        text: sub.text,
904                        actual_text: None,
905                        glyphs: sub.glyphs,
906                    },
907                    sub.advance_pt,
908                );
909                marker_x += sub.advance_pt;
910            }
911        }
912
913        let mut x = self.current_left_pt;
914        for (i, word) in line.iter().enumerate() {
915            if i > 0 {
916                x += word.space_before_pt;
917            }
918            // One TextRun per sub-run: same baseline, x advances by
919            // each sub-run's `advance_pt`. PDF emit's per-run `Tf`
920            // switch fires naturally at the font boundary between
921            // sub-runs (Latin → Math → Latin in `a≤b`-style runs).
922            for sub in &word.subruns {
923                self.push_text_run(
924                    TextRun {
925                        x_pt: x,
926                        baseline_from_top_pt: self.cursor_y,
927                        size_pt: word.size_pt,
928                        font: sub.font,
929                        text: sub.text.clone(),
930                        actual_text: word.actual_text.clone(),
931                        glyphs: sub.glyphs.clone(),
932                    },
933                    sub.advance_pt,
934                );
935                x += sub.advance_pt;
936            }
937        }
938        if let Some(trace) = &mut self.debug {
939            trace.line(self.current_page.number, self.cursor_y);
940        }
941        self.page_has_content = true;
942        self.cursor_y = max_size.mul_add(leading, self.cursor_y);
943    }
944
945    /// Emit a word that's wider than the column by chopping it on
946    /// already-shaped cluster boundaries. The word was shaped when it
947    /// was collected, so this avoids re-running rustybuzz for every
948    /// growing prefix of a degenerate long word.
949    fn flush_oversize_word(&mut self, word: &Word, leading: f32) {
950        let line_width = self.column_width_pt();
951        let mut chunk_text = String::with_capacity(word.text.len());
952        let mut chunk_width = 0.0_f32;
953        let mut chunk_subruns = Vec::new();
954        for cluster in word_clusters(word) {
955            if chunk_width + cluster.advance_pt > line_width && !chunk_subruns.is_empty() {
956                self.flush_oversize_chunk(
957                    std::mem::take(&mut chunk_text),
958                    chunk_width,
959                    std::mem::take(&mut chunk_subruns),
960                    word,
961                    leading,
962                );
963                chunk_width = 0.0;
964            }
965            chunk_text.push_str(&cluster.text);
966            chunk_width += cluster.advance_pt;
967            chunk_subruns.push(cluster);
968        }
969        if !chunk_subruns.is_empty() {
970            self.flush_oversize_chunk(chunk_text, chunk_width, chunk_subruns, word, leading);
971        }
972    }
973
974    fn flush_oversize_chunk(
975        &mut self,
976        text: String,
977        width_pt: f32,
978        subruns: Vec<WordSubRun>,
979        source: &Word,
980        leading: f32,
981    ) {
982        self.flush_line(
983            &[Word {
984                text,
985                actual_text: None,
986                space_before_pt: 0.0,
987                font: source.font,
988                size_pt: source.size_pt,
989                width_pt,
990                subruns,
991                shy_break_offsets: Vec::new(),
992            }],
993            leading,
994        );
995    }
996
997    fn start_new_page(&mut self) {
998        let next_number = self.current_page.number + 1;
999        let finished =
1000            std::mem::replace(&mut self.current_page, blank_page(next_number, self.page));
1001        self.pages.push(finished);
1002        self.cursor_y = self.page.margin;
1003        self.page_has_content = false;
1004    }
1005}
1006
1007fn starts_with_closing_punctuation(text: &str) -> bool {
1008    matches!(
1009        text.chars().next(),
1010        Some('.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '”' | '’' | '»')
1011    )
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    #![allow(
1017        clippy::unwrap_used,
1018        clippy::expect_used,
1019        reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
1020    )]
1021    use std::fmt::Write as _;
1022    use std::path::PathBuf;
1023
1024    use mos_core::{AttrMap, AttrValue, Document, NodeId, NodeKind, NodeSpec, SourceSpan};
1025
1026    use crate::types::BODY_SIZE_PT;
1027
1028    use super::*;
1029
1030    fn alloc_inline(doc: &mut Document, parent: NodeId, kind: NodeKind, text: &str) {
1031        let mut attrs = AttrMap::new();
1032        attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
1033        doc.alloc_child(
1034            parent,
1035            NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
1036                .with_attributes(attrs),
1037        );
1038    }
1039
1040    /// Tests that assert Base14 font variants on `TextRun` need to opt
1041    /// out of the default Noto Sans family. Prepend a `#set
1042    /// text(font: "Helvetica")` block so the family resolves to
1043    /// Base14 Helvetica.
1044    fn pin_helvetica(doc: &mut Document) {
1045        let mut attrs = AttrMap::new();
1046        attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
1047        attrs.insert(
1048            "set.arg.font".to_owned(),
1049            AttrValue::Str("Helvetica".to_owned()),
1050        );
1051        doc.alloc_child(
1052            doc.root,
1053            NodeSpec::new(
1054                NodeKind::Raw,
1055                SourceSpan::placeholder(PathBuf::from("test.mos")),
1056            )
1057            .with_attributes(attrs),
1058        );
1059    }
1060
1061    fn make_section(doc: &mut Document, level: i64, text: &str) -> NodeId {
1062        let mut attrs = AttrMap::new();
1063        attrs.insert("level".to_owned(), AttrValue::Int(level));
1064        let id = doc.alloc_child(
1065            doc.root,
1066            NodeSpec::new(
1067                NodeKind::Section,
1068                SourceSpan::placeholder(PathBuf::from("test.mos")),
1069            )
1070            .with_attributes(attrs),
1071        );
1072        alloc_inline(doc, id, NodeKind::Text, text);
1073        id
1074    }
1075
1076    fn make_paragraph(doc: &mut Document, text: &str) -> NodeId {
1077        let id = doc.alloc_child(
1078            doc.root,
1079            NodeSpec::new(
1080                NodeKind::Paragraph,
1081                SourceSpan::placeholder(PathBuf::from("test.mos")),
1082            ),
1083        );
1084        alloc_inline(doc, id, NodeKind::Text, text);
1085        id
1086    }
1087
1088    fn make_labelled_paragraph(doc: &mut Document, text: &str, label: &str) -> NodeId {
1089        let mut attrs = AttrMap::new();
1090        attrs.insert("label".to_owned(), AttrValue::Str(label.to_owned()));
1091        let id = doc.alloc_child(
1092            doc.root,
1093            NodeSpec::new(
1094                NodeKind::Paragraph,
1095                SourceSpan::placeholder(PathBuf::from("test.mos")),
1096            )
1097            .with_attributes(attrs),
1098        );
1099        alloc_inline(doc, id, NodeKind::Text, text);
1100        id
1101    }
1102
1103    fn make_raw_block(doc: &mut Document, text: &str) -> NodeId {
1104        let mut attrs = AttrMap::new();
1105        attrs.insert("raw.kind".to_owned(), AttrValue::Str("code".to_owned()));
1106        attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
1107        doc.alloc_child(
1108            doc.root,
1109            NodeSpec::new(
1110                NodeKind::Raw,
1111                SourceSpan::placeholder(PathBuf::from("test.mos")),
1112            )
1113            .with_attributes(attrs),
1114        )
1115    }
1116
1117    fn run_text<'a>(runs: &'a [TextRun], text: &str) -> &'a TextRun {
1118        runs.iter()
1119            .find(|run| run.text == text)
1120            .expect("run text not found")
1121    }
1122
1123    fn assert_runs_touch(left: &TextRun, right: &TextRun) {
1124        assert!(
1125            (left.baseline_from_top_pt - right.baseline_from_top_pt).abs() < 0.01,
1126            "runs are on different baselines: left={left:?}, right={right:?}"
1127        );
1128        let expected_x = left.x_pt + text_width(left.font, left.size_pt, &left.text);
1129        assert!(
1130            (right.x_pt - expected_x).abs() < 0.01,
1131            "expected {:?} to touch {:?}: expected x {expected_x:.3}, got {:.3}",
1132            right.text,
1133            left.text,
1134            right.x_pt
1135        );
1136    }
1137
1138    #[test]
1139    fn heading_then_paragraph_emits_runs_in_order() {
1140        let mut doc = Document::new(PathBuf::from("test.mos"));
1141        pin_helvetica(&mut doc);
1142        make_section(&mut doc, 1, "Hello");
1143        make_paragraph(&mut doc, "body");
1144        let result = LayoutEngine::new().layout(&doc);
1145        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1146        assert_eq!(result.graph.pages.len(), 1);
1147        let runs = &result.graph.pages[0].runs;
1148        assert!(runs.len() >= 2, "expected at least 2 runs, got {runs:?}");
1149        // Heading first, body below it.
1150        assert!(matches!(
1151            runs[0].font,
1152            Font::Base14(Base14Font::HelveticaBold)
1153        ));
1154        assert_eq!(runs[0].text, "Hello");
1155        let body_run = runs.iter().find(|r| r.text == "body").expect("body run");
1156        assert!(matches!(body_run.font, Font::Base14(Base14Font::Helvetica)));
1157        assert!(body_run.baseline_from_top_pt > runs[0].baseline_from_top_pt);
1158    }
1159
1160    #[test]
1161    fn long_paragraph_paginates() {
1162        // Build a paragraph long enough to spill a second page at
1163        // body size + leading 1.35.
1164        let mut doc = Document::new(PathBuf::from("test.mos"));
1165        // ~150 lines of text at 11pt × 1.35 leading ≈ 2227 pt of
1166        // copy. A4 minus margins is roughly 706 pt of vertical
1167        // space, so we expect ≥ 3 pages.
1168        let mut text = String::new();
1169        for i in 0..1500 {
1170            let _ = write!(text, "word{i} ");
1171        }
1172        make_paragraph(&mut doc, text.trim());
1173        let result = LayoutEngine::new().layout(&doc);
1174        assert!(
1175            result.graph.pages.len() >= 2,
1176            "expected pagination, got {} page(s)",
1177            result.graph.pages.len()
1178        );
1179    }
1180
1181    #[test]
1182    fn page_boundary_signatures_are_stable_and_change_with_pagination() {
1183        fn lay_out(word_count: usize) -> LayoutResult {
1184            let mut doc = Document::new(PathBuf::from("test.mos"));
1185            let mut text = String::new();
1186            for i in 0..word_count {
1187                let _ = write!(text, "word{i} ");
1188            }
1189            make_paragraph(&mut doc, text.trim());
1190            LayoutEngine::new().layout(&doc)
1191        }
1192
1193        // Deterministic for unchanged input: the same document laid out twice
1194        // signs identically and diverges nowhere.
1195        let first = lay_out(1500);
1196        let again = lay_out(1500);
1197        assert!(first.graph.pages.len() >= 2, "expected a multi-page layout");
1198        assert_eq!(
1199            first.page_boundary_signatures(),
1200            again.page_boundary_signatures(),
1201        );
1202        assert_eq!(
1203            first
1204                .page_boundary_signatures()
1205                .first_divergence(&again.page_boundary_signatures()),
1206            None,
1207        );
1208
1209        // Appending copy reflows pagination, so the signatures must diverge at
1210        // some page.
1211        let longer = lay_out(1540);
1212        let base_sig = first.page_boundary_signatures();
1213        let longer_sig = longer.page_boundary_signatures();
1214        assert_ne!(base_sig, longer_sig);
1215        assert!(longer_sig.pages().len() >= base_sig.pages().len());
1216        assert!(base_sig.first_divergence(&longer_sig).is_some());
1217    }
1218
1219    #[test]
1220    fn label_pages_maps_a_first_block_to_page_one() {
1221        let mut doc = Document::new(PathBuf::from("test.mos"));
1222        make_labelled_paragraph(&mut doc, "Introduction", "intro");
1223        let result = LayoutEngine::new().layout(&doc);
1224        assert_eq!(result.label_pages.get("intro").copied(), Some(1));
1225    }
1226
1227    #[test]
1228    fn label_pages_records_the_start_page_after_a_break() {
1229        // A page-filling paragraph, then a labelled paragraph with a unique
1230        // word. The label must bind to the page its content actually lands on
1231        // (post-break), not the page the cursor sat on before the break.
1232        let mut doc = Document::new(PathBuf::from("test.mos"));
1233        let mut filler = String::new();
1234        for i in 0..1500 {
1235            let _ = write!(filler, "word{i} ");
1236        }
1237        make_paragraph(&mut doc, filler.trim());
1238        make_labelled_paragraph(&mut doc, "ZZUNIQUE", "tail");
1239
1240        let result = LayoutEngine::new().layout(&doc);
1241        assert!(
1242            result.graph.pages.len() >= 2,
1243            "expected a multi-page layout"
1244        );
1245
1246        let recorded = result.label_pages.get("tail").copied();
1247        // Cross-check: the recorded page is exactly the page whose runs contain
1248        // the labelled paragraph's text.
1249        let actual = result
1250            .graph
1251            .pages
1252            .iter()
1253            .find(|page| page.runs.iter().any(|run| run.text == "ZZUNIQUE"))
1254            .map(|page| page.number);
1255        assert_eq!(recorded, actual);
1256        assert!(recorded.is_some_and(|page| page >= 2), "{recorded:?}");
1257    }
1258
1259    #[test]
1260    fn label_pages_omits_unlabelled_blocks_and_keeps_first_occurrence() {
1261        let mut doc = Document::new(PathBuf::from("test.mos"));
1262        make_paragraph(&mut doc, "unlabelled");
1263        make_labelled_paragraph(&mut doc, "first", "dup");
1264        make_labelled_paragraph(&mut doc, "second", "dup");
1265        let result = LayoutEngine::new().layout(&doc);
1266        // One entry per distinct label; the unlabelled block contributes none.
1267        assert_eq!(result.label_pages.len(), 1);
1268        assert_eq!(result.label_pages.get("dup").copied(), Some(1));
1269    }
1270
1271    #[test]
1272    fn label_pages_omits_a_labelled_block_that_emits_no_content() {
1273        let mut doc = Document::new(PathBuf::from("test.mos"));
1274        // A labelled empty paragraph commits no content (no words to flush)...
1275        make_labelled_paragraph(&mut doc, "", "ghost");
1276        // ...as does a labelled unsupported block (Table is ignored in MVP 0).
1277        let mut table_attrs = AttrMap::new();
1278        table_attrs.insert("label".to_owned(), AttrValue::Str("phantom".to_owned()));
1279        doc.alloc_child(
1280            doc.root,
1281            NodeSpec::new(
1282                NodeKind::Table,
1283                SourceSpan::placeholder(PathBuf::from("test.mos")),
1284            )
1285            .with_attributes(table_attrs),
1286        );
1287        // ...then a normal labelled paragraph that does.
1288        make_labelled_paragraph(&mut doc, "real text", "real");
1289
1290        let result = LayoutEngine::new().layout(&doc);
1291        // No-content labels must not leak onto a later block's page: they are
1292        // simply absent. Only the real paragraph maps, to its own page.
1293        assert!(!result.label_pages.contains_key("ghost"));
1294        assert!(!result.label_pages.contains_key("phantom"));
1295        assert_eq!(result.label_pages.get("real").copied(), Some(1));
1296        assert_eq!(result.label_pages.len(), 1);
1297    }
1298
1299    fn make_numbered_section(doc: &mut Document, level: i64, number: &str, text: &str) -> NodeId {
1300        let mut attrs = AttrMap::new();
1301        attrs.insert("level".to_owned(), AttrValue::Int(level));
1302        attrs.insert("number".to_owned(), AttrValue::Str(number.to_owned()));
1303        let id = doc.alloc_child(
1304            doc.root,
1305            NodeSpec::new(
1306                NodeKind::Section,
1307                SourceSpan::placeholder(PathBuf::from("test.mos")),
1308            )
1309            .with_attributes(attrs),
1310        );
1311        alloc_inline(doc, id, NodeKind::Text, text);
1312        id
1313    }
1314
1315    #[test]
1316    fn outline_captures_headings_in_document_order() {
1317        let mut doc = Document::new(PathBuf::from("test.mos"));
1318        make_numbered_section(&mut doc, 1, "1", "Alpha");
1319        make_numbered_section(&mut doc, 2, "1.1", "Beta");
1320        make_numbered_section(&mut doc, 3, "1.1.1", "Gamma");
1321        let result = LayoutEngine::new().layout(&doc);
1322        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1323
1324        let outline = &result.graph.outline;
1325        assert_eq!(outline.len(), 3, "expected 3 entries, got {outline:?}");
1326        assert_eq!(
1327            outline.iter().map(|e| e.level).collect::<Vec<_>>(),
1328            vec![1, 2, 3],
1329        );
1330        assert_eq!(
1331            outline.iter().map(|e| e.title.as_str()).collect::<Vec<_>>(),
1332            vec!["1 Alpha", "1.1 Beta", "1.1.1 Gamma"],
1333        );
1334        // All three headings sit on page 1, in top-to-bottom document
1335        // order: page_index non-decreasing, glyph tops strictly ascending.
1336        for pair in outline.windows(2) {
1337            assert!(
1338                pair[1].page_index >= pair[0].page_index,
1339                "page_index went backwards: {outline:?}"
1340            );
1341            assert!(
1342                pair[1].top_from_top_pt > pair[0].top_from_top_pt,
1343                "positions not ascending: {outline:?}"
1344            );
1345        }
1346    }
1347
1348    #[test]
1349    fn outline_title_includes_section_number() {
1350        let mut doc = Document::new(PathBuf::from("test.mos"));
1351        make_numbered_section(&mut doc, 1, "1", "Foo");
1352        let result = LayoutEngine::new().layout(&doc);
1353        let outline = &result.graph.outline;
1354        assert_eq!(outline.len(), 1);
1355        assert_eq!(outline[0].title, "1 Foo");
1356        // The bookmark title uses a bare number with no trailing dot,
1357        // distinct from the rendered "1." run in the page stream.
1358        assert!(
1359            !outline[0].title.ends_with('.'),
1360            "bookmark title must not carry the render-only trailing dot"
1361        );
1362    }
1363
1364    #[test]
1365    fn heading_across_page_break_binds_start_page() {
1366        // Fill page 1 with a long paragraph, then a heading. The heading
1367        // must bind to the page its first line actually lands on
1368        // (post-break), mirroring label_pages semantics — never page 1.
1369        let mut doc = Document::new(PathBuf::from("test.mos"));
1370        let mut filler = String::new();
1371        for i in 0..1500 {
1372            let _ = write!(filler, "word{i} ");
1373        }
1374        make_paragraph(&mut doc, filler.trim());
1375        make_numbered_section(&mut doc, 1, "2", "ZZLATER");
1376
1377        let result = LayoutEngine::new().layout(&doc);
1378        assert!(
1379            result.graph.pages.len() >= 2,
1380            "expected a multi-page layout"
1381        );
1382
1383        let outline = &result.graph.outline;
1384        assert_eq!(outline.len(), 1, "expected one heading entry");
1385        // Cross-check against the page whose runs actually contain the
1386        // heading text; page_index is 0-based, page.number is 1-based.
1387        let landing = result
1388            .graph
1389            .pages
1390            .iter()
1391            .find(|page| page.runs.iter().any(|run| run.text == "ZZLATER"))
1392            .map(|page| page.number)
1393            .expect("heading run must exist on some page");
1394        assert_eq!(
1395            outline[0].page_index,
1396            usize::try_from(landing - 1).unwrap_or(0)
1397        );
1398        assert!(
1399            outline[0].page_index >= 1,
1400            "heading after a full page must not bind to page 1"
1401        );
1402    }
1403
1404    #[test]
1405    fn document_without_headings_has_empty_outline() {
1406        let mut doc = Document::new(PathBuf::from("test.mos"));
1407        make_paragraph(&mut doc, "just a paragraph");
1408        make_paragraph(&mut doc, "another one");
1409        let result = LayoutEngine::new().layout(&doc);
1410        assert!(
1411            result.graph.outline.is_empty(),
1412            "heading-free doc must have no outline: {:?}",
1413            result.graph.outline
1414        );
1415    }
1416
1417    #[test]
1418    fn empty_heading_produces_no_outline_entry() {
1419        let mut doc = Document::new(PathBuf::from("test.mos"));
1420        // A section with no inline text emits no glyphs and no bookmark.
1421        make_section(&mut doc, 1, "");
1422        // A real paragraph ensures the page still has content, so the
1423        // absence of an entry is about the empty heading specifically.
1424        make_paragraph(&mut doc, "body");
1425        let result = LayoutEngine::new().layout(&doc);
1426        assert!(
1427            result.graph.outline.is_empty(),
1428            "empty heading must not create an outline entry: {:?}",
1429            result.graph.outline
1430        );
1431    }
1432
1433    #[test]
1434    fn emphasis_run_uses_oblique() {
1435        let mut doc = Document::new(PathBuf::from("test.mos"));
1436        pin_helvetica(&mut doc);
1437        let para = make_paragraph(&mut doc, "before");
1438        alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1439        alloc_inline(&mut doc, para, NodeKind::Text, "after");
1440        let result = LayoutEngine::new().layout(&doc);
1441        let runs = &result.graph.pages[0].runs;
1442        let italic = runs
1443            .iter()
1444            .find(|r| r.text == "italic")
1445            .expect("italic run");
1446        assert!(matches!(
1447            italic.font,
1448            Font::Base14(Base14Font::HelveticaOblique)
1449        ));
1450    }
1451
1452    #[test]
1453    fn bold_italic_run_uses_bold_oblique() {
1454        let mut doc = Document::new(PathBuf::from("test.mos"));
1455        pin_helvetica(&mut doc);
1456        let para = make_paragraph(&mut doc, "before");
1457        alloc_inline(&mut doc, para, NodeKind::BoldItalic, "both");
1458        alloc_inline(&mut doc, para, NodeKind::Text, "after");
1459        let result = LayoutEngine::new().layout(&doc);
1460        let runs = &result.graph.pages[0].runs;
1461        let both = runs
1462            .iter()
1463            .find(|r| r.text == "both")
1464            .expect("bold-italic run");
1465        assert!(matches!(
1466            both.font,
1467            Font::Base14(Base14Font::HelveticaBoldOblique)
1468        ));
1469    }
1470
1471    #[test]
1472    fn styled_and_code_punctuation_do_not_get_synthetic_glue() {
1473        let mut doc = Document::new(PathBuf::from("test.mos"));
1474        pin_helvetica(&mut doc);
1475        let para = make_empty_paragraph(&mut doc);
1476        alloc_inline(&mut doc, para, NodeKind::Text, "See ");
1477        alloc_inline(&mut doc, para, NodeKind::Strong, "bold");
1478        alloc_inline(&mut doc, para, NodeKind::Text, ". ");
1479        alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1480        alloc_inline(&mut doc, para, NodeKind::Text, ", ");
1481        alloc_inline(&mut doc, para, NodeKind::Raw, "code");
1482        alloc_inline(&mut doc, para, NodeKind::Text, "?");
1483
1484        let result = LayoutEngine::new().layout(&doc);
1485        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1486        let runs = &result.graph.pages[0].runs;
1487        assert_runs_touch(run_text(runs, "bold"), run_text(runs, "."));
1488        assert_runs_touch(run_text(runs, "italic"), run_text(runs, ","));
1489        assert_runs_touch(run_text(runs, "code"), run_text(runs, "?"));
1490    }
1491
1492    #[test]
1493    fn references_citations_and_brackets_do_not_get_synthetic_glue() {
1494        let mut doc = Document::new(PathBuf::from("test.mos"));
1495        pin_helvetica(&mut doc);
1496        let para = make_empty_paragraph(&mut doc);
1497        alloc_inline(&mut doc, para, NodeKind::Text, "See ");
1498        alloc_inline(&mut doc, para, NodeKind::Reference, "1.2");
1499        alloc_inline(&mut doc, para, NodeKind::Text, ", ");
1500        alloc_inline(&mut doc, para, NodeKind::Citation, "[1]");
1501        alloc_inline(&mut doc, para, NodeKind::Text, ". (");
1502        alloc_inline(&mut doc, para, NodeKind::Strong, "bold");
1503        alloc_inline(&mut doc, para, NodeKind::Text, ") [");
1504        alloc_inline(&mut doc, para, NodeKind::Emphasis, "italic");
1505        alloc_inline(&mut doc, para, NodeKind::Text, "]");
1506
1507        let result = LayoutEngine::new().layout(&doc);
1508        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1509        let runs = &result.graph.pages[0].runs;
1510        assert_runs_touch(run_text(runs, "1.2"), run_text(runs, ","));
1511        assert_runs_touch(run_text(runs, "[1]"), run_text(runs, "."));
1512        assert_runs_touch(run_text(runs, "("), run_text(runs, "bold"));
1513        assert_runs_touch(run_text(runs, "bold"), run_text(runs, ")"));
1514        assert_runs_touch(run_text(runs, "["), run_text(runs, "italic"));
1515        assert_runs_touch(run_text(runs, "italic"), run_text(runs, "]"));
1516    }
1517
1518    #[test]
1519    fn leading_and_trailing_ascii_whitespace_do_not_emit_empty_runs() {
1520        let mut doc = Document::new(PathBuf::from("test.mos"));
1521        pin_helvetica(&mut doc);
1522        make_paragraph(&mut doc, " \n\t foo  \n");
1523
1524        let result = LayoutEngine::new().layout(&doc);
1525        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1526        let runs = &result.graph.pages[0].runs;
1527        assert_eq!(
1528            runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
1529            vec!["foo"],
1530            "got {runs:?}"
1531        );
1532    }
1533
1534    #[test]
1535    fn multiple_ascii_whitespace_runs_collapse_to_one_glue() {
1536        let mut doc = Document::new(PathBuf::from("test.mos"));
1537        pin_helvetica(&mut doc);
1538        make_paragraph(&mut doc, "foo  \n\t  bar");
1539
1540        let result = LayoutEngine::new().layout(&doc);
1541        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1542        let runs = &result.graph.pages[0].runs;
1543        let foo = run_text(runs, "foo");
1544        let bar = run_text(runs, "bar");
1545        let foo_w = text_width(foo.font, foo.size_pt, "foo");
1546        let space_w = text_width(foo.font, foo.size_pt, " ");
1547        let gap = bar.x_pt - (foo.x_pt + foo_w);
1548        assert!(
1549            (gap - space_w).abs() < 0.01,
1550            "expected one collapsed space ({space_w:.3}pt), got {gap:.3}pt"
1551        );
1552    }
1553
1554    #[test]
1555    fn punctuation_without_source_space_does_not_wrap_alone() {
1556        let mut doc = Document::new(PathBuf::from("test.mos"));
1557        pin_helvetica(&mut doc);
1558        let font = Font::Base14(Base14Font::Helvetica);
1559        let bold = Font::Base14(Base14Font::HelveticaBold);
1560        let target =
1561            text_width(bold, BODY_SIZE_PT, "word") + text_width(font, BODY_SIZE_PT, ".") + 0.5;
1562        pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
1563        let para = make_empty_paragraph(&mut doc);
1564        alloc_inline(&mut doc, para, NodeKind::Text, "lead ");
1565        alloc_inline(&mut doc, para, NodeKind::Strong, "word");
1566        alloc_inline(&mut doc, para, NodeKind::Text, ".");
1567
1568        let result = LayoutEngine::new().layout(&doc);
1569        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1570        let runs = &result.graph.pages[0].runs;
1571        let lead = run_text(runs, "lead");
1572        let word = run_text(runs, "word");
1573        let dot = run_text(runs, ".");
1574        assert!(word.baseline_from_top_pt > lead.baseline_from_top_pt);
1575        assert_runs_touch(word, dot);
1576    }
1577
1578    #[test]
1579    fn source_line_wrap_before_closing_punctuation_does_not_create_glue() {
1580        let mut doc = Document::new(PathBuf::from("test.mos"));
1581        let para = make_empty_paragraph(&mut doc);
1582        alloc_inline(&mut doc, para, NodeKind::Strong, "κόσμε");
1583        alloc_inline(&mut doc, para, NodeKind::Text, "\n. None");
1584
1585        let result = LayoutEngine::new().layout(&doc);
1586        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1587        let runs = &result.graph.pages[0].runs;
1588        assert_runs_touch(run_text(runs, "κόσμε"), run_text(runs, "."));
1589    }
1590
1591    #[test]
1592    fn hard_break_after_pending_source_space_drops_that_space() {
1593        let mut doc = Document::new(PathBuf::from("test.mos"));
1594        pin_helvetica(&mut doc);
1595        let para = make_empty_paragraph(&mut doc);
1596        alloc_inline(&mut doc, para, NodeKind::Text, "foo ");
1597        alloc_hardbreak(&mut doc, para);
1598        alloc_inline(&mut doc, para, NodeKind::Text, "bar");
1599
1600        let result = LayoutEngine::new().layout(&doc);
1601        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1602        let runs = &result.graph.pages[0].runs;
1603        let foo = run_text(runs, "foo");
1604        let bar = run_text(runs, "bar");
1605        assert!((foo.x_pt - bar.x_pt).abs() < 0.01, "got {runs:?}");
1606        assert!(bar.baseline_from_top_pt > foo.baseline_from_top_pt);
1607    }
1608
1609    #[test]
1610    fn runs_stay_within_horizontal_margins() {
1611        let mut doc = Document::new(PathBuf::from("test.mos"));
1612        make_paragraph(
1613            &mut doc,
1614            "the quick brown fox jumps over the lazy dog the quick brown fox",
1615        );
1616        let result = LayoutEngine::new().layout(&doc);
1617        let runs = &result.graph.pages[0].runs;
1618        assert!(!runs.is_empty());
1619        let right = A4_WIDTH_PT - MARGIN_PT;
1620        for run in runs {
1621            assert!(run.x_pt >= MARGIN_PT - 1e-6, "x={}", run.x_pt);
1622            let end = run.x_pt + text_width(run.font, run.size_pt, &run.text);
1623            assert!(end <= right + 1e-3, "end={end} right={right}");
1624        }
1625    }
1626
1627    #[test]
1628    fn cyrillic_flows_through_embedded_default_without_substitution() {
1629        // The default text family is bundled Noto Sans, which covers
1630        // Cyrillic. The run carries the original UTF-8 text verbatim
1631        // and a non-empty shaped glyph stream; no substitution diagnostic (the warning
1632        // is retired) and no `?` substitution.
1633        let mut doc = Document::new(PathBuf::from("test.mos"));
1634        make_paragraph(&mut doc, "Привет");
1635        let result = LayoutEngine::new().layout(&doc);
1636        assert!(
1637            result.diagnostics.is_empty(),
1638            "expected no diagnostics, got {:?}",
1639            result.diagnostics
1640        );
1641        let runs = &result.graph.pages[0].runs;
1642        let cyr = runs.iter().find(|r| r.text == "Привет").expect("cyr run");
1643        assert!(matches!(cyr.font, Font::Embedded(_)));
1644        assert!(!cyr.glyphs.is_empty(), "expected shaped glyphs");
1645    }
1646
1647    #[test]
1648    fn decomposed_text_is_normalized_before_shaping() {
1649        let mut doc = Document::new(PathBuf::from("test.mos"));
1650        make_paragraph(&mut doc, "S\u{0326}");
1651
1652        let result = LayoutEngine::new().layout(&doc);
1653
1654        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1655        let run = result.graph.pages[0]
1656            .runs
1657            .iter()
1658            .find(|r| r.text == "\u{0218}")
1659            .expect("normalized run");
1660        assert!(matches!(run.font, Font::Embedded(_)));
1661        assert!(!run.glyphs.is_empty(), "expected shaped glyphs");
1662    }
1663
1664    #[test]
1665    fn extended_latin_passes_through_without_warning() {
1666        // Polish + Czech: every char is either a WinAnsi native
1667        // (`ó`, `r`, `i`, …) or an extended glyph reachable via
1668        // `extended_glyph_name` (`ł`, `Ł`, `ě`, `ř`; `ž` is WinAnsi at 0x9E).
1669        // No substitution, no diagnostic.
1670        let mut doc = Document::new(PathBuf::from("test.mos"));
1671        make_paragraph(&mut doc, "Łódź — Příliš");
1672        let result = LayoutEngine::new().layout(&doc);
1673        assert!(
1674            result.diagnostics.is_empty(),
1675            "expected no diagnostics, got {:?}",
1676            result.diagnostics
1677        );
1678        let text: String = result.graph.pages[0]
1679            .runs
1680            .iter()
1681            .map(|r| r.text.as_str())
1682            .collect::<Vec<_>>()
1683            .join(" ");
1684        assert!(text.contains("Łódź"), "got {text}");
1685        assert!(text.contains("Příliš"), "got {text}");
1686    }
1687
1688    #[test]
1689    fn cjk_and_emoji_flow_through_without_diagnostics() {
1690        // The substitution warning is retired. CJK and emoji are not covered by bundled
1691        // Noto Sans Regular either, but the layout engine no longer
1692        // filters them: they pass through to the shaped glyph stream
1693        // (rustybuzz emits `.notdef` glyphs for missing coverage,
1694        // which the PDF backend embeds harmlessly).
1695        let mut doc = Document::new(PathBuf::from("test.mos"));
1696        make_paragraph(&mut doc, "日本語 🦀");
1697        let result = LayoutEngine::new().layout(&doc);
1698        assert!(
1699            result.diagnostics.is_empty(),
1700            "uncovered glyphs should flow through without a diagnostic, got {:?}",
1701            result.diagnostics
1702        );
1703    }
1704
1705    #[test]
1706    fn winansi_chars_pass_through_without_warning() {
1707        // café / §1 / Straße all live in WinAnsi (Latin-1 + section
1708        // sign + germandbls). No substitution, no diagnostic.
1709        let mut doc = Document::new(PathBuf::from("test.mos"));
1710        make_paragraph(&mut doc, "café §1 Straße");
1711        let result = LayoutEngine::new().layout(&doc);
1712        assert!(
1713            result.diagnostics.is_empty(),
1714            "expected no diagnostics, got {:?}",
1715            result.diagnostics
1716        );
1717        let text: String = result.graph.pages[0]
1718            .runs
1719            .iter()
1720            .map(|r| r.text.as_str())
1721            .collect::<Vec<_>>()
1722            .join(" ");
1723        assert!(text.contains("café"), "got {text}");
1724        assert!(text.contains("Straße"), "got {text}");
1725    }
1726
1727    #[test]
1728    fn empty_document_emits_one_blank_page() {
1729        let doc = Document::new(PathBuf::from("test.mos"));
1730        let result = LayoutEngine::new().layout(&doc);
1731        assert_eq!(result.graph.pages.len(), 1);
1732        assert!(result.graph.pages[0].runs.is_empty());
1733    }
1734
1735    #[test]
1736    fn raw_inline_uses_courier() {
1737        let mut doc = Document::new(PathBuf::from("test.mos"));
1738        pin_helvetica(&mut doc);
1739        let para = make_paragraph(&mut doc, "before");
1740        alloc_inline(&mut doc, para, NodeKind::Raw, "code");
1741        alloc_inline(&mut doc, para, NodeKind::Text, "after");
1742        let result = LayoutEngine::new().layout(&doc);
1743        let runs = &result.graph.pages[0].runs;
1744        let code_run = runs.iter().find(|r| r.text == "code").expect("code run");
1745        assert!(matches!(code_run.font, Font::Base14(Base14Font::Courier)));
1746        // Adjacent runs stay in the default Helvetica face so the
1747        // engine isn't accidentally promoting everything to Courier.
1748        assert!(matches!(
1749            runs.iter().find(|r| r.text == "before").unwrap().font,
1750            Font::Base14(Base14Font::Helvetica)
1751        ));
1752    }
1753
1754    #[test]
1755    fn raw_block_tabs_render_as_spaces() {
1756        let mut doc = Document::new(PathBuf::from("test.mos"));
1757        make_raw_block(&mut doc, "\tprintln(\"hello\");");
1758
1759        let result = LayoutEngine::new().layout(&doc);
1760
1761        let rendered = result.graph.pages[0]
1762            .runs
1763            .iter()
1764            .map(|run| run.text.as_str())
1765            .collect::<String>();
1766        assert!(
1767            !rendered.contains('\t'),
1768            "raw block tabs should be expanded before shaping: {rendered:?}"
1769        );
1770        assert!(
1771            rendered.contains("    println"),
1772            "expected four-space tab expansion, got {rendered:?}"
1773        );
1774        assert!(
1775            result.graph.pages[0]
1776                .runs
1777                .iter()
1778                .any(|run| run.actual_text.as_deref() == Some("\tprintln(\"hello\");")),
1779            "raw block tabs should retain their original text for extraction"
1780        );
1781    }
1782
1783    #[test]
1784    fn raw_block_leading_blank_line_preserves_spacing() {
1785        let mut doc = Document::new(PathBuf::from("test.mos"));
1786        make_raw_block(&mut doc, "\ncode");
1787
1788        let result = LayoutEngine::new().layout(&doc);
1789
1790        let first_run = result.graph.pages[0]
1791            .runs
1792            .first()
1793            .expect("raw block should emit text after the leading blank");
1794        let expected_baseline = BODY_SIZE_PT.mul_add(
1795            BODY_LEADING,
1796            MARGIN_PT + ascent(FontFamily::noto_sans().monospace, BODY_SIZE_PT),
1797        );
1798        assert!(
1799            (first_run.baseline_from_top_pt - expected_baseline).abs() < 0.01,
1800            "baseline {}, expected {expected_baseline}",
1801            first_run.baseline_from_top_pt
1802        );
1803    }
1804
1805    #[test]
1806    fn heading_levels_pick_distinct_sizes() {
1807        let mut doc = Document::new(PathBuf::from("test.mos"));
1808        make_section(&mut doc, 1, "H1");
1809        make_section(&mut doc, 2, "H2");
1810        make_section(&mut doc, 3, "H3");
1811        let result = LayoutEngine::new().layout(&doc);
1812        let runs = &result.graph.pages[0].runs;
1813        let h1 = runs.iter().find(|r| r.text == "H1").expect("H1 run");
1814        let h2 = runs.iter().find(|r| r.text == "H2").expect("H2 run");
1815        let h3 = runs.iter().find(|r| r.text == "H3").expect("H3 run");
1816        assert!((h1.size_pt - HEADING_SIZES_PT[0]).abs() < f32::EPSILON);
1817        assert!((h2.size_pt - HEADING_SIZES_PT[1]).abs() < f32::EPSILON);
1818        assert!((h3.size_pt - HEADING_SIZES_PT[2]).abs() < f32::EPSILON);
1819        // Each level is strictly smaller than the one above it.
1820        assert!(h1.size_pt > h2.size_pt);
1821        assert!(h2.size_pt > h3.size_pt);
1822        // Vertical order matches source order.
1823        assert!(h1.baseline_from_top_pt < h2.baseline_from_top_pt);
1824        assert!(h2.baseline_from_top_pt < h3.baseline_from_top_pt);
1825    }
1826
1827    #[test]
1828    fn heading_after_long_paragraph_paginates_correctly() {
1829        // A paragraph long enough to span multiple pages, followed
1830        // by a heading. The heading must appear *after* every
1831        // paragraph word in document order, and the first paragraph
1832        // word and the heading must end up on different pages.
1833        let mut doc = Document::new(PathBuf::from("test.mos"));
1834        pin_helvetica(&mut doc);
1835        let mut text = String::new();
1836        for i in 0..1500 {
1837            let _ = write!(text, "word{i} ");
1838        }
1839        make_paragraph(&mut doc, text.trim());
1840        make_section(&mut doc, 1, "After");
1841        let result = LayoutEngine::new().layout(&doc);
1842        assert!(
1843            result.graph.pages.len() >= 2,
1844            "expected pagination, got {} page(s)",
1845            result.graph.pages.len()
1846        );
1847        // Locate the heading and the very first paragraph word.
1848        let mut heading_page: Option<u32> = None;
1849        let mut first_word_page: Option<u32> = None;
1850        for page in &result.graph.pages {
1851            for run in &page.runs {
1852                if run.text == "After"
1853                    && matches!(run.font, Font::Base14(Base14Font::HelveticaBold))
1854                {
1855                    heading_page = Some(page.number);
1856                }
1857                if run.text == "word0" && first_word_page.is_none() {
1858                    first_word_page = Some(page.number);
1859                }
1860            }
1861        }
1862        let heading_page = heading_page.expect("heading run not emitted");
1863        let first_word_page = first_word_page.expect("first paragraph word not emitted");
1864        assert!(
1865            heading_page > first_word_page,
1866            "heading on page {heading_page}, first paragraph word on page {first_word_page}"
1867        );
1868    }
1869
1870    #[test]
1871    fn heading_with_number_attribute_emits_prefix_run() {
1872        // Resolver writes `number = "2.1"` onto a section node; layout
1873        // must emit a leading bold run with that number plus a trailing
1874        // dot, ahead of the heading text.
1875        let mut doc = Document::new(PathBuf::from("test.mos"));
1876        pin_helvetica(&mut doc);
1877        let mut attrs = AttrMap::new();
1878        attrs.insert("level".to_owned(), AttrValue::Int(2));
1879        attrs.insert("number".to_owned(), AttrValue::Str("2.1".to_owned()));
1880        let section = doc.alloc_child(
1881            doc.root,
1882            NodeSpec::new(
1883                NodeKind::Section,
1884                SourceSpan::placeholder(PathBuf::from("test.mos")),
1885            )
1886            .with_attributes(attrs),
1887        );
1888        alloc_inline(&mut doc, section, NodeKind::Text, "Background");
1889        let result = LayoutEngine::new().layout(&doc);
1890        let runs = &result.graph.pages[0].runs;
1891        assert!(matches!(
1892            runs[0].font,
1893            Font::Base14(Base14Font::HelveticaBold)
1894        ));
1895        assert_eq!(runs[0].text, "2.1.");
1896        assert!(runs.iter().any(|r| r.text == "Background"));
1897        // The number's baseline matches the title's baseline because
1898        // they live on the same line.
1899        let title = runs.iter().find(|r| r.text == "Background").unwrap();
1900        assert!((runs[0].baseline_from_top_pt - title.baseline_from_top_pt).abs() < 1e-3);
1901    }
1902
1903    #[test]
1904    fn numbered_heading_prefix_and_title_have_one_space_gap() {
1905        let mut doc = Document::new(PathBuf::from("test.mos"));
1906        pin_helvetica(&mut doc);
1907        let mut attrs = AttrMap::new();
1908        attrs.insert("level".to_owned(), AttrValue::Int(2));
1909        attrs.insert("number".to_owned(), AttrValue::Str("2.1".to_owned()));
1910        let section = doc.alloc_child(
1911            doc.root,
1912            NodeSpec::new(
1913                NodeKind::Section,
1914                SourceSpan::placeholder(PathBuf::from("test.mos")),
1915            )
1916            .with_attributes(attrs),
1917        );
1918        alloc_inline(&mut doc, section, NodeKind::Text, "Background");
1919
1920        let result = LayoutEngine::new().layout(&doc);
1921        let runs = &result.graph.pages[0].runs;
1922        let prefix = run_text(runs, "2.1.");
1923        let title = run_text(runs, "Background");
1924        let prefix_w = text_width(prefix.font, prefix.size_pt, &prefix.text);
1925        let expected_gap = text_width(prefix.font, prefix.size_pt, " ");
1926        let gap = title.x_pt - (prefix.x_pt + prefix_w);
1927        assert!(
1928            (gap - expected_gap).abs() < 0.01,
1929            "expected one heading space ({expected_gap:.3}pt), got {gap:.3}pt"
1930        );
1931    }
1932
1933    #[test]
1934    fn reference_node_renders_resolved_text() {
1935        // A `Reference` node with a `text` attribute (set by the
1936        // resolver) flows through `collect_words` like any other inline
1937        //; no separate code path. The font defaults to the body face.
1938        let mut doc = Document::new(PathBuf::from("test.mos"));
1939        pin_helvetica(&mut doc);
1940        let para = make_paragraph(&mut doc, "see");
1941        let mut attrs = AttrMap::new();
1942        attrs.insert("label".to_owned(), AttrValue::Str("intro".to_owned()));
1943        attrs.insert("text".to_owned(), AttrValue::Str("1.2".to_owned()));
1944        doc.alloc_child(
1945            para,
1946            NodeSpec::new(
1947                NodeKind::Reference,
1948                SourceSpan::placeholder(PathBuf::from("test.mos")),
1949            )
1950            .with_attributes(attrs),
1951        );
1952        let result = LayoutEngine::new().layout(&doc);
1953        let runs = &result.graph.pages[0].runs;
1954        let reference = runs.iter().find(|r| r.text == "1.2").expect("ref run");
1955        assert!(matches!(
1956            reference.font,
1957            Font::Base14(Base14Font::Helvetica)
1958        ));
1959    }
1960
1961    // ---------- line-break controls (issue #26) ----------
1962
1963    fn alloc_hardbreak(doc: &mut Document, parent: NodeId) {
1964        // HardBreak nodes carry no attributes -- layout dispatches on
1965        // `NodeKind` alone (see `collect_words`).
1966        doc.alloc_child(
1967            parent,
1968            NodeSpec::new(
1969                NodeKind::HardBreak,
1970                SourceSpan::placeholder(PathBuf::from("test.mos")),
1971            ),
1972        );
1973    }
1974
1975    fn make_empty_paragraph(doc: &mut Document) -> NodeId {
1976        doc.alloc_child(
1977            doc.root,
1978            NodeSpec::new(
1979                NodeKind::Paragraph,
1980                SourceSpan::placeholder(PathBuf::from("test.mos")),
1981            ),
1982        )
1983    }
1984
1985    #[test]
1986    fn nbsp_keeps_two_words_in_a_single_run() {
1987        // U+00A0 is *not* ASCII whitespace, so the greedy breaker
1988        // never splits at it. The two halves end up as one TextRun
1989        // with the NBSP byte preserved -- the documented contract.
1990        let mut doc = Document::new(PathBuf::from("test.mos"));
1991        pin_helvetica(&mut doc);
1992        make_paragraph(&mut doc, "Mr.\u{A0}Smith");
1993        let result = LayoutEngine::new().layout(&doc);
1994        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
1995        let runs = &result.graph.pages[0].runs;
1996        assert_eq!(runs.len(), 1, "expected one TextRun, got {runs:?}");
1997        assert_eq!(runs[0].text, "Mr.\u{A0}Smith");
1998    }
1999
2000    #[test]
2001    fn hard_break_advances_one_line_not_paragraph_spacing() {
2002        let mut doc = Document::new(PathBuf::from("test.mos"));
2003        pin_helvetica(&mut doc);
2004        let para = make_empty_paragraph(&mut doc);
2005        alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2006        alloc_hardbreak(&mut doc, para);
2007        alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2008
2009        let result = LayoutEngine::new().layout(&doc);
2010        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2011        let runs = &result.graph.pages[0].runs;
2012        let foo = runs.iter().find(|r| r.text == "foo").expect("foo run");
2013        let bar = runs.iter().find(|r| r.text == "bar").expect("bar run");
2014        let delta = bar.baseline_from_top_pt - foo.baseline_from_top_pt;
2015        // BODY_SIZE_PT × default leading is the inter-line distance;
2016        // accept either 1.0 or the resolved style's default leading
2017        // by checking against the computed product.
2018        let expected = BODY_SIZE_PT * BODY_LEADING;
2019        assert!(
2020            (delta - expected).abs() < 0.01,
2021            "expected inter-line delta {expected}, got {delta} (foo={}, bar={})",
2022            foo.baseline_from_top_pt,
2023            bar.baseline_from_top_pt
2024        );
2025    }
2026
2027    #[test]
2028    fn two_hard_breaks_produce_a_blank_line() {
2029        let mut doc = Document::new(PathBuf::from("test.mos"));
2030        pin_helvetica(&mut doc);
2031        let para = make_empty_paragraph(&mut doc);
2032        alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2033        alloc_hardbreak(&mut doc, para);
2034        alloc_hardbreak(&mut doc, para);
2035        alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2036
2037        let result = LayoutEngine::new().layout(&doc);
2038        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2039        let runs = &result.graph.pages[0].runs;
2040        let foo = runs.iter().find(|r| r.text == "foo").expect("foo run");
2041        let bar = runs.iter().find(|r| r.text == "bar").expect("bar run");
2042        let delta = bar.baseline_from_top_pt - foo.baseline_from_top_pt;
2043        // Two line advances: one to flush "foo", one for the blank
2044        // line between the two hard breaks.
2045        let one_line = BODY_SIZE_PT * BODY_LEADING;
2046        let expected = 2.0 * one_line;
2047        assert!(
2048            (delta - expected).abs() < 0.01,
2049            "expected delta {expected} for two-line gap, got {delta}"
2050        );
2051    }
2052
2053    #[test]
2054    fn hard_break_at_paragraph_start_collapses_on_first_page_line() {
2055        // When the paragraph begins with a hard break and nothing is
2056        // on the page yet, the leading break has nothing above it to
2057        // push down -- it collapses, matching CommonMark's "ignore
2058        // hard break at block boundary".
2059        let mut doc = Document::new(PathBuf::from("test.mos"));
2060        pin_helvetica(&mut doc);
2061        let para = make_empty_paragraph(&mut doc);
2062        alloc_hardbreak(&mut doc, para);
2063        alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2064
2065        let result = LayoutEngine::new().layout(&doc);
2066        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2067        let runs = &result.graph.pages[0].runs;
2068        assert_eq!(runs.len(), 1, "expected one run, got {runs:?}");
2069        assert_eq!(runs[0].text, "foo");
2070    }
2071
2072    #[test]
2073    fn hard_break_after_oversize_word_does_not_add_blank_line() {
2074        // Regression: an oversize word emits chunks via
2075        // `flush_oversize_word`, which implicitly ends the line. A
2076        // following hard break used to add *another* line on top of
2077        // that implicit end. The break should now be absorbed so
2078        // `oversize\\next` produces `next` on the immediate next
2079        // line, not a blank-then-next.
2080        let mut doc = Document::new(PathBuf::from("test.mos"));
2081        pin_helvetica(&mut doc);
2082        let para = make_empty_paragraph(&mut doc);
2083        // A 500-char run of `a` is certainly wider than the default A4
2084        // column; the layout chops it into oversize chunks.
2085        let huge: String = "a".repeat(500);
2086        alloc_inline(&mut doc, para, NodeKind::Text, &huge);
2087        alloc_hardbreak(&mut doc, para);
2088        alloc_inline(&mut doc, para, NodeKind::Text, "next");
2089
2090        let result = LayoutEngine::new().layout(&doc);
2091        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2092        let runs = &result.graph.pages[0].runs;
2093        let next = runs.iter().find(|r| r.text == "next").expect("next run");
2094        // The last oversize chunk is the run whose text is all `a`s
2095        // and whose baseline is the highest among `a`-only runs.
2096        let last_chunk_baseline = runs
2097            .iter()
2098            .filter(|r| !r.text.is_empty() && r.text.chars().all(|c| c == 'a'))
2099            .map(|r| r.baseline_from_top_pt)
2100            .fold(f32::NEG_INFINITY, f32::max);
2101        assert!(
2102            last_chunk_baseline.is_finite(),
2103            "did not find any oversize-chunk runs"
2104        );
2105        let one_line = BODY_SIZE_PT * BODY_LEADING;
2106        let delta = next.baseline_from_top_pt - last_chunk_baseline;
2107        assert!(
2108            (delta - one_line).abs() < 0.01,
2109            "expected one-line gap ({one_line:.3}pt) between last oversize chunk and `next`, got {delta:.3}pt -- hard break emitted extra blank?"
2110        );
2111    }
2112
2113    #[test]
2114    fn leading_hard_break_collapses_after_prior_page_content() {
2115        // A hard break at the start of a paragraph must collapse even
2116        // when prior paragraphs have painted on the page -- the rule
2117        // is block-boundary, not page-state. Compare against a control
2118        // doc whose second paragraph has no leading hard break: the
2119        // two layouts must agree on the vertical position of `second`.
2120        let layout_one = |with_leading_break: bool| {
2121            let mut doc = Document::new(PathBuf::from("test.mos"));
2122            pin_helvetica(&mut doc);
2123            make_paragraph(&mut doc, "first");
2124            let p2 = make_empty_paragraph(&mut doc);
2125            if with_leading_break {
2126                alloc_hardbreak(&mut doc, p2);
2127            }
2128            alloc_inline(&mut doc, p2, NodeKind::Text, "second");
2129            LayoutEngine::new().layout(&doc).graph.pages[0]
2130                .runs
2131                .iter()
2132                .find(|r| r.text == "second")
2133                .expect("second run")
2134                .baseline_from_top_pt
2135        };
2136        let actual = layout_one(true);
2137        let control = layout_one(false);
2138        assert!(
2139            (actual - control).abs() < 0.01,
2140            "leading hard break should collapse: control baseline {control:.3}pt, got {actual:.3}pt"
2141        );
2142    }
2143
2144    #[test]
2145    fn shy_only_piece_does_not_emit_phantom_word() {
2146        // A whitespace-delimited piece consisting entirely of SHY
2147        // codepoints strips to empty -- skip it rather than emit a
2148        // zero-width Word, which would push an extra space gap into
2149        // the line because flush_line charges one space-advance per
2150        // word past the first.
2151        let mut doc = Document::new(PathBuf::from("test.mos"));
2152        pin_helvetica(&mut doc);
2153        // Three pieces after ASCII-whitespace split: "foo", "\u{AD}\u{AD}", "bar".
2154        // The middle piece must produce zero Word items.
2155        make_paragraph(&mut doc, "foo \u{AD}\u{AD} bar");
2156        let result = LayoutEngine::new().layout(&doc);
2157        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2158        let runs = &result.graph.pages[0].runs;
2159        assert_eq!(
2160            runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2161            vec!["foo", "bar"],
2162            "got {runs:?}"
2163        );
2164        let foo_w = text_width(runs[0].font, runs[0].size_pt, "foo");
2165        let space_w = text_width(runs[0].font, runs[0].size_pt, " ");
2166        let gap = runs[1].x_pt - (runs[0].x_pt + foo_w);
2167        assert!(
2168            (gap - space_w).abs() < 0.01,
2169            "expected one space gap ({space_w:.3}pt), got {gap:.3}pt -- phantom SHY-only word?"
2170        );
2171    }
2172
2173    #[test]
2174    fn soft_hyphen_is_stripped_from_emitted_runs() {
2175        // SHY codepoints must never appear in the rendered text --
2176        // when the word fits, the greedy breaker leaves it alone and
2177        // no visible hyphen is emitted.
2178        let mut doc = Document::new(PathBuf::from("test.mos"));
2179        pin_helvetica(&mut doc);
2180        make_paragraph(&mut doc, "super\u{AD}cali\u{AD}fragil");
2181        let result = LayoutEngine::new().layout(&doc);
2182        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2183        let runs = &result.graph.pages[0].runs;
2184        assert_eq!(runs.len(), 1, "expected one run, got {runs:?}");
2185        assert_eq!(runs[0].text, "supercalifragil");
2186        assert!(
2187            !runs[0].text.contains('\u{AD}'),
2188            "SHY leaked into rendered text: {:?}",
2189            runs[0].text
2190        );
2191    }
2192
2193    /// Insert a `#set page(margin: <pt>)` block so the column is
2194    /// narrow enough to force soft-hyphen breaks in subsequent
2195    /// paragraphs. Mirrors the helper in `style.rs`.
2196    fn pin_narrow_margin(doc: &mut Document, margin_pt: f32) {
2197        let mut attrs = AttrMap::new();
2198        attrs.insert("set".to_owned(), AttrValue::Str("page".to_owned()));
2199        attrs.insert(
2200            "set.arg.margin".to_owned(),
2201            AttrValue::Length(f64::from(margin_pt)),
2202        );
2203        doc.alloc_child(
2204            doc.root,
2205            NodeSpec::new(
2206                NodeKind::Raw,
2207                SourceSpan::placeholder(PathBuf::from("test.mos")),
2208            )
2209            .with_attributes(attrs),
2210        );
2211    }
2212
2213    #[test]
2214    fn shy_only_styled_child_preserves_break_opportunity() {
2215        let mut doc = Document::new(PathBuf::from("test.mos"));
2216        pin_helvetica(&mut doc);
2217        let font = Font::Base14(Base14Font::Helvetica);
2218        let target = text_width(font, BODY_SIZE_PT, "foo-") + 0.5;
2219        pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2220        let para = make_empty_paragraph(&mut doc);
2221        alloc_inline(&mut doc, para, NodeKind::Text, "foo");
2222        alloc_inline(&mut doc, para, NodeKind::Emphasis, "\u{AD}");
2223        alloc_inline(&mut doc, para, NodeKind::Text, "bar");
2224
2225        let result = LayoutEngine::new().layout(&doc);
2226        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2227        let runs = &result.graph.pages[0].runs;
2228        assert_eq!(
2229            runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2230            vec!["foo-", "bar"],
2231            "got {runs:?}"
2232        );
2233        assert!(
2234            run_text(runs, "bar").baseline_from_top_pt
2235                > run_text(runs, "foo-").baseline_from_top_pt
2236        );
2237    }
2238
2239    #[test]
2240    fn styled_soft_hyphen_split_preserves_subrun_fonts() {
2241        let mut doc = Document::new(PathBuf::from("test.mos"));
2242        pin_helvetica(&mut doc);
2243        let regular = Font::Base14(Base14Font::Helvetica);
2244        let bold = Font::Base14(Base14Font::HelveticaBold);
2245        let target =
2246            text_width(regular, BODY_SIZE_PT, "pre") + text_width(bold, BODY_SIZE_PT, "su-") + 0.5;
2247        pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2248        let para = make_empty_paragraph(&mut doc);
2249        alloc_inline(&mut doc, para, NodeKind::Text, "pre");
2250        alloc_inline(&mut doc, para, NodeKind::Strong, "su\u{AD}per");
2251
2252        let result = LayoutEngine::new().layout(&doc);
2253        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2254        let runs = &result.graph.pages[0].runs;
2255        assert_eq!(
2256            runs.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(),
2257            vec!["pre", "su-", "per"],
2258            "got {runs:?}"
2259        );
2260        assert!(matches!(
2261            run_text(runs, "pre").font,
2262            Font::Base14(Base14Font::Helvetica)
2263        ));
2264        assert!(matches!(
2265            run_text(runs, "su-").font,
2266            Font::Base14(Base14Font::HelveticaBold)
2267        ));
2268        assert!(matches!(
2269            run_text(runs, "per").font,
2270            Font::Base14(Base14Font::HelveticaBold)
2271        ));
2272        assert_runs_touch(run_text(runs, "pre"), run_text(runs, "su-"));
2273        assert!(
2274            run_text(runs, "per").baseline_from_top_pt > run_text(runs, "su-").baseline_from_top_pt
2275        );
2276    }
2277
2278    #[test]
2279    fn shy_breaks_word_when_line_overflows() {
2280        // `su\u{AD}per` on a column narrow enough that "super"
2281        // overflows but "su-" fits. The greedy breaker must split
2282        // at the SHY offset, render "su-" on the first line, and
2283        // continue with "per" on the second.
2284        let mut doc = Document::new(PathBuf::from("test.mos"));
2285        pin_helvetica(&mut doc);
2286        // Column ≈ 25pt (just over `su-` at 12pt Helvetica, under
2287        // the full `super`).
2288        pin_narrow_margin(&mut doc, (A4_WIDTH_PT - 25.0) / 2.0);
2289        make_paragraph(&mut doc, "su\u{AD}per");
2290        let result = LayoutEngine::new().layout(&doc);
2291        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2292        let runs = &result.graph.pages[0].runs;
2293        let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
2294        assert_eq!(texts, vec!["su-", "per"], "got {runs:?}");
2295        assert!(runs[1].baseline_from_top_pt > runs[0].baseline_from_top_pt);
2296        for r in runs {
2297            assert!(
2298                !r.text.contains('\u{AD}'),
2299                "SHY leaked into rendered text: {:?}",
2300                r.text
2301            );
2302        }
2303    }
2304
2305    #[test]
2306    fn shy_picks_latest_fitting_break() {
2307        // Two SHYs in `super\u{AD}cali\u{AD}fragil` (offsets 5, 9).
2308        // Column wide enough for "supercali-" but not the full word
2309        // must pick the latest fitting offset (9), not the earliest.
2310        let mut doc = Document::new(PathBuf::from("test.mos"));
2311        pin_helvetica(&mut doc);
2312        let font = Font::Base14(Base14Font::Helvetica);
2313        let target = text_width(font, BODY_SIZE_PT, "supercali-") + 2.0;
2314        pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2315        make_paragraph(&mut doc, "super\u{AD}cali\u{AD}fragil");
2316        let result = LayoutEngine::new().layout(&doc);
2317        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2318        let runs = &result.graph.pages[0].runs;
2319        let texts: Vec<&str> = runs.iter().map(|r| r.text.as_str()).collect();
2320        assert_eq!(texts, vec!["supercali-", "fragil"], "got {runs:?}");
2321    }
2322
2323    #[test]
2324    fn shy_at_zero_or_end_offset_is_ignored() {
2325        // `\u{AD}foo\u{AD}` strips to "foo" with offsets [0, 3] --
2326        // both are boundary positions and must be ignored. With a
2327        // column too narrow for "foo" the SHY path returns None and
2328        // the existing oversize-cluster fallback fires; no bare
2329        // leading or trailing hyphen appears.
2330        let mut doc = Document::new(PathBuf::from("test.mos"));
2331        pin_helvetica(&mut doc);
2332        let font = Font::Base14(Base14Font::Helvetica);
2333        // Narrower than "foo" so the column can't hold it whole.
2334        let target = text_width(font, BODY_SIZE_PT, "fo");
2335        pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2336        make_paragraph(&mut doc, "\u{AD}foo\u{AD}");
2337        let result = LayoutEngine::new().layout(&doc);
2338        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2339        let runs = &result.graph.pages[0].runs;
2340        // Boundary SHYs ignored: no run is just "-" and no run
2341        // ends with "-" (that would mean a SHY break was taken).
2342        for r in runs {
2343            assert_ne!(r.text, "-", "bare hyphen run from boundary SHY");
2344            assert!(
2345                !r.text.ends_with('-'),
2346                "trailing hyphen from boundary SHY: {:?}",
2347                r.text
2348            );
2349        }
2350        let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
2351        assert_eq!(joined, "foo", "all clusters together still spell foo");
2352    }
2353
2354    #[test]
2355    fn shy_falls_back_to_oversize_when_no_break_fits() {
2356        // Column so narrow that neither prefix at the SHY offset
2357        // ("super-" nor "supercali-") fits. The breaker falls
2358        // through to `flush_oversize_word`, which chops by
2359        // shaped clusters -- no SHY-driven `-` appears.
2360        let mut doc = Document::new(PathBuf::from("test.mos"));
2361        pin_helvetica(&mut doc);
2362        let font = Font::Base14(Base14Font::Helvetica);
2363        // Narrower than even "su-": forces every candidate to fail.
2364        let target = text_width(font, BODY_SIZE_PT, "s") + 0.5;
2365        pin_narrow_margin(&mut doc, (A4_WIDTH_PT - target) / 2.0);
2366        make_paragraph(&mut doc, "super\u{AD}cali");
2367        let result = LayoutEngine::new().layout(&doc);
2368        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2369        let runs = &result.graph.pages[0].runs;
2370        // Cluster fallback emits single-character runs; none of
2371        // them end with `-` (the source has no `-` and the SHY
2372        // path never fired).
2373        for r in runs {
2374            assert!(
2375                !r.text.ends_with('-'),
2376                "oversize fallback emitted hyphen: {:?}",
2377                r.text
2378            );
2379        }
2380        let joined: String = runs.iter().map(|r| r.text.as_str()).collect();
2381        assert_eq!(joined, "supercali");
2382    }
2383
2384    #[test]
2385    fn set_blocks_are_skipped() {
2386        let mut doc = Document::new(PathBuf::from("test.mos"));
2387        let mut attrs = AttrMap::new();
2388        attrs.insert("set".to_owned(), AttrValue::Str("page".to_owned()));
2389        doc.alloc_child(
2390            doc.root,
2391            NodeSpec::new(
2392                NodeKind::Raw,
2393                SourceSpan::placeholder(PathBuf::from("test.mos")),
2394            )
2395            .with_attributes(attrs),
2396        );
2397        make_paragraph(&mut doc, "body");
2398        let result = LayoutEngine::new().layout(&doc);
2399        let runs = &result.graph.pages[0].runs;
2400        assert_eq!(runs.len(), 1);
2401        assert_eq!(runs[0].text, "body");
2402    }
2403}