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