Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 6d9799fe10 xlsx: find a reused picture through a content hash instead of comparing every media part
Out::image_part compared each new picture byte by byte with every
media part written so far, so a workbook of many distinct pictures
sharing a long prefix was exported in quadratic time. Media parts are
now indexed by extension and SHA-256.
2026-11-03 04:41:44 +00:00
7 changed files with 44 additions and 105 deletions
+10 -31
View File
@@ -31,41 +31,20 @@ fn is_volatile(e: &Expr, volatile_names: &HashSet<String>) -> bool {
}
/// Upper-cased defined names whose formula is volatile, directly or through other names.
///
/// Volatility flows backwards along name references: each name is parsed and walked once, and a
/// worklist carries volatility from a name to the names that use it. Rescanning every name until
/// nothing changes was quadratic: a file listing its names so that each pass marked only one more
/// did one full pass per name.
pub(super) fn volatile_names(wb: &Workbook) -> HashSet<String> {
let parsed: Vec<(String, Option<Expr>)> =
wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect();
let mut set = HashSet::new();
// Upper-cased name -> the names whose formulas refer to it.
let mut users: HashMap<String, Vec<String>> = HashMap::new();
let mut queue = Vec::new();
let none = HashSet::new();
for n in &wb.names {
let name = n.name.to_ascii_uppercase();
let Ok(e) = gridcraft_engine::formula::parse(&n.formula) else { continue };
if is_volatile(&e, &none) {
if set.insert(name.clone()) {
queue.push(name.clone());
// Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point.
for _ in 0..=parsed.len() {
let before = set.len();
for (name, e) in &parsed {
if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) {
set.insert(name.clone());
}
continue;
}
let mut used = BTreeSet::new();
e.walk(&mut |x| {
if let Expr::Name(r) = x {
used.insert(r.to_ascii_uppercase());
}
});
for r in used {
users.entry(r).or_default().push(name.clone());
}
}
while let Some(v) = queue.pop() {
for user in users.remove(&v).unwrap_or_default() {
if set.insert(user.clone()) {
queue.push(user);
}
if set.len() == before {
break;
}
}
set
-22
View File
@@ -178,28 +178,6 @@ fn volatile_names_propagate() {
assert_eq!(check(&wb, &Options::default()).calc.volatile, 1);
}
#[test]
fn volatile_names_propagate_through_a_long_chain_in_one_walk() {
// Chain0 uses Chain1, Chain1 uses Chain2, ... and only the last is volatile: listed in this order, a rescan
// until nothing changes marked one name per pass (quadratic). Every name in the chain is
// volatile; a name off the chain is not.
const LEN: usize = 5_000;
let mut wb = book(&[("A1", "=1", n(1.0))]);
let name =
|name: String, formula: String| gridcraft_engine::model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
for i in 0..LEN {
let formula = if i + 1 == LEN { "NOW()".to_string() } else { format!("Chain{}+1", i + 1) };
wb.names.push(name(format!("Chain{i}"), formula));
}
wb.names.push(name("Plain".into(), "Chain0*0+Other".into()));
wb.names.push(name("Other".into(), "1".into()));
let set = volatile_names(&wb);
assert!((0..LEN).all(|i| set.contains(&format!("CHAIN{i}"))));
assert!(set.contains("PLAIN"));
assert!(!set.contains("OTHER"));
assert_eq!(set.len(), LEN + 1);
}
#[test]
fn round_trip_of_a_simple_book_is_clean() {
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
+3 -7
View File
@@ -459,11 +459,8 @@ pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
/// formula), or that unlocked it, whichever command made it. Sheets are matched by name, so
/// moving, renaming or deleting a sheet isn't an edit of its cells. A sort or duplicate removal
/// that moves an unlocked cell with equal content onto a locked one changes no content but would
/// leave the cell editable, so the effective lock (row and column formats included) is compared
/// as well.
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
/// deleting a sheet isn't an edit of its cells.
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
for sh in &old.sheets {
if !sh.is_protected() {
@@ -474,8 +471,7 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
continue;
}
for c in sh.cells.diff(&after.cells) {
let unlocked_now = !new.styles.get(after.style_id(c)).protection.locked;
if (unlocked_now || !same_content(sh.cell(c), after.cell(c)))
if !same_content(sh.cell(c), after.cell(c))
&& old.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, unlocked)
{
+4 -4
View File
@@ -398,10 +398,10 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
{
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
}
// The checks must authorize the sheet this command modifies, not whatever sheet is active:
// both get the index `target_sheet` resolved (from `sheet`, or a `Sheet!` range or cell).
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": sheet}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": sheet}), Axis::Cols, from, count, false)?;
// The checks must authorize the sheet this command modifies (`target_sheet`), not whatever
// sheet is active: both carry the command's own `sheet` parameter on.
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?;
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?;
let moved_from = if before <= from { from + count } else { from };
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
-36
View File
@@ -5297,42 +5297,6 @@ fn move_columns_checks_the_protection_of_the_named_sheet() {
assert_eq!(v(&s, "A1"), Value::text("A"));
}
#[test]
fn move_columns_checks_the_protection_of_a_sheet_named_by_its_range() {
// A `Sheet!` range or cell picks the sheet the move modifies; the protection checks must
// authorize that sheet too, not the active one.
let mut s = s();
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
s.execute("review.protectSheet", json!({})).unwrap();
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
for key in ["range", "cell"] {
let err = s.execute("sheet.moveColumns", json!({key: "P!A1", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
assert!(err.to_string().contains("protected"), "{err}");
}
let d = s.doc().unwrap();
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
let pv: Vec<Value> = (0..4).map(|c| sh.value(CellRef::new(0, c))).collect();
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
}
#[test]
fn sorting_cannot_unlock_a_locked_cell_by_moving_equal_content_onto_it() {
// An unlocked 2 sorted onto a locked 2 leaves the content as it was, but would make the cell
// editable; the sort is refused instead.
let mut s = s();
s.execute("range.setValues", json!({"range": "A1", "values": [[2], [2], [1]]})).unwrap();
s.execute("home.lockCell", json!({"range": "A1", "on": false})).unwrap();
s.execute("home.lockCell", json!({"range": "A3", "on": false})).unwrap();
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
let before = s.doc().unwrap().wb.clone();
let err =
s.execute("data.sort", json!({"range": "A1:A3", "header": false, "keys": [{"column": "A", "order": "asc"}]})).expect_err("sort unlocking A2");
assert!(err.to_string().contains("protected sheet"), "{err}");
assert_eq!(*s.doc().unwrap().wb, *before);
assert!(s.execute("cell.set", json!({"cell": "A2", "input": "9"})).is_err(), "A2 stays locked");
}
#[test]
fn a_script_cannot_switch_windows() {
// Stored scripts are scoped to their workbook: a step that activates another window would
+18
View File
@@ -85,3 +85,21 @@ fn different_pictures_with_the_same_length_stay_separate() {
let back = read_xlsx(&bytes).unwrap().0;
assert_eq!(back.sheets[0].images.iter().map(|i| i.data.to_vec()).collect::<Vec<_>>(), vec![a, b]);
}
/// Many distinct pictures that share a long prefix: each is matched against the earlier ones
/// through a content hash, not by comparing bytes with every one of them.
#[test]
fn many_distinct_pictures_with_a_common_prefix_are_each_written_once() {
let pictures: Vec<Vec<u8>> = (0..400u32)
.map(|i| {
let mut v = b"\x89PNG\r\n\x1a\n".to_vec();
v.extend(std::iter::repeat_n(7u8, 65_536));
v.extend(i.to_le_bytes());
v
})
.collect();
let refs: Vec<(u32, &[u8])> = pictures.iter().enumerate().map(|(i, p)| (i as u32 + 1, p.as_slice())).collect();
let wb = Workbook { sheets: vec![sheet_with("One", &refs), sheet_with("Two", &refs)], ..Default::default() };
let bytes = write_xlsx(&wb).unwrap();
assert_eq!(names(&bytes).iter().filter(|n| n.starts_with("xl/media/")).count(), 400);
}
+9 -5
View File
@@ -6,6 +6,7 @@ use std::fmt::Write as _;
use gridcraft_core::RangeRef;
use gridcraft_formula::quote_sheet;
use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook, WorkbookView};
use sha2::Digest as _;
use crate::package::ZipOut;
use crate::xml::{esc, esc_attr};
@@ -112,6 +113,9 @@ pub struct Out {
pub dxfs: Vec<Style>,
pub sst: Sst,
pub images: u32,
/// Media parts written so far, by extension and SHA-256 of their bytes: a picture used again
/// is found here instead of being compared byte by byte with every earlier one.
media: HashMap<(String, [u8; 32]), String>,
pub charts: u32,
/// The workbook's file name, which a PivotChart's link to its PivotTable names in brackets.
pub book_name: String,
@@ -142,15 +146,14 @@ impl Out {
/// The `xl/media` file name (`imageN.ext`) holding `data`. A picture used on several sheets,
/// or twice on one, is written once and every drawing points to that part, as in Excel.
pub fn image_part(&mut self, ext: &str, data: &[u8]) -> String {
let existing = self.parts.iter().find(|(name, bytes)| {
name.strip_prefix("xl/media/image").is_some_and(|rest| rest.ends_with(&format!(".{ext}"))) && bytes.as_slice() == data
});
if let Some((name, _)) = existing {
return name.trim_start_matches("xl/media/").to_string();
let key = (ext.to_string(), sha2::Sha256::digest(data).into());
if let Some(file) = self.media.get(&key) {
return file.clone();
}
self.images += 1;
let file = format!("image{}.{ext}", self.images);
self.part(&format!("xl/media/{file}"), None, data.to_vec());
self.media.insert(key, file.clone());
file
}
pub fn default_type(&mut self, ext: &str, ct: &str) {
@@ -249,6 +252,7 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
dxfs: vec![],
sst: Sst::default(),
images: 0,
media: HashMap::new(),
charts: 0,
book_name: book_name.filter(|n| !n.is_empty() && !n.contains(['[', ']'])).unwrap_or(DEFAULT_BOOK_NAME).to_string(),
chart_names: vec![],