Skip to main content

mos_layout/
lib.rs

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