diff --git a/.gitignore b/.gitignore
index f84ace4e..c3cb32df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,7 @@ __pycache__/
# wasm-pack build output: pkg/ is the npm package, www/pkg/ the demo site copy
/wasm/pkg/
/wasm/www/pkg/
+# cargo-fuzz working dirs: seeds live in fuzz/seeds/ and are checked in
+/fuzz/corpus/
+/fuzz/artifacts/
+/fuzz/coverage/
diff --git a/Cargo.lock b/Cargo.lock
index a01c9140..b7da1011 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -91,7 +91,6 @@ dependencies = [
name = "anydoc"
version = "0.1.9"
dependencies = [
- "calamine",
"cfb",
"csv",
"encoding_rs",
@@ -134,16 +133,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "atoi_simd"
-version = "0.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44"
-dependencies = [
- "debug_unsafe",
- "rustversion",
-]
-
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -195,30 +184,6 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
-[[package]]
-name = "byteorder"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
-
-[[package]]
-name = "calamine"
-version = "0.36.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fa68281b1a76b54a62156474adb06bb380a67e07dd60656e3217152b42183f3"
-dependencies = [
- "atoi_simd",
- "byteorder",
- "chrono",
- "codepage",
- "encoding_rs",
- "fast-float2",
- "log",
- "quick-xml",
- "serde",
- "zip",
-]
-
[[package]]
name = "cbc"
version = "0.1.2"
@@ -274,7 +239,6 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
- "serde",
"windows-link",
]
@@ -288,15 +252,6 @@ dependencies = [
"inout",
]
-[[package]]
-name = "codepage"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4"
-dependencies = [
- "encoding_rs",
-]
-
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -433,12 +388,6 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f"
-[[package]]
-name = "debug_unsafe"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2"
-
[[package]]
name = "defmt"
version = "1.1.1"
@@ -566,12 +515,6 @@ dependencies = [
"windows-sys",
]
-[[package]]
-name = "fast-float2"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55"
-
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1186,7 +1129,6 @@ version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
- "encoding_rs",
"memchr",
]
diff --git a/Cargo.toml b/Cargo.toml
index 04ed82f8..cca4e47d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,7 +7,7 @@ exclude = ["fuzz"]
name = "anydoc"
version = "0.1.9"
edition = "2024"
-# Edition 2024 needs 1.85; zip and calamine both raise it to 1.88.
+# Edition 2024 needs 1.85; zip raises it to 1.88.
rust-version = "1.88"
description = "Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown"
license = "MIT"
@@ -22,7 +22,6 @@ insta = "1"
sha2 = "0.11"
[dependencies]
-calamine = { version = "0.36.1", features = ["dates"] }
cfb = "0.14.0"
csv = "1.4.0"
flate2 = "1"
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
index 09c8896e..b8ea2618 100644
--- a/fuzz/Cargo.toml
+++ b/fuzz/Cargo.toml
@@ -10,6 +10,8 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
anydoc = { path = ".." }
+cfb = "0.14.0"
+zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
[[bin]]
name = "docx"
@@ -64,3 +66,21 @@ name = "pdf"
path = "fuzz_targets/pdf.rs"
test = false
doc = false
+
+[[bin]]
+name = "xls"
+path = "fuzz_targets/xls.rs"
+test = false
+doc = false
+
+[[bin]]
+name = "xlsb"
+path = "fuzz_targets/xlsb.rs"
+test = false
+doc = false
+
+[[bin]]
+name = "numfmt"
+path = "fuzz_targets/numfmt.rs"
+test = false
+doc = false
diff --git a/fuzz/README.md b/fuzz/README.md
new file mode 100644
index 00000000..9d84efff
--- /dev/null
+++ b/fuzz/README.md
@@ -0,0 +1,12 @@
+# Fuzzing
+
+ cargo +nightly fuzz run xlsx fuzz/corpus/xlsx fuzz/seeds/xlsx
+
+`fuzz/seeds/` holds checked-in starting inputs; `fuzz/corpus/` is the working
+directory libfuzzer writes to and is not checked in.
+
+`xls`, `xlsb` and `numfmt` wrap their input in a valid container (an OLE
+compound file, an OPC package, a styles part) so mutation reaches the record
+and format-code parsers rather than dying at the container gate. `xlsx` takes
+a whole workbook, and its seeds cover all three containers because the
+frontend picks the reader from the bytes.
diff --git a/fuzz/fuzz_targets/numfmt.rs b/fuzz/fuzz_targets/numfmt.rs
new file mode 100644
index 00000000..3b10040a
--- /dev/null
+++ b/fuzz/fuzz_targets/numfmt.rs
@@ -0,0 +1,54 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+use std::io::{Cursor, Write};
+
+// A number format code, carried into the engine on a minimal workbook. The
+// grammar is where the parsing complexity lives, and reaching it through a
+// discovered styles part would cost the fuzzer most of its budget. Only the
+// control characters XML forbids are dropped, so whitespace a code may carry
+// still reaches the parser.
+fuzz_target!(|data: &[u8]| {
+ let code = String::from_utf8_lossy(data);
+ let escaped: String = code
+ .chars()
+ .filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
+ .map(|c| match c {
+ '&' => "&".to_string(),
+ '<' => "<".to_string(),
+ '>' => ">".to_string(),
+ '"' => """.to_string(),
+ c => c.to_string(),
+ })
+ .collect();
+ let styles = format!(
+ r#""#
+ );
+
+ let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ let opts = zip::write::SimpleFileOptions::default();
+ let parts: [(&str, &str); 6] = [
+ ("[Content_Types].xml", CONTENT_TYPES),
+ ("_rels/.rels", RELS),
+ ("xl/workbook.xml", WORKBOOK),
+ ("xl/_rels/workbook.xml.rels", WORKBOOK_RELS),
+ ("xl/styles.xml", &styles),
+ ("xl/worksheets/sheet1.xml", SHEET),
+ ];
+ for (name, body) in parts {
+ if zip.start_file(name, opts).is_err() || zip.write_all(body.as_bytes()).is_err() {
+ return;
+ }
+ }
+ let Ok(bytes) = zip.finish() else {
+ return;
+ };
+ let _ = anydoc::to_markdown_bytes(bytes.into_inner().as_slice(), anydoc::Format::Excel);
+});
+
+const SML: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
+const CONTENT_TYPES: &str = r#""#;
+const RELS: &str = r#""#;
+const WORKBOOK: &str = r#""#;
+const WORKBOOK_RELS: &str = r#""#;
+const SHEET: &str = r#"1234.50.075text
"#;
diff --git a/fuzz/fuzz_targets/xls.rs b/fuzz/fuzz_targets/xls.rs
new file mode 100644
index 00000000..e0ffe43d
--- /dev/null
+++ b/fuzz/fuzz_targets/xls.rs
@@ -0,0 +1,22 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+use std::io::{Cursor, Write};
+
+// The BIFF record stream, wrapped in a valid OLE container so mutation
+// reaches the reader instead of dying at the container gate.
+fuzz_target!(|data: &[u8]| {
+ let Ok(mut ole) = cfb::CompoundFile::create(Cursor::new(Vec::new())) else {
+ return;
+ };
+ match ole.create_stream("Workbook") {
+ Ok(mut stream) => {
+ if stream.write_all(data).is_err() {
+ return;
+ }
+ }
+ Err(_) => return,
+ }
+ let bytes = ole.into_inner().into_inner();
+ let _ = anydoc::to_markdown_bytes(&bytes, anydoc::Format::Excel);
+});
diff --git a/fuzz/fuzz_targets/xlsb.rs b/fuzz/fuzz_targets/xlsb.rs
new file mode 100644
index 00000000..4c07eb9a
--- /dev/null
+++ b/fuzz/fuzz_targets/xlsb.rs
@@ -0,0 +1,40 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+use std::io::{Cursor, Write};
+
+// The worksheet, styles and shared-string parts, wrapped in a package whose
+// workbook part and relationships are already valid. Fuzzing the workbook
+// part instead would strand the input there: without a resolvable worksheet
+// relationship every sheet is skipped, and the cell, format and string
+// readers never run. The workbook part is parsed by the same record reader
+// this does reach.
+fuzz_target!(|data: &[u8]| {
+ let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ let opts = zip::write::SimpleFileOptions::default();
+ let parts: [(&str, &[u8]); 8] = [
+ ("[Content_Types].xml", CONTENT_TYPES.as_bytes()),
+ ("_rels/.rels", RELS.as_bytes()),
+ ("xl/workbook.bin", WORKBOOK),
+ ("xl/_rels/workbook.bin.rels", WORKBOOK_RELS.as_bytes()),
+ ("xl/worksheets/sheet1.bin", data),
+ ("xl/worksheets/sheet2.bin", data),
+ ("xl/styles.bin", data),
+ ("xl/sharedStrings.bin", data),
+ ];
+ for (name, body) in parts {
+ if zip.start_file(name, opts).is_err() || zip.write_all(body).is_err() {
+ return;
+ }
+ }
+ let Ok(bytes) = zip.finish() else {
+ return;
+ };
+ let _ = anydoc::to_markdown_bytes(bytes.into_inner().as_slice(), anydoc::Format::Excel);
+});
+
+const WORKBOOK: &[u8] = include_bytes!("../seeds/xlsb/workbook-bin");
+
+const CONTENT_TYPES: &str = r#""#;
+const RELS: &str = r#""#;
+const WORKBOOK_RELS: &str = r#""#;
diff --git a/fuzz/seeds/numfmt/ampm-multibyte b/fuzz/seeds/numfmt/ampm-multibyte
new file mode 100644
index 00000000..1cc7e92e
--- /dev/null
+++ b/fuzz/seeds/numfmt/ampm-multibyte
@@ -0,0 +1 @@
+aaaaé
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/ampm-token-multibyte b/fuzz/seeds/numfmt/ampm-token-multibyte
new file mode 100644
index 00000000..6e3bfe35
--- /dev/null
+++ b/fuzz/seeds/numfmt/ampm-token-multibyte
@@ -0,0 +1 @@
+h:mm AM/PMé
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/conditions b/fuzz/seeds/numfmt/conditions
new file mode 100644
index 00000000..7b0d7157
--- /dev/null
+++ b/fuzz/seeds/numfmt/conditions
@@ -0,0 +1 @@
+[>100]0.0;[<=100]0.00
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/currency b/fuzz/seeds/numfmt/currency
new file mode 100644
index 00000000..4f65ad99
--- /dev/null
+++ b/fuzz/seeds/numfmt/currency
@@ -0,0 +1 @@
+"$"#,##0.00
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/datetime b/fuzz/seeds/numfmt/datetime
new file mode 100644
index 00000000..15fe68a4
--- /dev/null
+++ b/fuzz/seeds/numfmt/datetime
@@ -0,0 +1 @@
+yyyy-mm-dd hh:mm:ss AM/PM
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/elapsed b/fuzz/seeds/numfmt/elapsed
new file mode 100644
index 00000000..516b83ad
--- /dev/null
+++ b/fuzz/seeds/numfmt/elapsed
@@ -0,0 +1 @@
+[h]:mm:ss
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/four-sections b/fuzz/seeds/numfmt/four-sections
new file mode 100644
index 00000000..26719ef3
--- /dev/null
+++ b/fuzz/seeds/numfmt/four-sections
@@ -0,0 +1 @@
+#,##0.00;[Red](#,##0.00);"nil";@
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/fraction b/fuzz/seeds/numfmt/fraction
new file mode 100644
index 00000000..e984e197
--- /dev/null
+++ b/fuzz/seeds/numfmt/fraction
@@ -0,0 +1 @@
+# ??/??
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/percent b/fuzz/seeds/numfmt/percent
new file mode 100644
index 00000000..7e95c3cc
--- /dev/null
+++ b/fuzz/seeds/numfmt/percent
@@ -0,0 +1 @@
+0.0%
\ No newline at end of file
diff --git a/fuzz/seeds/numfmt/scientific b/fuzz/seeds/numfmt/scientific
new file mode 100644
index 00000000..b466a3ff
--- /dev/null
+++ b/fuzz/seeds/numfmt/scientific
@@ -0,0 +1 @@
+0.00E+00
\ No newline at end of file
diff --git a/fuzz/seeds/xls/workbook-stream b/fuzz/seeds/xls/workbook-stream
new file mode 100644
index 00000000..e9bfb1ca
Binary files /dev/null and b/fuzz/seeds/xls/workbook-stream differ
diff --git a/fuzz/seeds/xlsb/sheet1-bin b/fuzz/seeds/xlsb/sheet1-bin
new file mode 100644
index 00000000..68321558
Binary files /dev/null and b/fuzz/seeds/xlsb/sheet1-bin differ
diff --git a/fuzz/seeds/xlsb/styles-bin b/fuzz/seeds/xlsb/styles-bin
new file mode 100644
index 00000000..bfcf3e2d
Binary files /dev/null and b/fuzz/seeds/xlsb/styles-bin differ
diff --git a/fuzz/seeds/xlsb/workbook-bin b/fuzz/seeds/xlsb/workbook-bin
new file mode 100644
index 00000000..7354d9f3
Binary files /dev/null and b/fuzz/seeds/xlsb/workbook-bin differ
diff --git a/fuzz/seeds/xlsx/merged.xlsx b/fuzz/seeds/xlsx/merged.xlsx
new file mode 100644
index 00000000..8dd24429
Binary files /dev/null and b/fuzz/seeds/xlsx/merged.xlsx differ
diff --git a/fuzz/seeds/xlsx/sheet.xls b/fuzz/seeds/xlsx/sheet.xls
new file mode 100644
index 00000000..6d830f15
Binary files /dev/null and b/fuzz/seeds/xlsx/sheet.xls differ
diff --git a/fuzz/seeds/xlsx/sheet.xlsb b/fuzz/seeds/xlsx/sheet.xlsb
new file mode 100644
index 00000000..e69c2945
Binary files /dev/null and b/fuzz/seeds/xlsx/sheet.xlsb differ
diff --git a/fuzz/seeds/xlsx/sheet.xlsx b/fuzz/seeds/xlsx/sheet.xlsx
new file mode 100644
index 00000000..44dba2b5
Binary files /dev/null and b/fuzz/seeds/xlsx/sheet.xlsx differ
diff --git a/src/formats/sheet/mod.rs b/src/formats/sheet/mod.rs
index f61ebcc6..5df92ad3 100644
--- a/src/formats/sheet/mod.rs
+++ b/src/formats/sheet/mod.rs
@@ -1,183 +1,85 @@
-//! Excel spreadsheets (xlsx, xlsm, xlsb, xls) via calamine.
+//! Excel spreadsheets (xlsx, xlsm, xlsb, xls). Every container is read
+//! in-house: SpreadsheetML as XML (xlsx, xlsm) or binary (xlsb), and
+//! OLE-based BIFF (xls). All three share the number format engine and grid
+//! assembly, so one workbook saved in any of them converts identically.
-use crate::error::ConvertError;
-use crate::model::{Block, Cell, Document, GridBuilder, Inline, TableKind};
-use crate::shared::header::resolve_header_rows;
-use crate::shared::text::clean_text;
-use calamine::{Data, Dimensions, Reader, Sheets, open_workbook_auto_from_rs};
-use std::collections::{HashMap, HashSet};
-use std::io::Cursor;
+mod numfmt;
+mod xls;
+mod xlsb;
+mod xlsx;
-/// Run one calamine operation behind a panic barrier: the calamine fork can
-/// panic on corrupt containers (pending an upstream fix), and a dependency
-/// panic must degrade to a typed error - while bugs in this crate's own code
-/// stay panics. `AssertUnwindSafe` is sound here because a caught panic
-/// always propagates as an error, so the workbook is never used again.
-fn contained(op: &str, f: impl FnOnce() -> T) -> Result {
- std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| {
- log::warn!("spreadsheet parser panicked during {op} on malformed input");
- ConvertError::malformed("unreadable workbook (parser aborted)")
- })
-}
+use crate::error::ConvertError;
+use crate::model::Document;
+use crate::package::archive::probe_ole;
+use crate::package::relationships::{read_rels, rel_type};
+use crate::package::{Package, path};
pub fn parse(bytes: &[u8]) -> Result {
- let mut workbook =
- contained("workbook open", || open_workbook_auto_from_rs(Cursor::new(bytes)))?
- .map_err(map_open_error)?;
- let sheet_names = contained("sheet listing", || workbook.sheet_names().to_owned())?;
- let multi_sheet = sheet_names.len() > 1;
- let merged = merged_regions(&mut workbook, &sheet_names)?;
-
- let mut doc = Document::default();
- let mut failed = 0usize;
- for name in &sheet_names {
- let range = match contained("worksheet read", || workbook.worksheet_range(name))? {
- Ok(r) => r,
- Err(e) => {
- log::warn!("skipping unreadable sheet {name:?}: {e}");
- failed += 1;
- continue;
- }
+ const OLE_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
+ if bytes.starts_with(&OLE_MAGIC) {
+ // An encrypted OOXML package is an OLE container carrying no BIFF
+ // workbook stream, so it has to be named before the reader looks
+ // for one.
+ return match probe_ole(bytes) {
+ Some(e @ ConvertError::Encrypted) => Err(e),
+ _ => xls::parse(bytes),
};
- if range.is_empty() {
- continue;
- }
- // Merged regions in range-relative coordinates: the top-left cell
- // becomes a spanning origin, the other positions are covered.
- let start = range.start().unwrap_or((0, 0));
- let (height, width) = (range.height(), range.width());
- let mut origins: HashMap<(usize, usize), (u32, u32)> = HashMap::new();
- let mut covered: HashSet<(usize, usize)> = HashSet::new();
- for d in merged.get(name.as_str()).map(Vec::as_slice).unwrap_or_default() {
- // Intersect the absolute merged region with the used range first:
- // a region wholly above or left of the range must not saturate
- // onto relative (0,0), and positions outside the range are never
- // materialized (a crafted region list must not force insertions
- // beyond the cells that actually exist).
- let (row0, col0) = (d.start.0.max(start.0), d.start.1.max(start.1));
- let row_end = (d.end.0 as u64 + 1).min(start.0 as u64 + height as u64);
- let col_end = (d.end.1 as u64 + 1).min(start.1 as u64 + width as u64);
- if (row0 as u64) >= row_end || (col0 as u64) >= col_end {
- continue;
- }
- // Translate the non-empty intersection to range-relative form.
- let r0 = (row0 - start.0) as usize;
- let c0 = (col0 - start.1) as usize;
- let r1 = (row_end - start.0 as u64) as usize;
- let c1 = (col_end - start.1 as u64) as usize;
- if r1 - r0 == 1 && c1 - c0 == 1 {
- continue;
- }
- origins.insert((r0, c0), ((c1 - c0) as u32, (r1 - r0) as u32));
- for r in r0..r1 {
- for c in c0..c1 {
- if (r, c) != (r0, c0) {
- covered.insert((r, c));
- }
- }
- }
- }
- let mut builder = GridBuilder::new();
- for (r, row) in range.rows().enumerate() {
- builder.next_row();
- for (c, data) in row.iter().enumerate() {
- if covered.contains(&(r, c)) {
- builder.covered();
- continue;
- }
- let text = format_data(data);
- let cell = if text.is_empty() {
- Cell::default()
- } else {
- Cell::from_inlines(vec![Inline::plain(text)])
- };
- match origins.get(&(r, c)) {
- Some(&(col_span, row_span)) => {
- builder.place(Cell::spanning(cell.blocks, col_span, row_span))?
- }
- None => builder.place(cell)?,
- }
- }
- }
- // A spreadsheet marks no header row, so the shape of the data decides.
- let mut table = builder.finish(TableKind::Data);
- if table.grid.is_empty() {
- continue;
- }
- table.header_rows = resolve_header_rows(&table, 0);
- if multi_sheet {
- doc.blocks.push(Block::heading(2, vec![Inline::plain(name.clone())]));
- }
- doc.blocks.push(Block::Table(table));
}
- if !sheet_names.is_empty() && failed == sheet_names.len() {
- return Err(ConvertError::malformed("no sheet in the workbook could be read"));
+ // Failing to open as a ZIP means not a workbook; a resource limit
+ // tripped by a valid archive still propagates.
+ let mut pkg = match Package::open(bytes) {
+ Ok(pkg) => pkg,
+ Err(ConvertError::Malformed { .. }) => return Err(not_a_workbook()),
+ Err(e) => return Err(e),
+ };
+ let Some(wb_part) = main_part(&mut pkg)? else {
+ return Err(not_a_workbook());
+ };
+ match classify(&mut pkg, &wb_part)? {
+ Some(Container::Xml) => xlsx::parse(&mut pkg, &wb_part),
+ Some(Container::Bin) => xlsb::parse(&mut pkg, &wb_part),
+ None => Err(not_a_workbook()),
}
- Ok(doc)
}
-/// Merged regions per sheet, where the container format exposes them (xlsx
-/// via each worksheet's mergeCells part, xls via BIFF MERGEDCELLS).
-fn merged_regions(
- workbook: &mut Sheets,
- sheet_names: &[String],
-) -> Result>, ConvertError> {
- let mut out: HashMap> = HashMap::new();
- for name in sheet_names {
- let regions = match workbook {
- Sheets::Xlsx(x) => {
- contained("merged-region listing", || x.merge_cells_by_sheet_name(name))?
- .map_err(|e| e.to_string())
- }
- Sheets::Xls(x) => {
- contained("merged-cell listing", || x.merge_cells_by_sheet_name(name))?
- .map_err(|e| e.to_string())
- }
- _ => continue,
- };
- match regions {
- Ok(dims) if !dims.is_empty() => {
- out.insert(name.clone(), dims);
- }
- Ok(_) => {}
- Err(e) => log::warn!("skipping unreadable merged-region list for {name:?}: {e}"),
- }
- }
- Ok(out)
+fn not_a_workbook() -> ConvertError {
+ ConvertError::malformed("not a readable workbook container")
}
-fn map_open_error(e: calamine::Error) -> ConvertError {
- let text = e.to_string();
- if text.to_ascii_lowercase().contains("password") {
- ConvertError::Encrypted
- } else {
- ConvertError::malformed(format!("unreadable workbook: {text}"))
+enum Container {
+ Xml,
+ Bin,
+}
+
+/// The package's main part: the root relationship names it wherever it
+/// lives, so it decides ahead of the conventional locations, which a
+/// package may also contain as leftovers.
+fn main_part(pkg: &mut Package) -> Result