1use crate::geometry::{Constraints, Rect, Size};
8use crate::layoutable::{LayoutCtx, LayoutResult, Layoutable};
9use crate::render_node::{align_offset, RenderNode};
10use crate::warnings::{LayoutWarning, LayoutWarningKind};
11use lightweight_pdf_core::{Color, ColumnWidth, Common, Element, Table, TableColumn};
12
13const EPS: f32 = 0.01;
14
15fn resolve_column_widths(columns: &[TableColumn], available_width: f32) -> Vec<f32> {
20 let fixed_total: f32 = columns
21 .iter()
22 .filter_map(|c| match c.width {
23 ColumnWidth::Fixed(w) => Some(w),
24 ColumnWidth::Flex(_) => None,
25 })
26 .sum();
27 let flex_sum: f32 = columns
28 .iter()
29 .filter_map(|c| match c.width {
30 ColumnWidth::Flex(w) => Some(w),
31 ColumnWidth::Fixed(_) => None,
32 })
33 .sum();
34 let leftover = (available_width - fixed_total).max(0.0);
35
36 let mut widths = Vec::with_capacity(columns.len());
37 let mut last_flex_idx = None;
38 for (i, c) in columns.iter().enumerate() {
39 match c.width {
40 ColumnWidth::Fixed(w) => widths.push(w),
41 ColumnWidth::Flex(weight) => {
42 widths.push(if flex_sum > 0.0 { leftover * (weight / flex_sum) } else { 0.0 });
43 last_flex_idx = Some(i);
44 }
45 }
46 }
47 if let Some(i) = last_flex_idx {
48 let sum: f32 = widths.iter().sum();
49 widths[i] += available_width - sum;
50 }
51 widths
52}
53
54fn measure_row_height(ctx: &LayoutCtx, cells: &[Element], col_widths: &[f32], cell_padding: f32) -> f32 {
55 cells
56 .iter()
57 .zip(col_widths.iter())
58 .map(|(cell, w)| {
59 let inner_w = (w - 2.0 * cell_padding).max(0.0);
60 cell.measure(
61 ctx,
62 Constraints {
63 max_width: inner_w,
64 max_height: f32::INFINITY,
65 },
66 )
67 .height
68 + 2.0 * cell_padding
69 })
70 .fold(0.0f32, f32::max)
71}
72
73pub fn table_min_unit(ctx: &LayoutCtx, table: &Table, width: f32) -> f32 {
78 let col_widths = resolve_column_widths(&table.columns, (width - 2.0 * table.common.padding).max(0.0));
79 let header_h = table
80 .header
81 .as_ref()
82 .map(|h| measure_row_height(ctx, h, &col_widths, table.cell_padding))
83 .unwrap_or(0.0);
84 let first_row_h = table
85 .rows
86 .first()
87 .map(|r| measure_row_height(ctx, r, &col_widths, table.cell_padding))
88 .unwrap_or(0.0);
89 header_h + first_row_h
90}
91
92#[allow(clippy::too_many_arguments)]
93fn layout_row_cells(
94 ctx: &LayoutCtx,
95 cells: &[Element],
96 columns: &[TableColumn],
97 col_widths: &[f32],
98 row_area: Rect,
99 cell_padding: f32,
100 warnings: &mut Vec<LayoutWarning>,
101 page: usize,
102) -> Vec<RenderNode> {
103 let mut nodes = Vec::with_capacity(cells.len());
104 let mut cursor_x = row_area.x;
105 for ((cell, col), w) in cells.iter().zip(columns.iter()).zip(col_widths.iter()) {
106 let inner_w = (w - 2.0 * cell_padding).max(0.0);
107 let content_h = (row_area.height - 2.0 * cell_padding).max(0.0);
108 let cell_size = cell.measure(
109 ctx,
110 Constraints {
111 max_width: inner_w,
112 max_height: f32::INFINITY,
113 },
114 );
115 let box_width = cell_size.width.min(inner_w).max(0.0);
116 let x_offset = align_offset(col.align, inner_w, box_width);
117 let cell_area = Rect {
118 x: cursor_x + cell_padding + x_offset,
119 y: row_area.y + cell_padding,
120 width: box_width,
121 height: content_h,
122 };
123 match cell.layout(ctx, cell_area, warnings, page) {
124 LayoutResult::Fit(node) => nodes.push(node),
125 LayoutResult::Split { current, .. } => {
126 nodes.push(current);
131 }
132 }
133 cursor_x += *w;
134 }
135 nodes
136}
137
138#[allow(clippy::too_many_arguments)]
139fn render_row(
140 ctx: &LayoutCtx,
141 table: &Table,
142 cells: &[Element],
143 col_widths: &[f32],
144 y: f32,
145 inner: &Rect,
146 row_height: f32,
147 background: Option<Color>,
148 warnings: &mut Vec<LayoutWarning>,
149 page: usize,
150) -> RenderNode {
151 let row_area = Rect {
152 x: inner.x,
153 y: inner.y + y,
154 width: inner.width,
155 height: row_height,
156 };
157 let nodes = layout_row_cells(ctx, cells, &table.columns, col_widths, row_area, table.cell_padding, warnings, page);
158 RenderNode::Group {
159 area: row_area,
160 clip: true,
161 background,
162 border: None,
163 children: nodes,
164 }
165}
166
167impl Layoutable for Table {
168 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
169 let width = self.common.width.unwrap_or(constraints.max_width);
170 let inner_width = (width - 2.0 * self.common.padding).max(0.0);
171 let col_widths = resolve_column_widths(&self.columns, inner_width);
172 let mut total = 0.0f32;
173 if let Some(header) = &self.header {
174 total += measure_row_height(ctx, header, &col_widths, self.cell_padding);
175 }
176 for row in &self.rows {
177 total += measure_row_height(ctx, row, &col_widths, self.cell_padding);
178 }
179 Size {
180 width,
181 height: self.common.height.unwrap_or(total + 2.0 * self.common.padding),
182 }
183 }
184
185 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
186 let inner = area.shrink(self.common.padding);
187 let col_widths = resolve_column_widths(&self.columns, inner.width);
188 let bound_height = self.common.height.map(|h| h - 2.0 * self.common.padding).unwrap_or(inner.height);
189 let header_height = self
190 .header
191 .as_ref()
192 .map(|h| measure_row_height(ctx, h, &col_widths, self.cell_padding))
193 .unwrap_or(0.0);
194
195 let mut rendered = Vec::new();
196 let mut cursor_y = 0.0f32;
197
198 if let Some(header) = &self.header {
199 rendered.push(render_row(
200 ctx,
201 self,
202 header,
203 &col_widths,
204 cursor_y,
205 &inner,
206 header_height,
207 None,
208 warnings,
209 page,
210 ));
211 cursor_y += header_height;
212 }
213
214 for (i, row) in self.rows.iter().enumerate() {
215 let absolute_i = self.row_offset + i;
216 let row_height = measure_row_height(ctx, row, &col_widths, self.cell_padding);
217 let remaining = (bound_height - cursor_y).max(0.0);
218 let stripe = self.striped.filter(|_| absolute_i % 2 == 1);
219
220 if row_height <= remaining + EPS {
221 rendered.push(render_row(
222 ctx,
223 self,
224 row,
225 &col_widths,
226 cursor_y,
227 &inner,
228 row_height,
229 stripe,
230 warnings,
231 page,
232 ));
233 cursor_y += row_height;
234 continue;
235 }
236
237 if cursor_y <= header_height + EPS {
238 rendered.push(render_row(
242 ctx,
243 self,
244 row,
245 &col_widths,
246 cursor_y,
247 &inner,
248 remaining,
249 stripe,
250 warnings,
251 page,
252 ));
253 warnings.push(LayoutWarning {
254 kind: LayoutWarningKind::ForcedPageBreak,
255 page,
256 element_hint: format!("Table row {absolute_i} larger than one page"),
257 });
258 cursor_y = bound_height;
259 continue;
260 }
261
262 if let Some(fixed_height) = self.common.height {
265 if !self.rows[i..].is_empty() {
266 warnings.push(LayoutWarning {
267 kind: LayoutWarningKind::ContentOverflow,
268 page,
269 element_hint: "Table content exceeds its fixed height".to_string(),
270 });
271 }
272 return LayoutResult::Fit(RenderNode::Group {
273 area: Rect {
274 height: fixed_height,
275 ..area
276 },
277 clip: true,
278 background: self.common.background,
279 border: self.common.border,
280 children: rendered,
281 });
282 }
283
284 let remainder = Table {
285 columns: self.columns.clone(),
286 header: self.header.clone(),
287 rows: self.rows[i..].to_vec(),
288 striped: self.striped,
289 cell_padding: self.cell_padding,
290 row_offset: absolute_i,
291 common: Common {
292 height: None,
293 ..self.common
294 },
295 };
296 let current = RenderNode::Group {
297 area: Rect { height: cursor_y, ..area },
298 clip: true,
299 background: self.common.background,
300 border: self.common.border,
301 children: rendered,
302 };
303 return LayoutResult::Split {
304 current,
305 remainder: Element::Table(remainder),
306 };
307 }
308
309 let outer_height = self.common.height.unwrap_or(cursor_y + 2.0 * self.common.padding);
310 LayoutResult::Fit(RenderNode::Group {
311 area: Rect {
312 height: outer_height,
313 ..area
314 },
315 clip: true,
316 background: self.common.background,
317 border: self.common.border,
318 children: rendered,
319 })
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::warnings::LayoutWarningKind;
327 use lightweight_pdf_core::{Align, Element, Text as TextEl};
328
329 struct FixedMetrics;
330 impl crate::font_resolver::FontMetrics for FixedMetrics {
331 fn advance(&self, ch: char) -> f32 {
332 if ch == ' ' {
333 300.0
334 } else {
335 600.0
336 }
337 }
338 fn ascent(&self) -> f32 {
339 800.0
340 }
341 fn descent(&self) -> f32 {
342 -200.0
343 }
344 }
345 struct FixedResolver;
346 impl crate::font_resolver::FontResolver for FixedResolver {
347 fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
348 &FixedMetrics
349 }
350 }
351 fn ctx() -> LayoutCtx<'static> {
352 LayoutCtx { resolver: &FixedResolver }
353 }
354
355 fn row(cells: &[&str]) -> Vec<Element> {
356 cells.iter().map(|c| Element::Text(TextEl::new(*c))).collect()
357 }
358
359 #[test]
360 fn column_widths_sum_exactly_to_available_width() {
361 let columns = vec![
362 TableColumn::flex(1.0),
363 TableColumn::fixed(37.3),
364 TableColumn::flex(2.0),
365 TableColumn::fixed(19.9),
366 ];
367 let widths = resolve_column_widths(&columns, 400.0);
368 let sum: f32 = widths.iter().sum();
369 assert!((sum - 400.0).abs() < 1e-3, "widths must sum exactly to available width, got {sum}");
370 assert_eq!(widths[1], 37.3);
371 assert_eq!(widths[3], 19.9);
372 }
373
374 #[test]
375 fn header_repeats_and_all_rows_survive_a_page_split() {
376 let table = Table::new()
377 .columns([TableColumn::flex(1.0)])
378 .header(["Beschreibung"])
379 .rows((0..20).map(|i| row(&[Box::leak(format!("Zeile {i}").into_boxed_str())])));
380 let c = ctx();
381 let mut warnings = Vec::new();
382 let area = Rect {
383 x: 0.0,
384 y: 0.0,
385 width: 200.0,
386 height: 60.0, };
388 let mut pages = Vec::new();
389 let mut current = Element::Table(table);
390 loop {
391 match current.layout(&c, area, &mut warnings, pages.len() + 1) {
392 LayoutResult::Fit(node) => {
393 pages.push(node);
394 break;
395 }
396 LayoutResult::Split { current: node, remainder } => {
397 pages.push(node);
398 current = remainder;
399 }
400 }
401 if pages.len() > 100 {
402 panic!("pagination did not terminate");
403 }
404 }
405 assert!(pages.len() > 1, "expected the table to span multiple pages");
406
407 let mut seen_rows = Vec::new();
411 for page in &pages {
412 let RenderNode::Group { children, .. } = page else {
413 panic!("expected a Group");
414 };
415 assert!(!children.is_empty(), "every page must render at least the header");
416 for row_node in children {
417 let RenderNode::Group { children: cells, .. } = row_node else {
418 panic!("expected row Group");
419 };
420 let RenderNode::Group { children: text_wrap, .. } = &cells[0] else {
421 panic!("expected clipped text wrapper");
422 };
423 let RenderNode::TextLines { lines, .. } = &text_wrap[0] else {
424 panic!("expected TextLines");
425 };
426 seen_rows.push(lines.join(" "));
427 }
428 }
429 let header_count = seen_rows.iter().filter(|s| *s == "Beschreibung").count();
430 assert_eq!(header_count, pages.len(), "header must repeat on every page exactly once");
431 let data_rows: Vec<_> = seen_rows.iter().filter(|s| *s != "Beschreibung").collect();
432 assert_eq!(data_rows.len(), 20, "no row may be lost or duplicated across the split");
433 for (i, row) in data_rows.iter().enumerate() {
434 assert_eq!(*row, &format!("Zeile {i}"), "rows must stay in order");
435 }
436 }
437
438 #[test]
439 fn cell_hard_breaks_a_token_wider_than_the_column() {
440 let table = Table::new().columns([TableColumn::fixed(30.0)]).rows([row(&["ABCDEFGHIJ"])]); let c = ctx();
442 let mut warnings = Vec::new();
443 let area = Rect {
444 x: 0.0,
445 y: 0.0,
446 width: 30.0,
447 height: 200.0,
448 };
449 let LayoutResult::Fit(RenderNode::Group { children, .. }) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
450 panic!("expected Fit");
451 };
452 let RenderNode::Group { children: cells, .. } = &children[0] else {
453 panic!("expected row group");
454 };
455 let RenderNode::Group { children: text_wrap, .. } = &cells[0] else {
456 panic!("expected clipped text wrapper");
457 };
458 let RenderNode::TextLines { lines, .. } = &text_wrap[0] else {
459 panic!("expected TextLines");
460 };
461 assert!(lines.len() > 1, "a token wider than the column must hard-break onto multiple lines");
462 }
463
464 #[test]
465 fn row_height_grows_with_tallest_cell_without_moving_other_rows() {
466 let table = Table::new().columns([TableColumn::fixed(30.0), TableColumn::fixed(30.0)]).rows([
467 row(&["kurz", "kurz"]),
468 row(&["ein sehr sehr sehr sehr langer Zellinhalt der umbricht", "kurz"]),
469 row(&["kurz", "kurz"]),
470 ]);
471 let c = ctx();
472 let mut warnings = Vec::new();
473 let area = Rect {
474 x: 0.0,
475 y: 0.0,
476 width: 60.0,
477 height: 400.0,
478 };
479 let LayoutResult::Fit(RenderNode::Group { children: rows, .. }) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
480 panic!("expected Fit");
481 };
482 assert_eq!(rows.len(), 3);
483 let heights: Vec<f32> = rows
484 .iter()
485 .map(|r| match r {
486 RenderNode::Group { area, .. } => area.height,
487 _ => panic!("expected Group"),
488 })
489 .collect();
490 assert!(heights[1] > heights[0], "the row with more content must be taller");
491 assert_eq!(heights[0], heights[2], "unrelated rows keep their own (equal) height");
492
493 let ys: Vec<f32> = rows
496 .iter()
497 .map(|r| match r {
498 RenderNode::Group { area, .. } => area.y,
499 _ => unreachable!(),
500 })
501 .collect();
502 assert!(ys[1] >= ys[0] + heights[0] - EPS);
503 assert!(ys[2] >= ys[1] + heights[1] - EPS);
504 }
505
506 #[test]
507 fn striped_alternates_and_survives_a_split() {
508 let table = Table::new()
509 .columns([TableColumn::flex(1.0)])
510 .header(["H"])
511 .striped(Color::rgb(240, 240, 240))
512 .rows((0..6).map(|i| row(&[Box::leak(format!("R{i}").into_boxed_str())])));
513 let c = ctx();
514 let mut warnings = Vec::new();
515 let area = Rect {
518 x: 0.0,
519 y: 0.0,
520 width: 100.0,
521 height: 3.5 * 22.4,
522 };
523 let LayoutResult::Split { remainder, .. } = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
524 panic!("expected a Split");
525 };
526 let Element::Table(remainder_table) = remainder else {
527 panic!("expected Table remainder");
528 };
529 assert_eq!(remainder_table.row_offset, 2);
533 }
534
535 #[test]
536 fn oversized_row_forces_its_own_page() {
537 let table = Table::new()
538 .columns([TableColumn::flex(1.0)])
539 .rows([row(&["normal"]), row(&["a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np"])]);
540 let c = ctx();
541 let mut warnings = Vec::new();
542 let area = Rect {
543 x: 0.0,
544 y: 0.0,
545 width: 100.0,
546 height: 100.0,
547 };
548 let mut pages = 0;
549 let mut current = Element::Table(table);
550 loop {
551 match current.layout(&c, area, &mut warnings, pages + 1) {
552 LayoutResult::Fit(_) => {
553 pages += 1;
554 break;
555 }
556 LayoutResult::Split { remainder, .. } => {
557 pages += 1;
558 current = remainder;
559 }
560 }
561 if pages > 50 {
562 panic!("pagination did not terminate");
563 }
564 }
565 assert!(pages >= 2, "the oversized row should push onto its own page");
566 assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ForcedPageBreak));
567 }
568
569 #[test]
570 fn table_column_align_positions_short_content_in_the_column() {
571 let table = Table::new()
572 .columns([TableColumn::fixed(100.0).align(Align::End)])
573 .rows([row(&["42"])]);
574 let c = ctx();
575 let mut warnings = Vec::new();
576 let area = Rect {
577 x: 0.0,
578 y: 0.0,
579 width: 100.0,
580 height: 50.0,
581 };
582 let LayoutResult::Fit(RenderNode::Group { children: rows, .. }) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
583 panic!("expected Fit");
584 };
585 let RenderNode::Group { children: cells, .. } = &rows[0] else {
586 panic!("expected row group");
587 };
588 let RenderNode::Group { area: cell_area, .. } = &cells[0] else {
589 panic!("expected clipped cell wrapper");
590 };
591 assert!(cell_area.x > 50.0, "expected right-aligned cell, got x={}", cell_area.x);
594 }
595}