#16: import ODS worksheet data without overwriting source files

This commit is contained in:
harrisonoconnorhover 2026-10-09 22:09:09 -04:00
parent 13664fe66b
commit da0bf32d8a
17 changed files with 1018 additions and 12 deletions

1
Cargo.lock generated
View File

@ -1466,6 +1466,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.21", "thiserror 2.0.21",
"zip",
] ]
[[package]] [[package]]

View File

@ -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) |

View File

@ -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
} }

View File

@ -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;
}; };

View File

@ -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()

View File

@ -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 }

View File

@ -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}),

View File

@ -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())),

View File

@ -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 {

View File

@ -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,
), ),

View File

@ -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;

652
crates/xlsx/src/ods.rs Normal file
View File

@ -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 })
}

View File

@ -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);

View File

@ -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;

View File

@ -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 &amp; three</text:span> four<text:s text:c="3"/><text:a>five</text:a><text:tab/>six<text:line-break/>seven&#33;<![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 &amp; 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")));
}

View File

@ -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>
``` ```

22
docs/ods-import.md Normal file
View File

@ -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.