mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 20:40:05 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d713a2da0b | ||
|
|
3778385561 |
@@ -23,8 +23,8 @@ use crate::xml::{self, El};
|
|||||||
mod parts;
|
mod parts;
|
||||||
mod rules;
|
mod rules;
|
||||||
|
|
||||||
|
use parts::{HyperlinkUse, read_comments, read_hyperlinks, read_note_shapes, read_threaded};
|
||||||
pub use parts::{read_autofilter, read_table};
|
pub use parts::{read_autofilter, read_table};
|
||||||
use parts::{read_comments, read_hyperlinks, read_note_shapes, read_threaded};
|
|
||||||
use rules::{read_cf, read_validation};
|
use rules::{read_cf, read_validation};
|
||||||
|
|
||||||
struct Shared {
|
struct Shared {
|
||||||
@@ -44,6 +44,12 @@ struct RowState {
|
|||||||
dynamic_cells: u64,
|
dynamic_cells: u64,
|
||||||
/// Row-membership checks served so far, for the work cap above.
|
/// Row-membership checks served so far, for the work cap above.
|
||||||
dynamic_checks: u64,
|
dynamic_checks: u64,
|
||||||
|
/// Bytes of unparsable shared-master text copied into follower cells so far: each follower
|
||||||
|
/// keeps its own copy, so a long master and many followers must stay bounded together.
|
||||||
|
shared_fallback_bytes: u64,
|
||||||
|
/// The cell being read lost its shared formula to the copy limit above: its cached value is
|
||||||
|
/// kept, even inside an array range (where a formula's cached values otherwise give way to it).
|
||||||
|
shared_copy_refused: bool,
|
||||||
dropped: u64,
|
dropped: u64,
|
||||||
rows: u64,
|
rows: u64,
|
||||||
}
|
}
|
||||||
@@ -138,6 +144,9 @@ const MAX_DYNAMIC_CHECKS: u64 = 16_000_000;
|
|||||||
/// Most conditional-format range cells one sheet keeps, over all rules of an element: every
|
/// Most conditional-format range cells one sheet keeps, over all rules of an element: every
|
||||||
/// rule copies its element's whole range list, so rules × ranges must stay bounded.
|
/// rule copies its element's whole range list, so rules × ranges must stay bounded.
|
||||||
const MAX_COND_CELLS: u64 = 4_000_000;
|
const MAX_COND_CELLS: u64 = 4_000_000;
|
||||||
|
/// Most bytes of an unparsable shared master's text one sheet copies into follower cells; past
|
||||||
|
/// it, followers keep their cached values.
|
||||||
|
const MAX_SHARED_FALLBACK_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
fn read_sheet_format(e: &El, sheet: &mut Sheet) {
|
fn read_sheet_format(e: &El, sheet: &mut Sheet) {
|
||||||
if let Some(h) = e.attr_f64("defaultRowHeight") {
|
if let Some(h) = e.attr_f64("defaultRowHeight") {
|
||||||
@@ -181,6 +190,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
|||||||
|
|
||||||
let mut fit_to_page = false;
|
let mut fit_to_page = false;
|
||||||
let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS;
|
let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS;
|
||||||
|
let mut hyperlinks_used = HyperlinkUse::default();
|
||||||
cx.cf_ext_ids.clear();
|
cx.cf_ext_ids.clear();
|
||||||
for e in &root.children {
|
for e in &root.children {
|
||||||
match e.name.as_str() {
|
match e.name.as_str() {
|
||||||
@@ -214,7 +224,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet),
|
"hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet, &mut hyperlinks_used),
|
||||||
"autoFilter" => sheet.autofilter = read_autofilter(cx, e),
|
"autoFilter" => sheet.autofilter = read_autofilter(cx, e),
|
||||||
"printOptions" => {
|
"printOptions" => {
|
||||||
sheet.print.gridlines = e.flag("gridLines", false);
|
sheet.print.gridlines = e.flag("gridLines", false);
|
||||||
@@ -568,6 +578,7 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let dynamic = c.attr("cm").is_some();
|
let dynamic = c.attr("cm").is_some();
|
||||||
|
st.shared_copy_refused = false;
|
||||||
let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic));
|
let formula = c.child("f").and_then(|f| read_formula(cx, st, f, pos, dynamic));
|
||||||
// A `PY(index,0|1)` formula is a Python cell whose code is a script of the Python part. The
|
// A `PY(index,0|1)` formula is a Python cell whose code is a script of the Python part. The
|
||||||
// older `PY("…",0|1)` form is read from the formula's own text: the formula parser would fold
|
// older `PY("…",0|1)` form is read from the formula's own text: the formula parser would fold
|
||||||
@@ -588,7 +599,7 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
|
|||||||
if py.is_some() {
|
if py.is_some() {
|
||||||
record_python_spill(st, sheet, pos);
|
record_python_spill(st, sheet, pos);
|
||||||
}
|
}
|
||||||
let spilled_from = if formula.is_none() && py.is_none() { dynamic_range(st, pos) } else { None };
|
let spilled_from = if formula.is_none() && py.is_none() && !st.shared_copy_refused { dynamic_range(st, pos) } else { None };
|
||||||
let python_anchor = spilled_from.is_some_and(|r| sheet.cells.get(r.start).is_some_and(|a| a.py.is_some()));
|
let python_anchor = spilled_from.is_some_and(|r| sheet.cells.get(r.start).is_some_and(|a| a.py.is_some()));
|
||||||
let value = match spilled_from {
|
let value = match spilled_from {
|
||||||
// A Python cell's spilled values are kept: they are its result until it runs again.
|
// A Python cell's spilled values are kept: they are its result until it runs again.
|
||||||
@@ -635,14 +646,23 @@ fn read_formula_text(cx: &mut Ctx<'_>, st: &mut RowState, f: &El, pos: CellRef,
|
|||||||
cx.warn("a shared formula refers to a missing master cell; kept the cached value");
|
cx.warn("a shared formula refers to a missing master cell; kept the cached value");
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
Some(match &sh.expr {
|
match &sh.expr {
|
||||||
Some(e) => {
|
Some(e) => {
|
||||||
let dr = pos.row as i64 - sh.anchor.row as i64;
|
let dr = pos.row as i64 - sh.anchor.row as i64;
|
||||||
let dc = pos.col as i64 - sh.anchor.col as i64;
|
let dc = pos.col as i64 - sh.anchor.col as i64;
|
||||||
Formula::from_expr(gridcraft_formula::adjust::shift_relative(e.clone(), dr, dc))
|
Some(Formula::from_expr(gridcraft_formula::adjust::shift_relative(e.clone(), dr, dc)))
|
||||||
}
|
}
|
||||||
None => Formula::new(&sh.text),
|
None => {
|
||||||
})
|
let total = st.shared_fallback_bytes.saturating_add(sh.text.len() as u64);
|
||||||
|
if total > MAX_SHARED_FALLBACK_BYTES {
|
||||||
|
cx.warn("some shared formulas could not be parsed and exceed the copy limit; those cells kept their cached values");
|
||||||
|
st.shared_copy_refused = true;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
st.shared_fallback_bytes = total;
|
||||||
|
Some(Formula::new(&sh.text))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"array" => {
|
"array" => {
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ use super::{
|
|||||||
NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr, from_file, xml,
|
NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr, from_file, xml,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet) {
|
/// Hyperlink cells and copied bytes one sheet has kept so far: every `<hyperlinks>` element of
|
||||||
let mut cells = 0u64;
|
/// the sheet writes into the same map, so the allowance is the sheet's, not the element's.
|
||||||
let mut bytes = 0u64;
|
#[derive(Default)]
|
||||||
|
pub(super) struct HyperlinkUse {
|
||||||
|
cells: u64,
|
||||||
|
bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel], sheet: &mut Sheet, used: &mut HyperlinkUse) {
|
||||||
|
let HyperlinkUse { cells, bytes } = used;
|
||||||
for h in e.kids("hyperlink") {
|
for h in e.kids("hyperlink") {
|
||||||
let Some(range) = h.attr("ref").and_then(RangeRef::parse) else { continue };
|
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 mut target = h.attr("id").and_then(|id| rels.iter().find(|r| r.id == id)).map(|r| r.target.clone()).unwrap_or_default();
|
||||||
@@ -24,7 +31,7 @@ pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel]
|
|||||||
let link = Hyperlink { target: target.clone(), tooltip: h.attr("tooltip").map(str::to_string), external };
|
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 link_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64;
|
||||||
for c in range.iter() {
|
for c in range.iter() {
|
||||||
if cells >= MAX_HYPERLINK_CELLS {
|
if *cells >= MAX_HYPERLINK_CELLS {
|
||||||
cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped"));
|
cx.warn(format!("sheet \"{name}\": hyperlinks past the first {MAX_HYPERLINK_CELLS} cells were dropped"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -32,8 +39,8 @@ pub(super) fn read_hyperlinks(cx: &mut Ctx<'_>, name: &str, e: &El, rels: &[Rel]
|
|||||||
cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped"));
|
cx.warn(format!("sheet \"{name}\": hyperlink targets past {MAX_HYPERLINK_BYTES} bytes were dropped"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cells += 1;
|
*cells += 1;
|
||||||
bytes += link_bytes;
|
*bytes += link_bytes;
|
||||||
sheet.hyperlinks.insert(c, link.clone());
|
sheet.hyperlinks.insert(c, link.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,11 @@ fn hyperlinks_past_the_cell_cap_are_reported() {
|
|||||||
let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap();
|
let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap();
|
||||||
assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 10_000);
|
assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 10_000);
|
||||||
assert!(rep.warnings.iter().any(|w| w.contains("hyperlinks past the first 10000 cells were dropped")), "{:?}", rep.warnings);
|
assert!(rep.warnings.iter().any(|w| w.contains("hyperlinks past the first 10000 cells were dropped")), "{:?}", rep.warnings);
|
||||||
|
// Several <hyperlinks> elements share the sheet's allowance.
|
||||||
|
let body = r#"<sheetData/><hyperlinks><hyperlink ref="A1:A6000" location="x"/></hyperlinks><hyperlinks><hyperlink ref="B1:B6000" location="x"/></hyperlinks>"#;
|
||||||
|
let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap();
|
||||||
|
assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 10_000);
|
||||||
|
assert!(rep.warnings.iter().any(|w| w.contains("hyperlinks past the first 10000 cells were dropped")), "{:?}", rep.warnings);
|
||||||
// A small list reads in full with no warning.
|
// A small list reads in full with no warning.
|
||||||
let body = r#"<sheetData/><hyperlinks><hyperlink ref="A1:A3" location="x"/></hyperlinks>"#;
|
let body = r#"<sheetData/><hyperlinks><hyperlink ref="A1:A3" location="x"/></hyperlinks>"#;
|
||||||
let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap();
|
let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap();
|
||||||
@@ -251,6 +256,42 @@ fn hyperlinks_past_the_cell_cap_are_reported() {
|
|||||||
assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
|
assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Followers of a shared master the parser refuses copy its text; a sheet full of them stops
|
||||||
|
/// copying at the limit and keeps the cached values instead.
|
||||||
|
#[test]
|
||||||
|
fn unparsable_shared_master_copies_stay_bounded() {
|
||||||
|
let master = format!("1{}", "+1".repeat(40_000));
|
||||||
|
let mut rows = format!(r#"<row r="1"><c r="A1"><f t="shared" ref="A1:A2000" si="0">{master}</f><v>1</v></c></row>"#);
|
||||||
|
for r in 2..=2000 {
|
||||||
|
rows.push_str(&format!(r#"<row r="{r}"><c r="A{r}"><f t="shared" si="0"/><v>{r}</v></c></row>"#));
|
||||||
|
}
|
||||||
|
let (wb, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
|
||||||
|
let s = wb.sheet(0).unwrap();
|
||||||
|
let copied: u64 = (2..=2000).filter(|r| s.cell(gridcraft_core::CellRef::new(r - 1, 0)).is_some_and(|c| c.formula.is_some())).count() as u64;
|
||||||
|
assert!(copied * master.len() as u64 <= 64 * 1024 * 1024, "{copied} copies");
|
||||||
|
assert!(copied < 1999, "{copied} copies");
|
||||||
|
assert_eq!(s.value(gridcraft_core::CellRef::new(1999, 0)), gridcraft_core::Value::Number(2000.0));
|
||||||
|
assert!(rep.warnings.iter().any(|w| w.contains("exceed the copy limit")), "{:?}", rep.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A follower dropped for the copy limit keeps its cached value even where an array range covers it.
|
||||||
|
#[test]
|
||||||
|
fn followers_past_the_copy_limit_keep_their_values_inside_array_ranges() {
|
||||||
|
let master = format!("1{}", "+1".repeat(40_000));
|
||||||
|
let mut rows = format!(
|
||||||
|
r#"<row r="1"><c r="B1"><f t="array" ref="B1:C2000">1</f><v>1</v></c><c r="C1"><f t="shared" ref="C1:C2000" si="0">{master}</f><v>1</v></c></row>"#
|
||||||
|
);
|
||||||
|
for r in 2..=2000 {
|
||||||
|
rows.push_str(&format!(r#"<row r="{r}"><c r="C{r}"><f t="shared" si="0"/><v>{r}</v></c></row>"#));
|
||||||
|
}
|
||||||
|
let (wb, rep) = read_xlsx(&minimal(&format!("<sheetData>{rows}</sheetData>"), &[], "", "")).unwrap();
|
||||||
|
let s = wb.sheet(0).unwrap();
|
||||||
|
let last = gridcraft_core::CellRef::new(1999, 2);
|
||||||
|
assert!(s.cell(last).is_some_and(|c| c.formula.is_none()), "the limit was reached");
|
||||||
|
assert_eq!(s.value(last), gridcraft_core::Value::Number(2000.0));
|
||||||
|
assert!(rep.warnings.iter().any(|w| w.contains("exceed the copy limit")), "{:?}", rep.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
/// Formula text built to overflow a recursive parser or tree walk (a million `%`, a 200,000-term
|
/// Formula text built to overflow a recursive parser or tree walk (a million `%`, a 200,000-term
|
||||||
/// sum): the cells keep their formula text and cached value, nothing overflows the stack.
|
/// sum): the cells keep their formula text and cached value, nothing overflows the stack.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user