#16: import ODS worksheet data without overwriting source files
This commit is contained in:
parent
13664fe66b
commit
da0bf32d8a
|
|
@ -1466,6 +1466,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@
|
||||||
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
|
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
|
||||||
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
|
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
|
||||||
PivotTables and print settings round-trip. CSV and TSV too.
|
PivotTables and print settings round-trip. CSV and TSV too.
|
||||||
|
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
|
||||||
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
|
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
|
||||||
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
|
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
|
||||||
structured table references, Excel's full number-format language, sort and AutoFilter,
|
structured table references, Excel's full number-format language, sort and AutoFilter,
|
||||||
|
|
@ -141,7 +142,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|
||||||
|---|---|
|
|---|---|
|
||||||
| L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) |
|
| L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) |
|
||||||
| L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) |
|
| L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) |
|
||||||
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX and CSV) |
|
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV and ODS data import) |
|
||||||
| L3 | `chart` (toolkit-free chart layout and rendering) |
|
| L3 | `chart` (toolkit-free chart layout and rendering) |
|
||||||
| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
|
| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
|
||||||
| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
|
| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,14 @@ impl Args {
|
||||||
fn exec(s: &mut Session, id: &str, params: Value) -> Result<Value, String> {
|
fn exec(s: &mut Session, id: &str, params: Value) -> Result<Value, String> {
|
||||||
let r = s.execute(id, params).map_err(|e| e.to_string());
|
let r = s.execute(id, params).map_err(|e| e.to_string());
|
||||||
s.take_ui_requests();
|
s.take_ui_requests();
|
||||||
|
if id == "file.open"
|
||||||
|
&& let Ok(value) = &r
|
||||||
|
&& let Some(warnings) = value.get("warnings").and_then(Value::as_array)
|
||||||
|
{
|
||||||
|
for warning in warnings.iter().filter_map(Value::as_str) {
|
||||||
|
eprintln!("Warning: {warning}");
|
||||||
|
}
|
||||||
|
}
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
let Some(file) =
|
let Some(file) =
|
||||||
rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"]).pick_file().await
|
rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "ods", "csv", "tsv", "txt", "json"]).pick_file().await
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,9 @@ fn services() -> Services {
|
||||||
Services {
|
Services {
|
||||||
pick_open: Some(Box::new(|| {
|
pick_open: Some(Box::new(|| {
|
||||||
rfd::FileDialog::new()
|
rfd::FileDialog::new()
|
||||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "csv", "tsv", "txt", "json"])
|
.add_filter("Spreadsheets", &["xlsx", "xlsm", "ods", "csv", "tsv", "txt", "json"])
|
||||||
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
|
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
|
||||||
|
.add_filter("OpenDocument Spreadsheet (data import)", &["ods"])
|
||||||
.add_filter("CSV", &["csv"])
|
.add_filter("CSV", &["csv"])
|
||||||
.pick_file()
|
.pick_file()
|
||||||
.and_then(|p| p.to_str().map(str::to_string))
|
.and_then(|p| p.to_str().map(str::to_string))
|
||||||
|
|
@ -172,6 +173,7 @@ fn main() -> eframe::Result<()> {
|
||||||
session.new_workbook();
|
session.new_workbook();
|
||||||
}
|
}
|
||||||
let mut app = SheetApp::new(session, services());
|
let mut app = SheetApp::new(session, services());
|
||||||
|
app.after_engine(); // Show warnings from files opened on the command line.
|
||||||
let control_port = control_port;
|
let control_port = control_port;
|
||||||
load_prefs(&mut app);
|
load_prefs(&mut app);
|
||||||
let mut viewport = egui::ViewportBuilder::default()
|
let mut viewport = egui::ViewportBuilder::default()
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true }
|
||||||
gridcraft-chart = { workspace = true }
|
gridcraft-chart = { workspace = true }
|
||||||
gridcraft-pdf = { workspace = true }
|
gridcraft-pdf = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
zip = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use crate::DocState;
|
||||||
pub fn specs() -> Vec<CommandSpec> {
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
vec![
|
vec![
|
||||||
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
|
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
|
||||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, csv, tsv, txt, json)", always, open),
|
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, ods data import, csv, tsv, txt, json)", always, open),
|
||||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save),
|
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html by extension)", has_doc, save),
|
||||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as),
|
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path}", has_doc, save_as),
|
||||||
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
|
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
|
||||||
|
|
@ -47,8 +47,15 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
return ok();
|
return ok();
|
||||||
};
|
};
|
||||||
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
|
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
|
||||||
let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string();
|
let imported =
|
||||||
let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true);
|
crate::io::FileKind::from_path(&name) == Some(crate::io::FileKind::Ods) || gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Ods;
|
||||||
|
let title = if imported {
|
||||||
|
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
|
||||||
|
} else {
|
||||||
|
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
|
||||||
|
};
|
||||||
|
// An import has no save target: ordinary Save must ask for a new destination.
|
||||||
|
let path = str_param(p, "path").filter(|_| !imported).map(str::to_string);
|
||||||
// Replace an untouched blank Book1 like Excel does.
|
// Replace an untouched blank Book1 like Excel does.
|
||||||
if s.documents().len() == 1
|
if s.documents().len() == 1
|
||||||
&& s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty()))
|
&& s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty()))
|
||||||
|
|
@ -56,6 +63,13 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||||
s.close_document(0);
|
s.close_document(0);
|
||||||
}
|
}
|
||||||
let i = s.add_document(DocState::new(wb, path, title));
|
let i = s.add_document(DocState::new(wb, path, title));
|
||||||
|
if !warnings.is_empty() {
|
||||||
|
let mut message = warnings.iter().take(5).cloned().collect::<Vec<_>>().join("\n\n");
|
||||||
|
if warnings.len() > 5 {
|
||||||
|
message.push_str(&format!("\n\n{} additional import warnings.", warnings.len() - 5));
|
||||||
|
}
|
||||||
|
s.ui_requests.push(crate::UiRequest::Message(message));
|
||||||
|
}
|
||||||
let d = s.doc()?;
|
let d = s.doc()?;
|
||||||
Ok(
|
Ok(
|
||||||
json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(), "warnings": warnings}),
|
json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::<Vec<_>>(), "warnings": warnings}),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Opening and saving files: XLSX (the native format), CSV/TSV, JSON (debug), HTML export.
|
//! Opening and saving files: XLSX, ODS data import, CSV/TSV, JSON (debug), HTML export.
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
|
@ -11,6 +11,7 @@ use crate::{EngineError, Result};
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum FileKind {
|
pub enum FileKind {
|
||||||
Xlsx,
|
Xlsx,
|
||||||
|
Ods,
|
||||||
Csv,
|
Csv,
|
||||||
Tsv,
|
Tsv,
|
||||||
Json,
|
Json,
|
||||||
|
|
@ -22,6 +23,7 @@ impl FileKind {
|
||||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||||
Some(match ext.as_str() {
|
Some(match ext.as_str() {
|
||||||
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
|
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
|
||||||
|
"ods" => FileKind::Ods,
|
||||||
"csv" => FileKind::Csv,
|
"csv" => FileKind::Csv,
|
||||||
"tsv" | "tab" | "txt" => FileKind::Tsv,
|
"tsv" | "tab" | "txt" => FileKind::Tsv,
|
||||||
"json" | "scjson" => FileKind::Json,
|
"json" | "scjson" => FileKind::Json,
|
||||||
|
|
@ -36,6 +38,10 @@ impl FileKind {
|
||||||
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||||
let kind = FileKind::from_path(name);
|
let kind = FileKind::from_path(name);
|
||||||
let sniffed = gridcraft_xlsx::sniff(bytes);
|
let sniffed = gridcraft_xlsx::sniff(bytes);
|
||||||
|
if sniffed == gridcraft_xlsx::Format::Ods || kind == Some(FileKind::Ods) {
|
||||||
|
let (wb, report) = gridcraft_xlsx::read_ods(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
|
||||||
|
return Ok((wb, report.warnings));
|
||||||
|
}
|
||||||
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
|
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
|
||||||
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
|
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
|
||||||
return Ok((wb, report.warnings));
|
return Ok((wb, report.warnings));
|
||||||
|
|
@ -81,6 +87,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||||
let sheet = wb.active_sheet;
|
let sheet = wb.active_sheet;
|
||||||
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
|
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
|
||||||
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
|
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||||
|
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||||
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
|
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b',')).unwrap_or_default()),
|
||||||
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
|
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
|
||||||
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
|
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,76 @@ fn xlsx_roundtrip_through_engine() {
|
||||||
assert_eq!(v(&s, "B3"), Value::Number(3.0));
|
assert_eq!(v(&s, "B3"), Value::Number(3.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ods_fixture() -> Vec<u8> {
|
||||||
|
use std::io::{Cursor, Write};
|
||||||
|
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||||
|
for (name, data) in [
|
||||||
|
("mimetype", "application/vnd.oasis.opendocument.spreadsheet"),
|
||||||
|
(
|
||||||
|
"META-INF/manifest.xml",
|
||||||
|
r#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3"><manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/></manifest:manifest>"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"content.xml",
|
||||||
|
r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2" office:version="1.3"><office:body><office:spreadsheet><table:table table:name="Data"><table:table-row><table:table-cell office:value-type="float" office:value="42" table:formula="of:=SUM([.B1:.B2])"/><table:table-cell office:value-type="string"><text:p>Original data</text:p></table:table-cell></table:table-row></table:table></office:spreadsheet></office:body></office:document-content>"#,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
zip.start_file(name, options).unwrap();
|
||||||
|
zip.write_all(data.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
zip.finish().unwrap().into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_import_keeps_cached_values_and_reports_limits() {
|
||||||
|
let bytes = ods_fixture();
|
||||||
|
assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods);
|
||||||
|
for name in ["source.ods", "source.xlsx"] {
|
||||||
|
let mut s = s();
|
||||||
|
let r = s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(&bytes)})).unwrap();
|
||||||
|
assert!(!r["warnings"].as_array().unwrap().is_empty());
|
||||||
|
assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Message(_))));
|
||||||
|
assert!(s.doc().unwrap().path.is_none());
|
||||||
|
assert!(s.doc().unwrap().display_title().ends_with(".xlsx"));
|
||||||
|
assert_eq!(v(&s, "A1"), Value::Number(42.0));
|
||||||
|
assert_eq!(v(&s, "B1"), Value::from("Original data"));
|
||||||
|
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().formula.is_none());
|
||||||
|
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||||
|
s.execute("file.open", json!({"name": "imported.xlsx", "base64": saved["base64"]})).unwrap();
|
||||||
|
assert_eq!(v(&s, "A1"), Value::Number(42.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn ods_import_never_reuses_source_as_save_target() {
|
||||||
|
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!("gridcraft-ods-{}-{nonce}", std::process::id()));
|
||||||
|
std::fs::create_dir(&dir).unwrap();
|
||||||
|
let bytes = ods_fixture();
|
||||||
|
for name in ["source.ods", "disguised.xlsx"] {
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(&path, &bytes).unwrap();
|
||||||
|
let mut s = s();
|
||||||
|
s.execute("file.open", json!({"path": path})).unwrap();
|
||||||
|
s.take_ui_requests();
|
||||||
|
s.execute("cell.set", json!({"cell": "A1", "input": "43"})).unwrap();
|
||||||
|
s.execute("file.save", json!({})).unwrap();
|
||||||
|
assert!(s.take_ui_requests().iter().any(|r| matches!(r, crate::UiRequest::Dialog(name, _) if name == "saveAs")));
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||||
|
assert!(s.execute("file.saveAs", json!({"path": dir.join("source.ods")})).is_err());
|
||||||
|
assert!(s.execute("file.saveBytes", json!({"format": "ods"})).is_err());
|
||||||
|
let output = dir.join("converted.xlsx");
|
||||||
|
s.execute("file.saveAs", json!({"path": output})).unwrap();
|
||||||
|
assert!(!s.doc().unwrap().is_dirty());
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||||
|
s.execute("file.open", json!({"name": "converted.xlsx", "base64": crate::io::base64_encode(&std::fs::read(output).unwrap())})).unwrap();
|
||||||
|
assert_eq!(v(&s, "A1"), Value::Number(43.0));
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn samples_build() {
|
fn samples_build() {
|
||||||
for (name, _) in crate::sample::SAMPLES {
|
for (name, _) in crate::sample::SAMPLES {
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||||
tool(
|
tool(
|
||||||
"open_workbook",
|
"open_workbook",
|
||||||
"Open workbook",
|
"Open workbook",
|
||||||
"Open a workbook file (.xlsx, .xlsm, .csv, .tsv, .txt, .json) and make it active.",
|
"Open a workbook file (.xlsx, .xlsm, .ods, .csv, .tsv, .txt, .json) and make it active. ODS imports worksheet data and cached formula values only; check the returned warnings.",
|
||||||
obj(json!({"path": string("File path")}), &["path"]),
|
obj(json!({"path": string("File path")}), &["path"]),
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! GridCraft file formats: XLSX (Office Open XML SpreadsheetML, transitional and strict) and CSV.
|
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus ODS data import.
|
||||||
//!
|
//!
|
||||||
//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
|
//! Clean-room implementation from ECMA-376 / ISO/IEC 29500 and observed behaviour. All entry
|
||||||
//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
|
//! points work on byte slices (no file system access), so the crate builds for WebAssembly.
|
||||||
|
|
@ -14,6 +14,7 @@ mod chart;
|
||||||
mod csv;
|
mod csv;
|
||||||
mod drawing;
|
mod drawing;
|
||||||
mod fmla;
|
mod fmla;
|
||||||
|
mod ods;
|
||||||
mod package;
|
mod package;
|
||||||
mod pivot;
|
mod pivot;
|
||||||
mod read;
|
mod read;
|
||||||
|
|
@ -32,6 +33,7 @@ use gridcraft_core::DateSystem;
|
||||||
use gridcraft_model::Workbook;
|
use gridcraft_model::Workbook;
|
||||||
|
|
||||||
pub use csv::{read_csv, write_csv};
|
pub use csv::{read_csv, write_csv};
|
||||||
|
pub use ods::read_ods;
|
||||||
|
|
||||||
/// Errors from reading or writing files.
|
/// Errors from reading or writing files.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|
@ -92,14 +94,15 @@ impl Default for CsvOptions {
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Format {
|
pub enum Format {
|
||||||
Xlsx,
|
Xlsx,
|
||||||
|
Ods,
|
||||||
Csv,
|
Csv,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Guesses the format from the first bytes: a zip package is XLSX, text is CSV.
|
/// Guesses the format from content: ODS mimetype, other ZIP packages as XLSX, text as CSV.
|
||||||
pub fn sniff(bytes: &[u8]) -> Format {
|
pub fn sniff(bytes: &[u8]) -> Format {
|
||||||
if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") {
|
if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") || bytes.starts_with(b"PK\x07\x08") {
|
||||||
return Format::Xlsx;
|
return if ods::has_mimetype(bytes) { Format::Ods } else { Format::Xlsx };
|
||||||
}
|
}
|
||||||
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||||||
return Format::Csv;
|
return Format::Csv;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,652 @@
|
||||||
|
//! Data-only OpenDocument Spreadsheet import. Formula caches become constants; styles and
|
||||||
|
//! other ODF features are deliberately not translated. All fixtures are authored in Rust.
|
||||||
|
//! Format reference: ODF 1.3, part 3, sections 6.1 and 9.1:
|
||||||
|
//! <https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part3-schema/OpenDocument-v1.3-os-part3-schema.html>.
|
||||||
|
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use gridcraft_core::date::{days_in_month, serial_from_ymd};
|
||||||
|
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||||
|
use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
|
||||||
|
use quick_xml::NsReader;
|
||||||
|
use quick_xml::events::{BytesStart, Event};
|
||||||
|
use quick_xml::name::ResolveResult;
|
||||||
|
|
||||||
|
use crate::package::{MAX_ENTRIES, Package};
|
||||||
|
use crate::{IoError, ReadReport};
|
||||||
|
|
||||||
|
const MIME: &[u8] = b"application/vnd.oasis.opendocument.spreadsheet";
|
||||||
|
const OFFICE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:office:1.0";
|
||||||
|
const TABLE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:table:1.0";
|
||||||
|
const TEXT: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:text:1.0";
|
||||||
|
const CALC_EXT: &[u8] = b"urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0";
|
||||||
|
const MANIFEST: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
|
||||||
|
const MAX_CELLS: u64 = 1_000_000;
|
||||||
|
const MAX_MERGES: u64 = 100_000;
|
||||||
|
const MAX_CELL_TEXT: usize = 1024 * 1024;
|
||||||
|
const MAX_TEXT: u64 = 64 * 1024 * 1024;
|
||||||
|
const MAX_SHEETS: usize = 1024;
|
||||||
|
|
||||||
|
/// Reads only the small, exact mimetype entry, including when the ZIP uses compression.
|
||||||
|
/// Looking at a filename or at arbitrary bytes inside the archive is not sufficient.
|
||||||
|
pub(crate) fn has_mimetype(bytes: &[u8]) -> bool {
|
||||||
|
let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false };
|
||||||
|
if zip.len() > MAX_ENTRIES {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(mut file) = zip.by_name("mimetype") else { return false };
|
||||||
|
if file.size() != MIME.len() as u64 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut value = Vec::new();
|
||||||
|
(&mut file).take(MIME.len() as u64 + 1).read_to_end(&mut value).is_ok() && value == MIME
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports ODS sheets, typed values, displayed text and merges. Cached formula results are
|
||||||
|
/// imported as constants, so editing their inputs does not recalculate the original formula.
|
||||||
|
/// Always inspect the returned warnings. Saving to ODS is not supported.
|
||||||
|
pub fn read_ods(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||||
|
let mut package = Package::open(bytes)?;
|
||||||
|
if !has_mimetype(bytes) {
|
||||||
|
return Err(IoError::Format("missing or invalid ODS mimetype".into()));
|
||||||
|
}
|
||||||
|
if let Some(manifest) = package.read("META-INF/manifest.xml")? {
|
||||||
|
check_manifest(&manifest)?;
|
||||||
|
}
|
||||||
|
let content = package.read("content.xml")?.ok_or_else(|| IoError::Format("ODS content.xml is missing".into()))?;
|
||||||
|
let mut import = Import::new();
|
||||||
|
import.parse(&content)?;
|
||||||
|
if import.wb.sheets.is_empty() {
|
||||||
|
return Err(IoError::Format("ODS contains no worksheets".into()));
|
||||||
|
}
|
||||||
|
Ok((import.wb, import.report))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ns(ns: ResolveResult<'_>, expected: &[u8]) -> bool {
|
||||||
|
matches!(ns, ResolveResult::Bound(n) if n.as_ref() == expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attr(reader: &NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result<Option<String>, IoError> {
|
||||||
|
let mut found = None;
|
||||||
|
for a in e.attributes() {
|
||||||
|
let a = a.map_err(|e| IoError::Xml(e.to_string()))?;
|
||||||
|
let (resolved, local) = reader.resolve_attribute(a.key);
|
||||||
|
if is_ns(resolved, ns) && local.as_ref() == name {
|
||||||
|
let value = a.unescape_value().map_err(|e| IoError::Xml(e.to_string()))?;
|
||||||
|
if value.len() > MAX_CELL_TEXT {
|
||||||
|
return Err(IoError::TooLarge("ODS attribute exceeds 1 MiB".into()));
|
||||||
|
}
|
||||||
|
found = Some(value.into_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn positive(value: Option<String>, default: u32, limit: u32) -> Result<u32, IoError> {
|
||||||
|
let n = match value {
|
||||||
|
Some(s) => s.parse::<u32>().map_err(|_| IoError::Format("invalid ODS repeat or span count".into()))?,
|
||||||
|
None => default,
|
||||||
|
};
|
||||||
|
if n == 0 || n > limit {
|
||||||
|
return Err(IoError::TooLarge("ODS repeat or span exceeds worksheet bounds".into()));
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_error(e: impl std::fmt::Display) -> IoError {
|
||||||
|
IoError::Xml(format!("ODS: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_manifest(bytes: &[u8]) -> Result<(), IoError> {
|
||||||
|
let mut reader = NsReader::from_reader(bytes);
|
||||||
|
let mut depth = 0usize;
|
||||||
|
let mut root_seen = false;
|
||||||
|
loop {
|
||||||
|
let event = reader.read_event().map_err(xml_error)?;
|
||||||
|
match &event {
|
||||||
|
Event::Start(e) | Event::Empty(e) => {
|
||||||
|
let (ns, name) = reader.resolve_element(e.name());
|
||||||
|
if is_ns(ns.clone(), MANIFEST) && name.as_ref() == b"encryption-data" {
|
||||||
|
return Err(IoError::Format("encrypted ODS files are not supported".into()));
|
||||||
|
}
|
||||||
|
if depth == 0 {
|
||||||
|
if root_seen || !is_ns(ns, MANIFEST) || name.as_ref() != b"manifest" {
|
||||||
|
return Err(xml_error("expected one manifest:manifest root"));
|
||||||
|
}
|
||||||
|
root_seen = true;
|
||||||
|
}
|
||||||
|
if matches!(event, Event::Start(_)) {
|
||||||
|
depth += 1;
|
||||||
|
if depth > crate::xml::MAX_DEPTH {
|
||||||
|
return Err(xml_error("manifest nested too deeply"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| xml_error("unexpected manifest closing element"))?,
|
||||||
|
Event::DocType(_) => return Err(xml_error("DOCTYPE is not supported")),
|
||||||
|
Event::Eof => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !root_seen || depth != 0 {
|
||||||
|
return Err(xml_error("incomplete manifest"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Tag {
|
||||||
|
Document,
|
||||||
|
Body,
|
||||||
|
Spreadsheet,
|
||||||
|
Table,
|
||||||
|
Rows,
|
||||||
|
Row,
|
||||||
|
Cell,
|
||||||
|
Covered,
|
||||||
|
Paragraph,
|
||||||
|
Inline,
|
||||||
|
Space,
|
||||||
|
Tab,
|
||||||
|
Break,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag(reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag {
|
||||||
|
let (ns, name) = reader.resolve_element(e.name());
|
||||||
|
let local = name.as_ref();
|
||||||
|
if is_ns(ns.clone(), OFFICE) {
|
||||||
|
return match local {
|
||||||
|
b"document-content" => Tag::Document,
|
||||||
|
b"body" => Tag::Body,
|
||||||
|
b"spreadsheet" => Tag::Spreadsheet,
|
||||||
|
_ => Tag::Other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if is_ns(ns.clone(), TABLE) {
|
||||||
|
return match local {
|
||||||
|
b"table" => Tag::Table,
|
||||||
|
b"table-row-group" | b"table-header-rows" | b"table-rows" => Tag::Rows,
|
||||||
|
b"table-row" => Tag::Row,
|
||||||
|
b"table-cell" => Tag::Cell,
|
||||||
|
b"covered-table-cell" => Tag::Covered,
|
||||||
|
_ => Tag::Other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if is_ns(ns, TEXT) {
|
||||||
|
return match local {
|
||||||
|
b"p" | b"h" => Tag::Paragraph,
|
||||||
|
b"span" | b"a" | b"meta" | b"meta-field" | b"ruby" | b"ruby-base" => Tag::Inline,
|
||||||
|
b"s" => Tag::Space,
|
||||||
|
b"tab" => Tag::Tab,
|
||||||
|
b"line-break" => Tag::Break,
|
||||||
|
_ => Tag::Other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Tag::Other
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SheetDraft {
|
||||||
|
sheet: Sheet,
|
||||||
|
depth: usize,
|
||||||
|
row: u32,
|
||||||
|
}
|
||||||
|
struct RowDraft {
|
||||||
|
depth: usize,
|
||||||
|
repeat: u32,
|
||||||
|
col: u32,
|
||||||
|
cells: Vec<RepeatedCell>,
|
||||||
|
}
|
||||||
|
struct RepeatedCell {
|
||||||
|
col: u32,
|
||||||
|
repeat: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
cell: Cell,
|
||||||
|
}
|
||||||
|
struct CellDraft {
|
||||||
|
depth: usize,
|
||||||
|
repeat: u32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
covered: bool,
|
||||||
|
kind: Option<String>,
|
||||||
|
raw: Option<String>,
|
||||||
|
formula: bool,
|
||||||
|
text: String,
|
||||||
|
paragraph: Option<usize>,
|
||||||
|
paragraphs: usize,
|
||||||
|
paragraph_started: bool,
|
||||||
|
pending_space: bool,
|
||||||
|
}
|
||||||
|
struct Import {
|
||||||
|
wb: Workbook,
|
||||||
|
report: ReadReport,
|
||||||
|
stack: Vec<Tag>,
|
||||||
|
sheet: Option<SheetDraft>,
|
||||||
|
row: Option<RowDraft>,
|
||||||
|
cell: Option<CellDraft>,
|
||||||
|
cells: u64,
|
||||||
|
merges: u64,
|
||||||
|
text: u64,
|
||||||
|
formats: Vec<(&'static str, StyleId)>,
|
||||||
|
spreadsheet_seen: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Import {
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut wb = Workbook::new();
|
||||||
|
wb.sheets.clear();
|
||||||
|
let mut report = ReadReport::default();
|
||||||
|
report.warn("ODS data-only import: formulas become their saved results and will not recalculate. Source formatting, charts, images and other features are not imported. Save a new .xlsx file; ODS saving is not supported.");
|
||||||
|
Self { wb, report, stack: vec![], sheet: None, row: None, cell: None, cells: 0, merges: 0, text: 0, formats: vec![], spreadsheet_seen: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(&mut self, bytes: &[u8]) -> Result<(), IoError> {
|
||||||
|
let mut reader = NsReader::from_reader(bytes);
|
||||||
|
let mut root_seen = false;
|
||||||
|
loop {
|
||||||
|
match reader.read_event().map_err(xml_error)? {
|
||||||
|
Event::Start(e) => {
|
||||||
|
if self.stack.len() >= crate::xml::MAX_DEPTH {
|
||||||
|
return Err(xml_error("XML nested too deeply"));
|
||||||
|
}
|
||||||
|
if self.stack.is_empty() {
|
||||||
|
if root_seen || tag(&reader, &e) != Tag::Document {
|
||||||
|
return Err(xml_error("expected one office:document-content root"));
|
||||||
|
}
|
||||||
|
root_seen = true;
|
||||||
|
}
|
||||||
|
self.start(&reader, &e)?;
|
||||||
|
}
|
||||||
|
Event::Empty(e) => {
|
||||||
|
if self.stack.is_empty() {
|
||||||
|
return Err(xml_error("expected office:document-content with a spreadsheet"));
|
||||||
|
}
|
||||||
|
self.start(&reader, &e)?;
|
||||||
|
self.end()?;
|
||||||
|
}
|
||||||
|
Event::End(_) => self.end()?,
|
||||||
|
Event::Text(t) => {
|
||||||
|
let text = t.xml_content().map_err(xml_error)?;
|
||||||
|
if self.stack.is_empty() && !text.trim().is_empty() {
|
||||||
|
return Err(xml_error("text outside the document"));
|
||||||
|
}
|
||||||
|
self.append(&text)?;
|
||||||
|
}
|
||||||
|
Event::CData(t) => self.append(&t.decode().map_err(xml_error)?)?,
|
||||||
|
Event::GeneralRef(r) => {
|
||||||
|
let ch = match r.resolve_char_ref().map_err(xml_error)? {
|
||||||
|
Some(c) => c,
|
||||||
|
None => match r.as_ref() {
|
||||||
|
b"amp" => '&',
|
||||||
|
b"lt" => '<',
|
||||||
|
b"gt" => '>',
|
||||||
|
b"apos" => '\'',
|
||||||
|
b"quot" => '"',
|
||||||
|
_ => return Err(xml_error("unsupported XML entity")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.append(ch.encode_utf8(&mut [0; 4]))?;
|
||||||
|
}
|
||||||
|
Event::DocType(_) => return Err(xml_error("DOCTYPE is not supported")),
|
||||||
|
Event::Eof => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !root_seen || !self.stack.is_empty() || !self.spreadsheet_seen {
|
||||||
|
return Err(xml_error("incomplete ODS document"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self, reader: &NsReader<&[u8]>, e: &BytesStart<'_>) -> Result<(), IoError> {
|
||||||
|
let t = tag(reader, e);
|
||||||
|
let depth = self.stack.len();
|
||||||
|
match t {
|
||||||
|
Tag::Spreadsheet if self.stack == [Tag::Document, Tag::Body] => {
|
||||||
|
if self.spreadsheet_seen {
|
||||||
|
return Err(xml_error("multiple spreadsheets in one document"));
|
||||||
|
}
|
||||||
|
self.spreadsheet_seen = true;
|
||||||
|
}
|
||||||
|
Tag::Table if self.stack == [Tag::Document, Tag::Body, Tag::Spreadsheet] => {
|
||||||
|
if self.wb.sheets.len() >= MAX_SHEETS {
|
||||||
|
return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into()));
|
||||||
|
}
|
||||||
|
let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default();
|
||||||
|
let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len());
|
||||||
|
if self.wb.check_sheet_name(&name, None).is_err() {
|
||||||
|
name = self.wb.next_sheet_name();
|
||||||
|
}
|
||||||
|
if name != original {
|
||||||
|
self.report.warn("Some ODS sheet names were shortened or renamed to valid, unique XLSX sheet names.");
|
||||||
|
}
|
||||||
|
self.sheet = Some(SheetDraft { sheet: Sheet::new(name), depth, row: 0 });
|
||||||
|
}
|
||||||
|
Tag::Row if self.sheet.as_ref().is_some_and(|s| self.stack.get(s.depth + 1..).is_some_and(|p| p.iter().all(|t| *t == Tag::Rows))) => {
|
||||||
|
self.row =
|
||||||
|
Some(RowDraft { depth, repeat: positive(attr(reader, e, TABLE, b"number-rows-repeated")?, 1, MAX_ROWS)?, col: 0, cells: vec![] });
|
||||||
|
}
|
||||||
|
Tag::Cell | Tag::Covered if self.row.as_ref().is_some_and(|r| depth == r.depth + 1) => {
|
||||||
|
let kind = if attr(reader, e, CALC_EXT, b"value-type")?.as_deref() == Some("error") {
|
||||||
|
Some("error".into())
|
||||||
|
} else {
|
||||||
|
attr(reader, e, OFFICE, b"value-type")?
|
||||||
|
};
|
||||||
|
let key: &[u8] = match kind.as_deref() {
|
||||||
|
Some("boolean") => b"boolean-value",
|
||||||
|
Some("date") => b"date-value",
|
||||||
|
Some("time") => b"time-value",
|
||||||
|
Some("string") => b"string-value",
|
||||||
|
_ => b"value",
|
||||||
|
};
|
||||||
|
self.cell = Some(CellDraft {
|
||||||
|
depth,
|
||||||
|
repeat: positive(attr(reader, e, TABLE, b"number-columns-repeated")?, 1, MAX_COLS)?,
|
||||||
|
width: positive(attr(reader, e, TABLE, b"number-columns-spanned")?, 1, MAX_COLS)?,
|
||||||
|
height: positive(attr(reader, e, TABLE, b"number-rows-spanned")?, 1, MAX_ROWS)?,
|
||||||
|
covered: t == Tag::Covered,
|
||||||
|
kind,
|
||||||
|
raw: attr(reader, e, OFFICE, key)?,
|
||||||
|
formula: attr(reader, e, TABLE, b"formula")?.is_some(),
|
||||||
|
text: String::new(),
|
||||||
|
paragraph: None,
|
||||||
|
paragraphs: 0,
|
||||||
|
paragraph_started: false,
|
||||||
|
pending_space: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Tag::Paragraph if self.cell.as_ref().is_some_and(|c| depth == c.depth + 1) => {
|
||||||
|
if let Some(cell) = self.cell.as_mut() {
|
||||||
|
if cell.paragraphs > 0 {
|
||||||
|
push_text(&mut cell.text, "\n")?;
|
||||||
|
}
|
||||||
|
cell.paragraph = Some(depth);
|
||||||
|
cell.paragraphs += 1;
|
||||||
|
cell.paragraph_started = false;
|
||||||
|
cell.pending_space = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Tag::Space | Tag::Tab | Tag::Break if self.captures_text() => {
|
||||||
|
let count = if t == Tag::Space {
|
||||||
|
let n = attr(reader, e, TEXT, b"c")?.map(|s| s.parse::<u32>()).transpose().map_err(xml_error)?.unwrap_or(1);
|
||||||
|
if n as usize > MAX_CELL_TEXT {
|
||||||
|
return Err(IoError::TooLarge("ODS repeated spaces exceed 1 MiB".into()));
|
||||||
|
}
|
||||||
|
n
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let value = match t {
|
||||||
|
Tag::Space => " ",
|
||||||
|
Tag::Tab => "\t",
|
||||||
|
_ => "\n",
|
||||||
|
};
|
||||||
|
if let Some(cell) = self.cell.as_mut() {
|
||||||
|
if cell.pending_space {
|
||||||
|
push_text(&mut cell.text, " ")?;
|
||||||
|
cell.pending_space = false;
|
||||||
|
}
|
||||||
|
cell.paragraph_started = true;
|
||||||
|
if cell.text.len().saturating_add(count as usize) > MAX_CELL_TEXT {
|
||||||
|
return Err(IoError::TooLarge("ODS cell text exceeds 1 MiB".into()));
|
||||||
|
}
|
||||||
|
for _ in 0..count {
|
||||||
|
cell.text.push_str(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
self.stack.push(t);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn captures_text(&self) -> bool {
|
||||||
|
self.cell
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|c| c.paragraph)
|
||||||
|
.is_some_and(|depth| self.stack.get(depth..).is_some_and(|p| p.iter().all(|t| matches!(t, Tag::Paragraph | Tag::Inline))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append(&mut self, text: &str) -> Result<(), IoError> {
|
||||||
|
if self.captures_text()
|
||||||
|
&& let Some(cell) = self.cell.as_mut()
|
||||||
|
{
|
||||||
|
// ODF 1.3 section 6.1.2: collapse XML whitespace across inline runs,
|
||||||
|
// but expand explicit text:s/tab/line-break elements separately.
|
||||||
|
for ch in text.chars() {
|
||||||
|
if matches!(ch, ' ' | '\t' | '\n' | '\r') {
|
||||||
|
cell.pending_space = cell.paragraph_started;
|
||||||
|
} else {
|
||||||
|
if cell.pending_space {
|
||||||
|
push_text(&mut cell.text, " ")?;
|
||||||
|
cell.pending_space = false;
|
||||||
|
}
|
||||||
|
push_text(&mut cell.text, ch.encode_utf8(&mut [0; 4]))?;
|
||||||
|
cell.paragraph_started = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end(&mut self) -> Result<(), IoError> {
|
||||||
|
self.stack.pop().ok_or_else(|| xml_error("unexpected closing element"))?;
|
||||||
|
let depth = self.stack.len();
|
||||||
|
if let Some(cell) = self.cell.as_mut()
|
||||||
|
&& cell.paragraph == Some(depth)
|
||||||
|
{
|
||||||
|
cell.paragraph = None;
|
||||||
|
}
|
||||||
|
if self.cell.as_ref().is_some_and(|c| c.depth == depth) {
|
||||||
|
self.finish_cell()?;
|
||||||
|
}
|
||||||
|
if self.row.as_ref().is_some_and(|r| r.depth == depth) {
|
||||||
|
self.finish_row()?;
|
||||||
|
}
|
||||||
|
if self.sheet.as_ref().is_some_and(|s| s.depth == depth)
|
||||||
|
&& let Some(sheet) = self.sheet.take()
|
||||||
|
{
|
||||||
|
self.wb.sheets.push(Arc::new(sheet.sheet));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_cell(&mut self) -> Result<(), IoError> {
|
||||||
|
let Some(draft) = self.cell.take() else { return Ok(()) };
|
||||||
|
let cell = self.convert(&draft);
|
||||||
|
let (width, height) = if draft.covered { (1, 1) } else { (draft.width, draft.height) };
|
||||||
|
let Some(row) = self.row.as_mut() else { return Ok(()) };
|
||||||
|
let end = row
|
||||||
|
.col
|
||||||
|
.checked_add(draft.repeat)
|
||||||
|
.filter(|n| *n <= MAX_COLS)
|
||||||
|
.ok_or_else(|| IoError::TooLarge("ODS row exceeds worksheet width".into()))?;
|
||||||
|
if !cell.is_blank() || width > 1 || height > 1 {
|
||||||
|
row.cells.push(RepeatedCell { col: row.col, repeat: draft.repeat, width, height, cell });
|
||||||
|
}
|
||||||
|
row.col = end;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(&mut self, draft: &CellDraft) -> Cell {
|
||||||
|
let raw = draft.raw.as_deref();
|
||||||
|
let mut format = None;
|
||||||
|
let value = match draft.kind.as_deref() {
|
||||||
|
Some("float" | "percentage" | "currency") => raw.and_then(|s| s.parse::<f64>().ok()).filter(|n| n.is_finite()).map(|n| {
|
||||||
|
if draft.kind.as_deref() == Some("percentage") {
|
||||||
|
format = Some("0.00%");
|
||||||
|
}
|
||||||
|
Value::Number(n)
|
||||||
|
}),
|
||||||
|
Some("boolean") => match raw {
|
||||||
|
Some("true" | "1") => Some(Value::Bool(true)),
|
||||||
|
Some("false" | "0") => Some(Value::Bool(false)),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Some("string") if raw.is_some() || draft.paragraphs > 0 || !draft.formula => Some(Value::Text(Arc::from(raw.unwrap_or(&draft.text)))),
|
||||||
|
Some("void") if !draft.formula => Some(Value::Empty),
|
||||||
|
Some("date") => raw.and_then(parse_date).map(|(n, f)| {
|
||||||
|
format = Some(f);
|
||||||
|
Value::Number(n)
|
||||||
|
}),
|
||||||
|
Some("time") => raw.and_then(parse_duration).map(|n| {
|
||||||
|
format = Some("[h]:mm:ss.000");
|
||||||
|
Value::Number(n)
|
||||||
|
}),
|
||||||
|
None if !draft.formula => Some(if draft.text.is_empty() { Value::Empty } else { Value::Text(Arc::from(draft.text.as_str())) }),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let value = value.unwrap_or_else(|| {
|
||||||
|
self.report.warn("Some ODS values or formula caches were unsupported or missing; their displayed text (or raw value) was kept as text. A formula without either is marked [Unavailable ODS formula result].");
|
||||||
|
let text = if !draft.text.is_empty() { draft.text.as_str() } else if let Some(raw) = raw { raw } else if draft.formula { "[Unavailable ODS formula result]" } else { "[Unsupported ODS value]" };
|
||||||
|
Value::Text(Arc::from(text))
|
||||||
|
});
|
||||||
|
let style = match format {
|
||||||
|
Some(code) => match self.formats.iter().find(|(f, _)| *f == code) {
|
||||||
|
Some((_, id)) => *id,
|
||||||
|
None => {
|
||||||
|
let id = self.wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code));
|
||||||
|
self.formats.push((code, id));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => StyleId::DEFAULT,
|
||||||
|
};
|
||||||
|
Cell { value, formula: None, style }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_row(&mut self) -> Result<(), IoError> {
|
||||||
|
let Some(row) = self.row.take() else { return Ok(()) };
|
||||||
|
let Some(sheet) = self.sheet.as_mut() else { return Ok(()) };
|
||||||
|
let end =
|
||||||
|
sheet.row.checked_add(row.repeat).filter(|n| *n <= MAX_ROWS).ok_or_else(|| IoError::TooLarge("ODS exceeds worksheet height".into()))?;
|
||||||
|
for entry in row.cells {
|
||||||
|
let count = u64::from(entry.repeat) * u64::from(row.repeat);
|
||||||
|
if !entry.cell.is_blank() {
|
||||||
|
self.cells = self.cells.saturating_add(count);
|
||||||
|
if let Value::Text(t) = &entry.cell.value {
|
||||||
|
self.text = self.text.saturating_add(count.saturating_mul(t.len() as u64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let merged = entry.width > 1 || entry.height > 1;
|
||||||
|
if merged {
|
||||||
|
self.merges = self.merges.saturating_add(count);
|
||||||
|
}
|
||||||
|
if self.cells > MAX_CELLS || self.merges > MAX_MERGES || self.text > MAX_TEXT {
|
||||||
|
return Err(IoError::TooLarge("ODS expands beyond the import limit (1 million cells, 100000 merges or 64 MiB of text)".into()));
|
||||||
|
}
|
||||||
|
let last_col = entry.col.checked_add(entry.repeat - 1).and_then(|c| c.checked_add(entry.width - 1));
|
||||||
|
let last_row = end.checked_add(entry.height - 1);
|
||||||
|
if last_col.is_none_or(|c| c >= MAX_COLS) || last_row.is_none_or(|r| r > MAX_ROWS) {
|
||||||
|
return Err(IoError::TooLarge("ODS merged cell exceeds worksheet bounds".into()));
|
||||||
|
}
|
||||||
|
if merged && ((entry.repeat > 1 && entry.width > 1) || (row.repeat > 1 && entry.height > 1)) {
|
||||||
|
return Err(IoError::Format("repeated ODS cells have overlapping merges".into()));
|
||||||
|
}
|
||||||
|
for r in sheet.row..end {
|
||||||
|
for c in entry.col..entry.col + entry.repeat {
|
||||||
|
let pos = CellRef::new(r, c);
|
||||||
|
if !entry.cell.is_blank() {
|
||||||
|
sheet.sheet.cells.set(pos, entry.cell.clone());
|
||||||
|
}
|
||||||
|
if merged {
|
||||||
|
sheet.sheet.merges.push(RangeRef::new(pos, CellRef::new(r + entry.height - 1, c + entry.width - 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sheet.row = end;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_text(out: &mut String, text: &str) -> Result<(), IoError> {
|
||||||
|
if out.len().saturating_add(text.len()) > MAX_CELL_TEXT {
|
||||||
|
return Err(IoError::TooLarge("ODS cell text exceeds 1 MiB".into()));
|
||||||
|
}
|
||||||
|
out.push_str(text);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Support unzoned ISO calendar dates and times. A timezone, invalid date or out-of-range
|
||||||
|
/// calendar year remains text; never silently discard an offset or roll an invalid date.
|
||||||
|
fn parse_date(s: &str) -> Option<(f64, &'static str)> {
|
||||||
|
let (date, time) = s.split_once('T').map_or((s, None), |(d, t)| (d, Some(t)));
|
||||||
|
if date.len() != 10 || date.get(4..5)? != "-" || date.get(7..8)? != "-" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let year: i32 = digits(date.get(..4)?)?.parse().ok()?;
|
||||||
|
let month: u32 = digits(date.get(5..7)?)?.parse().ok()?;
|
||||||
|
let day: u32 = digits(date.get(8..10)?)?.parse().ok()?;
|
||||||
|
if !(1900..=9999).contains(&year) || !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let day = serial_from_ymd(DateSystem::D1900, year.into(), month.into(), day.into())?;
|
||||||
|
let Some(time) = time else { return Some((day, "yyyy-mm-dd")) };
|
||||||
|
let mut parts = time.split(':');
|
||||||
|
let h = parts.next()?;
|
||||||
|
let m = parts.next()?;
|
||||||
|
let sec = parts.next()?;
|
||||||
|
if parts.next().is_some() || h.len() != 2 || m.len() != 2 || sec.split('.').next()?.len() != 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let h: u32 = digits(h)?.parse().ok()?;
|
||||||
|
let m: u32 = digits(m)?.parse().ok()?;
|
||||||
|
let sec = decimal(sec)?;
|
||||||
|
if h >= 24 || m >= 60 || sec >= 60.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((day + (h as f64 * 3600.0 + m as f64 * 60.0 + sec) / 86400.0, "yyyy-mm-dd hh:mm:ss.000"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digits(s: &str) -> Option<&str> {
|
||||||
|
(!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())).then_some(s)
|
||||||
|
}
|
||||||
|
fn decimal(s: &str) -> Option<f64> {
|
||||||
|
match s.split_once('.') {
|
||||||
|
Some((a, b)) => {
|
||||||
|
digits(a)?;
|
||||||
|
digits(b)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
digits(s)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.parse::<f64>().ok().filter(|n| n.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bounded ISO day/time duration. Calendar years/months cannot be represented as days.
|
||||||
|
fn parse_duration(s: &str) -> Option<f64> {
|
||||||
|
let (negative, s) = s.strip_prefix('-').map_or((false, s), |s| (true, s));
|
||||||
|
let mut rest = s.strip_prefix('P')?;
|
||||||
|
let mut total = 0.0;
|
||||||
|
let mut any = false;
|
||||||
|
if let Some((days, tail)) = rest.split_once('D') {
|
||||||
|
total += digits(days)?.parse::<f64>().ok()? * 86400.0;
|
||||||
|
rest = tail;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
if let Some(mut time) = rest.strip_prefix('T') {
|
||||||
|
let mut time_any = false;
|
||||||
|
for (unit, factor) in [('H', 3600.0), ('M', 60.0), ('S', 1.0)] {
|
||||||
|
if let Some((n, tail)) = time.split_once(unit) {
|
||||||
|
let value = if unit == 'S' { decimal(n)? } else { digits(n)?.parse::<f64>().ok()? };
|
||||||
|
total += value * factor;
|
||||||
|
time = tail;
|
||||||
|
time_any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !time.is_empty() || !time_any {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
any = true;
|
||||||
|
} else if !rest.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let days = total / 86400.0;
|
||||||
|
(any && days.is_finite() && days <= 2_958_465.0).then_some(if negative { -days } else { days })
|
||||||
|
}
|
||||||
|
|
@ -283,7 +283,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||||
Ok((wb, cx.report))
|
Ok((wb, cx.report))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unique_name(wb: &Workbook, name: &str, i: usize) -> String {
|
pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String {
|
||||||
let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
||||||
if base.trim().is_empty() {
|
if base.trim().is_empty() {
|
||||||
base = format!("Sheet{}", i + 1);
|
base = format!("Sheet{}", i + 1);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
mod csv_tests;
|
mod csv_tests;
|
||||||
mod fixtures;
|
mod fixtures;
|
||||||
mod malformed;
|
mod malformed;
|
||||||
|
mod ods_tests;
|
||||||
mod pivot_tests;
|
mod pivot_tests;
|
||||||
mod roundtrip;
|
mod roundtrip;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
//! Original ODS packages assembled in Rust, not files from another spreadsheet application.
|
||||||
|
|
||||||
|
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||||
|
|
||||||
|
use super::make_zip;
|
||||||
|
use crate::{Format, IoError, read_ods, read_xlsx, sniff, write_xlsx};
|
||||||
|
|
||||||
|
const MIME: &[u8] = b"application/vnd.oasis.opendocument.spreadsheet";
|
||||||
|
const MANIFEST: &[u8] = br#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/></manifest:manifest>"#;
|
||||||
|
|
||||||
|
fn content(tables: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0"?><office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:of="urn:oasis:names:tc:opendocument:xmlns:of:1.2"><office:body><office:spreadsheet>{tables}</office:spreadsheet></office:body></office:document-content>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn ods(tables: &str) -> Vec<u8> {
|
||||||
|
make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", MANIFEST), ("content.xml", content(tables).as_bytes())])
|
||||||
|
}
|
||||||
|
fn row(cells: &str) -> Vec<u8> {
|
||||||
|
ods(&format!(r#"<table:table table:name="Data"><table:table-row>{cells}</table:table-row></table:table>"#))
|
||||||
|
}
|
||||||
|
fn text(s: &str) -> Value {
|
||||||
|
Value::Text(s.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_typed_values_and_formula_caches_are_constants_and_survive_xlsx() {
|
||||||
|
let bytes = row(r#"
|
||||||
|
<table:table-cell office:value-type="float" office:value="42.5"/>
|
||||||
|
<table:table-cell office:value-type="boolean" office:boolean-value="true"/>
|
||||||
|
<table:table-cell office:value-type="percentage" office:value="0.25"/>
|
||||||
|
<table:table-cell office:value-type="currency" office:currency="USD" office:value="19.99"/>
|
||||||
|
<table:table-cell office:value-type="string" office:string-value="literal =1+1"/>
|
||||||
|
<table:table-cell table:formula="of:=A1*2" office:value-type="float" office:value="85"><text:p>85</text:p></table:table-cell>
|
||||||
|
<table:table-cell table:formula="of:=1/0" office:value-type="string"><text:p>#DIV/0!</text:p></table:table-cell>
|
||||||
|
<table:table-cell table:formula="of:=UNKNOWN()"/>
|
||||||
|
<table:table-cell office:value-type="float" office:value="NaN"><text:p>unavailable</text:p></table:table-cell>
|
||||||
|
<table:table-cell office:value-type="boolean" office:boolean-value="0"/>
|
||||||
|
"#);
|
||||||
|
let (wb, report) = read_ods(&bytes).unwrap();
|
||||||
|
assert!(report.warnings.iter().any(|w| w.contains("data-only") && w.contains("will not recalculate")));
|
||||||
|
assert!(report.warnings.iter().any(|w| w.contains("unsupported or missing")));
|
||||||
|
let expected = [
|
||||||
|
Value::Number(42.5),
|
||||||
|
Value::Bool(true),
|
||||||
|
Value::Number(0.25),
|
||||||
|
Value::Number(19.99),
|
||||||
|
text("literal =1+1"),
|
||||||
|
Value::Number(85.0),
|
||||||
|
text("#DIV/0!"),
|
||||||
|
text("[Unavailable ODS formula result]"),
|
||||||
|
text("unavailable"),
|
||||||
|
Value::Bool(false),
|
||||||
|
];
|
||||||
|
for (c, value) in expected.iter().enumerate() {
|
||||||
|
assert_eq!(&wb.sheets[0].value(CellRef::new(0, c as u32)), value);
|
||||||
|
assert!(wb.sheets[0].cell(CellRef::new(0, c as u32)).unwrap().formula.is_none());
|
||||||
|
}
|
||||||
|
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||||
|
for (c, value) in expected.iter().enumerate() {
|
||||||
|
assert_eq!(&reopened.sheets[0].value(CellRef::new(0, c as u32)), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_ordered_text_preserves_paragraphs_runs_entities_spaces_tabs_and_breaks() {
|
||||||
|
let bytes = row(
|
||||||
|
r#"<table:table-cell office:value-type="string"><text:p>one <text:span>two & three</text:span> four<text:s text:c="3"/><text:a>five</text:a><text:tab/>six<text:line-break/>seven!<![CDATA[<eight>]]></text:p><text:p>nine</text:p><office:annotation><text:p>not cell text</text:p></office:annotation></table:table-cell>"#,
|
||||||
|
);
|
||||||
|
let (wb, _) = read_ods(&bytes).unwrap();
|
||||||
|
assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("one two & three four five\tsix\nseven!<eight>\nnine"));
|
||||||
|
let bytes = row(r#"<table:table-cell office:value-type="string"><text:p> one
|
||||||
|
<text:span> two </text:span> three <text:s text:c="0"/>four<text:s text:c="2"/> </text:p><text:p><text:s/> five </text:p></table:table-cell>"#);
|
||||||
|
assert_eq!(read_ods(&bytes).unwrap().0.sheets[0].value(CellRef::new(0, 0)), text("one two three four \n five"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_repeats_merges_sheet_names_and_empty_tail_remain_sparse() {
|
||||||
|
let bytes = ods(r#"
|
||||||
|
<table:table table:name="First & Second">
|
||||||
|
<table:table-header-rows><table:table-row><table:table-cell table:number-columns-spanned="2" table:number-rows-spanned="2" office:value-type="string"><text:p>merged</text:p></table:table-cell><table:covered-table-cell office:value-type="float" office:value="99"/></table:table-row></table:table-header-rows>
|
||||||
|
<table:table-row><table:covered-table-cell table:number-columns-repeated="2"/></table:table-row>
|
||||||
|
<table:table-row-group><table:table-row table:number-rows-repeated="2"><table:table-cell table:number-columns-repeated="3" office:value-type="float" office:value="7"/></table:table-row></table:table-row-group>
|
||||||
|
<table:table-row table:number-rows-repeated="1048572"><table:table-cell table:number-columns-repeated="16384"/></table:table-row>
|
||||||
|
</table:table>
|
||||||
|
<table:table table:name="Another"><table:table-row><table:table-cell office:value-type="string" office:string-value="second sheet"/></table:table-row></table:table>
|
||||||
|
"#);
|
||||||
|
let (wb, _) = read_ods(&bytes).unwrap();
|
||||||
|
assert_eq!(wb.sheets.len(), 2);
|
||||||
|
assert_eq!(wb.sheets[0].name, "First & Second");
|
||||||
|
assert_eq!(wb.sheets[0].cells.len(), 8);
|
||||||
|
assert_eq!(wb.sheets[0].value(CellRef::new(0, 1)), Value::Number(99.0));
|
||||||
|
assert_eq!(wb.sheets[0].merges, vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(1, 1))]);
|
||||||
|
assert_eq!(wb.sheets[0].value(CellRef::new(3, 2)), Value::Number(7.0));
|
||||||
|
assert_eq!(wb.sheets[1].value(CellRef::new(0, 0)), text("second sheet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_dates_and_durations_use_formats_and_unsupported_values_stay_text() {
|
||||||
|
let bytes = row(r#"
|
||||||
|
<table:table-cell office:value-type="date" office:date-value="1900-02-28"/>
|
||||||
|
<table:table-cell office:value-type="date" office:date-value="2026-10-07T18:30:00.125"/>
|
||||||
|
<table:table-cell office:value-type="time" office:time-value="P1DT2H3M4.5S"/>
|
||||||
|
<table:table-cell office:value-type="time" office:time-value="-PT2H"/>
|
||||||
|
<table:table-cell office:value-type="date" office:date-value="1900-02-29"/>
|
||||||
|
<table:table-cell office:value-type="date" office:date-value="2026-10-07T18:00:00Z"/>
|
||||||
|
<table:table-cell office:value-type="time" office:time-value="P1M"/>
|
||||||
|
<table:table-cell office:value-type="time" office:time-value="PT2M1H"/>
|
||||||
|
"#);
|
||||||
|
let (wb, report) = read_ods(&bytes).unwrap();
|
||||||
|
let s = &wb.sheets[0];
|
||||||
|
assert_eq!(s.value(CellRef::new(0, 0)), Value::Number(59.0));
|
||||||
|
assert_eq!(s.value(CellRef::new(0, 1)), Value::Number(46302.0 + 66600.125 / 86400.0));
|
||||||
|
assert_eq!(s.value(CellRef::new(0, 2)), Value::Number(1.0 + 7384.5 / 86400.0));
|
||||||
|
assert_eq!(s.value(CellRef::new(0, 3)), Value::Number(-2.0 / 24.0));
|
||||||
|
for (c, value) in [(4, "1900-02-29"), (5, "2026-10-07T18:00:00Z"), (6, "P1M"), (7, "PT2M1H")] {
|
||||||
|
assert_eq!(s.value(CellRef::new(0, c)), text(value));
|
||||||
|
}
|
||||||
|
assert_eq!(wb.styles.get(s.cell(CellRef::new(0, 0)).unwrap().style).num_fmt.0.as_ref(), "yyyy-mm-dd");
|
||||||
|
assert_eq!(wb.styles.get(s.cell(CellRef::new(0, 2)).unwrap().style).num_fmt.0.as_ref(), "[h]:mm:ss.000");
|
||||||
|
assert_eq!(report.warnings.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_namespaces_are_resolved_not_inferred_from_prefix_or_local_name() {
|
||||||
|
let xml = content(r#"<table:table table:name="Actual"><table:table-row><table:table-cell office:value-type="float" office:value="9"/></table:table-row></table:table>"#)
|
||||||
|
.replace("<office:", "<o:").replace("</office:", "</o:").replace(" office:", " o:").replace("xmlns:office=", "xmlns:o=")
|
||||||
|
.replace("<table:", "<t:").replace("</table:", "</t:").replace(" table:", " t:").replace("xmlns:table=", "xmlns:t=");
|
||||||
|
let bytes = make_zip(&[("mimetype", MIME), ("content.xml", xml.as_bytes())]);
|
||||||
|
assert_eq!(read_ods(&bytes).unwrap().0.sheets[0].value(CellRef::new(0, 0)), Value::Number(9.0));
|
||||||
|
let spoofed = xml.replace("urn:oasis:names:tc:opendocument:xmlns:table:1.0", "wrong-namespace");
|
||||||
|
assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", spoofed.as_bytes())])).is_err());
|
||||||
|
let bytes =
|
||||||
|
row(r#"<table:table-cell xmlns:fake="wrong-namespace" fake:value-type="float" fake:value="123"><text:p>kept</text:p></table:table-cell>"#);
|
||||||
|
assert_eq!(read_ods(&bytes).unwrap().0.sheets[0].value(CellRef::new(0, 0)), text("kept"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_rejects_bad_packages_encryption_and_truncated_xml() {
|
||||||
|
assert!(read_ods(b"not zip").is_err());
|
||||||
|
let xml = content(r#"<table:table table:name="Data"/>"#);
|
||||||
|
for mime in [b"application/zip".as_slice(), b"application/vnd.oasis.opendocument.spreadsheet "] {
|
||||||
|
assert!(read_ods(&make_zip(&[("mimetype", mime), ("content.xml", xml.as_bytes())])).is_err());
|
||||||
|
}
|
||||||
|
let encrypted =
|
||||||
|
br#"<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:encryption-data/></manifest:manifest>"#;
|
||||||
|
assert!(
|
||||||
|
read_ods(&make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", encrypted), ("content.xml", xml.as_bytes())]))
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("encrypted")
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
xml.trim_end_matches("</office:document-content>").to_owned(),
|
||||||
|
xml.replace("</office:spreadsheet>", "</office:wrong>"),
|
||||||
|
"<root/>".to_owned(),
|
||||||
|
format!("{xml}{xml}"),
|
||||||
|
] {
|
||||||
|
assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", bad.as_bytes())])).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
let deep = content(&format!("{}{}", "<x>".repeat(300), "</x>".repeat(300)));
|
||||||
|
assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", deep.as_bytes())])).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_expansion_bounds_stop_small_repeat_bombs() {
|
||||||
|
for cells in [
|
||||||
|
r#"<table:table-cell table:number-columns-repeated="4294967295"/>"#,
|
||||||
|
r#"<table:table-cell table:number-columns-repeated="0"/>"#,
|
||||||
|
r#"<table:table-cell table:number-columns-repeated="16384"/><table:table-cell/>"#,
|
||||||
|
r#"<table:table-cell table:number-columns-repeated="16384" table:number-columns-spanned="2" office:value-type="float" office:value="1"/>"#,
|
||||||
|
r#"<table:table-cell office:value-type="string"><text:p><text:s text:c="4294967295"/></text:p></table:table-cell>"#,
|
||||||
|
] {
|
||||||
|
assert!(read_ods(&row(cells)).is_err(), "{cells}");
|
||||||
|
}
|
||||||
|
let cells = ods(
|
||||||
|
r#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="1048576"><table:table-cell office:value-type="float" office:value="1"/></table:table-row></table:table>"#,
|
||||||
|
);
|
||||||
|
assert!(matches!(read_ods(&cells), Err(IoError::TooLarge(_))));
|
||||||
|
let text = format!(
|
||||||
|
r#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="100000"><table:table-cell office:value-type="string" office:string-value="{}"/></table:table-row></table:table>"#,
|
||||||
|
"x".repeat(1024)
|
||||||
|
);
|
||||||
|
assert!(matches!(read_ods(&ods(&text)), Err(IoError::TooLarge(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_sniff_requires_exact_bounded_mimetype() {
|
||||||
|
assert_eq!(sniff(&row("")), Format::Ods);
|
||||||
|
assert_eq!(sniff(&make_zip(&[("mimetype", b"application/zip")])), Format::Xlsx);
|
||||||
|
assert_eq!(sniff(&make_zip(&[("elsewhere", MIME)])), Format::Xlsx);
|
||||||
|
assert_eq!(sniff(&make_zip(&[("mimetype", &vec![b'x'; 1_000_000])])), Format::Xlsx);
|
||||||
|
assert_eq!(sniff(b"a,b\n1,2"), Format::Csv);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_invalid_sheet_names_are_made_safe_for_xlsx() {
|
||||||
|
let long = "A".repeat(50);
|
||||||
|
let bytes = ods(&format!(
|
||||||
|
r#"<table:table table:name="{long}"/><table:table table:name="{long}"/><table:table table:name="'History'"/><table:table table:name="[]:*?"/>"#
|
||||||
|
));
|
||||||
|
let (wb, report) = read_ods(&bytes).unwrap();
|
||||||
|
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||||
|
assert!(wb.check_sheet_name(&sheet.name, Some(i)).is_ok());
|
||||||
|
}
|
||||||
|
assert!(report.warnings.iter().any(|w| w.contains("sheet names")));
|
||||||
|
assert_eq!(read_xlsx(&write_xlsx(&wb).unwrap()).unwrap().0.sheets.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ods_extension_error_cache_never_imports_its_placeholder_zero() {
|
||||||
|
let bytes = row(
|
||||||
|
r#"<table:table-cell xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" calcext:value-type="error" office:value-type="float" office:value="0" table:formula="of:=1/0"><text:p>#DIV/0!</text:p></table:table-cell>"#,
|
||||||
|
);
|
||||||
|
let (wb, report) = read_ods(&bytes).unwrap();
|
||||||
|
assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("#DIV/0!"));
|
||||||
|
assert!(report.warnings.iter().any(|w| w.contains("unsupported or missing")));
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a
|
Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a
|
||||||
non-zero exit code.
|
non-zero exit code.
|
||||||
|
|
||||||
|
`.ods` files support [worksheet data import](ods-import.md). Formula cells use their last saved
|
||||||
|
values; source formatting and other workbook features are omitted. Import warnings go to stderr.
|
||||||
|
Convert to `.xlsx` to save the imported workbook; ODS output is not supported.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo install --path apps/gridcraft-cli # or: cargo run -p gridcraft-cli -- <subcommand>
|
cargo install --path apps/gridcraft-cli # or: cargo run -p gridcraft-cli -- <subcommand>
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Importing OpenDocument spreadsheets
|
||||||
|
|
||||||
|
Open an `.ods` file with File → Open, drag it onto GridCraft, or pass it to the CLI. GridCraft
|
||||||
|
imports worksheet data into a new workbook and shows a warning describing the conversion limits.
|
||||||
|
|
||||||
|
The import reads sheets, numbers, text, booleans, supported dates/times, repeated cells/rows and
|
||||||
|
merged cells. Formula cells become their **last saved results**, not live formulas. These results
|
||||||
|
may be out of date if the source application did not recalculate before saving. Unsupported or
|
||||||
|
missing values retain available display text and produce a warning.
|
||||||
|
|
||||||
|
This is data import, not full ODS compatibility. Source formatting, formulas, charts, images,
|
||||||
|
names and other workbook features are not preserved. GridCraft does not write ODS files.
|
||||||
|
|
||||||
|
Save opens Save As and suggests a new `.xlsx` file. The source ODS file is not used as the
|
||||||
|
workbook's save destination; requests to save to `.ods` return an error.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gridcraft-cli info source.ods
|
||||||
|
gridcraft-cli convert source.ods imported.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI import warnings go to stderr, leaving structured output on stdout available for scripts.
|
||||||
Loading…
Reference in New Issue