1mod column;
13mod leaf;
14mod row;
15mod shared;
16
17pub(crate) use shared::{
18 clip_to_fixed_height, coerce_to_fit, coerce_to_fit_and_warn, finish_fit, line_height_pt, measure_at_width, push_warning,
19 resolve_auto_size, resolve_bound, shrink_and_bound_height, wrap_children,
20};
21
22use crate::font_resolver::FontResolver;
23use crate::geometry::{Constraints, Rect, Size};
24use crate::render_node::{RenderNode, StructRole};
25use crate::toc::TocHeading;
26use crate::warnings::LayoutWarning;
27use lightweight_pdf_core::Element;
28use std::collections::HashMap;
29
30pub(crate) fn wrap_result(result: LayoutResult, role: StructRole) -> LayoutResult {
38 match result {
39 LayoutResult::Fit(node) => LayoutResult::Fit(RenderNode::tagged(role, node)),
40 LayoutResult::Split { current, remainder } => LayoutResult::Split {
41 current: RenderNode::tagged(role, current),
42 remainder,
43 },
44 }
45}
46
47pub struct LayoutCtx<'a> {
48 pub resolver: &'a dyn FontResolver,
49 pub toc_headings: &'a [TocHeading],
53 pub toc_heading_pages: Option<&'a HashMap<String, usize>>,
59}
60
61impl<'a> LayoutCtx<'a> {
62 pub fn new(resolver: &'a dyn FontResolver) -> Self {
66 LayoutCtx {
67 resolver,
68 toc_headings: &[],
69 toc_heading_pages: None,
70 }
71 }
72}
73
74pub enum LayoutResult {
79 Fit(RenderNode),
80 Split { current: RenderNode, remainder: Element },
81}
82
83pub trait Layoutable {
84 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size;
85 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult;
86}
87
88impl Layoutable for Element {
95 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
96 match self {
97 Element::Text(t) => t.measure(ctx, constraints),
98 Element::Row(r) => r.measure(ctx, constraints),
99 Element::Column(c) => c.measure(ctx, constraints),
100 Element::Spacer(s) => s.measure(ctx, constraints),
101 Element::Line(l) => l.measure(ctx, constraints),
102 Element::Rect(r) => r.measure(ctx, constraints),
103 Element::Table(t) => t.measure(ctx, constraints),
104 Element::Image(i) => i.measure(ctx, constraints),
105 Element::List(l) => l.measure(ctx, constraints),
106 Element::TableOfContents(t) => t.measure(ctx, constraints),
107 Element::PageBreak => Size::default(),
108 }
109 }
110
111 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
112 match self {
113 Element::Text(t) => {
123 let role = match t.outline_level {
124 Some(n) => StructRole::Heading(n),
125 None => StructRole::Paragraph,
126 };
127 wrap_result(t.layout(ctx, area, warnings, page), role)
128 }
129 Element::Row(r) => r.layout(ctx, area, warnings, page),
130 Element::Column(c) => c.layout(ctx, area, warnings, page),
131 Element::Spacer(s) => s.layout(ctx, area, warnings, page),
132 Element::Line(l) => l.layout(ctx, area, warnings, page),
133 Element::Rect(r) => r.layout(ctx, area, warnings, page),
134 Element::Table(t) => wrap_result(t.layout(ctx, area, warnings, page), StructRole::Table),
135 Element::Image(i) => wrap_result(i.layout(ctx, area, warnings, page), StructRole::Figure),
136 Element::List(l) => wrap_result(l.layout(ctx, area, warnings, page), StructRole::List),
137 Element::TableOfContents(t) => wrap_result(t.layout(ctx, area, warnings, page), StructRole::Toc),
138 Element::PageBreak => LayoutResult::Fit(RenderNode::Empty),
139 }
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::shared::EPS;
146 use super::*;
147 use crate::pagination::paginate_body;
148 use crate::warnings::LayoutWarningKind;
149 use lightweight_pdf_core::{Column, Common, Overflow as OverflowKind, Rect as RectElement, Row, Span, Text as TextEl, TextStyle};
150
151 struct FixedMetrics;
152 impl crate::font_resolver::FontMetrics for FixedMetrics {
153 fn advance(&self, ch: char) -> f32 {
154 if ch == ' ' {
155 300.0
156 } else {
157 600.0
158 }
159 }
160 fn ascent(&self) -> f32 {
161 800.0
162 }
163 fn descent(&self) -> f32 {
164 -200.0
165 }
166 }
167 struct FixedResolver;
168 impl FontResolver for FixedResolver {
169 fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
170 &FixedMetrics
171 }
172 }
173
174 fn ctx() -> LayoutCtx<'static> {
175 LayoutCtx::new(&FixedResolver)
176 }
177
178 #[test]
181 fn column_auto_size_grows_with_content() {
182 let short = Column::new().child(TextEl::new("Hi").size(10.0).line_height(1.0));
183 let long = Column::new().children(vec![
184 TextEl::new("Line one").size(10.0).line_height(1.0),
185 TextEl::new("Line two").size(10.0).line_height(1.0),
186 TextEl::new("Line three").size(10.0).line_height(1.0),
187 ]);
188 let c = ctx();
189 let constraints = Constraints {
190 max_width: 400.0,
191 max_height: f32::INFINITY,
192 };
193 let short_size = short.measure(&c, constraints);
194 let long_size = long.measure(&c, constraints);
195 assert!(long_size.height > short_size.height, "more content must measure taller");
196 }
197
198 #[test]
201 fn fixed_height_text_clips_instead_of_splitting() {
202 let text = TextEl::new("AAAA BBBB CCCC DDDD").size(10.0).line_height(1.0).height(10.0);
203 let c = ctx();
204 let mut warnings = Vec::new();
205 let area = Rect {
207 x: 0.0,
208 y: 0.0,
209 width: 30.0,
210 height: 10.0,
211 };
212 let result = text.layout(&c, area, &mut warnings, 1);
213 assert!(
214 matches!(result, LayoutResult::Fit(_)),
215 "fixed-size box must Clip, never Split across pages"
216 );
217 assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::TextClipped));
218 }
219
220 #[test]
221 fn fixed_height_column_clips_instead_of_splitting() {
222 let col = Column::new().height(10.0).children(vec![
223 TextEl::new("Line one").size(10.0).line_height(1.0),
224 TextEl::new("Line two").size(10.0).line_height(1.0),
225 TextEl::new("Line three").size(10.0).line_height(1.0),
226 ]);
227 let c = ctx();
228 let mut warnings = Vec::new();
229 let area = Rect {
230 x: 0.0,
231 y: 0.0,
232 width: 400.0,
233 height: 10.0,
234 };
235 let result = col.layout(&c, area, &mut warnings, 1);
236 assert!(matches!(result, LayoutResult::Fit(_)), "fixed-height Column must Clip, never Split");
237 assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ContentOverflow));
238 }
239
240 #[test]
243 fn row_children_do_not_overlap_horizontally() {
244 let row = Row::new()
245 .gap(10.0)
246 .child(TextEl::new("Left").size(10.0))
247 .child(TextEl::new("Right").size(10.0));
248 let c = ctx();
249 let mut warnings = Vec::new();
250 let area = Rect {
251 x: 0.0,
252 y: 0.0,
253 width: 400.0,
254 height: 50.0,
255 };
256 let result = row.layout(&c, area, &mut warnings, 1);
257 let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
258 panic!("expected a Fit Group");
259 };
260 assert_eq!(children.len(), 2);
261 let rects: Vec<Rect> = children
262 .iter()
263 .map(|n| match n.untagged() {
264 RenderNode::Group { area, .. } => *area,
265 other => panic!("expected nested Group, got {other:?}"),
266 })
267 .collect();
268 assert!(
269 rects[0].x + rects[0].width <= rects[1].x + EPS,
270 "children must not overlap: {:?} vs {:?}",
271 rects[0],
272 rects[1]
273 );
274 }
275
276 #[test]
279 fn page_break_forces_a_split_at_the_marker() {
280 let col = Column::new().children(vec![
281 Element::Text(TextEl::new("a")),
282 Element::PageBreak,
283 Element::Text(TextEl::new("b")),
284 ]);
285 let c = ctx();
286 let mut warnings = Vec::new();
287 let area = Rect {
288 x: 0.0,
289 y: 0.0,
290 width: 400.0,
291 height: 400.0, };
293 match col.layout(&c, area, &mut warnings, 1) {
294 LayoutResult::Split { remainder, .. } => match remainder {
295 Element::Column(rem) => {
296 assert_eq!(rem.children.len(), 1);
297 match &rem.children[0] {
298 Element::Text(t) => assert_eq!(t.content, "b"),
299 other => panic!("expected Text, got {other:?}"),
300 }
301 }
302 other => panic!("expected Column remainder, got {other:?}"),
303 },
304 LayoutResult::Fit(_) => panic!("PageBreak must force a Split even when content would otherwise fit"),
305 }
306 }
307
308 #[test]
311 fn oversized_atomic_element_is_forced_onto_its_own_page_and_terminates() {
312 let children = vec![
313 Element::Rect(RectElement::new().height(5000.0).background(lightweight_pdf_core::Color::BLACK)),
314 Element::Rect(RectElement::new().height(20.0)),
315 ];
316 let c = ctx();
317 let mut warnings = Vec::new();
318 let area = Rect {
319 x: 0.0,
320 y: 0.0,
321 width: 200.0,
322 height: 100.0,
323 };
324 let pages = paginate_body(&children, area, &c, &mut warnings);
325 assert_eq!(
326 pages.len(),
327 2,
328 "oversized element consumes its own page, second Rect starts a fresh one"
329 );
330 assert_eq!(warnings.iter().filter(|w| w.kind == LayoutWarningKind::ForcedPageBreak).count(), 1);
331 }
332
333 fn line_text(n: usize) -> String {
336 (0..n).map(|i| format!("L{i}")).collect::<Vec<_>>().join("\n")
337 }
338
339 #[test]
340 fn short_paragraph_is_never_split() {
341 let text = TextEl::new(line_text(3)).size(10.0).line_height(1.0);
344 let c = ctx();
345 let mut warnings = Vec::new();
346 let area = Rect {
347 x: 0.0,
348 y: 0.0,
349 width: 400.0,
350 height: 20.0, };
352 match text.layout(&c, area, &mut warnings, 1) {
353 LayoutResult::Split { current, remainder } => {
354 assert!(
355 matches!(current, RenderNode::Empty),
356 "short paragraph must move whole, nothing placed on this page"
357 );
358 match remainder {
359 Element::Text(t) => assert_eq!(t.content, line_text(3)),
360 other => panic!("expected Text remainder, got {other:?}"),
361 }
362 }
363 LayoutResult::Fit(_) => panic!("expected a Split (paragraph doesn't fully fit)"),
364 }
365 }
366
367 #[test]
368 fn widow_is_avoided_by_pulling_lines_up() {
369 let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
372 let c = ctx();
373 let mut warnings = Vec::new();
374 let area = Rect {
375 x: 0.0,
376 y: 0.0,
377 width: 400.0,
378 height: 40.0, };
380 match text.layout(&c, area, &mut warnings, 1) {
381 LayoutResult::Split { current, remainder } => {
382 let RenderNode::Group { children, .. } = current else {
383 panic!("expected the clip-wrapping Group");
384 };
385 let RenderNode::TextLines { lines, .. } = &children[0] else {
386 panic!("expected TextLines");
387 };
388 assert_eq!(lines.len(), 3, "must pull one line up so the remainder has >= 2 lines");
389 match remainder {
390 Element::Text(t) => assert_eq!(t.content.split(' ').count(), 2),
391 other => panic!("expected Text remainder, got {other:?}"),
392 }
393 }
394 LayoutResult::Fit(_) => panic!("expected a Split"),
395 }
396 }
397
398 #[test]
399 fn orphan_moves_whole_paragraph_when_room_is_too_small() {
400 let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
403 let c = ctx();
404 let mut warnings = Vec::new();
405 let area = Rect {
406 x: 0.0,
407 y: 0.0,
408 width: 400.0,
409 height: 10.0,
410 };
411 match text.layout(&c, area, &mut warnings, 1) {
412 LayoutResult::Split { current, .. } => {
413 assert!(matches!(current, RenderNode::Empty));
414 }
415 LayoutResult::Fit(_) => panic!("expected a Split"),
416 }
417 }
418
419 #[test]
422 fn keep_with_next_moves_heading_along_with_its_body() {
423 let col = Column::new().gap(0.0).children(vec![
424 Element::Text(TextEl::new("Filler").size(10.0).line_height(1.0)),
425 Element::Text(TextEl::new("Heading").size(10.0).line_height(1.0).keep_with_next()),
426 Element::Text(TextEl::new("Body").size(10.0).line_height(1.0)),
427 ]);
428 let c = ctx();
429 let mut warnings = Vec::new();
430 let area = Rect {
433 x: 0.0,
434 y: 0.0,
435 width: 400.0,
436 height: 25.0,
437 };
438 match col.layout(&c, area, &mut warnings, 1) {
439 LayoutResult::Split { current, remainder } => {
440 let RenderNode::Group { children, .. } = current else {
441 panic!("expected Group");
442 };
443 assert_eq!(children.len(), 1, "only the filler should remain on this page");
444 match remainder {
445 Element::Column(rem) => {
446 assert_eq!(rem.children.len(), 2);
447 match &rem.children[0] {
448 Element::Text(t) => assert_eq!(t.content, "Heading"),
449 other => panic!("expected Heading Text, got {other:?}"),
450 }
451 }
452 other => panic!("expected Column remainder, got {other:?}"),
453 }
454 }
455 LayoutResult::Fit(_) => panic!("expected keep_with_next to force a Split before the heading"),
456 }
457 }
458
459 #[test]
460 fn overflow_ellipsis_truncates_fixed_single_line_text() {
461 let text = TextEl::new("AAAAAAAAAAAAAAAA")
462 .size(10.0)
463 .line_height(1.0)
464 .height(10.0)
465 .overflow(OverflowKind::Ellipsis);
466 let c = ctx();
467 let mut warnings = Vec::new();
468 let area = Rect {
469 x: 0.0,
470 y: 0.0,
471 width: 40.0,
472 height: 10.0,
473 };
474 let result = text.layout(&c, area, &mut warnings, 1);
475 let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
476 panic!("expected Fit Group (clip wrapper)");
477 };
478 let RenderNode::TextLines { lines, .. } = &children[0] else {
479 panic!("expected TextLines");
480 };
481 assert_eq!(lines.len(), 1);
482 assert!(lines[0].ends_with('…'), "expected an ellipsis, got {:?}", lines[0]);
483 }
484
485 #[test]
486 fn common_default_is_used() {
487 let c = Common::default();
489 assert_eq!(c.width, None);
490 assert_eq!(c.height, None);
491 }
492
493 #[test]
496 fn rich_text_wraps_words_from_multiple_spans_in_order() {
497 let style = TextStyle {
498 size: 10.0,
499 line_height: 1.0,
500 ..Default::default()
501 };
502 let text = TextEl::rich([Span::new("AAAA", style), Span::new(" BBBB", style)]);
503 let c = ctx();
504 let mut warnings = Vec::new();
505 let area = Rect {
506 x: 0.0,
507 y: 0.0,
508 width: 400.0,
509 height: 100.0,
510 };
511 let LayoutResult::Fit(RenderNode::Group { children, .. }) = text.layout(&c, area, &mut warnings, 1) else {
512 panic!("expected Fit");
513 };
514 let RenderNode::RichTextLines { lines, .. } = &children[0] else {
515 panic!("expected RichTextLines");
516 };
517 assert_eq!(lines.len(), 1, "both words fit on one line");
518 let words: Vec<&str> = lines[0].words.iter().map(|w| w.text.as_str()).collect();
519 assert_eq!(words, vec!["AAAA", "BBBB"], "words from both spans, in order, on the same line");
520 }
521
522 #[test]
523 fn rich_text_mixed_sizes_share_one_line_height_and_ascent() {
524 let small = TextStyle {
525 size: 10.0,
526 line_height: 1.0,
527 ..Default::default()
528 };
529 let big = TextStyle {
530 size: 20.0,
531 line_height: 1.0,
532 ..Default::default()
533 };
534 let text = TextEl::rich([Span::new("a", small), Span::new(" B", big)]);
535 let c = ctx();
536 let mut warnings = Vec::new();
537 let area = Rect {
538 x: 0.0,
539 y: 0.0,
540 width: 400.0,
541 height: 100.0,
542 };
543 let LayoutResult::Fit(RenderNode::Group { children, .. }) = text.layout(&c, area, &mut warnings, 1) else {
544 panic!("expected Fit");
545 };
546 let RenderNode::RichTextLines { lines, .. } = &children[0] else {
547 panic!("expected RichTextLines");
548 };
549 assert_eq!(lines.len(), 1);
550 assert_eq!(lines[0].height, 20.0);
554 assert_eq!(lines[0].ascent_pt, 16.0);
555 }
556
557 #[test]
558 fn rich_text_can_split_in_the_middle_of_a_single_span() {
559 let style = TextStyle {
562 size: 10.0,
563 line_height: 1.0,
564 ..Default::default()
565 };
566 let text = TextEl::rich([Span::new("L0 L1 L2 L3 L4", style)]);
567 let c = ctx();
568 let mut warnings = Vec::new();
569 let area = Rect {
570 x: 0.0,
571 y: 0.0,
572 width: 15.0,
573 height: 40.0, };
575 match text.layout(&c, area, &mut warnings, 1) {
576 LayoutResult::Split { current, remainder } => {
577 let RenderNode::Group { children, .. } = current else {
578 panic!("expected the clip-wrapping Group");
579 };
580 let RenderNode::RichTextLines { lines, .. } = &children[0] else {
581 panic!("expected RichTextLines");
582 };
583 assert_eq!(lines.len(), 3, "widow/orphan rule pulls one line up, same as plain text");
584 match remainder {
585 Element::Text(t) => {
586 let spans = t.spans.expect("remainder of a rich Text must still be rich text");
587 assert_eq!(spans.len(), 1, "the single span continues as a single span, split mid-span");
588 assert_eq!(spans[0].text, "L3 L4");
589 }
590 other => panic!("expected Text remainder, got {other:?}"),
591 }
592 }
593 LayoutResult::Fit(_) => panic!("expected a Split"),
594 }
595 }
596}