mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-27 20:26:48 -06:00
Speed up XLSX worksheet writing
Saving a large workbook spent about half its time building worksheet XML, mostly in small per-cell allocations. The writer now appends to the part directly: - Cell references, row numbers, style ids, shared string indexes and numbers are appended without temporary strings (push_a1, push_u64, push_num), and text is escaped in place (push_esc, with a fast path for plain ASCII). - sheetData walks the cell store in its own row-major order instead of copying every address into a BTreeSet and a per-row BTreeMap and looking each cell up again. Cells outside the store (pictures, spill values, table headers) still go through the sorted set. - Formulas with nothing the file spells differently (no LET/LAMBDA, no _xlfn function, no spill, @, trim operator or structured reference) are printed from the shared expression instead of being cloned and rebuilt twice by the file rewrite. - The A1 printer in gridcraft-formula appends references to the output instead of formatting each anchor into its own string. The output is byte-for-byte unchanged. write_perf, 200,000 rows (2.0M cells, 14.2 MB package), release, Ryzen 16 threads / Linux, 36 interleaved runs each: before: median 2083.9 ms, min 1615.7 ms after: median 1501.3 ms, min 1192.3 ms (-28.0 % median, -26.2 % min) What remains is mostly deflate (zlib-rs, level 6), about 70 % of the save. Tests pin the exact sheetData of a workbook covering row settings between cell rows, spill values, every value kind, number spellings, escaping and formulas with and without a file spelling (generated with the previous writer), and check each appending helper and the formula fast path against the code they replace. The formula fast path is guarded by a test that runs both paths over every function in the gridcraft-functions registry (a new dev-dependency, allowed by the layer rules), in 15 surrounding shapes covering every operator and rewrite, so a function or rewrite added later without updating the trigger list fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4425fed53f
commit
393a524aa3
Generated
+1
@@ -2124,6 +2124,7 @@ dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use gridcraft_core::{CellRef, col_to_letters, number_to_text};
|
||||
use gridcraft_core::{CellRef, number_to_text};
|
||||
|
||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
|
||||
@@ -19,8 +19,32 @@ fn sheet_prefix(s: &SheetSel) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_a1(a: &Anchor) -> String {
|
||||
format!("{}{}{}{}", if a.col_abs { "$" } else { "" }, col_to_letters(a.col), if a.row_abs { "$" } else { "" }, a.row as u64 + 1)
|
||||
fn push_anchor_a1(s: &mut String, a: &Anchor) {
|
||||
if a.col_abs {
|
||||
s.push('$');
|
||||
}
|
||||
push_col(s, a.col);
|
||||
if a.row_abs {
|
||||
s.push('$');
|
||||
}
|
||||
let _ = write!(s, "{}", a.row as u64 + 1);
|
||||
}
|
||||
|
||||
/// Appends [`gridcraft_core::col_to_letters`]`(col)` without allocating.
|
||||
fn push_col(s: &mut String, col: u32) {
|
||||
let mut letters = [0u8; 8];
|
||||
let mut i = letters.len();
|
||||
let mut n = col as u64 + 1;
|
||||
while n > 0 && i > 0 {
|
||||
i -= 1;
|
||||
if let Some(l) = letters.get_mut(i) {
|
||||
*l = b'A' + ((n - 1) % 26) as u8;
|
||||
}
|
||||
n = (n - 1) / 26;
|
||||
}
|
||||
for &l in letters.get(i..).unwrap_or_default() {
|
||||
s.push(char::from(l));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reference_a1(r: &Reference) -> String {
|
||||
@@ -29,15 +53,40 @@ pub fn reference_a1(r: &Reference) -> String {
|
||||
|
||||
/// A1 text with `sep` between the two ends of a range (`:` or a trim operator).
|
||||
fn range_a1(r: &Reference, sep: &str) -> String {
|
||||
let body = match &r.kind {
|
||||
RefKind::Cell(a) => anchor_a1(a),
|
||||
RefKind::Range(a, b) => format!("{}{sep}{}", anchor_a1(a), anchor_a1(b)),
|
||||
RefKind::Rows(r0, a0, r1, a1) => format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1),
|
||||
RefKind::Cols(c0, a0, c1, a1) => {
|
||||
format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1))
|
||||
let mut s = String::new();
|
||||
push_range_a1(&mut s, r, sep);
|
||||
s
|
||||
}
|
||||
|
||||
/// Appends [`range_a1`]`(r, sep)` to `s`.
|
||||
fn push_range_a1(s: &mut String, r: &Reference, sep: &str) {
|
||||
match &r.sheet {
|
||||
SheetSel::Current => {}
|
||||
SheetSel::Named(n) => {
|
||||
s.push_str("e_sheet(n));
|
||||
s.push('!');
|
||||
}
|
||||
};
|
||||
format!("{}{}", sheet_prefix(&r.sheet), body)
|
||||
sheet @ SheetSel::Span(_, _) => s.push_str(&sheet_prefix(sheet)),
|
||||
}
|
||||
let dollar = |abs: bool| if abs { "$" } else { "" };
|
||||
match &r.kind {
|
||||
RefKind::Cell(a) => push_anchor_a1(s, a),
|
||||
RefKind::Range(a, b) => {
|
||||
push_anchor_a1(s, a);
|
||||
s.push_str(sep);
|
||||
push_anchor_a1(s, b);
|
||||
}
|
||||
RefKind::Rows(r0, a0, r1, a1) => {
|
||||
let _ = write!(s, "{}{}{sep}{}{}", dollar(*a0), r0 + 1, dollar(*a1), r1 + 1);
|
||||
}
|
||||
RefKind::Cols(c0, a0, c1, a1) => {
|
||||
s.push_str(dollar(*a0));
|
||||
push_col(s, *c0);
|
||||
s.push_str(sep);
|
||||
s.push_str(dollar(*a1));
|
||||
push_col(s, *c1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn r1c1_part(letter: char, v: u32, abs: bool, base: u32) -> String {
|
||||
@@ -207,7 +256,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
||||
}
|
||||
Expr::Ref(r) => match r1c1 {
|
||||
Some(at) => s.push_str(&reference_r1c1(r, at)),
|
||||
None => s.push_str(&reference_a1(r)),
|
||||
None => push_range_a1(s, r, ":"),
|
||||
},
|
||||
Expr::Name(n) => match sheet_split(n) {
|
||||
// A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back.
|
||||
@@ -246,7 +295,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
||||
UnOp::Trim(mode) => match &**x {
|
||||
Expr::Ref(r) if !matches!(r.kind, RefKind::Cell(_)) => match r1c1 {
|
||||
Some(at) => s.push_str(&range_r1c1(r, at, mode.symbol())),
|
||||
None => s.push_str(&range_a1(r, mode.symbol())),
|
||||
None => push_range_a1(s, r, mode.symbol()),
|
||||
},
|
||||
Expr::Binary(BinOp::Range, a, b) => {
|
||||
write_expr(s, a, r1c1);
|
||||
|
||||
@@ -518,3 +518,28 @@ fn three_d_span_and_cell_start_still_lex_as_span() {
|
||||
assert_eq!(rt("SUM(Sheet1:Sheet3!A1)"), "SUM(Sheet1:Sheet3!A1)");
|
||||
assert_eq!(rt("SUM(Q1:Sheet3!A1)"), "SUM('Q1:Sheet3'!A1)");
|
||||
}
|
||||
|
||||
/// References print the same through the appending printer for every reference shape.
|
||||
#[test]
|
||||
fn reference_shapes_print_unchanged() {
|
||||
for text in [
|
||||
"A1",
|
||||
"XFD1048576",
|
||||
"$AA$10:AB$11",
|
||||
"$A:XFD",
|
||||
"B:$C",
|
||||
"1:$1048576",
|
||||
"$3:4",
|
||||
"'My Sheet'!A1",
|
||||
"'It''s'!$B$2:C3",
|
||||
"Sheet1:Sheet3!B2",
|
||||
"'Sheet 1:Sheet3'!B2",
|
||||
"SUM(A1:.B3)+SUM(A.:.B)",
|
||||
] {
|
||||
let e = parse(text).unwrap();
|
||||
assert_eq!(print(&e), text);
|
||||
if let Expr::Ref(r) = &e {
|
||||
assert_eq!(printer::reference_a1(r), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,3 +23,7 @@ zip = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Every registered function name, for the formula file-spelling test.
|
||||
gridcraft-functions = { workspace = true }
|
||||
|
||||
+81
-2
@@ -6,12 +6,29 @@ use gridcraft_model::Formula;
|
||||
|
||||
/// Formula text for a file.
|
||||
pub fn to_file(f: &Formula) -> String {
|
||||
match f.expr() {
|
||||
Some(e) => expr_to_file(e),
|
||||
match f.expr_arc() {
|
||||
// Most formulas have nothing the file spells differently: print them without copying.
|
||||
Some(e) if !needs_file_spelling(&e) => gridcraft_formula::print(&e),
|
||||
Some(e) => expr_to_file(Expr::clone(&e)),
|
||||
None => f.text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [`expr_to_file`] would change anything in `e`: a LET or LAMBDA (parameter prefixes),
|
||||
/// a function with a file prefix, a spill, `@` or trim operator, or a structured reference.
|
||||
fn needs_file_spelling(e: &Expr) -> bool {
|
||||
let mut found = false;
|
||||
e.walk(&mut |x| {
|
||||
found |= match x {
|
||||
Expr::Call(name, _) => name == "LET" || name == "LAMBDA" || crate::tables::file_function_name(name).is_some(),
|
||||
Expr::Unary(gridcraft_formula::UnOp::Spill | gridcraft_formula::UnOp::At | gridcraft_formula::UnOp::Trim(_), _) => true,
|
||||
Expr::Struct(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// Arbitrary formula text (names, CF, validation) for a file. Unparseable text is kept.
|
||||
pub fn text_to_file(text: &str) -> String {
|
||||
let body = text.strip_prefix('=').unwrap_or(text);
|
||||
@@ -136,6 +153,68 @@ pub fn from_file(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Formulas without a file spelling skip the rewrite; the text must be what it would give.
|
||||
#[test]
|
||||
fn plain_formulas_print_as_the_rewrite_would() {
|
||||
for text in [
|
||||
"SUM(A1:B2)*2",
|
||||
"A1&\"x\"\"y\"",
|
||||
"Sheet2!$A$1+'My Sheet'!C:C-$3:4",
|
||||
"IF(A1>0,{1,2;3,4},-B$2%)",
|
||||
"Sheet1:Sheet3!A1",
|
||||
"INDEX(A:A,1):B5",
|
||||
"Total*2",
|
||||
"XLOOKUP(1,A:A,B:B)",
|
||||
"LET(x,1,x)",
|
||||
"LAMBDA(a,a)(1)",
|
||||
"A1#",
|
||||
"@A1:A2",
|
||||
"SUM(A1:.B3)",
|
||||
"T[@C]",
|
||||
] {
|
||||
let f = Formula::new(text);
|
||||
assert_eq!(to_file(&f), expr_to_file(f.expr().unwrap()), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The fast path in [`to_file`] skips the rewrite on a list of what triggers it. Run both paths
|
||||
/// over every registered function, alone, nested and next to every operator, so a function or
|
||||
/// rewrite added later without updating [`needs_file_spelling`] fails here.
|
||||
#[test]
|
||||
fn every_registered_function_prints_as_the_rewrite_would() {
|
||||
let shapes = [
|
||||
"{}",
|
||||
"1+{}",
|
||||
"-{}%",
|
||||
"SUM({},A1:B2)",
|
||||
"IF(A1,{},Sheet2!$C$3)",
|
||||
"LET(x,{},x)",
|
||||
"LAMBDA(a,{})(1)",
|
||||
"@{}",
|
||||
"{}&A1#",
|
||||
"{}+SUM(A1:.B3)",
|
||||
"{}+T[@C]",
|
||||
"{}+T[[#All],[C]]",
|
||||
"({})*{{1,2;3,4}}",
|
||||
"{}:B5 C1:D2",
|
||||
"{}+My Sheet!A:A+Total",
|
||||
];
|
||||
let mut checked = 0;
|
||||
for spec in gridcraft_functions::all() {
|
||||
let n = spec.min_args.max(1).min(spec.max_args).min(4);
|
||||
let args = ["A1", "B2:C3", "1", "\"x\""].get(..n).unwrap_or_default().join(",");
|
||||
let call = format!("{}({args})", spec.name);
|
||||
for shape in shapes {
|
||||
let text = shape.replace("{}", &call).replace("{{", "{").replace("}}", "}");
|
||||
let f = Formula::new(&text);
|
||||
let Some(e) = f.expr() else { continue };
|
||||
assert_eq!(to_file(&f), expr_to_file(e), "{text}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > gridcraft_functions::all().len() * 10, "only {checked} formulas parsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spill_references() {
|
||||
assert_eq!(to_file(&Formula::new("SUM(Sheet1!$D$1#)+B2#")), "SUM(_xlfn.ANCHORARRAY(Sheet1!$D$1))+_xlfn.ANCHORARRAY(B2)");
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::tables::{paper_id, px_to_col_width, px_to_pt};
|
||||
use crate::write::{
|
||||
CT_CHART, CT_COMMENTS, CT_DRAWING, CT_TABLE, CT_THREADED_COMMENTS, NS_MAIN, NS_REL, NS_THREADED, Out, REL_THREADED_COMMENT, Rels, XML_DECL,
|
||||
};
|
||||
use crate::xml::{encode_xstring, esc, esc_attr, num};
|
||||
use crate::xml::{encode_xstring, esc, esc_attr, num, push_a1, push_esc, push_num, push_u64};
|
||||
|
||||
mod parts;
|
||||
mod rules;
|
||||
@@ -253,24 +253,46 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
}
|
||||
|
||||
// sheetData
|
||||
let mut keys: BTreeSet<CellRef> = sheet.cells.iter().map(|(c, _)| c).collect();
|
||||
keys.extend(sheet.cell_pictures.keys().copied());
|
||||
keys.extend(extra.keys().copied());
|
||||
keys.extend(overrides.keys().copied());
|
||||
let mut rows: BTreeMap<u32, Vec<CellRef>> = BTreeMap::new();
|
||||
for k in keys {
|
||||
rows.entry(k.row).or_default().push(k);
|
||||
}
|
||||
for r in sheet.rows.keys() {
|
||||
rows.entry(*r).or_default();
|
||||
// Every written cell in row-major order: the store already iterates that way, so only cells
|
||||
// that exist outside it (pictures, cached spill values, table headers) need sorting in.
|
||||
let mut keys: Vec<(CellRef, Option<&Cell>)> = Vec::with_capacity(sheet.cells.len());
|
||||
keys.extend(sheet.cells.iter().map(|(c, cell)| (c, Some(cell))));
|
||||
if !sheet.cell_pictures.is_empty() || !extra.is_empty() || !overrides.is_empty() || !keys.is_sorted_by_key(|(c, _)| *c) {
|
||||
let mut all: BTreeSet<CellRef> = keys.iter().map(|(c, _)| *c).collect();
|
||||
all.extend(sheet.cell_pictures.keys().copied());
|
||||
all.extend(extra.keys().copied());
|
||||
all.extend(overrides.keys().copied());
|
||||
keys = all.into_iter().map(|c| (c, sheet.cells.get(c))).collect();
|
||||
}
|
||||
s.reserve(keys.len().saturating_mul(32));
|
||||
s.push_str("<sheetData>");
|
||||
for (r, cells) in &rows {
|
||||
let info = sheet.rows.get(r).copied().unwrap_or_default();
|
||||
let mut row_infos = sheet.rows.iter().peekable();
|
||||
let mut next = 0;
|
||||
loop {
|
||||
// The next row with cells or row settings, and its cells.
|
||||
let cell_row = keys.get(next).map(|(c, _)| c.row);
|
||||
let info_row = row_infos.peek().map(|(r, _)| **r);
|
||||
let r = match (cell_row, info_row) {
|
||||
(Some(a), Some(b)) => a.min(b),
|
||||
(Some(a), None) => a,
|
||||
(None, Some(b)) => b,
|
||||
(None, None) => break,
|
||||
};
|
||||
let info = match row_infos.next_if(|(row, _)| **row == r) {
|
||||
Some((_, info)) => *info,
|
||||
None => LineInfo::default(),
|
||||
};
|
||||
let first = next;
|
||||
while keys.get(next).is_some_and(|(c, _)| c.row == r) {
|
||||
next += 1;
|
||||
}
|
||||
let cells = keys.get(first..next).unwrap_or_default();
|
||||
if cells.is_empty() && info == LineInfo::default() {
|
||||
continue;
|
||||
}
|
||||
let _ = write!(s, "<row r=\"{}\"", r + 1);
|
||||
s.push_str("<row r=\"");
|
||||
push_u64(&mut s, u64::from(r) + 1);
|
||||
s.push('"');
|
||||
if let Some(st) = info.style {
|
||||
let _ = write!(s, " s=\"{}\" customFormat=\"1\"", sid(st));
|
||||
}
|
||||
@@ -291,15 +313,14 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
||||
continue;
|
||||
}
|
||||
s.push('>');
|
||||
for c in cells {
|
||||
let cell = sheet.cells.get(*c);
|
||||
let ov = overrides.get(c);
|
||||
let ex = extra.get(c);
|
||||
let array = dynamic.get(c).copied().or_else(|| {
|
||||
for &(c, cell) in cells {
|
||||
let ov = overrides.get(&c);
|
||||
let ex = extra.get(&c);
|
||||
let array = dynamic.get(&c).copied().or_else(|| {
|
||||
let f = cell?.formula.as_ref()?;
|
||||
(f.array.is_none() && crate::array_formula::needs_array(wb, si, *c, f)).then_some(RangeRef::cell(*c))
|
||||
(f.array.is_none() && crate::array_formula::needs_array(wb, si, c, f)).then_some(RangeRef::cell(c))
|
||||
});
|
||||
write_cell(&mut s, *c, cell, sheet.cell_pictures.get(c), ov, ex, array.as_ref(), &sid, out);
|
||||
write_cell(&mut s, c, cell, sheet.cell_pictures.get(&c), ov, ex, array.as_ref(), &sid, out);
|
||||
}
|
||||
s.push_str("</row>");
|
||||
}
|
||||
@@ -635,9 +656,13 @@ fn write_cell(
|
||||
out: &mut Out,
|
||||
) {
|
||||
let style = cell.map(|x| sid(x.style)).unwrap_or(0);
|
||||
let _ = write!(s, "<c r=\"{}\"", c.a1());
|
||||
s.push_str("<c r=\"");
|
||||
push_a1(s, c);
|
||||
s.push('"');
|
||||
if style != 0 {
|
||||
let _ = write!(s, " s=\"{style}\"");
|
||||
s.push_str(" s=\"");
|
||||
push_u64(s, u64::from(style));
|
||||
s.push('"');
|
||||
}
|
||||
if let Some(picture) = picture {
|
||||
let vm = out.cell_pictures.index(picture);
|
||||
@@ -679,21 +704,19 @@ fn write_cell(
|
||||
};
|
||||
s.push_str(t);
|
||||
s.push('>');
|
||||
let text = esc(&crate::fmla::to_file(f));
|
||||
match (f.array, dynamic) {
|
||||
(Some(r), _) => {
|
||||
let _ = write!(s, "<f t=\"array\" ref=\"{}\">{text}</f>", r.a1());
|
||||
}
|
||||
(None, Some(r)) => {
|
||||
let _ = write!(s, "<f t=\"array\" ref=\"{}\">{text}</f>", r.a1());
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(s, "<f>{text}</f>");
|
||||
match f.array.or(dynamic.copied()) {
|
||||
Some(r) => {
|
||||
let _ = write!(s, "<f t=\"array\" ref=\"{}\">", r.a1());
|
||||
}
|
||||
None => s.push_str("<f>"),
|
||||
}
|
||||
push_esc(s, &crate::fmla::to_file(f));
|
||||
s.push_str("</f>");
|
||||
match &value {
|
||||
Value::Number(n) => {
|
||||
let _ = write!(s, "<v>{}</v>", num(*n));
|
||||
s.push_str("<v>");
|
||||
push_num(s, *n);
|
||||
s.push_str("</v>");
|
||||
}
|
||||
Value::Text(t) => {
|
||||
let _ = write!(s, "<v>{}</v>", esc(&encode_xstring(t)));
|
||||
@@ -712,11 +735,15 @@ fn write_cell(
|
||||
match &value {
|
||||
Value::Empty | Value::Array(_) => s.push_str("/>"),
|
||||
Value::Number(n) => {
|
||||
let _ = write!(s, "><v>{}</v></c>", num(*n));
|
||||
s.push_str("><v>");
|
||||
push_num(s, *n);
|
||||
s.push_str("</v></c>");
|
||||
}
|
||||
Value::Text(t) => {
|
||||
let i = out.sst.index(t);
|
||||
let _ = write!(s, " t=\"s\"><v>{i}</v></c>");
|
||||
s.push_str(" t=\"s\"><v>");
|
||||
push_u64(s, u64::from(i));
|
||||
s.push_str("</v></c>");
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
let _ = write!(s, " t=\"b\"><v>{}</v></c>", *b as u8);
|
||||
|
||||
@@ -23,6 +23,7 @@ mod richdata_tests;
|
||||
mod roundtrip;
|
||||
mod shape_text_rtl;
|
||||
mod shared_media;
|
||||
mod sheet_data_pin;
|
||||
mod sheet_names;
|
||||
mod smooth_roundtrip;
|
||||
mod trendline_roundtrip;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Pins the exact `<sheetData>` the writer produces: row order with row settings between and
|
||||
//! around cell rows, cells added outside the store (cached spill values), every value kind, number
|
||||
//! spellings, escaping, cell references far out in the grid and formulas with and without a file
|
||||
//! spelling. The expected text was produced by the writer before its per-cell path was made
|
||||
//! allocation-free, so any change in the bytes shows up here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId, Workbook};
|
||||
|
||||
use crate::package::Package;
|
||||
use crate::write_xlsx;
|
||||
|
||||
fn at(a: &str) -> CellRef {
|
||||
CellRef::parse(a).unwrap()
|
||||
}
|
||||
|
||||
fn formula(text: &str, value: Value, style: StyleId) -> Cell {
|
||||
Cell { value, formula: Some(Arc::new(Formula::new(text))), style }
|
||||
}
|
||||
|
||||
fn sheet_data(wb: &Workbook) -> String {
|
||||
let bytes = write_xlsx(wb).unwrap();
|
||||
let mut pkg = Package::open(&bytes).unwrap();
|
||||
let xml = String::from_utf8(pkg.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
|
||||
let start = xml.find("<sheetData>").unwrap();
|
||||
let end = xml.find("</sheetData>").unwrap() + "</sheetData>".len();
|
||||
xml[start..end].to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_data_is_written_byte_for_byte_as_pinned() {
|
||||
let mut wb = Workbook::new();
|
||||
let money = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new("0.00"));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.rows.insert(1, LineInfo { hidden: true, ..LineInfo::default() });
|
||||
sh.rows.insert(4, LineInfo { size: Some(30.0), custom: true, ..LineInfo::default() });
|
||||
sh.rows.insert(7, LineInfo { outline: 2, style: Some(money), ..LineInfo::default() });
|
||||
let set = |sh: &mut gridcraft_model::Sheet, a: &str, value: Value, style: StyleId| sh.cells.set(at(a), Cell { value, formula: None, style });
|
||||
set(sh, "A3", Value::text("a & <b> \"q\""), StyleId::DEFAULT);
|
||||
set(sh, "B3", Value::Number(-5.0), StyleId::DEFAULT);
|
||||
set(sh, "C3", Value::Number(0.1), money);
|
||||
set(sh, "D3", Value::Bool(true), StyleId::DEFAULT);
|
||||
set(sh, "E3", Value::Error(CellError::NA), StyleId::DEFAULT);
|
||||
set(sh, "Z3", Value::Number(-0.0), StyleId::DEFAULT);
|
||||
set(sh, "AA3", Value::Number(-2.5), StyleId::DEFAULT);
|
||||
set(sh, "A5", Value::Number(1e20), money);
|
||||
set(sh, "B5", Value::Number(1.5e-7), StyleId::DEFAULT);
|
||||
set(sh, "C5", Value::Number(123_456_789_012_345.0), StyleId::DEFAULT);
|
||||
set(sh, "D5", Value::Number(-1e15), StyleId::DEFAULT);
|
||||
set(sh, "E5", Value::Number(f64::NAN), StyleId::DEFAULT);
|
||||
set(sh, "F5", Value::text("Grüße\r\n\u{1}_x0041_ 日本"), StyleId::DEFAULT);
|
||||
set(sh, "G5", Value::text("a & <b> \"q\""), StyleId::DEFAULT);
|
||||
set(sh, "H5", Value::Empty, money);
|
||||
sh.cells.set(at("B1"), formula("SEQUENCE(2)", Value::Number(1.0), StyleId::DEFAULT));
|
||||
sh.spill_ranges.insert(at("B1"), RangeRef::parse("B1:B2").unwrap());
|
||||
sh.spill.insert(at("B2"), Value::Number(2.0));
|
||||
sh.cells.set(at("AB10000"), formula("SUM($A$1:B2,Sheet2!C:C,3:$5,'My Sheet'!$XFD$1)*2", Value::Number(0.25), money));
|
||||
sh.cells.set(at("AC10000"), formula("A1&\"<x>\"", Value::text("t & <u>"), StyleId::DEFAULT));
|
||||
sh.cells.set(at("AD10000"), formula("LET(x,1,x+XLOOKUP(1,A:A,B:B))", Value::Bool(false), StyleId::DEFAULT));
|
||||
sh.cells.set(at("AE10000"), formula("1/0", Value::Error(CellError::Div0), StyleId::DEFAULT));
|
||||
sh.cells.set(at("AF10000"), formula("A1", Value::Empty, StyleId::DEFAULT));
|
||||
sh.cells.set(at("XFD1048576"), Cell::value(Value::Number(7.0)));
|
||||
assert_eq!(sheet_data(&wb), EXPECTED);
|
||||
}
|
||||
|
||||
const EXPECTED: &str = r#"<sheetData><row r="1"><c r="B1" cm="1"><f t="array" ref="B1:B2">_xlfn.SEQUENCE(2)</f><v>1</v></c></row><row r="2" hidden="1"><c r="B2"><v>2</v></c></row><row r="3"><c r="A3" t="s"><v>0</v></c><c r="B3"><v>-5</v></c><c r="C3" s="1"><v>0.1</v></c><c r="D3" t="b"><v>1</v></c><c r="E3" t="e"><v>#N/A</v></c><c r="Z3"><v>0</v></c><c r="AA3"><v>-2.5</v></c></row><row r="5" ht="22.5" customHeight="1"><c r="A5" s="1"><v>1E20</v></c><c r="B5"><v>1.5E-7</v></c><c r="C5"><v>123456789012345</v></c><c r="D5"><v>-1000000000000000.0</v></c><c r="E5"><v>0</v></c><c r="F5" t="s"><v>1</v></c><c r="G5" t="s"><v>0</v></c><c r="H5" s="1"/></row><row r="8" s="1" customFormat="1" outlineLevel="2"/><row r="10000"><c r="AB10000" s="1"><f>SUM($A$1:B2,Sheet2!C:C,3:$5,'My Sheet'!$XFD$1)*2</f><v>0.25</v></c><c r="AC10000" t="str"><f>A1&"<x>"</f><v>t & <u></v></c><c r="AD10000" t="b"><f>_xlfn.LET(_xlpm.x,1,_xlpm.x+_xlfn.XLOOKUP(1,A:A,B:B))</f><v>0</v></c><c r="AE10000" t="e"><f>1/0</f><v>#DIV/0!</v></c><c r="AF10000"><f>A1</f></c></row><row r="1048576"><c r="XFD1048576"><v>7</v></c></row></sheetData>"#;
|
||||
+124
-4
@@ -325,6 +325,17 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
|
||||
/// Escapes text content.
|
||||
pub fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
push_esc(&mut o, s);
|
||||
o
|
||||
}
|
||||
|
||||
/// Appends [`esc`]`(s)` to `o`.
|
||||
pub fn push_esc(o: &mut String, s: &str) {
|
||||
if s.bytes().all(|b| matches!(b, b'\t' | b'\n' | 0x20..=0x7E) && !matches!(b, b'&' | b'<' | b'>')) {
|
||||
o.push_str(s);
|
||||
return;
|
||||
}
|
||||
o.reserve(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
@@ -335,7 +346,43 @@ pub fn esc(s: &str) -> String {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Appends `n` in decimal.
|
||||
pub fn push_u64(o: &mut String, mut n: u64) {
|
||||
let mut buf = [0u8; 20];
|
||||
let mut i = buf.len();
|
||||
loop {
|
||||
i -= 1;
|
||||
if let Some(d) = buf.get_mut(i) {
|
||||
*d = b'0' + (n % 10) as u8;
|
||||
}
|
||||
n /= 10;
|
||||
if n == 0 || i == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for &d in buf.get(i..).unwrap_or_default() {
|
||||
o.push(char::from(d));
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends the `A1`-style text of `c` ([`gridcraft_core::CellRef::a1`]).
|
||||
pub fn push_a1(o: &mut String, c: gridcraft_core::CellRef) {
|
||||
let mut letters = [0u8; 8];
|
||||
let mut i = letters.len();
|
||||
let mut n = u64::from(c.col) + 1;
|
||||
while n > 0 && i > 0 {
|
||||
i -= 1;
|
||||
if let Some(l) = letters.get_mut(i) {
|
||||
*l = b'A' + ((n - 1) % 26) as u8;
|
||||
}
|
||||
n = (n - 1) / 26;
|
||||
}
|
||||
for &l in letters.get(i..).unwrap_or_default() {
|
||||
o.push(char::from(l));
|
||||
}
|
||||
push_u64(o, u64::from(c.row) + 1);
|
||||
}
|
||||
|
||||
/// Escapes an attribute value (double-quoted).
|
||||
@@ -475,13 +522,30 @@ pub fn si_body(text: &str, phonetic: Option<&PhoneticString>) -> String {
|
||||
|
||||
/// Formats a number for an XML attribute/value (shortest round-trip form).
|
||||
pub fn num(n: f64) -> String {
|
||||
let mut s = String::new();
|
||||
push_num(&mut s, n);
|
||||
s
|
||||
}
|
||||
|
||||
/// Appends [`num`]`(n)` to `o`.
|
||||
pub fn push_num(o: &mut String, n: f64) {
|
||||
if !n.is_finite() {
|
||||
return "0".into();
|
||||
o.push('0');
|
||||
return;
|
||||
}
|
||||
if n == n.trunc() && n.abs() < 1e15 {
|
||||
return format!("{}", n as i64);
|
||||
let i = n as i64;
|
||||
if i < 0 {
|
||||
o.push('-');
|
||||
}
|
||||
push_u64(o, i.unsigned_abs());
|
||||
return;
|
||||
}
|
||||
let start = o.len();
|
||||
let _ = write!(o, "{n:?}");
|
||||
if let Some(e) = o.get(start..).and_then(|t| t.find('e')) {
|
||||
o.replace_range(start + e..start + e + 1, "E");
|
||||
}
|
||||
format!("{n:?}").replace('e', "E")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -618,6 +682,62 @@ mod tests {
|
||||
assert_eq!(esc_attr("a\rb"), "a b");
|
||||
}
|
||||
|
||||
/// The appending helpers the sheet writer uses write exactly what the allocating spellings
|
||||
/// (`CellRef::a1`, `to_string`, the `num` formula and the `esc` character loop) write.
|
||||
#[test]
|
||||
fn appending_helpers_match_the_allocating_spellings() {
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
|
||||
for c in [
|
||||
CellRef::new(0, 0),
|
||||
CellRef::new(9, 25),
|
||||
CellRef::new(99, 26),
|
||||
CellRef::new(0, 701),
|
||||
CellRef::new(0, 702),
|
||||
CellRef::new(MAX_ROWS - 1, MAX_COLS - 1),
|
||||
CellRef::new(u32::MAX, u32::MAX),
|
||||
] {
|
||||
let mut s = String::from("x");
|
||||
push_a1(&mut s, c);
|
||||
assert_eq!(s, format!("x{}", c.a1()));
|
||||
}
|
||||
for n in [0, 9, 10, 99, 1_048_576, u64::MAX] {
|
||||
let mut s = String::from("x");
|
||||
push_u64(&mut s, n);
|
||||
assert_eq!(s, format!("x{n}"));
|
||||
}
|
||||
for n in
|
||||
[0.0, -0.0, 1.0, -3.0, 0.1, -2.5, 1e-7, 1.5e20, 1e15, -1e15, 999_999_999_999_999.0, f64::NAN, f64::INFINITY, f64::MIN_POSITIVE, f64::MAX]
|
||||
{
|
||||
let reference = if !n.is_finite() {
|
||||
"0".to_string()
|
||||
} else if n == n.trunc() && n.abs() < 1e15 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{n:?}").replace('e', "E")
|
||||
};
|
||||
let mut s = String::from("x");
|
||||
push_num(&mut s, n);
|
||||
assert_eq!(s, format!("x{reference}"), "{n}");
|
||||
assert_eq!(num(n), reference);
|
||||
}
|
||||
for t in ["plain", "", "a & <b> > c \"q\"", "tab\tnew\nline\rcr", "\u{1}\u{8}ctl", "日本語 ü", "\u{FFFE}\u{FFFF}x", "~\u{7F}"] {
|
||||
let mut reference = String::new();
|
||||
for c in t.chars() {
|
||||
match c {
|
||||
'&' => reference.push_str("&"),
|
||||
'<' => reference.push_str("<"),
|
||||
'>' => reference.push_str(">"),
|
||||
'\r' => reference.push_str(" "),
|
||||
c if is_xml_char(c) => reference.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut s = String::from("x");
|
||||
push_esc(&mut s, t);
|
||||
assert_eq!(s, format!("x{reference}"), "{t:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers() {
|
||||
assert_eq!(num(1.0), "1");
|
||||
|
||||
Reference in New Issue
Block a user