//! Python cells in files: native JSON, and xlsx as `_xlfn._xlws.PY(index,0|1)` with the code in //! the Python part ([MS-XLSX] 2.1.23, 2.3.10) and cached values. Nothing here runs Python: results //! are cached ones, as a file would hold them. use std::io::{Read, Write}; use std::sync::Arc; use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_model::{Cell, PyCode, PyOutput, Workbook}; use serde_json::json; use super::{s, v}; use crate::{Session, Trust}; fn py(s: &mut Session, cell: &str, code: &str) { s.execute("python.set", json!({"cell": cell, "code": code})).unwrap(); } /// Gives a Python cell a result, as running it (or a file's cache) would. fn cache(s: &mut Session, cell: &str, value: Value, type_name: &str) { let d = s.doc_mut().unwrap(); let wb = Arc::make_mut(&mut d.wb); let sh = wb.sheet_mut(0).unwrap(); let c = sh.cells.get_mut(CellRef::parse(cell).unwrap()).unwrap(); let p = Arc::make_mut(c.py.as_mut().unwrap()); p.output = Some(PyOutput { value, type_name: type_name.into(), preview: String::new(), convertible: true }); } fn array(rows: usize, cols: usize, data: Vec) -> Value { Value::Array(Arc::new(Array::new(rows, cols, data).unwrap())) } fn save(s: &mut Session, format: &str) -> Vec { let r = s.execute("file.saveBytes", json!({"format": format})).unwrap(); crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap() } fn open(s: &mut Session, name: &str, bytes: &[u8]) { s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap(); } fn part(bytes: &[u8], name: &str) -> String { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut xml = String::new(); z.by_name(name).unwrap().read_to_string(&mut xml).unwrap(); xml } fn sheet_xml(bytes: &[u8]) -> String { part(bytes, "xl/worksheets/sheet1.xml") } /// The package with `edit` applied to each XML part (by name), and `add` added. fn with_parts(bytes: &[u8], edit: impl Fn(&str, &str) -> String, add: &[(&str, &str)]) -> Vec { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for i in 0..z.len() { let mut f = z.by_index(i).unwrap(); let name = f.name().to_string(); let mut data = Vec::new(); f.read_to_end(&mut data).unwrap(); if let Ok(text) = std::str::from_utf8(&data) { data = edit(&name, text).into_bytes(); } out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(&data).unwrap(); } for (name, text) in add { out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); out.write_all(text.as_bytes()).unwrap(); } out.finish().unwrap().into_inner() } /// The package with `edit` applied to the first sheet's XML. fn with_sheet_xml(bytes: &[u8], edit: impl Fn(&str) -> String) -> Vec { with_parts(bytes, |name, text| if name == "xl/worksheets/sheet1.xml" { edit(text) } else { text.to_string() }, &[]) } /// `xml` with its `` replaced by `data`. fn replace_sheet_data(xml: &str, data: &str) -> String { match (xml.find(""), xml.find("")) { (Some(a), Some(b), _) => format!("{}{data}{}", &xml[..a], &xml[b + "".len()..]), (_, _, Some(a)) => format!("{}{data}{}", &xml[..a], &xml[a + "".len()..]), _ => panic!("no sheetData in {xml}"), } } /// An empty workbook's package with `data` as its first sheet's cells and, when `scripts` is /// given, a Python part holding them (the relationship and the content type included), as /// [MS-XLSX] 2.1.23 describes it. fn excel_shaped(data: &str, scripts: Option<&[&str]>) -> Vec { let mut s = s(); let plain = save(&mut s, "xlsx"); let Some(scripts) = scripts else { return with_sheet_xml(&plain, |xml| replace_sheet_data(xml, data)) }; let codes: String = scripts.iter().map(|c| format!("{c}")).collect(); let python = format!( "\n{codes}" ); const REL: &str = ""; const TYPE: &str = ""; let edit = |name: &str, text: &str| match name { "xl/worksheets/sheet1.xml" => replace_sheet_data(text, data), "xl/_rels/workbook.xml.rels" => text.replace("", &format!("{REL}")), "[Content_Types].xml" => text.replace("", &format!("{TYPE}")), _ => text.to_string(), }; with_parts(&plain, edit, &[("xl/python/python1.xml", &python)]) } fn code(s: &Session, cell: &str) -> Option { s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_deref()).cloned() } /// A workbook with a value, a formula over a Python cell, a spilling table, an object, long code /// with quotes and line breaks, and an error. fn build() -> (Session, String) { let mut s = s(); s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap(); py(&mut s, "B1", "xl('A1') * 2"); cache(&mut s, "B1", Value::Number(42.0), "int"); s.execute("cell.set", json!({"cell": "A5", "input": "=B1+1"})).unwrap(); py(&mut s, "C1", "[[1, 'two'], [3, \"four\"]]"); cache(&mut s, "C1", array(2, 2, vec![1.0.into(), Value::text("two"), 3.0.into(), Value::text("four")]), "list"); s.execute("python.set", json!({"cell": "E1", "code": "[1, 2]", "output": "object"})).unwrap(); cache(&mut s, "E1", array(2, 1, vec![1.0.into(), 2.0.into()]), "list"); let long = format!("# {}\ntext = \"say \\\"hi\\\"\"\nlen(text)", "x".repeat(7_900)); assert!(PyCode::new(&long).fits()); py(&mut s, "F1", &long); cache(&mut s, "F1", Value::Number(8.0), "int"); py(&mut s, "G1", "1/0"); { let d = s.doc_mut().unwrap(); let sh = Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap(); sh.cells.get_mut(CellRef::parse("G1").unwrap()).unwrap().value = Value::Error(CellError::Python); } s.execute("formulas.calculateNow", json!({})).unwrap(); assert_eq!(v(&s, "B1"), Value::Number(42.0)); assert_eq!(v(&s, "A5"), Value::Number(43.0)); assert_eq!(v(&s, "D2"), Value::text("four")); assert_eq!(v(&s, "E1"), Value::text("list"), "a Python object shows its type"); assert_eq!(v(&s, "E2"), Value::Empty, "and doesn't spill"); (s, long) } #[test] fn xlsx_stores_the_code_in_the_python_part_with_cached_values() { let (mut s, long) = build(); let bytes = save(&mut s, "xlsx"); let xml = sheet_xml(&bytes); // The formula names the cell's script by index, in the order the cells are written: B1, C1, // E1, F1, G1. assert!(xml.contains("_xlfn._xlws.PY(0,0)42"), "{xml}"); assert!(xml.contains(">_xlfn._xlws.PY(2,1)"), "the Python object cell has return type 1: {xml}"); assert!(!xml.contains("xl('A1')"), "the code is not in the sheet"); let python = part(&bytes, "xl/python.xml"); assert!( python.contains("xl('A1') * 2"), "{python}" ); assert_eq!(python.matches("").count(), 5); assert!( part(&bytes, "[Content_Types].xml").contains("") ); assert!( part(&bytes, "xl/_rels/workbook.xml.rels") .contains("Type=\"http://schemas.microsoft.com/office/2023/09/relationships/Python\" Target=\"python.xml\"") ); assert!(xml.contains("t=\"array\" ref=\"C1:D2\""), "the spill is saved like a dynamic array's: {xml}"); assert!(xml.contains("#VALUE!") && !xml.contains("#PYTHON!"), "Python errors are cached as #VALUE!"); open(&mut s, "book.xlsx", &bytes); assert_eq!(s.doc().unwrap().trust, Trust::FromFile); // Cached values show, spills included, and the code is back. assert_eq!(v(&s, "B1"), Value::Number(42.0)); assert_eq!(v(&s, "A5"), Value::Number(43.0)); assert_eq!( (v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")), (Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four")) ); assert_eq!(code(&s, "B1").unwrap().code, "xl('A1') * 2"); assert!(code(&s, "E1").unwrap().object); assert_eq!(code(&s, "F1").unwrap().code, long, "long code with quotes and line breaks survives"); assert_eq!(v(&s, "G1"), Value::Error(CellError::Value)); // A blocker typed into the spill area gives #SPILL!; clearing it spills again, without // running anything. s.execute("cell.set", json!({"cell": "D2", "input": "x"})).unwrap(); assert_eq!(v(&s, "C1"), Value::Error(CellError::Spill)); s.execute("edit.clearContents", json!({"range": "D2"})).unwrap(); assert_eq!(v(&s, "D2"), Value::text("four")); // Saved again, the file holds the same formulas and scripts. let again = save(&mut s, "xlsx"); assert!(sheet_xml(&again).contains("_xlfn._xlws.PY(0,0)")); assert_eq!(part(&again, "xl/python.xml"), python); // A workbook without Python cells has no Python part. let mut plain = super::s(); assert!(!save(&mut plain, "xlsx").windows(10).any(|w| w == b"python.xml")); } #[test] fn excel_authored_python_cells_are_read_as_python() { // A workbook as [MS-XLSX] 2.3.10 describes it: `_xlfn._xlws.PY(index,0|1,params…)` naming a // script of the Python part, whose code names the parameters as `%Px%`, with a spilled result // cached. let data = "_xlfn._xlws.PY(0,0)1\ _xlfn._xlws.PY(1,1,C1:C3)DataFrame\ 2"; let bytes = excel_shaped(data, Some(&["[1, 2]", "df = xl(\"%P2%\", headers=True)\ndf.describe()"])); let mut s = s(); open(&mut s, "excel.xlsx", &bytes); let a1 = code(&s, "A1").expect("an Excel Python cell is a Python cell"); assert_eq!(a1.code, "[1, 2]"); assert!(!a1.object); assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Number(1.0), Value::Number(2.0)), "its spilled values are kept"); let cell = s.doc().unwrap().wb.active().unwrap().cell(CellRef::new(0, 0)).unwrap().clone(); assert!(cell.formula.is_none()); let b1 = code(&s, "B1").unwrap(); assert_eq!(b1.code, "df = xl(\"C1:C3\", headers=True)\ndf.describe()", "a parameter is put back as its reference"); assert!(b1.object); assert_eq!(v(&s, "B1"), Value::text("DataFrame")); // Saved by GridCraft, the code goes back into a Python part, the reference as text. let again = save(&mut s, "xlsx"); assert!(part(&again, "xl/python.xml").contains("df = xl(\"C1:C3\", headers=True)\ndf.describe()")); // The string-literal form (other applications, files GridCraft wrote before) still reads. let literal = "_xlfn._xlws.PY(\"1 + 1\",1)2"; open(&mut s, "literal.xlsx", &excel_shaped(literal, None)); assert_eq!(code(&s, "A1").map(|p| (p.code, p.object)), Some(("1 + 1".to_string(), true))); } #[test] fn a_py_formula_naming_a_missing_script_keeps_only_its_value() { let data = "_xlfn._xlws.PY(0,0)5_xlfn._xlws.PY(3,0)6"; for (name, bytes) in [("one.xlsx", excel_shaped(data, Some(&["5"]))), ("none.xlsx", excel_shaped(data, None))] { let (wb, warnings) = crate::io::open_bytes(name, &bytes).unwrap(); let sh = wb.sheet(0).unwrap(); let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap(); assert!(b1.py.is_none() && b1.formula.is_none(), "{name}: no code to keep, and no formula #NAME? would replace"); assert_eq!(b1.value, Value::Number(6.0), "{name}"); assert!(warnings.iter().any(|w| w.contains("Python")), "{name}: {warnings:?}"); } let (wb, _) = crate::io::open_bytes("one.xlsx", &excel_shaped(data, Some(&["5"]))).unwrap(); assert_eq!(wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("5")); } /// `bytes` with the part `name` stored uncompressed and one byte of its data changed, so the /// archive is intact but reading the part fails its checksum. fn with_damaged_part(bytes: &[u8], name: &str, marker: &str) -> Vec { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for i in 0..z.len() { let mut f = z.by_index(i).unwrap(); let entry = f.name().to_string(); let mut data = Vec::new(); f.read_to_end(&mut data).unwrap(); let options = zip::write::SimpleFileOptions::default(); let options = if entry == name { options.compression_method(zip::CompressionMethod::Stored) } else { options }; out.start_file(entry, options).unwrap(); out.write_all(&data).unwrap(); } let mut zipped = out.finish().unwrap().into_inner(); let at = zipped.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("the marker is stored as it is"); zipped[at + 1] ^= 0x20; zipped } #[test] fn a_damaged_python_part_is_a_warning_and_the_workbook_opens_with_its_values() { let data = "_xlfn._xlws.PY(0,0)59"; let good = excel_shaped(data, Some(&["5"])); // The part's data fails its checksum (a damaged archive entry). let damaged = with_damaged_part(&good, "xl/python/python1.xml", ""); // The part is not well-formed XML, before its first script. let broken = |at: &'static str, with: &'static str| { with_parts(&good, move |name, text| if name == "xl/python/python1.xml" { text.replace(at, with) } else { text.to_string() }, &[]) }; let malformed = broken("", "