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