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::{
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
18/// Fixed columns keep their exact width; the leftover space is shared
19/// proportionally among flex columns by weight (taffy `flex-grow`
20/// analogy). The last flex column absorbs any float-rounding remainder so
21/// the widths sum *exactly* to `available_width` (phase-3 DoD).
22fn 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
68/// The header row's height, or `0.0` if the table has no header — shared
69/// by `table_min_unit` and `Table::layout`.
70fn 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
78/// The smallest worthwhile placement for a `Table` when there's already
79/// other content on the page (used by `Column`'s "is it worth starting
80/// here" check, mirroring `Text`'s per-line granularity instead of
81/// treating a whole table as one atomic block).
82pub 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                // Cells never split (a row is an atomic unit,
120                // Grundprinzip 5's table addendum) — keep what fit,
121                // ContentOverflow already implied by TextClipped from the
122                // cell's own fixed-size handling if applicable.
123                nodes.push(current);
124            }
125        }
126        cursor_x += *w;
127    }
128    nodes
129}
130
131/// The per-row data that varies across `render_row` call sites (header vs.
132/// data row vs. forced/oversized row), grouped so the function itself
133/// doesn't need one positional `f32`/`Option<Color>` parameter per field.
134struct 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                // Only the header (or nothing) placed so far: this row is
233                // atomic and doesn't fit even a fresh page — force it,
234                // clip, warn (Grundprinzip 7), then move on.
235                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            // Doesn't fit — move this row and everything after it to a
260            // continuation page, which repeats the header.
261            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, // room for header + a couple of rows only
353        };
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        // Every page (after the first) must repeat the header as its
374        // first row, and every original data row must appear exactly
375        // once across all pages, in order.
376        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"])]); // 10 chars * 6pt = 60pt, column inner width ~22pt
407        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        // Rows must not overlap vertically: each row's y must be >= the
460        // previous row's y + height.
461        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        // Force a split after the header + 2 rows (each ~22.4pt: 14.4pt
482        // line height + 2*4pt cell padding).
483        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        // Row 2 (0-indexed) is the first row on the continuation page;
496        // row_offset must reflect its true absolute index so striping
497        // continues correctly instead of resetting.
498        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        // "42" is much narrower than the 100pt column; End-align must
558        // push it toward the right edge, not leave it at x=0.
559        assert!(cell_area.x > 50.0, "expected right-aligned cell, got x={}", cell_area.x);
560    }
561}