Delegate XLSX parsing to craft-libs

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 00:32:11 -05:00
1 parent 6ff34b922f
commit dce5d19dc2
4 files changed
+112 -182

No files matched your search

Generated
+40
View File
@@ -1051,6 +1051,17 @@ dependencies = [
"zip 9.1.1",
]
[[package]]
name = "craft-drawingml"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=3afade51630fcdb02662c761e0805bc6147bb0ac#3afade51630fcdb02662c761e0805bc6147bb0ac"
dependencies = [
"craft-xml",
"kurbo",
"log",
"serde",
]
[[package]]
name = "craft-eps"
version = "0.1.0"
@@ -1116,6 +1127,17 @@ dependencies = [
"serde_json",
]
[[package]]
name = "craft-opc"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=3afade51630fcdb02662c761e0805bc6147bb0ac#3afade51630fcdb02662c761e0805bc6147bb0ac"
dependencies = [
"craft-xml",
"craft-zip",
"log",
"thiserror 2.0.21",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -1164,6 +1186,11 @@ dependencies = [
"encoding_rs",
]
[[package]]
name = "craft-sheet"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=3afade51630fcdb02662c761e0805bc6147bb0ac#3afade51630fcdb02662c761e0805bc6147bb0ac"
[[package]]
name = "craft-svg"
version = "0.1.0"
@@ -1243,6 +1270,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "craft-xlsx"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=3afade51630fcdb02662c761e0805bc6147bb0ac#3afade51630fcdb02662c761e0805bc6147bb0ac"
dependencies = [
"craft-drawingml",
"craft-opc",
"craft-sheet",
"craft-xml",
"thiserror 2.0.21",
]
[[package]]
name = "craft-xml"
version = "0.1.0"
@@ -1678,6 +1717,7 @@ name = "designcraft-textimport"
version = "0.20.1"
dependencies = [
"craft-rtf",
"craft-xlsx",
"designcraft-doc",
"quick-xml 0.41.0",
"zip 3.0.0",
+1
View File
@@ -20,6 +20,7 @@ craft-idml = { git = "https://github.com/storytold/craft-libs", rev = "3afade516
craft-svg = { git = "https://github.com/storytold/craft-libs", rev = "3afade51630fcdb02662c761e0805bc6147bb0ac", version = "=0.1.0" }
craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "3afade51630fcdb02662c761e0805bc6147bb0ac", version = "=0.1.0" }
craft-rtf = { git = "https://github.com/storytold/craft-libs", rev = "3afade51630fcdb02662c761e0805bc6147bb0ac", version = "=0.1.0" }
craft-xlsx = { git = "https://github.com/storytold/craft-libs", rev = "3afade51630fcdb02662c761e0805bc6147bb0ac", version = "=0.1.0" }
# Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog.
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] }
# Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch.
+1
View File
@@ -11,5 +11,6 @@ workspace = true
[dependencies]
designcraft-doc = { workspace = true }
craft-rtf = { workspace = true }
craft-xlsx = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }
+70 -182
View File
@@ -6,85 +6,63 @@ use std::collections::BTreeMap;
use designcraft_doc::{CharFormat, ParaFormat, Story, StoryId, Table};
use crate::docx::{El, parse, part};
use crate::{ImportError, Imported};
/// "B12" → (row 11, column 1).
fn cell_ref(r: &str) -> Option<(usize, usize)> {
let letters: String = r.chars().take_while(char::is_ascii_alphabetic).collect();
let digits = &r[letters.len()..];
if !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let row: usize = digits.parse().ok()?;
let col = letters.chars().try_fold(0usize, |acc, c| {
let digit = (c.to_ascii_uppercase() as u8).checked_sub(b'A')? as usize + 1;
acc.checked_mul(26)?.checked_add(digit)
})?;
Some((row.checked_sub(1)?, col.checked_sub(1)?))
}
/// The worksheet part of the first sheet (workbook order), via the workbook relationships.
fn first_sheet(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>) -> Result<Option<String>, ImportError> {
let Some(wb) = part(zip, "xl/workbook.xml")? else { return Ok(None) };
let Some(rels) = part(zip, "xl/_rels/workbook.xml.rels")? else { return Ok(None) };
Ok(first_sheet_target(&wb, &rels))
}
fn first_sheet_target(wb: &str, rels: &str) -> Option<String> {
let wb = parse(wb).ok()?;
let rid = wb.child("sheets")?.els().find(|e| e.name == "sheet")?.attr("id")?.to_string();
let rels = parse(rels).ok()?;
let target = rels.els().find(|r| r.attr("Id") == Some(rid.as_str()))?.attr("Target")?.trim_start_matches('/').to_string();
Some(if target.starts_with("xl/") { target } else { format!("xl/{target}") })
}
/// The text of a shared string (`<si>`) or inline string (`<is>`): its own `<t>` and the `<t>` of its
/// `<r>` runs. Phonetic guides (`<rPh>`, `<phoneticPr>`) are the reading, not the cell text.
fn string_item(si: &El) -> String {
si.els()
.map(|e| match e.name.as_str() {
"t" => e.text(),
"r" => e.els().filter(|t| t.name == "t").map(El::text).collect(),
_ => String::new(),
})
.collect()
}
fn value(c: &El, shared: &[String]) -> String {
let v = || c.child("v").map(El::text).unwrap_or_default();
match c.attr("t") {
Some("s") => v().trim().parse::<usize>().ok().and_then(|i| shared.get(i).cloned()).unwrap_or_default(),
Some("inlineStr") => c.child("is").map(string_item).unwrap_or_default(),
// A cached formula string is text even when it looks like a number.
Some("str") => v(),
Some("b") => {
if v().trim() == "1" {
"TRUE".into()
} else {
"FALSE".into()
}
}
_ => {
let raw = v();
match raw.trim().parse::<f64>() {
Ok(f) if f.is_finite() => general(f),
_ => raw,
}
}
fn display(value: craft_xlsx::Value) -> String {
match value {
craft_xlsx::Value::Empty => String::new(),
craft_xlsx::Value::Number(n) => general(n),
craft_xlsx::Value::Text(s) => s.to_string(),
craft_xlsx::Value::Bool(b) => if b { "TRUE" } else { "FALSE" }.into(),
craft_xlsx::Value::Error(e) => e.to_string(),
}
}
/// A number as Excel's General format shows it: at most 15 significant digits (Excel stores
/// doubles with 17, so `=0.1+0.2` is cached as 0.30000000000000004 and shown as 0.3), whole
/// numbers without ".0", and an exponent for very large or very small values.
fn sheet_cells(
bytes: &[u8],
wanted: Option<&str>,
max_cells: usize,
max_text_bytes: usize,
) -> Result<(Vec<(usize, usize, String)>, bool, Vec<String>), ImportError> {
let mut package = craft_xlsx::Package::open(bytes, &craft_xlsx::Limits::default()).map_err(|e| ImportError::Corrupt(e.to_string()))?;
let workbook = package.workbook().map_err(|e| ImportError::Corrupt(e.to_string()))?;
let entry = match wanted {
Some(name) => workbook.sheets.iter().find(|sheet| sheet.name == name),
None => workbook.sheets.first(),
}
.ok_or_else(|| ImportError::Corrupt(wanted.map_or("the workbook has no sheets".into(), |name| format!("no worksheet \"{name}\""))))?;
let date_1904 = workbook.date_1904;
let strings = package.shared_strings().map_err(|e| ImportError::Corrupt(e.to_string()))?;
let mut cells = Vec::new();
let mut text_bytes = 0usize;
let mut cut = false;
let result = package.sheet(entry, |row| {
for cell in row.cells {
let value = display(cell.value.resolve(&strings, date_1904));
text_bytes = text_bytes.saturating_add(value.len());
if cells.len() >= max_cells || text_bytes > max_text_bytes {
cut = true;
return Err(craft_xlsx::Error::TooLarge("worksheet text or cell limit reached".into()));
}
cells.push((cell.at.row as usize, cell.at.col as usize, value));
}
Ok(())
});
if let Err(error) = result
&& !cut
{
return Err(ImportError::Corrupt(error.to_string()));
}
let warnings = package.warnings().to_vec();
Ok((cells, cut, warnings))
}
fn general(f: f64) -> String {
let r = format!("{f:.14e}").parse::<f64>().unwrap_or(f);
let a = r.abs();
if r == 0.0 {
"0".into()
} else if (1e-9..1e15).contains(&a) {
// Shortest text that reads back as the rounded value; whole numbers drop the ".0".
r.to_string()
} else {
let s = format!("{r:E}");
@@ -95,7 +73,11 @@ fn general(f: f64) -> String {
}
}
/// The first and last column used on the header row, if that row has any cells.
#[cfg(test)]
fn cell_ref(reference: &str) -> Option<(usize, usize)> {
craft_xlsx::CellRef::parse(reference).map(|cell| (cell.row as usize, cell.col as usize))
}
fn header_span(cells: &[(usize, usize, String)], header_row: usize) -> Option<(usize, usize)> {
cells.iter().filter(|(r, _, _)| *r == header_row).fold(None, |span, (_, c, _)| match span {
None => Some((*c, *c)),
@@ -103,20 +85,13 @@ fn header_span(cells: &[(usize, usize, String)], header_row: usize) -> Option<(u
})
}
/// One row's values laid into `width` columns starting at column `min_c`; values outside that
/// span come back separately (only the non-empty ones), so the caller can warn.
fn place_row(cols: &[(usize, String)], min_c: usize, width: usize) -> (Vec<String>, Vec<String>) {
let mut row = vec![String::new(); width];
let mut extra: Vec<String> = Vec::new();
for (c, v) in cols {
let at = c.checked_sub(min_c).filter(|at| *at < width);
match at {
Some(at) => {
if let Some(cell) = row.get_mut(at) {
*cell = v.clone();
}
}
None if !v.is_empty() => extra.push(v.clone()),
let mut extra = Vec::new();
for (c, value) in cols {
match c.checked_sub(min_c).filter(|at| *at < width) {
Some(at) => row[at] = value.clone(),
None if !value.is_empty() => extra.push(value.clone()),
None => {}
}
}
@@ -133,38 +108,17 @@ const MAX_RECORD_TEXT_BYTES: usize = 256 << 20;
const MAX_RECORD_CELLS: usize = 2 * crate::DATA_MERGE_MAX_ROWS * crate::DATA_MERGE_MAX_COLS;
pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, ImportError> {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?;
let sheet_part = sheet_target(&mut zip, sheet)?;
let sheet_xml = parse(&part(&mut zip, &sheet_part)?.ok_or_else(|| ImportError::Corrupt(format!("no {sheet_part} (not an Excel workbook)")))?)?;
let shared: Vec<String> = part(&mut zip, "xl/sharedStrings.xml")?
.map(|x| parse(&x))
.transpose()?
.map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect())
.unwrap_or_default();
let mut cells: Vec<(usize, usize, String)> = Vec::new();
// Each `t="s"` cell is its own copy of a shared string: bounded as `import` bounds it, before
// the row and column limits below can apply (one large string referenced by many cells).
let mut text_bytes = 0usize;
if let Some(data) = sheet_xml.child("sheetData") {
for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() {
for (ci, c) in row.els().filter(|e| e.name == "c").enumerate() {
let (r, col) = c.attr("r").and_then(cell_ref).unwrap_or((ri, ci));
let v = value(c, &shared);
text_bytes = text_bytes.saturating_add(v.len());
if text_bytes > MAX_RECORD_TEXT_BYTES || cells.len() >= MAX_RECORD_CELLS {
return Err(ImportError::Corrupt(format!(
"the sheet holds more than {} MB of text or {MAX_RECORD_CELLS} cells; the limit for a data source",
MAX_RECORD_TEXT_BYTES >> 20
)));
}
cells.push((r, col, v));
}
}
let (cells, cut, _) = sheet_cells(bytes, sheet, MAX_RECORD_CELLS, MAX_RECORD_TEXT_BYTES)?;
if cut {
return Err(ImportError::Corrupt(format!(
"the sheet holds more than {} MB of text or {MAX_RECORD_CELLS} cells; the limit for a data source",
MAX_RECORD_TEXT_BYTES >> 20
)));
}
if cells.is_empty() {
return Err(ImportError::Corrupt("the worksheet is empty".into()));
}
let header_row = cells.iter().map(|c| c.0).min().unwrap_or(0);
let header_row = cells.iter().map(|cell| cell.0).min().unwrap_or(0);
let Some((min_c, max_c)) = header_span(&cells, header_row) else {
return Err(ImportError::Corrupt("the worksheet has no header".into()));
};
@@ -172,21 +126,11 @@ pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, Im
if width > crate::DATA_MERGE_MAX_COLS {
return Err(ImportError::Corrupt(format!("the sheet has {width} columns; the limit is {}", crate::DATA_MERGE_MAX_COLS)));
}
// Group by row. Gaps (empty rows) are not materialised.
let mut by_row: BTreeMap<usize, Vec<(usize, String)>> = BTreeMap::new();
for (r, c, v) in cells {
by_row.entry(r).or_default().push((c, v));
}
let mut data_rows = 0usize;
for (r, cols) in &by_row {
if *r == header_row {
continue;
}
let any = cols.iter().any(|(_, v)| !v.is_empty());
if any {
data_rows = data_rows.saturating_add(1);
}
}
let data_rows = by_row.iter().filter(|(r, cols)| **r != header_row && cols.iter().any(|(_, v)| !v.is_empty())).count();
if data_rows > crate::DATA_MERGE_MAX_ROWS {
return Err(ImportError::Corrupt(format!("the sheet has {data_rows} data rows; the limit is {}", crate::DATA_MERGE_MAX_ROWS)));
}
@@ -202,64 +146,10 @@ pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result<Vec<Vec<String>>, Im
Ok(out)
}
/// Workbook order, or the sheet named `wanted`.
fn sheet_target(zip: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>, wanted: Option<&str>) -> Result<String, ImportError> {
let wb = parse(&part(zip, "xl/workbook.xml")?.ok_or_else(|| ImportError::Corrupt("no xl/workbook.xml (not an Excel workbook)".into()))?)?;
let sheets: Vec<(String, String)> = wb
.child("sheets")
.map(|s| s.els().filter(|e| e.name == "sheet").filter_map(|e| Some((e.attr("name")?.to_string(), e.attr("id")?.to_string()))).collect())
.unwrap_or_default();
let (name, rid) = match wanted {
None => sheets.first().cloned().ok_or_else(|| ImportError::Corrupt("the workbook has no sheets".into()))?,
Some(w) => sheets.into_iter().find(|(n, _)| n == w).ok_or_else(|| ImportError::Corrupt(format!("no worksheet \"{w}\"")))?,
};
let rels = parse(&part(zip, "xl/_rels/workbook.xml.rels")?.ok_or_else(|| ImportError::Corrupt("no workbook relationships".into()))?)?;
let target = rels
.els()
.find(|r| r.attr("Id") == Some(rid.as_str()))
.and_then(|r| r.attr("Target"))
.ok_or_else(|| ImportError::Corrupt(format!("worksheet \"{name}\" has no file")))?;
let target = target.trim_start_matches('/');
Ok(if target.starts_with("xl/") { target.to_string() } else { format!("xl/{target}") })
}
pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| ImportError::Corrupt(e.to_string()))?;
let sheet_part = first_sheet(&mut zip)?.unwrap_or_else(|| "xl/worksheets/sheet1.xml".into());
let sheet = parse(&part(&mut zip, &sheet_part)?.ok_or_else(|| ImportError::Corrupt(format!("no {sheet_part} (not an Excel workbook)")))?)?;
let shared: Vec<String> = part(&mut zip, "xl/sharedStrings.xml")?
.map(|x| parse(&x))
.transpose()?
.map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect())
.unwrap_or_default();
// A `t="s"` cell holds an index into the shared strings and each one is materialised as its
// own `String`, so one large shared string referenced by many cells multiplies: a 1 MB string
// and 3M references is terabytes, allocated before the grid cap below ever applies, and an
// allocation that large aborts the process rather than failing (#1584). Collection carries a
// byte budget and a cell count of its own.
const MAX_TEXT_BYTES: usize = 64 << 20;
const MAX_COLLECTED_CELLS: usize = 400_000;
let mut cells: Vec<((usize, usize), String)> = Vec::new();
let mut text_bytes = 0usize;
let mut cut = false;
if let Some(data) = sheet.child("sheetData") {
'rows: for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() {
for (ci, c) in row.els().filter(|e| e.name == "c").enumerate() {
let at = c.attr("r").and_then(cell_ref).unwrap_or((ri, ci));
let v = value(c, &shared);
if v.is_empty() {
continue;
}
if cells.len() >= MAX_COLLECTED_CELLS || text_bytes.saturating_add(v.len()) > MAX_TEXT_BYTES {
cut = true;
break 'rows;
}
text_bytes = text_bytes.saturating_add(v.len());
cells.push((at, v));
}
}
}
let mut warnings = Vec::new();
let (cells, cut, mut warnings) = sheet_cells(bytes, None, MAX_COLLECTED_CELLS, MAX_TEXT_BYTES)?;
if cut {
warnings.push(format!(
"the worksheet holds more than {} cells or {} MB of text: the rest was not read",
@@ -270,12 +160,10 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
if cells.is_empty() {
warnings.push("the worksheet is empty".into());
}
// The used range, from the first used row/column. Cell references come from the file: the
// table is capped before anything is allocated for it.
const MAX_CELLS: usize = 100_000;
const MAX_COLS: usize = 1_000;
let (r0, c0) = cells.iter().fold((usize::MAX, usize::MAX), |(r, c), ((a, b), _)| (r.min(*a), c.min(*b)));
let (r1, c1) = cells.iter().fold((0, 0), |(r, c), ((a, b), _)| (r.max(*a), c.max(*b)));
let (r0, c0) = cells.iter().fold((usize::MAX, usize::MAX), |(r, c), (a, b, _)| (r.min(*a), c.min(*b)));
let (r1, c1) = cells.iter().fold((0, 0), |(r, c), (a, b, _)| (r.max(*a), c.max(*b)));
let (nrows, ncols) = if cells.is_empty() { (1, 1) } else { ((r1 - r0).saturating_add(1), (c1 - c0).saturating_add(1)) };
let ncols_kept = ncols.min(MAX_COLS);
if ncols_kept < ncols {
@@ -286,15 +174,15 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
warnings.push(format!("only the first {nrows_kept} rows were placed"));
}
let mut rows: Vec<Vec<String>> = vec![vec![String::new(); ncols_kept]; nrows_kept];
for ((r, c), v) in cells {
for (r, c, v) in cells.into_iter().filter(|(_, _, value)| !value.is_empty()) {
if let Some(cell) = rows.get_mut(r - r0).and_then(|row| row.get_mut(c - c0)) {
*cell = v;
}
}
let mut story = Story::new(StoryId(0));
let width = 72.0 * ncols_kept as f64;
let t = Table::from_strings(1, &rows, width, &ParaFormat::default(), &CharFormat::default());
story.insert_table(0, t);
let table = Table::from_strings(1, &rows, width, &ParaFormat::default(), &CharFormat::default());
story.insert_table(0, table);
Ok(Imported { story, para_styles: vec![], char_styles: vec![], warnings })
}