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.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 21:01:51 -05:00
1 parent 75a2e152b1
commit ae085a2f7e
12 files changed
+1392 -82

No files matched your search

+6
View File
@@ -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<String> {
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 {
+3
View File
@@ -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"<a/>"[..]));
}
+14
View File
@@ -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).
+277
View File
@@ -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> {
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<Row>,
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<String>,
pub bytes: Vec<u8>,
}
/// 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<StringItem>,
pub styles: Option<Styles>,
/// 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<Option<Worksheet>>,
pub parts: Vec<KeptPart>,
/// What reading skipped or repaired.
pub warnings: Vec<String>,
}
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<Document, Error> {
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<String> = 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<Vec<u8>, 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<String> = 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<String>| {
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::<Vec<_>>(), |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()
}
+5 -1
View File
@@ -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)]
+9 -1
View File
@@ -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>) {
+197 -2
View File
@@ -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 `<v>` 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<u32>,
pub cells: Vec<Cell>,
/// The other attributes as written (`thickBot`, `x14ac:dyDescent`).
pub attrs: Vec<(String, String)>,
}
/// One `<col>`: 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<u8> {
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<Range> {
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<String>| {
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<String>) = 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#"<row r="3" spans="1:9" ht="20" customHeight="1" s="4" customFormat="1" outlineLevel="2" collapsed="1" hidden="1" thickBot="1"><c r="B3" s="2" t="s"><v>7</v></c><c r="C3"><f t="shared" ref="C3:C9" si="0" aca="1">A1*2</f><v>4</v></c><c r="D3"><f t="shared" si="0"/><v>0.1</v></c><c r="E3" t="e"><v>#SPILL!</v></c><c r="G3" t="b"><v>0</v></c><c r="H3" t="inlineStr"><is><t xml:space="preserve">in_x000A_line </t></is></c><c r="J3" t="str"><f>"a"&amp;"b"</f><v>a_x0009_b</v></c><c r="K3" t="d"><v>2024-01-01</v></c><c r="L3" s="1" cm="1" vm="2" ph="1"/></row>"#;
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#"<row r="3" s="4" customFormat="1" ht="20" customHeight="1" hidden="1" outlineLevel="2" collapsed="1" thickBot="1"><c r="B3" s="2" t="s"><v>7</v></c><c r="C3"><f t="shared" ref="C3:C9" si="0" aca="1">A1*2</f>"#), "{out}");
assert_eq!(read(&out), row);
let empty = Row { index: 0, ..Row::default() };
let mut w = Writer::new();
write_row(&mut w, &empty);
assert_eq!(w.finish(), r#"<row r="1"/>"#);
}
}
+65 -2
View File
@@ -33,6 +33,18 @@ pub struct PhoneticProps {
pub alignment: Option<String>,
}
const KINDS: &[&str] = &["halfwidthKatakana", "fullwidthKatakana", "Hiragana", "noConversion"];
const ALIGNMENTS: &[&str] = &["noControl", "left", "center", "distributed"];
/// `<t>`, with `xml:space="preserve"` when the text has edge whitespace or a line break, and
/// the text in `_xHHHH_` form ([`encode_xstring`]).
pub fn t_el(text: &str) -> El {
let t = El::new("t");
let ws = [' ', '\t', '\n', '\r'];
let t = if text.starts_with(ws) || text.ends_with(ws) || text.contains('\n') { t.with_attr("xml:space", "preserve") } else { t };
if text.is_empty() { t } else { t.with_text(encode_xstring(text)) }
}
/// One string item: a shared string (`<si>`), an inline string (`<is>`) or comment text.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StringItem {
@@ -68,8 +80,6 @@ impl StringItem {
phonetic_runs.push(PhoneticRun { start, end, text: reading });
}
}
const KINDS: &[&str] = &["halfwidthKatakana", "fullwidthKatakana", "Hiragana", "noConversion"];
const ALIGNMENTS: &[&str] = &["noControl", "left", "center", "distributed"];
let phonetic = e.child("phoneticPr").map(|p| PhoneticProps {
font_id: p.attr_u32("fontId").unwrap_or(0),
kind: p.attr("type").map(str::trim).filter(|v| KINDS.contains(v)).map(str::to_string),
@@ -79,6 +89,50 @@ impl StringItem {
}
}
impl StringItem {
/// The item as `<name>`: `si` in the shared strings, `is` in a cell, `text` in a comment.
/// The formatted runs when there are any, else the text; then the phonetic runs and
/// properties, checked again as the reader checks them (a run inside the text with a
/// reading, a kind and alignment the schema defines), so what is written is valid.
pub fn to_el(&self, name: &str) -> El {
let mut e = El::new(name);
if self.runs.is_empty() {
e.push(t_el(&self.text));
}
for r in &self.runs {
let mut run = El::new("r");
if let Some(f) = &r.font {
run.push(f.to_el("rPr", true));
}
run.push(t_el(&r.text));
e.push(run);
}
let len = u32::try_from(self.text.encode_utf16().count()).unwrap_or(u32::MAX);
for r in &self.phonetic_runs {
let (start, end) = (r.start.min(len), r.end.min(len));
if start < end && !r.text.is_empty() {
e.push(El::new("rPh").with_attr("sb", start).with_attr("eb", end).with_child(t_el(&r.text)));
}
}
if let Some(p) = &self.phonetic {
let mut pr = El::new("phoneticPr").with_attr("fontId", p.font_id);
if let Some(k) = p.kind.as_deref().filter(|k| KINDS.contains(k)) {
pr.set_attr("type", k);
}
if let Some(a) = p.alignment.as_deref().filter(|a| ALIGNMENTS.contains(a)) {
pr.set_attr("alignment", a);
}
e.push(pr);
}
e
}
/// A plain string.
pub fn plain(text: &str) -> StringItem {
StringItem { text: Arc::from(text), ..StringItem::default() }
}
}
/// The text of a string item: `<t>` and the `<t>` of each `<r>`, in order, with `_xHHHH_`
/// escapes decoded. Phonetic runs are not part of the text.
pub fn rich_text(e: &El) -> String {
@@ -192,5 +246,14 @@ mod tests {
assert_eq!(si.phonetic, Some(PhoneticProps { font_id: 3, kind: Some("Hiragana".into()), alignment: None }));
let plain = item("<si><t>a_x000A_b</t></si>");
assert_eq!((&*plain.text, plain.runs.len()), ("a\nb", 0));
// Written back: the dropped runs stay dropped, the bad alignment is left out.
let again = item(&si.to_el("si").to_xml());
assert_eq!((&again.text, &again.runs, &again.phonetic_runs, again.dropped_phonetic_runs), (&si.text, &si.runs, &si.phonetic_runs, 0));
assert_eq!(again.phonetic, si.phonetic);
assert_eq!(item(&plain.to_el("si").to_xml()), plain);
assert_eq!(t_el(" a\r").to_xml(), r#"<t xml:space="preserve"> a_x000D_</t>"#);
// A run that does not fit the text is not written.
let bad = StringItem { phonetic_runs: vec![PhoneticRun { start: 3, end: 9, text: "x".into() }], ..StringItem::plain("ab") };
assert_eq!(bad.to_el("si").to_xml(), "<si><t>ab</t></si>");
}
}
+326 -75
View File
@@ -5,6 +5,85 @@
use craft_sheet::{BorderStyle, DEFAULT_PALETTE, HAlign, Pattern, Script, Underline, VAlign, builtin_format};
use craft_xml::El;
use crate::write::{flag, list, num, set};
/// A value's spelling, by table: the first entry for a value is the one written.
fn spelled<T: PartialEq + Copy>(table: &[(&'static str, T)], v: T) -> Option<&'static str> {
table.iter().find(|(_, t)| *t == v).map(|(n, _)| *n)
}
fn spelling<T: Copy>(table: &[(&str, T)], s: &str) -> Option<T> {
table.iter().find(|(n, _)| *n == s).map(|(_, t)| *t)
}
const UNDERLINES: &[(&str, Underline)] = &[
("single", Underline::Single),
("double", Underline::Double),
("singleAccounting", Underline::SingleAccounting),
("doubleAccounting", Underline::DoubleAccounting),
("none", Underline::None),
];
const SCRIPTS: &[(&str, Script)] = &[("superscript", Script::Superscript), ("subscript", Script::Subscript), ("baseline", Script::Baseline)];
const SCHEMES: &[(&str, FontScheme)] = &[("major", FontScheme::Major), ("minor", FontScheme::Minor), ("none", FontScheme::None)];
const PATTERNS: &[(&str, Pattern)] = &[
("solid", Pattern::Solid),
("mediumGray", Pattern::Gray50),
("darkGray", Pattern::Gray75),
("lightGray", Pattern::Gray25),
("gray125", Pattern::Gray125),
("gray0625", Pattern::Gray0625),
("darkHorizontal", Pattern::DarkHorizontal),
("darkVertical", Pattern::DarkVertical),
("darkDown", Pattern::DarkDown),
("darkUp", Pattern::DarkUp),
("darkGrid", Pattern::DarkGrid),
("darkTrellis", Pattern::DarkTrellis),
("lightHorizontal", Pattern::LightHorizontal),
("lightVertical", Pattern::LightVertical),
("lightDown", Pattern::LightDown),
("lightUp", Pattern::LightUp),
("lightGrid", Pattern::LightGrid),
("lightTrellis", Pattern::LightTrellis),
("none", Pattern::None),
];
const BORDER_STYLES: &[(&str, BorderStyle)] = &[
("thin", BorderStyle::Thin),
("medium", BorderStyle::Medium),
("thick", BorderStyle::Thick),
("dashed", BorderStyle::Dashed),
("dotted", BorderStyle::Dotted),
("double", BorderStyle::Double),
("hair", BorderStyle::Hair),
("mediumDashed", BorderStyle::MediumDashed),
("dashDot", BorderStyle::DashDot),
("mediumDashDot", BorderStyle::MediumDashDot),
("dashDotDot", BorderStyle::DashDotDot),
("mediumDashDotDot", BorderStyle::MediumDashDotDot),
("slantDashDot", BorderStyle::SlantDashDot),
("none", BorderStyle::None),
];
const HALIGNS: &[(&str, HAlign)] = &[
("left", HAlign::Left),
("center", HAlign::Center),
("right", HAlign::Right),
("fill", HAlign::Fill),
("justify", HAlign::Justify),
("centerContinuous", HAlign::CenterAcross),
("distributed", HAlign::Distributed),
("general", HAlign::General),
];
const VALIGNS: &[(&str, VAlign)] = &[
("top", VAlign::Top),
("center", VAlign::Center),
("justify", VAlign::Justify),
("distributed", VAlign::Distributed),
("bottom", VAlign::Bottom),
];
/// `<name/>` for true, `<name val="0"/>` for false.
fn bool_el(name: &str, on: bool) -> El {
if on { El::new(name) } else { El::new(name).with_attr("val", "0") }
}
/// Characters of a font name kept (Excel allows 31; damaged files have far more).
const MAX_FONT_NAME_CHARS: usize = 255;
@@ -47,6 +126,19 @@ impl Color {
/// `0xRRGGBB` for an RGB colour, or an indexed one in `palette`; `None` for automatic and
/// theme colours. The tint is not applied.
/// The colour as an element named `name` (`color`, `fgColor`, `tabColor`).
pub fn to_el(&self, name: &str) -> El {
let e = El::new(name);
let mut e = match self.source {
ColorSource::Auto => e.with_attr("auto", "1"),
ColorSource::Rgb(v) => e.with_attr("rgb", format!("{v:08X}")),
ColorSource::Theme(t) => e.with_attr("theme", t),
ColorSource::Indexed(i) => e.with_attr("indexed", i),
};
set(&mut e, "tint", (self.tint != 0.0).then(|| num(self.tint)));
e
}
pub fn rgb(&self, palette: &[u32]) -> Option<u32> {
match self.source {
ColorSource::Rgb(v) => Some(v & 0xFF_FFFF),
@@ -109,28 +201,60 @@ impl Font {
shadow: flag("shadow"),
condense: flag("condense"),
extend: flag("extend"),
underline: e.child("u").map(|u| match u.attr("val").unwrap_or("single") {
"double" => Underline::Double,
"singleAccounting" => Underline::SingleAccounting,
"doubleAccounting" => Underline::DoubleAccounting,
"none" => Underline::None,
_ => Underline::Single,
}),
vert_align: val("vertAlign").map(|v| match v {
"superscript" => Script::Superscript,
"subscript" => Script::Subscript,
_ => Script::Baseline,
}),
underline: e.child("u").map(|u| spelling(UNDERLINES, u.attr("val").unwrap_or("single")).unwrap_or(Underline::Single)),
vert_align: val("vertAlign").map(|v| spelling(SCRIPTS, v).unwrap_or(Script::Baseline)),
color: e.child("color").map(Color::read),
family: e.child("family").and_then(|c| c.attr_u32("val")),
charset: e.child("charset").and_then(|c| c.attr_u32("val")),
scheme: val("scheme").map(|s| match s {
"major" => FontScheme::Major,
"minor" => FontScheme::Minor,
_ => FontScheme::None,
}),
scheme: val("scheme").map(|s| spelling(SCHEMES, s).unwrap_or(FontScheme::None)),
}
}
/// The font as `<name>`: `font` in the styles part, `rPr` in a rich-text run (`run`, which
/// names the typeface `rFont`).
pub fn to_el(&self, name: &str, run: bool) -> El {
let mut e = El::new(name);
let flags = [
("b", self.bold),
("i", self.italic),
("strike", self.strike),
("condense", self.condense),
("extend", self.extend),
("outline", self.outline),
("shadow", self.shadow),
];
for (n, v) in flags {
if let Some(v) = v {
e.push(bool_el(n, v));
}
}
let val = |n: &str, v: &str| El::new(n).with_attr("val", v);
if let Some(u) = self.underline {
e.push(if u == Underline::Single { El::new("u") } else { val("u", spelled(UNDERLINES, u).unwrap_or("none")) });
}
if let Some(v) = self.vert_align.and_then(|v| spelled(SCRIPTS, v)) {
e.push(val("vertAlign", v));
}
if let Some(sz) = self.size {
e.push(val("sz", &num(sz)));
}
if let Some(c) = &self.color {
e.push(c.to_el("color"));
}
if let Some(n) = &self.name {
e.push(val(if run { "rFont" } else { "name" }, n));
}
if let Some(f) = self.family {
e.push(val("family", &f.to_string()));
}
if let Some(c) = self.charset {
e.push(val("charset", &c.to_string()));
}
if let Some(s) = self.scheme.and_then(|s| spelled(SCHEMES, s)) {
e.push(val("scheme", s));
}
e
}
}
/// `<patternFill>` (18.8.32).
@@ -170,30 +294,6 @@ impl Default for Fill {
}
}
fn pattern(s: &str) -> Pattern {
match s {
"solid" => Pattern::Solid,
"mediumGray" => Pattern::Gray50,
"darkGray" => Pattern::Gray75,
"lightGray" => Pattern::Gray25,
"gray125" => Pattern::Gray125,
"gray0625" => Pattern::Gray0625,
"darkHorizontal" => Pattern::DarkHorizontal,
"darkVertical" => Pattern::DarkVertical,
"darkDown" => Pattern::DarkDown,
"darkUp" => Pattern::DarkUp,
"darkGrid" => Pattern::DarkGrid,
"darkTrellis" => Pattern::DarkTrellis,
"lightHorizontal" => Pattern::LightHorizontal,
"lightVertical" => Pattern::LightVertical,
"lightDown" => Pattern::LightDown,
"lightUp" => Pattern::LightUp,
"lightGrid" => Pattern::LightGrid,
"lightTrellis" => Pattern::LightTrellis,
_ => Pattern::None,
}
}
impl Fill {
pub fn read(e: &El) -> Fill {
if let Some(g) = e.child("gradientFill") {
@@ -213,11 +313,39 @@ impl Fill {
}
let Some(p) = e.child("patternFill") else { return Fill::default() };
Fill::Pattern(PatternFill {
pattern: p.attr("patternType").map(pattern),
pattern: p.attr("patternType").map(|t| spelling(PATTERNS, t).unwrap_or(Pattern::None)),
fg: p.child("fgColor").map(Color::read),
bg: p.child("bgColor").map(Color::read),
})
}
/// `<fill>`.
pub fn to_el(&self) -> El {
let inner = match self {
Fill::Pattern(p) => {
let mut e = El::new("patternFill");
set(&mut e, "patternType", p.pattern.and_then(|t| spelled(PATTERNS, t)).map(str::to_string));
for (n, c) in [("fgColor", &p.fg), ("bgColor", &p.bg)] {
if let Some(c) = c {
e.push(c.to_el(n));
}
}
e
}
Fill::Gradient(g) => {
let mut e = El::new("gradientFill");
set(&mut e, "type", g.path.then(|| "path".to_string()));
for (n, v) in [("degree", g.degree), ("left", g.left), ("right", g.right), ("top", g.top), ("bottom", g.bottom)] {
set(&mut e, n, (v != 0.0).then(|| num(v)));
}
for (pos, c) in &g.stops {
e.push(El::new("stop").with_attr("position", num(*pos)).with_child(c.to_el("color")));
}
e
}
};
El::new("fill").with_child(inner)
}
}
/// One edge of a border.
@@ -229,24 +357,20 @@ pub struct BorderEdge {
impl BorderEdge {
fn read(e: &El) -> BorderEdge {
let style = match e.attr("style").unwrap_or("none") {
"thin" => BorderStyle::Thin,
"medium" => BorderStyle::Medium,
"thick" => BorderStyle::Thick,
"dashed" => BorderStyle::Dashed,
"dotted" => BorderStyle::Dotted,
"double" => BorderStyle::Double,
"hair" => BorderStyle::Hair,
"mediumDashed" => BorderStyle::MediumDashed,
"dashDot" => BorderStyle::DashDot,
"mediumDashDot" => BorderStyle::MediumDashDot,
"dashDotDot" => BorderStyle::DashDotDot,
"mediumDashDotDot" => BorderStyle::MediumDashDotDot,
"slantDashDot" => BorderStyle::SlantDashDot,
_ => BorderStyle::None,
};
let style = e.attr("style").and_then(|s| spelling(BORDER_STYLES, s)).unwrap_or_default();
BorderEdge { style, color: e.child("color").map(Color::read) }
}
fn to_el(self, name: &str) -> El {
let mut e = El::new(name);
if self.style != BorderStyle::None {
set(&mut e, "style", spelled(BORDER_STYLES, self.style).map(str::to_string));
}
if let Some(c) = self.color {
e.push(c.to_el("color"));
}
e
}
}
/// `<border>` (18.8.4). An edge is `None` when its element is absent. `start` and `end` (the
@@ -281,6 +405,29 @@ impl Border {
outline: e.flag("outline", true),
}
}
/// `<border>`; the strict `start` and `end` are written as `left` and `right`.
pub fn to_el(&self) -> El {
let mut e = El::new("border");
set(&mut e, "diagonalUp", flag(self.diagonal_up));
set(&mut e, "diagonalDown", flag(self.diagonal_down));
set(&mut e, "outline", (!self.outline).then(|| "0".to_string()));
let edges = [
("left", self.left),
("right", self.right),
("top", self.top),
("bottom", self.bottom),
("diagonal", self.diagonal),
("vertical", self.vertical),
("horizontal", self.horizontal),
];
for (n, edge) in edges {
if let Some(edge) = edge {
e.push(edge.to_el(n));
}
}
e
}
}
/// `<alignment>` (18.8.1).
@@ -301,23 +448,8 @@ pub struct Alignment {
impl Alignment {
pub fn read(a: &El) -> Alignment {
Alignment {
horizontal: a.attr("horizontal").map(|h| match h {
"left" => HAlign::Left,
"center" => HAlign::Center,
"right" => HAlign::Right,
"fill" => HAlign::Fill,
"justify" => HAlign::Justify,
"centerContinuous" => HAlign::CenterAcross,
"distributed" => HAlign::Distributed,
_ => HAlign::General,
}),
vertical: a.attr("vertical").map(|v| match v {
"top" => VAlign::Top,
"center" => VAlign::Center,
"justify" => VAlign::Justify,
"distributed" => VAlign::Distributed,
_ => VAlign::Bottom,
}),
horizontal: a.attr("horizontal").map(|h| spelling(HALIGNS, h).unwrap_or(HAlign::General)),
vertical: a.attr("vertical").map(|v| spelling(VALIGNS, v).unwrap_or(VAlign::Bottom)),
text_rotation: a.attr_u32("textRotation"),
wrap_text: a.flag("wrapText", false),
shrink_to_fit: a.flag("shrinkToFit", false),
@@ -326,6 +458,20 @@ impl Alignment {
justify_last_line: a.flag("justifyLastLine", false),
}
}
/// `<alignment>`.
pub fn to_el(&self) -> El {
let mut e = El::new("alignment");
set(&mut e, "horizontal", self.horizontal.and_then(|h| spelled(HALIGNS, h)).map(str::to_string));
set(&mut e, "vertical", self.vertical.and_then(|v| spelled(VALIGNS, v)).map(str::to_string));
set(&mut e, "textRotation", self.text_rotation.map(|v| v.to_string()));
set(&mut e, "wrapText", flag(self.wrap_text));
set(&mut e, "indent", self.indent.map(|v| v.to_string()));
set(&mut e, "justifyLastLine", flag(self.justify_last_line));
set(&mut e, "shrinkToFit", flag(self.shrink_to_fit));
set(&mut e, "readingOrder", self.reading_order.map(|v| v.to_string()));
e
}
}
/// `<protection>` (18.8.33).
@@ -339,6 +485,13 @@ impl Protection {
fn read(p: &El) -> Protection {
Protection { locked: p.attr_bool("locked"), hidden: p.attr_bool("hidden") }
}
fn to_el(self) -> El {
let mut e = El::new("protection");
set(&mut e, "locked", self.locked.map(|v| u8::from(v).to_string()));
set(&mut e, "hidden", self.hidden.map(|v| u8::from(v).to_string()));
e
}
}
/// A cell format or a cell style format (`<xf>`, 18.8.45): indexes into the tables of
@@ -373,6 +526,28 @@ impl Xf {
el: xf.clone(),
}
}
/// `<xf>`: the typed fields, then the rest of [`Xf::el`] (its `apply*` flags and
/// extensions) as written.
pub fn to_el(&self) -> El {
const TYPED: &[&str] = &["numFmtId", "fontId", "fillId", "borderId", "xfId", "quotePrefix"];
let mut e = El::new("xf")
.with_attr("numFmtId", self.num_fmt_id)
.with_attr("fontId", self.font_id)
.with_attr("fillId", self.fill_id)
.with_attr("borderId", self.border_id);
set(&mut e, "xfId", self.xf_id.map(|v| v.to_string()));
set(&mut e, "quotePrefix", flag(self.quote_prefix));
e.attrs.extend(self.el.attrs.iter().filter(|(k, _)| !TYPED.contains(&k.as_str())).cloned());
if let Some(a) = &self.alignment {
e.push(a.to_el());
}
if let Some(p) = self.protection {
e.push(p.to_el());
}
e.children.extend(self.el.elements().filter(|c| c.name != "alignment" && c.name != "protection").cloned().map(craft_xml::Node::El));
e
}
}
/// A named cell style (`<cellStyle>`, 18.8.7).
@@ -382,6 +557,8 @@ pub struct CellStyle {
/// Index into [`Styles::cell_style_xfs`].
pub xf_id: u32,
pub builtin_id: Option<u32>,
/// `iLevel`: the outline level of a `RowLevel_n` or `ColLevel_n` style.
pub level: Option<u32>,
pub hidden: bool,
pub custom_builtin: bool,
}
@@ -415,6 +592,33 @@ impl Dxf {
protection: x.child("protection").map(Protection::read),
}
}
/// `<dxf>`.
pub fn to_el(&self) -> El {
let mut e = El::new("dxf");
if let Some(f) = &self.font {
e.push(f.to_el("font", false));
}
if let Some(n) = &self.num_fmt {
let mut f = El::new("numFmt");
set(&mut f, "numFmtId", n.id.map(|v| v.to_string()));
set(&mut f, "formatCode", n.code.clone());
e.push(f);
}
if let Some(f) = &self.fill {
e.push(f.to_el());
}
if let Some(a) = &self.alignment {
e.push(a.to_el());
}
if let Some(p) = self.protection {
e.push(p.to_el());
}
if let Some(b) = &self.border {
e.push(b.to_el());
}
e
}
}
/// The styles part.
@@ -457,6 +661,7 @@ impl Styles {
name: c.attr("name").unwrap_or_default().to_string(),
xf_id: c.attr_u32("xfId").unwrap_or(0),
builtin_id: c.attr_u32("builtinId"),
level: c.attr_u32("iLevel"),
hidden: c.flag("hidden", false),
custom_builtin: c.flag("customBuiltin", false),
})
@@ -477,6 +682,52 @@ impl Styles {
if self.indexed_colors.is_empty() { &DEFAULT_PALETTE } else { &self.indexed_colors }
}
/// The part, with every table regenerated from the fields above and the rest of
/// [`Styles::root`] (table styles, recent colours, extensions) kept in place.
pub fn to_xml(&self) -> Vec<u8> {
const ORDER: &[&str] =
&["numFmts", "fonts", "fills", "borders", "cellStyleXfs", "cellXfs", "cellStyles", "dxfs", "tableStyles", "colors", "extLst"];
let root = if self.root.name.is_empty() { El::new("styleSheet") } else { self.root.clone() };
let num_fmts = self.num_fmts.iter().map(|(id, code)| El::new("numFmt").with_attr("numFmtId", id).with_attr("formatCode", code)).collect();
let cell_styles = self
.cell_styles
.iter()
.map(|c| {
let mut e = El::new("cellStyle").with_attr("name", &c.name).with_attr("xfId", c.xf_id);
set(&mut e, "builtinId", c.builtin_id.map(|v| v.to_string()));
set(&mut e, "iLevel", c.level.map(|v| v.to_string()));
set(&mut e, "hidden", flag(c.hidden));
set(&mut e, "customBuiltin", flag(c.custom_builtin));
e
})
.collect();
let mut colors = root.child("colors").cloned().unwrap_or_else(|| El::new("colors"));
colors.children.retain(|n| !matches!(n, craft_xml::Node::El(c) if c.name == "indexedColors"));
if !self.indexed_colors.is_empty() {
let mut ix = El::new("indexedColors");
for c in &self.indexed_colors {
ix.push(El::new("rgbColor").with_attr("rgb", format!("FF{:06X}", c & 0xFF_FFFF)));
}
colors.children.insert(0, craft_xml::Node::El(ix));
}
let owned = vec![
("numFmts", list(&root, "numFmts", num_fmts)),
("fonts", list(&root, "fonts", self.fonts.iter().map(|f| f.to_el("font", false)).collect())),
("fills", list(&root, "fills", self.fills.iter().map(Fill::to_el).collect())),
("borders", list(&root, "borders", self.borders.iter().map(Border::to_el).collect())),
("cellStyleXfs", list(&root, "cellStyleXfs", self.cell_style_xfs.iter().map(Xf::to_el).collect())),
("cellXfs", list(&root, "cellXfs", self.cell_xfs.iter().map(Xf::to_el).collect())),
("cellStyles", list(&root, "cellStyles", cell_styles)),
("dxfs", list(&root, "dxfs", self.dxfs.iter().map(Dxf::to_el).collect())),
("colors", if colors.elements().next().is_some() { Some(colors) } else { None }),
];
let mut out = crate::write::assemble(&root, ORDER, owned);
crate::ns::declare(&mut out);
let mut w = craft_xml::Writer::document();
w.tree(&out);
w.into_bytes()
}
/// The format code of number format `id`: the file's custom format, else the built-in one.
/// `None` for an id that is neither (Excel shows General).
pub fn number_format(&self, id: u32) -> Option<&str> {
+84 -1
View File
@@ -4,6 +4,34 @@ use craft_opc::Rels;
use craft_sheet::Visibility;
use craft_xml::El;
use crate::write::{assemble, flag, set};
/// The children of a workbook in schema order (`CT_Workbook`).
const WORKBOOK: &[&str] = &[
"fileVersion",
"fileSharing",
"workbookPr",
"workbookProtection",
"bookViews",
"sheets",
"functionGroups",
"externalReferences",
"definedNames",
"calcPr",
"oleSize",
"customWorkbookViews",
"pivotCaches",
"smartTagPr",
"smartTagTypes",
"webPublishing",
"fileRecoveryPr",
"webPublishObjects",
"extLst",
];
/// `<definedName>` attributes read into [`DefinedName`]'s fields.
const NAME_ATTRS: &[&str] = &["name", "localSheetId", "comment", "hidden"];
/// What kind of sheet a workbook entry points at, from its relationship type.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SheetKind {
@@ -46,7 +74,7 @@ pub struct SheetEntry {
}
/// One `<definedName>`.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DefinedName {
/// The name as written, built-in names included (`_xlnm.Print_Area`, `_xlnm._FilterDatabase`).
pub name: String,
@@ -56,6 +84,8 @@ pub struct DefinedName {
pub formula: String,
pub comment: String,
pub hidden: bool,
/// The other attributes as written (`function`, `vbProcedure`, `shortcutKey`, ...).
pub attrs: Vec<(String, String)>,
}
/// The workbook part.
@@ -102,6 +132,7 @@ impl Workbook {
formula: d.text().trim().to_string(),
comment: d.attr("comment").unwrap_or_default().to_string(),
hidden: d.flag("hidden", false),
attrs: d.attrs.iter().filter(|(k, _)| !NAME_ATTRS.contains(&k.as_str())).cloned().collect(),
});
}
}
@@ -115,4 +146,56 @@ impl Workbook {
wb.root = root;
wb
}
/// The workbook part's root: `sheets` and `definedNames` regenerated from the fields, the
/// date system set in `workbookPr` and the active tab in the view it was read from;
/// everything else in [`Workbook::root`] as kept. Each sheet's `r:id` is written as
/// [`SheetEntry::rel_id`] holds it.
pub fn to_el(&self) -> El {
let root = if self.root.name.is_empty() { El::new("workbook") } else { self.root.clone() };
let mut pr = root.child("workbookPr").cloned().unwrap_or_else(|| El::new("workbookPr"));
set(&mut pr, "date1904", flag(self.date_1904));
let mut views = root.child("bookViews").cloned();
if let Some(tab) = self.active_tab {
let views = views.get_or_insert_with(|| El::new("bookViews"));
if views.child("workbookView").is_none() {
views.push(El::new("workbookView"));
}
let visible = views.children("workbookView").position(|v| v.attr("visibility").unwrap_or("visible") == "visible").unwrap_or(0);
if let Some(v) = views.elements_mut().filter(|v| v.name == "workbookView").nth(visible) {
v.set_attr("activeTab", tab);
}
}
let mut sheets = El::new("sheets");
for s in &self.sheets {
let mut e = El::new("sheet").with_attr("name", &s.name).with_attr("sheetId", s.sheet_id);
set(
&mut e,
"state",
match s.visibility {
Visibility::Visible => None,
Visibility::Hidden => Some("hidden".into()),
Visibility::VeryHidden => Some("veryHidden".into()),
},
);
e.set_attr("r:id", &s.rel_id);
sheets.push(e);
}
let mut names = El::new("definedNames");
for d in &self.defined_names {
let mut e = El::new("definedName").with_attr("name", &d.name);
e.attrs.extend(d.attrs.iter().cloned());
set(&mut e, "comment", (!d.comment.is_empty()).then(|| d.comment.clone()));
set(&mut e, "hidden", flag(d.hidden));
set(&mut e, "localSheetId", d.local_sheet_id.map(|v| v.to_string()));
names.push(if d.formula.is_empty() { e } else { e.with_text(&d.formula) });
}
let owned = vec![
("workbookPr", Some(pr)),
("bookViews", views),
("sheets", Some(sheets)),
("definedNames", (!self.defined_names.is_empty()).then_some(names)),
];
assemble(&root, WORKBOOK, owned)
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Helpers the part writers share: numbers as SpreadsheetML writes them, rebuilding a kept part
//! around regenerated children, and writing a tree with one child streamed.
use std::fmt::Write as _;
use craft_xml::{El, Node, Writer, is_name};
/// A number for an attribute or a `<v>`: integers without a fraction, others in the shortest
/// form that reads back to the same value, `E` for the exponent. Not finite is `0`.
pub fn num(n: f64) -> String {
if !n.is_finite() {
return "0".into();
}
if n == n.trunc() && n.abs() < 1e15 {
return format!("{}", n as i64);
}
let mut s = String::new();
let _ = write!(s, "{n:?}");
s.replace('e', "E")
}
/// `root` with the children named in `owned` regenerated. Each `(name, new)` takes the place
/// of the first child of that name, and later children of that name are dropped; when `root`
/// has none, `new` goes where `order` (the schema's sequence) puts it. `None` removes the
/// child. Every other child stays where it is, so elements the schema does not list
/// (`mc:AlternateContent`) keep their place.
pub(crate) fn assemble(root: &El, order: &[&str], mut owned: Vec<(&str, Option<El>)>) -> El {
let pos = |n: &str| order.iter().position(|o| *o == n).unwrap_or(order.len());
owned.sort_by_key(|(n, _)| pos(n));
// The children of `root` each owned name replaces, so a regenerated one is not also written
// where `order` would put it.
let present: Vec<&str> = owned.iter().map(|(n, _)| *n).filter(|n| root.child(*n).is_some()).collect();
let mut out = El { name: root.name.clone(), attrs: root.attrs.clone(), children: Vec::new() };
let mut pending: Vec<(&str, Option<El>)> = Vec::new();
let mut later: Vec<(&str, Option<El>)> = Vec::new();
for (n, e) in owned {
if present.contains(&n) { later.push((n, e)) } else { pending.push((n, e)) }
}
let mut done: Vec<&str> = Vec::new();
for node in &root.children {
let Node::El(c) = node else {
out.children.push(node.clone());
continue;
};
let p = pos(&c.name);
if p < order.len() {
// Missing children that come before this one.
while pending.first().is_some_and(|(n, _)| pos(n) < p) {
let (_, e) = pending.remove(0);
out.children.extend(e.map(Node::El));
}
}
if let Some(i) = later.iter().position(|(n, _)| *n == c.name) {
let (n, e) = later.remove(i);
out.children.extend(e.map(Node::El));
done.push(n);
continue;
}
if done.iter().any(|n| *n == c.name) {
continue;
}
out.children.push(node.clone());
}
out.children.extend(pending.into_iter().filter_map(|(_, e)| e).map(Node::El));
out
}
/// Writes `root` as a document, with the child named `streamed` written by `each` instead of
/// from the tree (a worksheet's `sheetData`).
pub(crate) fn write_streamed(root: &El, streamed: &str, mut each: impl FnMut(&mut Writer)) -> Vec<u8> {
let mut w = Writer::document();
let mut tag = w.tag(&root.name);
for (k, v) in root.attrs.iter().filter(|(k, _)| is_name(k)) {
tag = tag.attr(k, v);
}
tag.open();
for node in &root.children {
match node {
Node::El(c) if c.name == streamed => {
w.tag(streamed).open();
each(&mut w);
w.close();
}
Node::El(c) => w.tree(c),
Node::Text(t) => w.text(t),
_ => {}
}
}
w.into_bytes()
}
/// A `count` attribute and the given children, on a copy of `root`'s child `name` (keeping its
/// other attributes, `x14ac:knownFonts` and the like); `None` when there are no items.
pub(crate) fn list(root: &El, name: &str, items: Vec<El>) -> Option<El> {
if items.is_empty() {
return None;
}
let mut e = El::new(name);
if let Some(old) = root.child(name) {
e.attrs = old.attrs.iter().filter(|(k, _)| k != "count").cloned().collect();
}
e.attrs.insert(0, ("count".into(), items.len().to_string()));
e.children = items.into_iter().map(Node::El).collect();
Some(e)
}
/// Sets attribute `name` to `value`, or removes it for `None`.
pub(crate) fn set(e: &mut El, name: &str, value: Option<String>) {
match value {
Some(v) => e.set_attr(name, v),
None => {
e.remove_attr(name);
}
}
}
/// `Some("1")` for true: a flag written only when it is set.
pub(crate) fn flag(on: bool) -> Option<String> {
on.then(|| "1".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numbers() {
assert_eq!(num(3.0), "3");
assert_eq!(num(-0.0), "0");
assert_eq!(num(0.1), "0.1");
assert_eq!(num(1e20), "1E20");
assert_eq!(num(1.5e-7), "1.5E-7");
assert_eq!(num(f64::NAN), "0");
for n in [0.1 + 0.2, 1.0 / 3.0, 123456789.123, -2.5e-300] {
assert_eq!(num(n).parse::<f64>().ok(), Some(n));
}
}
#[test]
fn assembling_keeps_unlisted_children_in_place() {
let root = El::new("w")
.with_child(El::new("a"))
.with_child(El::new("mc:AlternateContent"))
.with_child(El::new("c").with_attr("old", 1))
.with_child(El::new("c"))
.with_child(El::new("ext"));
let order = ["a", "b", "c", "d", "ext"];
let out = assemble(
&root,
&order,
vec![("c", Some(El::new("c").with_attr("new", 1))), ("b", Some(El::new("b"))), ("d", Some(El::new("d"))), ("a", None)],
);
assert_eq!(out.to_xml(), r#"<w><mc:AlternateContent/><b/><c new="1"/><d/><ext/></w>"#);
let empty = assemble(&El::new("w"), &order, vec![("d", Some(El::new("d"))), ("a", Some(El::new("a")))]);
assert_eq!(empty.to_xml(), "<w><a/><d/></w>");
}
}
+249
View File
@@ -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<u8> {
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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook {ns}><fileVersion appName="xl" lastEdited="7"/><workbookPr defaultThemeVersion="166925"/><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="x15"><x15ac:absPath xmlns:x15ac="http://schemas.microsoft.com/office/spreadsheetml/2010/11/ac" url="C:\x\"/></mc:Choice></mc:AlternateContent><bookViews><workbookView xWindow="0" activeTab="1"/></bookViews>
<sheets><sheet name="Data" sheetId="1" r:id="rId1"/><sheet name="Chart" sheetId="3" r:id="rId9"/><sheet name="Hidden &amp; more" sheetId="2" state="hidden" r:id="rId2"/></sheets>
<definedNames><definedName name="_xlnm.Print_Area" localSheetId="0">Data!$A$1:$C$3</definedName><definedName name="Macro" function="1" vbProcedure="1" hidden="1">Module1.Run</definedName></definedNames><calcPr calcId="191029"/>
<extLst><ext uri="{{140A7094-0E35-4892-8432-C4D2E57EDEB5}}" xmlns:x15="http://schemas.microsoft.com/office/spreadsheetml/2010/11/main"><x15:workbookPr chartTrackingRefBase="1"/></ext></extLst></workbook>"#
);
let book_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="{RT}/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId9" Type="{RT}/chartsheet" Target="chartsheets/sheet1.xml"/><Relationship Id="rId3" Type="{RT}/theme" Target="theme/theme1.xml"/><Relationship Id="rId4" Type="{RT}/styles" Target="styles.xml"/><Relationship Id="rId5" Type="{RT}/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId6" Type="{RT}/calcChain" Target="calcChain.xml"/><Relationship Id="rId7" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/></Relationships>"#
);
let sheet1 = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet {ns} xr:uid="{{00000000-0001-0000-0000-000000000000}}"><sheetPr><tabColor rgb="FFFF0000"/></sheetPr><dimension ref="A1:C3"/><sheetViews><sheetView tabSelected="1" workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="15" x14ac:dyDescent="0.25"/><cols><col min="1" max="2" width="12.5" customWidth="1"/><col min="3" max="3" width="9" hidden="1" style="1"/></cols>
<sheetData><row r="1" spans="1:3" x14ac:dyDescent="0.25"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c><c r="C1" s="1"><v>0.1</v></c></row><row r="2" ht="30" customHeight="1"><c r="A2"><f>SUM(C1:C3)</f><v>0.30000000000000004</v></c><c r="B2" t="b"><v>1</v></c><c r="C2" s="1"><f t="shared" ref="C2:C3" si="0">C1*2</f><v>0.2</v></c></row><row r="3"><c r="A3" t="e"><v>#N/A</v></c><c r="B3" t="inlineStr"><is><t xml:space="preserve"> inline </t></is></c><c r="C3" s="1"><f t="shared" si="0"/><v>0.4</v></c></row></sheetData>
<mergeCells count="1"><mergeCell ref="A5:B6"/></mergeCells><conditionalFormatting sqref="C1:C3"><cfRule type="cellIs" dxfId="0" priority="1" operator="greaterThan"><formula>0.25</formula></cfRule></conditionalFormatting><pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/><drawing r:id="rId1"/>
<extLst><ext uri="{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}" xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"><x14:sparklineGroups xmlns:xm="http://schemas.microsoft.com/office/excel/2006/main"><x14:sparklineGroup><x14:sparklines><x14:sparkline><xm:f>Data!C1:C3</xm:f><xm:sqref>D1</xm:sqref></x14:sparkline></x14:sparklines></x14:sparklineGroup></x14:sparklineGroups></ext></extLst></worksheet>"#
);
let sheet1_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}/drawing" Target="../drawings/drawing1.xml"/></Relationships>"#
);
let styles = format!(
r#"<styleSheet {ns}><numFmts count="1"><numFmt numFmtId="164" formatCode="0.0%"/></numFmts><fonts count="2" x14ac:knownFonts="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font><font><b/><i val="0"/><u val="double"/><vertAlign val="superscript"/><sz val="9.5"/><color rgb="FF0000FF" tint="-0.25"/><name val="Arial"/></font></fonts>
<fills count="3"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><gradientFill degree="90"><stop position="0"><color theme="0"/></stop><stop position="1"><color indexed="12"/></stop></gradientFill></fill></fills>
<borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border><border diagonalUp="1" outline="0"><left style="thin"><color auto="1"/></left><bottom style="mediumDashDot"/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="164" fontId="1" fillId="2" borderId="1" xfId="0" applyNumberFormat="1" quotePrefix="1"><alignment horizontal="centerContinuous" vertical="top" wrapText="1" textRotation="255"/><protection locked="0"/></xf></cellXfs>
<cellStyles count="2"><cellStyle name="Normal" xfId="0" builtinId="0"/><cellStyle name="RowLevel_1" xfId="0" builtinId="1" iLevel="0" hidden="1"/></cellStyles><dxfs count="1"><dxf><font><color rgb="FF9C0006"/></font><fill><patternFill><bgColor rgb="FFFFC7CE"/></patternFill></fill></dxf></dxfs><tableStyles count="0" defaultTableStyle="TableStyleMedium2"/>
<colors><indexedColors><rgbColor rgb="FF000000"/><rgbColor rgb="FF123456"/></indexedColors><mruColors><color rgb="FF00FF00"/></mruColors></colors></styleSheet>"#
);
let strings = format!(
r#"<sst {ns} count="2" uniqueCount="2"><si><t>Name</t></si><si><r><rPr><b/><sz val="11"/><rFont val="Calibri"/></rPr><t xml:space="preserve">東京 </t></r><r><t>x_x000D_</t></r><rPh sb="0" eb="2"><t>トウキョウ</t></rPh><phoneticPr fontId="1" type="Hiragana"/></si></sst>"#
);
let package_rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{RT}/officeDocument" Target="xl/workbook.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>"#
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="bin" ContentType="application/vnd.ms-office.vbaProject"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.ms-excel.sheet.macroEnabled.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/chartsheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/></Types>"#;
zip(&[
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", package_rels.as_bytes()),
("docProps/core.xml", b"<cp:coreProperties xmlns:cp=\"urn:core\"/>"),
("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!("<worksheet {ns}><sheetData/></worksheet>").as_bytes()),
("xl/chartsheets/sheet1.xml", b"<chartsheet/>"),
("xl/theme/theme1.xml", b"<a:theme xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" name=\"Office\"/>"),
("xl/drawings/drawing1.xml", b"<xdr:wsDr xmlns:xdr=\"http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing\"/>"),
("xl/styles.xml", styles.as_bytes()),
("xl/sharedStrings.xml", strings.as_bytes()),
("xl/calcChain.xml", b"<calcChain/>"),
("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::<Vec<_>>(), y.cell_style_xfs.iter().map(|x| x.to_el()).collect::<Vec<_>>());
assert_eq!(x.cell_xfs.iter().map(|x| x.to_el()).collect::<Vec<_>>(), y.cell_xfs.iter().map(|x| x.to_el()).collect::<Vec<_>>());
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::<Vec<_>>(), [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#"<sheet name="Hidden &amp; more" sheetId="2" state="hidden" r:id="rId2"/>"#), "{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#"<row r="1" x14ac:dyDescent="0.25">"#), "{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::<Vec<_>>(), ["_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(_))));
}