Skip to main content

mos_layout/
lib.rs

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