1use crate::font_resolver::FontResolver;
2use crate::geometry::{Constraints, Rect, Size};
3use crate::render_node::{align_offset, RenderNode};
4use crate::text::{text_width_pt, wrap_text};
5use crate::warnings::{LayoutWarning, LayoutWarningKind};
6use lightweight_pdf_core::{Column, Element, Line, Overflow, Rect as RectElement, Row, Spacer, Text};
7
8const WIDOW_ORPHAN_N: usize = 2;
11const EPS: f32 = 0.01;
12
13pub struct LayoutCtx<'a> {
14 pub resolver: &'a dyn FontResolver,
15}
16
17pub enum LayoutResult {
22 Fit(RenderNode),
23 Split { current: RenderNode, remainder: Element },
24}
25
26pub trait Layoutable {
31 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size;
32 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult;
33}
34
35impl Layoutable for Element {
42 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
43 match self {
44 Element::Text(t) => t.measure(ctx, constraints),
45 Element::Row(r) => r.measure(ctx, constraints),
46 Element::Column(c) => c.measure(ctx, constraints),
47 Element::Spacer(s) => s.measure(ctx, constraints),
48 Element::Line(l) => l.measure(ctx, constraints),
49 Element::Rect(r) => r.measure(ctx, constraints),
50 Element::Table(t) => t.measure(ctx, constraints),
51 Element::Image(i) => i.measure(ctx, constraints),
52 Element::List(l) => l.measure(ctx, constraints),
53 Element::PageBreak => Size::default(),
54 }
55 }
56
57 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
58 match self {
59 Element::Text(t) => t.layout(ctx, area, warnings, page),
60 Element::Row(r) => r.layout(ctx, area, warnings, page),
61 Element::Column(c) => c.layout(ctx, area, warnings, page),
62 Element::Spacer(s) => s.layout(ctx, area, warnings, page),
63 Element::Line(l) => l.layout(ctx, area, warnings, page),
64 Element::Rect(r) => r.layout(ctx, area, warnings, page),
65 Element::Table(t) => t.layout(ctx, area, warnings, page),
66 Element::Image(i) => i.layout(ctx, area, warnings, page),
67 Element::List(l) => l.layout(ctx, area, warnings, page),
68 Element::PageBreak => LayoutResult::Fit(RenderNode::Empty),
69 }
70 }
71}
72
73fn line_height_pt(style: &lightweight_pdf_core::TextStyle) -> f32 {
78 style.size * style.line_height
79}
80
81fn text_lines_node(area: Rect, style: lightweight_pdf_core::TextStyle, lines: Vec<String>, lh: f32) -> RenderNode {
82 let height = lines.len() as f32 * lh;
83 RenderNode::clipped(
84 area,
85 RenderNode::TextLines {
86 area: Rect { height, ..area },
87 style,
88 lines,
89 line_height_pt: lh,
90 },
91 )
92}
93
94impl Layoutable for Text {
95 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
96 let width = self.common.width.unwrap_or(constraints.max_width);
97 let lines = wrap_text(ctx.resolver, &self.style, &self.content, width);
98 let lh = line_height_pt(&self.style);
99 let actual_width = lines
100 .iter()
101 .map(|l| text_width_pt(ctx.resolver, self.style.font, self.style.size, l))
102 .fold(0.0f32, f32::max);
103 Size {
104 width: self.common.width.unwrap_or(actual_width.min(width)),
105 height: self.common.height.unwrap_or(lines.len() as f32 * lh),
106 }
107 }
108
109 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
110 let lines = wrap_text(ctx.resolver, &self.style, &self.content, area.width);
111 let lh = line_height_pt(&self.style);
112 let total_height = lines.len() as f32 * lh;
113
114 if total_height <= area.height + EPS || lines.len() <= 1 {
115 if total_height > area.height + EPS {
116 warnings.push(LayoutWarning {
117 kind: LayoutWarningKind::TextClipped,
118 page,
119 element_hint: format!("Text \"{}\"", truncate_hint(&self.content)),
120 });
121 }
122 return LayoutResult::Fit(text_lines_node(area, self.style, lines, lh));
123 }
124
125 if self.common.height.is_some() {
131 return LayoutResult::Fit(layout_text_fixed_overflow(self, ctx, area, lines, lh, warnings, page));
132 }
133
134 let max_lines_by_height = ((area.height + EPS) / lh).floor().max(0.0) as usize;
135 let max_lines_by_height = max_lines_by_height.min(lines.len());
136
137 let mut split_at = max_lines_by_height;
138 if lines.len() < 2 * WIDOW_ORPHAN_N {
139 split_at = 0;
141 } else if split_at < WIDOW_ORPHAN_N {
142 split_at = 0;
144 } else if lines.len() - split_at < WIDOW_ORPHAN_N {
145 let adjusted = lines.len().saturating_sub(WIDOW_ORPHAN_N);
147 split_at = if adjusted >= WIDOW_ORPHAN_N { adjusted } else { 0 };
148 }
149
150 if split_at == 0 {
151 return LayoutResult::Split {
152 current: RenderNode::Empty,
153 remainder: Element::Text(self.clone()),
154 };
155 }
156
157 let (current_lines, remainder_lines) = lines.split_at(split_at);
158 let current = text_lines_node(
159 Rect {
160 height: current_lines.len() as f32 * lh,
161 ..area
162 },
163 self.style,
164 current_lines.to_vec(),
165 lh,
166 );
167 let remainder_text = remainder_lines.join(" ");
168 let mut remainder = self.clone();
169 remainder.content = remainder_text;
170 LayoutResult::Split {
171 current,
172 remainder: Element::Text(remainder),
173 }
174 }
175}
176
177fn layout_text_fixed_overflow(
183 text: &Text,
184 ctx: &LayoutCtx,
185 area: Rect,
186 lines: Vec<String>,
187 lh: f32,
188 warnings: &mut Vec<LayoutWarning>,
189 page: usize,
190) -> RenderNode {
191 let max_lines = (((area.height + EPS) / lh).floor().max(0.0) as usize).min(lines.len());
192 if max_lines >= lines.len() {
193 return text_lines_node(area, text.style, lines, lh);
194 }
195 warnings.push(LayoutWarning {
196 kind: LayoutWarningKind::TextClipped,
197 page,
198 element_hint: format!("Text \"{}\"", truncate_hint(&text.content)),
199 });
200 let take = if text.common.overflow == Overflow::Ellipsis {
201 max_lines.max(1).min(lines.len())
202 } else {
203 max_lines
204 };
205 let mut kept: Vec<String> = lines.into_iter().take(take).collect();
206 if text.common.overflow == Overflow::Ellipsis {
207 if let Some(last) = kept.last_mut() {
208 *last = fit_with_ellipsis(ctx, &text.style, last, area.width);
209 }
210 }
211 text_lines_node(area, text.style, kept, lh)
212}
213
214fn fit_with_ellipsis(ctx: &LayoutCtx, style: &lightweight_pdf_core::TextStyle, line: &str, max_width: f32) -> String {
217 let mut chars: Vec<char> = line.chars().collect();
218 loop {
219 let candidate: String = chars.iter().collect::<String>() + "…";
220 if text_width_pt(ctx.resolver, style.font, style.size, &candidate) <= max_width || chars.is_empty() {
221 return candidate;
222 }
223 chars.pop();
224 }
225}
226
227fn truncate_hint(s: &str) -> String {
228 if s.len() > 24 {
229 format!("{}…", &s[..24])
230 } else {
231 s.to_string()
232 }
233}
234
235impl Layoutable for Spacer {
242 fn measure(&self, _ctx: &LayoutCtx, _constraints: Constraints) -> Size {
243 Size {
244 width: self.size,
245 height: self.size,
246 }
247 }
248
249 fn layout(&self, _ctx: &LayoutCtx, _area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
250 LayoutResult::Fit(RenderNode::Empty)
251 }
252}
253
254impl Layoutable for Line {
259 fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
260 Size {
261 width: self.common.width.unwrap_or(constraints.max_width),
262 height: self.common.height.unwrap_or(self.thickness),
263 }
264 }
265
266 fn layout(&self, _ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
267 if self.thickness > area.height + EPS {
268 warnings.push(LayoutWarning {
269 kind: LayoutWarningKind::ContentOverflow,
270 page,
271 element_hint: "Line".to_string(),
272 });
273 }
274 let y_mid = area.y + (self.thickness / 2.0).min(area.height);
275 let node = RenderNode::Line {
276 x1: area.x,
277 y1: y_mid,
278 x2: area.x + area.width,
279 y2: y_mid,
280 thickness: self.thickness,
281 color: self.color,
282 };
283 LayoutResult::Fit(RenderNode::clipped(area, node))
284 }
285}
286
287impl Layoutable for RectElement {
292 fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
293 Size {
294 width: self.common.width.unwrap_or(constraints.max_width),
295 height: self.common.height.unwrap_or(0.0),
296 }
297 }
298
299 fn layout(&self, _ctx: &LayoutCtx, area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
300 let node = RenderNode::Rect {
301 area,
302 background: self.common.background,
303 border: self.common.border,
304 };
305 LayoutResult::Fit(RenderNode::clipped(area, node))
306 }
307}
308
309impl Layoutable for Row {
318 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
319 let width = self.common.width.unwrap_or(constraints.max_width);
320 let inner_width = (width - 2.0 * self.common.padding).max(0.0);
321 let (natural_heights, used_width) = row_natural_layout(self, ctx, inner_width);
322 let height = self
323 .common
324 .height
325 .unwrap_or(natural_heights.iter().cloned().fold(0.0f32, f32::max) + 2.0 * self.common.padding);
326 Size {
327 width: self.common.width.unwrap_or((used_width + 2.0 * self.common.padding).min(width)),
328 height,
329 }
330 }
331
332 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
333 let inner = area.shrink(self.common.padding);
334 let resolved_widths = resolve_row_widths(self, ctx, inner.width);
335
336 let mut children_nodes = Vec::with_capacity(self.children.len());
337 let mut cursor_x = inner.x;
338 let mut max_child_height = 0.0f32;
339 let mut child_sizes = Vec::with_capacity(self.children.len());
340
341 for (child, w) in self.children.iter().zip(resolved_widths.iter()) {
342 if let Element::Spacer(s) = child {
343 cursor_x += s.size + self.gap;
344 continue;
345 }
346 let size = child.measure(
347 ctx,
348 Constraints {
349 max_width: *w,
350 max_height: f32::INFINITY,
351 },
352 );
353 max_child_height = max_child_height.max(size.height);
354 child_sizes.push((cursor_x, *w, size.height));
355 cursor_x += w + self.gap;
356 }
357
358 let row_height = self.common.height.unwrap_or(max_child_height).max(0.0);
359 let bounded_row_height = row_height.min(inner.height.max(row_height));
360
361 let mut idx = 0;
362 for child in self.children.iter() {
363 if matches!(child, Element::Spacer(_)) {
364 continue;
365 }
366 let (x, w, h) = child_sizes[idx];
367 idx += 1;
368 let y_offset = align_offset(self.align, bounded_row_height, h);
369 let child_area = Rect {
370 x,
371 y: inner.y + y_offset,
372 width: w,
373 height: h,
374 };
375 match child.layout(ctx, child_area, warnings, page) {
376 LayoutResult::Fit(node) => children_nodes.push(node),
377 LayoutResult::Split { current, .. } => {
378 warnings.push(LayoutWarning {
381 kind: LayoutWarningKind::ContentOverflow,
382 page,
383 element_hint: "Row child taller than available space".to_string(),
384 });
385 children_nodes.push(current);
386 }
387 }
388 }
389
390 let outer_height = self.common.height.unwrap_or(bounded_row_height + 2.0 * self.common.padding);
391 let outer = Rect {
392 height: outer_height,
393 ..area
394 };
395 let group = RenderNode::Group {
396 area: outer,
397 clip: true,
398 background: self.common.background,
399 border: self.common.border,
400 children: children_nodes,
401 };
402 LayoutResult::Fit(group)
403 }
404}
405
406fn row_natural_layout(row: &Row, ctx: &LayoutCtx, bound_width: f32) -> (Vec<f32>, f32) {
409 let mut heights = Vec::new();
410 let mut used = 0.0f32;
411 let n = row.children.len();
412 for (i, child) in row.children.iter().enumerate() {
413 if let Element::Spacer(s) = child {
414 used += s.size;
415 } else {
416 let size = child.measure(
417 ctx,
418 Constraints {
419 max_width: bound_width,
420 max_height: f32::INFINITY,
421 },
422 );
423 heights.push(size.height);
424 used += size.width;
425 }
426 if i + 1 < n {
427 used += row.gap;
428 }
429 }
430 (heights, used)
431}
432
433fn resolve_row_widths(row: &Row, ctx: &LayoutCtx, bound_width: f32) -> Vec<f32> {
436 let n = row.children.len();
437 let gaps = if n > 0 { (n - 1) as f32 * row.gap } else { 0.0 };
438 let mut natural = vec![0.0f32; n];
439 let mut flex_sum = 0.0f32;
440 let mut fixed_total = 0.0f32;
441
442 for (i, child) in row.children.iter().enumerate() {
443 if let Element::Spacer(s) = child {
444 natural[i] = s.size;
445 fixed_total += s.size;
446 continue;
447 }
448 let common = child.common();
449 if let Some(f) = common.and_then(|c| c.flex) {
450 flex_sum += f;
451 continue;
452 }
453 let size = child.measure(
454 ctx,
455 Constraints {
456 max_width: bound_width,
457 max_height: f32::INFINITY,
458 },
459 );
460 natural[i] = size.width;
461 fixed_total += size.width;
462 }
463
464 let leftover = (bound_width - fixed_total - gaps).max(0.0);
465 if flex_sum > 0.0 {
466 for (i, child) in row.children.iter().enumerate() {
467 if let Some(f) = child.common().and_then(|c| c.flex) {
468 natural[i] = leftover * (f / flex_sum);
469 }
470 }
471 }
472 natural
473}
474
475impl Layoutable for Column {
482 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
483 let bound_width = self.common.width.unwrap_or(constraints.max_width);
490 let inner_width = (bound_width - 2.0 * self.common.padding).max(0.0);
491 let mut total_height = 0.0f32;
492 let mut max_child_width = 0.0f32;
493 let n = self.children.len();
494 for (i, child) in self.children.iter().enumerate() {
495 if let Element::PageBreak = child {
496 continue;
497 }
498 let (w, h) = if let Element::Spacer(s) = child {
499 (0.0, s.size)
500 } else {
501 let size = child.measure(
502 ctx,
503 Constraints {
504 max_width: inner_width,
505 max_height: f32::INFINITY,
506 },
507 );
508 (size.width, size.height)
509 };
510 max_child_width = max_child_width.max(w);
511 total_height += h;
512 if i + 1 < n {
513 total_height += self.gap;
514 }
515 }
516 Size {
517 width: self
518 .common
519 .width
520 .unwrap_or((max_child_width + 2.0 * self.common.padding).min(bound_width)),
521 height: self.common.height.unwrap_or(total_height + 2.0 * self.common.padding),
522 }
523 }
524
525 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
526 let inner = area.shrink(self.common.padding);
527 let bound_height = self.common.height.map(|h| h - 2.0 * self.common.padding).unwrap_or(inner.height);
528
529 let mut rendered = Vec::new();
530 let mut cursor_y = 0.0f32;
531
532 for (i, child) in self.children.iter().enumerate() {
533 if let Element::PageBreak = child {
534 let remainder_children: Vec<Element> = self.children[i + 1..].to_vec();
535 return finish_page(self, area, cursor_y, rendered, remainder_children, warnings, page);
536 }
537
538 let child_width = child
539 .common()
540 .and_then(|c| c.width)
541 .unwrap_or(inner.width - self.common.padding.min(inner.width));
542 let child_width = if child.common().and_then(|c| c.width).is_some() {
543 child_width
544 } else {
545 inner.width
546 };
547
548 if let Element::Spacer(s) = child {
549 cursor_y += s.size + self.gap;
550 continue;
551 }
552
553 let remaining_height = (bound_height - cursor_y).max(0.0);
554 let natural = child.measure(
555 ctx,
556 Constraints {
557 max_width: child_width,
558 max_height: f32::INFINITY,
559 },
560 );
561
562 let fits_fully = natural.height <= remaining_height + EPS;
563 let keep_ok = !fits_fully
564 || !child.common().map(|c| c.keep_with_next).unwrap_or(false)
565 || keep_with_next_satisfied(self, ctx, i, child_width, remaining_height - natural.height - self.gap);
566
567 if fits_fully && keep_ok {
568 let x_offset = align_offset(self.align, inner.width, child_width);
569 let child_area = Rect {
570 x: inner.x + x_offset,
571 y: inner.y + cursor_y,
572 width: child_width,
573 height: natural.height,
574 };
575 match child.layout(ctx, child_area, warnings, page) {
576 LayoutResult::Fit(node) => {
577 rendered.push(node);
578 cursor_y += natural.height + self.gap;
579 }
580 LayoutResult::Split { current, remainder } => {
581 let consumed = current.height();
588 if !matches!(current, RenderNode::Empty) {
589 rendered.push(current);
590 }
591 let mut remainder_children = vec![remainder];
592 remainder_children.extend(self.children[i + 1..].to_vec());
593 return finish_page(self, area, cursor_y + consumed, rendered, remainder_children, warnings, page);
594 }
595 }
596 continue;
597 }
598
599 let splittable = matches!(child, Element::Text(_) | Element::Column(_) | Element::Table(_));
601 let min_unit = match child {
602 Element::Text(t) => line_height_pt(&t.style),
603 Element::Table(t) => crate::table::table_min_unit(ctx, t, child_width),
604 _ => natural.height,
605 };
606
607 if cursor_y > EPS && (remaining_height < min_unit - EPS || !keep_ok) {
608 let mut remainder_children = vec![child.clone()];
611 remainder_children.extend(self.children[i + 1..].to_vec());
612 return finish_page(self, area, cursor_y, rendered, remainder_children, warnings, page);
613 }
614
615 if !splittable {
616 let x_offset = align_offset(self.align, inner.width, child_width);
622 let forced_height = if fits_fully { natural.height } else { remaining_height.max(0.0) };
623 let child_area = Rect {
624 x: inner.x + x_offset,
625 y: inner.y + cursor_y,
626 width: child_width,
627 height: forced_height,
628 };
629 if let LayoutResult::Fit(node) = child.layout(ctx, child_area, warnings, page) {
630 rendered.push(node);
631 }
632 if !fits_fully {
633 warnings.push(LayoutWarning {
634 kind: LayoutWarningKind::ForcedPageBreak,
635 page,
636 element_hint: "atomic element larger than one page".to_string(),
637 });
638 }
639 cursor_y += forced_height + self.gap;
640 continue;
641 }
642
643 let x_offset = align_offset(self.align, inner.width, child_width);
646 let child_area = Rect {
647 x: inner.x + x_offset,
648 y: inner.y + cursor_y,
649 width: child_width,
650 height: remaining_height,
651 };
652 match child.layout(ctx, child_area, warnings, page) {
653 LayoutResult::Fit(node) => {
654 rendered.push(node);
655 cursor_y += natural.height.min(remaining_height) + self.gap;
656 }
657 LayoutResult::Split { current, remainder } => {
658 let consumed = current.height();
663 if !matches!(current, RenderNode::Empty) {
664 rendered.push(current);
665 }
666 let mut remainder_children = vec![remainder];
667 remainder_children.extend(self.children[i + 1..].to_vec());
668 return finish_page(self, area, cursor_y + consumed, rendered, remainder_children, warnings, page);
669 }
670 }
671 }
672
673 let outer_height = self
674 .common
675 .height
676 .unwrap_or(cursor_y.max(0.0) - self.gap.min(cursor_y) + 2.0 * self.common.padding)
677 .max(0.0);
678 let outer_height = if cursor_y <= EPS { 0.0 } else { outer_height };
679 let outer = Rect {
680 height: outer_height,
681 ..area
682 };
683 LayoutResult::Fit(RenderNode::Group {
684 area: outer,
685 clip: true,
686 background: self.common.background,
687 border: self.common.border,
688 children: rendered,
689 })
690 }
691}
692
693fn keep_with_next_satisfied(col: &Column, ctx: &LayoutCtx, i: usize, width: f32, leftover: f32) -> bool {
698 let Some(next) = col.children.get(i + 1) else {
699 return true;
700 };
701 let min_needed = match next {
702 Element::Text(t) => line_height_pt(&t.style),
703 Element::Spacer(s) => s.size,
704 _ => {
705 next.measure(
706 ctx,
707 Constraints {
708 max_width: width,
709 max_height: f32::INFINITY,
710 },
711 )
712 .height
713 }
714 };
715 leftover + EPS >= min_needed
716}
717
718fn finish_page(
725 col: &Column,
726 outer_area: Rect,
727 cursor_y: f32,
728 rendered: Vec<RenderNode>,
729 remainder_children: Vec<Element>,
730 warnings: &mut Vec<LayoutWarning>,
731 page: usize,
732) -> LayoutResult {
733 if let Some(fixed_height) = col.common.height {
734 if !remainder_children.is_empty() {
735 warnings.push(LayoutWarning {
736 kind: LayoutWarningKind::ContentOverflow,
737 page,
738 element_hint: "Column content exceeds its fixed height".to_string(),
739 });
740 }
741 return LayoutResult::Fit(RenderNode::Group {
742 area: Rect {
743 height: fixed_height,
744 ..outer_area
745 },
746 clip: true,
747 background: col.common.background,
748 border: col.common.border,
749 children: rendered,
750 });
751 }
752
753 let outer_height = if cursor_y <= EPS {
754 0.0
755 } else {
756 (cursor_y - col.gap.min(cursor_y) + 2.0 * col.common.padding).max(0.0)
757 };
758 let current = RenderNode::Group {
759 area: Rect {
760 height: outer_height,
761 ..outer_area
762 },
763 clip: true,
764 background: col.common.background,
765 border: col.common.border,
766 children: rendered,
767 };
768 let remainder = Column {
769 children: remainder_children,
770 gap: col.gap,
771 align: col.align,
772 common: lightweight_pdf_core::Common {
773 height: None,
774 ..col.common
775 },
776 };
777 LayoutResult::Split {
778 current: if cursor_y <= EPS { RenderNode::Empty } else { current },
779 remainder: Element::Column(remainder),
780 }
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use crate::pagination::paginate_body;
787 use lightweight_pdf_core::{Common, Overflow as OverflowKind, Text as TextEl};
788
789 struct FixedMetrics;
790 impl crate::font_resolver::FontMetrics for FixedMetrics {
791 fn advance(&self, ch: char) -> f32 {
792 if ch == ' ' {
793 300.0
794 } else {
795 600.0
796 }
797 }
798 fn ascent(&self) -> f32 {
799 800.0
800 }
801 fn descent(&self) -> f32 {
802 -200.0
803 }
804 }
805 struct FixedResolver;
806 impl FontResolver for FixedResolver {
807 fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
808 &FixedMetrics
809 }
810 }
811
812 fn ctx() -> LayoutCtx<'static> {
813 LayoutCtx { resolver: &FixedResolver }
814 }
815
816 #[test]
819 fn column_auto_size_grows_with_content() {
820 let short = Column::new().child(TextEl::new("Hi").size(10.0).line_height(1.0));
821 let long = Column::new().children(vec![
822 TextEl::new("Line one").size(10.0).line_height(1.0),
823 TextEl::new("Line two").size(10.0).line_height(1.0),
824 TextEl::new("Line three").size(10.0).line_height(1.0),
825 ]);
826 let c = ctx();
827 let constraints = Constraints {
828 max_width: 400.0,
829 max_height: f32::INFINITY,
830 };
831 let short_size = short.measure(&c, constraints);
832 let long_size = long.measure(&c, constraints);
833 assert!(long_size.height > short_size.height, "more content must measure taller");
834 }
835
836 #[test]
839 fn fixed_height_text_clips_instead_of_splitting() {
840 let text = TextEl::new("AAAA BBBB CCCC DDDD").size(10.0).line_height(1.0).height(10.0);
841 let c = ctx();
842 let mut warnings = Vec::new();
843 let area = Rect {
845 x: 0.0,
846 y: 0.0,
847 width: 30.0,
848 height: 10.0,
849 };
850 let result = text.layout(&c, area, &mut warnings, 1);
851 assert!(
852 matches!(result, LayoutResult::Fit(_)),
853 "fixed-size box must Clip, never Split across pages"
854 );
855 assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::TextClipped));
856 }
857
858 #[test]
859 fn fixed_height_column_clips_instead_of_splitting() {
860 let col = Column::new().height(10.0).children(vec![
861 TextEl::new("Line one").size(10.0).line_height(1.0),
862 TextEl::new("Line two").size(10.0).line_height(1.0),
863 TextEl::new("Line three").size(10.0).line_height(1.0),
864 ]);
865 let c = ctx();
866 let mut warnings = Vec::new();
867 let area = Rect {
868 x: 0.0,
869 y: 0.0,
870 width: 400.0,
871 height: 10.0,
872 };
873 let result = col.layout(&c, area, &mut warnings, 1);
874 assert!(matches!(result, LayoutResult::Fit(_)), "fixed-height Column must Clip, never Split");
875 assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ContentOverflow));
876 }
877
878 #[test]
881 fn row_children_do_not_overlap_horizontally() {
882 let row = Row::new()
883 .gap(10.0)
884 .child(TextEl::new("Left").size(10.0))
885 .child(TextEl::new("Right").size(10.0));
886 let c = ctx();
887 let mut warnings = Vec::new();
888 let area = Rect {
889 x: 0.0,
890 y: 0.0,
891 width: 400.0,
892 height: 50.0,
893 };
894 let result = row.layout(&c, area, &mut warnings, 1);
895 let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
896 panic!("expected a Fit Group");
897 };
898 assert_eq!(children.len(), 2);
899 let rects: Vec<Rect> = children
900 .iter()
901 .map(|n| match n {
902 RenderNode::Group { area, .. } => *area,
903 other => panic!("expected nested Group, got {other:?}"),
904 })
905 .collect();
906 assert!(
907 rects[0].x + rects[0].width <= rects[1].x + EPS,
908 "children must not overlap: {:?} vs {:?}",
909 rects[0],
910 rects[1]
911 );
912 }
913
914 #[test]
917 fn page_break_forces_a_split_at_the_marker() {
918 let col = Column::new().children(vec![
919 Element::Text(TextEl::new("a")),
920 Element::PageBreak,
921 Element::Text(TextEl::new("b")),
922 ]);
923 let c = ctx();
924 let mut warnings = Vec::new();
925 let area = Rect {
926 x: 0.0,
927 y: 0.0,
928 width: 400.0,
929 height: 400.0, };
931 match col.layout(&c, area, &mut warnings, 1) {
932 LayoutResult::Split { remainder, .. } => match remainder {
933 Element::Column(rem) => {
934 assert_eq!(rem.children.len(), 1);
935 match &rem.children[0] {
936 Element::Text(t) => assert_eq!(t.content, "b"),
937 other => panic!("expected Text, got {other:?}"),
938 }
939 }
940 other => panic!("expected Column remainder, got {other:?}"),
941 },
942 LayoutResult::Fit(_) => panic!("PageBreak must force a Split even when content would otherwise fit"),
943 }
944 }
945
946 #[test]
949 fn oversized_atomic_element_is_forced_onto_its_own_page_and_terminates() {
950 let children = vec![
951 Element::Rect(RectElement::new().height(5000.0).background(lightweight_pdf_core::Color::BLACK)),
952 Element::Rect(RectElement::new().height(20.0)),
953 ];
954 let c = ctx();
955 let mut warnings = Vec::new();
956 let area = Rect {
957 x: 0.0,
958 y: 0.0,
959 width: 200.0,
960 height: 100.0,
961 };
962 let pages = paginate_body(&children, area, &c, &mut warnings);
963 assert_eq!(
964 pages.len(),
965 2,
966 "oversized element consumes its own page, second Rect starts a fresh one"
967 );
968 assert_eq!(warnings.iter().filter(|w| w.kind == LayoutWarningKind::ForcedPageBreak).count(), 1);
969 }
970
971 fn line_text(n: usize) -> String {
974 (0..n).map(|i| format!("L{i}")).collect::<Vec<_>>().join("\n")
975 }
976
977 #[test]
978 fn short_paragraph_is_never_split() {
979 let text = TextEl::new(line_text(3)).size(10.0).line_height(1.0);
982 let c = ctx();
983 let mut warnings = Vec::new();
984 let area = Rect {
985 x: 0.0,
986 y: 0.0,
987 width: 400.0,
988 height: 20.0, };
990 match text.layout(&c, area, &mut warnings, 1) {
991 LayoutResult::Split { current, remainder } => {
992 assert!(
993 matches!(current, RenderNode::Empty),
994 "short paragraph must move whole, nothing placed on this page"
995 );
996 match remainder {
997 Element::Text(t) => assert_eq!(t.content, line_text(3)),
998 other => panic!("expected Text remainder, got {other:?}"),
999 }
1000 }
1001 LayoutResult::Fit(_) => panic!("expected a Split (paragraph doesn't fully fit)"),
1002 }
1003 }
1004
1005 #[test]
1006 fn widow_is_avoided_by_pulling_lines_up() {
1007 let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
1010 let c = ctx();
1011 let mut warnings = Vec::new();
1012 let area = Rect {
1013 x: 0.0,
1014 y: 0.0,
1015 width: 400.0,
1016 height: 40.0, };
1018 match text.layout(&c, area, &mut warnings, 1) {
1019 LayoutResult::Split { current, remainder } => {
1020 let RenderNode::Group { children, .. } = current else {
1021 panic!("expected the clip-wrapping Group");
1022 };
1023 let RenderNode::TextLines { lines, .. } = &children[0] else {
1024 panic!("expected TextLines");
1025 };
1026 assert_eq!(lines.len(), 3, "must pull one line up so the remainder has >= 2 lines");
1027 match remainder {
1028 Element::Text(t) => assert_eq!(t.content.split(' ').count(), 2),
1029 other => panic!("expected Text remainder, got {other:?}"),
1030 }
1031 }
1032 LayoutResult::Fit(_) => panic!("expected a Split"),
1033 }
1034 }
1035
1036 #[test]
1037 fn orphan_moves_whole_paragraph_when_room_is_too_small() {
1038 let text = TextEl::new(line_text(5)).size(10.0).line_height(1.0);
1041 let c = ctx();
1042 let mut warnings = Vec::new();
1043 let area = Rect {
1044 x: 0.0,
1045 y: 0.0,
1046 width: 400.0,
1047 height: 10.0,
1048 };
1049 match text.layout(&c, area, &mut warnings, 1) {
1050 LayoutResult::Split { current, .. } => {
1051 assert!(matches!(current, RenderNode::Empty));
1052 }
1053 LayoutResult::Fit(_) => panic!("expected a Split"),
1054 }
1055 }
1056
1057 #[test]
1060 fn keep_with_next_moves_heading_along_with_its_body() {
1061 let col = Column::new().gap(0.0).children(vec![
1062 Element::Text(TextEl::new("Filler").size(10.0).line_height(1.0)),
1063 Element::Text(TextEl::new("Heading").size(10.0).line_height(1.0).keep_with_next()),
1064 Element::Text(TextEl::new("Body").size(10.0).line_height(1.0)),
1065 ]);
1066 let c = ctx();
1067 let mut warnings = Vec::new();
1068 let area = Rect {
1071 x: 0.0,
1072 y: 0.0,
1073 width: 400.0,
1074 height: 25.0,
1075 };
1076 match col.layout(&c, area, &mut warnings, 1) {
1077 LayoutResult::Split { current, remainder } => {
1078 let RenderNode::Group { children, .. } = current else {
1079 panic!("expected Group");
1080 };
1081 assert_eq!(children.len(), 1, "only the filler should remain on this page");
1082 match remainder {
1083 Element::Column(rem) => {
1084 assert_eq!(rem.children.len(), 2);
1085 match &rem.children[0] {
1086 Element::Text(t) => assert_eq!(t.content, "Heading"),
1087 other => panic!("expected Heading Text, got {other:?}"),
1088 }
1089 }
1090 other => panic!("expected Column remainder, got {other:?}"),
1091 }
1092 }
1093 LayoutResult::Fit(_) => panic!("expected keep_with_next to force a Split before the heading"),
1094 }
1095 }
1096
1097 #[test]
1098 fn overflow_ellipsis_truncates_fixed_single_line_text() {
1099 let text = TextEl::new("AAAAAAAAAAAAAAAA")
1100 .size(10.0)
1101 .line_height(1.0)
1102 .height(10.0)
1103 .overflow(OverflowKind::Ellipsis);
1104 let c = ctx();
1105 let mut warnings = Vec::new();
1106 let area = Rect {
1107 x: 0.0,
1108 y: 0.0,
1109 width: 40.0,
1110 height: 10.0,
1111 };
1112 let result = text.layout(&c, area, &mut warnings, 1);
1113 let LayoutResult::Fit(RenderNode::Group { children, .. }) = result else {
1114 panic!("expected Fit Group (clip wrapper)");
1115 };
1116 let RenderNode::TextLines { lines, .. } = &children[0] else {
1117 panic!("expected TextLines");
1118 };
1119 assert_eq!(lines.len(), 1);
1120 assert!(lines[0].ends_with('…'), "expected an ellipsis, got {:?}", lines[0]);
1121 }
1122
1123 #[test]
1124 fn common_default_is_used() {
1125 let c = Common::default();
1127 assert_eq!(c.width, None);
1128 assert_eq!(c.height, None);
1129 }
1130}