Skip to main content

pivot_pdf/
tables.rs

1use crate::document::format_coord;
2use crate::fonts::{BuiltinFont, FontRef};
3use crate::graphics::Color;
4use crate::textflow::{
5    break_word, line_height_for, measure_word, FitResult, Rect, TextStyle, UsedFonts, WordBreak,
6};
7use crate::truetype::TrueTypeFont;
8use crate::writer::escape_pdf_string;
9
10// -------------------------------------------------------
11// Public types
12// -------------------------------------------------------
13
14/// Horizontal text alignment within a table cell.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum TextAlign {
17    /// Text is left-aligned within the cell (default).
18    #[default]
19    Left,
20    /// Text is centered within the cell.
21    Center,
22    /// Text is right-aligned within the cell.
23    Right,
24}
25
26/// How text that overflows the cell height is handled.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum CellOverflow {
29    /// Text wraps across lines; row height grows to fit content (default).
30    Wrap,
31    /// Text is word-wrapped but clipped to the row's fixed height.
32    Clip,
33    /// Font size shrinks until all text fits within the row's fixed height.
34    Shrink,
35}
36
37/// Style options for a table cell.
38#[derive(Debug, Clone)]
39pub struct CellStyle {
40    /// Optional cell background color (overrides row background).
41    pub background_color: Option<Color>,
42    /// Optional text color. Defaults to PDF's current fill color (black).
43    pub text_color: Option<Color>,
44    /// Font reference.
45    pub font: FontRef,
46    /// Font size in points.
47    pub font_size: f64,
48    /// Padding applied to all four sides, in points.
49    pub padding: f64,
50    /// How to handle text that exceeds the available cell height.
51    pub overflow: CellOverflow,
52    /// How to handle words wider than the cell's available width.
53    pub word_break: WordBreak,
54    /// Horizontal text alignment within the cell.
55    pub text_align: TextAlign,
56}
57
58impl Default for CellStyle {
59    fn default() -> Self {
60        CellStyle {
61            background_color: None,
62            text_color: None,
63            font: FontRef::Builtin(BuiltinFont::Helvetica),
64            font_size: 10.0,
65            padding: 4.0,
66            overflow: CellOverflow::Wrap,
67            word_break: WordBreak::BreakAll,
68            text_align: TextAlign::Left,
69        }
70    }
71}
72
73/// A single table cell containing text and style.
74#[derive(Clone)]
75pub struct Cell {
76    /// Text content to display in the cell.
77    pub text: String,
78    /// Visual style for this cell (font, color, padding, overflow, alignment).
79    pub style: CellStyle,
80    /// Number of columns this cell spans (default `1`, must be >= 1).
81    pub col_span: usize,
82}
83
84impl Cell {
85    /// Create a cell with the default style.
86    pub fn new(text: impl Into<String>) -> Self {
87        Cell {
88            text: text.into(),
89            style: CellStyle::default(),
90            col_span: 1,
91        }
92    }
93
94    /// Create a cell with an explicit style.
95    pub fn styled(text: impl Into<String>, style: CellStyle) -> Self {
96        Cell {
97            text: text.into(),
98            style,
99            col_span: 1,
100        }
101    }
102}
103
104/// A row of cells in a table.
105#[derive(Clone)]
106pub struct Row {
107    /// Cells in this row. The sum of `col_span` values across all cells must
108    /// equal the table's column count.
109    pub cells: Vec<Cell>,
110    /// Optional background color applied to the entire row.
111    /// Per-cell background_color takes priority.
112    pub background_color: Option<Color>,
113    /// Fixed row height in points. Required for `Clip` and `Shrink` overflow.
114    /// When `None`, height is auto-calculated from cell content (`Wrap` mode).
115    pub height: Option<f64>,
116}
117
118impl Row {
119    /// Create a row with auto-calculated height and no background.
120    pub fn new(cells: Vec<Cell>) -> Self {
121        Row {
122            cells,
123            background_color: None,
124            height: None,
125        }
126    }
127}
128
129/// Table layout configuration. Holds column widths and visual style; does not
130/// store row data. The caller supplies one `Row` at a time to `fit_row`,
131/// enabling streaming from a database cursor without buffering the full dataset.
132pub struct Table {
133    /// Column widths in points.
134    pub columns: Vec<f64>,
135    /// Reference style for constructing cells. Clone it when creating cells
136    /// to apply consistent styling across the table.
137    pub default_style: CellStyle,
138    /// Border stroke color (default: black).
139    pub border_color: Color,
140    /// Border line width in points. Set to `0.0` to disable borders.
141    pub border_width: f64,
142}
143
144impl Table {
145    /// Create a new table layout with the given column widths.
146    pub fn new(columns: Vec<f64>) -> Self {
147        Table {
148            columns,
149            default_style: CellStyle::default(),
150            border_color: Color::rgb(0.0, 0.0, 0.0),
151            border_width: 0.5,
152        }
153    }
154
155    /// Generate PDF content stream bytes for a single row.
156    ///
157    /// Returns the content bytes, a `FitResult`, and the fonts used.
158    /// Updates `cursor` to reflect the row's placement.
159    pub(crate) fn generate_row_ops(
160        &self,
161        row: &Row,
162        cursor: &mut TableCursor,
163        tt_fonts: &mut [TrueTypeFont],
164    ) -> (Vec<u8>, FitResult, UsedFonts) {
165        let row_height = measure_row_height(row, &self.columns, &self.default_style, tt_fonts);
166        let bottom = cursor.rect.y - cursor.rect.height;
167
168        if cursor.current_y - row_height < bottom {
169            // Nothing placed yet on this page — rect is too small for this row.
170            // Otherwise the page is simply full and the caller should turn it.
171            let result = if cursor.first_row {
172                FitResult::BoxEmpty
173            } else {
174                FitResult::BoxFull
175            };
176            return (Vec::new(), result, UsedFonts::default());
177        }
178
179        let mut output: Vec<u8> = Vec::new();
180        let mut used = UsedFonts::default();
181
182        let segments = cell_segments(&row.cells, &self.columns, cursor.rect.x);
183
184        draw_row_backgrounds_with_segments(
185            row,
186            &segments,
187            cursor.rect.x,
188            cursor.current_y,
189            row_height,
190            &self.columns,
191            &mut output,
192        );
193
194        for (cell, (cell_x, cell_width)) in row.cells.iter().zip(segments.iter()) {
195            render_cell(
196                cell,
197                *cell_x,
198                cursor.current_y,
199                *cell_width,
200                row_height,
201                tt_fonts,
202                &mut output,
203                &mut used,
204            );
205        }
206
207        if self.border_width > 0.0 {
208            draw_row_borders(
209                &self.columns,
210                &row.cells,
211                cursor.rect.x,
212                cursor.current_y,
213                row_height,
214                self.border_color,
215                self.border_width,
216                &mut output,
217            );
218        }
219
220        cursor.current_y -= row_height;
221        cursor.first_row = false;
222
223        (output, FitResult::Stop, used)
224    }
225}
226
227/// Tracks where the next row will be placed within a page.
228///
229/// Created once per table area, then passed to each `fit_row` call.
230/// Call `reset()` when starting a new page to restore the cursor to
231/// the top of the new rect. `is_first_row()` lets the caller detect
232/// a fresh page — useful for repeating a header row.
233///
234/// # Example
235/// ```no_run
236/// # use pivot_pdf::{DocumentOptions, Table, TableCursor, Row, Cell, Rect, FitResult, PdfDocument};
237/// # let table = Table::new(vec![200.0, 200.0]);
238/// # let rect = Rect { x: 72.0, y: 720.0, width: 400.0, height: 648.0 };
239/// # let header = Row::new(vec![Cell::new("Name"), Cell::new("Value")]);
240/// # let data: Vec<Row> = vec![];
241/// # let mut doc = PdfDocument::new(Vec::<u8>::new(), DocumentOptions::default()).unwrap();
242/// let mut cursor = TableCursor::new(&rect);
243/// let mut rows = data.iter().peekable();
244/// while rows.peek().is_some() {
245///     doc.begin_page(612.0, 792.0);
246///     // Repeat header on every page
247///     doc.fit_row(&table, &header, &mut cursor).unwrap();
248///     while let Some(row) = rows.peek() {
249///         match doc.fit_row(&table, row, &mut cursor).unwrap() {
250///             FitResult::Stop    => { rows.next(); }
251///             FitResult::BoxFull => break,
252///             FitResult::BoxEmpty => { rows.next(); break; }
253///         }
254///     }
255///     doc.end_page().unwrap();
256///     if rows.peek().is_some() { cursor.reset(&rect); }
257/// }
258/// ```
259pub struct TableCursor {
260    /// Bounding rectangle for the current page.
261    pub(crate) rect: Rect,
262    /// Top of the next row (PDF absolute coordinates, from page bottom).
263    pub(crate) current_y: f64,
264    /// True when no rows have been placed on the current page yet.
265    pub(crate) first_row: bool,
266}
267
268impl TableCursor {
269    /// Create a cursor positioned at the top of `rect`.
270    pub fn new(rect: &Rect) -> Self {
271        TableCursor {
272            rect: *rect,
273            current_y: rect.y,
274            first_row: true,
275        }
276    }
277
278    /// Reset to the top of a new rect. Call this when starting a new page.
279    pub fn reset(&mut self, rect: &Rect) {
280        self.rect = *rect;
281        self.current_y = rect.y;
282        self.first_row = true;
283    }
284
285    /// Returns `true` if no rows have been placed on the current page yet.
286    ///
287    /// Use this to detect the start of a new page so you can insert a
288    /// repeated header row before placing data rows.
289    pub fn is_first_row(&self) -> bool {
290        self.first_row
291    }
292
293    /// Returns the Y coordinate where the next row would be placed.
294    ///
295    /// After placing all rows, this equals the bottom edge of the last row.
296    /// Use it to position content that follows the table (e.g., totals section)
297    /// without guessing where the table ended.
298    pub fn current_y(&self) -> f64 {
299        self.current_y
300    }
301}
302
303// -------------------------------------------------------
304// Measurement helpers
305// -------------------------------------------------------
306
307/// Compute (x_position, width) for each cell, respecting col_span.
308fn cell_segments(cells: &[Cell], columns: &[f64], row_x: f64) -> Vec<(f64, f64)> {
309    let mut segments = Vec::with_capacity(cells.len());
310    let mut col_idx = 0;
311    let mut x = row_x;
312    for cell in cells {
313        let span = cell.col_span.max(1);
314        let end = (col_idx + span).min(columns.len());
315        let width: f64 = columns[col_idx..end].iter().sum();
316        segments.push((x, width));
317        x += width;
318        col_idx = end;
319    }
320    segments
321}
322
323/// Compute which column dividers should be drawn.
324///
325/// Returns a `Vec<bool>` of length `columns.len() - 1`. `visible[k]` is `true`
326/// when the divider between column k and column k+1 should be drawn.
327fn visible_dividers(cells: &[Cell], columns: &[f64]) -> Vec<bool> {
328    let n = columns.len();
329    if n <= 1 {
330        return vec![];
331    }
332    let mut visible = vec![true; n - 1];
333    let mut col_idx = 0;
334    for cell in cells {
335        let span = cell.col_span.max(1);
336        // Suppress dividers within the span: between col_idx and col_idx+span-1.
337        for k in col_idx..(col_idx + span).saturating_sub(1) {
338            if k < visible.len() {
339                visible[k] = false;
340            }
341        }
342        col_idx += span;
343    }
344    visible
345}
346
347/// Compute the height needed for a row based on its content.
348///
349/// Returns `row.height` directly for fixed-height rows (Clip/Shrink modes).
350/// Otherwise computes the maximum cell height across all cells, using each
351/// cell's actual rendered width (accounting for col_span).
352fn measure_row_height(
353    row: &Row,
354    columns: &[f64],
355    default_style: &CellStyle,
356    tt_fonts: &[TrueTypeFont],
357) -> f64 {
358    if let Some(h) = row.height {
359        return h;
360    }
361    let mut max_height = 0.0_f64;
362    let mut col_idx = 0;
363    for cell in &row.cells {
364        let span = cell.col_span.max(1);
365        let end = (col_idx + span).min(columns.len());
366        let cell_width: f64 = columns[col_idx..end].iter().sum();
367        let h = measure_cell_height(&cell.text, &cell.style, cell_width, tt_fonts);
368        max_height = max_height.max(h);
369        col_idx = end;
370    }
371    if max_height == 0.0 {
372        // Fallback: no cells
373        let ts = make_text_style(default_style);
374        line_height_for(&ts, tt_fonts) + 2.0 * default_style.padding
375    } else {
376        max_height
377    }
378}
379
380/// Compute the height needed to display a cell's text content with wrapping.
381fn measure_cell_height(
382    text: &str,
383    style: &CellStyle,
384    col_width: f64,
385    tt_fonts: &[TrueTypeFont],
386) -> f64 {
387    let avail_width = col_width - 2.0 * style.padding;
388    let ts = make_text_style(style);
389    let lh = line_height_for(&ts, tt_fonts);
390    let lines = count_lines(text, avail_width, &ts, style.word_break, tt_fonts);
391    lines as f64 * lh + 2.0 * style.padding
392}
393
394/// Convert a `CellStyle` to a `TextStyle` for use with measurement helpers.
395fn make_text_style(style: &CellStyle) -> TextStyle {
396    TextStyle {
397        font: style.font,
398        font_size: style.font_size,
399    }
400}
401
402/// Count the total number of wrapped lines for `text` given the available width.
403fn count_lines(
404    text: &str,
405    avail_width: f64,
406    style: &TextStyle,
407    word_break: WordBreak,
408    tt_fonts: &[TrueTypeFont],
409) -> usize {
410    if text.is_empty() {
411        return 1;
412    }
413    text.split('\n')
414        .map(|para| count_paragraph_lines(para, avail_width, style, word_break, tt_fonts))
415        .sum::<usize>()
416        .max(1)
417}
418
419/// Count lines for a single paragraph (no newlines).
420fn count_paragraph_lines(
421    text: &str,
422    avail_width: f64,
423    style: &TextStyle,
424    word_break: WordBreak,
425    tt_fonts: &[TrueTypeFont],
426) -> usize {
427    let text = text.trim();
428    if text.is_empty() {
429        return 1;
430    }
431    let mut lines = 1usize;
432    let mut line_width = 0.0_f64;
433
434    for word in text.split_whitespace() {
435        let word_w = measure_word(word, style, tt_fonts);
436        let space_w = if line_width == 0.0 {
437            0.0
438        } else {
439            measure_word(" ", style, tt_fonts)
440        };
441        let needed = line_width + space_w + word_w;
442
443        if needed > avail_width && line_width > 0.0 {
444            lines += 1;
445            line_width = word_w;
446            // If this word still overflows on its own line, count extra lines.
447            if word_break != WordBreak::Normal && word_w > avail_width {
448                lines += count_break_lines(word, avail_width, style, word_break, tt_fonts) - 1;
449                line_width = trailing_piece_width(word, avail_width, style, word_break, tt_fonts);
450            }
451        } else if word_break != WordBreak::Normal && word_w > avail_width {
452            // First word on a fresh line and it's still too wide.
453            lines += count_break_lines(word, avail_width, style, word_break, tt_fonts) - 1;
454            line_width = trailing_piece_width(word, avail_width, style, word_break, tt_fonts);
455        } else {
456            line_width = needed;
457        }
458    }
459    lines
460}
461
462/// Count how many lines a single oversized word occupies when broken.
463fn count_break_lines(
464    word: &str,
465    avail_width: f64,
466    style: &TextStyle,
467    word_break: WordBreak,
468    tt_fonts: &[TrueTypeFont],
469) -> usize {
470    break_word(word, avail_width, style, word_break, tt_fonts).len()
471}
472
473/// Width of the last piece when a word is broken across lines.
474fn trailing_piece_width(
475    word: &str,
476    avail_width: f64,
477    style: &TextStyle,
478    word_break: WordBreak,
479    tt_fonts: &[TrueTypeFont],
480) -> f64 {
481    break_word(word, avail_width, style, word_break, tt_fonts)
482        .last()
483        .map_or(0.0, |p| measure_word(p, style, tt_fonts))
484}
485
486/// Word-wrap `text` into lines that fit within `avail_width`.
487fn wrap_text(
488    text: &str,
489    avail_width: f64,
490    style: &TextStyle,
491    word_break: WordBreak,
492    tt_fonts: &[TrueTypeFont],
493) -> Vec<String> {
494    let mut lines: Vec<String> = Vec::new();
495    for para in text.split('\n') {
496        wrap_paragraph(
497            para.trim(),
498            avail_width,
499            style,
500            word_break,
501            tt_fonts,
502            &mut lines,
503        );
504    }
505    if lines.is_empty() {
506        lines.push(String::new());
507    }
508    lines
509}
510
511/// Word-wrap a single paragraph into lines, appending to `out`.
512fn wrap_paragraph(
513    text: &str,
514    avail_width: f64,
515    style: &TextStyle,
516    word_break: WordBreak,
517    tt_fonts: &[TrueTypeFont],
518    out: &mut Vec<String>,
519) {
520    if text.is_empty() {
521        out.push(String::new());
522        return;
523    }
524    let mut current_line = String::new();
525    let mut line_width = 0.0_f64;
526
527    for word in text.split_whitespace() {
528        let word_w = measure_word(word, style, tt_fonts);
529        let space_w = if current_line.is_empty() {
530            0.0
531        } else {
532            measure_word(" ", style, tt_fonts)
533        };
534        let needed = line_width + space_w + word_w;
535
536        if needed > avail_width && !current_line.is_empty() {
537            out.push(current_line.clone());
538            current_line = String::new();
539            line_width = 0.0;
540            // Fall through to place word on fresh line (may need breaking).
541            place_word_on_line(
542                word,
543                avail_width,
544                style,
545                word_break,
546                tt_fonts,
547                &mut current_line,
548                &mut line_width,
549                out,
550            );
551        } else if word_w > avail_width && word_break != WordBreak::Normal && current_line.is_empty()
552        {
553            // Fresh line, word is too wide — break it.
554            place_word_on_line(
555                word,
556                avail_width,
557                style,
558                word_break,
559                tt_fonts,
560                &mut current_line,
561                &mut line_width,
562                out,
563            );
564        } else {
565            if !current_line.is_empty() {
566                current_line.push(' ');
567            }
568            current_line.push_str(word);
569            line_width = needed;
570        }
571    }
572    if !current_line.is_empty() {
573        out.push(current_line);
574    }
575}
576
577/// Append a single word to lines, breaking it if it is wider than `avail_width`.
578///
579/// All full pieces except the last are pushed to `out`. The last piece is
580/// accumulated into `current_line`/`line_width` so subsequent words can
581/// continue on the same line.
582fn place_word_on_line(
583    word: &str,
584    avail_width: f64,
585    style: &TextStyle,
586    word_break: WordBreak,
587    tt_fonts: &[TrueTypeFont],
588    current_line: &mut String,
589    line_width: &mut f64,
590    out: &mut Vec<String>,
591) {
592    let word_w = measure_word(word, style, tt_fonts);
593
594    if word_w <= avail_width || word_break == WordBreak::Normal {
595        if !current_line.is_empty() {
596            current_line.push(' ');
597        }
598        current_line.push_str(word);
599        *line_width += word_w;
600        return;
601    }
602
603    let pieces = break_word(word, avail_width, style, word_break, tt_fonts);
604    let last_idx = pieces.len() - 1;
605    for (i, piece) in pieces.into_iter().enumerate() {
606        if i < last_idx {
607            out.push(piece);
608        } else {
609            *current_line = piece.clone();
610            *line_width = measure_word(&piece, style, tt_fonts);
611        }
612    }
613}
614
615// -------------------------------------------------------
616// Rendering helpers
617// -------------------------------------------------------
618
619/// Get the PDF resource name for a font.
620fn pdf_font_name(font: FontRef, tt_fonts: &[TrueTypeFont]) -> String {
621    match font {
622        FontRef::Builtin(b) => b.pdf_name().to_string(),
623        FontRef::TrueType(id) => tt_fonts[id.0].pdf_name.clone(),
624    }
625}
626
627/// Record a font as used in the current page.
628fn record_font(font: &FontRef, used: &mut UsedFonts) {
629    match font {
630        FontRef::Builtin(b) => {
631            used.builtin.insert(*b);
632        }
633        FontRef::TrueType(id) => {
634            used.truetype.insert(id.0);
635        }
636    }
637}
638
639/// Emit a text string using the correct encoding for the font type.
640fn emit_cell_text(text: &str, font: FontRef, tt_fonts: &mut [TrueTypeFont], output: &mut Vec<u8>) {
641    if text.is_empty() {
642        return;
643    }
644    match font {
645        FontRef::Builtin(_) => {
646            let escaped = escape_pdf_string(text);
647            output.extend_from_slice(format!("({}) Tj\n", escaped).as_bytes());
648        }
649        FontRef::TrueType(id) => {
650            let hex = tt_fonts[id.0].encode_text_hex(text);
651            output.extend_from_slice(format!("{} Tj\n", hex).as_bytes());
652        }
653    }
654}
655
656/// Draw row and cell background fills.
657///
658/// Row background is drawn first; per-cell backgrounds overlay on top.
659/// `segments` contains the (x, width) for each cell, already accounting for col_span.
660fn draw_row_backgrounds_with_segments(
661    row: &Row,
662    segments: &[(f64, f64)],
663    row_x: f64,
664    row_top: f64,
665    row_height: f64,
666    columns: &[f64],
667    output: &mut Vec<u8>,
668) {
669    let row_bottom = row_top - row_height;
670
671    if let Some(bg) = row.background_color {
672        let total_width: f64 = columns.iter().sum();
673        output.extend_from_slice(
674            format!(
675                "{} {} {} rg\n{} {} {} {} re\nf\n",
676                format_coord(bg.r),
677                format_coord(bg.g),
678                format_coord(bg.b),
679                format_coord(row_x),
680                format_coord(row_bottom),
681                format_coord(total_width),
682                format_coord(row_height),
683            )
684            .as_bytes(),
685        );
686    }
687
688    for (cell, &(cell_x, cell_width)) in row.cells.iter().zip(segments.iter()) {
689        if let Some(bg) = cell.style.background_color {
690            output.extend_from_slice(
691                format!(
692                    "{} {} {} rg\n{} {} {} {} re\nf\n",
693                    format_coord(bg.r),
694                    format_coord(bg.g),
695                    format_coord(bg.b),
696                    format_coord(cell_x),
697                    format_coord(row_bottom),
698                    format_coord(cell_width),
699                    format_coord(row_height),
700                )
701                .as_bytes(),
702            );
703        }
704    }
705}
706
707/// Draw row borders: outer rectangle plus vertical column dividers.
708///
709/// Vertical dividers within a cell's col_span are suppressed.
710fn draw_row_borders(
711    columns: &[f64],
712    cells: &[Cell],
713    row_x: f64,
714    row_top: f64,
715    row_height: f64,
716    border_color: Color,
717    border_width: f64,
718    output: &mut Vec<u8>,
719) {
720    let row_bottom = row_top - row_height;
721    let total_width: f64 = columns.iter().sum();
722
723    output.extend_from_slice(b"q\n");
724    output.extend_from_slice(
725        format!(
726            "{} {} {} RG\n{} w\n",
727            format_coord(border_color.r),
728            format_coord(border_color.g),
729            format_coord(border_color.b),
730            format_coord(border_width),
731        )
732        .as_bytes(),
733    );
734
735    // Outer rectangle of the row
736    output.extend_from_slice(
737        format!(
738            "{} {} {} {} re\nS\n",
739            format_coord(row_x),
740            format_coord(row_bottom),
741            format_coord(total_width),
742            format_coord(row_height),
743        )
744        .as_bytes(),
745    );
746
747    // Vertical column dividers — suppressed within spans
748    let visible = visible_dividers(cells, columns);
749    let mut col_x = row_x;
750    for (k, &col_width) in columns[..columns.len().saturating_sub(1)]
751        .iter()
752        .enumerate()
753    {
754        col_x += col_width;
755        if visible.get(k).copied().unwrap_or(true) {
756            output.extend_from_slice(
757                format!(
758                    "{} {} m\n{} {} l\nS\n",
759                    format_coord(col_x),
760                    format_coord(row_top),
761                    format_coord(col_x),
762                    format_coord(row_bottom),
763                )
764                .as_bytes(),
765            );
766        }
767    }
768
769    output.extend_from_slice(b"Q\n");
770}
771
772/// Compute the x coordinate for a line of text within a cell based on alignment.
773fn aligned_x(
774    line: &str,
775    align: TextAlign,
776    cell_x: f64,
777    col_width: f64,
778    padding: f64,
779    ts: &TextStyle,
780    tt_fonts: &[TrueTypeFont],
781) -> f64 {
782    match align {
783        TextAlign::Left => cell_x + padding,
784        TextAlign::Right => {
785            let line_w = measure_word(line, ts, tt_fonts);
786            cell_x + col_width - padding - line_w
787        }
788        TextAlign::Center => {
789            let avail = col_width - 2.0 * padding;
790            let line_w = measure_word(line, ts, tt_fonts);
791            cell_x + padding + (avail - line_w).max(0.0) / 2.0
792        }
793    }
794}
795
796/// Render the text content of a single cell.
797///
798/// Wraps each cell in `q/Q` to isolate graphics state. Applies clip region
799/// for `Clip` mode and reduces font size for `Shrink` mode.
800fn render_cell(
801    cell: &Cell,
802    cell_x: f64,
803    row_top: f64,
804    col_width: f64,
805    row_height: f64,
806    tt_fonts: &mut [TrueTypeFont],
807    output: &mut Vec<u8>,
808    used: &mut UsedFonts,
809) {
810    let style = &cell.style;
811    let avail_width = (col_width - 2.0 * style.padding).max(0.0);
812    let avail_height = (row_height - 2.0 * style.padding).max(0.0);
813
814    // Resolve effective font size (may be reduced for Shrink mode)
815    let effective_font_size = if style.overflow == CellOverflow::Shrink {
816        shrink_font_size(
817            &cell.text,
818            style.font,
819            style.font_size,
820            avail_width,
821            avail_height,
822            style.word_break,
823            tt_fonts,
824        )
825    } else {
826        style.font_size
827    };
828
829    let ts = TextStyle {
830        font: style.font,
831        font_size: effective_font_size,
832    };
833    let lh = line_height_for(&ts, tt_fonts);
834    let lines = wrap_text(&cell.text, avail_width, &ts, style.word_break, tt_fonts);
835
836    output.extend_from_slice(b"q\n");
837
838    // Apply clipping rectangle for Clip mode
839    if style.overflow == CellOverflow::Clip {
840        let clip_bottom = row_top - row_height;
841        output.extend_from_slice(
842            format!(
843                "{} {} {} {} re\nW\nn\n",
844                format_coord(cell_x),
845                format_coord(clip_bottom),
846                format_coord(col_width),
847                format_coord(row_height),
848            )
849            .as_bytes(),
850        );
851    }
852
853    // Baseline: top of cell minus top padding minus font size (approximates ascent)
854    let first_line_y = row_top - style.padding - effective_font_size;
855
856    output.extend_from_slice(b"BT\n");
857
858    // Always set an explicit fill color for text. Without this, the fill
859    // color from background drawing (set outside q/Q) would bleed into
860    // text rendering, making text invisible on colored backgrounds.
861    let text_color = style
862        .text_color
863        .unwrap_or_else(|| Color::rgb(0.0, 0.0, 0.0));
864    output.extend_from_slice(
865        format!(
866            "{} {} {} rg\n",
867            format_coord(text_color.r),
868            format_coord(text_color.g),
869            format_coord(text_color.b),
870        )
871        .as_bytes(),
872    );
873
874    let font_name = pdf_font_name(ts.font, tt_fonts);
875    output.extend_from_slice(
876        format!("/{} {} Tf\n", font_name, format_coord(effective_font_size)).as_bytes(),
877    );
878    record_font(&ts.font, used);
879
880    let align = style.text_align;
881    let mut current_x = cell_x + style.padding; // placeholder; overwritten on first line
882
883    for (i, line) in lines.iter().enumerate() {
884        let line_x = aligned_x(line, align, cell_x, col_width, style.padding, &ts, tt_fonts);
885        if i == 0 {
886            output.extend_from_slice(
887                format!(
888                    "{} {} Td\n",
889                    format_coord(line_x),
890                    format_coord(first_line_y)
891                )
892                .as_bytes(),
893            );
894        } else {
895            let dx = line_x - current_x;
896            output.extend_from_slice(
897                format!("{} {} Td\n", format_coord(dx), format_coord(-lh)).as_bytes(),
898            );
899        }
900        current_x = line_x;
901        emit_cell_text(line, ts.font, tt_fonts, output);
902    }
903
904    output.extend_from_slice(b"ET\n");
905    output.extend_from_slice(b"Q\n");
906}
907
908/// Reduce font size by 0.5pt steps until the text fits within the available
909/// dimensions, stopping at a minimum of 4pt.
910///
911/// When `word_break` is not `Normal`, every word can be broken, so only the
912/// height constraint needs to be satisfied. When `Normal`, width must also
913/// fit (a word wider than the column can never wrap — only shrinking helps).
914fn shrink_font_size(
915    text: &str,
916    font: FontRef,
917    initial_size: f64,
918    avail_width: f64,
919    avail_height: f64,
920    word_break: WordBreak,
921    tt_fonts: &[TrueTypeFont],
922) -> f64 {
923    const MIN_FONT_SIZE: f64 = 4.0;
924    const STEP: f64 = 0.5;
925
926    let mut font_size = initial_size;
927    loop {
928        let ts = TextStyle { font, font_size };
929        let lh = line_height_for(&ts, tt_fonts);
930        let lines = count_lines(text, avail_width, &ts, word_break, tt_fonts);
931        let fits_height = lines as f64 * lh <= avail_height;
932        let fits_width = word_break != WordBreak::Normal
933            || text
934                .split_whitespace()
935                .all(|w| measure_word(w, &ts, tt_fonts) <= avail_width);
936        if (fits_height && fits_width) || font_size <= MIN_FONT_SIZE {
937            break;
938        }
939        font_size = (font_size - STEP).max(MIN_FONT_SIZE);
940    }
941    font_size
942}