Skip to main content

normordis_pdf/elements/
table.rs

1use serde::{Deserialize, Serialize};
2
3use super::{fixed_text::VerticalAlign, Element, RenderContext, RenderResult};
4use crate::{
5    compliance::ua::StructTag,
6    layout::TextAlign,
7    richtext::marks::AppliedStyle,
8    styles::RgbColor,
9};
10
11// ── RowHeight ─────────────────────────────────────────────────────────────────
12
13/// Controls how row height is determined.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15#[serde(rename_all = "snake_case")]
16pub enum RowHeight {
17    /// Height is determined by content (default).
18    #[default]
19    Auto,
20    /// Minimum height in mm — row can grow if content requires it.
21    AtLeast(f64),
22    /// Exact height in mm — content is clipped if it exceeds this height.
23    Exact(f64),
24}
25
26// ── CellBorders ───────────────────────────────────────────────────────────────
27
28/// Per-edge border configuration for a table cell.
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30pub struct CellBorders {
31    pub top: Option<CellBorder>,
32    pub bottom: Option<CellBorder>,
33    pub left: Option<CellBorder>,
34    pub right: Option<CellBorder>,
35}
36
37impl CellBorders {
38    pub fn is_empty(&self) -> bool {
39        self.top.is_none() && self.bottom.is_none()
40            && self.left.is_none() && self.right.is_none()
41    }
42}
43
44/// A single edge border for a table cell.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CellBorder {
47    pub width_mm: f64,
48    pub color: RgbColor,
49    pub style: BorderLineStyle,
50}
51
52impl Default for CellBorder {
53    fn default() -> Self {
54        Self {
55            width_mm: 0.3,
56            color: RgbColor { r: 0.8, g: 0.8, b: 0.8 },
57            style: BorderLineStyle::Solid,
58        }
59    }
60}
61
62/// Stroke pattern for cell borders.
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64#[serde(rename_all = "snake_case")]
65pub enum BorderLineStyle {
66    #[default]
67    Solid,
68    Dashed,
69    Dotted,
70    None,
71}
72
73// ── CellPadding ───────────────────────────────────────────────────────────────
74
75/// Per-edge insets for a table cell in mm.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct CellPadding {
78    pub top_mm: f64,
79    pub bottom_mm: f64,
80    pub left_mm: f64,
81    pub right_mm: f64,
82}
83
84impl Default for CellPadding {
85    fn default() -> Self {
86        Self { top_mm: 1.0, bottom_mm: 1.0, left_mm: 2.0, right_mm: 2.0 }
87    }
88}
89
90impl CellPadding {
91    pub fn uniform(mm: f64) -> Self {
92        Self { top_mm: mm, bottom_mm: mm, left_mm: mm, right_mm: mm }
93    }
94
95    pub fn horizontal_vertical(h_mm: f64, v_mm: f64) -> Self {
96        Self { top_mm: v_mm, bottom_mm: v_mm, left_mm: h_mm, right_mm: h_mm }
97    }
98}
99
100// ── TableStyle ────────────────────────────────────────────────────────────────
101
102/// Named table style — controls border, header background, and stripe settings.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct TableStyle {
105    /// Border drawn around the outside of the whole table.
106    pub outer_border: Option<CellBorder>,
107    /// Border drawn between cells (inner grid lines).
108    pub inner_border: Option<CellBorder>,
109    /// Background colour for header rows. `None` = no header background.
110    pub header_background: Option<RgbColor>,
111    /// Stripe colour for alternate body rows. `None` = no stripes.
112    pub stripe_color: Option<RgbColor>,
113}
114
115impl TableStyle {
116    /// Grid style — thin borders everywhere, light header background, no stripes.
117    pub fn grid() -> Self {
118        Self {
119            outer_border: Some(CellBorder::default()),
120            inner_border: Some(CellBorder::default()),
121            header_background: Some(RgbColor { r: 0.85, g: 0.88, b: 0.95 }),
122            stripe_color: None,
123        }
124    }
125
126    /// Bordered style — outer border only, header background, stripes.
127    pub fn bordered() -> Self {
128        Self {
129            outer_border: Some(CellBorder { width_mm: 0.5, ..CellBorder::default() }),
130            inner_border: None,
131            header_background: Some(RgbColor { r: 0.85, g: 0.88, b: 0.95 }),
132            stripe_color: Some(RgbColor { r: 0.96, g: 0.96, b: 0.96 }),
133        }
134    }
135
136    /// Striped style — no borders, alternating row background.
137    pub fn striped() -> Self {
138        Self {
139            outer_border: None,
140            inner_border: None,
141            header_background: Some(RgbColor { r: 0.85, g: 0.88, b: 0.95 }),
142            stripe_color: Some(RgbColor { r: 0.96, g: 0.96, b: 0.96 }),
143        }
144    }
145
146    /// Plain style — no borders, no background.
147    pub fn plain() -> Self {
148        Self {
149            outer_border: None,
150            inner_border: None,
151            header_background: None,
152            stripe_color: None,
153        }
154    }
155}
156
157// ── TableCell ─────────────────────────────────────────────────────────────────
158
159/// A single cell in a table row.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct TableCell {
162    pub text: String,
163    /// Number of columns this cell spans (default: 1).
164    #[serde(default = "default_span")]
165    pub col_span: u16,
166    /// Number of rows this cell spans (default: 1).
167    #[serde(default = "default_span")]
168    pub row_span: u16,
169    /// Text alignment within this cell (default: Left).
170    #[serde(default)]
171    pub alignment: TextAlign,
172    /// Per-edge borders. If empty, uses the table-level default borders.
173    #[serde(default)]
174    pub borders: CellBorders,
175    /// Background fill override for this cell.
176    #[serde(default)]
177    pub background: Option<RgbColor>,
178    /// Vertical alignment of text within the cell.
179    #[serde(default)]
180    pub vertical_align: VerticalAlign,
181    /// Cell padding (insets). Defaults to 1 mm top/bottom, 2 mm left/right.
182    #[serde(default)]
183    pub padding: CellPadding,
184    /// Named paragraph style for the cell text.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub style_ref: Option<String>,
187    /// Optional nested table. When set, renders a sub-table instead of `text`.
188    #[serde(skip)]
189    pub nested_table: Option<Box<Table>>,
190}
191
192fn default_span() -> u16 { 1 }
193
194impl TableCell {
195    pub fn new(text: impl Into<String>) -> Self {
196        Self {
197            text: text.into(),
198            col_span: 1,
199            row_span: 1,
200            alignment: TextAlign::Left,
201            borders: CellBorders::default(),
202            background: None,
203            vertical_align: VerticalAlign::Top,
204            padding: CellPadding::default(),
205            style_ref: None,
206            nested_table: None,
207        }
208    }
209
210    /// Sets a nested sub-table as the cell content (instead of text).
211    pub fn nested_table(mut self, table: Table) -> Self {
212        self.nested_table = Some(Box::new(table));
213        self
214    }
215
216    /// Apply a named style to this cell's text.
217    pub fn style(mut self, name: impl Into<String>) -> Self {
218        self.style_ref = Some(name.into());
219        self
220    }
221
222    pub fn padding(mut self, padding: CellPadding) -> Self {
223        self.padding = padding;
224        self
225    }
226
227    pub fn col_span(mut self, n: u16) -> Self {
228        self.col_span = n.max(1);
229        self
230    }
231
232    pub fn row_span(mut self, n: u16) -> Self {
233        self.row_span = n.max(1);
234        self
235    }
236
237    pub fn align(mut self, alignment: TextAlign) -> Self {
238        self.alignment = alignment;
239        self
240    }
241
242    pub fn background(mut self, color: RgbColor) -> Self {
243        self.background = Some(color);
244        self
245    }
246}
247
248impl From<String> for TableCell {
249    fn from(s: String) -> Self { Self::new(s) }
250}
251
252impl From<&str> for TableCell {
253    fn from(s: &str) -> Self { Self::new(s) }
254}
255
256// ── TableRow ──────────────────────────────────────────────────────────────────
257
258/// A row in a table with optional exact/minimum height.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct TableRow {
261    pub cells: Vec<TableCell>,
262    pub height: RowHeight,
263    pub is_header: bool,
264}
265
266impl TableRow {
267    pub fn new(cells: Vec<TableCell>) -> Self {
268        Self { cells, height: RowHeight::Auto, is_header: false }
269    }
270
271    /// Constructs a row from plain strings — convenience for simple tables.
272    pub fn plain(cells: Vec<String>) -> Self {
273        Self::new(cells.into_iter().map(TableCell::from).collect())
274    }
275
276    /// Sets an exact height for this row in mm.
277    pub fn height_exact(mut self, mm: f64) -> Self {
278        self.height = RowHeight::Exact(mm);
279        self
280    }
281
282    /// Sets a minimum height for this row in mm.
283    pub fn height_at_least(mut self, mm: f64) -> Self {
284        self.height = RowHeight::AtLeast(mm);
285        self
286    }
287}
288
289// ── TableBuilder ──────────────────────────────────────────────────────────────
290
291/// Fluent builder for tables with complex headers (`col_span`/`row_span`).
292pub struct TableBuilder {
293    header_rows: Vec<TableRow>,
294    body_rows: Vec<TableRow>,
295    col_widths: Option<Vec<f64>>,
296    show_header_background: bool,
297    stripe_rows: bool,
298    table_style: Option<TableStyle>,
299}
300
301impl TableBuilder {
302    pub fn header_row(mut self, cells: Vec<TableCell>) -> Self {
303        let mut row = TableRow::new(cells);
304        row.is_header = true;
305        self.header_rows.push(row);
306        self
307    }
308
309    pub fn row(mut self, cells: Vec<TableCell>) -> Self {
310        self.body_rows.push(TableRow::new(cells));
311        self
312    }
313
314    pub fn col_widths(mut self, pcts: Vec<f64>) -> Self {
315        self.col_widths = Some(pcts);
316        self
317    }
318
319    pub fn stripe(mut self) -> Self {
320        self.stripe_rows = true;
321        self
322    }
323
324    /// Apply a named table style (e.g. `TableStyle::grid()`).
325    pub fn table_style(mut self, style: TableStyle) -> Self {
326        self.table_style = Some(style);
327        self
328    }
329
330    pub fn build(self) -> Table {
331        Table {
332            headers: Vec::new(),
333            header_rows: self.header_rows,
334            rows: self.body_rows,
335            col_widths: self.col_widths,
336            show_header_background: self.show_header_background,
337            stripe_rows: self.stripe_rows,
338            table_style: self.table_style,
339        }
340    }
341}
342
343// ── Table ─────────────────────────────────────────────────────────────────────
344
345/// A data table with optional header background and alternating row stripes.
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct Table {
348    /// Simple string headers (backward compat). Converted to a header row on render.
349    pub headers: Vec<String>,
350    /// Rich header rows supporting `col_span` (set via `Table::builder()`).
351    #[serde(default)]
352    pub header_rows: Vec<TableRow>,
353    pub rows: Vec<TableRow>,
354    /// Column widths as percentages of content width. `None` = equal distribution.
355    pub col_widths: Option<Vec<f64>>,
356    /// Fill header row with `primary_color` at reduced opacity.
357    pub show_header_background: bool,
358    /// Alternate row backgrounds for readability.
359    pub stripe_rows: bool,
360    /// Optional named table style. When set, overrides `show_header_background` and
361    /// `stripe_rows`, and controls border drawing.
362    #[serde(default)]
363    pub table_style: Option<TableStyle>,
364}
365
366impl Table {
367    pub fn new(headers: Vec<String>, rows: Vec<TableRow>) -> Self {
368        Self {
369            headers,
370            header_rows: Vec::new(),
371            rows,
372            col_widths: None,
373            show_header_background: true,
374            stripe_rows: true,
375            table_style: None,
376        }
377    }
378
379    pub fn builder() -> TableBuilder {
380        TableBuilder {
381            header_rows: Vec::new(),
382            body_rows: Vec::new(),
383            col_widths: None,
384            show_header_background: true,
385            stripe_rows: false,
386            table_style: None,
387        }
388    }
389
390    pub fn col_widths(mut self, widths: Vec<f64>) -> Self {
391        self.col_widths = Some(widths);
392        self
393    }
394
395    /// Apply a named table style.
396    pub fn with_table_style(mut self, style: TableStyle) -> Self {
397        self.table_style = Some(style);
398        self
399    }
400
401    /// Enables alternating row stripes (already on by default for `Table::new`).
402    pub fn stripe(self) -> Self {
403        self
404    }
405
406    fn min_row_height_mm() -> f64 { 6.5 }
407
408    fn effective_row_height(row: &TableRow, measured: f64) -> f64 {
409        match row.height {
410            RowHeight::Auto => measured.max(Self::min_row_height_mm()),
411            RowHeight::AtLeast(h) => measured.max(h),
412            RowHeight::Exact(h) => h,
413        }
414    }
415
416    /// Computes column widths from percentages or equal distribution.
417    fn col_widths_mm(&self, usable_width: f64, col_count: usize) -> Vec<f64> {
418        if col_count == 0 { return Vec::new(); }
419        match &self.col_widths {
420            Some(pcts) => pcts.iter().map(|p| p / 100.0 * usable_width).collect(),
421            None => vec![usable_width / col_count as f64; col_count],
422        }
423    }
424
425    /// Computes the column count by scanning header rows and body rows.
426    fn effective_col_count(&self) -> usize {
427        let mut max = self.headers.len();
428        for r in &self.header_rows {
429            let span_sum: usize = r.cells.iter().map(|c| c.col_span as usize).sum();
430            max = max.max(span_sum);
431        }
432        for r in &self.rows {
433            let span_sum: usize = r.cells.iter().map(|c| c.col_span as usize).sum();
434            max = max.max(span_sum);
435        }
436        max
437    }
438
439    /// Measures the text height of a row and returns the effective row height in mm.
440    fn measure_row_height(
441        &self,
442        row: &TableRow,
443        col_widths: &[f64],
444        ctx: &RenderContext,
445    ) -> f64 {
446        let fs = ctx.style.font_size_body;
447        let mut measured = Self::min_row_height_mm();
448        let col_count = col_widths.len();
449        let mut col_idx = 0;
450
451        for cell in &row.cells {
452            let span = (cell.col_span as usize).min(col_count.saturating_sub(col_idx));
453            if span == 0 { break; }
454            let w: f64 = col_widths[col_idx..col_idx + span].iter().sum();
455            let h_pad = cell.padding.left_mm + cell.padding.right_mm;
456            let v_pad = cell.padding.top_mm + cell.padding.bottom_mm;
457            let inner_w = (w - h_pad).max(1.0);
458            let r = ctx.layout_engine.layout_plain(
459                &ctx.fonts, &cell.text, inner_w, cell.alignment, fs,
460                AppliedStyle::default(),
461            );
462            measured = measured.max(r.total_height_mm + v_pad);
463            col_idx += span;
464        }
465
466        Self::effective_row_height(row, measured)
467    }
468
469    /// Renders one row: background, borders, text. Does NOT advance the cursor.
470    #[allow(clippy::too_many_arguments)]
471    fn render_row(
472        &self,
473        row: &TableRow,
474        col_widths: &[f64],
475        x_base: f64,
476        row_h: f64,
477        body_row_idx: usize,
478        is_header: bool,
479        ctx: &mut RenderContext,
480    ) {
481        let y_top = ctx.flow.cursor_y_mm;
482        let y_bottom = y_top - row_h;
483        let tc = ctx.style.text_color.clone();
484        let col_count = col_widths.len();
485        let total_w: f64 = col_widths.iter().sum();
486
487        // Row background
488        let bg: Option<RgbColor> = if let Some(ref ts) = self.table_style {
489            if is_header {
490                ts.header_background.clone()
491            } else if body_row_idx % 2 == 1 {
492                ts.stripe_color.clone()
493            } else {
494                None
495            }
496        } else if is_header && self.show_header_background {
497            let pc = &ctx.style.primary_color;
498            Some(RgbColor {
499                r: pc.r * 0.85 + 0.15,
500                g: pc.g * 0.85 + 0.15,
501                b: pc.b * 0.85 + 0.15,
502            })
503        } else if !is_header && self.stripe_rows && body_row_idx % 2 == 1 {
504            Some(RgbColor { r: 0.96, g: 0.96, b: 0.96 })
505        } else {
506            None
507        };
508
509        if let Some(bg_col) = bg {
510            if ctx.ua_config.enabled { ctx.backend.begin_artifact_content(); }
511            let _ = ctx.backend.draw_rect(x_base, y_bottom, total_w, row_h, &bg_col);
512            if ctx.ua_config.enabled { ctx.backend.end_tagged_content(); }
513        }
514
515        let fs = ctx.style.font_size_body;
516        let mut col_x = x_base;
517        let mut col_idx = 0;
518
519        for cell in &row.cells {
520            let span = (cell.col_span as usize).min(col_count.saturating_sub(col_idx));
521            if span == 0 { break; }
522            let cell_w: f64 = col_widths[col_idx..col_idx + span].iter().sum();
523
524            // Per-cell background override (Artifact)
525            if let Some(ref cell_bg) = cell.background {
526                if ctx.ua_config.enabled { ctx.backend.begin_artifact_content(); }
527                let _ = ctx.backend.draw_rect(col_x, y_bottom, cell_w, row_h, cell_bg);
528                if ctx.ua_config.enabled { ctx.backend.end_tagged_content(); }
529            }
530
531            let h_pad = cell.padding.left_mm + cell.padding.right_mm;
532            let inner_w = (cell_w - h_pad).max(1.0);
533
534            if let Some(ref nested) = cell.nested_table {
535                let saved_x = ctx.layout.content_x_mm;
536                let saved_w = ctx.layout.content_width_mm;
537                let saved_cursor = ctx.flow.cursor_y_mm;
538
539                ctx.layout.content_x_mm = col_x + cell.padding.left_mm;
540                ctx.layout.content_width_mm = inner_w;
541                ctx.flow.cursor_y_mm = y_top - cell.padding.top_mm;
542                ctx.resume_index = 0;
543
544                let _ = nested.render(ctx);
545
546                ctx.layout.content_x_mm = saved_x;
547                ctx.layout.content_width_mm = saved_w;
548                ctx.flow.cursor_y_mm = saved_cursor;
549            } else {
550                let result = ctx.layout_engine.layout_plain(
551                    &ctx.fonts, &cell.text, inner_w, cell.alignment, fs,
552                    AppliedStyle::default(),
553                );
554
555                let content_h = result.total_height_mm;
556                let text_y_start = match cell.vertical_align {
557                    VerticalAlign::Top => y_top - cell.padding.top_mm,
558                    VerticalAlign::Middle => {
559                        let inner_h = row_h - cell.padding.top_mm - cell.padding.bottom_mm;
560                        y_top - cell.padding.top_mm - ((inner_h - content_h) / 2.0).max(0.0)
561                    }
562                    VerticalAlign::Bottom => y_bottom + cell.padding.bottom_mm + content_h,
563                };
564
565                let mut line_y = text_y_start;
566                for line in &result.lines {
567                    if line_y - line.height_mm < y_bottom + cell.padding.bottom_mm {
568                        break;
569                    }
570                    for seg in &line.segments {
571                        if seg.text.is_empty() { continue; }
572                        let Some(font_ref) = ctx.get_font_ref(seg.style.bold, seg.style.italic) else { continue };
573                        let x = col_x + cell.padding.left_mm + seg.x_offset_mm;
574                        let _ = ctx.draw_text(&seg.text, x, line_y, fs, font_ref, &tc);
575                    }
576                    line_y -= line.height_mm;
577                }
578            }
579
580            // Per-cell borders
581            if !cell.borders.is_empty() {
582                draw_cell_borders(ctx, &cell.borders, col_x, y_bottom, cell_w, row_h);
583            }
584
585            col_x += cell_w;
586            col_idx += span;
587        }
588
589        // Row bottom border — only when the table style defines inner_border,
590        // or when no table_style is set (legacy default: light separator).
591        let row_border: Option<(f32, RgbColor)> = match &self.table_style {
592            Some(ts) => ts.inner_border.as_ref().map(|b| {
593                ((b.width_mm * 72.0 / 25.4) as f32, b.color.clone())
594            }),
595            None => Some((0.3_f32, RgbColor { r: 0.75, g: 0.75, b: 0.75 })),
596        };
597        if let Some((pt, color)) = row_border {
598            if ctx.ua_config.enabled { ctx.backend.begin_artifact_content(); }
599            let _ = ctx.backend.draw_line(x_base, y_bottom, x_base + total_w, y_bottom, pt, &color);
600            if ctx.ua_config.enabled { ctx.backend.end_tagged_content(); }
601        }
602    }
603}
604
605// ── Drawing helpers ───────────────────────────────────────────────────────────
606
607fn draw_cell_borders(
608    ctx: &mut RenderContext,
609    borders: &CellBorders,
610    x: f64, y: f64, w: f64, h: f64,
611) {
612    let ua = ctx.ua_config.enabled;
613    if ua { ctx.backend.begin_artifact_content(); }
614    let draw_h = |ctx: &mut RenderContext, border: &CellBorder, bx: f64, by: f64, len: f64| {
615        let pt = (border.width_mm * 72.0 / 25.4) as f32;
616        let _ = ctx.backend.draw_line(bx, by, bx + len, by, pt, &border.color);
617    };
618    let draw_v = |ctx: &mut RenderContext, border: &CellBorder, bx: f64, by: f64, len: f64| {
619        let pt = (border.width_mm * 72.0 / 25.4) as f32;
620        let _ = ctx.backend.draw_line(bx, by, bx, by + len, pt, &border.color);
621    };
622    if let Some(ref b) = borders.top    { draw_h(ctx, b, x,     y + h, w); }
623    if let Some(ref b) = borders.bottom { draw_h(ctx, b, x,     y,     w); }
624    if let Some(ref b) = borders.left   { draw_v(ctx, b, x,     y,     h); }
625    if let Some(ref b) = borders.right  { draw_v(ctx, b, x + w, y,     h); }
626    if ua { ctx.backend.end_tagged_content(); }
627}
628
629// ── UA helpers ────────────────────────────────────────────────────────────────
630
631fn ua_tag_row(ctx: &mut RenderContext, is_header: bool) {
632    let mcid = ctx.next_mcid();
633    let cell_tag = if is_header { StructTag::TH } else { StructTag::TD };
634    ctx.ua_begin_group(StructTag::TR, None);
635    ctx.ua_begin_group(cell_tag, None);
636    ctx.ua_content_ref(mcid);
637    ctx.ua_end_group();
638    ctx.ua_end_group();
639    ctx.backend.begin_tagged_content(b"TR", mcid);
640}
641
642// ── Element impl ──────────────────────────────────────────────────────────────
643
644impl Element for Table {
645    fn estimated_height_mm(&self) -> f64 {
646        let header_h = if self.headers.is_empty() && self.header_rows.is_empty() {
647            0.0
648        } else {
649            Self::min_row_height_mm()
650        };
651        let rows_h: f64 = self.rows.iter().map(|r| match r.height {
652            RowHeight::Exact(h) => h,
653            RowHeight::AtLeast(h) => h.max(Self::min_row_height_mm()),
654            RowHeight::Auto => Self::min_row_height_mm(),
655        }).sum();
656        header_h + rows_h
657    }
658
659    fn render(&self, ctx: &mut RenderContext) -> crate::Result<RenderResult> {
660        let col_count = self.effective_col_count();
661        if col_count == 0 {
662            return Ok(RenderResult::done());
663        }
664
665        let usable_w = ctx.layout.content_width_mm;
666        let x_base = ctx.layout.content_x_mm;
667        let col_widths = self.col_widths_mm(usable_w, col_count);
668        let start = ctx.resume_index;
669        let ua = ctx.ua_config.enabled;
670
671        let header_rows: Vec<&TableRow> = if !self.header_rows.is_empty() {
672            self.header_rows.iter().collect()
673        } else {
674            Vec::new()
675        };
676        let simple_headers = self.header_rows.is_empty() && !self.headers.is_empty();
677        let hdr_count = if simple_headers { 1 } else { header_rows.len() };
678
679        let has_headers = simple_headers || !header_rows.is_empty();
680
681        if ua && start == 0 {
682            ctx.ua_begin_group(StructTag::Table, None);
683            if has_headers { ctx.ua_begin_group(StructTag::THead, None); }
684        }
685
686        // On continuation pages: re-render headers
687        if start > 0 {
688            if simple_headers {
689                let hdr_row = TableRow {
690                    cells: self.headers.iter().map(TableCell::new).collect(),
691                    height: RowHeight::Auto,
692                    is_header: true,
693                };
694                let row_h = self.measure_row_height(&hdr_row, &col_widths, ctx);
695                if ua { ua_tag_row(ctx, true); }
696                self.render_row(&hdr_row, &col_widths, x_base, row_h, 0, true, ctx);
697                if ua { ctx.backend.end_tagged_content(); }
698                ctx.flow.advance(row_h);
699            } else {
700                for hdr in &header_rows {
701                    let row_h = self.measure_row_height(hdr, &col_widths, ctx);
702                    if ua { ua_tag_row(ctx, true); }
703                    self.render_row(hdr, &col_widths, x_base, row_h, 0, true, ctx);
704                    if ua { ctx.backend.end_tagged_content(); }
705                    ctx.flow.advance(row_h);
706                }
707            }
708        }
709
710        // Render header rows on the first call (start == 0)
711        if start == 0 {
712            if simple_headers {
713                let hdr_row = TableRow {
714                    cells: self.headers.iter().map(TableCell::new).collect(),
715                    height: RowHeight::Auto,
716                    is_header: true,
717                };
718                let row_h = self.measure_row_height(&hdr_row, &col_widths, ctx);
719                if ua { ua_tag_row(ctx, true); }
720                self.render_row(&hdr_row, &col_widths, x_base, row_h, 0, true, ctx);
721                if ua { ctx.backend.end_tagged_content(); }
722                ctx.flow.advance(row_h);
723            } else {
724                for hdr in &header_rows {
725                    let row_h = self.measure_row_height(hdr, &col_widths, ctx);
726                    if ua { ua_tag_row(ctx, true); }
727                    self.render_row(hdr, &col_widths, x_base, row_h, 0, true, ctx);
728                    if ua { ctx.backend.end_tagged_content(); }
729                    ctx.flow.advance(row_h);
730                }
731            }
732        }
733
734        if ua && start == 0 {
735            if has_headers { ctx.ua_end_group(); } // THead
736            ctx.ua_begin_group(StructTag::TBody, None);
737        }
738
739        // Body rows — resumable
740        let body_start = start.saturating_sub(hdr_count);
741
742        for (i, row) in self.rows.iter().enumerate().skip(body_start) {
743            let row_h = self.measure_row_height(row, &col_widths, ctx);
744
745            if ctx.flow.would_overflow(row_h) && i > body_start {
746                ctx.resume_index = hdr_count + i;
747                if ua {
748                    ctx.ua_end_group(); // TBody
749                    ctx.ua_end_group(); // Table
750                }
751                return Ok(RenderResult::more());
752            }
753
754            if ua { ua_tag_row(ctx, false); }
755            self.render_row(row, &col_widths, x_base, row_h, i, false, ctx);
756            if ua { ctx.backend.end_tagged_content(); }
757            ctx.flow.advance(row_h);
758        }
759
760        if ua {
761            ctx.ua_end_group(); // TBody
762            ctx.ua_end_group(); // Table
763        }
764        Ok(RenderResult::done())
765    }
766}