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