xlsx: stop memory amplification from declared sizes (#2173) (#2197)

* xlsx: stop memory amplification from declared sizes (#2173)

Hyperlinks: drop targets over 8,192 bytes before copying them into each cell, cut ScreenTips to 255 characters, and count the 10,000-cell cap across all <hyperlinks> blocks of a sheet. Conditional formats: cap the sqref copies made by extra rules at 1M ranges per sheet. Styles: build each custom number format once and share its Arc; cut font names to 255 characters. Shared formulas: keep cached values for dependents of over-long masters, and charge each expansion against a 2 GiB per-workbook budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Import the hyperlink caps in sheet_read/parts after the merge

Upstream switched parts.rs to an explicit super:: import list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* xlsx: charge conditional-format ranges per sheet; cap number format codes (#2173)

- read_cf keeps a cumulative per-sheet count of the range cells retained
  rules hold, instead of multiplying the current block by the rule count,
  and charges only rules that are kept.
- Custom and inline number format codes longer than 255 characters (Excel's
  limit) are dropped with a warning; their cells show General.
- The shared NumFmt map is built only for ids a cell or differential
  format refers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* xlsx: keep over-long number format codes as stored (#2173)

an_overlong_number_format_code_does_not_stop_the_open requires a code
past 255 characters to round-trip; sharing one NumFmt per referenced id
already removes the amplification. Keep the referenced-id sharing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* xlsx: bound only what a shared formula costs beyond an ordinary cell; count characters for the length warning

Review follow-up for #2197 (@skylartaylor):
- Each dependent of a shared formula now pays only its cost beyond an ordinary formula cell
  (SHARED_FORMULA_FREE_COST, 4 KiB by shared_cost), and the per-workbook budget for that excess
  drops from 2 GiB to 256 MiB. Many ordinary shared formulas still all expand; a long master
  copied into thousands of cells stops at the budget and keeps the cached values.
- The over-long-master warning now uses the parser's own test (bytes, then characters), so an
  unparseable multibyte master no longer gets a false length warning.

Refs #2173

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-06 10:52:53 +01:00
1 parent 21b64df0fe
commit ff35c6b89d
6 files changed
+344 -35

No files matched your search

+4
View File
@@ -35,6 +35,9 @@ pub struct Ctx<'a> {
pub cell_pictures: Vec<Option<Arc<CellPicture>>>,
/// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`.
pub cf_ext_ids: Vec<(String, usize)>,
/// Bytes charged so far for shared formulas expanded into dependent cells (see
/// [`crate::sheet_read::MAX_SHARED_FORMULA_BYTES`]).
pub shared_formula_bytes: u64,
/// Whether GridCraft calculates a function, when the caller says: cell formulas wrapped by
/// Google Sheets' export are then read as the formula inside (see [`crate::read_xlsx_with`]).
pub known_function: Option<&'a dyn Fn(&str) -> bool>,
@@ -255,6 +258,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
num_fmts: Default::default(),
cell_pictures: vec![],
cf_ext_ids: vec![],
shared_formula_bytes: 0,
known_function,
python: Default::default(),
};
+115 -4
View File
@@ -31,6 +31,16 @@ struct Shared {
anchor: CellRef,
expr: Option<Expr>,
text: String,
/// Approximate bytes one dependent cell costs (its printed text plus its expression tree);
/// what exceeds [`SHARED_FORMULA_FREE_COST`] is charged against [`MAX_SHARED_FORMULA_BYTES`].
cost: u64,
}
/// What one sheet's hyperlinks have copied so far, across all its `<hyperlinks>` blocks.
#[derive(Default)]
struct LinkBudget {
cells: u64,
bytes: u64,
}
#[derive(Default)]
@@ -120,10 +130,23 @@ fn settle_python_spills_past_budget(st: &RowState, sheet: &mut Sheet) {
}
}
const MAX_HYPERLINK_CELLS: u64 = 10_000;
/// Target and tooltip bytes kept for hyperlinks of one sheet, over all cells: the same URL is
/// copied for every cell of its range, so length × cells must stay bounded whatever the file.
const MAX_HYPERLINK_BYTES: u64 = 8 * 1024 * 1024;
/// Most cells one sheet's hyperlinks may cover (each cell gets its own copy of the link).
const MAX_HYPERLINK_CELLS: u64 = 10_000;
/// Longest hyperlink target kept, in bytes (Excel's own limit is about 2,079 characters).
const MAX_HYPERLINK_TARGET: usize = 8_192;
/// Longest hyperlink ScreenTip kept, in characters (Excel's limit).
const MAX_TOOLTIP_CHARS: usize = 255;
/// What a dependent of a shared formula may cost for free: an ordinary formula cell's worth (by
/// [`shared_cost`], a formula of about 40 operators and operands), so a sheet of many ordinary
/// shared formulas is never cut short however many cells share them.
const SHARED_FORMULA_FREE_COST: u64 = 4096;
/// Budget, per workbook, for what shared formulas cost beyond [`SHARED_FORMULA_FREE_COST`] per
/// dependent cell: a long master copied into many cells is where the expansion multiplies the
/// file's bytes, and this bounds it to well within a web build's memory.
pub const MAX_SHARED_FORMULA_BYTES: u64 = 256 << 20;
const MAX_COLUMN_ASSIGNMENTS: u64 = 1_000_000;
/// Most dynamic-array and legacy-array ranges of one sheet.
const MAX_DYNAMIC_RANGES: usize = 65_536;
@@ -182,6 +205,8 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, cr
drop(bytes);
let mut fit_to_page = false;
let mut link_budget = LinkBudget::default();
let mut cf_kept_cells = 0u64;
let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS;
cx.cf_ext_ids.clear();
for e in &root.children {
@@ -208,7 +233,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, cr
}
}
}
"conditionalFormatting" => read_cf(cx, e, &mut sheet),
"conditionalFormatting" => read_cf(cx, e, &mut sheet, &mut cf_kept_cells),
"dataValidations" => {
for v in e.kids("dataValidation") {
if let Some(v) = read_validation(v) {
@@ -216,7 +241,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<(Sheet, cr
}
}
}
"hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet),
"hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet, &mut link_budget),
"autoFilter" => sheet.autofilter = read_autofilter(cx, e),
"printOptions" => {
sheet.print.gridlines = e.flag("gridLines", false);
@@ -641,13 +666,30 @@ fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef,
let si = f.attr_u32("si")?;
if !text.is_empty() {
let expr = gridcraft_formula::parse(text).ok();
st.shared.insert(si, Shared { anchor: pos, expr, text: text.to_string() });
let cost = shared_cost(text, expr.as_ref());
st.shared.insert(si, Shared { anchor: pos, expr, text: text.to_string(), cost });
Some(Formula::new(text))
} else {
let Some(sh) = st.shared.get(&si) else {
cx.warn("a shared formula refers to a missing master cell; kept the cached value");
return None;
};
// The parser's own test: bytes first (cheap), then characters, so a multibyte
// master that failed to parse for another reason gets no false length warning.
if sh.expr.is_none()
&& sh.text.len() > gridcraft_formula::MAX_FORMULA_LEN
&& sh.text.chars().count() > gridcraft_formula::MAX_FORMULA_LEN
{
cx.warn(format!(
"a shared formula is longer than {} characters; kept the cached values of the cells that share it",
gridcraft_formula::MAX_FORMULA_LEN
));
return None;
}
if !charge(&mut cx.shared_formula_bytes, extra_shared_cost(sh.cost), MAX_SHARED_FORMULA_BYTES) {
cx.warn("shared formulas expand to too much data; kept the cached values of the remaining cells that share them");
return None;
}
Some(match &sh.expr {
Some(e) => {
let dr = pos.row as i64 - sh.anchor.row as i64;
@@ -720,6 +762,41 @@ pub fn parse_iso_datetime(s: &str, sys: DateSystem) -> Option<f64> {
Some(serial)
}
/// What one dependent of a shared formula costs: its printed text plus its expression tree.
fn shared_cost(text: &str, expr: Option<&Expr>) -> u64 {
let mut nodes = 0u64;
if let Some(e) = expr {
e.walk(&mut |_| nodes = nodes.saturating_add(1));
}
let node = std::mem::size_of::<Expr>() as u64;
(text.len() as u64).saturating_mul(2).saturating_add(nodes.saturating_mul(node)).saturating_add(64)
}
/// What one dependent of a shared formula costing `cost` is charged: its cost beyond an ordinary
/// formula cell's.
fn extra_shared_cost(cost: u64) -> u64 {
cost.saturating_sub(SHARED_FORMULA_FREE_COST)
}
/// Adds `cost` to `used` when that stays within `cap`; `false` (and `used` unchanged) otherwise.
fn charge(used: &mut u64, cost: u64, cap: u64) -> bool {
match used.checked_add(cost) {
Some(total) if total <= cap => {
*used = total;
true
}
_ => false,
}
}
/// The first `max` characters of `s`.
fn truncate_chars(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
Some((at, _)) => s.get(..at).unwrap_or(s).to_string(),
None => s.to_string(),
}
}
pub fn parse_sqref(s: Option<&str>) -> Vec<RangeRef> {
s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect()
}
@@ -780,6 +857,40 @@ fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
mod tests {
use super::*;
#[test]
fn shared_formula_budget_stops_expansion() {
let expr = gridcraft_formula::parse("SUM(1,2,3)").ok();
let cost = shared_cost("SUM(1,2,3)", expr.as_ref());
assert!(cost > 64 + 4 * std::mem::size_of::<Expr>() as u64);
let cap = cost * 3;
let mut used = 0;
assert!((0..3).all(|_| charge(&mut used, cost, cap)));
assert!(!charge(&mut used, cost, cap));
assert_eq!(used, cap);
assert!(!charge(&mut used, u64::MAX, u64::MAX));
}
#[test]
fn ordinary_shared_formulas_are_free_and_long_ones_pay_their_excess() {
let text = r#"IF(AND(A1>0,B1<>""),VLOOKUP(C1,Sheet2!$A$1:$D$500,4,FALSE)*SUM(D1:D10)/COUNT(D1:D10),IFERROR(E1/F1,0))"#;
let expr = gridcraft_formula::parse(text).ok();
assert!(expr.is_some());
assert_eq!(extra_shared_cost(shared_cost(text, expr.as_ref())), 0, "an ordinary formula costs nothing extra");
let long = vec![format!("SUM({}1)", "1,".repeat(199)); 10].join("+");
let expr = gridcraft_formula::parse(&long).ok();
assert!(expr.is_some());
let cost = shared_cost(&long, expr.as_ref());
assert_eq!(extra_shared_cost(cost), cost - SHARED_FORMULA_FREE_COST);
// A few thousand copies of a long master no longer fit the budget.
assert!(extra_shared_cost(cost).saturating_mul(10_000) > MAX_SHARED_FORMULA_BYTES);
}
#[test]
fn truncate_keeps_whole_characters() {
assert_eq!(truncate_chars("ab\u{e9}d", 3), "ab\u{e9}");
assert_eq!(truncate_chars("ab", 3), "ab");
}
#[test]
fn iso_dates() {
assert_eq!(parse_iso_datetime("1900-01-01", DateSystem::D1900), Some(1.0));
+22 -13
View File
@@ -1,17 +1,25 @@
use super::{
AutoFilter, CellRef, Comment, Ctx, El, FilterCriterion, HashMap, Hyperlink, MAX_COLS, MAX_HYPERLINK_BYTES, MAX_HYPERLINK_CELLS, MAX_ROWS,
NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr, from_file, xml,
AutoFilter, CellRef, Comment, Ctx, El, FilterCriterion, HashMap, Hyperlink, LinkBudget, MAX_COLS, MAX_HYPERLINK_BYTES, MAX_HYPERLINK_CELLS,
MAX_HYPERLINK_TARGET, MAX_ROWS, MAX_TOOLTIP_CHARS, NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr,
from_file, truncate_chars, xml,
};
pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) {
let mut cells = 0u64;
let mut bytes = 0u64;
/// `budget` counts the cells and bytes this sheet's hyperlinks have copied so far, across
/// `<hyperlinks>` blocks.
pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet, budget: &mut LinkBudget) {
for h in e.kids("hyperlink") {
let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue };
let mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default();
let rel_target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.as_str()).unwrap_or("");
let location = h.attr("location").filter(|l| !l.is_empty());
// Each covered cell gets its own copy of the link: refuse oversized targets before copying.
if rel_target.len().saturating_add(location.map_or(0, str::len)) > MAX_HYPERLINK_TARGET {
cx.warn(format!("sheet \"{name}\": hyperlinks longer than {MAX_HYPERLINK_TARGET} characters were dropped"));
continue;
}
let mut target = rel_target.to_string();
// A relationship is a file or web page; a bare `location` is a place in this workbook.
let external = !target.is_empty();
if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) {
if let Some(loc) = location {
if target.is_empty() {
target = loc.trim_start_matches('#').to_string();
} else {
@@ -21,19 +29,20 @@ pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel]
if target.is_empty() {
continue;
}
let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external };
let link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64;
let tooltip = h.attr("tooltip").map(|t| truncate_chars(t, MAX_TOOLTIP_CHARS));
let link_bytes = (target.len() + tooltip.as_ref().map_or(0, String::len)) as u64;
let link = Hyperlink { target, tooltip, external };
for c in range.iter() {
if cells >= MAX_HYPERLINK_CELLS {
if budget.cells >= MAX_HYPERLINK_CELLS {
cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped"));
return;
}
if bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES {
if budget.bytes.saturating_add(link_bytes) > MAX_HYPERLINK_BYTES {
cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped"));
return;
}
cells += 1;
bytes += link_bytes;
budget.cells += 1;
budget.bytes += link_bytes;
sheet.hyperlinks.insert(c, link.clone());
}
}
+11 -7
View File
@@ -45,11 +45,15 @@ fn cfvos(e: &El) -> Vec<CfValueKind> {
.collect()
}
pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
/// One `conditionalFormatting` block. Every rule keeps its own copy of the block's ranges, so
/// `kept_cells` counts the range cells the sheet's retained rules hold so far, across blocks; a
/// rule that would take it past [`MAX_COND_CELLS`] is dropped. Unsupported rules cost nothing.
pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet, kept_cells: &mut u64) {
let ranges = parse_sqref(e.attr("sqref"));
if ranges.is_empty() {
return;
}
let block_cells = ranges.iter().map(RangeRef::count).fold(0u64, u64::saturating_add);
for r in e.kids("cfRule") {
let style = Box::new(r.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)).cloned().unwrap_or_default());
let formulas: Vec<String> = r.kids("formula").map(|f| from_file(f.text.trim())).collect();
@@ -111,15 +115,15 @@ pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
continue;
}
};
if let Some(id) = ext_id {
cx.cf_ext_ids.push((id, sheet.cond_formats.len()));
}
let rules = sheet.cond_formats.len();
let area = ranges.iter().map(RangeRef::count).sum::<u64>().saturating_mul(1 + rules as u64);
if area > MAX_COND_CELLS {
let total = kept_cells.saturating_add(block_cells);
if total > MAX_COND_CELLS {
cx.warn(format!("conditional formats past {MAX_COND_CELLS} kept range cells were dropped"));
break;
}
*kept_cells = total;
if let Some(id) = ext_id {
cx.cf_ext_ids.push((id, sheet.cond_formats.len()));
}
sheet.cond_formats.push(CondFormat {
ranges: ranges.clone(),
rule,
+38 -11
View File
@@ -1,7 +1,7 @@
//! `styles.xml`: reading cell formats and differential formats into model styles, and writing
//! the workbook's style table.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use gridcraft_model::style::{DxfStated, Protection};
@@ -14,6 +14,9 @@ use crate::xml::{El, esc_attr, num};
// ---------------------------------------------------------------- reading
/// Longest font name kept, in characters (Excel's own limit is 31).
pub const MAX_FONT_NAME_CHARS: usize = 255;
/// Styles read from a file.
#[derive(Debug, Default)]
pub struct StylesIn {
@@ -63,7 +66,11 @@ const MAX_FONT_FAMILY: u8 = 14;
pub fn read_font(e: &El, palette: &[u32]) -> Font {
let mut f = Font::default();
if let Some(n) = e.child("name").or_else(|| e.child("rFont")).and_then(|n| n.attr("val")) {
f.name = n.to_string();
// Every cell format that uses the font copies its name: keep it short.
f.name = match n.char_indices().nth(MAX_FONT_NAME_CHARS) {
Some((at, _)) => n.get(..at).unwrap_or_default().to_string(),
None => n.to_string(),
};
}
if let Some(sz) = e.child("sz").and_then(|s| s.attr_f64("val")) {
f.size = sz.clamp(1.0, 409.0) as f32;
@@ -277,14 +284,16 @@ fn read_protection(p: &El) -> Protection {
Protection { locked: p.flag("locked", true), hidden: p.flag("hidden", false) }
}
fn num_fmt_code(id: u32, custom: &HashMap<u32, String>) -> NumFmt {
/// A number format by id. Custom formats are built once and shared (`NumFmt` is an `Arc`), so
/// many cell formats naming one long code don't each copy it.
fn num_fmt_code(id: u32, custom: &HashMap<u32, NumFmt>) -> NumFmt {
if let Some(c) = custom.get(&id) {
return NumFmt::new(c);
return c.clone();
}
NumFmt::new(builtin_format(id).unwrap_or("General"))
}
fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap<u32, String>, checkboxes: &[bool]) -> Style {
fn read_xf(xf: &El, fonts: &[Font], fills: &[Fill], borders: &[Borders], fmts: &HashMap<u32, NumFmt>, checkboxes: &[bool]) -> Style {
let mut s = Style::default();
if let Some(f) = xf.attr_u32("fontId").and_then(|i| fonts.get(i as usize)) {
s.font = f.clone();
@@ -336,6 +345,20 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
}
}
}
// One shared `NumFmt` per custom id a cell format or differential format refers to (`NumFmt`
// is an `Arc`); declared but unused codes get no second copy.
let mut used: HashSet<u32> = HashSet::new();
for list in ["cellXfs", "cellStyleXfs"] {
if let Some(x) = root.child(list) {
used.extend(x.kids("xf").map(|xf| xf.attr_u32("numFmtId").unwrap_or(0)));
}
}
if let Some(d) = root.child("dxfs") {
used.extend(
d.kids("dxf").filter_map(|x| x.child("numFmt")).filter(|n| n.attr("formatCode").is_none()).filter_map(|n| n.attr_u32("numFmtId")),
);
}
let shared_fmts: HashMap<u32, NumFmt> = fmts.iter().filter(|(id, _)| used.contains(id)).map(|(&id, code)| (id, NumFmt::new(code))).collect();
// The scheme link only means something on a font in the body face; elsewhere it stays as a new font has it.
let body_font = |e: &El| {
let mut f = read_font(e, &palette);
@@ -345,10 +368,14 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
let fonts: Vec<Font> = root.child("fonts").map(|f| f.kids("font").map(body_font).collect()).unwrap_or_default();
let fills: Vec<Fill> = root.child("fills").map(|f| f.kids("fill").map(|e| read_fill(e, &palette, false)).collect()).unwrap_or_default();
let borders: Vec<Borders> = root.child("borders").map(|f| f.kids("border").map(|e| read_border(e, &palette)).collect()).unwrap_or_default();
let xfs =
root.child("cellXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts, checkboxes)).collect()).unwrap_or_default();
let style_xfs: Vec<Style> =
root.child("cellStyleXfs").map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &fmts, &[])).collect()).unwrap_or_default();
let xfs = root
.child("cellXfs")
.map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &shared_fmts, checkboxes)).collect())
.unwrap_or_default();
let style_xfs: Vec<Style> = root
.child("cellStyleXfs")
.map(|x| x.kids("xf").map(|xf| read_xf(xf, &fonts, &fills, &borders, &shared_fmts, &[])).collect())
.unwrap_or_default();
let mut named = Vec::new();
if let Some(cs) = root.child("cellStyles") {
for c in cs.kids("cellStyle") {
@@ -359,12 +386,12 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn
}
}
}
let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &fmts)).collect()).unwrap_or_default();
let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &shared_fmts)).collect()).unwrap_or_default();
StylesIn { xfs, dxfs, palette, named, num_fmts: fmts }
}
/// One differential format (`dxf`): the style it sets over a cell's own.
fn read_dxf(x: &El, palette: &[u32], fmts: &HashMap<u32, String>) -> Style {
fn read_dxf(x: &El, palette: &[u32], fmts: &HashMap<u32, NumFmt>) -> Style {
let mut s = Style::default();
if let Some(e) = x.child("font") {
let f = read_font(e, palette);
+154
View File
@@ -280,6 +280,160 @@ fn hostile_formula_text_does_not_overflow_the_stack() {
assert_eq!(a_value, gridcraft_core::Value::Number(3.0));
}
/// #2173: a hyperlink is copied into every cell it covers, so oversized targets are dropped,
/// ScreenTips are cut to 255 characters and the cell cap holds across `<hyperlinks>` blocks.
#[test]
fn hyperlink_copies_are_bounded() {
let long = "A".repeat(20_000);
let tip = "t".repeat(20_000);
let body = format!(
r#"<sheetData/><hyperlinks><hyperlink ref="A1:A10000" location="{long}" tooltip="{tip}"/><hyperlink ref="B1:B3" location="Data!C1" tooltip="{tip}"/></hyperlinks>"#
);
let (wb, rep) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
let links = &wb.sheets[0].hyperlinks;
assert_eq!(links.len(), 3);
assert!(links.values().all(|l| l.target == "Data!C1" && l.tooltip.as_ref().map(|t| t.chars().count()) == Some(255)));
assert!(rep.warnings.iter().any(|w| w.contains("longer than")), "{:?}", rep.warnings);
let blocks: String =
["A", "B", "C"].iter().map(|c| format!(r#"<hyperlinks><hyperlink ref="{c}1:{c}8000" location="Data!A1"/></hyperlinks>"#)).collect();
let (wb, _) = read_xlsx(&minimal(&format!("<sheetData/>{blocks}"), &[], "", "")).unwrap();
assert_eq!(wb.sheets[0].hyperlinks.len(), 10_000);
}
/// #2173: every rule of a conditional-format block copies the block's ranges; the kept range
/// cells (ranges x rules) are capped per sheet.
#[test]
fn conditional_format_range_copies_are_bounded() {
let sq = (1..=10_000).map(|r| format!("A{r}")).collect::<Vec<_>>().join(" ");
let rules = r#"<cfRule type="containsBlanks" priority="1"/>"#.repeat(500);
let body = format!(r#"<sheetData/><conditionalFormatting sqref="{sq}">{rules}</conditionalFormatting>"#);
let (wb, rep) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
let cfs = &wb.sheets[0].cond_formats;
// 400 rules of 10,000 one-cell ranges reach the 4,000,000 kept-cell cap.
assert_eq!(cfs.len(), 400);
assert!(cfs.iter().all(|c| c.ranges.len() == 10_000));
assert!(rep.warnings.iter().any(|w| w.contains("kept range cells")), "{:?}", rep.warnings);
}
/// #2173: the kept-range-cell cap counts every block of the sheet. A large block that fills the
/// cap leaves no room for a later one-cell block.
#[test]
fn conditional_format_cap_holds_across_blocks() {
let block = |sq: &str, n: usize| {
format!(r#"<conditionalFormatting sqref="{sq}">{}</conditionalFormatting>"#, r#"<cfRule type="containsBlanks" priority="1"/>"#.repeat(n))
};
let body = format!("<sheetData/>{}{}", block("A1:D1000000", 1), block("E1", 1));
let (wb, rep) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
let cfs = &wb.sheets[0].cond_formats;
assert_eq!(cfs.len(), 1, "4,000,000 cells fill the cap; the one-cell block would exceed it");
assert_eq!(cfs[0].ranges, vec![gridcraft_core::RangeRef::parse("A1:D1000000").unwrap()]);
assert!(rep.warnings.iter().any(|w| w.contains("kept range cells")), "{:?}", rep.warnings);
}
/// #2173: earlier blocks are charged their own cells, not the later block's size times the rule
/// count, so a large block after a few small rules still fits under the cap.
#[test]
fn conditional_format_cap_admits_a_large_block_after_small_ones() {
let block = |sq: &str, n: usize| {
format!(r#"<conditionalFormatting sqref="{sq}">{}</conditionalFormatting>"#, r#"<cfRule type="containsBlanks" priority="1"/>"#.repeat(n))
};
let body = format!("<sheetData/>{}{}", block("E1", 3), block("A1:C1000000", 1));
let (wb, rep) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
assert_eq!(wb.sheets[0].cond_formats.len(), 4, "3 + 3,000,000 kept cells are under the cap");
assert!(!rep.warnings.iter().any(|w| w.contains("kept range cells")), "{:?}", rep.warnings);
}
/// #2173: cell formats share one copy of each custom number format (even an over-long one, which
/// is kept as stored) and font names are cut to 255 characters.
#[test]
fn style_copies_are_bounded() {
let code = "0".repeat(50_000);
let font = "F".repeat(100_000);
let xfs = r#"<xf numFmtId="164" fontId="0"/>"#.repeat(10_000);
let styles = format!(
r#"<styleSheet><numFmts><numFmt numFmtId="164" formatCode="{code}"/><numFmt numFmtId="165" formatCode="0.000&quot;x&quot;"/></numFmts><fonts><font><name val="{font}"/></font></fonts><cellXfs>{xfs}<xf numFmtId="165"/><xf numFmtId="165"/></cellXfs><dxfs><dxf><numFmt numFmtId="166" formatCode="{code}"/></dxf></dxfs></styleSheet>"#
);
let st = crate::styles::read_styles(&crate::xml::parse(styles.as_bytes()).unwrap(), &[], "");
assert_eq!(st.xfs.len(), 10_002);
let first = &st.xfs[0].num_fmt.0;
assert_eq!(first.len(), 50_000);
assert!(st.xfs[..10_000].iter().all(|s| std::sync::Arc::ptr_eq(&s.num_fmt.0, first) && s.font.name.chars().count() == 255));
assert_eq!(st.xfs[10_000].num_fmt.as_str(), "0.000\"x\"");
assert!(std::sync::Arc::ptr_eq(&st.xfs[10_000].num_fmt.0, &st.xfs[10_001].num_fmt.0));
assert_eq!(st.dxfs[0].num_fmt.as_str(), code);
// The same styles part read through the package.
let rels = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#;
let sheet = r#"<sheetData><row r="1"><c r="A1" s="1"><v>1</v></c></row></sheetData>"#;
let (wb, _) = read_xlsx(&minimal(sheet, &[("xl/styles.xml", &styles)], rels, "")).unwrap();
assert_eq!(wb.sheets.len(), 1);
}
/// #2173: a shared formula's master is copied into every dependent; an over-long master keeps
/// the dependents' cached values instead of copying it into each of them.
#[test]
fn shared_formula_copies_are_bounded() {
use gridcraft_core::{CellRef, Value};
let master = format!("SUM({}1)", "1,".repeat(500_000));
let mut rows = format!(r#"<row r="1"><c r="A1"><f t="shared" si="0" ref="A1:A20000">{master}</f><v>1</v></c></row>"#);
for r in 2..=20_000 {
rows.push_str(&format!(r#"<row r="{r}"><c r="A{r}"><f t="shared" si="0"/><v>7</v></c></row>"#));
}
let (wb, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
let cells = &wb.sheets[0].cells;
for r in [1, 9_999, 19_999] {
let c = cells.get(CellRef::new(r, 0)).unwrap();
assert!(c.formula.is_none());
assert_eq!(c.value, Value::Number(7.0));
}
assert!(rep.warnings.iter().any(|w| w.contains("shared formula is longer")), "{:?}", rep.warnings);
}
#[test]
fn a_long_shared_master_expands_into_only_part_of_many_cells() {
use gridcraft_core::{CellRef, Value};
// About 4,000 characters and 2,000 operands: parseable, but far more than an ordinary
// formula cell, so each copy pays for its excess and ten thousand of them exceed the budget.
let master = vec![format!("SUM({}1)", "1,".repeat(199)); 10].join("+");
let mut rows = format!(r#"<row r="1"><c r="A1"><f t="shared" si="0" ref="A1:A10000">{master}</f><v>1</v></c></row>"#);
for r in 2..=10_000 {
rows.push_str(&format!(r#"<row r="{r}"><c r="A{r}"><f t="shared" si="0"/><v>7</v></c></row>"#));
}
let (wb, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
let cells = &wb.sheets[0].cells;
assert!(cells.get(CellRef::new(1, 0)).unwrap().formula.is_some(), "the first dependents still expand");
let last = cells.get(CellRef::new(9_999, 0)).unwrap();
assert!(last.formula.is_none());
assert_eq!(last.value, Value::Number(7.0));
assert!(rep.warnings.iter().any(|w| w.contains("expand to too much data")), "{:?}", rep.warnings);
}
#[test]
fn many_ordinary_shared_formulas_all_expand() {
let mut rows =
String::from(r#"<row r="1"><c r="B1"><f t="shared" si="0" ref="B1:B60000">IF(A1>0,SUM(A1:A10)/COUNT(A1:A10),"")</f><v>1</v></c></row>"#);
for r in 2..=60_000 {
rows.push_str(&format!(r#"<row r="{r}"><c r="B{r}"><f t="shared" si="0"/><v>7</v></c></row>"#));
}
let (wb, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
let cells = &wb.sheets[0].cells;
assert!(cells.get(gridcraft_core::CellRef::new(59_999, 1)).unwrap().formula.is_some());
assert!(!rep.warnings.iter().any(|w| w.contains("shared formula")), "{:?}", rep.warnings);
}
#[test]
fn an_unparseable_multibyte_shared_master_gets_no_length_warning() {
// 5,001 characters but over 10,000 bytes, and unparseable for another reason (an open string).
let master = format!("\"{}", "\u{e9}".repeat(5_000));
assert!(master.len() > gridcraft_formula::MAX_FORMULA_LEN && master.chars().count() < gridcraft_formula::MAX_FORMULA_LEN);
let rows = format!(
r#"<row r="1"><c r="A1"><f t="shared" si="0" ref="A1:A2">{master}</f><v>1</v></c></row><row r="2"><c r="A2"><f t="shared" si="0"/><v>7</v></c></row>"#
);
let (_, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
assert!(!rep.warnings.iter().any(|w| w.contains("longer than")), "{:?}", rep.warnings);
}
#[test]
fn repeated_column_ranges_have_one_worksheet_work_budget() {
// 62 * 16,384 = 1,015,808 assignments, while the map holds at most 16,384 columns.