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(_)))); +}