The corner drag now grabs the chart where the press began and the selected object's corner shows a resize cursor. The Format Chart Area Size fields, chart and axis title fields and the Table Name field reloaded their value from the workbook every frame, so typed or dragged edits were thrown away; they now keep a draft until the field loses focus (Escape cancels, like sheet tab rename).
The web build had no way to actually print — Ctrl+P (Cmd+P, declared on
file.print's shortcut) did nothing at all, and the ribbon's "Print…" menu
item ran file.print and silently discarded the result. file.print already
builds a PDF and returns it as base64 specifically "for the UI to hand to
the system print dialog" per its own doc comment — this wires that up.
- ui-egui: new Services::print hook (web: hand PDF bytes to the browser's
print flow directly, no download); SheetApp::print_now runs file.print,
decodes the base64 result, and calls the hook.
- ui-egui: Key::P now dispatches to print_now (was unbound); the ribbon's
"Print…" menu item calls print_now instead of discarding file.print's
result.
- gridcraft-web: implements the hook (Blob -> object URL -> hidden iframe
-> iframe.contentWindow.print() on load).
Desktop builds are unaffected (no Services::print set there; print_now
falls back to the existing alert-on-error behaviour with no UI action on
success, same as before this change).
cell_param returned None for any cell argument it could not parse, so every
command that falls back to the selection (cell.set, view.freezePanes,
formulas.trace*, insert.* and others) silently used the active cell instead:
set_cell with cell "ZZZZ0" wrote A1 and reported success. It also dropped a
sheet prefix, so "Other!B2" was applied to the active sheet.
cell_param now returns Result<Option<CellRef>>. An absent or null value is still
None, so the selection fallback is unchanged; an unparsable or non-string value
is an error; a sheet prefix naming a sheet other than the one the command works
on is an error.
cell_param_on is for the commands that resolve their own sheet (cell.get, links,
comments, notes, checkboxes, chart placement, paste): they accept a prefix naming
that sheet. cell.set resolves its own target sheet and still writes to another
existing sheet.
cell.set treated a missing input as an empty string, so a call that forgot the
input (or sent it under the wrong key) cleared the cell and reported success.
It now returns an error when neither `input` nor `value` is given. An explicit
empty string still clears the cell.
winit reports no system theme on Linux — `ActiveEventLoop::system_theme()` is
`None` there on every desktop — so `System` in the appearance setting never saw
the desktop's choice and fell back to light. A new `desktop_theme` module asks
the XDG Desktop Portal's Settings interface for
`org.gnome.desktop.interface color-scheme` on a thread of its own, so no
D-Bus call is ever made from the frame loop, and the app follows the answer,
including a change made while it runs.
egui resolves `ThemePreference::System` from what winit reports and offers no
way to set that itself, so where a desktop answered, the app hands egui the
concrete theme instead. What is drawn is unchanged on macOS and Windows, where
winit answers and the same resolution happens a step earlier; the saved
preference is still `system`, so a file keeps meaning the same thing.
The colour scheme is asked for under its GNOME schema name first, which GNOME's
portal and XFCE's both answer, then under the older freedesktop one, which KDE
answers. A desktop that answers neither leaves the user's own choice alone.
Add static pictures inside cells with XLSX roundtrip
Rebased onto main (conflicts with #83/#98 and main resolved by maintainer); the contributor's branch does not allow maintainer edits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve array evaluation for non-spilling XLSX formulas
Rebased onto main with a maintainer fix-up (LET bindings via split_last/as_chunks for clippy, no slice indexing); the contributor's branch does not allow maintainer edits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
English (and languages without a formula table) now shows stored formula
text verbatim instead of re-parsing it every frame; localized entry no longer
trims leading spaces before formula detection (" =1" stays text, as in the
engine). Adds a display round-trip test for string literals containing
separators, decimal commas and the intersection operator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Implements the two slicer commands from the parity catalog as first-class
floating objects that filter a table column.
- model: Slicer struct on Sheet (#[serde(default)]), included in
Workbook::next_object_id; slicer anchors use AnchorMode::MoveOnly.
- engine: cmd/slicer.rs with insert.slicer, table.insertSlicer, slicer.toggle
(single + multi), slicer.selectAll, slicer.clear, slicer.delete. Selection is
written as a FilterCriterion::Values into the sheet AutoFilter and applied via
the existing apply_filter. object.move / object.delete handle "slicer".
- ui-egui: slicers rendered, hit-tested, selected and dragged like charts; value
tiles toggle on click (Ctrl/Cmd = multi), funnel button clears. Ribbon buttons
under Insert > Filters and Table Design > Tools.
- tests: slicer_filters_table; the every_command_survives_empty_params fuzz
covers the new commands.
Native-only for now; .xlsx slicer-cache round-trip is a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and feature, format, hardware, UI and localization parity docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#101/#100 added tests that name temp dirs with SystemTime::now(); #125's
disallowed-methods lint (wasm has no clock) landed alongside them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge the fixed#104 branch (which carries #31 and #38) and restructure
the Brazilian Portuguese language onto its per-language layout:
- The PORTUGUESE table moves, unchanged, to crates/ui-egui/src/i18n/pt.rs
as pt::translate, like zh/ko/ru. Language::PtBr gets code() "pt"
(parse also accepts pt-BR/pt_BR via the tag reduction); it is still
saved as "pt".
- No per-language command: the View combo and the control channel switch
with app.language.set {"language": "pt"} (app.language.portuguese is
dropped); error text, engine.commands params and the snapshot example
list every code.
- pt.rs covers the keys added since (Page Layout|Orientation, the display
theme combo, and the Sheet Options View/Print checkboxes, Exibir /
Imprimir); the coverage test checks pt like zh/ko/ru.
- people.toml keeps both new contributor entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge the fixed#31 branch (which carries #38's font fallback) and resolve:
- theme.rs: build on #38's fallback_faces/load_fallback instead of the old
load_cjk. The interface language only reorders the two Han entries
(sys-cjk-han-ja / sys-cjk-han-zh; Japanese first for ja, Chinese first
otherwise); Hangul, Thai and Hebrew keep their place. The region-merged
Noto Sans CJK .ttc is loaded once, with the leading language's face index.
- Fonts are rebuilt on a language switch only when the Han order changes
(HanOrder), not on every switch. font_definitions()/setup_context() use
the default order and never read the host locale.
- Tests stay locale-independent (UiState defaults to English; the desktop
app applies the system language). The web build follows the browser
language except CJK, which would render as tofu without system fonts.
- app.language.set error/params list all five codes; engine.commands lists
it. zh/ko/ru gain the keys #31 added (Page Layout|Orientation, display
theme). The Page Layout screen checkbox keeps English "View" via a
"Sheet Options|View" key (was shown as "Screen").
- Coverage test checks zh/ja/ko/Thai/Hebrew/Cyrillic in both the UI and
cell families by comparing glyphs with the tofu glyph.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- UiState::default() is English: tests, the kittest harness and the
snapshot example no longer depend on the host's locale. The desktop app
alone starts from the system language, when ui.json names no supported
language (first run, an older file, an unknown value).
- An unknown or malformed saved language reads as English instead of
failing the whole UiState and resetting every other preference.
- app.language.set takes {"language": "ja"} (with `code` as an alias, the
name the PR description used); the View combo now uses it via
Language::code(). The app.language.* commands are listed by
engine.commands with the other UI commands and documented in
docs/control-protocol.md.
- Home's text "Orientation" (方向) and Page Layout's print "Orientation"
(印刷の向き) get separate keys through a `context|label` key form.
- Japanese: Hide Rows → 行を表示しない, Trace Dependents → 参照先のトレース;
translate the display-theme combo merged in from main.
- system_theme test: the View tab now has two combo boxes; pick the theme one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-anchor loop zips objects with their measured corners instead of indexing; a sort moves
only objects anchored inside the sorted block on the other axis too (a picture at Z3 stays
put when A1:B4 is sorted). insert.picture defaults to Move but don't size (Excel's
editAs="oneCell", which the writer emits for it), and object.setAnchorMode errors on an
unknown or empty kind. Tests for each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edit.copy and edit.cut build and return the HTML table only with
{"html": true}; the UI asks for it when the host can publish HTML, so
MCP and control-channel responses are not bloated by up to 4 MB. Tests
cover the default, html:false and html:true.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every legacy .xls/.doc is a CFB/OLE2 file, so the signature alone isn't encryption. Walk the
compound file's directory (bounds-checked, bounded loops; raw UTF-16 scan when the structure
is unreadable) and report Encrypted only for an EncryptedPackage/EncryptionInfo stream;
other compound files are LegacyBinary with a "save as .xlsx" message. Tests build CFB bytes
in code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
shape.setText refuses edits on a protected sheet (drawing objects are
locked; there is no edit-objects allowance yet) and text longer than
32,767 characters, like a cell. The editor doesn't open on a protected
sheet and its TextEdit carries the same character limit. Tests for both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blank cell and row repeats that run past 16384 columns / 1048576 rows
(padding from writers with larger sheets) are clipped instead of
rejecting the file; any data or merge past the edge is still an error.
convert() builds its Cell with ..Cell::value(value) so new Cell fields
take their defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Load each fallback font file once: the region-merged Noto Sans CJK .ttc
found for Han also covers Hangul (and Tahoma covers Thai and Hebrew), so a
later script whose candidate file is already loaded reuses it instead of
loading tens of MB again. New test: no two fallback faces share a file.
- Fold in the Linux CJK paths from #103: Fedora google-noto-cjk/, Arch
noto-cjk/ and wenquanyi/, Debian truetype/wqy/wqy-microhei.ttc, the SC
.otf builds, /usr/local, plus SimSun on Windows. Document the .ttc face
indices (JP 0, KR 1, SC 2, TC 3, HK 4); Hangul uses the KR face.
- macOS: Hiragino Sans (ヒラギノ角ゴシック W3) and Hiragino Sans GB come
before PingFang.ttc, which recent macOS no longer ships in /System.
- Comments promise glyphs instead of tofu, not correct rendering.
- fallback_covers_its_script compared has_glyphs, which is a false negative
when the owning face also draws the replacement glyph (DejaVu Sans as UI
font owns Hebrew); compare laid-out glyphs with the tofu glyph instead.
Co-authored-by: eyouhi <47080137+eyouhi@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
smooth_run gives every segment the same number of sub-segments (cap / n, 1..100) so long
series are smoothed end to end within the point cap, and clamps the Catmull-Rom curve to
the plot box. List "smooth" in chart.addElement's params doc, drop the smooth_demo
example, and use the test sheet's own name in series refs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the shared integration points for both formats: combined sniff,
file.open spec, file filters, MCP description, docs. open_bytes now lets
sniffed content (ODS or XLSB) win over the extension.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PhRun is 10 bytes per [MS-XLSB] 2.5.103 (four u16 fields plus a 16-bit
phType/alcH/unused1 word), so the documented size is kept; since the
RichStr ends its record and phonetic data is not imported, bytes after
the documented runs are now ignored instead of rejecting the file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
recalc_with_range_formulas_scales_linearly is flaky under load, so it is #[ignore]d (run with
--ignored in release). A small range_formulas_recalc_and_bulk_edit_correctly test keeps the
helper's value check in every run. The helper reads Instant::now, so it allows
clippy::disallowed_methods (required once #125's clippy.toml lands).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clip a file chart's series block to the sheet's used range and refuse blocks over the
insert.chart cell cap, so whole-column references can't build ~1M series.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ribbon_keys ran ui.ribbonTab every frame while the prefix was E/O/OC,
forcing the Home tab continuously. Run it once on the prefix change and
add a regression test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fill Series by month used the previous cell's day as an overflowing DATE()
day, so Jan 31 + 1 month became Mar 3 and February was skipped. The same
overflow affected year steps from Feb 29 and the autofill month pattern.
Each item is now computed from the start date plus n months and the day is
clamped to the target month's length (Jan 31, Feb 28, Mar 31, Apr 30), so
the clamp does not compound. Leap years are handled.
Fixes#132
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel documents the first argument of MIN as optional and returns 0
when there are no numbers. MIN was registered with a minimum of one
argument, so =MIN() returned #VALUE!. The aggregate already returns 0
for an empty list, so only the registered arity needed to change.
Fixes#138
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Histogram, box & whisker, waterfall, funnel, treemap and sunburst charts
were written as clustered columns, and reading skipped such charts in
files made by Excel, which keeps them as cx:chartSpace (chartex) parts.
The writer now writes these kinds as chartEx parts, as Excel lays them
out: the data goes through hidden _xlchart.v1.N workbook names, each
part comes with chart style and colour parts, and the drawing frames it
in mc:AlternateContent with a text-box fallback. Fills are written out
(series in theme accents, waterfall decreases and treemap/sunburst boxes
in their own, a white chart area), so they don't depend on the style
part. The reader reads the parts back (resolving the _xlchart names,
which aren't kept as names) with data, title, axis titles, legend,
labels and colours, and follows mc:AlternateContent in drawings.
Excel doesn't support spilled arrays in tables: a formula such as
=SEQUENCE(2) entered in a table cell shows #SPILL! ("Spill range in
table"). GridCraft spilled it over the table's cells. An array whose
formula is in a table is now blocked, both when it is computed and when
it is written back.
Go To (and the Name Box) with a table's name selected the whole table,
header and totals rows included. In Excel a table's name stands for its
data rows, as in formulas (=SUM(Table1) adds only the data).
Editing a table's header cell changed only the cell: the column kept its
old name, so =SUM(Table1[a]) still worked after the header read "zz",
and saving wrote the old names back into the header row, undoing the
edit on reopening. As in Excel, the column now takes the new header;
an emptied header becomes ColumnN and a repeated one gets a number
("a2"), written back into the cell, and structured references follow
the new name.
A formula whose references are only known while evaluating isn't in the
dependency graph, so when it read a spill area before the array was laid
out in the same pass, nothing evaluated it again. With the full
recalculation now laying every spill out afresh, =INDIRECT("F7") over
a spill moved by an inserted row read 0 after Calculate Now. Such
formulas are evaluated once more when a pass computed any array.
A dynamic array that couldn't spill was only rejected when results were
written back, after formulas evaluated later in the same pass had read
the array as if it had spilled. With 1s in A1:C3 except B2 =
SEQUENCE(2,2,9) (blocked), =SUM(A1:C3) gave 47 after a full
recalculation (opening a file, Calculate Now) instead of #SPILL!; a
reader through OFFSET kept the wrong value even when entered one cell
at a time. The spill area is now checked as soon as the array is
computed, so the anchor is #SPILL! for everything after it.
When two dynamic arrays were recalculated in the same pass and the
first read the second's spill area before the second was evaluated, it
read the old values. Formulas reading a spill area that changed are
evaluated again, but dynamic arrays were left out of that second round
(only to keep an array from re-queuing itself), so with D11 =
SEQUENCE(1,3,A1) and G5 = UNIQUE(VSTACK(F9:F12,A1)), changing A1 from 1
to 10 left 3 in G6 instead of 12. Only the array itself is left out
now.
With =SEQUENCE(5) in F8 and =SEQUENCE(1,3) in E10 (blocked by F10),
typing 0 into F9 blocks F8's array, which frees F10, but E10 stayed
#SPILL! until a full recalculation. When a formula stops spilling over
an area, #SPILL! anchors above or to the left of it are evaluated again.
Clearing a cell re-evaluated #SPILL! anchors only within 64 rows and
columns above and to the left of it, so =SEQUENCE(100) in A1 stayed
#SPILL! after the blocker in A90 was cleared. Emptied cells now look
for blocked anchors any distance above or to the left, with one pass
over the formulas per sheet however many cells were cleared.
Fixes#139.
Inserting or deleting rows and columns moved formula cells but not the
values they had spilled, and the full recalculation that follows
reconciled spills only at the formulas' new positions. =SEQUENCE(3) in
A5 with row 1 deleted showed #SPILL! in A4 (blocked by its own old
values in A6:A7); deleting the anchor's row left 2 and 3 behind. A full
recalculation now drops all spills and lays them out again from the
formulas.
Fixes#133.
=SUM(C3:INDEX(B9:C11,2,2)) adds C3:C10, but only C3 and B9:C11 were
recorded as its precedents, so typing into C5 left the total unchanged
until a full recalculation. A range whose ends are function results now
depends on the box spanned by the references inside it, which contains
any cell those functions can return.
Typing a value over a dynamic array formula, or clearing it, left the
values it had spilled: with =SEQUENCE(3) in A1, typing 2 into A1 kept
2 and 3 in A2:A3, and =SUM(A1:A3) gave 7. Only formulas are evaluated
again, so the old spill of a cell that no longer holds a formula was
never written back. It is now removed when the formula goes, and the
formulas reading its area are recalculated.
Add, Subtract, Multiply and Divide replaced every cell that didn't hold
a plain number with the pasted value: multiplying a column by 1 turned
numbers stored as text, headers and #N/A all into 1, and a formula into
a constant. As in Excel, numbers stored as text now take part (the
usual way to turn them into numbers), other text and errors stay, and
a formula is kept and combined with the pasted number: =(B3*2)*1.
The target was capped at 5 million cells but the source wasn't: with the
whole sheet as source and A1 as target, AutoFill read all 17 billion
source cells one column at a time and never finished.
A formula's result that is a blank cell shows 0, but blanks inside an
array result spilled as blank cells: =A1:A3, or FILTER or SORT of a
range with blanks, left empty cells that ISBLANK saw as blank and
COUNT skipped. Excel shows 0 in each. GROUPBY and PIVOTBY keep the
blank cells of their own layout.
A multi-cell reference was read only up to the sheet's used range, so
the array it gave depended on what else was on the sheet:
ROWS(A1:A10*1), COUNTBLANK(A1:A10) and SUMPRODUCT(--(A1:A10="")) came
out smaller than in Excel, and changed as cells were entered elsewhere
without the formula recalculating. References of up to 16,384 cells now
keep their size; larger ones (A:A, ranges reserved for data to come
such as $A$2:$A$50000) still stop at the used range, so that formulas
over them stay fast.
current_region (Ctrl+A, and the range that sort, filter, AutoSum,
tables and Get Data use when one cell is selected) grew the region one
ring of cells per step and stopped after 10,000 steps, rescanning every
cell of the ring each time. With 20,000 rows of data and one cell
selected, data.sortDescending sorted only the first 10,001 rows and
left the rest as they were; a 100,000-row region took 30 s and was cut
at row 10,001.
It now grows one side at a time while the line next to that side has
data (diagonals included), checking only the cells of that line, with
no step limit.
Each header was checked against every name before it, once per suffix
tried, so a source whose headers repeat (a row of thousands of equal
labels, such as check boxes) took cubic time: 4,000 columns took 28 s
in Recommended PivotTables, and a row of 16,384 hung the app, in
PivotTables, in Insert Table and in Get Data from Table/Range. Taken
names and the next suffix of each header are now kept in hash maps, in
the pivot source, in table creation and in the XLSX pivot cache; the
names are the same as before.
Given whole columns ("range":"D:D"), Remove Duplicates compared a
million blank rows and reported 1,048,573 removed duplicates; given the
whole sheet it ran for minutes, as it also did on a table resized to
the whole sheet. Rows and columns past the used range are blank, so
they are left out: the result is the same and the counts match the
data.
With the whole sheet selected (Ctrl+A on an empty sheet), Fill Down,
Right, Up and Left tried to fill all 17 billion cells and ran out of
memory, and Remove Duplicates scanned every row for minutes. Fill Up
and Fill Left also took their source row or column before narrowing the
selection to the data, so they filled up to row 1,048,576 even when the
sheet had data.
Whole rows and columns now narrow to the used range before anything
else (nothing to do on an empty sheet), Remove Duplicates works on the
active cell of an empty sheet, and a fill of more than 5,000,000 cells
is refused, as Auto Fill already does.
jump() searched for the next filled cell by stepping (dr, dc); with
neither set, the step never moved, so selection.move {"jump":true}
from a blank cell of a sheet with content looped forever (found with
an agent call that left out dr and dc). It now returns the cell it
started from.
Every edit goes through cmd::commit, which read std::time::Instant::now()
up front for GRIDCRAFT_PROFILE timing, whether profiling was on or not.
wasm32-unknown-unknown has no clock: Instant::now panics there, and with
panic=abort the browser reports `RuntimeError: unreachable`. So in the
web app, typing 1 into A1 and pressing Enter trapped (#118). The native
apps and the CLI have a clock and were unaffected.
commit now reads the clock only when GRIDCRAFT_PROFILE is set, and
never on wasm. The other clock reads (NOW(), the current year for typed
dates, the UI's frame timer) already skip wasm.
Native tests can't see this trap, so clippy.toml now disallows
Instant::now and SystemTime::now. A clock read has to be gated off wasm
and allowed at the call, and `cargo clippy -D warnings` fails on the old
commit code. The existing, already-gated reads and the native-only perf
example allow it.
Checked by building the engine for wasm32-unknown-unknown into a small
cdylib that runs cell.set A1=1 on a new workbook, and running it in
Node: before the fix it traps with `RuntimeError: unreachable`, after
it returns normally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Ctrl+Shift+Enter array formula was evaluated like a dynamic array:
- Read from a file, the cached values in its range were kept as
constants, so the formula showed #SPILL! (any multi-cell array
formula saved by Excel or LibreOffice).
- Entered over C2:C4 with a two-value result, C4 stayed blank where
Excel shows #N/A, and a single-cell array formula spilled.
- On save, the values of its other cells were not cached.
The result is now fitted to the range: a single row or column repeats
across it, cells past the result show #N/A, and a larger result is cut
off. Reading drops the cached values inside the range, entering clears
the range, and saving caches its values. The formula bar shows the
formula in braces, {=B2:B3*2}.
A scatter or bubble chart whose X values are text (a first column of
labels) drew no points, as none of its X values were numbers. Like
Excel, such X values are now ignored and the points numbered 1..n.
chart.switchRowColumn did nothing on a chart read from a file: only
charts made by insert.chart keep their source range. Without one, the
command now takes the block the series' names, categories and values
span (when they're all on one sheet), and keeps it.
A combo chart whose series have no kind of their own (as insert.chart
makes them) was written as one column group, and a stock chart as
clustered columns, so both reopened as clustered columns. Combo series
are now written as drawn (the first as columns, the others as lines),
and stock charts as a stockChart group (as lines when they don't have
the 3 or 4 series a stock chart needs).
formula::parse("Sheet1!$D$1#") failed with "unknown error literal".
A # right after a cell reference is now the spill range operator: it
evaluates to the anchor's current spill range, or #REF! when the cell
doesn't spill (or its spill is blocked). Its users depend on the
anchor, so they recalculate when the spill grows or shrinks. Names can
use it.
Files have no # operator: like Excel, the writer stores A1# as
_xlfn.ANCHORARRAY(A1), and the parser reads that back as A1#.
The parser upper-cases function names, so =Double(4) showed as
=DOUBLE(4). Calls of LAMBDA names are now spelled like the name when
a formula is entered and when a file is read.
formulas.createFromSelection ignored bottom and right, and replaced any
workbook name with the same label without asking. Bottom-row and
right-column labels now name the cells above and to their left. An
existing name is kept and listed in the result's skipped, unless
replace is set (Excel asks; a command doesn't open dialogs).
Delete Cells built the references' edit and then discarded it, so
references into the deleted cells didn't become #REF!, and Insert Cells
treated the shift as a move of everything below, so =SUM(A1:A5) didn't
grow to A1:A6 when A3 was inserted.
Two new structural edits, InsertCells and DeleteCells, adjust a
reference like inserting/deleting rows (columns) when it lies within the
shifted columns (rows), and leave it as it is otherwise, as Excel does.
Formulas, defined names, chart series, conditional-format, validation,
table and print-area ranges, notes, links and sparklines follow.
On a protected sheet a formula formatted Hidden still showed with Show
Formulas on, matched Find in formulas, and showed in the formula bar
when a cell of its spill range was selected.
Only cell.set checked protection. The check now runs where every edit
is committed: an edit that changes the value or formula of a locked
cell on a protected sheet is refused with Excel's message, whichever
command made it (clear, paste, fills, setValues, replace, goal seek,
sort, insert/delete cells...).
Inserting and deleting rows and columns follow the protection's
options, and deleted lines must not hold locked cells.
Moving a sheet left its sheet-level names on whatever sheet took its
index, and copying it shifted nothing and copied no names. Moving now
re-indexes name scopes, and a copy gets copies of the sheet's names,
referring to the copy, as in Excel.
Deleting a sheet left names that referred to it pointing at nothing;
they now become #REF!. formulas.deleteName honours scope, and the Name
Manager's Delete passes it.
They are not error codes of the file format, and Excel refuses to open a
file whose cached values contain them ("Parameter error"). Any workbook
with a blocked spill could not be opened in Excel. Excel caches these
errors as #VALUE! itself.
Adds PtBr to the interface-language system from #31: a PORTUGUESE table
covering the same 259 chrome strings as JAPANESE (a test keeps the two
tables in step), Excel's pt-BR wording throughout, "pt"/"pt-br" codes
(serde-stored as "pt"), Português (Brasil) in the switcher, the
app.language.portuguese command, and first-run detection of a pt-BR
system locale via the existing tag reduction.
Reserve a strip beside the virtual grid for a theme-aware vertical
scrollbar. Support paging, dragging and wheel scrolling without changing
the active cell. Keep the scroll extent fixed during thumb dragging.
Co-authored-by: Codex <noreply@openai.com>
Resolve input styles from the destination cell, row or column before
parsing. Preserve inherited alignment, borders and number formats for
single-cell and bulk entry, including text paste and array formulas.
Co-authored-by: Codex <noreply@openai.com>
Opening a workbook with a SUM(E:J) on each of 50,000 rows took 9 s
(1.7 s at 25,000, 0.4 s at 12,500), and editing a cell that 50,000
VLOOKUPs read froze the app for 40 s or more. Two things grew with the
number of ranges:
- Evaluating a range trims it to the sheet's used range, and
Sheet::used_range walks every stored row. It ran once per range read,
so a full recalc was rows x range formulas. The pass's host now works
it out once per sheet (the workbook doesn't change during a pass);
Host::used_range keeps the old behaviour for other hosts.
- The reverse index kept each column's range precedents in a Vec:
finding a cell's dependents scanned every range in the column, and
re-entering a formula ran a retain over each of its columns. Pasting
a column, filling formulas down or sorting was quadratic. Each column
(and each sheet's wide ranges) now has an interval index: sorted by
start, with a segment tree of the largest end, so a lookup costs
O(log n + matches). Removals mark entries dead and insertions wait in
a small sorted set; Graph::settle merges them after each batch, so
edits cost O(log n) amortised. Single-cell dependents are a set, so
removing one is O(1). dependents_of_range asks each column once
instead of once per cell.
Semantics are unchanged: whole rows and columns, other sheets, names,
tables, spills and volatile functions go through the same paths.
Release CLI, open + full recalc: 12.5k rows 0.39 s -> 0.06 s, 25k 1.83 s
-> 0.14 s, 50k 9.3 s -> 0.28 s. The 50k x 20 bug-hunt workbook: recalc
on open 87 s -> 1.7 s, editing C2 (100k dependents) 51 s -> 1.3 s;
filling 50k SUMs down 17 s -> 0.18 s, sorting 50k rows 9 s -> 0.14 s.
Tests: the interval index against a linear scan (inserts, removals,
revivals, merges) and its query cost in tree nodes; range, wide,
whole-column, whole-row and cross-sheet dependents after edits; and a
scaling test (open, change a column, re-enter every formula) that
allows 8x the time for 4x the rows. Before the fix it took 16x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Excel adds up the range that starts at sum_range's top-left cell and has
the criteria range's height and width, whatever size sum_range has. With
A1:B3 = a,1 / b,2 / a,3, =SUMIF(A1:A3,"a",B1) is 4; GridCraft gave 1,
because if_numbers only saw sum_range's values (one cell) and read them
at the criteria positions. Its comment promised the resizing, but by then
the argument was a plain value.
The evaluator has the references, so it now does the resizing, as it does
for OFFSET and INDEX: for SUMIF and AVERAGEIF with three arguments, a
single-area sum range reference becomes resized_sum_range(sum, criteria
height, criteria width), cut off at the sheet's edge. It works across
sheets and through names. The dependency graph records the resized
range, so editing a cell inside it recalculates; when the criteria range
isn't a plain reference the formula is recalculated every time instead.
SUMIFS and AVERAGEIFS keep requiring same-size ranges (#VALUE!
otherwise).
Tests: a single-cell, shorter, wider, longer and horizontal sum range,
whole-column criteria, editing a cell of the resized range, the sheet
edge, another sheet and a defined name, and SUMIFS/AVERAGEIFS size
mismatches. They fail before the fix (1 instead of 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add support for Excel's "smoothed line" chart style. The smooth flag flows
model -> resolve -> chart render, persists through XLSX (<c:smooth val="1"/>),
and is toggled via the chart.addElement "smooth" element.
Rendering interpolates each gap-free run with a uniform Catmull-Rom spline
(0.5 tangent scale, 100 sub-segments per data segment), so the curve passes
through every data point and rounds the vertices — matching Excel's observed
behaviour. Long runs are still decimated before smoothing.
- model: Series.smooth (#[serde(default)], so old workbooks load unchanged)
- chart: SeriesData.smooth; smooth_run + catmull in render; wired through
resolve and the sanitize() copy; draw_runs takes the flag (line + scatter)
- engine: chart.addElement element="smooth"
- xlsx: read/write <c:smooth val> instead of hard-coding val="0"
- tests: smooth_line_adds_interpolated_points_and_keeps_endpoints,
smooth_line_splits_at_gaps, smooth_line_setting_round_trips
- examples/smooth_demo.rs: generates a straight+smooth side-by-side workbook
cargo xtask ci: all 7 steps pass.
serial_from_ymd worked out the real calendar date and then moved serials
up to 60 down by one. February 1900 has no 29th in the real calendar, so
DATE(1900,2,29) and DATEVALUE("2/29/1900") rolled over to 1 March (61),
and DATE(1900,3,0) gave 28 February (59). Excel's 1900 system keeps the
Lotus leap day: serial 60 is 29 February 1900, and DAY(60) already
said so.
The serial of the first of the month is now taken in Excel's calendar
(one lower up to February 1900), and the day, with any overflow, counts
on from there. So day and month overflow pass through serial 60 both
ways: DATE(1900,1,60), DATE(1900,4,-31) and DATE(1901,-10,29) are 60 too.
Month and day arithmetic is checked, so hostile values return None.
days_in_month_in gives February 1900 29 days in the 1900 system; EDATE
and EOMONTH use it, so EOMONTH(60,0) is 60 rather than the day before,
and the text parser uses it in place of its own special case. The 1904
system is unchanged (1900 is before its epoch).
Tests: serials around the leap day (overflow both ways, every serial
0-366 round-trips through its year, month and day), hostile months and
days, parsing 2/29/1900 and 1900-02-29, DATE/DATEVALUE/EDATE/EOMONTH
in the 1900 system and the 1904 system. The DATE, DATEVALUE and serial
cases fail before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
grid.rs measured the digit width once, for an 11pt cell font, and
display_text judged how many General digits fit from that whatever the
cell's size. 9pt marks such as 100 in columns with a stored width of 4
(common in mark sheets) came out as ##. The width is now measured per cell
with its own font and size, as print.rs already does; the shared 11pt
measurement and its paint_quadrant parameter go away.
Test: a 9pt 100 in a 28px column draws in full, while 123456789 next to it
still shows ####. Fails before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Narrow columns with tall, upright headers (mark sheets) set both a text
rotation and Wrap Text. The wrap path drew the text flat and returned before
the rotation was applied, so headers came out as 3-4 character fragments.
- Rotated text no longer takes the flat wrap path. Upright (+/-90 deg) text
wraps to the row height, its lines stacking across the column; other
angles keep one line, as before.
- Downward text was anchored at the bottom-left and ran out of the cell
(over the row headers); it now starts at the top, one block-height in.
- Upright text no longer overflows into empty neighbours: its width is its
line height, and the overflow repainted their gridlines.
- Print/PDF gets the same three fixes.
Tests: ui-egui interaction test (shapes drawn at +/-90 deg, inside a
one-column clip) and a print test (every text block on the page rotated,
a few long lines each). Each failed before the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
egui bundles only Latin glyphs, and the fallback chain appended just the
Han/Kana and Hangul faces, so Thai and Hebrew text rendered as tofu boxes
even though the text was read correctly.
Generalise the CJK fallback into a per-script fallback: every family now
also carries a Thai face (Leelawadee UI / Tahoma / Thonburi / Noto Sans
Thai) and a Hebrew face (David / Arial Hebrew / Noto Sans Hebrew). epaint
shapes the complex scripts itself via harfrust -- it only needed a face
that has the glyphs. The faces stay at the end of each chain, so Latin
still wins for Latin text.
The tests become script-general: a structural check that every family
ends with the loaded fallback faces, and a per-script render check that
the sample string resolves to a real glyph when the face is present.
Full bidi reordering of mixed LTR/RTL text is a separate epaint limitation
(no Unicode Bidi Algorithm); a pure other-script run is shaped correctly.
Add an interface-language preference. It defaults to the system's
language on first run (sys-locale, native only) and is switched by hand
from the View tab, next to Dark Mode, or with app.language.english /
app.language.japanese over the control channel. It is saved in ui.json.
Follows the filmcraft house convention: a Language enum and a
table of English-to-Japanese strings with English fallback, so coverage
can grow incrementally. Japanese chrome renders without any bundled
font: the CJK fallback faces already appended to every family cover
kana and kanji.
The ribbon chrome all flows through the small_button/big_button/
toggle_button/split_button/icon_button helpers and menu_items, so
translating there localises the whole ribbon in one place; the language
of the frame is read from egui context data (i18n::current). Command
ids, file names and document text are never translated.
Local clippy 1.96 flags pre-existing patterns the code has always had:
manual range-contains comparisons, a dead `|| true` filter and a
simplifiable boolean. None change behaviour; they only clear the lints
so `cargo xtask ci` passes on a current toolchain.
A Japanese (or Chinese/Korean) workbook opened fine — the engine reads the
strings correctly — but every kanji and kana drew as a .notdef box, because
egui's bundled fonts carry no CJK coverage and nothing added a CJK face to the
fallback chain.
Append a system CJK face to every font family (UI, cell, serif, mono): a
Japanese face first for correct kana/JIS kanji, a Chinese face for breadth, and
a Hangul face for Korean. epaint picks the first face in a family that has the
glyph, so Latin still renders from the Latin fonts and only Han/Kana/Hangul
reach the CJK fonts. No font bytes are bundled (AGENTS.md): families are loaded
from the OS, and a machine with no CJK font simply keeps the old behaviour.
Tests: the CJK keys end every family chain (Latin stays first); and the
proportional family resolves 「カテゴリ」 to real glyphs, not the replacement
character — verified to fail without the fallback.
The engine now preserves a workbook's VBA project, but the UI had no surface
for it and silently dropped every open-time warning. Give preserved macros an
honest, Excel-shaped home without a false "Enable Content":
- Info bar under the ribbon (Excel's message-bar slot) showing the warning a
`file.open` result carries. A VBA project gets an amber bar reading "This
workbook contains VBA macros. GridCraft keeps them intact when you save, but
does not run them." with a "What this means" button; anything else becomes a
blue informational bar. Dismissable; a clean open clears a stale notice.
- Macros dialog (Automate ▸ Macros, Alt+F8): lists GridCraft's own scripts and
the preserved VBA project (presence + size, not module names — the blob is
opaque). Mirrors Excel's Developer ▸ Macros without claiming to run VBA.
- Status-bar badge "Macros preserved (not executed)" whenever the workbook
carries a VBA project.
New panel `notice_bar` reuses theme tokens (notice_warn_*/notice_info_*), so it
follows light/dark. Two new UI commands, `ui.notice` and `ui.notice.dismiss`,
reach it from the control channel.
Tests: info bar appears for a macro workbook and clears on dismiss; a non-VBA
warning becomes an info bar; clean open clears the notice; the macros dialog
lists the VBA entry; status bar renders with a VBA project; Alt+F8 opens the
dialog. Snapshot-verified in light and dark.
Opening an .xlsm and saving it silently stripped xl/vbaProject.bin, turning a
macro-enabled workbook into a macro-free one — data loss for anyone whose work
depends on macros. GridCraft still does not run VBA, but it now carries the
project through unchanged.
- model: `Workbook.vba_project: Option<Vec<u8>>` (base64 in JSON via a new
`b64::opt` helper). `None` = no macros.
- xlsx read: capture the `vbaProject` relationship's bytes instead of warning
and dropping them; warn that macros are kept but not executed.
- xlsx write: when present, emit `xl/vbaProject.bin` byte-identical, add the
`.../relationships/vbaProject` relationship, switch the main part to the
macro-enabled content type (`...sheet.macroEnabled.main+xml`) and add the
`application/vnd.ms-office.vbaProject` override.
- engine: `view.macros` lists the preserved project alongside GridCraft's own
scripts (`{"kind":"vba","executed":false}`), so callers can see it is there.
- ROADMAP: scope now "preserve, don't execute" (was "out of scope").
Tests: round-trip byte-identical blob + correct content types/relationship;
capture from a foreign-produced .xlsm; a macro-free workbook stays clean.
Local clippy (1.96) flags pre-existing code that an older toolchain accepted;
these lints fire on clean main and block `cargo xtask ci`. All rewrites are
behavior-preserving:
- core/date.rs, numfmt/render.rs, functions/math.rs, functions/stat.rs:
`!x.is_finite() || x < a || x >= b` -> `!(a..b).contains(&x)` (Range::contains
is false for NaN/inf, matching the existing is_finite guard).
- functions/util.rs: `!(A && !B)` -> `!A || B`.
- engine/cmd/file.rs: drop a dead `.filter(|| true)`.
- ui-egui/grid.rs: `find(|| true)` was always the first table (use `.first()`);
`wrap || Justify || (nl && wrap)` -> `wrap || Justify`.
- ui-egui/formula_bar.rs: `tab || (enter && false)` -> `tab`.
- engine/cmd/spelling.rs: split the wasm/non-wasm dictionary init so the
non-wasm `mut` no longer warns under wasm check.
Excel-encrypted workbooks are CFB/OLE2 compound files (magic D0CF11E0),
not zip packages. sniff() now returns Format::Encrypted for them, and
open_bytes reports a clear password-protected message instead of the
cryptic 'not a valid zip package' error.
Excel anchors a floating object to the grid in one of three ways, stored in the
file as the drawing anchor's `editAs`: "move and size with cells" (twoCellAnchor,
the default), "move but don't size with cells" (oneCellAnchor / editAs="oneCell")
and "don't move or size with cells" (editAs="absolute"). GridCraft had no such
concept: xlsx import dropped `editAs`, export always wrote a plain twoCellAnchor,
and sorting/filtering never moved an object — so pictures stayed put when their
rows were reordered.
Model: add `AnchorMode` on `Anchor` (serde default = MoveAndSize, so existing
documents round-trip unchanged).
xlsx: read the anchor kind and `editAs` into the mode; write it back out
(editAs="oneCell"/"absolute" as Excel does).
Engine: sorting now moves objects anchored inside the sorted body with their
lines, re-fitting "move and size" objects, keeping "move but don't size"
objects' size, and leaving absolute ones pinned. Structural insert/delete
honours the same rule (previously it moved every object's cell unconditionally
and ignored shapes). New `object.setAnchorMode` command sets an object's mode;
`arrange.selectionPane` reports it.
Tests: sort moves images with their rows; absolute objects stay put across a
sort and a row delete; move-only keeps its size; the mode survives an xlsx
round-trip; the drawing fixture parses all three anchor kinds.
The font dropdown showed a hardcoded 17-name list and cells always
rendered in the built-in Calibri/role font, so a font installed on the
machine could not be listed or chosen. Both trace to one constraint:
egui panics when asked for a FontFamily::Name that was never bound to
font data, so only the 8 built-in roles were ever bound.
Scan the platform font folders for installed families (a bounded read of
each file's name table via skrifa, already compiled transitively through
epaint), bind the faces a workbook actually uses into egui as
fnt:{family}:{b|n}{i|n} roles chained to the defaults, and offer the
installed families in the font combos after the standard set. cell_family
returns the installed role when it is bound, else the previous bucketing,
so it never names an unbound family. The scan runs in the background and
the first frame that rebinds skips painting, like the existing startup
gate. Native only; wasm keeps the standard list.
Incidental: newer local clippy flagged pre-existing lints in untouched
crates (no toolchain is pinned); fixed behavior-preservingly so the gate
passes.
* About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle)
Adds an About GridCraft dialog (tabs About · Contributors · Models) following the craftrules
standard (standards/contributors.md): contributors/contributors.json is generated by
craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as
static tables. Each contributor line: @username, opt-in display/real name, PRs, commits,
lines +/−/Δ, binary assets +/−, first/last commit. Sortable by every column; alphabetical
ignores case and the leading @.
Reachable from the macOS app menu (About GridCraft, previously a web link), Help ▸
Contributors…, and Automate ▸ About GridCraft in the ribbon (all platforms and the web).
Dialog hyperlinks open through the host's open_url service. The snapshot example gains
--dialog 'name={json}' for offscreen renders of dialogs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section
build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: regenerate (verified people.toml entries)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- ui-egui: title bar with QAT, full ribbon (Home/Insert/Draw/Page Layout/Formulas/Data/
Review/View/Automate + Table/Chart Design), formula bar with Name Box, virtualised grid
(frozen panes, overflow, borders, CF data bars/scales/icons, tables, sparklines, filter
buttons, charts/pictures/shapes), in-cell editor with point mode, reference colouring,
autocomplete and argument hints, sheet tabs, status bar, dialogs, control channel
- apps/sheetcraft: desktop app (control server, file dialogs, prefs, drag & drop)
- chart: toolkit-free chart layout/rendering for 25 chart kinds + sparklines + rasteriser
- mcp + sheetcraft-cli: MCP server (headless or bridged) and CLI (info/convert/eval/cat/run/mcp)
- xtask (ci/layers/assets/wasm/version/parity), release workflows, packaging, placeholder icon
- automatic row heights
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>