Skip to main content

mos_layout/
image.rs

1use std::sync::Arc;
2
3use mos_core::{AttrValue, Diagnostic, Document, Node, NodeId, NodeKind, codes};
4use mos_fonts::ascent;
5
6use crate::support::{read_int_attr, read_length_attr};
7use crate::word::{ShyBreak, Word, WordItem, try_shy_break, word_clusters};
8use crate::{ImageHandle, ImagePlacement, LayoutState, PARA_SPACE_AFTER_PT};
9
10impl LayoutState {
11    /// Lay out a top-level `Image` node as a block. The image is
12    /// horizontally centred within the column and capped at the column
13    /// width; declared `width`/`height` override natural 72-DPI size.
14    pub(super) fn layout_image(&mut self, node_id: NodeId, image: &Node) {
15        let Some((width_pt, height_pt)) = Self::intrinsic_image_size(image) else {
16            return;
17        };
18        let Some(handle) = self.intern_image(image) else {
19            self.diagnostics.push(
20                Diagnostic::simple(
21                    &codes::MOS0035,
22                    None,
23                    format!("image node {node_id:?} missing decoded pixel data; skipping"),
24                )
25                .with_span(image.span.clone()),
26            );
27            return;
28        };
29
30        let column_w = self.column_width_pt();
31        let render_w = width_pt.min(column_w);
32        let aspect = if width_pt > 0.0 {
33            height_pt / width_pt
34        } else {
35            1.0
36        };
37        let render_h = if render_w < width_pt {
38            render_w * aspect
39        } else {
40            height_pt
41        };
42        let available_y = self.page.height - self.page.margin;
43        if self.cursor_y + render_h > available_y && self.page_has_content {
44            self.start_new_page();
45        }
46
47        // Page settled for this image; bind any labels waiting on first
48        // content (e.g. a labelled `#figure` whose first child is the image).
49        self.bind_pending_labels();
50
51        let x = (column_w - render_w).mul_add(0.5, self.current_left_pt);
52        self.current_page.images.push(ImagePlacement {
53            handle,
54            x_pt: x,
55            top_from_top_pt: self.cursor_y,
56            width_pt: render_w,
57            height_pt: render_h,
58        });
59        self.page_has_content = true;
60
61        // `cursor_y` is the next text baseline. Add body ascent so
62        // following text starts after image bottom + paragraph gap.
63        let body_ascent = ascent(self.text.family.regular, self.text.size_pt);
64        self.cursor_y += render_h + PARA_SPACE_AFTER_PT + body_ascent;
65    }
66
67    /// Lay out a `Figure` block and keep image + caption together when
68    /// the remaining space allows.
69    pub(super) fn layout_figure(&mut self, document: &Document, figure: &Node) {
70        let column_w = self.column_width_pt();
71        let body_ascent = ascent(self.text.family.regular, self.text.size_pt);
72        let mut total_h = 0.0_f32;
73        let mut block_count = 0_u32;
74        for child_id in &figure.children {
75            let Some(child) = document.get(*child_id) else {
76                continue;
77            };
78            let block_h = match child.kind {
79                NodeKind::Image => {
80                    let Some((w, h)) = Self::intrinsic_image_size(child) else {
81                        continue;
82                    };
83                    let render_w = w.min(column_w);
84                    let render_h = if w > 0.0 && render_w < w {
85                        render_w * (h / w)
86                    } else {
87                        h
88                    };
89                    render_h + body_ascent
90                }
91                NodeKind::Paragraph => self.measure_paragraph_height(document, child),
92                _ => continue,
93            };
94            total_h += block_h;
95            block_count += 1;
96        }
97        #[allow(
98            clippy::cast_precision_loss,
99            reason = "a figure with > 2^23 children is not a real document"
100        )]
101        if block_count > 0 {
102            total_h = PARA_SPACE_AFTER_PT.mul_add(block_count as f32, total_h);
103        }
104        let available_y = self.page.height - self.page.margin;
105        if self.cursor_y + total_h > available_y && self.page_has_content {
106            self.start_new_page();
107        }
108        for child_id in &figure.children {
109            let Some(child) = document.get(*child_id) else {
110                continue;
111            };
112            match child.kind {
113                NodeKind::Image => self.layout_image(*child_id, child),
114                NodeKind::Paragraph => self.layout_paragraph(document, child),
115                _ => {}
116            }
117        }
118    }
119
120    fn measure_paragraph_height(&self, document: &Document, paragraph: &Node) -> f32 {
121        let size = self.text.size_pt;
122        let leading = self.text.leading;
123        let regular = self.text.family.regular;
124        let items = self.collect_words(document, paragraph, regular, size);
125        if items.is_empty() {
126            return 0.0;
127        }
128        let line_width = self.column_width_pt();
129        let mut lines = 0_u32;
130        let mut line_has_words = false;
131        let mut line_width_used = 0.0_f32;
132        let mut paragraph_emitted_line = false;
133        let mut last_was_hardbreak_flush = false;
134        let mut pending: Option<Word> = None;
135        let mut item_idx = 0;
136
137        loop {
138            let word = if let Some(word) = pending.take() {
139                word
140            } else if item_idx < items.len() {
141                let item = &items[item_idx];
142                item_idx += 1;
143                match item {
144                    WordItem::Word(word) => word.clone(),
145                    WordItem::HardBreak => {
146                        if line_has_words {
147                            lines += 1;
148                            line_has_words = false;
149                            line_width_used = 0.0;
150                            paragraph_emitted_line = true;
151                            last_was_hardbreak_flush = true;
152                        } else if last_was_hardbreak_flush {
153                            lines += 1;
154                        } else if paragraph_emitted_line {
155                            last_was_hardbreak_flush = true;
156                        }
157                        continue;
158                    }
159                }
160            } else {
161                break;
162            };
163
164            let space_w = if line_has_words {
165                word.space_before_pt
166            } else {
167                0.0
168            };
169
170            if line_width_used + space_w + word.width_pt <= line_width {
171                line_has_words = true;
172                line_width_used += space_w + word.width_pt;
173                continue;
174            }
175
176            if line_has_words && space_w <= f32::EPSILON {
177                line_width_used += word.width_pt;
178                continue;
179            }
180
181            if line_has_words
182                && let Some(ShyBreak { suffix, .. }) = try_shy_break(
183                    &word,
184                    line_width - line_width_used - space_w,
185                    self.text.family.fallbacks,
186                )
187            {
188                lines += 1;
189                line_has_words = false;
190                line_width_used = 0.0;
191                paragraph_emitted_line = true;
192                last_was_hardbreak_flush = false;
193                pending = Some(suffix);
194                continue;
195            }
196
197            if line_has_words {
198                lines += 1;
199                line_has_words = false;
200                line_width_used = 0.0;
201                paragraph_emitted_line = true;
202                last_was_hardbreak_flush = false;
203            }
204
205            if word.width_pt > line_width {
206                if let Some(ShyBreak { suffix, .. }) =
207                    try_shy_break(&word, line_width, self.text.family.fallbacks)
208                {
209                    lines += 1;
210                    paragraph_emitted_line = true;
211                    last_was_hardbreak_flush = false;
212                    pending = Some(suffix);
213                    continue;
214                }
215                lines += oversize_chunk_count(&word, line_width);
216                paragraph_emitted_line = true;
217                last_was_hardbreak_flush = false;
218                continue;
219            }
220
221            line_has_words = true;
222            line_width_used = word.width_pt;
223        }
224        if line_has_words {
225            lines += 1;
226        }
227        paragraph_height_from_lines(lines, size, leading)
228    }
229
230    #[allow(
231        clippy::cast_precision_loss,
232        reason = "pixel dimensions clamp well below the f32 mantissa cap"
233    )]
234    fn intrinsic_image_size(image: &Node) -> Option<(f32, f32)> {
235        let pw = read_int_attr(image, "pixel_width")?;
236        let ph = read_int_attr(image, "pixel_height")?;
237        if pw <= 0 || ph <= 0 {
238            return None;
239        }
240        let natural_w = pw as f32;
241        let natural_h = ph as f32;
242        let declared_w = read_length_attr(image, "width");
243        let declared_h = read_length_attr(image, "height");
244        let aspect = natural_h / natural_w;
245        let (w, h) = match (declared_w, declared_h) {
246            (Some(w), Some(h)) => {
247                let scale = (w / natural_w).min(h / natural_h);
248                (natural_w * scale, natural_h * scale)
249            }
250            (Some(w), None) => (w, w * aspect),
251            (None, Some(h)) => (h / aspect, h),
252            (None, None) => (natural_w, natural_h),
253        };
254        Some((w, h))
255    }
256
257    fn intern_image(&mut self, image: &Node) -> Option<ImageHandle> {
258        let resolved_path = match image.attributes.get("resolved_path") {
259            Some(AttrValue::Str(s)) => s.clone(),
260            _ => match image.attributes.get("src") {
261                Some(AttrValue::Str(s)) => s.clone(),
262                _ => return None,
263            },
264        };
265        if let Some(existing) = self
266            .image_handles
267            .iter()
268            .find(|h| h.resolved_path == resolved_path)
269        {
270            return Some(existing.clone());
271        }
272        let pw = read_int_attr(image, "pixel_width")?;
273        let ph = read_int_attr(image, "pixel_height")?;
274        let pixels: Arc<[u8]> = match image.attributes.get("pixels") {
275            Some(AttrValue::Bytes(b)) => Arc::clone(b),
276            _ => return None,
277        };
278        let id = u32::try_from(self.image_handles.len()).unwrap_or(u32::MAX);
279        let handle = ImageHandle {
280            id,
281            resolved_path,
282            pixel_width: u32::try_from(pw).ok()?,
283            pixel_height: u32::try_from(ph).ok()?,
284            rgb8: pixels,
285        };
286        self.image_handles.push(handle.clone());
287        Some(handle)
288    }
289}
290
291#[allow(
292    clippy::cast_precision_loss,
293    reason = "line counts in any sane document fit well inside the f32 mantissa"
294)]
295fn paragraph_height_from_lines(lines: u32, size: f32, leading: f32) -> f32 {
296    lines as f32 * size * leading
297}
298
299fn oversize_chunk_count(word: &Word, line_width: f32) -> u32 {
300    let mut chunks = 0_u32;
301    let mut chunk_has_content = false;
302    let mut chunk_width = 0.0_f32;
303    for cluster in word_clusters(word) {
304        if chunk_width + cluster.advance_pt > line_width && chunk_has_content {
305            chunks += 1;
306            chunk_width = 0.0;
307        }
308        chunk_width += cluster.advance_pt;
309        chunk_has_content = true;
310    }
311    if chunk_has_content {
312        chunks += 1;
313    }
314    chunks
315}
316
317#[cfg(test)]
318mod tests {
319    #![allow(
320        clippy::unwrap_used,
321        clippy::expect_used,
322        reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
323    )]
324
325    use std::fmt::Write as _;
326    use std::path::PathBuf;
327    use std::sync::Arc;
328
329    use mos_core::{AttrMap, NodeSpec, SourceSpan};
330
331    use mos_fonts::{Base14Font, Font, FontFamily, text_width};
332
333    use crate::types::{BODY_LEADING, BODY_SIZE_PT};
334    use crate::{A4_HEIGHT_PT, A4_WIDTH_PT, LayoutEngine, MARGIN_PT, PageStyle, TextStyle};
335
336    use super::*;
337
338    fn alloc_inline(doc: &mut Document, parent: NodeId, kind: NodeKind, text: &str) {
339        let mut attrs = AttrMap::new();
340        attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
341        doc.alloc_child(
342            parent,
343            NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
344                .with_attributes(attrs),
345        );
346    }
347
348    fn alloc_hard_break(doc: &mut Document, parent: NodeId) {
349        doc.alloc_child(
350            parent,
351            NodeSpec::new(
352                NodeKind::HardBreak,
353                SourceSpan::placeholder(PathBuf::from("test.mos")),
354            ),
355        );
356    }
357
358    fn pin_helvetica(doc: &mut Document) {
359        let mut attrs = AttrMap::new();
360        attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
361        attrs.insert(
362            "set.arg.font".to_owned(),
363            AttrValue::Str("Helvetica".to_owned()),
364        );
365        doc.alloc_child(
366            doc.root,
367            NodeSpec::new(
368                NodeKind::Raw,
369                SourceSpan::placeholder(PathBuf::from("test.mos")),
370            )
371            .with_attributes(attrs),
372        );
373    }
374
375    fn helvetica_state_with_column_width(column_width_pt: f32) -> LayoutState {
376        LayoutState::new(
377            PageStyle {
378                width: A4_WIDTH_PT,
379                height: A4_HEIGHT_PT,
380                margin: (A4_WIDTH_PT - column_width_pt) * 0.5,
381            },
382            TextStyle {
383                size_pt: BODY_SIZE_PT,
384                leading: BODY_LEADING,
385                family: FontFamily::helvetica(),
386            },
387        )
388    }
389
390    fn make_paragraph(doc: &mut Document, text: &str) -> NodeId {
391        let id = doc.alloc_child(
392            doc.root,
393            NodeSpec::new(
394                NodeKind::Paragraph,
395                SourceSpan::placeholder(PathBuf::from("test.mos")),
396            ),
397        );
398        alloc_inline(doc, id, NodeKind::Text, text);
399        id
400    }
401
402    fn make_image(
403        doc: &mut Document,
404        path: &str,
405        pixel_w: u32,
406        pixel_h: u32,
407        declared_width_pt: Option<f64>,
408        declared_height_pt: Option<f64>,
409    ) -> NodeId {
410        let pixels: Arc<[u8]> = Arc::from(vec![0; (pixel_w * pixel_h * 3) as usize]);
411        let mut attrs = AttrMap::new();
412        attrs.insert("src".to_owned(), AttrValue::Str(path.to_owned()));
413        attrs.insert(
414            "resolved_path".to_owned(),
415            AttrValue::Str(format!("/tmp/{path}")),
416        );
417        attrs.insert("pixel_width".to_owned(), AttrValue::Int(i64::from(pixel_w)));
418        attrs.insert(
419            "pixel_height".to_owned(),
420            AttrValue::Int(i64::from(pixel_h)),
421        );
422        attrs.insert("pixels".to_owned(), AttrValue::Bytes(pixels));
423        if let Some(w) = declared_width_pt {
424            attrs.insert("width".to_owned(), AttrValue::Length(w));
425        }
426        if let Some(h) = declared_height_pt {
427            attrs.insert("height".to_owned(), AttrValue::Length(h));
428        }
429        doc.alloc_child(
430            doc.root,
431            NodeSpec::new(
432                NodeKind::Image,
433                SourceSpan::placeholder(PathBuf::from("test.mos")),
434            )
435            .with_attributes(attrs),
436        )
437    }
438
439    #[test]
440    fn image_block_natural_size_at_72dpi() {
441        let mut doc = Document::new(PathBuf::from("test.mos"));
442        make_image(&mut doc, "x.png", 100, 60, None, None);
443
444        let result = LayoutEngine::new().layout(&doc);
445
446        let img = &result.graph.pages[0].images[0];
447        assert!((img.width_pt - 100.0).abs() < 0.5);
448        assert!((img.height_pt - 60.0).abs() < 0.5);
449    }
450
451    #[test]
452    fn image_block_declared_width_preserves_aspect_ratio() {
453        let mut doc = Document::new(PathBuf::from("test.mos"));
454        make_image(&mut doc, "x.png", 200, 100, Some(80.0), None);
455
456        let result = LayoutEngine::new().layout(&doc);
457
458        let img = &result.graph.pages[0].images[0];
459        assert!((img.width_pt - 80.0).abs() < 0.5);
460        assert!((img.height_pt - 40.0).abs() < 0.5);
461    }
462
463    #[test]
464    fn image_block_both_dims_fits_inside_box_preserving_aspect() {
465        let mut doc = Document::new(PathBuf::from("test.mos"));
466        make_image(&mut doc, "x.png", 200, 100, Some(80.0), Some(80.0));
467
468        let result = LayoutEngine::new().layout(&doc);
469
470        let img = &result.graph.pages[0].images[0];
471        assert!((img.width_pt - 80.0).abs() < 0.5, "w = {}", img.width_pt);
472        assert!((img.height_pt - 40.0).abs() < 0.5, "h = {}", img.height_pt);
473    }
474
475    #[test]
476    fn image_block_both_dims_taller_box_fits_by_width() {
477        let mut doc = Document::new(PathBuf::from("test.mos"));
478        make_image(&mut doc, "x.png", 200, 100, Some(40.0), Some(80.0));
479
480        let result = LayoutEngine::new().layout(&doc);
481
482        let img = &result.graph.pages[0].images[0];
483        assert!((img.width_pt - 40.0).abs() < 0.5, "w = {}", img.width_pt);
484        assert!((img.height_pt - 20.0).abs() < 0.5, "h = {}", img.height_pt);
485    }
486
487    #[test]
488    fn image_block_clamped_to_column_width() {
489        let mut doc = Document::new(PathBuf::from("test.mos"));
490        make_image(&mut doc, "x.png", 4000, 2000, None, None);
491
492        let result = LayoutEngine::new().layout(&doc);
493
494        let img = &result.graph.pages[0].images[0];
495        let col = 2.0f32.mul_add(-MARGIN_PT, A4_WIDTH_PT);
496        assert!(img.width_pt <= col + 0.5);
497        let aspect = 4000.0_f32 / 2000.0;
498        assert!((img.height_pt - img.width_pt / aspect).abs() < 0.5);
499    }
500
501    #[test]
502    fn image_block_centers_inside_current_column() {
503        let mut doc = Document::new(PathBuf::from("test.mos"));
504        let image_id = make_image(&mut doc, "x.png", 100, 50, None, None);
505        let image = doc.get(image_id).expect("image");
506        let mut state = LayoutState::new(
507            PageStyle {
508                width: A4_WIDTH_PT,
509                height: A4_HEIGHT_PT,
510                margin: MARGIN_PT,
511            },
512            TextStyle::default(),
513        );
514        state.current_left_pt = MARGIN_PT + 60.0;
515        let expected_column_w = state.column_width_pt();
516
517        state.layout_image(image_id, image);
518
519        let placed = &state.current_page.images[0];
520        let expected_x = (expected_column_w - placed.width_pt).mul_add(0.5, state.current_left_pt);
521        assert!((placed.x_pt - expected_x).abs() < 0.01);
522    }
523
524    #[test]
525    fn oversized_image_after_paragraph_does_not_force_extra_page() {
526        let mut doc = Document::new(PathBuf::from("test.mos"));
527        make_paragraph(&mut doc, "lead paragraph");
528        make_image(&mut doc, "big.png", 1000, 1000, None, None);
529
530        let result = LayoutEngine::new().layout(&doc);
531
532        assert_eq!(result.graph.pages.len(), 1);
533        assert_eq!(result.graph.pages[0].images.len(), 1);
534        assert!(!result.graph.pages[0].runs.is_empty());
535    }
536
537    #[test]
538    fn image_dedup_emits_one_handle_per_resolved_path() {
539        let mut doc = Document::new(PathBuf::from("test.mos"));
540        make_image(&mut doc, "same.png", 50, 50, None, None);
541        make_image(&mut doc, "same.png", 50, 50, None, None);
542
543        let result = LayoutEngine::new().layout(&doc);
544
545        assert_eq!(result.graph.images.len(), 1);
546        let placements: Vec<_> = result
547            .graph
548            .pages
549            .iter()
550            .flat_map(|p| p.images.iter())
551            .collect();
552        assert_eq!(placements.len(), 2);
553        assert_eq!(placements[0].handle.id, placements[1].handle.id);
554    }
555
556    #[test]
557    fn figure_lays_out_image_then_caption() {
558        let mut doc = Document::new(PathBuf::from("test.mos"));
559        pin_helvetica(&mut doc);
560        make_figure_with_image_and_caption(&mut doc, 80, 50, "Caption text.");
561
562        let result = LayoutEngine::new().layout(&doc);
563
564        let page = &result.graph.pages[0];
565        assert_eq!(page.images.len(), 1);
566        let caption_run = page
567            .runs
568            .iter()
569            .find(|r| r.text == "Caption" || r.text == "text.")
570            .expect("caption run not found");
571        assert!(caption_run.baseline_from_top_pt > page.images[0].top_from_top_pt);
572    }
573
574    #[test]
575    fn paragraph_height_measurement_counts_shy_breaks_like_flow() {
576        let mut doc = Document::new(PathBuf::from("test.mos"));
577        let para = make_paragraph(&mut doc, "x super\u{AD}cali");
578        let line_width = text_width(
579            Font::Base14(Base14Font::Helvetica),
580            BODY_SIZE_PT,
581            "x super-",
582        ) + 1.0;
583        let state = helvetica_state_with_column_width(line_width);
584
585        let height = state.measure_paragraph_height(&doc, doc.get(para).expect("paragraph"));
586        let expected = 2.0 * BODY_SIZE_PT * BODY_LEADING;
587
588        assert!(
589            (height - expected).abs() < 0.01,
590            "expected two measured lines ({expected:.3}pt), got {height:.3}pt"
591        );
592    }
593
594    #[test]
595    fn paragraph_height_measurement_matches_flow_for_caption_break_edges() {
596        let mut doc = Document::new(PathBuf::from("test.mos"));
597        let para = doc.alloc_child(
598            doc.root,
599            NodeSpec::new(
600                NodeKind::Paragraph,
601                SourceSpan::placeholder(PathBuf::from("test.mos")),
602            ),
603        );
604        alloc_inline(&mut doc, para, NodeKind::Text, "lead");
605        alloc_hard_break(&mut doc, para);
606        alloc_inline(&mut doc, para, NodeKind::Text, "pre");
607        alloc_inline(&mut doc, para, NodeKind::Strong, "su\u{AD}per");
608        alloc_inline(&mut doc, para, NodeKind::Text, ",");
609        let regular = Font::Base14(Base14Font::Helvetica);
610        let bold = Font::Base14(Base14Font::HelveticaBold);
611        let line_width =
612            text_width(regular, BODY_SIZE_PT, "pre") + text_width(bold, BODY_SIZE_PT, "su-") + 0.5;
613
614        let measured_state = helvetica_state_with_column_width(line_width);
615        let height = measured_state.measure_paragraph_height(&doc, doc.get(para).expect("para"));
616        let mut flowed_state = helvetica_state_with_column_width(line_width);
617        flowed_state.layout_paragraph(&doc, doc.get(para).expect("para"));
618
619        let mut baselines: Vec<f32> = Vec::new();
620        for run in &flowed_state.current_page.runs {
621            if baselines
622                .iter()
623                .all(|baseline| (run.baseline_from_top_pt - *baseline).abs() > 0.01)
624            {
625                baselines.push(run.baseline_from_top_pt);
626            }
627        }
628        let expected = 3.0 * BODY_SIZE_PT * BODY_LEADING;
629        assert!(
630            (height - expected).abs() < 0.01,
631            "expected three measured lines ({expected:.3}pt), got {height:.3}pt"
632        );
633        assert_eq!(
634            baselines.len(),
635            3,
636            "flowed runs: {:?}",
637            flowed_state.current_page.runs
638        );
639    }
640
641    #[test]
642    fn figure_dry_run_skips_unrenderable_images() {
643        let mut doc = Document::new(PathBuf::from("test.mos"));
644        let fig = doc.alloc_child(
645            doc.root,
646            NodeSpec::new(
647                NodeKind::Figure,
648                SourceSpan::placeholder(PathBuf::from("test.mos")),
649            ),
650        );
651        doc.alloc_child(
652            fig,
653            NodeSpec::new(
654                NodeKind::Image,
655                SourceSpan::placeholder(PathBuf::from("test.mos")),
656            ),
657        );
658        let caption = doc.alloc_child(
659            fig,
660            NodeSpec::new(
661                NodeKind::Paragraph,
662                SourceSpan::placeholder(PathBuf::from("test.mos")),
663            ),
664        );
665        alloc_inline(&mut doc, caption, NodeKind::Text, "Caption");
666
667        let mut state = helvetica_state_with_column_width(120.0);
668        state.page_has_content = true;
669        let caption_h = state.measure_paragraph_height(&doc, doc.get(caption).expect("caption"));
670        let available_y = state.page.height - state.page.margin;
671        state.cursor_y = available_y - caption_h - PARA_SPACE_AFTER_PT - 1.0;
672        state.layout_figure(&doc, doc.get(fig).expect("figure"));
673
674        assert_eq!(state.current_page.number, 1);
675        assert!(state.current_page.images.is_empty());
676        assert_eq!(state.current_page.runs.len(), 1);
677    }
678
679    fn make_figure_with_image_and_caption(
680        doc: &mut Document,
681        pixel_w: u32,
682        pixel_h: u32,
683        caption: &str,
684    ) -> NodeId {
685        let fig = doc.alloc_child(
686            doc.root,
687            NodeSpec::new(
688                NodeKind::Figure,
689                SourceSpan::placeholder(PathBuf::from("test.mos")),
690            ),
691        );
692        let mut img_attrs = AttrMap::new();
693        img_attrs.insert("src".to_owned(), AttrValue::Str("fig.png".to_owned()));
694        img_attrs.insert(
695            "resolved_path".to_owned(),
696            AttrValue::Str(format!("/tmp/figkt-{pixel_w}x{pixel_h}.png")),
697        );
698        img_attrs.insert("pixel_width".to_owned(), AttrValue::Int(i64::from(pixel_w)));
699        img_attrs.insert(
700            "pixel_height".to_owned(),
701            AttrValue::Int(i64::from(pixel_h)),
702        );
703        img_attrs.insert(
704            "pixels".to_owned(),
705            AttrValue::Bytes(Arc::from(vec![0_u8; (pixel_w * pixel_h * 3) as usize])),
706        );
707        doc.alloc_child(
708            fig,
709            NodeSpec::new(
710                NodeKind::Image,
711                SourceSpan::placeholder(PathBuf::from("test.mos")),
712            )
713            .with_attributes(img_attrs),
714        );
715        let cap = doc.alloc_child(
716            fig,
717            NodeSpec::new(
718                NodeKind::Paragraph,
719                SourceSpan::placeholder(PathBuf::from("test.mos")),
720            ),
721        );
722        alloc_inline(doc, cap, NodeKind::Text, caption);
723        fig
724    }
725
726    #[test]
727    fn figure_image_and_caption_stay_on_the_same_page() {
728        let mut doc = Document::new(PathBuf::from("test.mos"));
729        pin_helvetica(&mut doc);
730        let mut filler = String::new();
731        for i in 0..540 {
732            let _ = write!(filler, "word{i} ");
733        }
734        make_paragraph(&mut doc, filler.trim());
735        make_figure_with_image_and_caption(&mut doc, 400, 300, "Tight caption.");
736
737        let result = LayoutEngine::new().layout(&doc);
738
739        let mut figure_page: Option<u32> = None;
740        let mut caption_page: Option<u32> = None;
741        for page in &result.graph.pages {
742            if !page.images.is_empty() && figure_page.is_none() {
743                figure_page = Some(page.number);
744            }
745            if page
746                .runs
747                .iter()
748                .any(|r| r.text == "Tight" || r.text == "caption.")
749                && caption_page.is_none()
750            {
751                caption_page = Some(page.number);
752            }
753        }
754        assert_eq!(figure_page, caption_page);
755    }
756}