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, StructRole};
13use crate::warnings::{LayoutWarning, LayoutWarningKind};
14use lightweight_pdf_core::{Color, ColumnWidth, Common, Element, Table, TableCell, 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: &[TableCell], col_widths: &[f32], cell_padding: f32) -> f32 {
58    let mut max_h = 0.0f32;
59    let mut col_idx = 0;
60    for cell in cells {
61        if col_idx >= col_widths.len() {
62            break;
63        }
64        let span = cell.colspan.max(1);
65        let end_idx = (col_idx + span).min(col_widths.len());
66        let total_w: f32 = col_widths[col_idx..end_idx].iter().sum();
67        let padding = cell.padding.unwrap_or(cell_padding);
68        let inner_w = (total_w - 2.0 * padding).max(0.0);
69        let h = measure_at_width(ctx, &cell.element, inner_w).height + 2.0 * padding;
70        max_h = max_h.max(h);
71        col_idx = end_idx;
72    }
73    max_h
74}
75
76/// The header row's height, or `0.0` if the table has no header — shared
77/// by `table_min_unit` and `Table::layout`.
78fn header_row_height(ctx: &LayoutCtx, table: &Table, col_widths: &[f32]) -> f32 {
79    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}
85
86/// The smallest worthwhile placement for a `Table` when there's already
87/// other content on the page (used by `Column`'s "is it worth starting
88/// here" check, mirroring `Text`'s per-line granularity instead of
89/// treating a whole table as one atomic block).
90pub fn table_min_unit(ctx: &LayoutCtx, table: &Table, width: f32) -> f32 {
91    let col_widths = resolve_column_widths(&table.columns, (width - 2.0 * table.common.padding).max(0.0));
92    let first_row_h = table
93        .rows
94        .first()
95        .map(|r| measure_row_height(ctx, r, &col_widths, table.cell_padding))
96        .unwrap_or(0.0);
97    header_row_height(ctx, table, &col_widths) + first_row_h
98}
99
100fn layout_row_cells(
101    ctx: &LayoutCtx,
102    table: &Table,
103    cells: &[TableCell],
104    col_widths: &[f32],
105    row_area: Rect,
106    warnings: &mut Vec<LayoutWarning>,
107    page: usize,
108) -> Vec<RenderNode> {
109    let cell_padding = table.cell_padding;
110    let mut nodes = Vec::with_capacity(cells.len());
111    let mut cursor_x = row_area.x;
112    let mut col_idx = 0;
113    for cell in cells {
114        if col_idx >= table.columns.len() {
115            push_warning(
116                warnings,
117                LayoutWarningKind::TableRowOverflow,
118                page,
119                format!(
120                    "table row has {} cell(s), table has {} column(s) — extra cells dropped",
121                    cells.len(),
122                    table.columns.len()
123                ),
124            );
125            break;
126        }
127        let span = cell.colspan.max(1);
128        let end_idx = (col_idx + span).min(table.columns.len());
129        let total_w: f32 = col_widths[col_idx..end_idx].iter().sum();
130        let col_align = cell.align.unwrap_or(table.columns[col_idx].align);
131        let padding = cell.padding.unwrap_or(cell_padding);
132
133        let inner_w = (total_w - 2.0 * padding).max(0.0);
134        let content_h = (row_area.height - 2.0 * padding).max(0.0);
135        let cell_size = measure_at_width(ctx, &cell.element, inner_w);
136        let box_width = cell_size.width.min(inner_w).max(0.0);
137        let x_offset = align_offset(col_align, inner_w, box_width);
138        let cell_box = Rect {
139            x: cursor_x,
140            y: row_area.y,
141            width: total_w,
142            height: row_area.height,
143        };
144        let mut cell_children = Vec::new();
145        if cell.background.is_some() || cell.border.is_some() {
146            cell_children.push(RenderNode::Rect {
147                area: cell_box,
148                background: cell.background,
149                border: cell.border,
150                corner_radius: 0.0,
151            });
152        }
153        let cell_area = Rect {
154            x: cursor_x + padding + x_offset,
155            y: row_area.y + padding,
156            width: box_width,
157            height: content_h,
158        };
159        match cell.element.layout(ctx, cell_area, warnings, page) {
160            LayoutResult::Fit(node) => cell_children.push(node),
161            LayoutResult::Split { current, .. } => {
162                cell_children.push(current);
163            }
164        }
165        // Header cells only (issue #27) — `layout_row_cells` has exactly
166        // one call site, `render_row`, which is itself only ever called
167        // for the header row.
168        nodes.push(RenderNode::tagged(
169            StructRole::TableHeaderCell,
170            RenderNode::Group {
171                area: cell_box,
172                clip: false,
173                background: None,
174                border: None,
175                corner_radius: 0.0,
176                children: cell_children,
177            },
178        ));
179        cursor_x += total_w;
180        col_idx = end_idx;
181    }
182    nodes
183}
184
185/// The per-row data that varies across `render_row` call sites (header vs.
186/// data row vs. forced/oversized row), grouped so the function itself
187/// doesn't need one positional `f32`/`Option<Color>` parameter per field.
188struct RowRenderParams<'a> {
189    cells: &'a [TableCell],
190    y: f32,
191    row_height: f32,
192    background: Option<Color>,
193}
194
195fn render_row(
196    ctx: &LayoutCtx,
197    table: &Table,
198    col_widths: &[f32],
199    inner: &Rect,
200    warnings: &mut Vec<LayoutWarning>,
201    page: usize,
202    row: RowRenderParams,
203) -> RenderNode {
204    let row_area = Rect {
205        x: inner.x,
206        y: inner.y + row.y,
207        width: inner.width,
208        height: row.row_height,
209    };
210    let nodes = layout_row_cells(ctx, table, row.cells, col_widths, row_area, warnings, page);
211    RenderNode::tagged(
212        StructRole::TableRow,
213        RenderNode::Group {
214            area: row_area,
215            clip: true,
216            background: row.background,
217            border: None,
218            corner_radius: 0.0,
219            children: nodes,
220        },
221    )
222}
223
224// ---------------------------------------------------------------------
225// rowspan: body rows only (the header repeats per page independently and
226// stays on the simple per-row path above — a rowspan cell "hanging over"
227// into a repeated header makes no sense). A `rowspan` cell blocks its
228// column(s) for the rows below it; a row with no free cell for a given
229// column simply omits a `TableCell` there (same convention as HTML
230// `<tr>`/`<td>`). The page-break rule (`plan/02-elementcatalog-and-features.md`
231// discussion for #14): a rowspan may never be split across a page boundary
232// — the whole "block" of rows it covers moves to the next page together.
233// ---------------------------------------------------------------------
234
235/// One cell's resolved grid position, computed once per `Table::layout`
236/// call by [`plan_grid`] and reused for both row-height accounting and
237/// rendering.
238struct CellPlacement<'a> {
239    row: usize,
240    col_start: usize,
241    col_end: usize,
242    cell: &'a TableCell,
243}
244
245/// Simulates column occupancy row by row: a cell with `rowspan > 1`
246/// placed at row `r` blocks its column(s) through row `r + rowspan - 1`.
247/// Returns every cell's resolved `(row, col_start, col_end)` and, per row,
248/// whether it's a "continuation" row (some column still blocked by an
249/// earlier row's `rowspan`) — a continuation row is never a legal
250/// page-break point on its own; see [`atomic_blocks`].
251fn plan_grid<'a>(
252    rows: &'a [Vec<TableCell>],
253    num_columns: usize,
254    warnings: &mut Vec<LayoutWarning>,
255    page: usize,
256) -> (Vec<CellPlacement<'a>>, Vec<bool>) {
257    // blocked_until[col] = column `col` is occupied by an earlier row's
258    // rowspan for every row index strictly less than this value.
259    let mut blocked_until = vec![0usize; num_columns];
260    let mut placements = Vec::new();
261    let mut is_continuation = Vec::with_capacity(rows.len());
262
263    for (row_idx, row) in rows.iter().enumerate() {
264        is_continuation.push((0..num_columns).any(|c| blocked_until[c] > row_idx));
265
266        let mut col_idx = 0;
267        for cell in row {
268            while col_idx < num_columns && blocked_until[col_idx] > row_idx {
269                col_idx += 1;
270            }
271            if col_idx >= num_columns {
272                push_warning(
273                    warnings,
274                    LayoutWarningKind::TableRowOverflow,
275                    page,
276                    format!("table row {row_idx} has more cells than the table has free column(s) after rowspan — extra cells dropped"),
277                );
278                break;
279            }
280            let col_end = (col_idx + cell.colspan.max(1)).min(num_columns);
281            let rowspan = cell.rowspan.max(1);
282            if rowspan > 1 {
283                for slot in blocked_until[col_idx..col_end].iter_mut() {
284                    *slot = row_idx + rowspan;
285                }
286            }
287            placements.push(CellPlacement {
288                row: row_idx,
289                col_start: col_idx,
290                col_end,
291                cell,
292            });
293            col_idx = col_end;
294        }
295    }
296    (placements, is_continuation)
297}
298
299/// Each row's natural height from its own (non-`rowspan`-owning) cells —
300/// `rowspan` cells are handled separately by [`apply_rowspan_deficits`],
301/// since their content can spread across several rows.
302fn natural_row_heights(ctx: &LayoutCtx, placements: &[CellPlacement], num_rows: usize, col_widths: &[f32], cell_padding: f32) -> Vec<f32> {
303    let mut heights = vec![0.0f32; num_rows];
304    for p in placements {
305        if p.cell.rowspan.max(1) > 1 {
306            continue;
307        }
308        let padding = p.cell.padding.unwrap_or(cell_padding);
309        let inner_w = (col_widths[p.col_start..p.col_end].iter().sum::<f32>() - 2.0 * padding).max(0.0);
310        let h = measure_at_width(ctx, &p.cell.element, inner_w).height + 2.0 * padding;
311        heights[p.row] = heights[p.row].max(h);
312    }
313    heights
314}
315
316/// If a `rowspan` cell's own content needs more height than the rows it
317/// spans already provide, the shortfall is added to the *last* spanned
318/// row — simple, predictable, and keeps every other row's height
319/// undisturbed by a tall spanning cell.
320fn apply_rowspan_deficits(ctx: &LayoutCtx, placements: &[CellPlacement], heights: &mut [f32], col_widths: &[f32], cell_padding: f32) {
321    for p in placements {
322        let span = p.cell.rowspan.max(1);
323        if span <= 1 {
324            continue;
325        }
326        let padding = p.cell.padding.unwrap_or(cell_padding);
327        let inner_w = (col_widths[p.col_start..p.col_end].iter().sum::<f32>() - 2.0 * padding).max(0.0);
328        let needed = measure_at_width(ctx, &p.cell.element, inner_w).height + 2.0 * padding;
329        let end_row = (p.row + span).min(heights.len());
330        let available: f32 = heights[p.row..end_row].iter().sum();
331        if needed > available {
332            if let Some(last) = heights[p.row..end_row].last_mut() {
333                *last += needed - available;
334            }
335        }
336    }
337}
338
339/// Groups row indices into maximal runs where every row after the first
340/// is a continuation row of the one before it — the atomic units the
341/// page-break loop in `Table::layout` treats as indivisible.
342fn atomic_blocks(is_continuation: &[bool]) -> Vec<std::ops::Range<usize>> {
343    let mut blocks = Vec::new();
344    let mut i = 0;
345    while i < is_continuation.len() {
346        let start = i;
347        i += 1;
348        while i < is_continuation.len() && is_continuation[i] {
349            i += 1;
350        }
351        blocks.push(start..i);
352    }
353    blocks
354}
355
356/// Reduces a forced block's row heights to fit `budget`: rows keep their
357/// natural height from the top until the budget runs out, everything
358/// after collapses to zero — "clip the tail," the same read-order
359/// priority a single oversized row already got before `rowspan` existed.
360fn shrink_row_heights_to_fit(natural: &[f32], budget: f32) -> Vec<f32> {
361    let mut remaining_budget = budget.max(0.0);
362    natural
363        .iter()
364        .map(|&h| {
365            let take = h.min(remaining_budget);
366            remaining_budget -= take;
367            take
368        })
369        .collect()
370}
371
372/// Everything a block's rendering needs that stays the same across every
373/// cell/row in it — bundled so `render_cells_starting_at`/`render_block`
374/// don't grow past clippy's argument-count limit.
375struct TableRenderCtx<'a> {
376    ctx: &'a LayoutCtx<'a>,
377    table: &'a Table,
378    col_widths: &'a [f32],
379    placements: &'a [CellPlacement<'a>],
380    page: usize,
381}
382
383/// Lays out every cell placed at `row_idx`, using `block_heights[local_row_idx..]`
384/// for cells that reach beyond this single row (`rowspan > 1`) —
385/// `block_heights` is local to the block currently being rendered
386/// (already resolved to either natural or forced-page-clipped values by
387/// the caller), not the table-wide array.
388fn render_cells_starting_at(
389    trc: &TableRenderCtx,
390    row_area: &Rect,
391    warnings: &mut Vec<LayoutWarning>,
392    row_idx: usize,
393    local_row_idx: usize,
394    block_heights: &[f32],
395) -> Vec<RenderNode> {
396    let cell_padding = trc.table.cell_padding;
397    let mut nodes = Vec::new();
398    for p in trc.placements.iter().filter(|p| p.row == row_idx) {
399        let span = p.cell.rowspan.max(1);
400        let local_end = (local_row_idx + span).min(block_heights.len());
401        let cell_h: f32 = block_heights[local_row_idx..local_end].iter().sum();
402        let total_w: f32 = trc.col_widths[p.col_start..p.col_end].iter().sum();
403        let col_align = p.cell.align.unwrap_or(trc.table.columns[p.col_start].align);
404        let cursor_x = row_area.x + trc.col_widths[..p.col_start].iter().sum::<f32>();
405        let padding = p.cell.padding.unwrap_or(cell_padding);
406
407        let cell_box = Rect {
408            x: cursor_x,
409            y: row_area.y,
410            width: total_w,
411            height: cell_h,
412        };
413        // A cell's own background/border paints over its *whole* box
414        // (not just the padded content area), and — precedence: cell
415        // beats row beats column — over whatever the row's zebra stripe
416        // already painted underneath it.
417        let mut cell_children = Vec::new();
418        if p.cell.background.is_some() || p.cell.border.is_some() {
419            cell_children.push(RenderNode::Rect {
420                area: cell_box,
421                background: p.cell.background,
422                border: p.cell.border,
423                corner_radius: 0.0,
424            });
425        }
426
427        let inner_w = (total_w - 2.0 * padding).max(0.0);
428        let content_h = (cell_h - 2.0 * padding).max(0.0);
429        let cell_size = measure_at_width(trc.ctx, &p.cell.element, inner_w);
430        let box_width = cell_size.width.min(inner_w).max(0.0);
431        let x_offset = align_offset(col_align, inner_w, box_width);
432        let cell_area = Rect {
433            x: cursor_x + padding + x_offset,
434            y: row_area.y + padding,
435            width: box_width,
436            height: content_h,
437        };
438        match p.cell.element.layout(trc.ctx, cell_area, warnings, trc.page) {
439            LayoutResult::Fit(node) => cell_children.push(node),
440            LayoutResult::Split { current, .. } => cell_children.push(current),
441        }
442        // Body cells only (issue #27) — this function is only ever
443        // called for `self.rows`, never the header (see `render_row`).
444        nodes.push(RenderNode::tagged(
445            StructRole::TableCell,
446            RenderNode::Group {
447                area: cell_box,
448                clip: false,
449                background: None,
450                border: None,
451                corner_radius: 0.0,
452                children: cell_children,
453            },
454        ));
455    }
456    nodes
457}
458
459/// Renders one atomic block (`block.len() == 1` for the overwhelmingly
460/// common non-`rowspan` case — identical `RenderNode` shape to
461/// `render_row` above, so existing single-row tests/output are
462/// unaffected).
463///
464/// A `block.len() > 1` block wraps every row in one non-striped, clipping
465/// outer `Group` (its area spans the whole block, so a `rowspan` cell
466/// drawn from an earlier row is never clipped by a row boundary it
467/// extends past) with each row's own stripe painted as a plain
468/// background `Rect` child instead of the `Group.background` field.
469fn render_block(
470    trc: &TableRenderCtx,
471    inner: &Rect,
472    warnings: &mut Vec<LayoutWarning>,
473    block: std::ops::Range<usize>,
474    block_heights: &[f32], // one entry per row in `block`, in order
475    block_top_y: f32,
476    row_backgrounds: &[Option<Color>], // indexed by absolute row index
477) -> RenderNode {
478    if block.len() == 1 {
479        let row_idx = block.start;
480        let row_area = Rect {
481            x: inner.x,
482            y: inner.y + block_top_y,
483            width: inner.width,
484            height: block_heights[0],
485        };
486        let nodes = render_cells_starting_at(trc, &row_area, warnings, row_idx, 0, block_heights);
487        return RenderNode::tagged(
488            StructRole::TableRow,
489            RenderNode::Group {
490                area: row_area,
491                clip: true,
492                background: row_backgrounds[row_idx],
493                border: None,
494                corner_radius: 0.0,
495                children: nodes,
496            },
497        );
498    }
499
500    let block_area = Rect {
501        x: inner.x,
502        y: inner.y + block_top_y,
503        width: inner.width,
504        height: block_heights.iter().sum(),
505    };
506    let mut children = Vec::new();
507    let mut cursor_y = block_top_y;
508    for (local_idx, row_idx) in block.clone().enumerate() {
509        let row_h = block_heights[local_idx];
510        let row_area = Rect {
511            x: inner.x,
512            y: inner.y + cursor_y,
513            width: inner.width,
514            height: row_h,
515        };
516        // Each visual row gets its own `TableRow` tag (issue #27), even
517        // though a `rowspan` cell's content lives in an earlier row's
518        // node: the per-row granularity needed for one `/TR` per row is
519        // already present in this loop's structure.
520        let mut row_children = Vec::new();
521        if let Some(bg) = row_backgrounds[row_idx] {
522            row_children.push(RenderNode::Rect {
523                area: row_area,
524                background: Some(bg),
525                border: None,
526                corner_radius: 0.0,
527            });
528        }
529        row_children.extend(render_cells_starting_at(
530            trc,
531            &row_area,
532            warnings,
533            row_idx,
534            local_idx,
535            block_heights,
536        ));
537        children.push(RenderNode::tagged(
538            StructRole::TableRow,
539            RenderNode::Group {
540                area: row_area,
541                clip: false,
542                background: None,
543                border: None,
544                corner_radius: 0.0,
545                children: row_children,
546            },
547        ));
548        cursor_y += row_h;
549    }
550    RenderNode::Group {
551        area: block_area,
552        clip: true,
553        background: None,
554        border: None,
555        corner_radius: 0.0,
556        children,
557    }
558}
559
560impl Layoutable for Table {
561    fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
562        let (width, inner_width) = resolve_bound(self.common.width, constraints.max_width, self.common.padding);
563        let col_widths = resolve_column_widths(&self.columns, inner_width);
564        let mut total = header_row_height(ctx, self, &col_widths);
565        for row in &self.rows {
566            total += measure_row_height(ctx, row, &col_widths, self.cell_padding);
567        }
568        Size {
569            width,
570            height: resolve_auto_size(self.common.height, total, self.common.padding),
571        }
572    }
573
574    fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
575        let (inner, bound_height) = shrink_and_bound_height(area, self.common.height, self.common.padding);
576        let col_widths = resolve_column_widths(&self.columns, inner.width);
577        let header_height = header_row_height(ctx, self, &col_widths);
578
579        let mut rendered = Vec::new();
580        let mut cursor_y = 0.0f32;
581
582        if let Some(header) = &self.header {
583            rendered.push(render_row(
584                ctx,
585                self,
586                &col_widths,
587                &inner,
588                warnings,
589                page,
590                RowRenderParams {
591                    cells: header,
592                    y: cursor_y,
593                    row_height: header_height,
594                    background: None,
595                },
596            ));
597            cursor_y += header_height;
598        }
599
600        let (placements, is_continuation) = plan_grid(&self.rows, self.columns.len(), warnings, page);
601        let mut row_heights = natural_row_heights(ctx, &placements, self.rows.len(), &col_widths, self.cell_padding);
602        apply_rowspan_deficits(ctx, &placements, &mut row_heights, &col_widths, self.cell_padding);
603        let row_backgrounds: Vec<Option<Color>> = (0..self.rows.len())
604            .map(|i| self.striped.filter(|_| (self.row_offset + i) % 2 == 1))
605            .collect();
606        let trc = TableRenderCtx {
607            ctx,
608            table: self,
609            col_widths: &col_widths,
610            placements: &placements,
611            page,
612        };
613
614        for block in atomic_blocks(&is_continuation) {
615            let block_height: f32 = row_heights[block.clone()].iter().sum();
616            let remaining = (bound_height - cursor_y).max(0.0);
617
618            if block_height <= remaining + EPS {
619                rendered.push(render_block(
620                    &trc,
621                    &inner,
622                    warnings,
623                    block.clone(),
624                    &row_heights[block.clone()],
625                    cursor_y,
626                    &row_backgrounds,
627                ));
628                cursor_y += block_height;
629                continue;
630            }
631
632            if cursor_y <= header_height + EPS {
633                // Only the header (or nothing) placed so far: this block
634                // is atomic (a rowspan may never split across a page,
635                // #14) and doesn't fit even a fresh page — force it,
636                // clip from the bottom up, warn (Grundprinzip 7), then
637                // move on.
638                let forced_heights = shrink_row_heights_to_fit(&row_heights[block.clone()], remaining);
639                rendered.push(render_block(
640                    &trc,
641                    &inner,
642                    warnings,
643                    block.clone(),
644                    &forced_heights,
645                    cursor_y,
646                    &row_backgrounds,
647                ));
648                let start = self.row_offset + block.start;
649                let end = self.row_offset + block.end - 1;
650                let hint = if start == end {
651                    format!("Table row {start} larger than one page")
652                } else {
653                    format!("Table rows {start}-{end} (rowspan) larger than one page")
654                };
655                push_warning(warnings, LayoutWarningKind::ForcedPageBreak, page, hint);
656                cursor_y = bound_height;
657                continue;
658            }
659
660            // Doesn't fit — move this block (and everything after it) to
661            // a continuation page, which repeats the header. Never split
662            // mid-block: that's exactly the invariant `atomic_blocks`
663            // exists to protect.
664            if let Some(fixed_height) = self.common.height {
665                let overflow_hint = (!self.rows[block.start..].is_empty()).then_some("Table content exceeds its fixed height");
666                return clip_to_fixed_height(area, fixed_height, &self.common, rendered, warnings, page, overflow_hint);
667            }
668
669            let remainder = Table {
670                columns: self.columns.clone(),
671                header: self.header.clone(),
672                rows: self.rows[block.start..].to_vec(),
673                striped: self.striped,
674                cell_padding: self.cell_padding,
675                row_offset: self.row_offset + block.start,
676                common: Common {
677                    height: None,
678                    ..self.common
679                },
680            };
681            let current = wrap_children(area, cursor_y, &self.common, rendered);
682            return LayoutResult::Split {
683                current,
684                remainder: Element::Table(remainder),
685            };
686        }
687
688        finish_fit(&self.common, area, cursor_y, rendered)
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::warnings::LayoutWarningKind;
696    use lightweight_pdf_core::{Align, Element, Text as TextEl};
697
698    struct FixedMetrics;
699    impl crate::font_resolver::FontMetrics for FixedMetrics {
700        fn advance(&self, ch: char) -> f32 {
701            if ch == ' ' {
702                300.0
703            } else {
704                600.0
705            }
706        }
707        fn ascent(&self) -> f32 {
708            800.0
709        }
710        fn descent(&self) -> f32 {
711            -200.0
712        }
713    }
714    struct FixedResolver;
715    impl crate::font_resolver::FontResolver for FixedResolver {
716        fn metrics(&self, _key: lightweight_pdf_core::FontKey) -> &dyn crate::font_resolver::FontMetrics {
717            &FixedMetrics
718        }
719    }
720    fn ctx() -> LayoutCtx<'static> {
721        LayoutCtx::new(&FixedResolver)
722    }
723
724    fn row(cells: &[&str]) -> Vec<Element> {
725        cells.iter().map(|c| Element::Text(TextEl::new(*c))).collect()
726    }
727
728    #[test]
729    fn column_widths_sum_exactly_to_available_width() {
730        let columns = vec![
731            TableColumn::flex(1.0),
732            TableColumn::fixed(37.3),
733            TableColumn::flex(2.0),
734            TableColumn::fixed(19.9),
735        ];
736        let widths = resolve_column_widths(&columns, 400.0);
737        let sum: f32 = widths.iter().sum();
738        assert!((sum - 400.0).abs() < 1e-3, "widths must sum exactly to available width, got {sum}");
739        assert_eq!(widths[1], 37.3);
740        assert_eq!(widths[3], 19.9);
741    }
742
743    #[test]
744    fn header_repeats_and_all_rows_survive_a_page_split() {
745        let table = Table::new()
746            .columns([TableColumn::flex(1.0)])
747            .header(["Beschreibung"])
748            .rows((0..20).map(|i| row(&[Box::leak(format!("Zeile {i}").into_boxed_str())])));
749        let c = ctx();
750        let mut warnings = Vec::new();
751        let area = Rect {
752            x: 0.0,
753            y: 0.0,
754            width: 200.0,
755            height: 60.0, // room for header + a couple of rows only
756        };
757        let mut pages = Vec::new();
758        let mut current = Element::Table(table);
759        loop {
760            match current.layout(&c, area, &mut warnings, pages.len() + 1) {
761                LayoutResult::Fit(node) => {
762                    pages.push(node);
763                    break;
764                }
765                LayoutResult::Split { current: node, remainder } => {
766                    pages.push(node);
767                    current = remainder;
768                }
769            }
770            if pages.len() > 100 {
771                panic!("pagination did not terminate");
772            }
773        }
774        assert!(pages.len() > 1, "expected the table to span multiple pages");
775
776        // Every page (after the first) must repeat the header as its
777        // first row, and every original data row must appear exactly
778        // once across all pages, in order.
779        let mut seen_rows = Vec::new();
780        for page in &pages {
781            let RenderNode::Group { children, .. } = page.untagged() else {
782                panic!("expected a Group");
783            };
784            assert!(!children.is_empty(), "every page must render at least the header");
785            for row_node in children {
786                let RenderNode::Group { children: cells, .. } = row_node.untagged() else {
787                    panic!("expected row Group");
788                };
789                let RenderNode::Group { children: cell_inner, .. } = cells[0].untagged() else {
790                    panic!("expected cell Group");
791                };
792                let RenderNode::Group { children: text_wrap, .. } = cell_inner[0].untagged() else {
793                    panic!("expected clipped text wrapper");
794                };
795                let RenderNode::TextLines { lines, .. } = &text_wrap[0] else {
796                    panic!("expected TextLines");
797                };
798                seen_rows.push(lines.join(" "));
799            }
800        }
801        let header_count = seen_rows.iter().filter(|s| *s == "Beschreibung").count();
802        assert_eq!(header_count, pages.len(), "header must repeat on every page exactly once");
803        let data_rows: Vec<_> = seen_rows.iter().filter(|s| *s != "Beschreibung").collect();
804        assert_eq!(data_rows.len(), 20, "no row may be lost or duplicated across the split");
805        for (i, row) in data_rows.iter().enumerate() {
806            assert_eq!(*row, &format!("Zeile {i}"), "rows must stay in order");
807        }
808    }
809
810    #[test]
811    fn cell_hard_breaks_a_token_wider_than_the_column() {
812        let table = Table::new().columns([TableColumn::fixed(30.0)]).rows([row(&["ABCDEFGHIJ"])]); // 10 chars * 6pt = 60pt, column inner width ~22pt
813        let c = ctx();
814        let mut warnings = Vec::new();
815        let area = Rect {
816            x: 0.0,
817            y: 0.0,
818            width: 30.0,
819            height: 200.0,
820        };
821        let LayoutResult::Fit(node) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
822            panic!("expected Fit");
823        };
824        let RenderNode::Group { children, .. } = node.untagged() else {
825            panic!("expected Group");
826        };
827        let RenderNode::Group { children: cells, .. } = children[0].untagged() else {
828            panic!("expected row group");
829        };
830        let RenderNode::Group { children: cell_inner, .. } = cells[0].untagged() else {
831            panic!("expected cell group");
832        };
833        let RenderNode::Group { children: text_wrap, .. } = cell_inner[0].untagged() else {
834            panic!("expected clipped text wrapper");
835        };
836        let RenderNode::TextLines { lines, .. } = &text_wrap[0] else {
837            panic!("expected TextLines");
838        };
839        assert!(lines.len() > 1, "a token wider than the column must hard-break onto multiple lines");
840    }
841
842    #[test]
843    fn row_height_grows_with_tallest_cell_without_moving_other_rows() {
844        let table = Table::new().columns([TableColumn::fixed(30.0), TableColumn::fixed(30.0)]).rows([
845            row(&["kurz", "kurz"]),
846            row(&["ein sehr sehr sehr sehr langer Zellinhalt der umbricht", "kurz"]),
847            row(&["kurz", "kurz"]),
848        ]);
849        let c = ctx();
850        let mut warnings = Vec::new();
851        let area = Rect {
852            x: 0.0,
853            y: 0.0,
854            width: 60.0,
855            height: 400.0,
856        };
857        let LayoutResult::Fit(node) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
858            panic!("expected Fit");
859        };
860        let RenderNode::Group { children: rows, .. } = node.untagged() else {
861            panic!("expected Group");
862        };
863        assert_eq!(rows.len(), 3);
864        let heights: Vec<f32> = rows
865            .iter()
866            .map(|r| match r.untagged() {
867                RenderNode::Group { area, .. } => area.height,
868                _ => panic!("expected Group"),
869            })
870            .collect();
871        assert!(heights[1] > heights[0], "the row with more content must be taller");
872        assert_eq!(heights[0], heights[2], "unrelated rows keep their own (equal) height");
873
874        // Rows must not overlap vertically: each row's y must be >= the
875        // previous row's y + height.
876        let ys: Vec<f32> = rows
877            .iter()
878            .map(|r| match r.untagged() {
879                RenderNode::Group { area, .. } => area.y,
880                _ => unreachable!(),
881            })
882            .collect();
883        assert!(ys[1] >= ys[0] + heights[0] - EPS);
884        assert!(ys[2] >= ys[1] + heights[1] - EPS);
885    }
886
887    #[test]
888    fn striped_alternates_and_survives_a_split() {
889        let table = Table::new()
890            .columns([TableColumn::flex(1.0)])
891            .header(["H"])
892            .striped(Color::rgb(240, 240, 240))
893            .rows((0..6).map(|i| row(&[Box::leak(format!("R{i}").into_boxed_str())])));
894        let c = ctx();
895        let mut warnings = Vec::new();
896        // Force a split after the header + 2 rows (each ~22.4pt: 14.4pt
897        // line height + 2*4pt cell padding).
898        let area = Rect {
899            x: 0.0,
900            y: 0.0,
901            width: 100.0,
902            height: 3.5 * 22.4,
903        };
904        let LayoutResult::Split { remainder, .. } = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
905            panic!("expected a Split");
906        };
907        let Element::Table(remainder_table) = remainder else {
908            panic!("expected Table remainder");
909        };
910        // Row 2 (0-indexed) is the first row on the continuation page;
911        // row_offset must reflect its true absolute index so striping
912        // continues correctly instead of resetting.
913        assert_eq!(remainder_table.row_offset, 2);
914    }
915
916    #[test]
917    fn oversized_row_forces_its_own_page() {
918        let table = Table::new()
919            .columns([TableColumn::flex(1.0)])
920            .rows([row(&["normal"]), row(&["a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np"])]);
921        let c = ctx();
922        let mut warnings = Vec::new();
923        let area = Rect {
924            x: 0.0,
925            y: 0.0,
926            width: 100.0,
927            height: 100.0,
928        };
929        let mut pages = 0;
930        let mut current = Element::Table(table);
931        loop {
932            match current.layout(&c, area, &mut warnings, pages + 1) {
933                LayoutResult::Fit(_) => {
934                    pages += 1;
935                    break;
936                }
937                LayoutResult::Split { remainder, .. } => {
938                    pages += 1;
939                    current = remainder;
940                }
941            }
942            if pages > 50 {
943                panic!("pagination did not terminate");
944            }
945        }
946        assert!(pages >= 2, "the oversized row should push onto its own page");
947        assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::ForcedPageBreak));
948    }
949
950    #[test]
951    fn table_column_align_positions_short_content_in_the_column() {
952        let table = Table::new()
953            .columns([TableColumn::fixed(100.0).align(Align::End)])
954            .rows([row(&["42"])]);
955        let c = ctx();
956        let mut warnings = Vec::new();
957        let area = Rect {
958            x: 0.0,
959            y: 0.0,
960            width: 100.0,
961            height: 50.0,
962        };
963        let LayoutResult::Fit(node) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
964            panic!("expected Fit");
965        };
966        let RenderNode::Group { children: rows, .. } = node.untagged() else {
967            panic!("expected Group");
968        };
969        let RenderNode::Group { children: cells, .. } = rows[0].untagged() else {
970            panic!("expected row group");
971        };
972        let RenderNode::Group { children: cell_inner, .. } = cells[0].untagged() else {
973            panic!("expected cell group");
974        };
975        let RenderNode::Group { area: cell_area, .. } = cell_inner[0].untagged() else {
976            panic!("expected clipped cell wrapper");
977        };
978        // "42" is much narrower than the 100pt column; End-align must
979        // push it toward the right edge, not leave it at x=0.
980        assert!(cell_area.x > 50.0, "expected right-aligned cell, got x={}", cell_area.x);
981    }
982
983    fn cell(text: &str) -> TableCell {
984        TableCell::from(text)
985    }
986
987    #[test]
988    fn rowspan_cell_spans_multiple_rows_as_one_atomic_block() {
989        let table = Table::new()
990            .columns([TableColumn::fixed(30.0), TableColumn::fixed(30.0)])
991            .rows(vec![
992                vec![TableCell::new("Summe").rowspan(2), cell("Zeile 1")],
993                // Column 0 is covered by the rowspan cell above — this row
994                // supplies only its own (column 1) cell, same convention as
995                // HTML <tr>/<td>.
996                vec![cell("Zeile 2")],
997            ]);
998        let c = ctx();
999        let mut warnings = Vec::new();
1000        let area = Rect {
1001            x: 0.0,
1002            y: 0.0,
1003            width: 60.0,
1004            height: 400.0,
1005        };
1006        let LayoutResult::Fit(node) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
1007            panic!("expected Fit");
1008        };
1009        let RenderNode::Group { children: blocks, .. } = node.untagged() else {
1010            panic!("expected Group");
1011        };
1012        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1013        // Both rows must merge into one atomic block, not two separate
1014        // row groups.
1015        assert_eq!(
1016            blocks.len(),
1017            1,
1018            "expected the 2-row span to render as a single block, got {} top-level children",
1019            blocks.len()
1020        );
1021        let RenderNode::Group {
1022            area: block_area,
1023            children: block_rows,
1024            ..
1025        } = &blocks[0]
1026        else {
1027            panic!("expected block group");
1028        };
1029        // The block contains one `TableRow` per visual row (issue #27),
1030        // each holding that row's own cells.
1031        assert_eq!(
1032            block_rows.len(),
1033            2,
1034            "expected 2 row children in the block, got {}",
1035            block_rows.len()
1036        );
1037        let total_cells: usize = block_rows
1038            .iter()
1039            .map(|r| match r.untagged() {
1040                RenderNode::Group { children, .. } => children.len(),
1041                _ => panic!("expected row group"),
1042            })
1043            .sum();
1044        // 3 rendered cells total: "Summe" (once, spanning both rows),
1045        // "Zeile 1", "Zeile 2".
1046        assert_eq!(total_cells, 3, "expected 3 rendered cells, got {total_cells}");
1047        // The block covers both rows' height (single-row height is
1048        // 22.4pt, same constant as `striped_alternates_and_survives_a_split`).
1049        assert!(
1050            block_area.height > 30.0,
1051            "block should cover both rows, got height {}",
1052            block_area.height
1053        );
1054    }
1055
1056    #[test]
1057    fn rowspan_never_splits_across_a_page_boundary() {
1058        let mut rows: Vec<Vec<TableCell>> = (0..3).map(|i| vec![cell(&format!("R{i}")), cell("x")]).collect();
1059        rows.push(vec![TableCell::new("Spanned").rowspan(2), cell("y0")]);
1060        rows.push(vec![cell("y1")]);
1061        let table = Table::new()
1062            .columns([TableColumn::fixed(30.0), TableColumn::fixed(30.0)])
1063            .rows(rows);
1064        let c = ctx();
1065        let mut warnings = Vec::new();
1066        // Single-row height is 22.4pt (14.4pt line height + 2*4pt cell
1067        // padding, same constant as `striped_alternates_and_survives_a_split`).
1068        // 4 rows' worth of budget lands the natural split right in the
1069        // middle of the 2-row span if it weren't treated as atomic.
1070        let area = Rect {
1071            x: 0.0,
1072            y: 0.0,
1073            width: 60.0,
1074            height: 4.0 * 22.4,
1075        };
1076        let LayoutResult::Split { current, remainder } = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
1077            panic!("expected a Split");
1078        };
1079        let RenderNode::Group { children, .. } = current.untagged() else {
1080            panic!("expected Group");
1081        };
1082        assert_eq!(
1083            children.len(),
1084            3,
1085            "the rowspan block must not partially fit onto the current page, got {} rows",
1086            children.len()
1087        );
1088        let Element::Table(remainder_table) = remainder else {
1089            panic!("expected Table remainder");
1090        };
1091        assert_eq!(
1092            remainder_table.rows.len(),
1093            2,
1094            "the whole 2-row span must move to the continuation page together"
1095        );
1096        assert_eq!(remainder_table.row_offset, 3);
1097    }
1098
1099    #[test]
1100    fn continuation_row_with_too_many_cells_still_reports_overflow() {
1101        let table = Table::new()
1102            .columns([TableColumn::fixed(30.0), TableColumn::fixed(30.0)])
1103            .rows(vec![
1104                vec![TableCell::new("Spanned").rowspan(2), cell("a")],
1105                // Column 0 is blocked by the rowspan above (only column 1 is
1106                // free); this row wrongly supplies 2 cells anyway.
1107                vec![cell("b"), cell("c")],
1108            ]);
1109        let c = ctx();
1110        let mut warnings = Vec::new();
1111        let area = Rect {
1112            x: 0.0,
1113            y: 0.0,
1114            width: 60.0,
1115            height: 400.0,
1116        };
1117        // Only the `warnings` out-parameter matters for this test; the
1118        // layout result itself is exercised elsewhere.
1119        let _ = Element::Table(table).layout(&c, area, &mut warnings, 1);
1120        assert!(warnings.iter().any(|w| w.kind == LayoutWarningKind::TableRowOverflow));
1121    }
1122
1123    #[test]
1124    fn cell_background_overrides_the_row_stripe() {
1125        let stripe = Color::rgb(240, 240, 240);
1126        let cell_bg = Color::rgb(255, 0, 0);
1127        let table = Table::new()
1128            .columns([TableColumn::fixed(30.0), TableColumn::fixed(30.0)])
1129            .striped(stripe)
1130            .rows(vec![
1131                vec![cell("a"), cell("b")],
1132                vec![TableCell::new("c").background(cell_bg), cell("d")], // striped row (index 1)
1133            ]);
1134        let c = ctx();
1135        let mut warnings = Vec::new();
1136        let area = Rect {
1137            x: 0.0,
1138            y: 0.0,
1139            width: 60.0,
1140            height: 400.0,
1141        };
1142        let LayoutResult::Fit(node) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
1143            panic!("expected Fit");
1144        };
1145        let RenderNode::Group { children: blocks, .. } = node.untagged() else {
1146            panic!("expected Group");
1147        };
1148        let RenderNode::Group {
1149            background: row_bg,
1150            children: cells,
1151            ..
1152        } = blocks[1].untagged()
1153        else {
1154            panic!("expected row group");
1155        };
1156        // The row's own Group.background still carries the stripe...
1157        assert_eq!(*row_bg, Some(stripe));
1158        // ...but the styled cell paints its own Rect on top of it, with
1159        // its own color, not the stripe's.
1160        let has_cell_rect = cells.iter().any(|cell_node| {
1161            let RenderNode::Group {
1162                children: cell_children, ..
1163            } = cell_node.untagged()
1164            else {
1165                return false;
1166            };
1167            cell_children
1168                .iter()
1169                .any(|n| matches!(n, RenderNode::Rect { background: Some(bg), .. } if *bg == cell_bg))
1170        });
1171        assert!(
1172            has_cell_rect,
1173            "expected a cell-level background Rect overriding the stripe, got: {cells:?}"
1174        );
1175    }
1176
1177    #[test]
1178    fn cell_padding_overrides_the_table_default() {
1179        let table = Table::new()
1180            .columns([TableColumn::fixed(60.0)])
1181            .cell_padding(4.0)
1182            .rows(vec![vec![TableCell::new("x").padding(20.0)]]);
1183        let c = ctx();
1184        let mut warnings = Vec::new();
1185        let area = Rect {
1186            x: 0.0,
1187            y: 0.0,
1188            width: 60.0,
1189            height: 400.0,
1190        };
1191        let LayoutResult::Fit(node) = Element::Table(table).layout(&c, area, &mut warnings, 1) else {
1192            panic!("expected Fit");
1193        };
1194        let RenderNode::Group { children: blocks, .. } = node.untagged() else {
1195            panic!("expected Group");
1196        };
1197        // Row height must reflect the cell's own (larger) padding, not
1198        // the table's default 4.0 — content height (14.4pt line) + 2*20pt.
1199        let RenderNode::Group { area: row_area, .. } = blocks[0].untagged() else {
1200            panic!("expected row group");
1201        };
1202        assert!(
1203            row_area.height > 14.4 + 2.0 * 20.0 - EPS,
1204            "expected the cell's own padding to grow the row, got height {}",
1205            row_area.height
1206        );
1207    }
1208}