diff --git a/Cargo.lock b/Cargo.lock index e3a4144..d742e69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1466,6 +1466,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/README.md b/README.md index f5aa689..c9d9738 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ - **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays), tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines, 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, 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, @@ -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) | | 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) | | L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) | | L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) | diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 561162f..c97b1d6 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -147,6 +147,14 @@ impl Args { fn exec(s: &mut Session, id: &str, params: Value) -> Result { let r = s.execute(id, params).map_err(|e| e.to_string()); 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 } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c06d890..4c5f5b8 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -98,7 +98,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); wasm_bindgen_futures::spawn_local(async move { 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 { return; }; diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6acaf51..4dd5913 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -96,8 +96,9 @@ fn services() -> Services { Services { pick_open: Some(Box::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("OpenDocument Spreadsheet (data import)", &["ods"]) .add_filter("CSV", &["csv"]) .pick_file() .and_then(|p| p.to_str().map(str::to_string)) @@ -172,6 +173,7 @@ fn main() -> eframe::Result<()> { session.new_workbook(); } let mut app = SheetApp::new(session, services()); + app.after_engine(); // Show warnings from files opened on the command line. let control_port = control_port; load_prefs(&mut app); let mut viewport = egui::ViewportBuilder::default() diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 53e2350..b559b83 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,3 +23,6 @@ gridcraft-xlsx = { workspace = true } gridcraft-chart = { workspace = true } gridcraft-pdf = { workspace = true } image = { workspace = true } + +[dev-dependencies] +zip = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 37a58dd..0ec5432 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -8,7 +8,7 @@ use crate::DocState; pub fn specs() -> Vec { vec![ 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.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), @@ -47,8 +47,15 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; 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 path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true); + let imported = + 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. 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())) @@ -56,6 +63,13 @@ fn open(s: &mut Session, p: &Json) -> Result { s.close_document(0); } let i = s.add_document(DocState::new(wb, path, title)); + if !warnings.is_empty() { + let mut message = warnings.iter().take(5).cloned().collect::>().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()?; Ok( json!({"index": i, "title": d.display_title(), "sheets": d.wb.sheets.iter().map(|s| s.name.clone()).collect::>(), "warnings": warnings}), diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index aef3681..f9a08e9 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -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 _; @@ -11,6 +11,7 @@ use crate::{EngineError, Result}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FileKind { Xlsx, + Ods, Csv, Tsv, Json, @@ -22,6 +23,7 @@ impl FileKind { let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); Some(match ext.as_str() { "xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx, + "ods" => FileKind::Ods, "csv" => FileKind::Csv, "tsv" | "tab" | "txt" => FileKind::Tsv, "json" | "scjson" => FileKind::Json, @@ -36,6 +38,10 @@ impl FileKind { pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); 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) { 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)); @@ -81,6 +87,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { let sheet = wb.active_sheet; 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::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::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())), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f3e36cd..9f3b398 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -145,6 +145,76 @@ fn xlsx_roundtrip_through_engine() { assert_eq!(v(&s, "B3"), Value::Number(3.0)); } +fn ods_fixture() -> Vec { + 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#""#, + ), + ( + "content.xml", + r#"Original data"#, + ), + ] { + 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] fn samples_build() { for (name, _) in crate::sample::SAMPLES { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0fb6ecd..263196e 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec { tool( "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"]), false, ), diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 7c72476..1d08957 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -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 //! points work on byte slices (no file system access), so the crate builds for WebAssembly. @@ -14,6 +14,7 @@ mod chart; mod csv; mod drawing; mod fmla; +mod ods; mod package; mod pivot; mod read; @@ -32,6 +33,7 @@ use gridcraft_core::DateSystem; use gridcraft_model::Workbook; pub use csv::{read_csv, write_csv}; +pub use ods::read_ods; /// Errors from reading or writing files. #[derive(Debug, thiserror::Error)] @@ -92,14 +94,15 @@ impl Default for CsvOptions { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Format { Xlsx, + Ods, Csv, 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 { 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]) { return Format::Csv; diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs new file mode 100644 index 0000000..c41bec6 --- /dev/null +++ b/crates/xlsx/src/ods.rs @@ -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: +//! . + +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, 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, default: u32, limit: u32) -> Result { + let n = match value { + Some(s) => s.parse::().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, +} +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, + raw: Option, + formula: bool, + text: String, + paragraph: Option, + paragraphs: usize, + paragraph_started: bool, + pending_space: bool, +} +struct Import { + wb: Workbook, + report: ReadReport, + stack: Vec, + sheet: Option, + row: Option, + cell: Option, + 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::()).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::().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 { + match s.split_once('.') { + Some((a, b)) => { + digits(a)?; + digits(b)?; + } + None => { + digits(s)?; + } + } + s.parse::().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 { + 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::().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::().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 }) +} diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 915d8e4..be38442 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -283,7 +283,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { 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(); if base.trim().is_empty() { base = format!("Sheet{}", i + 1); diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 46bd997..2bad0c5 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -3,6 +3,7 @@ mod csv_tests; mod fixtures; mod malformed; +mod ods_tests; mod pivot_tests; mod roundtrip; diff --git a/crates/xlsx/src/tests/ods_tests.rs b/crates/xlsx/src/tests/ods_tests.rs new file mode 100644 index 0000000..69f92c2 --- /dev/null +++ b/crates/xlsx/src/tests/ods_tests.rs @@ -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#""#; + +fn content(tables: &str) -> String { + format!( + r#"{tables}"# + ) +} +fn ods(tables: &str) -> Vec { + make_zip(&[("mimetype", MIME), ("META-INF/manifest.xml", MANIFEST), ("content.xml", content(tables).as_bytes())]) +} +fn row(cells: &str) -> Vec { + ods(&format!(r#"{cells}"#)) +} +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#" + + + + + + 85 + #DIV/0! + + unavailable + + "#); + 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#"one two & three fourfivesixseven!]]>ninenot cell text"#, + ); + let (wb, _) = read_ods(&bytes).unwrap(); + assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), text("one two & three four five\tsix\nseven!\nnine")); + let bytes = row(r#" one + two three four five "#); + 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#" + + merged + + + + + + "#); + 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#" + + + + + + + + + "#); + 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#""#) + .replace("kept"#); + 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#""#); + 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#""#; + 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("").to_owned(), + xml.replace("", ""), + "".to_owned(), + format!("{xml}{xml}"), + ] { + assert!(read_ods(&make_zip(&[("mimetype", MIME), ("content.xml", bad.as_bytes())])).is_err(), "{bad}"); + } + let deep = content(&format!("{}{}", "".repeat(300), "".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#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + assert!(read_ods(&row(cells)).is_err(), "{cells}"); + } + let cells = ods( + r#""#, + ); + assert!(matches!(read_ods(&cells), Err(IoError::TooLarge(_)))); + let text = format!( + r#""#, + "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#""# + )); + 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#"#DIV/0!"#, + ); + 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"))); +} diff --git a/docs/cli.md b/docs/cli.md index 2cf5985..22ffaeb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,6 +3,10 @@ Headless GridCraft: inspect, convert, evaluate, script and serve MCP without a window. Errors go to stderr with a 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 cargo install --path apps/gridcraft-cli # or: cargo run -p gridcraft-cli -- ``` diff --git a/docs/ods-import.md b/docs/ods-import.md new file mode 100644 index 0000000..69d95cf --- /dev/null +++ b/docs/ods-import.md @@ -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.