diff --git a/Cargo.lock b/Cargo.lock index 53b2326da..a092cd3e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,6 +2124,7 @@ dependencies = [ "aes", "gridcraft-core", "gridcraft-formula", + "gridcraft-functions", "gridcraft-model", "quick-xml 0.41.0", "sha1 0.11.0", diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 8a1c0c45b..3b5989372 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -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) { } 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) { 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); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index a7506aa4b..0f16116af 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -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); + } + } +} diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index b53a44828..562afff11 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -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 } diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index c5e1f686c..966088d5b 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -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)"); diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index aeca78123..b03bfbd5e 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -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 = 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> = 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 = 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(""); - 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, " ( 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(""); } @@ -635,9 +656,13 @@ fn write_cell( out: &mut Out, ) { let style = cell.map(|x| sid(x.style)).unwrap_or(0); - let _ = write!(s, "'); - let text = esc(&crate::fmla::to_file(f)); - match (f.array, dynamic) { - (Some(r), _) => { - let _ = write!(s, "{text}", r.a1()); - } - (None, Some(r)) => { - let _ = write!(s, "{text}", r.a1()); - } - _ => { - let _ = write!(s, "{text}"); + match f.array.or(dynamic.copied()) { + Some(r) => { + let _ = write!(s, "", r.a1()); } + None => s.push_str(""), } + push_esc(s, &crate::fmla::to_file(f)); + s.push_str(""); match &value { Value::Number(n) => { - let _ = write!(s, "{}", num(*n)); + s.push_str(""); + push_num(s, *n); + s.push_str(""); } Value::Text(t) => { let _ = write!(s, "{}", 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, ">{}", num(*n)); + s.push_str(">"); + push_num(s, *n); + s.push_str(""); } Value::Text(t) => { let i = out.sst.index(t); - let _ = write!(s, " t=\"s\">{i}"); + s.push_str(" t=\"s\">"); + push_u64(s, u64::from(i)); + s.push_str(""); } Value::Bool(b) => { let _ = write!(s, " t=\"b\">{}", *b as u8); diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index a7f6f1eb7..73be1a064 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -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; diff --git a/crates/xlsx/src/tests/sheet_data_pin.rs b/crates/xlsx/src/tests/sheet_data_pin.rs new file mode 100644 index 000000000..5f9b72a01 --- /dev/null +++ b/crates/xlsx/src/tests/sheet_data_pin.rs @@ -0,0 +1,68 @@ +//! Pins the exact `` 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("").unwrap(); + let end = xml.find("").unwrap() + "".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 & \"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 & \"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&\"\"", Value::text("t & "), 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#"_xlfn.SEQUENCE(2)10-50.11#N/A0-2.51E201.5E-7123456789012345-1000000000000000.0010SUM($A$1:B2,Sheet2!C:C,3:$5,'My Sheet'!$XFD$1)*20.25A1&"<x>"t & <u>_xlfn.LET(_xlpm.x,1,_xlpm.x+_xlfn.XLOOKUP(1,A:A,B:B))01/0#DIV/0!A17"#; diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index 166994180..940a66261 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -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 & > 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");