Skip to main content

lightweight_pdf_layout/
table.rs

1//! `Table` layout (Phase 3, `plan/phases/phase-3-tables.md`): fixed/flex
2//! column widths, cell content reuses the ordinary `Layoutable` machinery
3//! (word-wrap included), row height auto-grows with the tallest cell
4//! (Grundprinzip 5), a row is never split mid-row (atomic unit), the
5//! header repeats on every continuation page.
6
7use 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
15/// Fixed columns keep their exact width; the leftover space is shared
16/// proportionally among flex columns by weight (taffy `flex-grow`
17/// analogy). The last flex column absorbs any float-rounding remainder so
18/// the widths sum *exactly* to `available_width` (phase-3 DoD).
19fn 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
73/// The smallest worthwhile placement for a `Table` when there's already
74/// other content on the page (used by `Column`'s "is it worth starting
75/// here" check, mirroring `Text`'s per-line granularity instead of
76/// treating a whole table as one atomic block).
77pub 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                // Cells never split (a row is an atomic unit,
127                // Grundprinzip 5's table addendum) — keep what fit,
128                // ContentOverflow already implied by TextClipped from the
129                // cell's own fixed-size handling if applicable.
130                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                // Only the header (or nothing) placed so far: this row is
239                // atomic and doesn't fit even a fresh page — force it,
240                // clip, warn (Grundprinzip 7), then move on.
241                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            // Doesn't fit — move this row and everything after it to a
263            // continuation page, which repeats the header.
264            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, // room for header + a couple of rows only
387        };
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        // Every page (after the first) must repeat the header as its
408        // first row, and every original data row must appear exactly
409        // once across all pages, in order.
410        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"])]); // 10 chars * 6pt = 60pt, column inner width ~22pt
441        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        // Rows must not overlap vertically: each row's y must be >= the
494        // previous row's y + height.
495        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        // Force a split after the header + 2 rows (each ~22.4pt: 14.4pt
516        // line height + 2*4pt cell padding).
517        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        // Row 2 (0-indexed) is the first row on the continuation page;
530        // row_offset must reflect its true absolute index so striping
531        // continues correctly instead of resetting.
532        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        // "42" is much narrower than the 100pt column; End-align must
592        // push it toward the right edge, not leave it at x=0.
593        assert!(cell_area.x > 50.0, "expected right-aligned cell, got x={}", cell_area.x);
594    }
595}