1use 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
27const WORKBOOK: &str = "xl/workbook.xml";
29const WORKBOOK_RELS: &str = "xl/_rels/workbook.xml.rels";
30const MAX_PART: u64 = 4 << 20;
32
33pub 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
45pub 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
71fn 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
80fn 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
99fn 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
127fn 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
169pub 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 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
234fn 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
258fn 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
276fn 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 & 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<b>AB&bogus;"), "a<b>AB&bogus;");
348 }
349}
350
351pub 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 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 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
422fn 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
495fn 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
505fn 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
515fn 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
539fn 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
565fn 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#[derive(Clone, Copy)]
576enum ExcelColType {
577 Int64,
578 Float64,
579 Boolean,
580 Utf8,
581 Date,
582 Datetime,
583}