Skip to main content

lopdf_table/
layout.rs

1//! Layout calculation for tables
2
3use crate::Result;
4use crate::constants::*;
5use crate::error::TableError;
6use crate::table::{ColumnWidth, Table};
7use tracing::{debug, trace};
8
9/// Calculated layout information for a table
10#[derive(Debug, Clone)]
11pub struct TableLayout {
12    pub column_widths: Vec<f32>,
13    pub row_heights: Vec<f32>,
14    pub total_width: f32,
15    pub total_height: f32,
16}
17
18fn cell_is_bold(cell: &crate::table::Cell) -> bool {
19    cell.style.as_ref().map(|s| s.bold).unwrap_or(false)
20}
21
22fn metrics_for_cell<'a>(
23    table: &'a Table,
24    cell: &crate::table::Cell,
25) -> Option<&'a dyn crate::font::FontMetrics> {
26    if cell_is_bold(cell) {
27        table
28            .bold_font_metrics
29            .as_ref()
30            .map(|m| m.as_ref())
31            .or(table.font_metrics.as_ref().map(|m| m.as_ref()))
32    } else {
33        table.font_metrics.as_ref().map(|m| m.as_ref())
34    }
35}
36
37/// Calculate the layout for a table
38pub fn calculate_layout(table: &Table) -> Result<TableLayout> {
39    table.validate()?;
40
41    debug!(
42        "Calculating layout for table with {} rows",
43        table.rows.len()
44    );
45
46    // Determine the available table width
47    let available_width = table.total_width.unwrap_or_else(|| {
48        // If no total width specified, calculate based on content
49        estimate_total_width(table)
50    });
51
52    // Calculate column widths based on specifications
53    let column_widths = if let Some(ref width_specs) = table.column_widths {
54        resolve_column_widths(width_specs, available_width, table)?
55    } else {
56        calculate_column_widths(table)?
57    };
58
59    // Calculate row heights (considering text wrapping if enabled)
60    let row_heights = calculate_row_heights(table, &column_widths)?;
61
62    // Calculate totals
63    let total_width = column_widths.iter().sum();
64    let total_height = row_heights.iter().sum();
65
66    trace!("Layout calculated: {}x{}", total_width, total_height);
67
68    Ok(TableLayout {
69        column_widths,
70        row_heights,
71        total_width,
72        total_height,
73    })
74}
75
76/// Estimate total table width based on content
77fn estimate_total_width(_table: &Table) -> f32 {
78    // Default to a reasonable page width minus margins
79    // Standard US Letter is 612 points wide, leave 50 points margin on each side
80    LETTER_WIDTH - (DEFAULT_MARGIN * 2.0)
81}
82
83/// Resolve column widths from specifications
84fn resolve_column_widths(
85    specs: &[ColumnWidth],
86    available_width: f32,
87    table: &Table,
88) -> Result<Vec<f32>> {
89    let mut resolved_widths = vec![0.0; specs.len()];
90    let mut total_fixed_width = 0.0;
91    let mut total_percentage = 0.0;
92    let mut auto_columns = Vec::new();
93
94    // First pass: calculate fixed widths and percentages
95    for (i, spec) in specs.iter().enumerate() {
96        match spec {
97            ColumnWidth::Pixels(width) => {
98                resolved_widths[i] = *width;
99                total_fixed_width += width;
100            }
101            ColumnWidth::Percentage(percent) => {
102                total_percentage += percent;
103            }
104            ColumnWidth::Auto => {
105                auto_columns.push(i);
106            }
107        }
108    }
109
110    // Calculate remaining width for auto columns
111    let percentage_width = available_width * (total_percentage / 100.0);
112    let remaining_width = available_width - total_fixed_width - percentage_width;
113
114    // Second pass: resolve percentage widths
115    for (i, spec) in specs.iter().enumerate() {
116        if let ColumnWidth::Percentage(percent) = spec {
117            resolved_widths[i] = available_width * (percent / 100.0);
118        }
119    }
120
121    // Third pass: distribute remaining width among auto columns
122    if !auto_columns.is_empty() {
123        if remaining_width > 0.0 {
124            // Calculate content-based proportions for auto columns
125            let mut auto_proportions = vec![0.0; auto_columns.len()];
126            let mut total_proportion = 0.0;
127
128            for (idx, &col) in auto_columns.iter().enumerate() {
129                // Estimate width based on content
130                let max_content_width = estimate_column_content_width(table, col);
131                auto_proportions[idx] = max_content_width;
132                total_proportion += max_content_width;
133            }
134
135            // Distribute remaining width proportionally
136            for (idx, &col) in auto_columns.iter().enumerate() {
137                if total_proportion > 0.0 {
138                    resolved_widths[col] =
139                        remaining_width * (auto_proportions[idx] / total_proportion);
140                } else {
141                    resolved_widths[col] = remaining_width / auto_columns.len() as f32;
142                }
143                // Ensure minimum width
144                resolved_widths[col] = resolved_widths[col].max(MIN_COLUMN_WIDTH);
145            }
146        } else {
147            // If no remaining width, give auto columns a minimum width
148            for &col in &auto_columns {
149                resolved_widths[col] = MIN_COLUMN_WIDTH;
150            }
151        }
152    }
153
154    trace!("Resolved column widths: {:?}", resolved_widths);
155    Ok(resolved_widths)
156}
157
158/// Estimate content width for a specific column
159fn estimate_column_content_width(table: &Table, col_idx: usize) -> f32 {
160    let mut max_width = 0.0;
161
162    for row in &table.rows {
163        if col_idx < row.cells.len() {
164            let cell = &row.cells[col_idx];
165            let font_size = cell
166                .style
167                .as_ref()
168                .and_then(|s| s.font_size)
169                .unwrap_or(table.style.default_font_size);
170
171            let estimated_width = if let Some(metrics) = metrics_for_cell(table, cell) {
172                crate::drawing_utils::estimate_text_width_with_metrics(
173                    &cell.content,
174                    font_size,
175                    metrics,
176                )
177            } else {
178                crate::drawing_utils::estimate_text_width(&cell.content, font_size)
179            };
180            max_width = f32::max(max_width, estimated_width);
181        }
182    }
183
184    // Add padding
185    let padding = table.style.padding.left + table.style.padding.right;
186    max_width + padding
187}
188
189/// Calculate automatic column widths based on content
190fn calculate_column_widths(table: &Table) -> Result<Vec<f32>> {
191    let col_count = table.column_count();
192    if col_count == 0 {
193        return Err(TableError::LayoutError("No columns in table".to_string()));
194    }
195
196    let mut max_widths = vec![0.0; col_count];
197
198    for row in &table.rows {
199        for (i, cell) in row.cells.iter().enumerate() {
200            if i >= col_count {
201                break;
202            }
203
204            let font_size = cell
205                .style
206                .as_ref()
207                .and_then(|s| s.font_size)
208                .unwrap_or(table.style.default_font_size);
209
210            let estimated_width = if let Some(metrics) = metrics_for_cell(table, cell) {
211                crate::drawing_utils::estimate_text_width_with_metrics(
212                    &cell.content,
213                    font_size,
214                    metrics,
215                )
216            } else {
217                crate::drawing_utils::estimate_text_width(&cell.content, font_size)
218            };
219
220            max_widths[i] = f32::max(max_widths[i], estimated_width);
221        }
222    }
223
224    // Add padding
225    let padding = table.style.padding.left + table.style.padding.right;
226    for width in &mut max_widths {
227        *width += padding;
228        // Ensure minimum width
229        *width = width.max(MIN_COLUMN_WIDTH);
230    }
231
232    trace!("Calculated column widths: {:?}", max_widths);
233    Ok(max_widths)
234}
235
236/// Compute image-driven content height for a single image using contain-fit.
237fn single_image_content_height(image: &crate::table::CellImage, available_width: f32) -> f32 {
238    if image.width_px == 0 || image.height_px == 0 {
239        return 0.0;
240    }
241    let aspect = image.aspect_ratio();
242    // Contain: scale to fit available width, preserving aspect ratio
243    let mut height = available_width / aspect;
244    // Cap at max_render_height_pts if set
245    if let Some(max_h) = image.max_render_height_pts {
246        height = height.min(max_h);
247    }
248    height
249}
250
251/// Compute image-driven content height for one or more images laid out side-by-side.
252fn images_content_height(images: &[crate::table::CellImage], available_width: f32) -> f32 {
253    if images.is_empty() {
254        return 0.0;
255    }
256    if images.len() == 1 {
257        return single_image_content_height(&images[0], available_width);
258    }
259    // Multiple images share width with gaps between them
260    const IMAGE_GAP: f32 = 4.0;
261    let total_gap = IMAGE_GAP * (images.len() as f32 - 1.0);
262    let slot_w = (available_width - total_gap) / images.len() as f32;
263    images
264        .iter()
265        .map(|img| single_image_content_height(img, slot_w))
266        .fold(0.0f32, f32::max)
267}
268
269/// Calculate row heights based on content
270fn calculate_row_heights(table: &Table, column_widths: &[f32]) -> Result<Vec<f32>> {
271    let mut heights = Vec::with_capacity(table.rows.len());
272
273    for row in &table.rows {
274        if let Some(height) = row.height {
275            heights.push(height);
276        } else {
277            // Calculate based on content
278            let mut max_height = 0.0;
279
280            for (i, cell) in row.cells.iter().enumerate() {
281                if i >= column_widths.len() {
282                    break;
283                }
284
285                let padding = cell
286                    .style
287                    .as_ref()
288                    .and_then(|s| s.padding.as_ref())
289                    .unwrap_or(&table.style.padding);
290
291                let font_size = cell
292                    .style
293                    .as_ref()
294                    .and_then(|s| s.font_size)
295                    .unwrap_or(table.style.default_font_size);
296
297                // Calculate available width for content
298                let available_width = column_widths[i] - padding.left - padding.right;
299
300                // Text-driven height
301                let text_height = if cell.text_wrap {
302                    if let Some(metrics) = metrics_for_cell(table, cell) {
303                        crate::text::calculate_wrapped_text_height_with_metrics(
304                            &cell.content,
305                            available_width,
306                            font_size,
307                            DEFAULT_LINE_HEIGHT_MULTIPLIER,
308                            metrics,
309                        )
310                    } else {
311                        crate::text::calculate_wrapped_text_height(
312                            &cell.content,
313                            available_width,
314                            font_size,
315                            DEFAULT_LINE_HEIGHT_MULTIPLIER,
316                        )
317                    }
318                } else if !cell.content.is_empty() {
319                    font_size_to_height(font_size)
320                } else {
321                    0.0
322                };
323
324                // Image-driven height
325                let img_height = images_content_height(&cell.images, available_width);
326
327                max_height = f32::max(max_height, f32::max(text_height, img_height));
328            }
329
330            // Add padding
331            max_height += table.style.padding.top + table.style.padding.bottom;
332            // Ensure minimum height
333            max_height = max_height.max(font_size_to_height(table.style.default_font_size));
334
335            heights.push(max_height);
336        }
337    }
338
339    trace!("Calculated row heights: {:?}", heights);
340    Ok(heights)
341}
342
343/// Convert font size to line height
344fn font_size_to_height(font_size: f32) -> f32 {
345    // Standard line height is typically 1.2x font size
346    font_size * DEFAULT_LINE_HEIGHT_MULTIPLIER
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::table::{Cell, Row};
353
354    #[test]
355    fn test_layout_calculation() {
356        let table = Table::new()
357            .add_row(Row::new(vec![
358                Cell::new("Short"),
359                Cell::new("Medium text"),
360                Cell::new("This is a longer piece of text"),
361            ]))
362            .add_row(Row::new(vec![
363                Cell::new("A"),
364                Cell::new("B"),
365                Cell::new("C"),
366            ]));
367
368        let layout = calculate_layout(&table).unwrap();
369
370        assert_eq!(layout.column_widths.len(), 3);
371        assert_eq!(layout.row_heights.len(), 2);
372        assert!(layout.total_width > 0.0);
373        assert!(layout.total_height > 0.0);
374    }
375}