mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-07 12:13:13 +00:00
* 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:
co-authored by
Claude Opus 5.5
FredApps
parent
21b64df0fe
commit
ff35c6b89d
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"x""/></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.
|
||||
|
||||
Reference in New Issue
Block a user