Skip to main content

oxidize_pdf/advanced_tables/
table_renderer.rs

1//! Table renderer for converting advanced tables to PDF content
2
3use super::cell_style::{BorderConfiguration, BorderStyle, CellAlignment, CellStyle};
4use super::header_builder::HeaderBuilder;
5use super::table_builder::{AdvancedTable, CellData, RowData};
6use crate::error::PdfError;
7use crate::graphics::Color;
8use crate::page::Page;
9use crate::text::{measure_text, Font};
10
11/// Renderer for advanced tables
12pub struct TableRenderer {
13    /// Default row height when not specified
14    pub default_row_height: f64,
15    /// Default header height
16    pub default_header_height: f64,
17    /// Whether to auto-calculate cell heights based on content
18    pub auto_height: bool,
19}
20
21impl TableRenderer {
22    /// Create a new table renderer
23    pub fn new() -> Self {
24        Self {
25            default_row_height: 25.0,
26            default_header_height: 30.0,
27            auto_height: true,
28        }
29    }
30
31    /// Calculate the total height needed to render a table
32    ///
33    /// This is essential for intelligent positioning and layout management.
34    /// Returns the height in points from bottom to top of the rendered table.
35    pub fn calculate_table_height(&self, table: &AdvancedTable) -> f64 {
36        let mut total_height = 0.0;
37
38        // Calculate header height
39        if table.show_header {
40            if let Some(header) = &table.header {
41                // For complex headers, calculate based on levels and row spans
42                total_height += header.calculate_height();
43            } else if !table.columns.is_empty() {
44                // Simple header from column definitions
45                total_height += self.default_header_height;
46            }
47        }
48
49        // Calculate rows height, tracking rows absorbed by rowspan.
50        // When auto_height is enabled, expand rows to fit multiline content
51        // (mirrors the same logic used in render_rows).
52        let mut rows_to_skip: usize = 0;
53        for (row_idx, row) in table.rows.iter().enumerate() {
54            if rows_to_skip > 0 {
55                rows_to_skip -= 1;
56                continue;
57            }
58
59            let base_height = row.min_height.unwrap_or(self.default_row_height);
60            let row_height = if self.auto_height {
61                let content_height = self.calculate_content_row_height(table, row, row_idx);
62                base_height.max(content_height)
63            } else {
64                base_height
65            };
66
67            let max_rowspan = row.cells.iter().map(|cell| cell.rowspan).max().unwrap_or(1);
68            if max_rowspan > 1 {
69                total_height += row_height * max_rowspan as f64;
70                rows_to_skip = max_rowspan - 1;
71            } else {
72                total_height += row_height;
73            }
74        }
75
76        // Add small buffer for table borders
77        if table.table_border {
78            total_height += 2.0; // Top and bottom borders
79        }
80
81        total_height
82    }
83
84    /// Render a table to a PDF page
85    pub fn render_table(
86        &self,
87        page: &mut Page,
88        table: &AdvancedTable,
89        x: f64,
90        y: f64,
91    ) -> Result<f64, PdfError> {
92        // Validate table structure
93        table
94            .validate()
95            .map_err(|e| PdfError::InvalidOperation(e.to_string()))?;
96
97        let mut current_y = y;
98
99        // Render header if present
100        if table.show_header {
101            if let Some(header) = &table.header {
102                current_y = self.render_header(page, table, header, x, current_y)?;
103            } else if !table.columns.is_empty() {
104                // Render simple header from column definitions
105                current_y = self.render_simple_header(page, table, x, current_y)?;
106            }
107        }
108
109        // Render table rows
110        current_y = self.render_rows(page, table, x, current_y)?;
111
112        // Render table border if enabled
113        if table.table_border {
114            self.render_table_border(page, table, x, y, current_y)?;
115        }
116
117        Ok(current_y)
118    }
119
120    /// Render table headers
121    fn render_header(
122        &self,
123        page: &mut Page,
124        table: &AdvancedTable,
125        header: &HeaderBuilder,
126        x: f64,
127        start_y: f64,
128    ) -> Result<f64, PdfError> {
129        let mut current_y = start_y;
130        let column_positions = self.calculate_column_positions(table, x);
131
132        for level in header.levels.iter() {
133            let row_height = self.default_header_height;
134
135            for cell in level {
136                let cell_x = column_positions[cell.start_col];
137                let cell_width = self.calculate_span_width(table, cell.start_col, cell.colspan);
138                let cell_height = row_height * cell.rowspan as f64;
139
140                let style = cell.style.as_ref().unwrap_or(&table.header_style);
141
142                self.render_cell(
143                    page,
144                    &cell.text,
145                    cell_x,
146                    current_y - cell_height,
147                    cell_width,
148                    cell_height,
149                    style,
150                )?;
151            }
152
153            current_y -= row_height;
154        }
155
156        Ok(current_y)
157    }
158
159    /// Render simple header from column definitions
160    fn render_simple_header(
161        &self,
162        page: &mut Page,
163        table: &AdvancedTable,
164        x: f64,
165        start_y: f64,
166    ) -> Result<f64, PdfError> {
167        let column_positions = self.calculate_column_positions(table, x);
168        let header_height = self.default_header_height;
169
170        for (col_idx, column) in table.columns.iter().enumerate() {
171            let cell_x = column_positions[col_idx];
172            let cell_width = column.width;
173
174            self.render_cell(
175                page,
176                &column.header,
177                cell_x,
178                start_y - header_height,
179                cell_width,
180                header_height,
181                &table.header_style,
182            )?;
183        }
184
185        Ok(start_y - header_height)
186    }
187
188    /// Calculate the minimum height needed for a row's content
189    fn calculate_content_row_height(
190        &self,
191        table: &AdvancedTable,
192        row: &RowData,
193        row_idx: usize,
194    ) -> f64 {
195        let mut max_height = self.default_row_height;
196
197        let mut actual_col = 0usize;
198        for cell in row.cells.iter() {
199            let style = self.resolve_cell_style(table, cell, row_idx, actual_col);
200            let font = style.font.clone().unwrap_or(Font::Helvetica);
201            let font_size = style.font_size.unwrap_or(12.0);
202            // Sum column widths for colspan
203            let col_width: f64 = (actual_col..actual_col + cell.colspan)
204                .filter_map(|c| table.columns.get(c).map(|col| col.width))
205                .sum();
206            let available_width = col_width - style.padding.left - style.padding.right;
207
208            if available_width > 0.0 && style.text_wrap {
209                let lines =
210                    self.wrap_text_to_lines(&cell.content, available_width, &font, font_size);
211                let line_height = font_size * 1.2;
212                let needed =
213                    (lines.len() as f64 * line_height) + style.padding.top + style.padding.bottom;
214                if needed > max_height {
215                    max_height = needed;
216                }
217            }
218
219            actual_col += cell.colspan;
220        }
221
222        max_height
223    }
224
225    /// Render table data rows
226    fn render_rows(
227        &self,
228        page: &mut Page,
229        table: &AdvancedTable,
230        x: f64,
231        start_y: f64,
232    ) -> Result<f64, PdfError> {
233        let mut current_y = start_y;
234        let column_positions = self.calculate_column_positions(table, x);
235        let num_cols = table.columns.len();
236        // Track the last row index each column is occupied through (exclusive upper bound).
237        // rowspan_end[c] > row_idx means column c is occupied by a rowspan from a previous row.
238        let mut rowspan_end: Vec<usize> = vec![0; num_cols];
239
240        for (row_idx, row) in table.rows.iter().enumerate() {
241            // Calculate row height: explicit min_height, or auto-fit content
242            let base_height = row.min_height.unwrap_or(self.default_row_height);
243            let row_height = if self.auto_height {
244                let content_height = self.calculate_content_row_height(table, row, row_idx);
245                base_height.max(content_height)
246            } else {
247                base_height
248            };
249
250            // Track actual column position (accounts for colspan and rowspan)
251            let mut actual_col = 0usize;
252            for cell in row.cells.iter() {
253                // Skip columns occupied by rowspan from previous rows
254                while actual_col < num_cols && rowspan_end[actual_col] > row_idx {
255                    actual_col += 1;
256                }
257
258                if actual_col >= column_positions.len() {
259                    break;
260                }
261
262                let cell_x = column_positions[actual_col];
263                let cell_width = self.calculate_span_width(table, actual_col, cell.colspan);
264                let cell_height = row_height * cell.rowspan as f64;
265
266                let style = self.resolve_cell_style(table, cell, row_idx, actual_col);
267
268                self.render_cell(
269                    page,
270                    &cell.content,
271                    cell_x,
272                    current_y - cell_height,
273                    cell_width,
274                    cell_height,
275                    &style,
276                )?;
277
278                // Record rowspan: this cell occupies columns through row_idx + rowspan - 1
279                if cell.rowspan > 1 {
280                    for c in actual_col..(actual_col + cell.colspan).min(num_cols) {
281                        rowspan_end[c] = row_idx + cell.rowspan;
282                    }
283                }
284
285                actual_col += cell.colspan;
286            }
287
288            current_y -= row_height;
289        }
290
291        Ok(current_y)
292    }
293
294    /// Render an individual cell
295    #[allow(clippy::too_many_arguments)]
296    fn render_cell(
297        &self,
298        page: &mut Page,
299        content: &str,
300        x: f64,
301        y: f64,
302        width: f64,
303        height: f64,
304        style: &CellStyle,
305    ) -> Result<(), PdfError> {
306        // Draw background if specified
307        if let Some(bg_color) = style.background_color {
308            page.graphics()
309                .save_state()
310                .set_fill_color(bg_color)
311                .rectangle(x, y, width, height)
312                .fill()
313                .restore_state();
314        }
315
316        // Draw borders
317        self.render_cell_borders(page, x, y, width, height, &style.border)?;
318
319        // Draw text content
320        if !content.is_empty() {
321            self.render_cell_text(page, content, x, y, width, height, style)?;
322        }
323
324        Ok(())
325    }
326
327    /// Render cell borders
328    fn render_cell_borders(
329        &self,
330        page: &mut Page,
331        x: f64,
332        y: f64,
333        width: f64,
334        height: f64,
335        border_config: &BorderConfiguration,
336    ) -> Result<(), PdfError> {
337        let graphics = page.graphics();
338
339        // Top border
340        if border_config.top.style != BorderStyle::None {
341            graphics
342                .save_state()
343                .set_stroke_color(border_config.top.color)
344                .set_line_width(border_config.top.width);
345
346            self.apply_line_style(graphics, border_config.top.style);
347
348            graphics
349                .move_to(x, y + height)
350                .line_to(x + width, y + height)
351                .stroke()
352                .restore_state();
353        }
354
355        // Bottom border
356        if border_config.bottom.style != BorderStyle::None {
357            graphics
358                .save_state()
359                .set_stroke_color(border_config.bottom.color)
360                .set_line_width(border_config.bottom.width);
361
362            self.apply_line_style(graphics, border_config.bottom.style);
363
364            graphics
365                .move_to(x, y)
366                .line_to(x + width, y)
367                .stroke()
368                .restore_state();
369        }
370
371        // Left border
372        if border_config.left.style != BorderStyle::None {
373            graphics
374                .save_state()
375                .set_stroke_color(border_config.left.color)
376                .set_line_width(border_config.left.width);
377
378            self.apply_line_style(graphics, border_config.left.style);
379
380            graphics
381                .move_to(x, y)
382                .line_to(x, y + height)
383                .stroke()
384                .restore_state();
385        }
386
387        // Right border
388        if border_config.right.style != BorderStyle::None {
389            graphics
390                .save_state()
391                .set_stroke_color(border_config.right.color)
392                .set_line_width(border_config.right.width);
393
394            self.apply_line_style(graphics, border_config.right.style);
395
396            graphics
397                .move_to(x + width, y)
398                .line_to(x + width, y + height)
399                .stroke()
400                .restore_state();
401        }
402
403        Ok(())
404    }
405
406    /// Apply line style for borders
407    fn apply_line_style(
408        &self,
409        _graphics: &mut crate::graphics::GraphicsContext,
410        _style: BorderStyle,
411    ) {
412        // TODO: Implement line styles when GraphicsContext supports dash patterns
413        // For now, all borders will be solid
414    }
415
416    /// Truncate text to fit within a specified width, adding ellipsis if needed
417    fn truncate_text_to_width(
418        &self,
419        text: &str,
420        max_width: f64,
421        font: &Font,
422        font_size: f64,
423    ) -> String {
424        // If text already fits, return as-is
425        let full_width = measure_text(text, font, font_size);
426        if full_width <= max_width {
427            return text.to_string();
428        }
429
430        // If even ellipsis doesn't fit, return empty string
431        let ellipsis = "...";
432        let ellipsis_width = measure_text(ellipsis, font, font_size);
433        if ellipsis_width > max_width {
434            return String::new();
435        }
436
437        // If exactly ellipsis width, return ellipsis
438        if ellipsis_width == max_width {
439            return ellipsis.to_string();
440        }
441
442        // Linear scan: iterate character by character until width is exceeded.
443        // For typical cell text (<100 chars) this is simpler and avoids allocating a Vec<char>.
444        let available_width = max_width - ellipsis_width;
445        let mut last_fit_end = 0usize;
446        let mut width_so_far = 0.0f64;
447
448        for (byte_pos, ch) in text.char_indices() {
449            let ch_len = ch.len_utf8();
450            let ch_str = &text[byte_pos..byte_pos + ch_len];
451            let ch_width = measure_text(ch_str, font, font_size);
452            if width_so_far + ch_width > available_width {
453                break;
454            }
455            width_so_far += ch_width;
456            last_fit_end = byte_pos + ch_len;
457        }
458
459        if last_fit_end == 0 {
460            ellipsis.to_string()
461        } else {
462            format!("{}{}", &text[..last_fit_end], ellipsis)
463        }
464    }
465
466    /// Wrap text into multiple lines that fit within the given width.
467    ///
468    /// Uses incremental width tracking to avoid remeasuring the full current line on
469    /// every word — a significant saving in the hot path for tables with many cells.
470    fn wrap_text_to_lines(
471        &self,
472        text: &str,
473        max_width: f64,
474        font: &Font,
475        font_size: f64,
476    ) -> Vec<String> {
477        let mut lines = Vec::new();
478
479        // Split by existing newlines first
480        for paragraph in text.split('\n') {
481            if paragraph.is_empty() {
482                lines.push(String::new());
483                continue;
484            }
485
486            // If paragraph fits, add it directly (single measure check)
487            let paragraph_width = measure_text(paragraph, font, font_size);
488            if paragraph_width <= max_width {
489                lines.push(paragraph.to_string());
490                continue;
491            }
492
493            // Word-wrap the paragraph
494            let words: Vec<&str> = paragraph.split_whitespace().collect();
495            if words.is_empty() {
496                continue;
497            }
498
499            let mut current_line = String::new();
500            let mut current_line_width = 0.0f64;
501            let space_width = measure_text(" ", font, font_size);
502
503            for word in words {
504                let word_width = measure_text(word, font, font_size);
505
506                if current_line.is_empty() {
507                    // First word on the line
508                    if word_width <= max_width {
509                        current_line = word.to_string();
510                        current_line_width = word_width;
511                    } else {
512                        // Word is too long — break it character by character
513                        let chars: Vec<char> = word.chars().collect();
514                        let mut char_line = String::new();
515                        let mut char_line_width = 0.0f64;
516                        for c in chars {
517                            let char_width = measure_text(&c.to_string(), font, font_size);
518                            if char_line_width + char_width <= max_width {
519                                char_line.push(c);
520                                char_line_width += char_width;
521                            } else {
522                                if !char_line.is_empty() {
523                                    lines.push(char_line);
524                                }
525                                char_line = c.to_string();
526                                char_line_width = char_width;
527                            }
528                        }
529                        current_line = char_line;
530                        current_line_width = char_line_width;
531                    }
532                } else {
533                    // Test adding word to current line using incremental width
534                    let test_width = current_line_width + space_width + word_width;
535
536                    if test_width <= max_width {
537                        current_line.push(' ');
538                        current_line.push_str(word);
539                        current_line_width = test_width;
540                    } else {
541                        // Start new line
542                        lines.push(current_line);
543                        if word_width <= max_width {
544                            current_line = word.to_string();
545                            current_line_width = word_width;
546                        } else {
547                            // Word is too long — break it character by character
548                            let chars: Vec<char> = word.chars().collect();
549                            let mut char_line = String::new();
550                            let mut char_line_width = 0.0f64;
551                            for c in chars {
552                                let char_width = measure_text(&c.to_string(), font, font_size);
553                                if char_line_width + char_width <= max_width {
554                                    char_line.push(c);
555                                    char_line_width += char_width;
556                                } else {
557                                    if !char_line.is_empty() {
558                                        lines.push(char_line);
559                                    }
560                                    char_line = c.to_string();
561                                    char_line_width = char_width;
562                                }
563                            }
564                            current_line = char_line;
565                            current_line_width = char_line_width;
566                        }
567                    }
568                }
569            }
570
571            // Add remaining text
572            if !current_line.is_empty() {
573                lines.push(current_line);
574            }
575        }
576
577        if lines.is_empty() {
578            lines.push(String::new());
579        }
580
581        lines
582    }
583
584    /// Render text within a cell
585    #[allow(clippy::too_many_arguments)]
586    fn render_cell_text(
587        &self,
588        page: &mut Page,
589        content: &str,
590        x: f64,
591        y: f64,
592        width: f64,
593        height: f64,
594        style: &CellStyle,
595    ) -> Result<(), PdfError> {
596        let font = style.font.clone().unwrap_or(Font::Helvetica);
597        let font_size = style.font_size.unwrap_or(12.0);
598        let text_color = style.text_color.unwrap_or(Color::black());
599
600        // Calculate available width for text (considering padding)
601        let available_width = width - style.padding.left - style.padding.right;
602
603        if available_width <= 0.0 {
604            return Ok(());
605        }
606
607        // Check if text wrapping is enabled
608        if style.text_wrap {
609            // Wrap text into multiple lines
610            let lines = self.wrap_text_to_lines(content, available_width, &font, font_size);
611
612            if lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()) {
613                return Ok(());
614            }
615
616            // Calculate line height (typically 1.2x font size)
617            let line_height = font_size * 1.2;
618            let total_text_height = lines.len() as f64 * line_height;
619
620            // Calculate available height (considering padding)
621            let available_height = height - style.padding.top - style.padding.bottom;
622
623            // Calculate starting Y position (top of text block, vertically centered)
624            // In PDF coordinate system, Y increases upward
625            let text_block_top =
626                y + height - style.padding.top - (available_height - total_text_height) / 2.0;
627
628            // Render each line
629            for (line_idx, line) in lines.iter().enumerate() {
630                if line.is_empty() {
631                    continue;
632                }
633
634                // Calculate X position based on alignment
635                let text_x = match style.alignment {
636                    CellAlignment::Left => x + style.padding.left,
637                    CellAlignment::Center => {
638                        let line_width = measure_text(line, &font, font_size);
639                        x + style.padding.left + (available_width - line_width) / 2.0
640                    }
641                    CellAlignment::Right => {
642                        let line_width = measure_text(line, &font, font_size);
643                        x + width - style.padding.right - line_width
644                    }
645                    CellAlignment::Justify => x + style.padding.left,
646                };
647
648                // Y position for this line (descending from top)
649                let text_y = text_block_top - (line_idx as f64 + 0.8) * line_height;
650
651                // Only render if within cell bounds
652                if text_y >= y + style.padding.bottom {
653                    page.text()
654                        .set_font(font.clone(), font_size)
655                        .set_fill_color(text_color)
656                        .at(text_x, text_y)
657                        .write(line)?;
658                }
659            }
660        } else {
661            // Original truncation behavior
662            let display_text =
663                self.truncate_text_to_width(content, available_width, &font, font_size);
664
665            // Calculate text position based on alignment and padding
666            let text_x = match style.alignment {
667                CellAlignment::Left => x + style.padding.left,
668                CellAlignment::Center => {
669                    // For center alignment, we need to calculate based on actual text width
670                    let text_width = measure_text(&display_text, &font, font_size);
671                    x + style.padding.left + (available_width - text_width) / 2.0
672                }
673                CellAlignment::Right => {
674                    let text_width = measure_text(&display_text, &font, font_size);
675                    x + width - style.padding.right - text_width
676                }
677                CellAlignment::Justify => x + style.padding.left,
678            };
679
680            // Vertically center with padding applied
681            let text_y = style
682                .padding
683                .pad_vertically(&page.coordinate_system(), y + height / 2.0);
684
685            // Only render text if we have something to display
686            if !display_text.is_empty() {
687                let text_obj = page
688                    .text()
689                    .set_font(font, font_size)
690                    .set_fill_color(text_color);
691
692                text_obj.at(text_x, text_y).write(&display_text)?;
693            }
694        }
695
696        Ok(())
697    }
698
699    /// Calculate column positions based on widths
700    fn calculate_column_positions(&self, table: &AdvancedTable, start_x: f64) -> Vec<f64> {
701        let mut positions = Vec::new();
702        let mut current_x = start_x;
703
704        for column in &table.columns {
705            positions.push(current_x);
706            current_x += column.width + table.cell_spacing;
707        }
708
709        positions
710    }
711
712    /// Calculate width for a cell that spans multiple columns
713    fn calculate_span_width(&self, table: &AdvancedTable, start_col: usize, colspan: usize) -> f64 {
714        let mut total_width = 0.0;
715
716        for i in 0..colspan {
717            if let Some(column) = table.columns.get(start_col + i) {
718                total_width += column.width;
719                if i > 0 {
720                    total_width += table.cell_spacing;
721                }
722            }
723        }
724
725        total_width
726    }
727
728    /// Resolve the effective style for a cell
729    fn resolve_cell_style(
730        &self,
731        table: &AdvancedTable,
732        cell: &CellData,
733        row_idx: usize,
734        col_idx: usize,
735    ) -> CellStyle {
736        // Priority: cell style > specific cell style > row style > column style > table default
737
738        if let Some(cell_style) = &cell.style {
739            return cell_style.clone();
740        }
741
742        table.get_cell_style(row_idx, col_idx)
743    }
744
745    /// Render table border
746    fn render_table_border(
747        &self,
748        page: &mut Page,
749        table: &AdvancedTable,
750        x: f64,
751        start_y: f64,
752        end_y: f64,
753    ) -> Result<(), PdfError> {
754        let total_width = table.calculate_width();
755        let height = start_y - end_y;
756
757        page.graphics()
758            .save_state()
759            .set_stroke_color(Color::black())
760            .set_line_width(1.0)
761            .rectangle(x, end_y, total_width, height)
762            .stroke()
763            .restore_state();
764
765        Ok(())
766    }
767}
768
769impl Default for TableRenderer {
770    fn default() -> Self {
771        Self::new()
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use crate::text::Font;
779
780    #[test]
781    fn test_truncate_text_to_width_no_truncation_needed() {
782        let renderer = TableRenderer::new();
783        let text = "Short";
784        let max_width = 100.0;
785        let font = Font::Helvetica;
786        let font_size = 12.0;
787
788        let result = renderer.truncate_text_to_width(text, max_width, &font, font_size);
789        assert_eq!(result, "Short");
790    }
791
792    #[test]
793    fn test_truncate_text_to_width_with_truncation() {
794        let renderer = TableRenderer::new();
795        let text = "This is a very long text that should be truncated";
796        let max_width = 50.0; // Very narrow width
797        let font = Font::Helvetica;
798        let font_size = 12.0;
799
800        let result = renderer.truncate_text_to_width(text, max_width, &font, font_size);
801        assert!(result.ends_with("..."));
802        assert!(result.len() < text.len());
803
804        // Verify the truncated text fits within the width
805        let truncated_width = measure_text(&result, &font, font_size);
806        assert!(truncated_width <= max_width);
807    }
808
809    #[test]
810    fn test_truncate_text_to_width_empty_when_too_narrow() {
811        let renderer = TableRenderer::new();
812        let text = "Any text";
813        let max_width = 5.0; // Too narrow even for ellipsis
814        let font = Font::Helvetica;
815        let font_size = 12.0;
816
817        let result = renderer.truncate_text_to_width(text, max_width, &font, font_size);
818        assert_eq!(result, "");
819    }
820
821    #[test]
822    fn test_truncate_text_to_width_exactly_ellipsis_width() {
823        let renderer = TableRenderer::new();
824        let text = "Some text";
825        let font = Font::Helvetica;
826        let font_size = 12.0;
827
828        // Calculate width that exactly fits ellipsis
829        let ellipsis_width = measure_text("...", &font, font_size);
830
831        let result = renderer.truncate_text_to_width(text, ellipsis_width, &font, font_size);
832        assert_eq!(result, "...");
833    }
834
835    #[test]
836    fn test_truncate_text_to_width_single_character() {
837        let renderer = TableRenderer::new();
838        let text = "A";
839        let max_width = 50.0;
840        let font = Font::Helvetica;
841        let font_size = 12.0;
842
843        let result = renderer.truncate_text_to_width(text, max_width, &font, font_size);
844        assert_eq!(result, "A");
845    }
846
847    #[test]
848    fn test_truncate_text_to_width_different_fonts() {
849        let renderer = TableRenderer::new();
850        let text = "This text will be truncated";
851        let max_width = 60.0;
852        let font_size = 12.0;
853
854        // Test with different fonts
855        let helvetica_result =
856            renderer.truncate_text_to_width(text, max_width, &Font::Helvetica, font_size);
857        let courier_result =
858            renderer.truncate_text_to_width(text, max_width, &Font::Courier, font_size);
859        let times_result =
860            renderer.truncate_text_to_width(text, max_width, &Font::TimesRoman, font_size);
861
862        // All should be truncated and fit within width
863        for result in [&helvetica_result, &courier_result, &times_result] {
864            assert!(result.ends_with("..."));
865            assert!(result.len() < text.len());
866        }
867
868        // Courier (monospace) might have different truncation point
869        // All results should be valid and within width limits
870        assert!(!helvetica_result.is_empty());
871        assert!(!courier_result.is_empty());
872        assert!(!times_result.is_empty());
873    }
874
875    #[test]
876    fn test_truncate_text_to_width_empty_input() {
877        let renderer = TableRenderer::new();
878        let text = "";
879        let max_width = 100.0;
880        let font = Font::Helvetica;
881        let font_size = 12.0;
882
883        let result = renderer.truncate_text_to_width(text, max_width, &font, font_size);
884        assert_eq!(result, "");
885    }
886
887    #[test]
888    fn test_truncate_text_to_width_unicode_characters() {
889        let renderer = TableRenderer::new();
890        let text = "Héllö Wørld with ümlauts and émojis 🚀🎉";
891        let max_width = 80.0;
892        let font = Font::Helvetica;
893        let font_size = 12.0;
894
895        let result = renderer.truncate_text_to_width(text, max_width, &font, font_size);
896
897        // Should handle unicode properly
898        if result != text {
899            assert!(result.ends_with("..."));
900        }
901
902        // Verify width constraint
903        let result_width = measure_text(&result, &font, font_size);
904        assert!(result_width <= max_width);
905    }
906
907    // ================== truncate_text_to_width linear scan tests ==================
908
909    #[test]
910    fn test_truncate_linear_short_text_fits() {
911        let renderer = TableRenderer::new();
912        let text = "Hi";
913        let font = Font::Helvetica;
914        let font_size = 12.0;
915        // A wide width means text fits unchanged
916        let result = renderer.truncate_text_to_width(text, 500.0, &font, font_size);
917        assert_eq!(result, "Hi");
918    }
919
920    #[test]
921    fn test_truncate_linear_overflow_adds_ellipsis() {
922        let renderer = TableRenderer::new();
923        let text = "This is a long sentence that will not fit";
924        let font = Font::Helvetica;
925        let font_size = 12.0;
926        let result = renderer.truncate_text_to_width(text, 40.0, &font, font_size);
927        assert!(
928            result.ends_with("..."),
929            "Expected ellipsis suffix, got: {result}"
930        );
931        assert!(result.len() < text.len());
932        let result_width = measure_text(&result, &font, font_size);
933        assert!(result_width <= 40.0, "Truncated text exceeds max_width");
934    }
935
936    #[test]
937    fn test_truncate_linear_unicode() {
938        let renderer = TableRenderer::new();
939        // Multi-byte UTF-8 characters: each Japanese kanji is 3 bytes
940        let text = "日本語テスト文字列";
941        let font = Font::Helvetica;
942        let font_size = 12.0;
943        let result = renderer.truncate_text_to_width(text, 50.0, &font, font_size);
944        // Result must be valid UTF-8 (no partial char splits)
945        assert!(std::str::from_utf8(result.as_bytes()).is_ok());
946        if result != text {
947            assert!(
948                result.ends_with("..."),
949                "Truncated unicode should end with ellipsis"
950            );
951        }
952        let result_width = measure_text(&result, &font, font_size);
953        assert!(result_width <= 50.0);
954    }
955
956    // ================== wrap_text_to_lines tests (Issue #131) ==================
957
958    #[test]
959    fn test_wrap_text_to_lines_no_wrap_needed() {
960        let renderer = TableRenderer::new();
961        let text = "Short text";
962        let max_width = 200.0;
963        let font = Font::Helvetica;
964        let font_size = 12.0;
965
966        let lines = renderer.wrap_text_to_lines(text, max_width, &font, font_size);
967        assert_eq!(lines.len(), 1);
968        assert_eq!(lines[0], "Short text");
969    }
970
971    #[test]
972    fn test_wrap_text_to_lines_simple_wrap() {
973        let renderer = TableRenderer::new();
974        let text = "This is a longer text that should wrap to multiple lines";
975        let max_width = 80.0; // Narrow width to force wrapping
976        let font = Font::Helvetica;
977        let font_size = 12.0;
978
979        let lines = renderer.wrap_text_to_lines(text, max_width, &font, font_size);
980        assert!(lines.len() > 1, "Text should wrap to multiple lines");
981
982        // Verify all lines fit within max_width
983        for line in &lines {
984            let line_width = measure_text(line, &font, font_size);
985            assert!(
986                line_width <= max_width + 1.0, // Small tolerance for floating point
987                "Line '{}' exceeds max_width (width: {}, max: {})",
988                line,
989                line_width,
990                max_width
991            );
992        }
993    }
994
995    #[test]
996    fn test_wrap_text_to_lines_preserves_newlines() {
997        let renderer = TableRenderer::new();
998        let text = "Line one\nLine two\nLine three";
999        let max_width = 200.0; // Wide enough for any single line
1000        let font = Font::Helvetica;
1001        let font_size = 12.0;
1002
1003        let lines = renderer.wrap_text_to_lines(text, max_width, &font, font_size);
1004        assert_eq!(lines.len(), 3);
1005        assert_eq!(lines[0], "Line one");
1006        assert_eq!(lines[1], "Line two");
1007        assert_eq!(lines[2], "Line three");
1008    }
1009
1010    #[test]
1011    fn test_wrap_text_to_lines_empty_input() {
1012        let renderer = TableRenderer::new();
1013        let text = "";
1014        let max_width = 100.0;
1015        let font = Font::Helvetica;
1016        let font_size = 12.0;
1017
1018        let lines = renderer.wrap_text_to_lines(text, max_width, &font, font_size);
1019        assert_eq!(lines.len(), 1);
1020        assert_eq!(lines[0], "");
1021    }
1022
1023    #[test]
1024    fn test_wrap_text_to_lines_single_word_too_long() {
1025        let renderer = TableRenderer::new();
1026        let text = "Supercalifragilisticexpialidocious";
1027        let max_width = 50.0; // Too narrow for the word
1028        let font = Font::Helvetica;
1029        let font_size = 12.0;
1030
1031        let lines = renderer.wrap_text_to_lines(text, max_width, &font, font_size);
1032        // Word should be broken across lines
1033        assert!(lines.len() >= 1, "Should produce at least one line");
1034
1035        // When we join all lines, we should get the original word
1036        let joined: String = lines.join("");
1037        assert_eq!(joined, text);
1038    }
1039
1040    #[test]
1041    fn test_wrap_text_to_lines_multiple_spaces() {
1042        let renderer = TableRenderer::new();
1043        let text = "Word   with   spaces";
1044        let max_width = 200.0;
1045        let font = Font::Helvetica;
1046        let font_size = 12.0;
1047
1048        let lines = renderer.wrap_text_to_lines(text, max_width, &font, font_size);
1049        assert_eq!(lines.len(), 1);
1050        // The implementation treats each space-separated segment as a word
1051        assert!(!lines[0].is_empty());
1052    }
1053
1054    #[test]
1055    fn test_wrap_text_to_lines_unicode() {
1056        let renderer = TableRenderer::new();
1057        let text = "日本語テキスト with English words";
1058        let max_width = 100.0;
1059        let font = Font::Helvetica;
1060        let font_size = 12.0;
1061
1062        let lines = renderer.wrap_text_to_lines(text, max_width, &font, font_size);
1063        // Should handle unicode without panic
1064        assert!(!lines.is_empty());
1065    }
1066}