Skip to main content

datui_lib/formats/
excel.rs

1//! Excel workbooks: the sheets of one, listed on the home screen and summed up on the
2//! Info panel.
3//!
4//! A sheet is read whole by calamine when it is opened (see [`read`]). What is said of
5//! the others comes from what that open read anyway, or from the start of each sheet:
6//! an `.xlsx` sheet declares its range (`<dimension ref="A1:D100"/>`) before its
7//! cells, so its size costs no read of them.
8//!
9//! The home screen lists an `.xlsx` or `.xlsm` workbook's sheets from its directory and
10//! `xl/workbook.xml`, a few KB however large the workbook. An `.xls` or `.xlsb` file
11//! keeps its sheet names in a binary stream that only a read of the workbook finds, so
12//! it opens its first sheet and `--table` names another.
13
14use std::io::Read;
15use std::path::Path;
16
17use calamine::{Data, Dimensions, Range, Reader, Sheets, open_workbook_auto};
18use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
19use color_eyre::Result;
20use polars::prelude::*;
21
22use crate::formats::model_files::MetaValue;
23use crate::formats::sqlite::Table;
24use crate::formats::text_formats::{Detail, count};
25use crate::{FileFormat, OpenOptions};
26
27/// The workbook part of an `.xlsx` or `.xlsm` file, and its relationships.
28const WORKBOOK: &str = "xl/workbook.xml";
29const WORKBOOK_RELS: &str = "xl/_rels/workbook.xml.rels";
30/// The most of either part read: a workbook of thousands of sheets is still far less.
31const MAX_PART: u64 = 4 << 20;
32
33/// Whether `head`, the first bytes of `file`, begin a workbook whose sheets can be listed
34/// without reading it: a zip file holding `xl/workbook.xml`.
35pub fn is_listable(head: &[u8], file: Option<&Path>) -> bool {
36    head.starts_with(b"PK\x03\x04")
37        && file.is_some_and(|file| {
38            std::fs::File::open(file)
39                .ok()
40                .and_then(|f| ::zip::ZipArchive::new(f).ok())
41                .is_some_and(|zip| zip.index_for_name(WORKBOOK).is_some())
42        })
43}
44
45/// The sheets of the workbook at `path`, in the workbook's order, for the home screen
46/// and `--table`. A hidden sheet is the workbook's own, listed after Ctrl+A; a chart
47/// sheet holds no cells and is left out.
48pub fn sheets(path: &Path) -> Result<Vec<Table>> {
49    let mut zip = ::zip::ZipArchive::new(std::fs::File::open(path)?)?;
50    let workbook = part(&mut zip, WORKBOOK)?;
51    let rels = part(&mut zip, WORKBOOK_RELS).unwrap_or_default();
52    let charts: Vec<String> = tags(&rels, "Relationship")
53        .into_iter()
54        .filter(|attrs| attr(attrs, "Type").is_some_and(|t| t.ends_with("/chartsheet")))
55        .filter_map(|attrs| attr(&attrs, "Id"))
56        .collect();
57    Ok(tags(&workbook, "sheet")
58        .into_iter()
59        .filter(|attrs| attr(attrs, "id").is_none_or(|id| !charts.contains(&id)))
60        .filter_map(|attrs| {
61            Some(Table {
62                name: attr(&attrs, "name")?,
63                kind: "worksheet".to_string(),
64                internal: attr(&attrs, "state").is_some_and(|s| s != "visible"),
65                columns: Vec::new(),
66            })
67        })
68        .collect())
69}
70
71/// One part of a zip file as text.
72fn part(zip: &mut ::zip::ZipArchive<std::fs::File>, name: &str) -> Result<String> {
73    let mut text = String::new();
74    zip.by_name(name)?
75        .take(MAX_PART)
76        .read_to_string(&mut text)?;
77    Ok(text)
78}
79
80/// The attributes of each element named `name` (with or without a namespace prefix) in
81/// `xml`, as written: `<sheet name="Sales" sheetId="1" r:id="rId1"/>`.
82fn tags<'a>(xml: &'a str, name: &str) -> Vec<Vec<(&'a str, String)>> {
83    let mut found = Vec::new();
84    let mut rest = xml;
85    while let Some(at) = rest.find('<') {
86        rest = &rest[at + 1..];
87        let end = rest.find('>').unwrap_or(rest.len());
88        let tag = &rest[..end];
89        rest = &rest[end..];
90        let element = tag.split(|c: char| c.is_whitespace() || c == '/').next();
91        let local = element.map(|e| e.rsplit(':').next().unwrap_or(e));
92        if local == Some(name) {
93            found.push(attributes(&tag[element.map_or(0, str::len)..]));
94        }
95    }
96    found
97}
98
99/// The attributes of a tag, after its name, with entities resolved. A name keeps no
100/// prefix: `r:id` is `id`.
101fn attributes(text: &str) -> Vec<(&str, String)> {
102    let mut attrs = Vec::new();
103    let mut rest = text;
104    while let Some(eq) = rest.find('=') {
105        let key = rest[..eq].trim().trim_start_matches('/');
106        let key = key.rsplit(':').next().unwrap_or(key);
107        let value = rest[eq + 1..].trim_start();
108        let Some(quote) = value.chars().next().filter(|q| *q == '"' || *q == '\'') else {
109            break;
110        };
111        let Some(close) = value[1..].find(quote) else {
112            break;
113        };
114        attrs.push((key, unescape(&value[1..1 + close])));
115        rest = &value[close + 2..];
116    }
117    attrs
118}
119
120fn attr(attrs: &[(&str, String)], key: &str) -> Option<String> {
121    attrs
122        .iter()
123        .find(|(k, _)| *k == key)
124        .map(|(_, v)| v.clone())
125}
126
127/// XML's five entities and character references.
128fn unescape(text: &str) -> String {
129    if !text.contains('&') {
130        return text.to_string();
131    }
132    let mut out = String::with_capacity(text.len());
133    let mut rest = text;
134    while let Some(at) = rest.find('&') {
135        out.push_str(&rest[..at]);
136        rest = &rest[at..];
137        let Some(end) = rest.find(';') else {
138            break;
139        };
140        let entity = &rest[1..end];
141        let ch = match entity {
142            "amp" => Some('&'),
143            "lt" => Some('<'),
144            "gt" => Some('>'),
145            "quot" => Some('"'),
146            "apos" => Some('\''),
147            _ => entity
148                .strip_prefix("#x")
149                .map(|hex| u32::from_str_radix(hex, 16))
150                .or_else(|| entity.strip_prefix('#').map(str::parse))
151                .and_then(|n| n.ok())
152                .and_then(char::from_u32),
153        };
154        match ch {
155            Some(ch) => {
156                out.push(ch);
157                rest = &rest[end + 1..];
158            }
159            None => {
160                out.push('&');
161                rest = &rest[1..];
162            }
163        }
164    }
165    out.push_str(rest);
166    out
167}
168
169/// The Excel tab of the Info panel for a workbook whose sheet `opened` was read as
170/// `range`: each sheet's range, the opened one's from its cells and the others' as
171/// their files declare it or as the open already parsed them.
172pub fn detail<RS: std::io::Read + std::io::Seek>(
173    workbook: &mut Sheets<RS>,
174    opened: &str,
175    range: &Range<Data>,
176) -> Detail {
177    let meta: Vec<calamine::Sheet> = workbook.sheets_metadata().to_vec();
178    let mut list = Vec::with_capacity(meta.len());
179    let mut tables = Vec::new();
180    let mut hidden = 0u64;
181    let mut charts = 0u64;
182    for sheet in &meta {
183        let mut said = Vec::new();
184        let other = match sheet.typ {
185            calamine::SheetType::WorkSheet => None,
186            calamine::SheetType::ChartSheet => Some("chart sheet"),
187            calamine::SheetType::DialogSheet => Some("dialog sheet"),
188            calamine::SheetType::MacroSheet => Some("macro sheet"),
189            calamine::SheetType::Vba => Some("VBA module"),
190        };
191        if other.is_none() {
192            tables.push(sheet.name.clone());
193        }
194        if let Some(other) = other {
195            charts += 1;
196            said.push(other.to_string());
197        } else if sheet.name == opened {
198            said.push(size(range.start().zip(range.end())));
199            said.push("opened".to_string());
200        } else if let Some(dims) =
201            // A sheet that declares no range reads as the default one.
202            declared(workbook, &sheet.name).filter(|d| *d != Dimensions::default())
203        {
204            said.push(size(Some((dims.start, dims.end))));
205        }
206        if sheet.visible != calamine::SheetVisible::Visible {
207            hidden += 1;
208            said.push("hidden".to_string());
209        }
210        list.push((sheet.name.clone(), MetaValue::Text(said.join(", "))));
211    }
212    let mut first = count(meta.len() as u64, "worksheet", "worksheets");
213    let middot = crate::glyphs::get().middot;
214    if hidden > 0 {
215        first.push_str(&format!(" {middot} {hidden} hidden"));
216    }
217    if charts > 0 {
218        first.push_str(&format!(
219            " {middot} {}",
220            count(charts, "without cells", "without cells")
221        ));
222    }
223    Detail {
224        tab: crate::formats::text_formats::tab(FileFormat::Excel),
225        lines: vec![first, format!("Opened: {opened}")],
226        list_title: "Worksheets",
227        list,
228        tables,
229        table: Some(opened.to_string()),
230        ..Default::default()
231    }
232}
233
234/// The range a sheet other than the one opened says it covers, read no further than
235/// its cells: an `.xlsx` or `.xlsb` sheet's declared range, or an `.xls` or `.ods`
236/// sheet as the open already parsed it.
237fn declared<RS: std::io::Read + std::io::Seek>(
238    workbook: &mut Sheets<RS>,
239    name: &str,
240) -> Option<Dimensions> {
241    match workbook {
242        Sheets::Xlsx(xlsx) => xlsx
243            .worksheet_cells_reader(name)
244            .ok()
245            .map(|r| r.dimensions()),
246        Sheets::Xlsb(xlsb) => xlsb
247            .worksheet_cells_reader(name)
248            .ok()
249            .map(|r| r.dimensions()),
250        Sheets::Xls(_) | Sheets::Ods(_) => {
251            let range = workbook.worksheet_range(name).ok()?;
252            let (start, end) = range.start().zip(range.end())?;
253            Some(Dimensions { start, end })
254        }
255    }
256}
257
258/// A range as a sheet names it, and its size: `A1:D100, 100 × 4`. Empty: `empty`.
259fn size(range: Option<((u32, u32), (u32, u32))>) -> String {
260    let Some(((r0, c0), (r1, c1))) = range.filter(|((r0, c0), (r1, c1))| r1 >= r0 && c1 >= c0)
261    else {
262        return "empty".to_string();
263    };
264    let times = crate::glyphs::get().times;
265    format!(
266        "{}{}:{}{}, {} {times} {}",
267        column(c0),
268        r0 + 1,
269        column(c1),
270        r1 + 1,
271        crate::numfmt::group_chrome((r1 - r0 + 1) as usize),
272        crate::numfmt::group_chrome((c1 - c0 + 1) as usize)
273    )
274}
275
276/// A 0-based column as a sheet letters it: 0 is `A`, 26 is `AA`.
277fn column(mut c: u32) -> String {
278    let mut letters = Vec::new();
279    loop {
280        letters.push(b'A' + (c % 26) as u8);
281        if c < 26 {
282            break;
283        }
284        c = c / 26 - 1;
285    }
286    letters.reverse();
287    String::from_utf8(letters).unwrap_or_default()
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn columns_are_lettered_as_a_sheet_letters_them() {
296        for (c, letters) in [
297            (0, "A"),
298            (3, "D"),
299            (25, "Z"),
300            (26, "AA"),
301            (701, "ZZ"),
302            (702, "AAA"),
303        ] {
304            assert_eq!(column(c), letters, "{c}");
305        }
306        assert_eq!(
307            size(Some(((0, 0), (99, 3)))),
308            "A1:D100, 100 × 4".replace('×', crate::glyphs::get().times)
309        );
310        assert_eq!(size(None), "empty");
311    }
312
313    #[test]
314    fn sheets_are_read_from_the_workbook_part() {
315        let workbook = r#"<?xml version="1.0"?>
316<workbook xmlns:r="x"><sheets>
317<sheet name="Sales &amp; Costs" sheetId="1" r:id="rId1"/>
318<sheet name='2023' sheetId="2" r:id="rId2" state="hidden"/>
319<x:sheet name="Chart" sheetId="3" r:id="rId3"/>
320</sheets></workbook>"#;
321        let rels = r#"<Relationships>
322<Relationship Id="rId1" Type="http://x/worksheet" Target="worksheets/sheet1.xml"/>
323<Relationship Id="rId2" Type="http://x/worksheet" Target="worksheets/sheet2.xml"/>
324<Relationship Id="rId3" Type="http://x/chartsheet" Target="chartsheets/sheet1.xml"/>
325</Relationships>"#;
326        let dir = tempfile::tempdir().unwrap();
327        let path = dir.path().join("book.xlsx");
328        let mut zip = ::zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
329        for (name, text) in [(WORKBOOK, workbook), (WORKBOOK_RELS, rels)] {
330            zip.start_file(name, ::zip::write::SimpleFileOptions::default())
331                .unwrap();
332            std::io::Write::write_all(&mut zip, text.as_bytes()).unwrap();
333        }
334        zip.finish().unwrap();
335        let head = std::fs::read(&path).unwrap();
336        assert!(is_listable(&head, Some(&path)));
337        let sheets = sheets(&path).unwrap();
338        let named: Vec<(&str, bool)> = sheets
339            .iter()
340            .map(|t| (t.name.as_str(), t.internal))
341            .collect();
342        assert_eq!(named, [("Sales & Costs", false), ("2023", true)]);
343    }
344
345    #[test]
346    fn entities_resolve() {
347        assert_eq!(unescape("a&lt;b&gt;&#65;&#x42;&bogus;"), "a<b>AB&bogus;");
348    }
349}
350
351/// One sheet of a workbook (xls, xlsx, xlsm, xlsb), read whole by calamine, with the
352/// workbook's Excel tab for the Info panel. The sheet is the one `options.table`
353/// (`--table`) names, or by 0-based index when no sheet is so named.
354pub fn read(path: &Path, options: &OpenOptions) -> Result<(LazyFrame, Detail)> {
355    let mut workbook =
356        open_workbook_auto(path).map_err(|e| color_eyre::eyre::eyre!("Excel: {}", e))?;
357    let sheet_names = workbook.sheet_names().to_vec();
358    if sheet_names.is_empty() {
359        return Err(color_eyre::eyre::eyre!("Excel file has no worksheets"));
360    }
361    // Named so a bad --table says what to ask for instead: "0 'Sales', 1 'Summary'".
362    let sheets_on_offer = || {
363        sheet_names
364            .iter()
365            .enumerate()
366            .map(|(i, name)| format!("{} '{}'", i, name))
367            .collect::<Vec<_>>()
368            .join(", ")
369    };
370    // A sheet's name before an index: the home screen names a sheet called `2023`.
371    let opened = match options.table.as_deref() {
372        None => sheet_names[0].clone(),
373        Some(name) if sheet_names.iter().any(|n| n == name) => name.to_string(),
374        Some(sheet_sel) => match sheet_sel.parse::<usize>() {
375            Ok(idx) => sheet_names.get(idx).cloned().ok_or_else(|| {
376                color_eyre::eyre::eyre!(
377                    "Excel: no worksheet at index {}; this file has: {}",
378                    idx,
379                    sheets_on_offer()
380                )
381            })?,
382            Err(_) => {
383                return Err(color_eyre::eyre::eyre!(
384                    "Excel: no worksheet named '{}'; this file has: {}",
385                    sheet_sel,
386                    sheets_on_offer()
387                ));
388            }
389        },
390    };
391    let range = workbook
392        .worksheet_range(&opened)
393        .map_err(|e| color_eyre::eyre::eyre!("Excel: {}", e))?;
394    let detail = crate::formats::excel::detail(&mut workbook, &opened, &range);
395    drop(workbook);
396    let rows: Vec<Vec<Data>> = range.rows().map(|r| r.to_vec()).collect();
397    if rows.is_empty() {
398        let empty_df = DataFrame::empty();
399        return Ok((empty_df.lazy(), detail));
400    }
401    let headers: Vec<String> = rows[0]
402        .iter()
403        .map(|c| calamine::DataType::as_string(c).unwrap_or_else(|| c.to_string()))
404        .collect();
405    let n_cols = headers.len();
406    let mut series_vec = Vec::with_capacity(n_cols);
407    for (col_idx, header) in headers.iter().enumerate() {
408        let col_cells: Vec<Option<&Data>> = rows[1..].iter().map(|row| row.get(col_idx)).collect();
409        let inferred = excel_infer_column_type(&col_cells);
410        let name = if header.is_empty() {
411            format!("column_{}", col_idx + 1)
412        } else {
413            header.clone()
414        };
415        let series = excel_column_to_series(name.as_str(), &col_cells, inferred)?;
416        series_vec.push(series.into());
417    }
418    let df = DataFrame::new_infer_height(series_vec)?;
419    Ok((df.lazy(), detail))
420}
421
422/// Infers column type: prefers Int64 for whole-number floats; infers Date/Datetime for
423/// calamine DateTime/DateTimeIso or for string columns that parse as ISO date/datetime.
424fn excel_infer_column_type(cells: &[Option<&Data>]) -> ExcelColType {
425    use calamine::DataType as CalamineTrait;
426    let mut has_string = false;
427    let mut has_float = false;
428    let mut has_int = false;
429    let mut has_bool = false;
430    let mut has_datetime = false;
431    for cell in cells.iter().flatten() {
432        if CalamineTrait::is_string(*cell) {
433            has_string = true;
434            break;
435        }
436        if CalamineTrait::is_float(*cell)
437            || CalamineTrait::is_datetime(*cell)
438            || CalamineTrait::is_datetime_iso(*cell)
439        {
440            has_float = true;
441        }
442        if CalamineTrait::is_int(*cell) {
443            has_int = true;
444        }
445        if CalamineTrait::is_bool(*cell) {
446            has_bool = true;
447        }
448        if CalamineTrait::is_datetime(*cell) || CalamineTrait::is_datetime_iso(*cell) {
449            has_datetime = true;
450        }
451    }
452    if has_string {
453        let any_parsed = cells
454            .iter()
455            .flatten()
456            .any(|c| excel_cell_to_naive_datetime(c).is_some());
457        let all_non_empty_parse = cells
458            .iter()
459            .flatten()
460            .all(|c| CalamineTrait::is_empty(*c) || excel_cell_to_naive_datetime(c).is_some());
461        if any_parsed && all_non_empty_parse {
462            if excel_parsed_cells_all_midnight(cells) {
463                ExcelColType::Date
464            } else {
465                ExcelColType::Datetime
466            }
467        } else {
468            ExcelColType::Utf8
469        }
470    } else if has_int {
471        ExcelColType::Int64
472    } else if has_datetime {
473        if excel_parsed_cells_all_midnight(cells) {
474            ExcelColType::Date
475        } else {
476            ExcelColType::Datetime
477        }
478    } else if has_float {
479        let all_whole = cells.iter().flatten().all(|cell| {
480            cell.as_f64()
481                .is_none_or(|f| f.is_finite() && (f - f.trunc()).abs() < 1e-10)
482        });
483        if all_whole {
484            ExcelColType::Int64
485        } else {
486            ExcelColType::Float64
487        }
488    } else if has_bool {
489        ExcelColType::Boolean
490    } else {
491        ExcelColType::Utf8
492    }
493}
494
495/// True if every cell that parses as datetime has time 00:00:00.
496fn excel_parsed_cells_all_midnight(cells: &[Option<&Data>]) -> bool {
497    let midnight = NaiveTime::from_hms_opt(0, 0, 0).expect("valid time");
498    cells
499        .iter()
500        .flatten()
501        .filter_map(|c| excel_cell_to_naive_datetime(c))
502        .all(|dt| dt.time() == midnight)
503}
504
505/// Converts a calamine cell to NaiveDateTime (Excel serial, DateTimeIso, or parseable string).
506fn excel_cell_to_naive_datetime(cell: &Data) -> Option<NaiveDateTime> {
507    use calamine::DataType;
508    if let Some(dt) = cell.as_datetime() {
509        return Some(dt);
510    }
511    let s = cell.get_datetime_iso().or_else(|| cell.get_string())?;
512    parse_naive_datetime_str(s)
513}
514
515/// Parses an ISO-style date/datetime string; tries FORMATS in order.
516fn parse_naive_datetime_str(s: &str) -> Option<NaiveDateTime> {
517    let s = s.trim();
518    if s.is_empty() {
519        return None;
520    }
521    const FORMATS: &[&str] = &[
522        "%Y-%m-%dT%H:%M:%S%.f",
523        "%Y-%m-%dT%H:%M:%S",
524        "%Y-%m-%d %H:%M:%S%.f",
525        "%Y-%m-%d %H:%M:%S",
526        "%Y-%m-%d",
527    ];
528    for fmt in FORMATS {
529        if let Ok(dt) = NaiveDateTime::parse_from_str(s, fmt) {
530            return Some(dt);
531        }
532    }
533    if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
534        return Some(d.and_hms_opt(0, 0, 0).expect("midnight"));
535    }
536    None
537}
538
539/// Build a Polars Series from a column of calamine cells using the inferred type.
540fn excel_column_to_series(
541    name: &str,
542    cells: &[Option<&Data>],
543    col_type: ExcelColType,
544) -> Result<Series> {
545    use calamine::DataType as CalamineTrait;
546    use polars::datatypes::TimeUnit;
547    let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid date");
548    let series = match col_type {
549        ExcelColType::Int64 => values(name, cells, |c| c.as_i64()),
550        ExcelColType::Float64 => values(name, cells, |c| c.as_f64()),
551        ExcelColType::Boolean => values(name, cells, |c| c.get_bool()),
552        ExcelColType::Utf8 => values(name, cells, |c| c.as_string()),
553        ExcelColType::Date => values(name, cells, |c| {
554            excel_cell_to_naive_datetime(c).map(|dt| (dt.date() - epoch).num_days() as i32)
555        })
556        .cast(&DataType::Date)?,
557        ExcelColType::Datetime => values(name, cells, |c| {
558            excel_cell_to_naive_datetime(c).map(|dt| dt.and_utc().timestamp_micros())
559        })
560        .cast(&DataType::Datetime(TimeUnit::Microseconds, None))?,
561    };
562    Ok(series)
563}
564
565/// The column of `cells` as `get` reads each, empty cells null.
566fn values<T>(name: &str, cells: &[Option<&Data>], get: impl Fn(&Data) -> Option<T>) -> Series
567where
568    Series: NamedFrom<Vec<Option<T>>, [Option<T>]>,
569{
570    let values: Vec<Option<T>> = cells.iter().map(|c| c.and_then(&get)).collect();
571    Series::new(name.into(), values)
572}
573
574/// Inferred type for an Excel column (preserves numbers, bools, dates; avoids stringifying).
575#[derive(Clone, Copy)]
576enum ExcelColType {
577    Int64,
578    Float64,
579    Boolean,
580    Utf8,
581    Date,
582    Datetime,
583}