Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 8878b8be6f xlsx: repair repeated sheet names through a set of assigned names
Importing a workbook whose sheets share a name rescanned every sheet for
every suffix tried, so a file of repeated names opened in cubic time.
SheetNames keeps the assigned names and the next suffix per stem.
2026-11-03 04:21:28 +00:00
8 changed files with 71 additions and 109 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
+1 -1
View File
@@ -317,7 +317,7 @@ impl Import {
return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into()));
}
let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default();
let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len());
let mut name = crate::read::SheetNames::of(&self.wb).unique(original.trim_matches('\''), self.wb.sheets.len());
if self.wb.check_sheet_name(&name, None).is_err() {
name = self.wb.next_sheet_name();
}
+39 -8
View File
@@ -1,5 +1,6 @@
//! Reading the workbook part and everything hanging off it.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use gridcraft_core::{DateSystem, RangeRef};
@@ -304,6 +305,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
let mut file_to_model: Vec<Option<usize>> = vec![];
let mut pivot_caches = crate::pivot::CachesIn::new(&wb_xml, &wb_rels);
let sheet_els: Vec<El> = wb_xml.child("sheets").map(|s| s.kids("sheet").cloned().collect()).unwrap_or_default();
let mut names = SheetNames::default();
for (i, se) in sheet_els.iter().enumerate() {
let name = se.attr("name").unwrap_or("").to_string();
let rel = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).cloned();
@@ -322,7 +324,7 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
file_to_model.push(None);
continue;
}
let name = unique_name(&wb, &name, i);
let name = names.unique(&name, i);
let mut sheet = match crate::sheet_read::read_sheet(&mut cx, &rel.target, &name) {
Ok(s) => s,
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
@@ -455,15 +457,44 @@ pub fn read_xlsx<'a>(bytes: &'a [u8], known_function: Option<&'a dyn Fn(&str) ->
Ok((wb, cx.report))
}
pub(crate) fn unique_name(wb: &Workbook, name: &str, i: usize) -> String {
let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
if base.trim().is_empty() {
base = format!("Sheet{}", i + 1);
/// The sheet names an import has assigned so far, for repairing repeated ones.
#[derive(Default)]
pub(crate) struct SheetNames {
/// Assigned names, ASCII-lowercased.
taken: HashSet<String>,
/// For each lowercased stem, the next suffix to try: a file full of repeated names would
/// otherwise retry every occupied suffix for every sheet.
next: HashMap<String, u64>,
}
impl SheetNames {
pub(crate) fn of(wb: &Workbook) -> Self {
SheetNames { taken: wb.sheets.iter().map(|s| s.name.to_ascii_lowercase()).collect(), next: HashMap::new() }
}
if wb.sheet_index(&base).is_none() {
return base;
/// A valid name for the i-th sheet, unique among the names assigned so far, which it joins.
pub(crate) fn unique(&mut self, name: &str, i: usize) -> String {
let mut base: String = name.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
if base.trim().is_empty() {
base = format!("Sheet{}", i + 1);
}
let name = if self.taken.contains(&base.to_ascii_lowercase()) {
let stem: String = base.chars().take(25).collect();
let next = self.next.entry(stem.to_ascii_lowercase()).or_insert(2);
let found = (*next..).find(|n| !self.taken.contains(&format!("{stem} ({n})").to_ascii_lowercase()));
match found {
Some(n) => {
*next = n + 1;
format!("{stem} ({n})")
}
None => base,
}
} else {
base
};
self.taken.insert(name.to_ascii_lowercase());
name
}
(2..).map(|n| format!("{} ({n})", base.chars().take(25).collect::<String>())).find(|n| wb.sheet_index(n).is_none()).unwrap_or(base)
}
/// Strips a sheet qualifier (`'My Sheet'!$A$1` → `$A$1`).
+14
View File
@@ -35,3 +35,17 @@ fn sheet_ids_survive_a_save() {
let (again, _) = crate::read_xlsx(&write_xlsx(&read).unwrap()).unwrap();
assert_eq!(again.sheets.iter().map(|s| s.file_id).collect::<Vec<_>>(), ids);
}
/// Repairing repeated names takes one set lookup per candidate, so thousands of sheets sharing
/// a name are renamed without rescanning the workbook for every suffix tried.
#[test]
fn repeated_sheet_names_are_repaired_without_rescans() {
let mut names = crate::read::SheetNames::default();
assert_eq!(names.unique("Data", 0), "Data");
assert_eq!(names.unique("data", 1), "data (2)");
assert_eq!(names.unique("", 2), "Sheet3");
assert_eq!(names.unique("Data (3)", 3), "Data (3)");
for i in 4..100_000 {
assert_eq!(names.unique("Data", i), format!("Data ({i})"));
}
}