Merge pull request #100 from harrisonoconnorhover/codex/ods-import

Import worksheet data and cached values from ODS files
This commit is contained in:
Brandon Thomas committed 2026-10-10 12:52:43 -04:00
commit b68ae0fe53
14 files changed
+1033 -15

No files matched your search

+2 -1
View File
@@ -85,6 +85,7 @@
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
PivotTables and print settings round-trip. CSV and TSV too.
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
[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,
@@ -155,7 +156,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, CSV and XLSB data import) |
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB 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) |
+4 -2
View File
@@ -97,8 +97,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) =
rfd::AsyncFileDialog::new().add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "csv", "tsv", "txt", "json"]).pick_file().await
let Some(file) = rfd::AsyncFileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.pick_file()
.await
else {
return;
};
+2 -1
View File
@@ -110,9 +110,10 @@ fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "csv", "tsv", "txt", "json"])
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
.add_filter("Excel Binary Workbook (data import)", &["xlsb"])
.add_filter("OpenDocument Spreadsheet (data import)", &["ods"])
.add_filter("CSV", &["csv"])
.pick_file()
.and_then(|p| p.to_str().map(str::to_string))
+3 -3
View File
@@ -8,7 +8,7 @@ use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
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, xlsb data import, csv, tsv, txt, json)", always, open),
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64} (xlsx, xlsm, xlsb and 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,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
return ok();
};
let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?;
let imported =
crate::io::FileKind::from_path(&name) == Some(crate::io::FileKind::Xlsb) || gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsb;
let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb);
let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
} else {
+14 -3
View File
@@ -1,4 +1,4 @@
//! Opening and saving files: XLSX, XLSB data import, CSV/TSV, JSON (debug), HTML export.
//! Opening and saving files: XLSX, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
@@ -12,6 +12,7 @@ use crate::{EngineError, Result};
pub enum FileKind {
Xlsx,
Xlsb,
Ods,
Csv,
Tsv,
Json,
@@ -24,6 +25,7 @@ impl FileKind {
Some(match ext.as_str() {
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
"xlsb" => FileKind::Xlsb,
"ods" => FileKind::Ods,
"csv" => FileKind::Csv,
"tsv" | "tab" | "txt" => FileKind::Tsv,
"json" | "scjson" => FileKind::Json,
@@ -38,8 +40,16 @@ impl FileKind {
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Xlsb || kind == Some(FileKind::Xlsb) {
let (wb, report) = gridcraft_xlsx::read_xlsb(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
// Content wins over the extension: an ODS or XLSB package is imported as what it is,
// whatever it is called; the extension only decides when the bytes don't say.
let import = match sniffed {
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
};
if let Some(import) = import {
let read = if import == FileKind::Ods { gridcraft_xlsx::read_ods } else { gridcraft_xlsx::read_xlsb };
let (wb, report) = read(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) {
@@ -88,6 +98,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
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::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
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())),
+70
View File
@@ -574,6 +574,76 @@ fn xlsx_roundtrip_through_engine() {
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]
fn samples_build() {
for (name, _) in crate::sample::SAMPLES {
+1 -1
View File
@@ -329,7 +329,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"open_workbook",
"Open workbook",
"Open a workbook file (.xlsx, .xlsm, .xlsb, .csv, .tsv, .txt, .json) and make it active. XLSB imports worksheet data and cached formula values only; check the returned warnings.",
"Open a workbook file (.xlsx, .xlsm, .xlsb, .ods, .csv, .tsv, .txt, .json) and make it active. XLSB and ODS import worksheet data and cached formula values only; check the returned warnings.",
obj(json!({"path": string("File path")}), &["path"]),
false,
),
+12 -3
View File
@@ -1,4 +1,4 @@
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB data import.
//! GridCraft file formats: XLSX (transitional and strict) and CSV import/export, plus XLSB and 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;
@@ -33,6 +34,7 @@ use gridcraft_core::DateSystem;
use gridcraft_model::Workbook;
pub use csv::{read_csv, write_csv};
pub use ods::read_ods;
pub use xlsb::read_xlsb;
/// Errors from reading or writing files.
@@ -95,14 +97,21 @@ impl Default for CsvOptions {
pub enum Format {
Xlsx,
Xlsb,
Ods,
Csv,
Unknown,
}
/// Guesses XLSB from package content types, other ZIP packages as XLSX, and text as CSV.
/// Guesses the format from content: ODS mimetype, XLSB package content types, 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 if xlsb::is_xlsb(bytes) { Format::Xlsb } else { Format::Xlsx };
return if ods::has_mimetype(bytes) {
Format::Ods
} else if xlsb::is_xlsb(bytes) {
Format::Xlsb
} else {
Format::Xlsx
};
}
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) || bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
return Format::Csv;
+659
View File
@@ -0,0 +1,659 @@
//! 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, u32::MAX)?, 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, u32::MAX)?,
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 kept = !cell.is_blank() || width > 1 || height > 1;
// Writers pad rows with blank repeats up to their own (possibly wider) sheet edge;
// blank cells past ours carry no data, so clip them instead of rejecting the file.
let end = match row.col.checked_add(draft.repeat).filter(|n| *n <= MAX_COLS) {
Some(end) => end,
None if !kept => MAX_COLS,
None => return Err(IoError::TooLarge("ODS row exceeds worksheet width".into())),
};
if kept {
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 { style, ..Cell::value(value) }
}
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(()) };
// Blank trailing row repeats past the sheet edge (e.g. from larger-sheet writers) are clipped.
let end = match sheet.row.checked_add(row.repeat).filter(|n| *n <= MAX_ROWS) {
Some(end) => end,
None if row.cells.is_empty() => MAX_ROWS,
None => return Err(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 })
}
+1 -1
View File
@@ -285,7 +285,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);
+1
View File
@@ -3,6 +3,7 @@
mod csv_tests;
mod fixtures;
mod malformed;
mod ods_tests;
mod pivot_tests;
mod roundtrip;
mod xlsb_tests;
+238
View File
@@ -0,0 +1,238 @@
//! 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="4294967296"/>"#,
r#"<table:table-cell table:number-columns-repeated="4294967295" office:value-type="float" office:value="1"/>"#,
r#"<table:table-cell table:number-columns-repeated="0"/>"#,
r#"<table:table-cell table:number-columns-repeated="16384"/><table:table-cell office:value-type="float" office:value="1"/>"#,
r#"<table:table-cell table:number-columns-repeated="16384"/><table:table-cell table:number-columns-spanned="2"/>"#,
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")));
}
#[test]
fn ods_blank_repeats_past_the_sheet_edge_are_clipped() {
// Writers with larger sheets pad to their own edge; blank padding past ours carries no data.
let bytes = ods(r#"<table:table table:name="Data">
<table:table-row><table:table-cell office:value-type="float" office:value="1"/><table:table-cell table:number-columns-repeated="4294967295"/><table:table-cell/></table:table-row>
<table:table-row table:number-rows-repeated="16777215"><table:table-cell table:number-columns-repeated="16777216"/></table:table-row>
<table:table-row table:number-rows-repeated="5"><table:table-cell/></table:table-row>
</table:table>"#);
let (wb, _) = read_ods(&bytes).unwrap();
assert_eq!(wb.sheets[0].cells.len(), 1);
assert_eq!(wb.sheets[0].value(CellRef::new(0, 0)), Value::Number(1.0));
// Data after the clipped edge is still an error, never silently dropped.
let late = ods(
r#"<table:table table:name="Data"><table:table-row table:number-rows-repeated="1048576"><table:table-cell/></table:table-row><table:table-row><table:table-cell office:value-type="float" office:value="1"/></table:table-row></table:table>"#,
);
assert!(matches!(read_ods(&late), Err(IoError::TooLarge(_))));
}
+4
View File
@@ -7,6 +7,10 @@ non-zero exit code.
values; source formatting and other workbook features are omitted. Import warnings go to stderr.
Convert to `.xlsx` to save the imported workbook; XLSB output is not supported.
`.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 -- <subcommand>
```
+22
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.