mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-10 04:19:48 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee59d04d3 | ||
|
|
ee5b3fadde |
@@ -254,27 +254,36 @@ pub(crate) fn sheet_names_in(formula: &str) -> Vec<String> {
|
||||
/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets
|
||||
/// that exist under the same names (the chart is kept as it was, so a series on a deleted or
|
||||
/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name.
|
||||
fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool {
|
||||
///
|
||||
/// `usable` caches, by relationship id, whether that chart sheet's graph refers only to sheets
|
||||
/// that exist (`false` when the relationship is missing): entries sharing one graph are analysed
|
||||
/// once, and only the verdict is kept, not the references it came from.
|
||||
fn chart_sheet_usable<'p>(wb: &Workbook, p: &'p Preserved, s: &'p PreservedSheet, usable: &mut HashMap<&'p str, bool>) -> bool {
|
||||
let same = |a: &str, b: &str| a.chars().take(31).collect::<String>().eq_ignore_ascii_case(&b.chars().take(31).collect::<String>());
|
||||
if wb.sheets.iter().any(|w| same(&w.name, &s.name)) {
|
||||
return false;
|
||||
}
|
||||
let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false };
|
||||
let parts = reachable(p, root);
|
||||
let mut formulas = vec![];
|
||||
for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) {
|
||||
if let Ok(x) = xml::parse(&q.data) {
|
||||
chart_formulas(&x, &mut formulas);
|
||||
*usable.entry(s.rel_id.as_str()).or_insert_with(|| {
|
||||
let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false };
|
||||
let parts = reachable(p, root);
|
||||
let mut formulas = vec![];
|
||||
for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) {
|
||||
if let Ok(x) = xml::parse(&q.data) {
|
||||
chart_formulas(&x, &mut formulas);
|
||||
}
|
||||
}
|
||||
}
|
||||
formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n)))
|
||||
formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back
|
||||
/// (see [`chart_sheet_usable`]) are left out with the parts only they reach.
|
||||
pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept {
|
||||
let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() };
|
||||
let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s));
|
||||
let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = {
|
||||
let mut refs = HashMap::new();
|
||||
p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s, &mut refs))
|
||||
};
|
||||
let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id);
|
||||
let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect();
|
||||
// Parts reached only by dropped chart sheets go with them.
|
||||
|
||||
@@ -23,8 +23,8 @@ use crate::xml::{self, El};
|
||||
mod parts;
|
||||
mod rules;
|
||||
|
||||
use parts::{HyperlinkUse, read_comments, read_hyperlinks, read_note_shapes, read_threaded};
|
||||
pub use parts::{read_autofilter, read_table};
|
||||
use parts::{read_comments, read_hyperlinks, read_note_shapes, read_threaded};
|
||||
use rules::{read_cf, read_validation};
|
||||
|
||||
struct Shared {
|
||||
@@ -44,12 +44,6 @@ struct RowState {
|
||||
dynamic_cells: u64,
|
||||
/// Row-membership checks served so far, for the work cap above.
|
||||
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,
|
||||
rows: u64,
|
||||
}
|
||||
@@ -144,9 +138,6 @@ const MAX_DYNAMIC_CHECKS: u64 = 16_000_000;
|
||||
/// 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.
|
||||
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) {
|
||||
if let Some(h) = e.attr_f64("defaultRowHeight") {
|
||||
@@ -190,7 +181,6 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
|
||||
let mut fit_to_page = false;
|
||||
let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS;
|
||||
let mut hyperlinks_used = HyperlinkUse::default();
|
||||
cx.cf_ext_ids.clear();
|
||||
for e in &root.children {
|
||||
match e.name.as_str() {
|
||||
@@ -224,7 +214,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
||||
}
|
||||
}
|
||||
}
|
||||
"hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet, &mut hyperlinks_used),
|
||||
"hyperlinks" => read_hyperlinks(cx, name, e, &rels, &mut sheet),
|
||||
"autoFilter" => sheet.autofilter = read_autofilter(cx, e),
|
||||
"printOptions" => {
|
||||
sheet.print.gridlines = e.flag("gridLines", false);
|
||||
@@ -578,7 +568,6 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
|
||||
return;
|
||||
}
|
||||
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));
|
||||
// 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
|
||||
@@ -599,7 +588,7 @@ fn read_cell(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, c: &El, pos
|
||||
if py.is_some() {
|
||||
record_python_spill(st, sheet, pos);
|
||||
}
|
||||
let spilled_from = if formula.is_none() && py.is_none() && !st.shared_copy_refused { dynamic_range(st, pos) } else { None };
|
||||
let spilled_from = if formula.is_none() && py.is_none() { 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 value = match spilled_from {
|
||||
// A Python cell's spilled values are kept: they are its result until it runs again.
|
||||
@@ -646,23 +635,14 @@ 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");
|
||||
return None;
|
||||
};
|
||||
match &sh.expr {
|
||||
Some(match &sh.expr {
|
||||
Some(e) => {
|
||||
let dr = pos.row as i64 - sh.anchor.row as i64;
|
||||
let dc = pos.col as i64 - sh.anchor.col as i64;
|
||||
Some(Formula::from_expr(gridcraft_formula::adjust::shift_relative(e.clone(), dr, dc)))
|
||||
Formula::from_expr(gridcraft_formula::adjust::shift_relative(e.clone(), dr, dc))
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
None => Formula::new(&sh.text),
|
||||
})
|
||||
}
|
||||
}
|
||||
"array" => {
|
||||
|
||||
@@ -3,16 +3,9 @@ use super::{
|
||||
NoteBox, NoteCorner, RangeRef, Rel, Sheet, Table, TableColumn, TotalsFn, XmlColumnPr, from_file, xml,
|
||||
};
|
||||
|
||||
/// Hyperlink cells and copied bytes one sheet has kept so far: every `<hyperlinks>` element of
|
||||
/// the sheet writes into the same map, so the allowance is the sheet's, not the element's.
|
||||
#[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;
|
||||
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;
|
||||
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();
|
||||
@@ -31,7 +24,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_bytes = (target.len() + link.tooltip.as_ref().map_or(0, |t| t.len())) as u64;
|
||||
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"));
|
||||
return;
|
||||
}
|
||||
@@ -39,8 +32,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"));
|
||||
return;
|
||||
}
|
||||
*cells += 1;
|
||||
*bytes += link_bytes;
|
||||
cells += 1;
|
||||
bytes += link_bytes;
|
||||
sheet.hyperlinks.insert(c, link.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,11 +244,6 @@ fn hyperlinks_past_the_cell_cap_are_reported() {
|
||||
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);
|
||||
// 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.
|
||||
let body = r#"<sheetData/><hyperlinks><hyperlink ref="A1:A3" location="x"/></hyperlinks>"#;
|
||||
let (wb, rep) = read_xlsx(&minimal(body, &parts, rels, "")).unwrap();
|
||||
@@ -256,42 +251,6 @@ fn hyperlinks_past_the_cell_cap_are_reported() {
|
||||
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
|
||||
/// sum): the cells keep their formula text and cached value, nothing overflows the stack.
|
||||
#[test]
|
||||
|
||||
@@ -696,6 +696,32 @@ fn chartsheet_package() -> Vec<u8> {
|
||||
])
|
||||
}
|
||||
|
||||
/// Thousands of chart-sheet entries naming one chart sheet's relationship: its graph is walked
|
||||
/// and its charts parsed once when saving, not once per entry.
|
||||
#[test]
|
||||
fn chart_sheet_entries_sharing_a_graph_are_checked_once() {
|
||||
let bytes = chartsheet_package();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut parts: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
parts.push((f.name().to_string(), data));
|
||||
}
|
||||
let extra: String = (0..3_000).map(|i| format!(r#"<sheet name="Copy{i}" sheetId="{}" r:id="rId2"/>"#, 100 + i)).collect();
|
||||
for (name, data) in &mut parts {
|
||||
if name == "xl/workbook.xml" {
|
||||
*data = String::from_utf8(data.clone()).unwrap().replace("</sheets>", &format!("{extra}</sheets>")).into_bytes();
|
||||
}
|
||||
}
|
||||
let refs: Vec<(&str, &[u8])> = parts.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect();
|
||||
let (wb, _) = read_xlsx(&make_zip(&refs)).unwrap();
|
||||
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
|
||||
u.assert_valid();
|
||||
assert!(written_sheets(&u).iter().any(|(n, _)| n == "Chart1"));
|
||||
}
|
||||
|
||||
/// The sheets of a written workbook: (name, state) in file order.
|
||||
fn written_sheets(u: &Unpacked) -> Vec<(String, String)> {
|
||||
let wb = xml::parse(u.parts.get("xl/workbook.xml").unwrap()).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user