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, text_width};
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_pt - self.page.margin_pt;
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 = self.page.margin_pt + (column_w - render_w) * 0.5;
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 => self.intrinsic_image_size(child).map_or(0.0, |(w, h)| {
80                    let render_w = w.min(column_w);
81                    let render_h = if w > 0.0 && render_w < w {
82                        render_w * (h / w)
83                    } else {
84                        h
85                    };
86                    render_h + body_ascent
87                }),
88                NodeKind::Paragraph => self.measure_paragraph_height(document, child),
89                _ => continue,
90            };
91            total_h += block_h;
92            block_count += 1;
93        }
94        #[allow(
95            clippy::cast_precision_loss,
96            reason = "a figure with > 2^23 children is not a real document"
97        )]
98        if block_count > 0 {
99            total_h += PARA_SPACE_AFTER_PT * block_count as f32;
100        }
101        let available_y = self.page.height_pt - self.page.margin_pt;
102        if self.cursor_y + total_h > available_y && self.page_has_content {
103            self.start_new_page();
104        }
105        for child_id in &figure.children {
106            let Some(child) = document.get(*child_id) else {
107                continue;
108            };
109            match child.kind {
110                NodeKind::Image => self.layout_image(*child_id, child),
111                NodeKind::Paragraph => self.layout_paragraph(document, child),
112                _ => {}
113            }
114        }
115    }
116
117    fn measure_paragraph_height(&mut self, document: &Document, paragraph: &Node) -> f32 {
118        let size = self.text.size_pt;
119        let leading = self.text.leading;
120        let regular = self.text.family.regular;
121        let items = self.collect_words(document, paragraph, regular, size);
122        if items.is_empty() {
123            return 0.0;
124        }
125        let line_width = self.column_width_pt();
126        let mut lines = 0_u32;
127        let mut line_has_words = false;
128        let mut line_width_used = 0.0_f32;
129        let mut paragraph_emitted_line = false;
130        let mut last_was_hardbreak_flush = false;
131        let mut pending: Option<Word> = None;
132        let mut item_idx = 0;
133
134        loop {
135            let word = if let Some(word) = pending.take() {
136                word
137            } else if item_idx < items.len() {
138                let item = &items[item_idx];
139                item_idx += 1;
140                match item {
141                    WordItem::Word(word) => word.clone(),
142                    WordItem::HardBreak => {
143                        if line_has_words {
144                            lines += 1;
145                            line_has_words = false;
146                            line_width_used = 0.0;
147                            paragraph_emitted_line = true;
148                            last_was_hardbreak_flush = true;
149                        } else if last_was_hardbreak_flush {
150                            lines += 1;
151                        } else if paragraph_emitted_line {
152                            last_was_hardbreak_flush = true;
153                        }
154                        continue;
155                    }
156                }
157            } else {
158                break;
159            };
160
161            let space_w = if line_has_words {
162                text_width(word.font, word.size_pt, " ")
163            } else {
164                0.0
165            };
166
167            if line_width_used + space_w + word.width_pt <= line_width {
168                line_has_words = true;
169                line_width_used += space_w + word.width_pt;
170                continue;
171            }
172
173            if line_has_words
174                && let Some(ShyBreak { suffix, .. }) = try_shy_break(
175                    &word,
176                    line_width - line_width_used - space_w,
177                    self.text.family.fallbacks,
178                )
179            {
180                lines += 1;
181                line_has_words = false;
182                line_width_used = 0.0;
183                paragraph_emitted_line = true;
184                last_was_hardbreak_flush = false;
185                pending = Some(suffix);
186                continue;
187            }
188
189            if line_has_words {
190                lines += 1;
191                line_has_words = false;
192                line_width_used = 0.0;
193                paragraph_emitted_line = true;
194                last_was_hardbreak_flush = false;
195            }
196
197            if word.width_pt > line_width {
198                if let Some(ShyBreak { suffix, .. }) =
199                    try_shy_break(&word, line_width, self.text.family.fallbacks)
200                {
201                    lines += 1;
202                    paragraph_emitted_line = true;
203                    last_was_hardbreak_flush = false;
204                    pending = Some(suffix);
205                    continue;
206                }
207                lines += oversize_chunk_count(&word, line_width);
208                paragraph_emitted_line = true;
209                last_was_hardbreak_flush = false;
210                continue;
211            }
212
213            line_has_words = true;
214            line_width_used = word.width_pt;
215        }
216        if line_has_words {
217            lines += 1;
218        }
219        #[allow(
220            clippy::cast_precision_loss,
221            reason = "line counts in any sane document fit well inside the f32 mantissa"
222        )]
223        let h = lines as f32 * size * leading;
224        h
225    }
226
227    #[allow(
228        clippy::cast_precision_loss,
229        reason = "pixel dimensions clamp well below the f32 mantissa cap"
230    )]
231    fn intrinsic_image_size(&self, image: &Node) -> Option<(f32, f32)> {
232        let pw = read_int_attr(image, "pixel_width")?;
233        let ph = read_int_attr(image, "pixel_height")?;
234        if pw <= 0 || ph <= 0 {
235            return None;
236        }
237        let natural_w = pw as f32;
238        let natural_h = ph as f32;
239        let declared_w = read_length_attr(image, "width");
240        let declared_h = read_length_attr(image, "height");
241        let aspect = natural_h / natural_w;
242        let (w, h) = match (declared_w, declared_h) {
243            (Some(w), Some(h)) => {
244                let scale = (w / natural_w).min(h / natural_h);
245                (natural_w * scale, natural_h * scale)
246            }
247            (Some(w), None) => (w, w * aspect),
248            (None, Some(h)) => (h / aspect, h),
249            (None, None) => (natural_w, natural_h),
250        };
251        Some((w, h))
252    }
253
254    fn intern_image(&mut self, image: &Node) -> Option<ImageHandle> {
255        let resolved_path = match image.attributes.get("resolved_path") {
256            Some(AttrValue::Str(s)) => s.clone(),
257            _ => match image.attributes.get("src") {
258                Some(AttrValue::Str(s)) => s.clone(),
259                _ => return None,
260            },
261        };
262        if let Some(existing) = self
263            .image_handles
264            .iter()
265            .find(|h| h.resolved_path == resolved_path)
266        {
267            return Some(existing.clone());
268        }
269        let pw = read_int_attr(image, "pixel_width")?;
270        let ph = read_int_attr(image, "pixel_height")?;
271        let pixels: Arc<[u8]> = match image.attributes.get("pixels") {
272            Some(AttrValue::Bytes(b)) => Arc::clone(b),
273            _ => return None,
274        };
275        let id = u32::try_from(self.image_handles.len()).unwrap_or(u32::MAX);
276        let handle = ImageHandle {
277            id,
278            resolved_path,
279            pixel_width: u32::try_from(pw).ok()?,
280            pixel_height: u32::try_from(ph).ok()?,
281            rgb8: pixels,
282        };
283        self.image_handles.push(handle.clone());
284        Some(handle)
285    }
286}
287
288fn oversize_chunk_count(word: &Word, line_width: f32) -> u32 {
289    let mut chunks = 0_u32;
290    let mut chunk_has_content = false;
291    let mut chunk_width = 0.0_f32;
292    for cluster in word_clusters(word) {
293        if chunk_width + cluster.advance_pt > line_width && chunk_has_content {
294            chunks += 1;
295            chunk_width = 0.0;
296        }
297        chunk_width += cluster.advance_pt;
298        chunk_has_content = true;
299    }
300    if chunk_has_content {
301        chunks += 1;
302    }
303    chunks
304}
305
306#[cfg(test)]
307mod tests {
308    #![allow(
309        clippy::unwrap_used,
310        clippy::expect_used,
311        reason = "tests panic loudly on setup failure; matches crate-wide test-module convention"
312    )]
313
314    use std::path::PathBuf;
315    use std::sync::Arc;
316
317    use mos_core::{AttrMap, NodeSpec, SourceSpan};
318
319    use mos_fonts::FontFamily;
320
321    use crate::types::{BODY_LEADING, BODY_SIZE_PT};
322    use crate::{A4_HEIGHT_PT, A4_WIDTH_PT, LayoutEngine, MARGIN_PT, PageStyle, TextStyle};
323
324    use super::*;
325
326    fn alloc_inline(doc: &mut Document, parent: NodeId, kind: NodeKind, text: &str) {
327        let mut attrs = AttrMap::new();
328        attrs.insert("text".to_owned(), AttrValue::Str(text.to_owned()));
329        doc.alloc_child(
330            parent,
331            NodeSpec::new(kind, SourceSpan::placeholder(PathBuf::from("test.mos")))
332                .with_attributes(attrs),
333        );
334    }
335
336    fn pin_helvetica(doc: &mut Document) {
337        let mut attrs = AttrMap::new();
338        attrs.insert("set".to_owned(), AttrValue::Str("text".to_owned()));
339        attrs.insert(
340            "set.arg.font".to_owned(),
341            AttrValue::Str("Helvetica".to_owned()),
342        );
343        doc.alloc_child(
344            doc.root,
345            NodeSpec::new(
346                NodeKind::Raw,
347                SourceSpan::placeholder(PathBuf::from("test.mos")),
348            )
349            .with_attributes(attrs),
350        );
351    }
352
353    fn helvetica_state_with_column_width(column_width_pt: f32) -> LayoutState {
354        LayoutState::new(
355            PageStyle {
356                width_pt: A4_WIDTH_PT,
357                height_pt: A4_HEIGHT_PT,
358                margin_pt: (A4_WIDTH_PT - column_width_pt) * 0.5,
359            },
360            TextStyle {
361                size_pt: BODY_SIZE_PT,
362                leading: BODY_LEADING,
363                family: FontFamily::helvetica(),
364            },
365        )
366    }
367
368    fn make_paragraph(doc: &mut Document, text: &str) -> NodeId {
369        let id = doc.alloc_child(
370            doc.root,
371            NodeSpec::new(
372                NodeKind::Paragraph,
373                SourceSpan::placeholder(PathBuf::from("test.mos")),
374            ),
375        );
376        alloc_inline(doc, id, NodeKind::Text, text);
377        id
378    }
379
380    fn make_image(
381        doc: &mut Document,
382        path: &str,
383        pixel_w: u32,
384        pixel_h: u32,
385        declared_width_pt: Option<f64>,
386        declared_height_pt: Option<f64>,
387    ) -> NodeId {
388        let pixels: Arc<[u8]> = Arc::from(vec![0; (pixel_w * pixel_h * 3) as usize]);
389        let mut attrs = AttrMap::new();
390        attrs.insert("src".to_owned(), AttrValue::Str(path.to_owned()));
391        attrs.insert(
392            "resolved_path".to_owned(),
393            AttrValue::Str(format!("/tmp/{path}")),
394        );
395        attrs.insert("pixel_width".to_owned(), AttrValue::Int(i64::from(pixel_w)));
396        attrs.insert(
397            "pixel_height".to_owned(),
398            AttrValue::Int(i64::from(pixel_h)),
399        );
400        attrs.insert("pixels".to_owned(), AttrValue::Bytes(pixels));
401        if let Some(w) = declared_width_pt {
402            attrs.insert("width".to_owned(), AttrValue::Length(w));
403        }
404        if let Some(h) = declared_height_pt {
405            attrs.insert("height".to_owned(), AttrValue::Length(h));
406        }
407        doc.alloc_child(
408            doc.root,
409            NodeSpec::new(
410                NodeKind::Image,
411                SourceSpan::placeholder(PathBuf::from("test.mos")),
412            )
413            .with_attributes(attrs),
414        )
415    }
416
417    #[test]
418    fn image_block_natural_size_at_72dpi() {
419        let mut doc = Document::new(PathBuf::from("test.mos"));
420        make_image(&mut doc, "x.png", 100, 60, None, None);
421
422        let result = LayoutEngine::new().layout(&doc);
423
424        let img = &result.graph.pages[0].images[0];
425        assert!((img.width_pt - 100.0).abs() < 0.5);
426        assert!((img.height_pt - 60.0).abs() < 0.5);
427    }
428
429    #[test]
430    fn image_block_declared_width_preserves_aspect_ratio() {
431        let mut doc = Document::new(PathBuf::from("test.mos"));
432        make_image(&mut doc, "x.png", 200, 100, Some(80.0), None);
433
434        let result = LayoutEngine::new().layout(&doc);
435
436        let img = &result.graph.pages[0].images[0];
437        assert!((img.width_pt - 80.0).abs() < 0.5);
438        assert!((img.height_pt - 40.0).abs() < 0.5);
439    }
440
441    #[test]
442    fn image_block_both_dims_fits_inside_box_preserving_aspect() {
443        let mut doc = Document::new(PathBuf::from("test.mos"));
444        make_image(&mut doc, "x.png", 200, 100, Some(80.0), Some(80.0));
445
446        let result = LayoutEngine::new().layout(&doc);
447
448        let img = &result.graph.pages[0].images[0];
449        assert!((img.width_pt - 80.0).abs() < 0.5, "w = {}", img.width_pt);
450        assert!((img.height_pt - 40.0).abs() < 0.5, "h = {}", img.height_pt);
451    }
452
453    #[test]
454    fn image_block_both_dims_taller_box_fits_by_width() {
455        let mut doc = Document::new(PathBuf::from("test.mos"));
456        make_image(&mut doc, "x.png", 200, 100, Some(40.0), Some(80.0));
457
458        let result = LayoutEngine::new().layout(&doc);
459
460        let img = &result.graph.pages[0].images[0];
461        assert!((img.width_pt - 40.0).abs() < 0.5, "w = {}", img.width_pt);
462        assert!((img.height_pt - 20.0).abs() < 0.5, "h = {}", img.height_pt);
463    }
464
465    #[test]
466    fn image_block_clamped_to_column_width() {
467        let mut doc = Document::new(PathBuf::from("test.mos"));
468        make_image(&mut doc, "x.png", 4000, 2000, None, None);
469
470        let result = LayoutEngine::new().layout(&doc);
471
472        let img = &result.graph.pages[0].images[0];
473        let col = A4_WIDTH_PT - 2.0 * MARGIN_PT;
474        assert!(img.width_pt <= col + 0.5);
475        let aspect = 4000.0_f32 / 2000.0;
476        assert!((img.height_pt - img.width_pt / aspect).abs() < 0.5);
477    }
478
479    #[test]
480    fn oversized_image_after_paragraph_does_not_force_extra_page() {
481        let mut doc = Document::new(PathBuf::from("test.mos"));
482        make_paragraph(&mut doc, "lead paragraph");
483        make_image(&mut doc, "big.png", 1000, 1000, None, None);
484
485        let result = LayoutEngine::new().layout(&doc);
486
487        assert_eq!(result.graph.pages.len(), 1);
488        assert_eq!(result.graph.pages[0].images.len(), 1);
489        assert!(!result.graph.pages[0].runs.is_empty());
490    }
491
492    #[test]
493    fn image_dedup_emits_one_handle_per_resolved_path() {
494        let mut doc = Document::new(PathBuf::from("test.mos"));
495        make_image(&mut doc, "same.png", 50, 50, None, None);
496        make_image(&mut doc, "same.png", 50, 50, None, None);
497
498        let result = LayoutEngine::new().layout(&doc);
499
500        assert_eq!(result.graph.images.len(), 1);
501        let placements: Vec<_> = result
502            .graph
503            .pages
504            .iter()
505            .flat_map(|p| p.images.iter())
506            .collect();
507        assert_eq!(placements.len(), 2);
508        assert_eq!(placements[0].handle.id, placements[1].handle.id);
509    }
510
511    #[test]
512    fn figure_lays_out_image_then_caption() {
513        let mut doc = Document::new(PathBuf::from("test.mos"));
514        pin_helvetica(&mut doc);
515        make_figure_with_image_and_caption(&mut doc, 80, 50, "Caption text.");
516
517        let result = LayoutEngine::new().layout(&doc);
518
519        let page = &result.graph.pages[0];
520        assert_eq!(page.images.len(), 1);
521        let caption_run = page
522            .runs
523            .iter()
524            .find(|r| r.text == "Caption" || r.text == "text.")
525            .expect("caption run not found");
526        assert!(caption_run.baseline_from_top_pt > page.images[0].top_from_top_pt);
527    }
528
529    #[test]
530    fn paragraph_height_measurement_counts_shy_breaks_like_flow() {
531        let mut doc = Document::new(PathBuf::from("test.mos"));
532        let para = make_paragraph(&mut doc, "x super\u{AD}cali");
533        let line_width = text_width(
534            mos_fonts::Font::Base14(mos_fonts::Base14Font::Helvetica),
535            BODY_SIZE_PT,
536            "x super-",
537        ) + 1.0;
538        let mut state = helvetica_state_with_column_width(line_width);
539
540        let height = state.measure_paragraph_height(&doc, doc.get(para).expect("paragraph"));
541        let expected = 2.0 * BODY_SIZE_PT * BODY_LEADING;
542
543        assert!(
544            (height - expected).abs() < 0.01,
545            "expected two measured lines ({expected:.3}pt), got {height:.3}pt"
546        );
547    }
548
549    fn make_figure_with_image_and_caption(
550        doc: &mut Document,
551        pixel_w: u32,
552        pixel_h: u32,
553        caption: &str,
554    ) -> NodeId {
555        let fig = doc.alloc_child(
556            doc.root,
557            NodeSpec::new(
558                NodeKind::Figure,
559                SourceSpan::placeholder(PathBuf::from("test.mos")),
560            ),
561        );
562        let mut img_attrs = AttrMap::new();
563        img_attrs.insert("src".to_owned(), AttrValue::Str("fig.png".to_owned()));
564        img_attrs.insert(
565            "resolved_path".to_owned(),
566            AttrValue::Str(format!("/tmp/figkt-{pixel_w}x{pixel_h}.png")),
567        );
568        img_attrs.insert("pixel_width".to_owned(), AttrValue::Int(i64::from(pixel_w)));
569        img_attrs.insert(
570            "pixel_height".to_owned(),
571            AttrValue::Int(i64::from(pixel_h)),
572        );
573        img_attrs.insert(
574            "pixels".to_owned(),
575            AttrValue::Bytes(Arc::from(vec![0_u8; (pixel_w * pixel_h * 3) as usize])),
576        );
577        doc.alloc_child(
578            fig,
579            NodeSpec::new(
580                NodeKind::Image,
581                SourceSpan::placeholder(PathBuf::from("test.mos")),
582            )
583            .with_attributes(img_attrs),
584        );
585        let cap = doc.alloc_child(
586            fig,
587            NodeSpec::new(
588                NodeKind::Paragraph,
589                SourceSpan::placeholder(PathBuf::from("test.mos")),
590            ),
591        );
592        alloc_inline(doc, cap, NodeKind::Text, caption);
593        fig
594    }
595
596    #[test]
597    fn figure_image_and_caption_stay_on_the_same_page() {
598        let mut doc = Document::new(PathBuf::from("test.mos"));
599        pin_helvetica(&mut doc);
600        let mut filler = String::new();
601        for i in 0..540 {
602            filler.push_str(&format!("word{i} "));
603        }
604        make_paragraph(&mut doc, filler.trim());
605        make_figure_with_image_and_caption(&mut doc, 400, 300, "Tight caption.");
606
607        let result = LayoutEngine::new().layout(&doc);
608
609        let mut figure_page: Option<u32> = None;
610        let mut caption_page: Option<u32> = None;
611        for page in &result.graph.pages {
612            if !page.images.is_empty() && figure_page.is_none() {
613                figure_page = Some(page.number);
614            }
615            if page
616                .runs
617                .iter()
618                .any(|r| r.text == "Tight" || r.text == "caption.")
619                && caption_page.is_none()
620            {
621                caption_page = Some(page.number);
622            }
623        }
624        assert_eq!(figure_page, caption_page);
625    }
626}