XLSX: a Python script's length is measured after its escapes are decoded

A script's code was cut at the character limit before its ST_Xstring escapes
were decoded, so a script whose escapes (`_x0009_`, `_x000D_`) made the file's
text longer than the limit lost its end without a warning, although its code
fits. The code is now decoded first and then cut one character past the limit,
so a script that is too long once decoded still fails the check and its cell
keeps only its value, as before. Decoding is linear and reads at most a bounded
number of encoded characters, enough for any code that fits.
This commit is contained in:
prxnvt committed 2026-11-01 12:58:13 -05:00
1 parent e82e91b451
commit 84d4f6aa84
2 files changed
+35 -3

No files matched your search

+19
View File
@@ -317,6 +317,25 @@ fn a_python_cell_without_its_code_keeps_its_whole_cached_result_as_values() {
}
}
#[test]
fn a_script_is_measured_after_its_escapes_are_decoded() {
let max = gridcraft_model::python::MAX_FORMULA_CHARS;
// 2,000 tabs written as `_x0009_`: 14,000 characters in the file, 2,004 as code, which fits.
let escaped = format!("x = 1{}end", "_x0009_".repeat(2_000));
assert!(escaped.chars().count() > max);
// Code longer than a cell may hold once decoded is still refused.
let long = format!("{}{}", "_x0009_".repeat(10), "y".repeat(max));
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>1</v></c><c r=\"B1\"><f>_xlfn._xlws.PY(1,0)</f><v>2</v></c></row></sheetData>";
let (wb, warnings) = crate::io::open_bytes("escapes.xlsx", &excel_shaped(data, Some(&[&escaped, &long]))).unwrap();
let sh = wb.sheet(0).unwrap();
let a1 = sh.cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.clone());
assert_eq!(a1, Some(format!("x = 1{}end", "\t".repeat(2_000))), "read in full");
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
assert!(b1.py.is_none(), "too long once decoded: only the value");
assert_eq!(b1.value, Value::Number(2.0));
assert!(warnings.iter().any(|w| w.contains("B1")), "{warnings:?}");
}
#[test]
fn many_cells_naming_one_long_script_take_from_one_budget() {
// 20,000 cells name one script of 8,000 characters: 160 MB of code if each took a copy.
+16 -3
View File
@@ -111,15 +111,28 @@ pub fn read(cx: &mut Ctx<'_>, rels: &[crate::package::Rel]) -> Result<ScriptsIn,
Ok(out)
}
/// Encoded characters of a script that are decoded: enough for its first [`MAX_SCRIPT_CHARS`]
/// characters of code, whatever its escapes. One character takes at most 14 encoded characters
/// (a UTF-16 pair of `_xHHHH_` escapes) and a `\r\n` line break becomes one character, so
/// `2 × (MAX_SCRIPT_CHARS + 1)` decoded characters cover the code kept, and two more escapes'
/// worth keep the last of them, and the line break after it, from being cut.
const MAX_ENCODED_CHARS: usize = 14 * (2 * (MAX_SCRIPT_CHARS + 1) + 2);
/// A `pythonScript` element's code (ST_Xstring), with `\n` line breaks, cut after
/// [`MAX_SCRIPT_CHARS`] characters.
/// [`MAX_SCRIPT_CHARS`] characters. The limit is measured on the decoded code, so escapes in the
/// file (`_x000D_`, `_x0009_`) don't cut a script that fits; decoding is linear and reads at most
/// [`MAX_ENCODED_CHARS`] characters.
fn script_code(el: &El) -> String {
let text = el.child("code").map(|c| c.text.as_str()).unwrap_or_default();
let text = match text.char_indices().nth(MAX_SCRIPT_CHARS) {
let encoded = match text.char_indices().nth(MAX_ENCODED_CHARS) {
Some((at, _)) => text.get(..at).unwrap_or_default(),
None => text,
};
gridcraft_model::python::normalize_code(&xml::decode_xstring(text))
let code = gridcraft_model::python::normalize_code(&xml::decode_xstring(encoded));
match code.char_indices().nth(MAX_SCRIPT_CHARS) {
Some((at, _)) => code.get(..at).unwrap_or_default().to_string(),
None => code,
}
}
/// `PY(index, 0|1, params…)`, as a file's formula holds it: the index, whether the return type is