evobench_tools/stats_tables/tables/
excel_table_view.rs

1//! Convert a sequence of `TableView`s to workbooks in an Excel file
2
3use std::{borrow::Cow, path::Path};
4
5use anyhow::{Context, Result, anyhow};
6use cj_path_util::unix::polyfill::add_extension;
7use rust_xlsxwriter::{Color, Format, FormatAlign, workbook::Workbook};
8
9use super::table_view::{ColumnFormatting, Highlight, TableView, Unit};
10use crate::io_utils::div::xrename;
11
12/// How many characters to add to the automatic column width
13/// calculation to try to avoid setting widths too small to accomodate
14/// the strings in the cells.
15const WIDTH_SAFETY_MARGIN_CHARS: f64 = 2.0;
16
17pub fn excel_file_write<'t>(
18    tables: impl IntoIterator<Item = &'t (dyn TableView + 't)>,
19    file: &Path,
20) -> Result<()> {
21    let mut workbook = Workbook::new();
22
23    for table in tables {
24        let worksheet = workbook.add_worksheet();
25        worksheet.set_name(table.table_name()).with_context(|| {
26            anyhow!(
27                "trying to use table name as worksheet name: {:?}",
28                table.table_name()
29            )
30        })?;
31
32        let _titles = table.table_view_header();
33        let titles = (*_titles).as_ref();
34
35        // Our own max width tracking, in characters
36        let mut column_widths: Vec<usize> = titles.iter().map(|_| 1).collect();
37
38        let mut rownum = 0;
39
40        {
41            // How many lines do our labels take max?
42            let mut num_lines = 1;
43            for (i, (label, unit, _column_formatting)) in titles.iter().enumerate() {
44                // write cell
45                {
46                    let colnum =
47                        u16::try_from(i).with_context(|| anyhow!("too many columns for excel"))?;
48                    let perhaps_unit: Cow<str> = match unit {
49                        Unit::None => "".into(),
50                        Unit::DimensionLess => "".into(),
51                        Unit::Count => "\n(count)".into(),
52                        Unit::ViewType(unit) => format!("\n({unit})").into(),
53                    };
54                    let val = format!("{label}{perhaps_unit}");
55                    {
56                        let max_width = val
57                            .split('\n')
58                            .map(|s| s.chars().count())
59                            .max()
60                            .unwrap_or(0);
61                        column_widths[i] = column_widths[i].max(max_width);
62                    }
63                    let format = Format::new().set_bold();
64                    worksheet
65                        .write_with_format(rownum, colnum, &val, &format)
66                        .with_context(|| anyhow!("write title value {val:?}"))?;
67                }
68
69                // update num_lines
70                {
71                    let label_linebreaks = label.chars().filter(|c| *c == '\n').count();
72                    let unit_lines = match unit {
73                        Unit::None => 0,
74                        Unit::DimensionLess => 0,
75                        Unit::Count => 1,
76                        Unit::ViewType(_) => 1,
77                    };
78                    num_lines = num_lines.max(label_linebreaks + 1 + unit_lines);
79                }
80            }
81
82            let height = (num_lines * 15) as f64;
83            worksheet
84                .set_row_height(rownum, height)
85                .with_context(|| anyhow!("setting height of row {rownum} to height {height}"))?;
86        }
87
88        for row in table.table_view_body() {
89            rownum += 1;
90            for (i, (val, highlight)) in row.iter().enumerate() {
91                let column_formatting: ColumnFormatting = titles[i].2;
92                let colnum =
93                    u16::try_from(i).with_context(|| anyhow!("too many columns for excel"))?;
94
95                let mut format = Format::new();
96                match column_formatting {
97                    ColumnFormatting::Spacer => (),
98                    ColumnFormatting::Number => {
99                        format = format.set_align(FormatAlign::Right);
100                    }
101                    ColumnFormatting::String { width_chars: _ } => (),
102                }
103                match highlight {
104                    Highlight::Spacer => (),
105                    Highlight::Neutral => (),
106                    Highlight::Red => {
107                        format = format.set_font_color(Color::Red);
108                    }
109                    Highlight::Green => {
110                        format = format.set_background_color(Color::Green);
111                    }
112                }
113
114                {
115                    let max_width = val
116                        .split('\n')
117                        .map(|s| s.chars().count())
118                        .max()
119                        .unwrap_or(0);
120                    column_widths[i] = column_widths[i].max(max_width);
121                }
122
123                worksheet
124                    .write_with_format(rownum, colnum, val.as_ref(), &format)
125                    .with_context(|| anyhow!("write value {val:?}"))?;
126            }
127        }
128
129        // Set column widths
130        {
131            // Note: newer versions of rust_xlsxwriter have
132            // `autofit_to_max_width(&mut self, max_autofit_width: u16)`;
133            // with this version, instead first autofit the whole sheet,
134            // then fix other columns.
135            //worksheet.autofit();
136            // Actually that works very badly for our number (for
137            // LibreOffice, anyway). So use our own character
138            // counting.
139            for (i, num_chars) in column_widths.iter().enumerate() {
140                let colnum =
141                    u16::try_from(i).with_context(|| anyhow!("too many columns for excel"))?;
142                let width = *num_chars as f64 + WIDTH_SAFETY_MARGIN_CHARS;
143                worksheet.set_column_width(colnum, width).with_context(|| {
144                    anyhow!("setting column width on column {colnum} to {width}")
145                })?;
146            }
147
148            for (i, (_label, _unit, column_formatting)) in titles.as_ref().iter().enumerate() {
149                let colnum =
150                    u16::try_from(i).with_context(|| anyhow!("too many columns for excel"))?;
151
152                match column_formatting {
153                    ColumnFormatting::Spacer => {
154                        worksheet
155                            .set_column_width(colnum, 3.0)
156                            .with_context(|| anyhow!("setting column width on column {colnum}"))?;
157                    }
158                    ColumnFormatting::Number => {
159                        // Alignment already done while writing the cells.
160
161                        // Autofit already done above. Newer versions of
162                        // rust_xlsxwriter instead:
163                        // worksheet.autofit_to_max_width(max_autofit_width:
164                        // u16).
165                    }
166                    ColumnFormatting::String { width_chars } => {
167                        if let Some(width_chars) = width_chars {
168                            worksheet
169                                .set_column_width(colnum, *width_chars)
170                                .with_context(|| {
171                                    anyhow!("setting column width on column {colnum}")
172                                })?;
173                        }
174                    }
175                }
176            }
177        }
178    }
179
180    let file_tmp =
181        add_extension(file, "tmp").ok_or_else(|| anyhow!("path misses a filename: {file:?}"))?;
182    workbook
183        .save(&file_tmp)
184        .with_context(|| anyhow!("saving to file {file_tmp:?}"))?;
185    xrename(&file_tmp, &file)?;
186
187    Ok(())
188}