A Python cell is written to xlsx as [MS-XLSX] 2.3.10 specifies: its
formula is _xlfn._xlws.PY(i,0|1), where i is the zero-based index of the
cell's script in the workbook's Python part and 0|1 the return type, 0
for an Excel value and 1 for a Python object. The part (2.1.23) is
xl/python.xml, content type application/vnd.ms-excel.python+xml,
related from the workbook part with
http://schemas.microsoft.com/office/2023/09/relationships/Python; its
root python holds pythonScripts, one pythonScript per Python cell in the
order the cells are written, with the code in code. The code keeps its
xl() references as text, so the formula has no further arguments. The
cell keeps its cached value. A spilling result is written like a dynamic
array (cm="1", t="array" ref=...) with the spilled values cached, so
other applications show the values (and #NAME? if they recalculate).
The reader turns a PY( formula with or without the _xlfn. and _xlws.
prefixes into a Python cell. An index names a script of the Python part,
and each %Px% in the script becomes the text of the formula's argument x
(2.3.10.4). The string-literal form, PY("chunk"&"chunk",0|1), still
reads, from the formula's own text so line breaks in the code survive. A
formula naming a script the part doesn't hold keeps only its cached
value, with a warning: no calculation can evaluate it. The reader keeps
the cached spilled values as the cell's result, so a workbook from Excel
shows its arrays without running anything. Python errors are cached as
#VALUE!, as #SPILL! and #CALC! already are. Native JSON keeps Cell.py
whole: code, return type, the last result and the exact error.
Calc lays out a Python cell's kept result: Python cells are nodes of the
dependency graph that read nothing, and a pass takes a dirty Python
cell's result instead of evaluating it, so its array spills, is blocked
(#SPILL!) and unblocked, and feeds its dependents exactly like a
formula's. An array that is malformed or has more than 1,000,000 cells
(only a crafted file holds one) shows #SPILL!. A dirty Python cell is
pending in the pass, as a dirty formula is, so a formula, a range read
or a cell-by-cell range walk that reaches it before its turn lays it out
first rather than reading its old value.
Opening a file can't make the app loop over a claimed size: the reader
keeps a Python cell's spill range only when it has at most 1,000,000
cells (a range claiming the whole sheet is dropped with its values, as a
formula's spilled values are), and io::adopt_python rebuilds results for
at most 4,000,000 cells over the whole workbook, with a warning for the
cells it skips. The Python part is read up to 64 MB and 1,000,000
scripts, a script is kept up to one character past what a cell may
hold, and the cells take at most 64 MB of code from the part in all,
since several formulas can name one script. io::enforce_python_limits
drops the code of a cell whose code doesn't fit in 8,192 characters as
PY(...) (the cell keeps its cached value, with a warning) and a kept
result that isn't a well-formed array within the cap, each with its
spill range and spilled values, so the sheet and a save no longer show
the old spill. Formats that keep PY() as an ordinary formula (xls, xlsb,
ods) are adopted the same way. Opening a file runs nothing.
The command-sequence fuzzer sets Python cells (code with xl()
references, quotes, line breaks, and code at and over the size limit),
and its xlsx round-trip check allows the Python errors becoming
#VALUE!, which is what the file format stores.
Tests: the written formula naming its script, the Python part with its
content type and relationship, return types, a spilling table saved as a
dynamic array, Python errors as #VALUE!, the round trip of values, code,
long code with quotes and line breaks and the error, and no Python part
without Python cells; a workbook laid out as the format specifies (an
index, a parameter named as %P2%) read as Python cells with their
spilled values and saved back with its code in a Python part; the
string-literal form; a formula naming a missing script, with and without
a Python part; 20,000 cells naming one long script against the code
budget; the part's reader and writer (escaping, odd %P tokens, a long
script, a repeated parameter); a blocker in the spill area giving #SPILL!
and clearing it spilling again; the native JSON round trip; a spill
range claiming the whole sheet; the workbook-wide budget; hostile JSON
(code over the limit, a result claiming far more cells than it holds); a
malformed result that never spills; PY() formulas at 8,079 and 8,080
characters of code. Calc: formulas, MAX, SUM, COUNTA and SUMPRODUCT read
a dirty Python cell whose array is blocked before its turn, in a full
and an incremental recalculation, and see the array once it is
unblocked; enforce_python_limits removes the spill of dropped code and
of a dropped result, and keeps a sound one.
doesn't run it
python.set {cell?, code, output?: object|value} makes the cell (the
active cell by default) a Python cell, keeping its style and taking a
formula and its spill away. `output` is the return type Microsoft
documents for PY(): an Excel value (the default) or a Python object;
without it a Python cell keeps the one it had. The code must fit in
Excel's 8,192-character formula once written as PY(...), quotes doubled
and literals joined, so the command checks the encoded formula rather
than a character count. A table's header row refuses Python (it holds
the column's name, as Excel keeps formulas out of headers), and a locked
cell on a protected sheet refuses it through the usual protection check.
Nothing runs Python here. Unchanged code keeps its cached result and
value; new or changed code shows #BLOCKED!. One undo step takes the cell
back.
Sheet::clear_spill removes the spilled values inside a range with work
bounded by the values stored, not by the range's size.
In the interface, F2, a double click or the formula bar on a Python cell
edits its code, and a changed text is committed through python.set,
which keeps the return type; through cell.set it would have replaced the
Python cell with an ordinary cell holding the code as text. Typing over
a Python cell replaces it, as typing over a formula does, and committing
the code unchanged writes nothing. Deleting all the code clears the
cell through cell.set, as an empty entry clears any other cell; code
of spaces is still code.
Tests: the code and return type in cell.get, #BLOCKED! for new code,
cached results kept for unchanged code, the active-cell default, undo,
refused params (missing or non-text code, a bad cell, a bad return
type), the encoded limit at 8,079 and 8,080 characters and with doubled
quotes, a table header, a protected sheet, and a formula's spill taken
away. A UI test edits a Python cell's code with F2 and Enter, commits it
unchanged, and types over it; another clears a Python cell by deleting
all its code, keeps code of spaces, and undoes the clearing.
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.
* Sonar: explicit imports, fn paths for closures, extract helpers for cognitive complexity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Fix imports
* Fix serde imports
* Fix engine imports
* Fix clippy gate and a type error from the explicit-import change
Remove the unused imports the review flagged (cf.rs, pivot.rs, tables.rs, cmd/sheet.rs) so clippy -D warnings passes, and point the backstage parent-folder lookup at Path::to_str, which the fn-path rewrite had broken.
Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Apply rustfmt to the explicit-import cleanup so the fmt gate passes
* Share the per-directory font path expansion in theme.rs
The candidate font lists repeated the same loop over the Linux font
directories in every role. A single helper expands them in the same order,
so the lookup order is unchanged and the duplicated blocks go away.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Read the Windows fonts folder in one place in theme.rs
Every script-font list repeated the same WINDIR lookup. A single helper
returns the same path, so the duplicated lines go away without changing
which font files are tried.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Build the light and dark UI palettes from one table in theme.rs
Tokens::light() and Tokens::dark() listed the same 40 colour fields in the
same order, so every change had to be made twice. A single palette
constructor takes the mode and picks each colour's value, so each field is
written once and both modes keep their exact colours.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Share the Noto CJK font list between the CJK script lists in theme.rs
The Japanese, Simplified and Korean lists each repeated the same loop over
the Linux CJK directories with a different locale file name. One helper
builds that loop, and each list keeps its own face index, so the lookup
order is unchanged.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* Share Linux Noto directory list across Thai and Hebrew font lookups
Removes the duplicated directory arrays flagged by SonarCloud new-code duplication.
* Reuse in_dirs for Thai and Hebrew Noto font candidates
Removes the remaining duplicated per-directory loops flagged by SonarCloud.
* Share one font-list builder for the Thai and Hebrew faces
The two per-script functions repeated the same Windows, macOS, Linux and
variable-font setup. A single script_fonts helper now builds both lists,
keeping the candidate order unchanged, which clears the Sonar duplication gate.
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
- Macros::estimated_bytes counted names, descriptions and step counts but not step parameters,
so each save, rename or delete (which copies the macro list an undo step shares) could keep a
4 M-character parameter per step unseen by the 128 MB budget. It now counts each macro's JSON
as it is written, without building it.
- This changes the macro storage layer below #2152, the smallest change that makes the budget
hold (answers CodeRabbit thread 4366060037).
- Test: re_saving_a_large_macro_keeps_the_undo_history_within_its_budget.
A macro whose format version is above this build's was dropped on open,
so the next save removed it from the file, and the warning said only
that it couldn't be opened. It is now kept as the file held it
(Workbook::macros_unread, shared between snapshots), never read, listed
or run, and written back unchanged by JSON and xlsx saves, numbered
after the readable macros and around visible names of the storage
shape. Kept macros count toward the 1,000-macro limit after the
readable ones; one too large for an xlsx item is left out. The warning
says it is kept and saved back; damaged and over-limit macros keep
their own warning.
check_limits capped the name, description, notes, step count, depth and
input names, and left the shortcut, permissions, output anchor, default
range, column types, command ids, Repeat ranges, If cells and values and
Stop messages bounded only by the item size; the input declaration was
stored unchecked (rows [5, 2], a cell input with cols [2, 5]).
Now each of them has a limit (docs/macros.md lists them), one-line
fields refuse control characters, and a command id is letters, digits,
. and _ (every registered id passes, tested). An If value may be null
(Empty and NotEmpty ignore it); Repeat.max stays the runner's to bound.
An input's least rows and columns can't pass its most, a cell input is
one row and one column with no header and at most one column type, and
no input lists more column types than columns. check_limits takes every
struct apart field by field, so a new field doesn't compile there until
its limit is decided. A macro from a file that breaks a rule is left
out with the usual warning, never changed to fit.
Insert > Line or Area and the All Charts list offer 100% Stacked Area,
and insert.chart and chart.set take it as type area with subtype
stacked100. Each series' area stacks on the ones before it as its
share of the category's total. When every series on the value axis is
100% stacked and no value is negative, the axis runs from 0% to 100%.
A blank cell counts as zero, and a category whose total is zero stays
on the baseline.
An XLSX area chart with percentStacked grouping now opens as 100%
stacked and is saved back as percentStacked. It used to open as a
stacked area and was saved as stacked.
The writer numbers sheetIds from 1 in sheet order, so a rewritten workbook
changes the id of any sheet that is not part of a gapless series from 1.
Anything that recognises a sheet by its id (parts carried through as they
are, external tools) loses it. The model keeps the sheetId it read
(Sheet::file_id, PreservedSheet::sheet_id, not serialised to JSON) and the
writer writes it back; a new sheet, or a copy with a taken id, gets the first
free one.
Data > Outline > Settings now has Summary rows below detail and Summary
columns to right of detail. With one cleared, each group's - / + button
sits on the line before the group, its bracket runs down to the last
detail line, and Hide Detail, Show Detail, the level buttons and
Ungroup mark or clear the collapsed flag on that line. Changing a
direction moves the flags of collapsed groups to their new summary
lines.
- model: Sheet::summary_below and summary_right (on by default, also
for older JSON).
- xlsx: read and written as sheetPr/outlinePr summaryBelow and
summaryRight; files that set them no longer lose them on save.
- engine: data.outlineSettings {sheet?, summaryBelow?, summaryRight?},
undoable; outline::groups_with places the summary line.
- ui: a Settings button in the Data tab's Outline group opens the
dialog; the outline band follows the setting.
A PivotTable has an Enable show details option, set with the new
pivot.options command ({pivot?, enableShowDetails?: bool}). While it is
off, pivot.showDetails and double-clicking a value cell list no records:
the double-click shows a message and does not open the cell for editing.
Labels and headers still edit. Changing the option is one undo step and
does not refresh the report. pivot.fields reports the setting.
XLSX files keep it: an off option is written as enableDrill="0" on the
pivotTableDefinition, and enableDrill is read on open (on when absent).
A macro could declare an input called Q1, AB12 or Sum1, and a step written
with plain A1 text, as a recorded one is, would then resolve to the input's
range instead of the cell. check_limits refuses such names; the docs say so.
Regression test.
- Workbook::macros is a shared list (Macros, a copy-on-write newtype): a
workbook copy made for a cell edit or an undo snapshot holds the same list,
and the undo budget charges a list once a snapshot owns its own copy.
Before, every mutating command copied every step of every macro, and a
hundred undo entries kept a hundred copies the budget never saw.
- Saving to xlsx drops the last save's items and writes the macros in one
pass each (stash::set_many), instead of a scan of every name per macro.
- A hidden _gc_macro_<x>_<k> name with a key this build did not write is
kept on open and on save, as a visible one is, instead of being dropped
without a warning.
- macros::check is the one check for every path that adds a macro (JSON, a
file, built in code): the model's limits and the xlsx size limit.
Regression tests for each.
The depth check refused any step list at the fifth level, so a fourth Repeat
or If could not even hold an empty body: three levels nested, not the four
MAX_MACRO_DEPTH and the docs state. The check now counts the Repeat or If
steps themselves, in the model's limits and in the parser's pass over the
JSON. The test asks for four and refuses a fifth.
- The workbook's active sheet is an atomic set in place (Workbook::active_sheet /
set_active_sheet), so a window swap or sheet switch never clones the
Arc<Workbook> or marks it dirty. Selections already live in DocState and the
window views. Save records the sheet of the most recently active window.
- A dialog taken out of its window while it runs goes back to that window when
a command run from it switched windows (dialogs::show, ui.dialog.confirm).
- view.arrangeAll {arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?}
returns the visible windows, active first; the desktop moves and sizes the
native windows over the main window's monitor. New Window and Arrange All
buttons on the View ribbon; Arrange Windows dialog.
- Docs: ui-parity, hardware-parity, gaps #20, target-app-parity, ROADMAP.
A sheet can hide its outline's level buttons and brackets while keeping
the outline (Excel's Ctrl+8, saved as showOutlineSymbols="0"). It wasn't
modelled, so such sheets showed a wide outline band Excel doesn't.
- model: Sheet::show_outline_symbols (default on, also for older JSON).
- xlsx: read and written on the sheet view.
- engine: view.showOutlineSymbols {on?} (Ctrl+8), undoable.
- ui: no outline bands while it's off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a workbook accepted a macro whose format version `v` was above MACRO_VERSION. Serde
dropped the fields this build doesn't know, and the next save wrote the reduced macro back
with `v: 2`, so a newer build would read it as a complete version 2 macro.
A version above MACRO_VERSION is now refused by Macro::check_limits, and parse_macro checks
the raw `v` before reading the steps. Such a macro takes the existing per-macro path: it is
left out with the usual single warning (which now names a newer format), and the rest of the
workbook opens. Tests cover JSON and xlsx hidden names, with current and missing versions as
controls.
Workbook::macros was read straight into typed macros, so one entry this
build couldn't read (a step type from a later build, an unknown
onConflict value, an entry that isn't an object) failed the whole file
with "not a GridCraft JSON workbook". The field is now read as raw
entries into Workbook::macros_read, and macros::load reads each one
with parse_macro, as for xlsx: short-form steps are accepted, the depth
limit is checked before deserializing, and a bad entry is dropped with
the same warning. Only the first 1,000 entries are read, as in xlsx.
Only parse_macro refused a name holding a line break or other control
character. Macros that came from the macros field of a JSON workbook
were checked with check_limits alone, so a file could bring in a macro
named "a\nb" that macro.save and macro.rename would refuse. The check
now lives in Macro::check_limits, so every path applies it, and such a
macro is dropped with the usual warning when the file is opened.
A macro is plain data in the model (model/src/macros.rs): a name, a
description, an optional shortcut, declared inputs (shape, header,
column types, non-empty, default reference), steps, an output rule
(anchor and what to do on a conflict) and the commands it may run that
read or write files. A step runs a command (id and params) or is one of
three bounded control steps: Repeat over the rows or columns of a range,
If on one cell's value, and Stop with a message. Every step carries
enabled, breakpoint and note flags. These are GridCraft macros, not VBA;
a VBA project in a file stays preserved and is never run.
Workbook::macros holds them. The JSON format keeps the field. In xlsx,
io::save_bytes writes each macro as one chunked hidden-name item,
_gc_macro_<n>, and io::open_bytes takes the items back out of the names,
so the names never linger in the open document.
Limits are checked whenever a macro is read from a file or a command:
at most 1,000 macros, 10,000 steps each, Repeat and If nested 4 deep,
names and notes of bounded length, and at most 4,194,304 characters of
JSON, so that saving a workbook never fails because of a macro; nesting
is checked before the steps are deserialized. What is over or damaged
is dropped with one warning, and clashing names get a suffix.
Automate scripts keep their own storage (_sc_scripts) and are neither
read nor changed by this.
Conflicts:
- crates/model/src/lib.rs, crates/xlsx/src/read.rs, crates/xlsx/src/write.rs: use lists unioned (ShowObjects and the phonetic types)
- docs/file-format-parity.md: main's header and rows kept (#1692 collapsed the three duplicate .xlsx rows; they stay), phonetic-runs text added to each; Last updated 2026-10-20
crates/xlsx/src/styles.rs: family/charset block ported into the dxf.is_none() branch of font_xml (PR #1225 made it take Option<&DxfStated>)
docs/file-format-parity.md: header Change line keeps both sides' clauses
Conflicts:
- docs/file-format-parity.md: header and the three .xlsx rows keep main's text plus the show-objects entry; both log rows kept
- docs/ui-parity.md: header keeps main's text plus Ctrl+6; both log rows kept