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 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 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 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 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}