Files
gridcraft/crates/model/src/cell.rs
T
prxnvt 44c663aea2 Model: Python cells, and the #PYTHON!, #BUSY!, #TIMEOUT! and #BLOCKED! errors
Cell gains `py: Option<Arc<PyCode>>`, read with a serde default and not
written for ordinary cells, so older native files load and ordinary
cells serialize as before. PyCode holds the code, the return type (an
Excel value or a Python object), the last result (value, type name,
preview, whether it converted to cells) and a stale flag for manual
calculation. The cell's `value` stays the cached result, so formulas,
display and saving read a Python cell like any other. is_blank,
CellStore::has and input_text (which returns the code) take the code
into account; Cell::has_content and clear_content cover value, formula
and code together.

The model reads and writes the PY function's syntax as Microsoft
documents it, PY("chunk"&"chunk",0|1), with string literals of at most
255 characters and the return type 0 for an Excel value, 1 for a Python
object. The reader takes the `_xlfn._xlws.` prefix (and `_xlfn.` or
`_xlws.` alone, in any letter case); to_file_formula writes it. (An xlsx
file keeps the code in its Python part instead; the xlsx commit adds
it.) The size limit is Excel's 8,192 characters for that formula, not a
count of code characters: doubled quotes and the "&" between literals
count, so code with many quotes fits in fewer characters (8,079
characters of plain code fit exactly). The model also finds the
xl("...") literals in code, skipping comments and other strings; a plain
literal may escape a quote or a backslash. A rewritten reference that
holds the literal's quote (a sheet name that needs quotes, 'Q4
2026'!B2) takes the other quote as the literal's delimiter, or an
escaped quote when it holds both, so it never ends the string early.

Edits: cell.set over a Python cell, Clear Contents, Clear All and the
cells a merge empties remove the code; Clear Formats keeps it. Copy and
paste (All, Formulas), Fill, sort, cut-move, sheet copy and undo carry
it; a cut to another sheet first qualifies its xl() references with the
source sheet, as a moved formula's references are, so they keep
reading the same cells. Paste Values and Paste Link do not carry it,
and paste operations do not combine numbers with a Python cell. Fill
copies Python cells instead of extending them as a series. cell.set
with =PY(...) is refused with a message that points to python.set, and
Text to Columns keeps a piece that reads as =PY(...) as text instead of
clearing it. Goal Seek refuses a Python cell as its changing cell, and
Solver as a variable cell, as both refuse formulas: the number written
there would replace the code. The sheet-protection check compares the
code, so a locked Python cell cannot be changed. cell.get reports
{python: code, output, typeName, preview, stale}.

The xl() literals follow reference-moving edits: rewrite_all_formulas
(row and column insert and delete, cell insert and delete, cut-move),
sheet rename and sheet delete adjust each literal like a formula
reference, and replace it only when it changed.

Errors: CellError gains Python, Busy, Timeout and Blocked, and ALL has
14 entries, so the lexer reads them as literals. ERROR.TYPE gives 11 for
#BLOCKED!, the number Excel returns. Microsoft documents no ERROR.TYPE
number for #PYTHON!, #BUSY! or #TIMEOUT!; they take 19, 20 and 21, after
the numbers Excel is known to use. The xlsx writer caches the four as
#VALUE!, as it already does #SPILL! and #CALC!, because they are not
error codes of the file format.

Tests cover the =PY refusal, removal by entry and clearing, undo, copy,
paste values, fill and sort, xl() rewrites through row insert, sheet
rename (also to names that need quotes, with one quote and with both),
cut-move (also to another sheet) and column delete, Goal Seek and
Solver refusing Python cells, Text to Columns keeping a =PY piece,
native JSON round trips, the new errors, the prefixes, escaped quotes in
literals, and the encoded size limit at 8,192 characters exactly and
with doubled quotes.
2026-10-26 10:18:38 -04:00

124 lines
4.9 KiB
Rust

//! Cells.
use std::sync::Arc;
use gridcraft_core::{RangeRef, Value};
use gridcraft_formula::Expr;
use serde::{Deserialize, Serialize};
use crate::python::PyCode;
use crate::style::StyleId;
/// A parsed formula. `text` is the canonical formula text without the leading `=`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Formula {
pub text: String,
#[serde(skip)]
pub expr: Option<Arc<Expr>>,
/// Legacy array formula (Ctrl+Shift+Enter) covering this range; the formula lives in the
/// top-left cell.
pub array: Option<RangeRef>,
/// Read from a file that marks this formula as a dynamic-array formula (cell metadata `cm`).
/// The writer keeps the mark, so a formula Excel evaluates as an array is never saved as a
/// legacy formula (which Excel evaluates with implicit intersection). Formulas entered or
/// rewritten in GridCraft start without it, and the writer decides from their shape.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dynamic: bool,
}
impl Formula {
/// Parses `text` (with or without `=`). Unparseable formulas keep their text and evaluate to
/// `#NAME?`.
pub fn new(text: &str) -> Formula {
let body = text.strip_prefix('=').unwrap_or(text);
match gridcraft_formula::parse(body) {
Ok(e) => Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false },
Err(_) => Formula { text: body.to_string(), expr: None, array: None, dynamic: false },
}
}
pub fn from_expr(e: Expr) -> Formula {
Formula { text: gridcraft_formula::print(&e), expr: Some(Arc::new(e)), array: None, dynamic: false }
}
/// The parsed expression, re-parsing after deserialization.
pub fn expr(&self) -> Option<Expr> {
self.expr.as_deref().cloned().or_else(|| gridcraft_formula::parse(&self.text).ok())
}
/// The parsed expression without copying it (re-parses after deserialization).
pub fn expr_arc(&self) -> Option<Arc<Expr>> {
self.expr.clone().or_else(|| gridcraft_formula::parse(&self.text).ok().map(Arc::new))
}
}
/// A static picture stored as cell content, independent of floating drawing anchors.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CellPicture {
/// Encoded PNG/JPEG bytes, base64 in JSON. Sheet entries share the payload through Arc.
#[serde(with = "crate::b64")]
pub data: Vec<u8>,
pub mime: String,
pub alt: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Cell {
/// Constant value, or the cached result of the formula or Python code.
pub value: Value,
pub formula: Option<Arc<Formula>>,
pub style: StyleId,
/// Python code, for a Python cell (which has no formula).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub py: Option<Arc<PyCode>>,
}
impl Cell {
pub fn value(v: Value) -> Cell {
Cell { value: v, formula: None, style: StyleId::DEFAULT, py: None }
}
pub fn formula(f: Formula) -> Cell {
Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT, py: None }
}
/// A value cell with this style.
pub fn styled(v: Value, style: StyleId) -> Cell {
Cell { value: v, formula: None, style, py: None }
}
/// No value, no formula, no Python code and the default style: storing it is pointless.
pub fn is_blank(&self) -> bool {
self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT && self.py.is_none()
}
/// Whether the cell holds content (a value, a formula or Python code), formats aside.
pub fn has_content(&self) -> bool {
!self.value.is_empty() || self.formula.is_some() || self.py.is_some()
}
/// Removes the content (value, formula and Python code), keeping the style.
pub fn clear_content(&mut self) {
self.value = Value::Empty;
self.formula = None;
self.py = None;
}
/// What the formula bar shows: a formula, a Python cell's code, or the value.
pub fn input_text(&self) -> String {
if let Some(py) = &self.py {
return py.code.clone();
}
if let Some(f) = &self.formula {
return format!("={}", f.text);
}
match &self.value {
Value::Text(t) => {
// Text that would parse as something else is shown with its apostrophe by Excel
// only when entered that way; we show it plainly.
t.to_string()
}
v => v.display(),
}
}
/// What the formula bar shows when not editing: [`input_text`](Self::input_text), with a
/// legacy array formula in braces (`{=A1:A2*2}`).
pub fn bar_text(&self) -> String {
match &self.formula {
Some(f) if f.array.is_some() => format!("{{={}}}", f.text),
_ => self.input_text(),
}
}
}