From ae085a2f7e7dc6118489059945f1156bcbb62d3d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:01:51 -0500 Subject: [PATCH] craft-xlsx: write SpreadsheetML workbooks (Document read/write) (#1041) * craft-xlsx: read parts with canonical prefixes so kept trees can be written back The part layer read every part by local name, so extLst children (x14:, xr:, xm:), mc:AlternateContent and r:id lost their namespaces and could not be written back. Parts are now read with one fixed prefix per namespace (the main namespace unprefixed), mc:Ignorable and mc:Choice/@Requires are rewritten to match, and ns::declare prepares a kept tree for writing. craft-xml: a namespace listed with the empty prefix reads unprefixed. * craft-xlsx: write SpreadsheetML workbooks (Document read and write) Document holds a whole workbook: the workbook part, shared strings, styles, each worksheet's rows and the rest of its part, and every other part kept as bytes. write regenerates the workbook part, worksheets, shared strings and styles from the model, keeps unknown children in place (schema order for regenerated ones), keeps part names and relationship ids, and writes kept parts unchanged. Writers: Styles::to_xml and to_el for each table entry, StringItem::to_el (phonetic runs checked as on reading), SheetPart::to_xml with rows streamed, Workbook::to_el, XlsxKind. Row and DefinedName keep their other attributes; CellStyle reads iLevel. craft-opc: LazyPackage::canonical gives a part's stored name. --- crates/opc/src/read.rs | 6 + crates/opc/tests/package.rs | 3 + crates/xlsx/README.md | 14 ++ crates/xlsx/src/document.rs | 277 +++++++++++++++++++++++++ crates/xlsx/src/lib.rs | 6 +- crates/xlsx/src/ns.rs | 10 +- crates/xlsx/src/sheet.rs | 199 +++++++++++++++++- crates/xlsx/src/strings.rs | 67 +++++- crates/xlsx/src/styles.rs | 401 +++++++++++++++++++++++++++++------- crates/xlsx/src/workbook.rs | 85 +++++++- crates/xlsx/src/write.rs | 157 ++++++++++++++ crates/xlsx/tests/write.rs | 249 ++++++++++++++++++++++ 12 files changed, 1392 insertions(+), 82 deletions(-) create mode 100644 crates/xlsx/src/document.rs create mode 100644 crates/xlsx/src/write.rs create mode 100644 crates/xlsx/tests/write.rs diff --git a/crates/opc/src/read.rs b/crates/opc/src/read.rs index c080327ff..b35fc109d 100644 --- a/crates/opc/src/read.rs +++ b/crates/opc/src/read.rs @@ -228,6 +228,12 @@ impl<'a> LazyPackage<'a> { self.names.contains_key(&normalize(part)) } + /// The stored spelling of part `name` (leading `/` ignored; case-insensitive), as + /// [`Package::canonical`] gives it: for a writer that keeps the package's part names. + pub fn canonical(&self, name: &str) -> Option { + self.names.get(&normalize(name)).map(|n| n.trim_start_matches('/').replace('\\', "/")) + } + /// Bytes charged to [`Limits::max_total`] so far. It is the whole of it once a read has /// failed: see [`LazyPackage::read_limited`]. pub fn spent(&self) -> u64 { diff --git a/crates/opc/tests/package.rs b/crates/opc/tests/package.rs index 48aefaf1f..8f3bf7f45 100644 --- a/crates/opc/tests/package.rs +++ b/crates/opc/tests/package.rs @@ -191,6 +191,9 @@ fn backslash_zip_entry_names_are_found() { assert_eq!(p.canonical("docprops/core.xml"), Some("docProps/core.xml")); let mut lazy = LazyPackage::open(&z, &LIMITS).unwrap(); assert_eq!(lazy.names(), ["docprops/core.xml", "ppt/slides/slide1.xml"]); + assert_eq!(lazy.canonical("ppt/slides/slide1.xml").as_deref(), Some("PPT/Slides/Slide1.xml")); + assert_eq!(lazy.canonical("/DOCPROPS/core.xml").as_deref(), Some("docProps/core.xml")); + assert_eq!(lazy.canonical("nothing"), None); assert_eq!(lazy.read("PPT/slides/slide1.xml").unwrap().as_deref(), Some(&b""[..])); } diff --git a/crates/xlsx/README.md b/crates/xlsx/README.md index 2fd27dfa6..8d831eec2 100644 --- a/crates/xlsx/README.md +++ b/crates/xlsx/README.md @@ -24,6 +24,20 @@ A cell keeps the file's terms: a shared-string index, a format index, the formul file spells it, value and cell metadata indexes. `CellValue::resolve` gives the value it shows. Shared formulas are not expanded and formulas are not parsed: that needs a formula grammar. +## Writing + +`Document::read` holds a whole SpreadsheetML workbook: the `Workbook`, shared strings, `Styles`, +each worksheet's `Row`s and `SheetPart`, and every other part as a `KeptPart` (bytes and content +type: drawings, charts, the theme, document properties, a VBA project, their relationships). +`Document::write` regenerates the workbook part (sheets, defined names, date system, active tab), +the worksheets (dimension, columns, merged cells, rows), the shared strings and the styles tables +from those fields, keeps the rest of each part's tree in place, and writes the kept parts as they +were. Part names and relationship ids are kept, so a file read and written without changes points +where it did, and writing it twice gives the same bytes. The calculation chain is dropped (Excel +rebuilds it). `XlsxKind` is the workbook part's content type (`.xlsx`, `.xlsm`, `.xltx`, `.xltm`). +The parts this crate writes use the transitional namespaces; a strict file's kept parts stay as +they were. A binary workbook is not written. + A binary workbook gives the same `Workbook`, strings, rows and merged cells, with saved values only: its formulas are token streams that are not decoded, and its styles are not read (each cell keeps its format index). diff --git a/crates/xlsx/src/document.rs b/crates/xlsx/src/document.rs new file mode 100644 index 000000000..25b75daeb --- /dev/null +++ b/crates/xlsx/src/document.rs @@ -0,0 +1,277 @@ +//! A whole workbook held in memory and written back: the parts this crate reads, regenerated, +//! and every other part carried through as it was. + +use craft_opc::{ContentTypes, PackageOut, RT_BASE, Rel, RelOut, Rels, RelsOut, relative, rels_path}; + +use crate::sheet::{CellValue, Row, SheetPart}; +use crate::strings::StringItem; +use crate::styles::Styles; +use crate::workbook::{SheetKind, Workbook}; +use crate::{Error, Limits, Package}; + +const CT_WORKSHEET: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"; +const CT_STRINGS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"; +const CT_STYLES: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"; +/// The relationship type namespace of strict files: kept relationships are written with the +/// transitional one, as the parts this crate writes are. +const RT_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships/"; + +/// Which member of the family a package is: its workbook part's content type. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum XlsxKind { + /// `.xlsx` + #[default] + Workbook, + /// `.xlsm` + MacroWorkbook, + /// `.xltx` + Template, + /// `.xltm` + MacroTemplate, +} + +impl XlsxKind { + const ALL: [XlsxKind; 4] = [XlsxKind::Workbook, XlsxKind::MacroWorkbook, XlsxKind::Template, XlsxKind::MacroTemplate]; + + /// The kind a file name's extension asks for; anything else is a plain workbook. + pub fn from_path(path: &str) -> XlsxKind { + let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default(); + match ext.as_str() { + "xlsm" => XlsxKind::MacroWorkbook, + "xltx" => XlsxKind::Template, + "xltm" => XlsxKind::MacroTemplate, + _ => XlsxKind::Workbook, + } + } + + /// The content type of the workbook part. + pub fn main_content_type(self) -> &'static str { + match self { + XlsxKind::Workbook => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", + XlsxKind::MacroWorkbook => "application/vnd.ms-excel.sheet.macroEnabled.main+xml", + XlsxKind::Template => "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml", + XlsxKind::MacroTemplate => "application/vnd.ms-excel.template.macroEnabled.main+xml", + } + } + + /// May carry a VBA project (`.xlsm`, `.xltm`). + pub fn is_macro_enabled(self) -> bool { + matches!(self, XlsxKind::MacroWorkbook | XlsxKind::MacroTemplate) + } + + fn of_content_type(ct: &str) -> Option { + XlsxKind::ALL.into_iter().find(|k| k.main_content_type() == ct) + } +} + +/// One worksheet: its rows and the rest of its part. +#[derive(Clone, Debug, Default)] +pub struct Worksheet { + pub rows: Vec, + pub part: SheetPart, +} + +/// A part carried through unchanged: drawings, charts, the theme, document properties, a VBA +/// project, and the relationships of each. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct KeptPart { + pub name: String, + /// From `[Content_Types].xml`; `None` when it names none. + pub content_type: Option, + pub bytes: Vec, +} + +/// A whole SpreadsheetML workbook. +/// +/// [`Document::read`] holds every part; [`Document::write`] regenerates the workbook part, the +/// worksheets, the shared strings, the styles and their relationships from the fields, and +/// writes every [`KeptPart`] back as it was. A read file written without changes keeps its part +/// names and relationship ids, so kept parts still point where they did. The calculation chain +/// is dropped: Excel rebuilds it, and a stale one makes it repair the file. +#[derive(Clone, Debug, Default)] +pub struct Document { + pub kind: XlsxKind, + /// The workbook part's name; `xl/workbook.xml` when empty. + pub workbook_part: String, + pub workbook: Workbook, + /// The workbook part's relationships as read. Those to worksheets, shared strings, styles + /// and the calculation chain are made again on writing; the rest are kept with their ids. + pub workbook_rels: Rels, + pub shared_strings: Vec, + pub styles: Option, + /// One per [`Workbook::sheets`] entry: the worksheet, or `None` for a sheet kept as its + /// parts (a chart sheet, a macro sheet, a worksheet whose part could not be read). + pub worksheets: Vec>, + pub parts: Vec, + /// What reading skipped or repaired. + pub warnings: Vec, +} + +impl Document { + /// Reads every part. A binary workbook is refused: its formulas and styles are not read, so + /// it cannot be written back as SpreadsheetML without loss. + pub fn read(bytes: &[u8], limits: &Limits) -> Result { + let mut p = Package::open(bytes, limits)?; + if p.is_binary() { + return Err(Error::Invalid("a binary workbook (.xlsb) cannot be held as SpreadsheetML".into())); + } + let workbook = p.workbook()?; + let workbook_part = p.workbook_part().to_string(); + let workbook_rels = p.workbook_rels()?; + let shared_strings = p.shared_strings()?; + let styles = p.styles()?; + let mut worksheets = Vec::with_capacity(workbook.sheets.len()); + for entry in &workbook.sheets { + if entry.kind != SheetKind::Worksheet || entry.part.is_none() { + worksheets.push(None); + continue; + } + let mut rows = Vec::new(); + match p.sheet(entry, |row| { + rows.push(row); + Ok(()) + }) { + Ok(part) => worksheets.push(Some(Worksheet { rows, part })), + Err(Error::TooLarge(m)) => return Err(Error::TooLarge(m)), + Err(e) => { + p.warn(format!("sheet \"{}\" kept unread: {e}", entry.name)); + worksheets.push(None); + } + } + } + let types = p.opc().read("[Content_Types].xml")?.map(|b| ContentTypes::parse(&b)).unwrap_or_default(); + let mut made = vec!["[Content_Types].xml".to_string(), workbook_part.clone(), rels_path(&workbook_part)]; + for ws in worksheets.iter().flatten() { + made.push(ws.part.part.clone()); + made.push(rels_path(&ws.part.part)); + } + made.extend(workbook_rels.list.iter().filter(|r| !r.external && regenerated(r)).map(|r| r.target.clone())); + let opc = p.opc(); + let names: Vec = opc.names().iter().filter_map(|n| opc.canonical(n)).collect(); + let mut parts = Vec::new(); + for name in names { + if made.iter().any(|m| m.eq_ignore_ascii_case(&name)) || name.ends_with('/') { + continue; + } + let Some(bytes) = p.opc().read(&name)? else { continue }; + parts.push(KeptPart { content_type: types.of(&name).map(str::to_string), name, bytes }); + } + let kind = types.of(&workbook_part).and_then(XlsxKind::of_content_type).unwrap_or_default(); + Ok(Document { kind, workbook_part, workbook, workbook_rels, shared_strings, styles, worksheets, parts, warnings: p.warnings().to_vec() }) + } + + /// The package. Refused when [`Document::worksheets`] does not match the workbook's sheets, + /// or a sheet without a worksheet has no kept relationship to point at. + pub fn write(&self) -> Result, Error> { + if self.worksheets.len() != self.workbook.sheets.len() { + return Err(Error::Invalid(format!("{} worksheets for {} sheets", self.worksheets.len(), self.workbook.sheets.len()))); + } + let book_part = if self.workbook_part.is_empty() { "xl/workbook.xml" } else { &self.workbook_part }; + let mut out = PackageOut::default(); + let mut rels = RelsOut::default(); + // A worksheet's relationship is kept only for a sheet kept as its parts: the others are + // made again, and one whose sheet was removed goes with it. + let unread = |r: &Rel| self.workbook.sheets.iter().zip(&self.worksheets).any(|(e, ws)| ws.is_none() && e.rel_id == r.id); + for r in self.workbook_rels.list.iter().filter(|r| !regenerated(r) && (r.kind() != "worksheet" || unread(r))) { + rels.items.push(kept(r)); + } + let mut workbook = self.workbook.clone(); + let mut sheet_parts = Vec::new(); + let mut taken: Vec = self.parts.iter().map(|p| p.name.to_ascii_lowercase()).collect(); + for (i, (entry, ws)) in workbook.sheets.iter_mut().zip(&self.worksheets).enumerate() { + let Some(ws) = ws else { + if rels.items.iter().any(|r| r.id == entry.rel_id) { + continue; + } + return Err(Error::Invalid(format!("sheet \"{}\" has neither a worksheet nor a kept part", entry.name))); + }; + let mut name = ws.part.part.clone(); + if name.is_empty() || taken.contains(&name.to_ascii_lowercase()) { + name = fresh(&taken, |n| format!("xl/worksheets/sheet{}.xml", i + n)); + } + taken.push(name.to_ascii_lowercase()); + if entry.rel_id.is_empty() || rels.items.iter().any(|r| r.id == entry.rel_id) { + entry.rel_id = fresh_id(&rels); + } + rels.items.push(RelOut { + id: entry.rel_id.clone(), + ty: format!("{RT_BASE}worksheet"), + target: relative(book_part, &name), + external: false, + }); + sheet_parts.push((name, ws)); + } + let mut add = |kind: &str, default: &str, taken: &mut Vec| { + let old = self.workbook_rels.list.iter().find(|r| r.kind() == kind && !r.external).map(|r| r.target.clone()); + let name = old.filter(|n| !taken.contains(&n.to_ascii_lowercase())).unwrap_or_else(|| default.to_string()); + taken.push(name.to_ascii_lowercase()); + let id = fresh_id(&rels); + rels.items.push(RelOut { id, ty: format!("{RT_BASE}{kind}"), target: relative(book_part, &name), external: false }); + name + }; + let strings = (!self.shared_strings.is_empty()).then(|| add("sharedStrings", "xl/sharedStrings.xml", &mut taken)); + let styles = self.styles.as_ref().map(|s| (add("styles", "xl/styles.xml", &mut taken), s)); + + let mut root = workbook.to_el(); + crate::ns::declare(&mut root); + let mut w = craft_xml::Writer::document(); + w.tree(&root); + out.xml_part(book_part, self.kind.main_content_type(), w.into_bytes()); + out.rels(book_part, &rels); + if !self.parts.iter().any(|p| p.name == "_rels/.rels") { + let mut package = RelsOut::default(); + package.add("officeDocument", book_part, false); + out.rels("", &package); + } + for (name, ws) in &sheet_parts { + out.xml_part(name, CT_WORKSHEET, ws.part.to_xml(&ws.rows)); + let mut sheet_rels = RelsOut::default(); + sheet_rels.items.extend(ws.part.rels.list.iter().map(kept)); + out.rels(name, &sheet_rels); + } + if let Some(name) = &strings { + let count: usize = + sheet_parts.iter().flat_map(|(_, ws)| &ws.rows).flat_map(|r| &r.cells).filter(|c| matches!(c.value, CellValue::Shared(_))).count(); + let mut w = craft_xml::Writer::document(); + w.tag("sst").attr("xmlns", crate::ns::MAIN).attr("count", count).attr("uniqueCount", self.shared_strings.len()).open(); + for s in &self.shared_strings { + w.tree(&s.to_el("si")); + } + out.xml_part(name, CT_STRINGS, w.into_bytes()); + } + if let Some((name, s)) = &styles { + out.xml_part(name, CT_STYLES, s.to_xml()); + } + for p in &self.parts { + match &p.content_type { + Some(ct) => out.binary(&p.name, ct, p.bytes.clone()), + None => out.raw(&p.name, p.bytes.clone()), + } + } + Ok(out.finish()?) + } +} + +/// A workbook relationship to a part that [`Document::write`] makes again (or drops). +fn regenerated(r: &Rel) -> bool { + matches!(r.kind(), "sharedStrings" | "styles" | "calcChain") +} + +/// A relationship as read, to be written back with its id and target as written. +fn kept(r: &Rel) -> RelOut { + let ty = match r.ty.strip_prefix(RT_STRICT) { + Some(kind) => format!("{RT_BASE}{kind}"), + None => r.ty.clone(), + }; + RelOut { id: r.id.clone(), ty, target: r.target_raw.clone(), external: r.external } +} + +/// An `rIdN` no relationship in `rels` has. +fn fresh_id(rels: &RelsOut) -> String { + fresh(&rels.items.iter().map(|r| r.id.to_ascii_lowercase()).collect::>(), |n| format!("rId{n}")) +} + +/// The first of `name(1)`, `name(2)`, ... that is not `taken` (compared in lower case). +fn fresh(taken: &[String], name: impl Fn(usize) -> String) -> String { + (1..).map(name).find(|n| !taken.contains(&n.to_ascii_lowercase())).unwrap_or_default() +} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 97b3e9360..09d7b673e 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -49,6 +49,7 @@ //! # } //! ``` +mod document; pub mod ns; mod package; mod sheet; @@ -57,19 +58,22 @@ mod styles; #[doc(hidden)] pub mod testing; mod workbook; +mod write; mod xlsb; pub use craft_sheet::{ BorderStyle, CellError, CellRef, DEFAULT_PALETTE, HAlign, MAX_COLS, MAX_ROWS, Pattern, Range, Script, Underline, VAlign, Value, Visibility, builtin_format, builtin_format_id, quote_sheet, sheet_needs_quotes, }; +pub use document::{Document, KeptPart, Worksheet, XlsxKind}; pub use package::{Package, is_xlsb}; pub use sheet::{Cell, CellFormula, CellValue, Column, FormulaKind, Row, SheetPart, iso_date_serial}; -pub use strings::{PhoneticProps, PhoneticRun, StringItem, TextRun, decode_xstring, encode_xstring, rich_text}; +pub use strings::{PhoneticProps, PhoneticRun, StringItem, TextRun, decode_xstring, encode_xstring, rich_text, t_el}; pub use styles::{ Alignment, Border, BorderEdge, CellStyle, Color, ColorSource, Dxf, Fill, Font, FontScheme, GradientFill, PatternFill, Protection, Styles, Xf, }; pub use workbook::{DefinedName, SheetEntry, SheetKind, Workbook}; +pub use write::num; /// Why a workbook could not be read. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] diff --git a/crates/xlsx/src/ns.rs b/crates/xlsx/src/ns.rs index 5e7dbc846..3edf87928 100644 --- a/crates/xlsx/src/ns.rs +++ b/crates/xlsx/src/ns.rs @@ -98,8 +98,13 @@ pub const CANONICAL: Canonical<'static> = Canonical { namespaces: NAMESPACES, un /// drops each `mc:Choice` that requires a namespace this crate does not know, since its content /// was not kept. Declarations already on `root` are replaced. pub fn declare(root: &mut El) { + declare_with(root, &[]); +} + +/// [`declare`], also declaring the `prefixes` of names written outside the tree (streamed rows). +pub(crate) fn declare_with(root: &mut El, prefixes: &[&str]) { drop_unknown_choices(root); - let mut used: Vec<&str> = Vec::new(); + let mut used: Vec<&str> = prefixes.to_vec(); collect(root, &mut used); let mut decls = vec![("xmlns".to_string(), MAIN.to_string())]; for (p, uri) in NAMESPACES { @@ -112,12 +117,15 @@ pub fn declare(root: &mut El) { root.attrs = decls; } +/// Drops each `mc:Choice` that requires an unlisted namespace, and an `mc:AlternateContent` left +/// with no choice and no fallback. fn drop_unknown_choices(e: &mut El) { e.children .retain(|n| !matches!(n, Node::El(c) if c.name == "mc:Choice" && c.attr("Requires").is_some_and(|r| r.split_whitespace().any(|p| p == "?")))); for c in e.elements_mut() { drop_unknown_choices(c); } + e.children.retain(|n| !matches!(n, Node::El(c) if c.name == "mc:AlternateContent" && c.elements().next().is_none())); } fn collect<'a>(e: &'a El, used: &mut Vec<&'a str>) { diff --git a/crates/xlsx/src/sheet.rs b/crates/xlsx/src/sheet.rs index 5375436e8..fc0e33bc9 100644 --- a/crates/xlsx/src/sheet.rs +++ b/crates/xlsx/src/sheet.rs @@ -5,9 +5,57 @@ use std::sync::Arc; use craft_opc::Rels; use craft_sheet::{CellError, CellRef, MAX_COLS, MAX_ROWS, Range, Value}; -use craft_xml::El; +use craft_xml::{El, Writer}; -use crate::strings::{StringItem, decode_xstring}; +use crate::strings::{StringItem, decode_xstring, encode_xstring}; +use crate::write::{assemble, list, num, write_streamed}; + +/// The children of a worksheet in schema order (`CT_Worksheet`). +const WORKSHEET: &[&str] = &[ + "sheetPr", + "dimension", + "sheetViews", + "sheetFormatPr", + "cols", + "sheetData", + "sheetCalcPr", + "sheetProtection", + "protectedRanges", + "scenarios", + "autoFilter", + "sortState", + "dataConsolidate", + "customSheetViews", + "mergeCells", + "phoneticPr", + "conditionalFormatting", + "dataValidations", + "hyperlinks", + "printOptions", + "pageMargins", + "pageSetup", + "headerFooter", + "rowBreaks", + "colBreaks", + "customProperties", + "cellWatches", + "ignoredErrors", + "smartTags", + "drawing", + "legacyDrawing", + "legacyDrawingHF", + "drawingHF", + "picture", + "oleObjects", + "controls", + "webPublishItems", + "tableParts", + "extLst", +]; + +/// Row attributes read into [`Row`]'s fields; the others go to [`Row::attrs`]. `spans` is +/// dropped: it is a hint that goes stale when cells change. +const ROW_ATTRS: &[&str] = &["r", "spans", "ht", "customHeight", "hidden", "outlineLevel", "collapsed", "s", "customFormat"]; /// What a cell's `` holds, by its type (`t`, 18.18.11). #[derive(Clone, Debug, Default, PartialEq)] @@ -105,6 +153,8 @@ pub struct Row { /// `s` when `customFormat` is set: the format of the row's empty cells. pub style: Option, pub cells: Vec, + /// The other attributes as written (`thickBot`, `x14ac:dyDescent`). + pub attrs: Vec<(String, String)>, } /// One ``: columns `first..=last` (zero-based). @@ -169,6 +219,134 @@ impl SheetPart { } } +impl SheetPart { + /// The worksheet part with `rows` as its `sheetData`. The dimension, the columns and the + /// merged cells come from the fields; everything else in [`SheetPart::root`] is written as + /// kept, in place. + pub fn to_xml(&self, rows: &[Row]) -> Vec { + let root = if self.root.name.is_empty() { El::new("worksheet") } else { self.root.clone() }; + let mut cols = El::new("cols"); + for c in &self.columns { + cols.push(column_el(c)); + } + let merges = self.merges.iter().map(|m| El::new("mergeCell").with_attr("ref", m.a1())).collect(); + let owned = vec![ + ("dimension", Some(El::new("dimension").with_attr("ref", used(rows).map_or_else(|| "A1".to_string(), |r| r.a1())))), + ("cols", (!self.columns.is_empty()).then_some(cols)), + ("sheetData", Some(El::new("sheetData"))), + ("mergeCells", list(&root, "mergeCells", merges)), + ]; + let mut root = assemble(&root, WORKSHEET, owned); + let prefixes: Vec<&str> = rows.iter().flat_map(|r| &r.attrs).filter_map(|(k, _)| k.split_once(':').map(|(p, _)| p)).collect(); + crate::ns::declare_with(&mut root, &prefixes); + write_streamed(&root, "sheetData", |w| rows.iter().for_each(|r| write_row(w, r))) + } +} + +/// The range from the first to the last cell of `rows`. +fn used(rows: &[Row]) -> Option { + let mut cells = rows.iter().flat_map(|r| &r.cells).map(|c| c.at); + let first = cells.next()?; + let (lo, hi) = cells + .fold((first, first), |(lo, hi), c| (CellRef::new(lo.row.min(c.row), lo.col.min(c.col)), CellRef::new(hi.row.max(c.row), hi.col.max(c.col)))); + Some(Range::new(lo, hi)) +} + +fn column_el(c: &Column) -> El { + let mut e = El::new("col").with_attr("min", c.first + 1).with_attr("max", c.last + 1); + let mut put = |k: &str, v: Option| { + if let Some(v) = v { + e.set_attr(k, v); + } + }; + put("width", c.width.map(num)); + put("style", c.style.map(|s| s.to_string())); + put("hidden", c.hidden.then(|| "1".into())); + put("bestFit", c.best_fit.then(|| "1".into())); + put("customWidth", c.custom_width.then(|| "1".into())); + put("outlineLevel", (c.outline_level > 0).then(|| c.outline_level.to_string())); + put("collapsed", c.collapsed.then(|| "1".into())); + e +} + +fn write_row(w: &mut Writer, row: &Row) { + let mut t = w.tag("row").attr("r", row.index + 1); + if let Some(s) = row.style { + t = t.attr("s", s).attr("customFormat", 1); + } + t = t.attr_opt("ht", row.height.map(num)); + if row.custom_height { + t = t.attr("customHeight", 1); + } + if row.hidden { + t = t.attr("hidden", 1); + } + if row.outline_level > 0 { + t = t.attr("outlineLevel", row.outline_level); + } + if row.collapsed { + t = t.attr("collapsed", 1); + } + for (k, v) in row.attrs.iter().filter(|(k, _)| craft_xml::is_name(k)) { + t = t.attr(k, v); + } + if row.cells.is_empty() { + t.empty(); + return; + } + t.open(); + for c in &row.cells { + write_cell(w, c); + } + w.close(); +} + +fn write_cell(w: &mut Writer, c: &Cell) { + let (kind, v): (Option<&str>, Option) = match &c.value { + CellValue::Empty => (None, None), + CellValue::Number(n) => (None, Some(num(*n))), + CellValue::Shared(i) => (Some("s"), Some(i.to_string())), + // An unreadable value is kept as the text it was. + CellValue::Text(t) => (Some("str"), Some(encode_xstring(t))), + CellValue::Invalid(t) => (Some("str"), Some(encode_xstring(t))), + CellValue::Inline(_) => (Some("inlineStr"), None), + CellValue::Bool(b) => (Some("b"), Some(u8::from(*b).to_string())), + CellValue::Error(e) => (Some("e"), Some(e.as_str().to_string())), + CellValue::Date(d) => (Some("d"), Some(d.clone())), + }; + let mut t = w.tag("c").attr("r", c.at.a1()); + if c.style != 0 { + t = t.attr("s", c.style); + } + let t = t.attr_opt("t", kind).attr_opt("cm", c.cell_metadata).attr_opt("vm", c.value_metadata); + let t = if c.phonetic { t.attr("ph", 1) } else { t }; + if c.formula.is_none() && v.is_none() && !matches!(c.value, CellValue::Inline(_)) { + t.empty(); + return; + } + t.open(); + if let Some(f) = &c.formula { + let kind = match f.kind { + FormulaKind::Normal => None, + FormulaKind::Shared => Some("shared"), + FormulaKind::Array => Some("array"), + FormulaKind::DataTable => Some("dataTable"), + }; + let mut t = w.tag("f").attr_opt("t", kind).attr_opt("ref", f.range.map(|r| r.a1())).attr_opt("si", f.shared_index); + for (k, v) in f.attrs.iter().filter(|(k, _)| !matches!(k.as_str(), "t" | "ref" | "si") && craft_xml::is_name(k)) { + t = t.attr(k, v); + } + if f.text.is_empty() { t.empty() } else { t.text(&f.text) } + } + if let CellValue::Inline(s) = &c.value { + w.tree(&s.to_el("is")); + } + if let Some(v) = v { + w.tag("v").text(&v); + } + w.close(); +} + /// Reads rows one at a time, remembering where the last one ended. #[derive(Default)] pub(crate) struct RowReader { @@ -203,6 +381,7 @@ impl RowReader { collapsed: e.flag("collapsed", false), style: if e.flag("customFormat", false) { e.attr_u32("s") } else { None }, cells: Vec::new(), + attrs: e.attrs.iter().filter(|(k, _)| !ROW_ATTRS.contains(&k.as_str())).cloned().collect(), }; let mut next_col = 0u32; for c in e.children("c") { @@ -384,4 +563,20 @@ mod tests { assert!(r.row(&craft_xml::El::new("row").with_attr("r", "0")).is_none()); assert_eq!((r.rows, r.dropped), (4, 3)); } + + #[test] + fn rows_write_back() { + let xml = r#""#; + let read = |xml: &str| RowReader::default().row(&craft_xml::Options::local().parse(xml.as_bytes()).unwrap_or_default()).unwrap_or_default(); + let row = read(xml); + let mut w = Writer::new(); + write_row(&mut w, &row); + let out = w.finish(); + assert!(out.starts_with(r#""); + } +} diff --git a/crates/xlsx/tests/write.rs b/crates/xlsx/tests/write.rs new file mode 100644 index 000000000..4ada52b5e --- /dev/null +++ b/crates/xlsx/tests/write.rs @@ -0,0 +1,249 @@ +//! Writing: a workbook read, written and read again is the same workbook, and every part this +//! crate does not read comes back byte for byte. + +use craft_xlsx::testing::zip; +use craft_xlsx::{ + Cell, CellRef, CellValue, DefinedName, Document, Error, Font, KeptPart, Limits, Package, Range, Row, SheetEntry, SheetKind, StringItem, Styles, + Value, Visibility, Workbook, Worksheet, XlsxKind, +}; + +const MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; +const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +/// A workbook as Excel saves one: extensions, markup compatibility, a drawing with its own +/// relationships, a chart sheet, document properties and a macro project. +fn excel_like() -> Vec { + let ns = format!( + r#"xmlns="{MAIN}" xmlns:r="{RT}" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x14ac="http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac" xmlns:xr="http://schemas.microsoft.com/office/spreadsheetml/2014/revision" mc:Ignorable="x14ac xr""# + ); + let book = format!( + r#" + + +Data!$A$1:$C$3 +"# + ); + let book_rels = format!( + r#""# + ); + let sheet1 = format!( + r#" + +010.1SUM(C1:C3)0.300000000000000041C1*20.2#N/A inline 0.4 +0.25 +Data!C1:C3D1"# + ); + let sheet1_rels = format!( + r#""# + ); + let styles = format!( + r#" + + + + +"# + ); + let strings = format!( + r#"Name東京 x_x000D_トウキョウ"# + ); + let package_rels = format!( + r#""# + ); + let types = r#""#; + zip(&[ + ("[Content_Types].xml", types.as_bytes()), + ("_rels/.rels", package_rels.as_bytes()), + ("docProps/core.xml", b""), + ("xl/workbook.xml", book.as_bytes()), + ("xl/_rels/workbook.xml.rels", book_rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet1.as_bytes()), + ("xl/worksheets/_rels/sheet1.xml.rels", sheet1_rels.as_bytes()), + ("xl/worksheets/sheet2.xml", format!("").as_bytes()), + ("xl/chartsheets/sheet1.xml", b""), + ("xl/theme/theme1.xml", b""), + ("xl/drawings/drawing1.xml", b""), + ("xl/styles.xml", styles.as_bytes()), + ("xl/sharedStrings.xml", strings.as_bytes()), + ("xl/calcChain.xml", b""), + ("xl/vbaProject.bin", &[0xD0, 0xCF, 0x11, 0xE0, 1, 2, 3]), + ]) +} + +fn read(bytes: &[u8]) -> Document { + Document::read(bytes, &Limits::default()).unwrap() +} + +/// Everything that should survive a round trip: the fields, and what each regenerated part is +/// written as (a kept tree reads back without what writing drops, such as a choice that needs a +/// namespace this crate does not know). +fn assert_same(a: &Document, b: &Document) { + let written = |d: &Document| { + let mut root = d.workbook.to_el(); + craft_xlsx::ns::declare(&mut root); + root + }; + assert_eq!(a.kind, b.kind); + assert_eq!( + (&a.workbook.sheets, &a.workbook.defined_names, a.workbook.date_1904, a.workbook.active_tab), + (&b.workbook.sheets, &b.workbook.defined_names, b.workbook.date_1904, b.workbook.active_tab) + ); + assert_eq!(written(a), written(b)); + assert_eq!(a.shared_strings, b.shared_strings); + if let (Some(x), Some(y)) = (&a.styles, &b.styles) { + assert_eq!(x.num_fmts, y.num_fmts); + assert_eq!(x.fonts, y.fonts); + assert_eq!(x.fills, y.fills); + assert_eq!(x.borders, y.borders); + assert_eq!(x.cell_style_xfs.iter().map(|x| x.to_el()).collect::>(), y.cell_style_xfs.iter().map(|x| x.to_el()).collect::>()); + assert_eq!(x.cell_xfs.iter().map(|x| x.to_el()).collect::>(), y.cell_xfs.iter().map(|x| x.to_el()).collect::>()); + assert_eq!(x.cell_styles, y.cell_styles); + assert_eq!(x.dxfs, y.dxfs); + assert_eq!(x.indexed_colors, y.indexed_colors); + assert_eq!(String::from_utf8(x.to_xml()), String::from_utf8(y.to_xml())); + } else { + assert!(a.styles.is_none() && b.styles.is_none()); + } + assert_eq!(a.worksheets.len(), b.worksheets.len()); + for (x, y) in a.worksheets.iter().zip(&b.worksheets) { + let (Some(x), Some(y)) = (x, y) else { + assert!(x.is_none() && y.is_none()); + continue; + }; + assert_eq!(x.rows, y.rows); + assert_eq!((&x.part.part, &x.part.columns, &x.part.merges), (&y.part.part, &y.part.columns, &y.part.merges)); + assert_eq!(String::from_utf8(x.part.to_xml(&x.rows)), String::from_utf8(y.part.to_xml(&y.rows))); + assert_eq!(x.part.rels.list, y.part.rels.list); + } + let mut pa = a.parts.clone(); + let mut pb = b.parts.clone(); + pa.sort_by(|x, y| x.name.cmp(&y.name)); + pb.sort_by(|x, y| x.name.cmp(&y.name)); + assert_eq!(pa, pb); +} + +#[test] +fn a_workbook_round_trips() { + let doc = read(&excel_like()); + assert_eq!(doc.kind, XlsxKind::MacroWorkbook); + assert_eq!(doc.worksheets.iter().map(Option::is_some).collect::>(), [true, false, true]); + let kept: Vec<&str> = doc.parts.iter().map(|p| p.name.as_str()).collect(); + for name in + ["_rels/.rels", "docProps/core.xml", "xl/theme/theme1.xml", "xl/drawings/drawing1.xml", "xl/chartsheets/sheet1.xml", "xl/vbaProject.bin"] + { + assert!(kept.contains(&name), "{name} in {kept:?}"); + } + assert!( + !kept.iter().any(|n| n.contains("calcChain") || n.contains("styles") || n.contains("sharedStrings") || n.contains("worksheets")), + "{kept:?}" + ); + + let bytes = doc.write().unwrap(); + let again = read(&bytes); + assert_same(&doc, &again); + // Written again, the bytes are the same. + assert_eq!(again.write().unwrap(), bytes); + + // What the reader sees, through the part layer. + let mut p = Package::open(&bytes, &Limits::default()).unwrap(); + assert!(p.opc().read("xl/calcChain.xml").unwrap().is_none()); + let wb = p.workbook().unwrap(); + assert_eq!(wb.active_tab, Some(1)); + assert_eq!(wb.defined_names[1].attrs, [("function".to_string(), "1".to_string()), ("vbProcedure".to_string(), "1".to_string())]); + assert!(wb.root.child("mc:AlternateContent").is_none(), "a choice requiring an unlisted namespace is dropped"); + assert!(wb.root.find("x15:workbookPr").is_some()); + let strings = p.shared_strings().unwrap(); + let mut values = vec![]; + p.sheet(&wb.sheets[0], |row| { + values.extend(row.cells.into_iter().map(|c| (c.at.a1(), c.value.resolve(&strings, false)))); + Ok(()) + }) + .unwrap(); + assert_eq!(values[1], ("B1".to_string(), Value::text("東京 x\r"))); + assert_eq!(values[7], ("B3".to_string(), Value::text(" inline "))); + let book = String::from_utf8(p.opc().read("xl/workbook.xml").unwrap().unwrap()).unwrap(); + assert!(book.contains(r#""#), "{book}"); + let sheet = String::from_utf8(p.opc().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); + assert!(sheet.contains(r#"mc:Ignorable="x14ac xr""#) && sheet.contains(r#""#), "{sheet}"); + let types = String::from_utf8(p.opc().read("[Content_Types].xml").unwrap().unwrap()).unwrap(); + assert!(types.contains("macroEnabled.main+xml") && types.contains(r#"Extension="bin""#) && !types.contains("calcChain"), "{types}"); +} + +#[test] +fn edits_are_written() { + let mut doc = read(&excel_like()); + let ws = doc.worksheets[0].as_mut().unwrap(); + ws.rows.push(Row { index: 9, cells: vec![Cell { at: CellRef::new(9, 3), value: CellValue::Shared(2), ..Cell::default() }], ..Row::default() }); + ws.part.merges.push(Range::parse("E1:F1").unwrap()); + doc.shared_strings.push(StringItem::plain("new")); + doc.workbook.sheets[2].visibility = Visibility::Visible; + doc.workbook.date_1904 = true; + doc.styles.as_mut().unwrap().fonts[0].italic = Some(true); + let again = read(&doc.write().unwrap()); + assert_same(&doc, &again); + let ws = again.worksheets[0].as_ref().unwrap(); + assert_eq!(ws.part.root.child("dimension").and_then(|d| d.attr("ref")), Some("A1:D10")); + assert_eq!(ws.part.root.child("mergeCells").and_then(|m| m.attr("count")), Some("2")); + + // A sheet removed takes its relationship with it. + doc.workbook.sheets.remove(2); + doc.worksheets.remove(2); + let bytes = doc.write().unwrap(); + let mut p = Package::open(&bytes, &Limits::default()).unwrap(); + let rels = p.workbook_rels().unwrap(); + assert!(!rels.list.iter().any(|r| r.target.contains("sheet2"))); + assert!(p.opc().read("xl/worksheets/sheet2.xml").unwrap().is_none()); + assert_eq!(rels.all("worksheet").count(), 1); +} + +#[test] +fn a_new_workbook() { + let cell = |at: &str, value| Cell { at: CellRef::parse(at).unwrap(), value, ..Cell::default() }; + let doc = Document { + workbook: Workbook { + sheets: vec![SheetEntry { + name: "Sheet1".into(), + sheet_id: 1, + visibility: Visibility::Visible, + rel_id: String::new(), + kind: SheetKind::Worksheet, + part: None, + }], + defined_names: vec![DefinedName { name: "Two".into(), formula: "Sheet1!$B$1".into(), ..DefinedName::default() }], + ..Workbook::default() + }, + shared_strings: vec![StringItem::plain("hello")], + styles: Some(Styles { fonts: vec![Font { size: Some(11.0), name: Some("Calibri".into()), ..Font::default() }], ..Styles::default() }), + worksheets: vec![Some(Worksheet { + rows: vec![Row { index: 0, cells: vec![cell("A1", CellValue::Shared(0)), cell("B1", CellValue::Number(2.0))], ..Row::default() }], + ..Worksheet::default() + })], + ..Document::default() + }; + let bytes = doc.write().unwrap(); + let mut p = Package::open(&bytes, &Limits::default()).unwrap(); + assert_eq!(p.workbook_part(), "xl/workbook.xml"); + let wb = p.workbook().unwrap(); + assert_eq!((wb.sheets[0].part.as_deref(), wb.sheets[0].rel_id.as_str()), (Some("xl/worksheets/sheet1.xml"), "rId1")); + assert_eq!(wb.defined_names[0].formula, "Sheet1!$B$1"); + let strings = p.shared_strings().unwrap(); + let mut values = vec![]; + p.sheet(&wb.sheets[0], |row| { + values.extend(row.cells.into_iter().map(|c| c.value.resolve(&strings, false))); + Ok(()) + }) + .unwrap(); + assert_eq!(values, [Value::text("hello"), Value::Number(2.0)]); + assert_eq!(p.styles().unwrap().unwrap().fonts.len(), 1); + assert_eq!(read(&bytes).parts.iter().map(|p: &KeptPart| p.name.as_str()).collect::>(), ["_rels/.rels"]); +} + +#[test] +fn what_cannot_be_written_is_refused() { + let mut doc = read(&excel_like()); + doc.worksheets.pop(); + assert!(matches!(doc.write(), Err(Error::Invalid(_)))); + let mut doc = read(&excel_like()); + doc.workbook.sheets[1].rel_id = "nowhere".into(); + assert!(matches!(doc.write(), Err(Error::Invalid(_)))); +}