diff --git a/crates/xlsx/README.md b/crates/xlsx/README.md index 09b09c698..2fd27dfa6 100644 --- a/crates/xlsx/README.md +++ b/crates/xlsx/README.md @@ -14,6 +14,12 @@ This is the part layer. `Package::open` opens the container through `craft-opc` | `sheet(entry, each)` | each `Row` of `Cell`s to a callback, then a `SheetPart`: the rest of the worksheet as a tree, its relationships, columns and merged cells | | `opc()`, `read_xml()`, `optional_xml()`, `rels()` | any other part | +Every part is read with fixed prefixes (`ns::CANONICAL`): the SpreadsheetML main namespace +(transitional or strict) unprefixed, the others by `ns::NAMESPACES` (`r:id`, `mc:AlternateContent`, +`x14:sparklineGroups`, `xm:sqref`), and the prefixes in `mc:Ignorable` and `mc:Choice/@Requires` +rewritten to match. A tree kept from a part goes back out with `ns::declare`, which declares what +it uses. A name in a namespace that is not listed reads as `?:name` and is not written. + A cell keeps the file's terms: a shared-string index, a format index, the formula text as the 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. diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 1859b2c4b..97b3e9360 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -13,6 +13,10 @@ //! read without holding it; what is left of the part (properties, columns, merged cells, //! validation, and so on) comes back as a tree ([`SheetPart`]). //! +//! Parts are read with fixed prefixes ([`ns`]): the SpreadsheetML main namespace has none +//! (`sheetData`), the others have theirs whatever the file used (`r:id`, `x14:sparklineGroups`, +//! `mc:AlternateContent`), so a tree kept from a part can be written back with [`ns::declare`]. +//! //! Nothing is interpreted beyond the format: a cell holds a shared-string index and a format //! index, a width is in the file's units, a formula is the file's text. Every other part //! (drawings, charts, pivot caches, macros) is reachable through [`Package::opc`], so a caller @@ -45,6 +49,7 @@ //! # } //! ``` +pub mod ns; mod package; mod sheet; mod strings; diff --git a/crates/xlsx/src/ns.rs b/crates/xlsx/src/ns.rs new file mode 100644 index 000000000..5e7dbc846 --- /dev/null +++ b/crates/xlsx/src/ns.rs @@ -0,0 +1,182 @@ +//! Namespaces: every part is read with one fixed prefix per namespace, whatever prefix the file +//! used, so a tree can be matched by name and written back with [`declare`]. +//! +//! SpreadsheetML's main namespace (transitional or strict) has no prefix: `sheetData`, `row`. +//! The others have the prefixes in [`NAMESPACES`]: `r:id`, `mc:AlternateContent`, +//! `x14:dataValidation`, `xm:sqref`, `a:solidFill`. A name in a namespace that is not listed +//! reads as `?:local`; [`craft_xml::El::write_xml`] leaves such names out. + +use craft_xml::{AttrValueHook, Canonical, El, Node, Scope}; + +/// SpreadsheetML, transitional (ECMA-376 Part 1, 18). +pub const MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; +/// SpreadsheetML, strict (ISO/IEC 29500-1). +pub const MAIN_STRICT: &str = "http://purl.oclc.org/ooxml/spreadsheetml/main"; +/// Relationship references (`r:id`). +pub const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; +/// Markup compatibility (ECMA-376 Part 3). +pub const MC: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006"; + +/// (prefix, namespace URI) for every namespace read with a prefix of its own. Where a prefix has +/// several URIs (transitional and strict), the first is the one [`declare`] writes. +pub const NAMESPACES: &[(&str, &str)] = &[ + ("", MAIN), + ("", MAIN_STRICT), + ("r", R), + ("r", "http://purl.oclc.org/ooxml/officeDocument/relationships"), + ("mc", MC), + ("x12ac", "http://schemas.microsoft.com/office/spreadsheetml/2011/1/ac"), + ("x14ac", "http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac"), + ("x14", "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"), + ("x15ac", "http://schemas.microsoft.com/office/spreadsheetml/2010/11/ac"), + ("x15", "http://schemas.microsoft.com/office/spreadsheetml/2010/11/main"), + ("x16r2", "http://schemas.microsoft.com/office/spreadsheetml/2015/02/main"), + ("xr", "http://schemas.microsoft.com/office/spreadsheetml/2014/revision"), + ("xr2", "http://schemas.microsoft.com/office/spreadsheetml/2015/revision2"), + ("xr3", "http://schemas.microsoft.com/office/spreadsheetml/2016/revision3"), + ("xr4", "http://schemas.microsoft.com/office/spreadsheetml/2016/revision4"), + ("xr5", "http://schemas.microsoft.com/office/spreadsheetml/2016/revision5"), + ("xr6", "http://schemas.microsoft.com/office/spreadsheetml/2016/revision6"), + ("xr7", "http://schemas.microsoft.com/office/spreadsheetml/2016/revision7"), + ("xr8", "http://schemas.microsoft.com/office/spreadsheetml/2016/revision8"), + ("xr9", "http://schemas.microsoft.com/office/spreadsheetml/2016/revision9"), + ("xr10", "http://schemas.microsoft.com/office/spreadsheetml/2016/revision10"), + ("xm", "http://schemas.microsoft.com/office/excel/2006/main"), + ("xda", "http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray"), + ("rv", "http://schemas.microsoft.com/office/spreadsheetml/2017/richdata"), + ("rvrel", "http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel"), + ("xcalcf", "http://schemas.microsoft.com/office/spreadsheetml/2018/calcfeatures"), + ("x18tc", "http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"), + ("xpdl", "http://schemas.microsoft.com/office/spreadsheetml/2016/pivotdefaultlayout"), + ("xfpb", "http://schemas.microsoft.com/office/spreadsheetml/2022/featurepropertybag"), + ("xpy", "http://schemas.microsoft.com/office/spreadsheetml/2023/python"), + ("a", "http://schemas.openxmlformats.org/drawingml/2006/main"), + ("a", "http://purl.oclc.org/ooxml/drawingml/main"), + ("xdr", "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"), + ("xdr", "http://purl.oclc.org/ooxml/drawingml/spreadsheetDrawing"), + ("c", "http://schemas.openxmlformats.org/drawingml/2006/chart"), + ("c", "http://purl.oclc.org/ooxml/drawingml/chart"), + ("cx", "http://schemas.microsoft.com/office/drawing/2014/chartex"), + ("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"), + ("a14", "http://schemas.microsoft.com/office/drawing/2010/main"), + ("v", "urn:schemas-microsoft-com:vml"), + ("o", "urn:schemas-microsoft-com:office:office"), + ("x", "urn:schemas-microsoft-com:office:excel"), + ("xsi", "http://www.w3.org/2001/XMLSchema-instance"), +]; + +/// The markup-compatibility attributes whose values are lists of prefixes (or of `prefix:name`). +const PREFIX_LISTS: &[&str] = &["mc:Ignorable", "mc:ProcessContent", "mc:MustUnderstand", "mc:PreserveElements", "mc:PreserveAttributes"]; + +/// Rewrites the prefixes in `mc:Ignorable` (and its kin) and `mc:Choice/@Requires` to the +/// canonical ones. In a list, a prefix of an unlisted namespace is left out; in `Requires` it +/// becomes `?`, so that the choice is known to need something this crate cannot carry. +fn mc_prefixes(el: &str, attr: &str, value: &str, scope: &Scope<'_>) -> Option { + let requires = el == "mc:Choice" && attr == "Requires"; + if !requires && !PREFIX_LISTS.contains(&attr) { + return None; + } + let canon = |p: &str| scope.uri(p).map_or("?", |uri| CANONICAL.prefix(uri)); + let tokens = value.split_whitespace().filter_map(|t| { + let (p, rest) = t.split_once(':').map_or((t, None), |(p, l)| (p, Some(l))); + let c = canon(p); + if requires { + return Some(c.to_string()); + } + (c != "?" && !c.is_empty()).then(|| rest.map_or_else(|| c.to_string(), |l| format!("{c}:{l}"))) + }); + Some(tokens.collect::>().join(" ")) +} + +const MC_HOOK: &AttrValueHook<'static> = &mc_prefixes; + +/// The canonical names parts are read with. +pub const CANONICAL: Canonical<'static> = Canonical { namespaces: NAMESPACES, unknown: "?", element: None, attr_value: Some(MC_HOOK) }; + +/// Prepares a tree read with [`CANONICAL`] for writing: declares on `root` every namespace its +/// names and markup-compatibility attributes use (the main namespace as the default one), and +/// 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) { + drop_unknown_choices(root); + let mut used: Vec<&str> = Vec::new(); + collect(root, &mut used); + let mut decls = vec![("xmlns".to_string(), MAIN.to_string())]; + for (p, uri) in NAMESPACES { + if !p.is_empty() && used.contains(p) && !decls.iter().any(|(k, _)| k.strip_prefix("xmlns:") == Some(p)) { + decls.push((format!("xmlns:{p}"), uri.to_string())); + } + } + root.attrs.retain(|(k, _)| k != "xmlns" && !k.starts_with("xmlns:")); + decls.append(&mut root.attrs); + root.attrs = decls; +} + +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); + } +} + +fn collect<'a>(e: &'a El, used: &mut Vec<&'a str>) { + if let Some((p, _)) = e.name.split_once(':') { + used.push(p); + } + for (k, v) in &e.attrs { + if let Some((p, _)) = k.split_once(':') { + used.push(p); + } + if PREFIX_LISTS.contains(&k.as_str()) || (e.name == "mc:Choice" && k == "Requires") { + used.extend(v.split_whitespace().map(|t| t.split_once(':').map_or(t, |(p, _)| p))); + } + } + for c in e.elements() { + collect(c, used); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(xml: &str) -> El { + craft_xml::Options::canonical(&CANONICAL).parse(xml.as_bytes()).unwrap_or_default() + } + + #[test] + fn names_get_fixed_prefixes_and_write_back() { + let x = format!( + r#""# + ); + let mut root = read(&x); + assert_eq!(root.attr("mc:Ignorable"), Some("x14ac")); + assert_eq!(root.child("drawing").and_then(|d| d.attr("r:id")), Some("rId1")); + let choices: Vec<_> = root.find_all("mc:Choice").filter_map(|c| c.attr("Requires")).collect(); + assert_eq!(choices, ["?", "x14ac"]); + declare(&mut root); + let out = root.to_xml(); + assert_eq!( + out, + format!( + r#""# + ) + ); + // What was written reads back to the same tree. + let mut again = read(&out); + declare(&mut again); + assert_eq!(again, root); + } + + #[test] + fn extension_lists_keep_their_namespaces() { + let x = r#"Other!$A$1:$A$3G1:G4"#; + let mut root = read(x); + assert!(root.find("dataValidation").is_none(), "an extension's element is not the main one"); + assert_eq!(root.find("xm:sqref").map(|e| e.text().into_owned()), Some("G1:G4".into())); + declare(&mut root); + assert!(root.to_xml().starts_with(r#" Vec { r#" 11B1*22 1904-01-039 - "# + + A1:E1F1"# ); zip(&[ ("[Content_Types].xml", b""), @@ -82,6 +83,13 @@ fn a_strict_workbook_with_prefixes() { assert_eq!((part.columns[0].first, part.columns[0].last, part.columns[0].width), (1, 2, Some(20.5))); assert_eq!(part.root.child("pageSetup").and_then(|e| e.attr("orientation")), Some("landscape")); assert!(part.root.child("sheetData").is_some_and(|d| d.elements().next().is_none())); + // Other namespaces keep fixed prefixes, so the rest of the part can be written back. + assert_eq!(part.root.child("drawing").and_then(|d| d.attr("r:id")), Some("d1")); + assert_eq!(part.root.find("xm:sqref").map(|e| e.text().into_owned()).as_deref(), Some("F1")); + let mut root = part.root.clone(); + craft_xlsx::ns::declare(&mut root); + let again = craft_xml::Options::canonical(&craft_xlsx::ns::CANONICAL).parse(root.to_xml().as_bytes()).unwrap(); + assert_eq!(again, part.root); assert_eq!((part.rows, part.dropped, part.damaged.as_deref()), (2, 0, None)); let values: Vec<_> = cells.iter().map(|c| c.value.resolve(&strings, true)).collect(); assert_eq!(values, [Value::text("one"), Value::Number(1.0), Value::Number(2.0), Value::Number(2.0), Value::Empty]); diff --git a/crates/xml/src/read.rs b/crates/xml/src/read.rs index 3af4d9cac..e155099e8 100644 --- a/crates/xml/src/read.rs +++ b/crates/xml/src/read.rs @@ -81,7 +81,8 @@ pub type AttrValueHook<'a> = dyn Fn(&str, &str, &str, &Scope<'_>) -> Option Events<'o, 'i> { let listed = canon.prefix(uri); let parent = self.open.last().map(|o| o.name.as_str()); let chosen = canon.element.and_then(|hook| hook(&NsName { prefix: listed, uri, local, parent })).unwrap_or(listed); - format!("{chosen}:{local}") + if chosen.is_empty() { local.to_string() } else { format!("{chosen}:{local}") } } } } @@ -521,9 +522,9 @@ impl<'o, 'i> Events<'o, 'i> { Names::AsWritten => raw.to_string(), Names::Local => local_of(raw).to_string(), Names::Canonical(canon) => match raw.split_once(':') { - Some((prefix, local)) => match self.uri(prefix) { - Some(uri) => format!("{}:{local}", canon.prefix(uri)), - None => local.to_string(), + Some((prefix, local)) => match self.uri(prefix).map(|uri| canon.prefix(uri)) { + Some(p) if !p.is_empty() => format!("{p}:{local}"), + _ => local.to_string(), }, None => raw.to_string(), }, diff --git a/crates/xml/tests/apps.rs b/crates/xml/tests/apps.rs index 6438cef59..927222b4d 100644 --- a/crates/xml/tests/apps.rs +++ b/crates/xml/tests/apps.rs @@ -64,6 +64,17 @@ fn word_unknown_namespaces_read_behind_a_question_mark_and_are_not_written() { assert_eq!(body.to_xml(), r#"t"#); } +/// A vocabulary's own namespace listed with the empty prefix reads unprefixed (SpreadsheetML +/// keeps its main namespace as the default one), whatever prefix the document used. +#[test] +fn an_empty_canonical_prefix_reads_unprefixed() { + const MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + let canon = Canonical::new(&[("", MAIN), ("x14", "urn:x14")]); + let x = format!(r#""#); + let root = Options::canonical(&canon).parse(x.as_bytes()).unwrap(); + assert_eq!(root.to_xml(), r#""#); +} + /// WordCraft reads the one Strict WordprocessingDrawing namespace as Word 2010's several, by /// element and parent: the element hook. #[test]