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