Excel compatibility
Feature compatibility comparison between Excel/Google Sheets and Rows n Columns Spreadsheet
A comprehensive feature-by-feature comparison between Excel/Google Sheets and the Rows n Columns Spreadsheet component.
Legend:
✅ Full support
⚠️ Partial / known gaps documented inline
❌ Not supported (deferred or out of scope)
N/A Not applicable
At a glance
Import
✅
✅
⚠️
⚠️
✅
✅
Export
✅
❌
❌
❌
✅
✅
Password-encrypted file (CFB)
⚠️
❌
❌
❌
❌
N/A
XLSM imports identically to XLSX but discards macros/VBA on round-trip (security).
XLSB parser exists for read-only viewing; export round-trips through XLSX.
XLS (BIFF8, Excel 97-2003) imports through SheetJS's BIFF decoder (
libs/toolkit/excel-parser/plugins/xls): sheet names + visibility, cell values, number formats, formulas with their cached results, merges, column widths / row heights, hyperlinks, notes, defined names and the 1904 date flag. Cell fonts / fills / borders, conditional formatting, data validation, charts and drawings are not decoded; export round-trips through XLSX. An HTML / text export saved with a.xlsname still fails with the "not an Excel workbook" message and a password-protected BIFF workbook with the "save as .xlsx" guidance.CSV is value-only — no formatting, formulas, or sheet structure beyond rows/columns.
Password-protected XLSX export uses CFB encryption (
libs/toolkit/exporter/xlsx/xlsx-export.ts). Password-protected XLSX import decrypts agile (AES-CBC, Office 2010+), standard (AES-ECB, Office 2007) and RC4 CryptoAPI packages (libs/toolkit/excel-parser/cfb-decryptor.ts); extensible and legacy BIFF schemes are not.
Feature compatibility matrix
Basic editing
Cell editing
✅
✅
✅
Multi-cell selection
✅
✅
✅
Including non-contiguous via Ctrl/Cmd+click
Copy / paste / cut
✅
✅
✅
Clipboard API + internal copy buffer (preserves formulas, styles, merges)
Paste special
✅
✅
✅
Values / formats / formulas only, transpose, operations (add, subtract, multiply, divide — =dest+source, formulas stay formulas), skip blanks; every flag composes. Context menu → Paste special… / Ctrl+Alt+V opens the dialog (host-mounted <PasteSpecialDialogHost />); paste(options) API. Both engines
Go To (Ctrl+G / F5)
✅
⚠️ (Name box)
✅
Reference (A1, B2:C4, A:A, 3:3, Sheet2!A1, 'My Sheet'!B2) or defined name → select + scroll. Built-in dialog (<GoToDialogHost />) unless the host passes onRequestGoTo. Go To Special (Special… panel): Notes / Constants / Formulas / Errors / Blanks / Visible cells only, resolved over the current selection (the whole sheet for a single cell) into per-row selection runs; hidden rows / columns are skipped (resolveGoToSpecial). Row / column differences, precedents / dependents (use the trace arrows) and objects are not in the dialog
Evaluate Formula
✅
❌
✅
Context menu → Evaluate formula on a formula cell: read-only stepper over the formula's parse tree (@rowsncolumns/calculator createFormulaEvaluation), each step scratch-evaluated through onRequestCalculate (both engines); errors recovered via ERROR.TYPE. Step In / Step Out are not implemented (<EvaluateFormulaDialogHost />). Watch Window: <WatchWindowHost /> + context menu → Add to Watch Window; a floating panel lists sheet / cell / value / formula, refreshed with the grid, double-click jumps to the cell. Trace precedents / dependents arrows: toolbar ButtonTracePrecedents / ButtonTraceDependents in both engines (rnc through the engine's precedents() / dependents())
Undo / redo
✅
✅
✅
Immer patch-based; works alongside YJS / ShareDB
Find & replace
✅
✅
✅
Find pane + Cmd/Ctrl+H opens Replace; Replace + Replace All wired through useOnFindReplace (regex search, single-cell or whole-sheet scope, atomic undo)
Fill handle (drag to fill)
✅
✅
✅
Includes series detection (linear, date, weekday) + user-defined custom autofill lists via useSpreadsheetState({customAutofillLists})
Magic fill / smart fill
✅ (Sheets)
✅
⚠️
AI-driven via OpenAI integration; opt-in
Autofill from selection
✅
✅
✅
useOnFill, useOnFillRange
Remove duplicates
✅
✅
✅
onRemoveDuplicates callback
Text to columns
✅
✅
✅
Delimited (context menu one-click delimiters, or the dialog's delimiter list / "Other") + Fixed width (onSplitTextToColumns(…, delimiter, { columnBreaks }); the dialog's ruler / typed break positions, live preview). Both engines (split-text-to-columns carries columnBreaks; rnc-store cuts on character boundaries). Host-mounted <TextToColumnsDialogHost />; without it the context menu keeps the one-click delimiters only. Column data formats (Text / Date per column, "Do not import column") are not implemented — every field is General
Subtotal (Data → Outline → Subtotal)
✅
❌
✅
onSubtotal(sheetId, { range, groupByColumnIndex, aggregateColumnIndices, fn, hasHeader, replaceCurrent }): a " Total" row after each run of equal group-column values with =SUBTOTAL(n, first:last) (Excel's 1–11 codes) per aggregate column, a Grand Total row over the whole block, detail rows at outline level 2 / total rows at level 1 (the outline gutter's +/− and level buttons collapse them). Groups on runs — sort first, as in Excel — and on the DISPLAYED text of the group column, so a date column labels 1/1/2024 Total (not the serial), booleans TRUE Total, currency $1,000 Total. "Use function" follows Excel's dialog: Count = COUNTA (SUBTOTAL(3,…), non-empty cells), Count Numbers = COUNT (SUBTOTAL(2,…)). "Replace current subtotals" removes existing SUBTOTAL( rows AND resets the range's outline first, so re-applying yields the same three levels every time (Excel); unchecked, the new subtotals nest inside the old ones and the outline deepens (Excel's nested subtotals — the outline shape then differs from Excel's, which keeps the outer totals one level above the inner ones). Both engines through the streamed insert-row / change-batch / ungroup-rows / group-rows commands (the rnc adapter reads the range in one engine window). Context menu → Data tools → Subtotal… (<SubtotalDialogHost />). Not implemented: "Summary below data" off (totals above), "Page break between groups"
Advanced Filter (criteria range)
✅
❌
✅
onAdvancedFilter(sheetId, { listRange, criteriaRange, action: "filter" | "copy", copyTo, uniqueOnly }): criteria header row + rows (AND across a row, OR across rows; a blank criteria row matches everything, as in Excel), = <> < <= > >= operators, bare text = begins-with, ="=text" exact, * ? ~ wildcards, numeric compare when both sides are numbers. In place = hide the non-matching records (Data → Clear ≙ unhide rows); copy = header + matching records written at copyTo. Both engines (hide-row / show-row / change-batch). Context menu → Data tools → Advanced filter… (<AdvancedFilterDialogHost />). Not implemented: computed criteria (a formula under a header that is not a list header — ignored), date-typed criteria (>1/1/2024 compares as text), Excel's filter-mode flag on export
Goal Seek (What-If Analysis)
✅
❌
✅
onGoalSeek(sheetId, setCell, targetValue, changingCell): Excel's defaults (100 iterations, 0.001 tolerance), secant search with bisection once a sign change is bracketed and a back-off on worsening steps, same solver in both engines. JS reference: every trial is a scratch evaluation (the changing cell pinned via the calculator's override map, its dependent formula chain re-evaluated into the same map) and only the solution is written (one undo step). rnc engine: the goal-seek command iterates inside the store against the calc engine and commits the value as a set-value (one undo step). Dialog shows Excel's "found a solution" / "may not have found a solution" status (<GoalSeekDialogHost />, Context menu → Data tools → Goal seek…). Step / Pause are not implemented
Consolidate, Solver, What-If Data Tables, Scenario Manager
✅
❌
❌
Not implemented (Solver and Data Tables need a TABLE()-style multi-cell what-if engine; Scenario Manager stores named input sets — none of these are on the roadmap yet). Goal Seek covers the single-input what-if case
Drag & drop cells
✅
✅
✅
Within sheet + drop external files
Cell formatting
Font family
✅
✅
✅
Resolved against workbook theme fonts
Font size
✅
✅
✅
Bold / italic / underline / strikethrough
✅
✅
✅
Text color
✅
✅
✅
Theme + tint, indexed, RGB
Background color (solid)
✅
✅
✅
Pattern cell fills
✅
❌
✅
All 17 OOXML patternTypes render as tiled bitmaps and round-trip. Authoring: Fill colour popover → Pattern tab (pattern style + pattern colour painted over the solid colour) when the host passes fill + onChangeFill to BackgroundColorSelector
Gradient cell fills
✅
❌
✅
Linear (degree) + path (left/right/top/bottom) gradients with 2+ stops. Parser reads <gradientFill> from xl/styles.xml; exporter emits it back with <stop position><color rgb=…/></stop> children; canvas renderer paints via createLinearGradient / createRadialGradient. Gradient takes precedence over pattern fill (Excel UI treats them as mutually exclusive on one cell). Authoring: Fill colour popover → Gradient tab (two colours + Horizontal / Vertical / Diagonal up / Diagonal down / From center shading styles); picking a solid colour on a gradient cell replaces the gradient
Number formats (built-in)
✅
✅
✅
~68 built-in formats. Excel's built-in numFmtIds 0–49 round-trip as built-in ids in both toolkits (JS libs/toolkit, Rust rnc-import / rnc-export) — a cell whose pattern is a canonical built-in code (0.00, #,##0, 0%, h:mm, @, …) is written with that id and no custom <numFmt> entry, so Excel renders it natively. Ids 14 / 22 are Excel's locale Short Date / Short Date + time: both importers store them as the en-US codes m/d/yyyy / m/d/yyyy h:mm (DATE / DATE_TIME, e.g. 1/15/2024 / 1/15/2024 18:00) and both exporters write numFmtId="14" / "22" back — never ECMA's nominal mm-dd-yy / m/d/yy h:mm as a custom code (#939 X-11 / X-17)
Custom number format patterns
✅
✅
✅
Full pattern grammar — fractions (# ?/?), asterisk-padding (* ), locale tokens ([$$-409], [$€-2], [$£-809], [$¥-411]), conditional color/comparison segments ([Red][>100]…;[Blue][<0]…;…), elapsed-time brackets ([h]:mm), millisecond precision (hh:mm:ss.000), 4-segment patterns with @ string placeholder. Delegated to the numfmt library; audit tests in libs/utils/__tests__/formatter-edge-cases.spec.ts. As in Excel, a [$symbol-LCID] token selects the currency symbol and the language of day/month names and AM/PM only — digit grouping and the decimal separator follow the workbook locale, so [$€-407] #,##0.00 is € 1,234.50 in an en-US workbook and € 1.234,50 in a de-DE one, [$-407]0.00E+00 is 1.23E+03 and the fractional seconds of [$-407]h:mm:ss.00 take the workbook decimal mark (both engines). Excel's stock escaped date entries ([$-409]mmmm d\, yyyy;@, d\.m\.yyyy) render as dates in both engines
Decimal places (increase/decrease)
✅
✅
✅
Negative elapsed times ([h]:mm durations)
⚠️
✅
✅
Rows n Columns displays negative durations with a minus sign and the correct magnitude (-0:53:00), matching Google Sheets and Excel's 1904 date system. Excel in the default 1900 date system renders any negative time value as #######, so an exported file shows #### there until the workbook uses the 1904 system
Horizontal alignment
✅
✅
✅
left, center, right, justify, distributed, fill, centerContinuous. HorizontalAlign carries Excel's extra modes verbatim through both xlsx importers / exporters (rnc-model enum included). Canvas: justify / distributed wrap inside the cell's own width even without wrapStrategy: "wrap" (the row auto-fits when applied — alignmentImpliesWrap in @rowsncolumns/utils is shared by the renderer and the state hooks), justify justifies each line, distributed / centerContinuous render centred, fill repeats the text to the cell width from the measured natural width; none of the three overflow into neighbours. Toolbar: Justify icon + Distributed / Fill buttons in the alignment popover (centerContinuous renders / round-trips but is not authorable there)
Vertical alignment
✅
✅
✅
top, middle, bottom
Text wrap
✅
✅
✅
Text rotation (angle, vertical)
✅
✅
✅
0–360° + vertical orientation
Shrink to fit
✅
❌
✅
Indent
✅
✅
✅
Increase / decrease
Borders
✅
✅
✅
All 13 OOXML styles round-trip: solid (thin/medium/thick), hair, dashed, dotted, double, mediumDashed, mediumDashDot, dashDot, dashDotDot, slantDashDot. Canvas renderer paints distinct dash patterns via Canvas setLineDash for each variant
Border "outline + inside" composite
✅
❌
✅
"All borders" option in the border picker — changeBorder(..., "all", ...) sets borders on every edge of every cell in the selection
Merge cells
✅
✅
✅
Standard + merge across rows
Clear formatting
✅
✅
✅
Format painter / paint format
✅
✅
✅
Single + double-click toggle; copies merges (tiled with the pattern) and table banding as static fills, both engines (#620)
User-defined color palette
✅
✅
✅
userDefinedColors workbook setting
Workbook named cell styles (<cellStyles> — Heading 1, Accent N, Total, Good/Bad/Neutral)
✅
❌
✅
Three layers: (1) Toolkit round-trip preservation ✅ — parser reads <cellStyles> into workbook.workbookStyles.namedCellStyles; exporter accepts a namedCellStyles arg on createExcelFile. (2) Cell formatting from imported named styles ✅ — flows through the existing cellStyleXfs inheritance, so cells that reference a named style get the right font/fill on import. (3) UI gallery ✅ — the parser resolves each <cellStyle>'s cellStyleXfs[xfId] to a CellFormat (NamedCellStyle.format) honouring the applyX="0" flags (Excel's Percent / Comma / Currency are number-format-only; a style without flags such as Normal includes its default font, as in Excel), the import worker sends the list with the theme, useSpreadsheetState keeps it as namedCellStyles (onChangeNamedCellStyles) and CellStyleSelector renders it as a Workbook styles section (hidden / Normal / format-less entries skipped). Forward namedCellStyles to createExcelFile for the round-trip. Rust engine: rnc-import does not surface <cellStyles>, so the gallery stays empty for documents loaded through the rnc engine. Cells store resolved formats (uf) rather than style refs; applying a named style snapshots its format onto each cell
Conditional formatting → cell style
✅
✅
✅
See Conditional Formatting section
Rows & columns
Insert / delete rows
✅
✅
✅
Shifts dependent ranges + formulas. A Ctrl-selected (non-contiguous) delete follows Excel on both engines: every reference shifts by the number of deleted rows above it and a reference into a deleted row breaks to #REF! — deleting rows 2 and 4 turns =A5 into =A3, =A3 into =A2, =A4 into =#REF!, =SUM(A1:A5) into =SUM(A1:A3); defined names, CF / DV ranges, tables, merges, filters, row metadata and the other range-bound state close up the same way (#939 K-09)
Insert / delete columns
✅
✅
✅
Same non-contiguous rule as rows: deleting columns B and D turns =E1 into =C1, =C1 into =B1, =D1 into =#REF!; a table loses only its deleted columns and is removed only when every one of its columns goes (#939 K-09)
Insert / delete cells (shift)
✅
✅
✅
Shift up/down/left/right
Move rows / columns
✅
✅
✅
Re-anchors conditional formats + validations
Resize rows / columns
✅
✅
✅
Drag handle + auto-fit
Hide / show rows / columns
✅
✅
✅
Separate from group-collapse
Freeze rows / columns (panes)
✅
✅
✅
Split (non-frozen) panes
✅
❌
❌
Deferred — the canvas grid renders one scroll viewport per sheet. Import maps <pane state="split"> to a plain unfrozen view (its xSplit/ySplit are pane positions in twips, never freeze counts) and export writes no pane, so a split is not preserved through a round-trip (#546 D-21)
Group / ungroup rows / columns
✅
✅
✅
7 nesting levels; XLSX + ODS round-trip
Collapse / expand outline groups
✅
✅
✅
+/- gutter buttons, 1/2/3 level selector
Show row/column headers toggle
✅
✅
✅
showRowColHeaders round-trips xlsx; canvas showHeaders prop honors it via useSpreadsheetState
Right-to-left layout
✅
✅
⚠️
Flag round-trips, grid does not mirror yet. <sheetView rightToLeft="1"> imports (XLSX + XLSB BrtWsView.fRightToLeft) and exports in both the JS toolkit and the Rust engine; ODS carries it as the table style's style:writing-mode="rl-tb". Surfaced as Sheet.rightToLeft for hosts. Mirroring the canvas (column A on the right) is deferred — the column-offset math in libs/grid / canvas-grid assumes LTR (#546 D-21)
Formulas & functions
Formula bar
✅
✅
✅
With Name Box + range selector
Formula autocomplete
✅
✅
✅
Function names + named ranges + table refs
Function tooltips (arg hints)
✅
✅
✅
Per-arg highlight as you type
A1 cell references
✅
✅
✅
Absolute ($) + relative
Range references
✅
✅
✅
Cross-sheet references
✅
✅
✅
Sheet1!A1, 'Sheet 2'!A1
Cross-workbook references
✅
✅
⚠️
[Book.xlsx]Sheet!A1 round-trips opaquely in both engines — the xl/externalLinks/* parts (with Excel's cached values), their rels and <externalReferences> are preserved on import and re-emitted on export (JS toolkit + rnc-import / rnc-export), and the [N]-prefixed formulas keep their cached results. The linked workbook is never opened, so the reference cannot be evaluated or edited here (Phase 6 remainder)
Named ranges (range-typed: MyRange = A1:B10)
✅
✅
✅
Workbook + sheet scope. XLSX: a sheet-scoped name (<definedName localSheetId>) imports as scopeSheetId and exports with localSheetId again, so two sheets can each own a LocalRange and Excel's hidden _xlnm._FilterDatabase stays sheet-scoped — never duplicate global names that make Excel repair the file; one entry per (name, scope) pair (#939 X-05)
Named ranges (static value: MyVal = 42)
✅
✅
✅
Scalar value returned directly. XLSX round-trips numeric (TaxRate = 0.2) and string ("Hello") constant names alike (#939 X-05)
Named functions (LAMBDA-typed: MyFunc = =LAMBDA(x, x*2)) called as =MyFunc(args)
✅
✅
✅
Engine's _callFunction fallback resolves named LAMBDAs three ways: pre-built lambda value, {ref, value} wrapper, or the raw "=LAMBDA(...)" source string (parses + applies). Works in both the main-thread calc and the worker since both use the same fast-formula-parser engine. The named-range editor's "Text or Formula" type works out of the box
Named ranges (formula-typed: MyVar = =SUM(A1:A10)) used as a value =MyVar (not as a call)
✅
✅
✅
Excel virtual-cell model: each formula-typed name is a first-class StaticNode (nr:<scope>:<name>) in the DAG. Cell formulas referencing the name get real DAG input edges to that node, so the unified topological sort recomputes them whenever the named range's value changes. Workbook + sheet scope are distinct (keyed by numeric sheetId so renames don't break things), lookup follows Excel rules (sheet-scoped first, workbook fallback). Order-independent registration via two-pass batch: fullPrice = price * 1.08 wires the nr:price → nr:fullprice edge even if listed before price
Workbook & sheet-scoped names with the same display name
✅
✅
✅
E.g. workbook Tax = =0.08 and sheet-1 Tax = =0.20 coexist as separate DAG nodes; resolved per Excel scoping (sheet first, then workbook)
Chained named ranges (fullPrice = price * 1.08)
✅
✅
✅
Real static-to-static DAG edge; unified topo sort evaluates price before fullPrice before any cell using fullPrice
Named-range edit in Name Manager (re-define formula)
✅
✅
✅
Static node updated in place — dependent cells stay attached, are marked dirty, and recompute with the new value on next recalc
Named range JSON round-trip (hydrate calc state from saved DAG)
✅
❌
✅
Dag.toJSON / fromJSON serialize both directions of the cell ↔ named-range edges so a rehydrated calculator matches the in-memory model
Structured table references
✅
❌
✅
Table1[#Headers], Table1[@[Col 1]], totals row
~378 Excel-compatible functions
✅
✅
✅
Math, statistical, text, date, financial, engineering, logical, info, lookup, web, etc.
GETPIVOTDATA(value_field, pivot_anchor, [field, item]…)
✅
✅
✅
Header-matched lookup into a rendered pivot — locates the value cell for the given (field=item) tuple by scanning the pivot's row + column header bands
Dynamic arrays / spill
✅
✅
✅
FILTER, SORT, SORTBY, UNIQUE, SEQUENCE, RANDARRAY. xlsx round-trip: a spilling anchor exports as Excel 365 writes it — <c cm="1"><f t="array" ref="A1:B2"> plus xl/metadata.xml (XLDAPR) — so Excel re-opens it as a spilling dynamic array, not a legacy {=…} CSE array; an imported cm="1" anchor keeps that shape (#939 X-12)
Excel file-format function prefixes (_xlfn., _xlfn._xlws., _xlpm., _xludf.)
✅
n/a
✅
Storage-only markers Excel never shows. Import strips them all (=_xlfn._xlws.FILTER(…) → =FILTER(…), _xlfn.LET(_xlpm.x,…) → LET(x,…)), export re-adds them from one shared modern-function list (libs/toolkit/helpers/excel-formula-prefixes.ts), including _xlpm. on every LET / LAMBDA parameter and _xlfn._xlws. on FILTER / SORT / PY; defined names included (#939 X-02)
#SPILL! / #CALC! / #FIELD! errors
✅
✅
✅
Modern error codes implemented
A1# spilled-range operator
✅
❌
✅
Lexer + grammar + eval + dep resolver all wired; resolves to the current spill bounds via Calculator.arrayRangeMap. xlsx stores it as _xlfn.ANCHORARRAY(A1) — imported back to A1#, exported from A1# (#939 X-02)
@ implicit-intersection operator
✅
❌
✅
xlsx stores it as _xlfn.SINGLE(x) — imported back to @x, exported from @x
Array formulas (legacy CSE)
✅
✅
✅
Ctrl+Shift+Enter. An imported <f t="array"> without cm="1" is remembered as a CSE array (cse on the anchor cell) and exported without cm, so Excel keeps showing {=…}
Modern array helpers (VSTACK, HSTACK, TOCOL, TOROW, etc.)
✅
✅
✅
Phase 6 shipped
Pick functions (CHOOSEROWS, CHOOSECOLS)
✅
✅
✅
ROWS / COLUMNS of an array (ROWS({1,2;3,4}), COLUMNS(RANDARRAY(3,2)), ROWS(SORT(…)))
✅
✅
✅
Both engines report the array's shape; a scalar is a 1×1 array (ROWS(5) = 1) and an error argument is the result (ROWS(NA()) = #N/A). The JS engine rejected every array with #VALUE!
Custom functions
✅
✅
✅
Register via formula engine
LET
✅
✅
✅
Implemented via source-level expansion: LET(name, value, ..., body) inlines (value) for each name in the body before parse. Nested LET supported.
LAMBDA (IIFE: =LAMBDA(x, x*x)(5))
✅
✅
✅
Inlined at compile time via the LAMBDA preprocessor
LAMBDA as a value (passed to MAP/REDUCE/etc.)
✅
✅
✅
Standalone LAMBDA(...) transforms to __RNC_LAMBDA__("p,...", "body source") which constructs a runtime lambda value {__isLambda, params, body}
MAP / REDUCE / BYROW / BYCOL / SCAN / MAKEARRAY
✅
✅
✅
HOFs accept lambda values and re-invoke the parser per element with bindings substituted in. Recursion guard caps depth at 256
Closures (lambda captures outer LET binding)
✅
✅
✅
LET expands before LAMBDA, baking captured values into the body source — e.g. LET(n, 5, LAMBDA(x, x+n)) becomes __RNC_LAMBDA__("x", "x+(5)")
LET-bound lambda used as a function (LET(double, LAMBDA(x, x*2), double(5)))
✅
✅
✅
LET substitutes Function-shaped occurrences of binding names with the lambda source, then the IIFE inliner collapses it
Dep tracking through lambda bodies (MAP(A1:A3, LAMBDA(v, v+B1)) tracks B1)
✅
✅
✅
DepParser walks __RNC_LAMBDA__ body strings via a fresh inner parser instance
Recursive lambdas (LET(fact, LAMBDA(n, IF(n<=1, 1, n*fact(n-1))), fact(5)))
✅
✅
❌
Body-reparse at runtime loses the LET binding for fact; needs a runtime registry that survives per-call re-parse. Deferred
GROUPBY / PIVOTBY
✅
❌
✅
Built on the existing LAMBDA infra — LambdaFunctions entries + funsNeedContext registration only; no chevrotain grammar changes. GROUPBY buckets rows by row-field tuple and invokes the aggregation lambda once per group; PIVOTBY adds a column axis. Args supported: field_headers, total_depth (grand total top/bottom), sort_order (asc/desc), boolean filter_array. Aggregation lambdas receive the group's value vector as an Excel array literal {a, b, c} (already lexed natively), so any aggregator works — LAMBDA(v, SUM(v)), LAMBDA(v, AVERAGE(v)), custom LAMBDAs. Dep tracking through lambda bodies is automatic via the existing __RNC_LAMBDA__ DepParser branch
FORECAST.ETS* (Holt-Winters)
✅
❌
✅
FORECAST.ETS / .CONFINT / .SEASONALITY / .STAT in both engines (the Rust port follows the JS fitter, so both agree to 1e-9 on the same inputs): additive Holt-Winters (level + trend + seasonal), 5×5×5 grid search over α/β/γ to minimize in-sample SSE; seasonality follows Excel — 1 / omitted auto-detects (candidate periods 2..min(N/2, 12)), 0 fits without a seasonal component, n > 1 is the period. data_completion / aggregation are validated but do not change the fit (no gap filling, duplicate timeline points are not aggregated). Matches Excel within ~1% on typical seasonal series. An error value anywhere in values / timeline is the result in both engines (text and blanks are skipped)
Formulas Excel refuses to enter (=SUM(), =IF(1), =SUM(1, =3-, a lone =)
❌ (entry refused)
✅
⚠️
Stored rather than refused, with one contract in both engines: too few arguments evaluates to #N/A (Sheets' value), a syntax error to #ERROR! "Formula parse error." with the typed text kept for re-editing, and a lone = is the text = (whitespace after the =, = A1, is still a formula). The Rust engine applies the #N/A rule to every function through its min_args; the JS engine applies it per function — pinned for SUM / COUNT / AVERAGE / IF / VLOOKUP / INDEX / SUMIF / COUNTIF / COUNTIFS, malformed LET / LAMBDA, and the LAMBDA helpers (MAP / BYROW / BYCOL / MAKEARRAY) — so a JS function that does not validate its arguments can still surface #VALUE! instead. Enough arguments of the wrong kind is a different error in both: LET(x,1,2,y) / REDUCE(0,A1:A3) #VALUE!, a body-less LAMBDA(x) #CALC!
DATEDIF(…, "md") month-end quirk
✅
✅
✅
Both engines reproduce Excel's roll-over: DATEDIF("2024-01-31","2024-03-01","md") is -1 (the start day is moved into the month before the end date and 2024-02-31 rolls to 2024-03-02), the documented "MD may be negative" behaviour — not the intuitive 1
Iterative calculation
✅
✅
✅
enabled / iterations / delta parsed & honored. Options UI: <CalculationOptionsDialog /> (enable / maximum iterations / maximum change, Excel's 100 / 0.001 defaults) — the host applies the result through the iterativeCalculation prop (legacy engine) or the set-iterative-calculation engine command (rnc); the rnc engine does not yet expose the current policy back to the UI
1904 date system
✅
❌
⚠️
A <workbookPr date1904="1"/> workbook is converted to the 1900 system on import in both engines: date / datetime-formatted numbers (literals and cached formula results) are shifted by 1462 days, so the dates Excel showed, DATE() / YEAR() / TEXT() and the displayed values all agree, and the file exports as a 1900-system workbook; useOnImportExcelFile resolves use1904: false (the data's system) and reports the file's flag as sourceUse1904. Known gap: a General-formatted serial that only a dependent cell formats as a date is not shifted (the format is not on the literal). The engines run only on the 1900 system — there is no in-app toggle, and negative time values render as -h:mm (not #######) regardless
Manual / on-demand calculation
✅
❌
✅
Toggleable; queue-based dispatcher
Calculation in web worker
✅
✅
✅
Off-main-thread engine via worker
Formula dependency tracing
✅
✅
✅
Precedents / dependents (DAG-backed)
Formula auditing (highlight refs)
✅
✅
✅
Circular reference detection
✅
✅
✅
Surfaces in calculation engine
Spill-range blocking detection
✅
✅
✅
Emits #SPILL!
Formula protection (lock formulas)
✅
✅
✅
Per-cell locked + protection metadata
External / cross-workbook links
✅
❌
⚠️
Imported, cached and re-exported by both engines (link parts + rels + <externalReferences>); the linked workbook is never opened or refreshed, so cross-workbook references can't be edited
Localized function names
✅
❌
❌
Deferred (Phase 11); always English on the wire
Formula format derivation
When a formula cell has no explicit uf.numberFormat, the rendered format is auto-derived from the formula's structure and precedents — matching how Excel and Google Sheets infer the format of a calculated cell (e.g. =TODAY() → DATE, =A1*B1 where A1 is currency → CURRENCY). Derivation runs at render time inside getDerivedFormat (libs/spreadsheet-state/hooks/use-sheet-properties.ts); results are cached per cell via an LRU keyed on (value, precedent format refs) so invalidation propagates whenever a direct precedent's format object identity changes.
Function return-type defaults
✅
✅
✅
~140 functions: DATE/TIME producers — TODAY/NOW/DATE/TIME/EDATE/EOMONTH/WORKDAY/WORKDAY.INTL render as DATE/TIME/DATE_TIME; Date extractors (DAY/MONTH/YEAR/HOUR/MINUTE/SECOND/WEEKDAY/WEEKNUM/ISOWEEKNUM/DATEDIF/DAYS/DAYS360/NETWORKDAYS/NETWORKDAYS.INTL/YEARFRAC/DATEVALUE/TIMEVALUE) render as NUMBER; Math/trig (POWER/SQRT/EXP/LN/LOG/LOG10/SIGN/SIN/COS/TAN/ASIN/ACOS/ATAN/ATAN2/SINH/COSH/TANH/asinh-acosh-atanh/DEGREES/RADIANS/PI/FACT/COMBIN/PERMUT/GCD/LCM/SUMSQ) → NUMBER; Aggregation/rounding (SUM/SUMPRODUCT/ROUND family/INT/MOD/QUOTIENT/CEILING/FLOOR/MROUND/TRUNC) → NUMBER with CURRENCY-precedent preservation; Counts (COUNT/COUNTA/COUNTIF/COUNTIFS/ROW/COLUMN/ROWS/COLUMNS/RANK/RANK.EQ/RANK.AVG/FREQUENCY/LEN/SEARCH/FIND/MATCH/XMATCH) → NUMBER; Text (TEXT/LEFT/RIGHT/MID/CONCAT/CONCATENATE/TEXTJOIN/LOWER/UPPER/PROPER/TRIM/CLEAN/SUBSTITUTE/REPLACE/REPT/ADDRESS/CHAR/UNICHAR/T/DOLLAR/FIXED/BAHTTEXT) → TEXT; Statistical scalars (AVEDEV/DEVSQ/KURT/SKEW/SKEW.P/COVAR/COVARIANCE.S-P/CORREL/RSQ/SLOPE/INTERCEPT/STEYX/PEARSON) → NUMBER; Financial rates (XIRR/IRR/MIRR/RATE/NPER/EFFECT/NOMINAL) → NUMBER
Arg-index inheritance
✅
✅
✅
SUMIFS/AVERAGEIFS/MINIFS/MAXIFS (sum_range), SUMIF/AVERAGEIF (conditional 1 vs 3 args), FILTER/INDEX/SORT/SORTBY/UNIQUE/TAKE/DROP/TOCOL/TOROW/WRAPROWS/WRAPCOLS/CHOOSECOLS/CHOOSEROWS/TRANSPOSE, VLOOKUP (literal col), HLOOKUP (literal row), XLOOKUP (return_array)
Statistical functions inherit format from data
✅
✅
✅
MEDIAN, MODE/MODE.SNGL/MODE.MULT, PERCENTILE/.INC/.EXC, QUARTILE/.INC/.EXC, LARGE, SMALL, TRIMMEAN, GEOMEAN, HARMEAN, AVERAGEA, MINA, MAXA, VAR/.S/.P/VARA/VARPA, STDEV/.S/.P/STDEVA/STDEVPA, FORECAST/.LINEAR/.ETS (y values arg)
Financial functions inherit CURRENCY from principal
✅
✅
✅
PMT/PV/FV (pmt or pv arg), NPV/XNPV (first value), IPMT/PPMT (pv), CUMIPMT/CUMPRINC (pv), DDB/SLN/SYD/DB (cost)
Arithmetic format inference
✅
✅
✅
AST-based combineTypes over ADD/SUB/MUL/DIV/EXP/CONCAT/COMPARE — DATE − DATE → NUMBER, DATE + N → DATE, %A × %B → NUMBER, CURRENCY × N → CURRENCY, A & B → TEXT, A = B → GENERAL
Conditional branch merge
✅
✅
✅
IF, IFS, IFERROR, IFNA, CHOOSE, SWITCH — result-arg types merged via mergeValueTypes
ROUND family decimal adjustment
✅
✅
✅
ROUND(currency, 0) shrinks the pattern's decimal count; ROUNDUP / ROUNDDOWN / TRUNC / INT / MROUND / CEILING / FLOOR / QUOTIENT covered
First-non-generic precedent wins (fold loop)
✅
✅
✅
NUMBER/GENERAL/COUNT are generic and get upgraded; once a specific format is locked in (PERCENT, CURRENCY, DATE, etc.) it stays — matches Excel's first-encountered semantics. AST aggregate handlers (SUM/AVG/MIN/MAX) still apply Excel's "currency wins" rule independently
CURRENCY pattern preservation across NUMBER coercion
✅
✅
✅
The "$"#,##0.00 symbol+pattern survive when SUM / ROUND / etc. coerce the result type to NUMBER
Date pattern fidelity through date-arithmetic functions
✅
✅
✅
=EDATE(A1, 1) with A1's custom yyyy-mm-dd keeps yyyy-mm-dd instead of falling back to the locale default; same for EOMONTH, WORKDAY, etc. when the precedent is date-family
Default patterns synthesized for date/time when no precedent has one
✅
✅
✅
=TODAY() → DATE with getDefaultDateFormat(locale), =NOW() → DATE_TIME, =TIME(…) → TIME (without these the renderer falls back to General and shows the serial number — the original =TODAY() bug)
Anchor-only range inheritance
✅
✅
✅
SUM(A1:A10) inherits A1's format; O(1) per range, matches Excel's first-cell rule
Style references (cellXfs resolution)
✅
✅
✅
Precedents stored as { sid } style refs are resolved through the workbook <xfs> table before their numberFormat is read
Multi-level chain propagation
✅
✅
✅
C1 = B1 = A1 = TODAY() — all three render as DATE. Single-pass via cache fingerprint invalidation; no recursion through the dependency graph
Cycle safety in format derivation
✅
✅
✅
The calculator rejects cycles; if one slips through (malformed graph during teardown) the cache-based lookup terminates without looping
OFFSET / INDIRECT / INDEX with computed indices / A1# spill refs
✅
✅
⚠️
Falls back to the function's default return type — runtime-resolved targets aren't traced for format inheritance
AGGREGATE / SUBTOTAL function-number arg decoding
✅
✅
❌
First arg (1=AVG, 9=SUM, …) selects the inner aggregator; we don't decode it, so format follows the precedent fold rather than the selected function's semantics
Format derivation through LAMBDA / MAP / REDUCE bodies
✅
✅
⚠️
Lambda bodies aren't re-introspected by the format inference; result format follows the precedent fold. Formula evaluation itself works (see Phase 7 rows above)
Cross-workbook reference format inheritance
✅
✅
❌
Same scope as cross-workbook formula support — deferred with the rest of Phase 6
Multi-currency arithmetic ($A + €B)
✅
✅
⚠️
We follow Excel's general rule (first encountered for ADD/SUB, NUMBER across currencies for MUL/DIV); a few corner cases differ from Excel's exact behaviour
Implementation guarantees:
No user-space recursion through the cell dependency graph. Earlier versions walked precedents recursively; deeply chained formulas could blow the JS stack. The current implementation is iterative — precedent lookups are O(1) cache reads, so chain depth is unbounded.
Single-pass cache convergence in the common case. Row-major iteration naturally visits precedents before dependents, so
getEffectiveFormatreturns the correct format in one render pass. The LRU fingerprint catches any out-of-order resolution on the next render.Per-call cost. Cold-cache, single-precedent cell: ~2.5 μs. Warm-cache: ~140 ns. A 1000-cell render takes ~2.5 ms — well inside a 16 ms frame budget. Coverage measured by
libs/spreadsheet-state/hooks/__tests__/use-sheet-properties.bench.ts.Compatibility coverage. ~96% of common Excel/Sheets format derivation cases. Remaining gaps: runtime-resolved references (OFFSET/INDIRECT), AGGREGATE/SUBTOTAL function-number decoding, format introspection through LAMBDA bodies, cross-workbook references — see the partial-support rows above.
Data features
Tables (structured data)
✅
❌
✅
Full TableView lifecycle. A table's filter buttons round-trip like Excel: the table-level toggle is the presence of <autoFilter> in the table part, the per-column toggle is <filterColumn colId hiddenButton="1"> ↔ Column.filterButton (JS toolkit xlsx + xlsb import, xlsx export; rnc-import / rnc-export; the rnc store's TableDef persists both the table-level toggle and each column's filterButton, so they survive a store load → export). A table's <autoFilter ref> spans the header + data rows only — never the totals row, which Excel neither filters nor sorts and repairs when included (#939 X-15)
Table styles + banded rows / columns
✅
❌
✅
Header / total / first / last column overrides. The totals row's bold font and double top rule come from the style's totalRow band (painted by the grid, baked by Convert to Range / the format painter), not from an explicit cell format
Table totals row
✅
❌
✅
Per-column aggregation preserved: <tableColumn totalsRowFunction / totalsRowLabel> + <totalsRowFormula> round-trip as Column.totalsRowFunction / totalsRowLabel / totalsRowFormula in the JS toolkit (xlsx + xlsb import, xlsx export) and the Rust engine (rnc-import / rnc-export). A totals row built in-app (label + SUBTOTAL(1xx,…)) exports with the matching function so Excel's totals dropdown is pre-selected; the totals cells keep their own formula text and number format. Metadata is kept while the totals row is hidden (totalsRowCount="0"), as in Excel. Rust-engine (rnc-store) load → export: the imported totals cells are preserved (never re-seeded), so labels and SUBTOTAL(1xx,…) functions round-trip via the cells; a custom totals formula round-trips as the cell formula but its totalsRowFunction="custom" attribute is not persisted by the store's TableDef (headers only)
Custom table styles
✅
❌
⚠️
Built-in styles fully wired; user-authored styles round-trip-only. Built-in TableStyleLight* / Medium* / Dark* gallery ships in TableStyleSelector (theme-accent-derived), and a table picks one via TableView.styleName. User-authored XLSX <tableStyle> blocks round-trip preservation works — parser exposes them via workbook.workbookStyles.getCustomTableStyleExports(), exporter re-emits them via the customTableStyles arg on createExcelFile (each <tableStyleElement> re-registered as a dxf). What's NOT wired: the TableStyleSelector picker doesn't surface custom styles, useSpreadsheetState doesn't expose the list as reactive state, and applying a custom style by name to a TableView isn't a code path — host has to forward + render
AutoFilter (basic)
✅
✅
✅
Text, number, date, blank, error criteria. XLSX round-trip on both engines follows the schema: <filterColumn> carries exactly ONE of filters / top10 / customFilters / dynamicFilter / colorFilter / iconFilter (precedence colorFilter → iconFilter → condition → value list — a color / icon column is never padded with a synthesized <filters> list); every customFilter operator round-trips verbatim (lessThan stays lessThan, >= 10 AND < 50 stays a two-clause Custom AutoFilter, notEqual + x* — "does not begin with" — stays notEqual as a TEXT_NOT_EQ with the wildcard value); the checked "(Blanks)" entry is <filters blank="1">, never a <filter val>; Excel's date tree (<filters><dateGroupItem year month day … dateTimeGrouping/>) round-trips on FilterCriteria.dateGroups (the in-app filter keys on visibleValues / condition); <top10> round-trips as NUMBER_TOP_N / NUMBER_TOP_PERCENT / NUMBER_BOTTOM_* (#939 X-06)
Filter by color
✅
✅
✅
Phase 3 shipped (XLSX + ODS round-trip). A color-filtered column exports <colorFilter> alone (#939 X-06)
Filter by icon
✅
❌
✅
Phase 3 shipped. An icon-filtered column exports <iconFilter> alone (#939 X-06)
Multi-column sort
✅
✅
✅
Multi-level Sort dialog (SortRangeDialog) — add / delete / reorder "then by" levels, each with column + sort-on (value / cell color / font color / icon) + order — opened from the AutoFilter dropdown's Custom sort…. Hooks accept multi-spec (onSortRange per-column SortSpecs[], onSortTable multi-dimension overload). The context-menu range "Sort A→Z / Z→A" remains single-column. A table's persisted levels round-trip as <sortState><sortCondition ref descending sortBy dxfId iconSet iconId customList/></sortState> ↔ TableView.sortSpecs (JS toolkit xlsx + xlsb import, xlsx export; rnc-import / rnc-export, seeded into the rnc table-filter facet)
Sort by color / icon
✅
❌
✅
Selectable per level in the Sort dialog; color / icon swatches are auto-discovered per column (shared with the filter's discovery via useColumnColorIcons)
Custom sort orders / lists
✅
❌
✅
Surfaced in the Sort dialog from useSpreadsheetState({customAutofillLists}); the chosen list drives spec.customList
Data validation
✅
✅
✅
list, range, decimal, whole, date, time, textLength, custom formula. Rule editor authors Excel's Whole number / Time / Text length kinds (operator + bounds): stored as xlsxSource (both exporters replay type="whole" / "time" / "textLength") plus a composed condition both engines evaluate for the red marker (INT() / LEN() custom formula, NUMBER_* over time serials); imported rules of those kinds open in the same form. Both importers carry the source rule as xlsxSource — the JS toolkit and rnc-import alike — so type="whole" / "time" / "textLength" survive the round trip through either engine instead of coming back as decimal / date / custom; time is a NUMBER_* comparison over day-fraction serials on both importers, and an imported Whole-number rule rejects non-integers on both engines (the INT() guard is applied at evaluation from xlsxSource.type="whole", so 5.5 fails "whole between 1 and 10" like in Excel). Editing a rule's generic bounds drops a stale imported xlsxSource instead of replaying it. Commit-time enforcement (Excel errorStyle) — see the error/input messages row
Data validation error/input messages
✅
✅
✅
XLSX showInputMessage / promptTitle / prompt and showErrorMessage / errorStyle / errorTitle / error import and export in both the JS toolkit and the Rust engine (rnc-import carries them as inputMessage / alert). An alert with Excel's default text (a bare showErrorMessage="1", or a rule whose alert has a style but no title/body) exports showErrorMessage="1" from both exporters — the flag, not the custom text, is what makes Excel reject the entry (#939 D-08). Authorable in the rule editor (input-message title/body + error-alert style/title/body) and enforced on direct cell entry: stop rejects the value (blocking modal, cell unchanged, focus stays); warning / information allow override (OK / Cancel). Input message renders as a cell tooltip on hover (#130); an Invalid cell's tooltip shows the rule's own error title / body instead of the generic marker text (#131). Paste / fill keep the flag-only red marker; CUSTOM_FORMULA rules fall back to flag-only (no synchronous eval at commit) except the Whole number / Time / Text length kinds with literal bounds, which block at entry like Excel. A typed formula (=5, =A1) is never decided on its text — it is entered and its result gets the flag-only marker after the recalc, as in Excel. The Time kind compares the full serial (a date-time such as 45292.5 is out of a 9:00–12:00 range) and accepts 24:00 as 1.0. The <ValidationAlertDialog> is host-mounted
Dropdown lists from range
✅
✅
✅
Dropdown lists (inline values)
✅
✅
✅
Options are comma-separated with no escaping — matching Excel, whose inline list (formula1='"a,b,c"') has no syntax for an option containing a comma. Such options need a range-backed list (ONE_OF_RANGE). Internally the whole option set is stored as one comma-joined values[0] string (unquoted); consumers split it themselves
Schema-based tables (typed columns)
❌
❌
✅
Custom — not an Excel feature
Calculated columns
✅
❌
✅
Auto-fill formula on insert / paste. XLSX: <calculatedColumnFormula> round-trips on both engines — the Rust export now writes it (with the table's <filterColumn> criteria and <sortState>) from the persisted table definition, so a downloaded workbook keeps auto-filling new rows in Excel (#939 X-15)
Pivot tables (rendering imported)
✅
✅
✅
XLSX pivotCacheDefinition + pivotTable round-trip; rendered through the same libs/pivot pipeline as authored pivots
Pivot tables (authoring UI)
✅
✅
✅
DuckDB-powered analytical layer (PIVOT operator + JS post-transforms)
Pivot row / column / value fields (drag, reorder, show-hide)
✅
✅
✅
Drag-and-drop field list panel; per-field hide checkbox in editor
Pivot subtotals + grand totals toggles
✅
✅
✅
Per-pivot showSubtotals + showRowGrandTotals + showColumnGrandTotals (PivotEditor's Totals panel)
Pivot sort (row label, column label, value field)
✅
✅
✅
Tri-state ↑/↓/none on every field; value-field sort handles BOTH the grouping-only alias shape ("Field") and the column-pivoted shape (agg(Field))
Pivot label filter (set values, search, select-all)
✅
✅
✅
Per-field filter popover with searchable checkbox list
Pivot Top N filter
✅
✅
✅
Top / Bottom × N by any value field — emits IN (SELECT ... ORDER BY agg(byField) LIMIT N) against the SOURCE CTE
Pivot refresh button + auto-refresh on source mutation
✅
✅
✅
Manual refresh icon in PivotEditor; sourceDataVersion invalidates results when source range mutates
Pivot drilldown (double-click value cell)
✅
✅
✅
onDrillDownAtCell returns the underlying source rows for the clicked aggregate
Pivot "Show Values As" (% of total, % of row, % of column, running total, rank)
✅
✅
✅
Per value field (each values[i].showAs transforms only its own aggregate columns, like Excel's per-data-field showDataAs); post-aggregation transform in both engines; PERCENT / NUMBER number-format from the first active mode. XLSX round-trip via dataField@showDataAs (+ x14:dataField@pivotShowAs for ranks) in both toolkits; Excel modes without an engine equivalent (difference, % of parent, index) degrade to the plain aggregate
Pivot field grouping (date by period, numeric by bucket size)
✅
✅
✅
DuckDB EXCLUDE-based SOURCE rewrite (date_trunc / FLOOR) — period whitelisted to prevent SQL injection
GETPIVOTDATA()
✅
✅
✅
2D matrix header-matching lookup; resolves the value cell for a given (field=value, …) tuple
Slicer ↔ pivot wiring
✅
❌
✅
Slicer selection applies as a label filter to bound pivots; pivot re-runs through the same filter pipeline
Pivot tables (XLSX round-trip of cache+def)
✅
❌
✅
refreshOnLoad="1" so Excel rebuilds the cache from the live source; positions, rows/columns/values, aggregation function, sort + filter state all preserved
Calculated fields
✅
✅
✅
PivotTable.calculatedFields[{ name, formula }] — formula (arithmetic + - * / ^, numbers, bare or 'quoted' field refs) evaluated per cell over the SUM of each referenced field (Excel semantics), always Sum-summarized; JS = formula over sum(TRY_CAST(...)) in the grouping query / folded constituent sums in the PIVOT query, Rust = rnc-pivot::calc. Round-trips as cacheField@formula + databaseField="0". Gaps: function calls (IF, …) in formulas → NULL cells (formula still exported verbatim); subtotals / grand totals sum the leaf results, whereas Excel re-evaluates the formula over the total sums (identical for additive formulas)
Calculated items
✅
❌
❌
Deferred — needs cache items (pivotCacheRecords) + member arithmetic on a dimension; no engine equivalent
Slicers (table-backed)
✅
❌
✅
Create/move/resize/delete + XLSX + ODS round-trip. Same placement modes as charts (OverlayPosition.placement + toCell): insert / delete rows or columns move / stretch / remove the frame per mode on both engines; XLSX keeps the slicer in a twoCellAnchor and spells the modes as editAs (#668 C-46)
Slicers (pivot-backed)
✅
❌
✅
Slicer reads distinct values from the bound pivot's source range (column matching fieldName); selection routes through onFilterPivot → applySlicerSelectionToPivots → pivot's filter pipeline. XLSX export of the pivot OLAP cube cache itself deferred — table-backed slicers ship in full
Timeline filters
✅
❌
❌
Deferred
Conditional formatting
Number-based rules
✅
✅
✅
greater / less / equal / between, etc.
Text-based rules
✅
✅
✅
contains / starts with / ends with / equals
Date-based rules
✅
✅
✅
today, yesterday, this week, etc.
Custom formula rules
✅
✅
✅
Gradient / color scale (2-stop, 3-stop)
✅
✅
✅
Data bars
✅
❌
✅
dataBarRule round-trips XLSX + ODS (see cf-roundtrip.spec.ts)
Icon sets
✅
❌
✅
iconSetRule round-trips XLSX + ODS (3TrafficLights1, 3Arrows, 5Rating, etc.)
Top / bottom N rules
✅
❌
⚠️
Implementable via custom formula
Duplicate / unique rules
✅
❌
✅
Range re-anchor on insert/move
✅
✅
✅
updateRangedRulesOnStructuralChange + updateRangedRulesOnReorder
Charts
Column charts (clustered / stacked)
✅
✅
✅
Every exported <c:ser> (all chart types) lists its children in the CT_*Ser schema order — idx, order, tx, spPr, …, cat, val (xVal, yVal for scatter / bubble), …, extLst — so the part validates in LibreOffice / Google Sheets / python readers, not only in Excel (#939 X-10)
Bar charts
✅
✅
✅
Line charts (smooth / stepped)
✅
✅
✅
Excel's gallery variants are pickable — Line, Stacked, 100% stacked, each with or without markers, Smooth, 3-D. <c:lineChart><c:grouping> ↔ stackedType and the chart-level <c:marker val="1"/> (+ per-series <c:symbol val="none"/>) ↔ showMarkers round-trip (#668 C-15) The Rust engine (rnc-import / rnc-export) reads and writes the same grouping and marker shape (#668 C-34), plus each series' own <c:marker><c:symbol> / <c:size> as ChartData.marker (#668 C-42); a combo chart's line group follows the same rules (#668 C-35).
Area charts
✅
✅
✅
Pie / doughnut charts
✅
✅
✅
Exploded Pie / Exploded Doughnut are pickable (Pie → Exploded pie / Exploded doughnut) and render with the slices pulled apart in echarts (all slices in the select state, selectedOffset) and plotly (pull). spec.explosion (% of the radius, Excel default 25) ↔ per-series <c:explosion> on XLSX and chart:pie-offset on the ODS series style (#668 C-32); editable as Explosion (% of radius) in the chart editor's Customize → Chart options for pie / doughnut (#668 C-41). ODS carries a doughnut as chart:ring (pie is chart:circle) in both directions (#668 C-39)
Scatter charts
✅
✅
✅
c:scatterChart import — each series carries c:xVal/c:yVal instead of the c:cat/c:val shape used by other charts. Export emits the same. Renders via plotly/echarts scatter
Bubble charts
✅
✅
✅
c:bubbleChart — adds c:bubbleSize (third dimension). Bubble size scales calculated via createBubbleSizeScale. Round-trip preserves all three axes
Pie of Pie / Bar of Pie (c:ofPieChart)
✅
❌
✅
Renders as the composite (main pie with an aggregated "Other" slice + secondary pie / stacked bar and connector lines, utils/of-pie.ts); splitType / splitPos / secondPieSize round-trip. Creatable from the picker (Pie → Pie of pie / Bar of pie)
Cross-sheet data references
✅
✅
✅
A chart on Sheet1 can pull its data from 'Other Sheet'!$A$1:$A$10. Parser resolves the prefix to the actual data sheet's sheetId so every series source is pinned correctly; exporter re-encodes the sheet name
Radar charts
✅
❌
✅
Creatable from the chart-type picker (standard / with-markers / filled) and rendered natively — echarts radar coordinate + plotly scatterpolar (utils/radar.ts)
Treemap
✅
✅
✅
Excel-native chartex round-trip: exported as xl/charts/chartEx{N}.xml (cx:chartSpace, layoutId="treemap", hierarchy strDim = parent columns + leaf column, size numDim) behind an mc:AlternateContent anchor, and imported from Excel-authored parts (multi-level hierarchies split into parentLabels / labels). Rust engine: not yet imported / exported (the rnc download degrades a treemap to a bar chart)
Sunburst
✅
✅
✅
Same chartex round-trip as treemap (layoutId="sunburst"); same Rust-engine limitation
Stock (line-derived)
✅
❌
✅
Creatable from the picker (HLC / OHLC / VHLC / VOHLC) and rendered as line series in echarts + plotly (utils/stock.ts toLineSeries); a toCandlestick transform is also available for renderers that prefer OHLC bodies. XLSX: the V-variants write the volume series as a sibling <c:barChart> next to the <c:stockChart> (both on the primary axes) and the parser derives stockVariant from that layout + the price-series count (#668 C-15); the JS exporter writes <c:hiLowLines/> on every layout and <c:upDownBars> on OHLC / VOHLC in CT_StockChart order (#668 C-40). The Rust engine round-trips the same layout: rnc-import derives stockVariant (a volume <c:barChart> before the <c:stockChart> no longer reads as a column chart) and rnc-export writes a native <c:stockChart> with hi-low lines (+ up/down bars for OHLC / VOHLC) instead of a line chart (#668 C-34).
Stock (OHLC / candlestick)
✅
❌
✅
OHLC / VOHLC variants render as a native candlestick — echarts candlestick series + plotly candlestick trace (utils/stock.ts toCandlestick). Pickable from the chart-type picker (Stock → OHLC)
Waterfall
✅
❌
✅
Renders in echarts + plotly. Phase 5.5b: full c15:waterfallChart in mc:Choice + c:barChart fallback — Excel 2016+ renders natively
Funnel
✅
❌
✅
Renders in echarts + plotly. Phase 5.5b: full c15:funnelChart in mc:Choice + c:barChart fallback — Excel 2016+ renders natively
3D variants (bar3D / column3D / pie3D / line3D / area3D, incl. 3-D stacked / 100% stacked column and bar)
✅
❌
✅
Import keeps the class as sourceChartType (BAR_3D / LINE_3D / PIE_3D / AREA_3D) on the 2-D base chartType; both exporters (JS toolkit + rnc-export) write the <c:…3DChart> element back with <c:view3D> (line3D adds a <c:serAx>), ODS as chart:three-dimensional="true" on the plot-area style. Renders through lazily-loaded echarts-gl when WebGL is available (PIE_3D keeps the 2-D pie). Picker: Column → 3-D Column, Bar → 3-D Bar, Line → 3-D Line, Pie → 3-D Pie, Area → 3-D Area (#546 D-18)
Surface
✅
❌
✅
surfaceChart (Contour) / surface3DChart (3-D Surface) → chartType: "area" + sourceChartType: SURFACE / SURFACE_3D; exported back as the same elements (<c:wireframe>, surface3D adds <c:serAx>), ODS as chart:surface. Rendered as an echarts-gl surface. Picker: Surface → 3-D Surface / Contour
Histogram
✅
✅
✅
Native histogram (echarts/plotly) with auto/Sturges binning + bin-count override. Phase 5.5b: exporter emits full c15:histogramChart in mc:Choice with c:barChart fallback — Excel 2016+ renders natively. Excel-authored chartex histograms (cx:series layoutId="clusteredColumn" + cx:binning) import too, keeping the bin count; chartex waterfall / funnel / box & whisker parts import as their chart types
Box & whisker
✅
❌
✅
Native boxplot (echarts boxplot series, plotly box trace). Phase 5.5b: full c15:boxWhiskerChart (quartileMethod, showMean, showOutlier) in mc:Choice + c:barChart fallback — Excel 2016+ renders natively
Pareto
✅
❌
⚠️
Constructible via column + line combo
Combo (mixed series types)
✅
✅
✅
Per-series seriesChartType picker in chart-editor (bar/column/line/area)
Dual-axis (secondary axis)
✅
✅
✅
Per-series Plot on secondary axis checkbox in the chart editor; rendered as a second yAxis in echarts (yAxisIndex) and yaxis2 in plotly. secondaryValueAxisOptions round-trips; the per-series flag is editor-set (the XLSX parser doesn't tag series by axis)
Chart titles, legends, axis labels
✅
✅
✅
Value-axis number format ("Linked to source")
✅
✅
✅
Tick labels, data labels and tooltip values render through the source cells' number format (currency, percent, thousands, decimals) on both renderers, in the grid's locale (a Russian sheet's 120,0 cell labels as 120,0; the grid hands its locale to every chart component), or through an explicit ChartAxisOptions.numberFormatPattern (chart editor → Value axis → Number format; blank = linked to source). <c:valAx><c:numFmt sourceLinked="0"> round-trips through XLSX; sourceLinked="1" stays unset in the spec and resolves from the cells at draw time. ODS: the pattern is a number:number-style / percentage-style / currency-style data style on the axis style (style:data-style-name, chart:link-data-style-to-source="false"), both directions; a linked axis writes no data style (the ODF default). Only the numeric grammar converts — dates, fractions, scientific and text patterns degrade to a linked axis; a currency literal reads back with its own spacing (a space between symbol and digits is a number:text, #668 C-29 / C-52). Plotly converts the numeric pattern subset to d3 tickformat + prefix/suffix (dates / fractions / scientific keep plotly's default ticks)
Data labels (value / category / percent)
✅
✅
✅
Rendered from spec and authorable in the chart editor — master "Show data labels" toggle + value / category name / series name / percentage checkboxes + label position (center / inside / outside / inside-end / outside-end / best-fit, plus Excel's line and scatter placements above / below / left / right — `<c:dLblPos val="t
Trendlines
✅
✅
✅
linear / polynomial / exponential / logarithmic (+ power, moving-avg). Per-series toggle + type picker in the chart editor; regression computed in utils/trendline.ts (with R² + equation) and drawn as an overlay line in both echarts + plotly
Error bars
✅
✅
✅
Per-series toggle (fixedVal / percentage / stdDev / stdErr) in the editor; magnitudes from utils/error-bars.ts, drawn via an echarts custom series + plotly native error_y. custom range-based bars are unsupported (the model carries no plus/minus range fields)
Chart styles / colors
✅
✅
✅
Excel's Chart Design tab in the chart editor's Customize tab (#668 C-16): Chart Styles gallery (8 tiles = spec transforms in libs/charts/utils/chart-styles.ts; ChartSpec.chartStyle ↔ <c:style val> / <c14:style val+100>, Excel's unstyled 2 stays implicit), Change Colors gallery (4 colourful + 4 monochromatic palettes → ChartSpec.palette, per-series colours cleared so it applies; both renderers cycle palette[i % n] for series without a color), Quick Layouts (6 title / legend / data-label / axis-title arrangements; the pick is kept as ChartSpec.quickLayout so the control shows the applied layout — Excel writes no layout id to XLSX, so it does not round-trip), Reset to match style (clears per-series colour / gradient / point overrides), Format Chart Area fill (spec.backgroundColor ↔ chartSpace <c:spPr><a:solidFill><a:srgbClr> — theme bg1 fills are not imported), border (EmbeddedChart.borderColor ↔ <a:ln>), rounded corners, and the title font (titleTextFormat). OOXML has no palette element: on export the palette lands on each <c:ser><c:spPr> and imports back as per-series colours (JS toolkit and rnc-export / rnc-import alike); ODS degrades the same way (no style id)
Chart series gradient fill
✅
✅
✅
Per-series gradient toggle in the chart editor. Rendered: echarts paints faithfully via graphic.LinearGradient (cardinal angles axis-aligned, utils/gradient.ts); plotly degrades to a representative solid color (no per-trace gradient fill). Linear (degree) + path (left/right/top/bottom insets). XLSX exporter emits <a:gradFill> inside <c:spPr> with EMU-per-mille positions + 60000ths-of-a-degree angle. Parser hydrates from the same shape. ODS exporter emits <draw:gradient> defs in automatic-styles + references via draw:fill-gradient-name on the series style; ODS-side parser hydration is deferred (rare in the wild). Field on ChartData.gradient
Switch Row/Column (series orientation)
✅
✅
✅
Excel's Chart Design → Switch Row/Column: chart-editor Setup button + chart right-click item re-derive domains / series from dataRange the other way (or transpose rectangular sources; old series names become inline categories) and flip ChartSpec.seriesOrientation; one undo step on both engines. Create-from-selection picks series-in-rows when the selection is wider than tall, like Excel. OOXML/ODS carry no flag — each series is its own reference — so the orientation is inferred from the series shape on import (#668 C-14)
Hidden and Empty Cells (Select Data → "Show empty cells as", "Show #N/A as an empty cell")
✅
✅
✅
ChartSpec.emptyCells (gaps / zero / connect) ↔ <c:dispBlanksAs val="gap|zero|span"/> and ODS chart:treat-empty-cells (leave-gap / use-zero / ignore) on the plot-area style; ChartSpec.showNaAsEmpty ↔ the Excel 2016+ <c16r3:dispNaAsBlank> chart extension (no ODS equivalent — degrades on ODS export). Rendered in echarts (connectNulls, null → 0) and plotly (connectgaps); with showNaAsEmpty: false a line/area series is drawn straight through the #N/A point (Excel's pre-2016 behaviour). Editable in the chart-editor Setup tab; both exporters (JS toolkit + rnc-export) and both importers (rnc-import) round-trip it, Excel's defaults staying implicit (#668 C-14)
Chart move / resize
✅
✅
✅
Z-order — Bring Forward / Send Backward / Bring to Front / Send to Back
✅
❌
✅
Excel's Arrange menu, on the chart / object right-click menu (Arrange submenu; each command offered only while it would move the object). Charts and embedded objects (pictures, shapes, text boxes) share one stacking order per sheet: EmbeddedObjectPosition.zOrder (absent = creation order — charts below embeds, as painted before), planned across both host arrays by planZOrderChange and persisted as ONE undo step on both engines (legacy: one history entry with both the charts and embeds patches; Rust: one batch of update-chart / update-embed). The grid paints the rank as z-index and emits the frames back-to-front. XLSX: drawing-anchor order follows zOrder on export and import numbers every anchor back onto it (JS toolkit and rnc-export / rnc-import alike); ODS carries no stacking beyond frame order and degrades to it (#668 C-18)
Placement — Move and size with cells / Move but don't size / Don't move or size
✅
❌
✅
OverlayPosition.placement (default moveAndSize) + the second anchor (toCell / toOffsetXPixels / toOffsetYPixels ↔ twoCellAnchor to), both engines. A moveAndSize frame spans both anchors — a column / row resize between them stretches it, insert / delete rows or columns grow / shrink it, deleting its whole span removes it; moveOnly keeps its pixel size; absolute ignores inserts / deletes. Chart editor → Customize → Properties radios; every move / resize re-anchors the bottom-right corner. XLSX: twoCellAnchor / oneCellAnchor / absoluteAnchor (+ Excel's editAs spellings on import); ODS: table:end-cell-address for moveAndSize, the other modes degrade to a fixed-size frame. Pictures and shapes share the model (#668 C-06)
Chart editing UI
✅
✅
✅
Charts in print / print preview; Copy as Picture
✅
❌
✅
Print preview lays every chart on the page its anchor cell lands on (like Excel), at the anchor's position and on-sheet size under the page scale — placeChartsOnPages (use-page-layout.ts) + PageBlock.charts; the image is the mounted renderer's PNG (exportPng, registered by both echarts and plotly), a titled frame when the chart's sheet is not on screen; the print iframe waits for the images before printing. A chart overhanging its page is clipped (Excel splits it). Right-click → Copy as Picture puts the same PNG on the system clipboard: the pending capture is handed to ClipboardItem inside the click (Safari requires this; Chromium ≥ 98 / Firefox ≥ 127 accept it), with a resolved-blob retry for engines that refuse promise values; the item is hidden where the clipboard cannot take images (#668 C-19)
Formula-driven titles & labels
✅
✅
✅
Range references resolved at render
Chartsheets (a tab that is one chart)
✅
❌
✅
Sheet.type: "chartsheet" + Sheet.chartId; the chart lives in charts with position.sheetId = the chartsheet and an absolute overlay at A1. XLSX: xl/chartsheets/sheetN.xml (…/relationships/chartsheet workbook rel, spreadsheetml.chartsheet+xml content type, <sheetPr><tabColor>, <sheetViews>, <pageMargins>, <drawing>) with the chart as an absoluteAnchor in the sheet's drawing part; import keys on the workbook rel type and pins the chart to the chartsheet while its series still resolve to the data sheet. ODS has no chartsheet — see ODS export below (#668 C-17)
PivotCharts (chart bound to a PivotTable)
✅
✅
✅
Insert → PivotChart (pick a PivotTable, or create an empty one from the selection — both fill in as fields are added) and PivotTable → PivotChart on the pivot's right-click menu. ChartSpec.pivotTableId + pivotSource (categoryAxis: "rows" — row items are the categories and each value column a series, Excel's default — or "columns", the transpose; optional valueFields). The chart plots the pivot's data area — the label row and every row down to the grand total, every value column that is not a subtotal / grand-total column (group rows plot as categories; Excel plots leaf items on a hierarchical axis) — and re-plots on every pivot render on both engines: refresh, filter, sort, field moves, expand / collapse, source auto-refresh, in the pivot edit's own undo step; series formatting survives by position. Deleting the pivot converts the chart to a static chart of its last values (binding dropped, ranges kept), one undo re-binds. The chart editor shows the range as bound and the in-grid range handles are off. XLSX round-trip (#668 C-56): <c:pivotSource> (name + fmtId) is written for a chart bound to an exported pivot and resolved back to the imported pivot by name and target sheet, <c:pivotFmts> rides on ChartSpec.pivotFormats, and Excel's Field Buttons choices (<c14:pivotOptions>) on ChartSpec.pivotFieldButtons; the chart's right-click menu offers Hide / Show All Field Buttons (one undo step, both engines). ODS has no PivotChart shape. Field buttons are drawn over the chart frame (#668 C-71, PivotChartFieldButtons via the renderer's fieldButtons slot): axis (row fields, bottom-left), legend (column fields, top-right), value fields and report filters (top-left); the transpose swaps axis and legend. Axis / legend / report-filter buttons open the pivot field's own dropdown (Sort A→Z / Z→A, the distinct-values checklist, Top-N) — the same PivotFieldFilterPopover the PivotTable field list uses; the value button is a label (Excel's Value Field Settings menu is not offered). Nested row fields plot the leaf items only on a hierarchical category axis (ChartSpec.categoryLevels, Region › Product …, re-derived on every pivot render): plotly multicategory, echarts a grouped label axis per outer level. XLSX export still writes the leaf-level categories only — Excel rebuilds a PivotChart's axis from the pivot cache, so no <c:multiLvlStrRef> is written (#668 C-57). The pivot table definition's <chartFormats> (the pivot areas the chart's <c:pivotFmt> entries format, keyed by the chart's <c:fmtId>) round-trips verbatim on PivotTable.chartFormats and the fmtId on ChartSpec.pivotSource.formatId, so the indices an exported PivotChart re-emits still resolve; an in-app PivotChart writes no <chartFormats> and fmtId 0 (#668 C-72). The pivot's own item lists round-trip too — each <pivotField><items> (Excel's item order and flags) and the cache's <sharedItems> (values + type attributes) on PivotTable.cacheFields, written back with saveData="0" and no records part — so the item positions those <chartFormats> reference index into the emitted items; an in-app pivot still exports the item-less, refresh-on-load cache (#668 C-84)
Visual content
Embedded images
✅
✅
✅
Insert, move, resize, replace. A pasted (Ctrl+V) or dropped image file is a floating picture anchored at the active cell — natural pixel size, scaled down to fit the visible grid, movable and resizable — the same object Insert → Image → over cells creates, in both engines (createImageEmbed; rnc: the lossless create-embed command). Only the explicit Insert image in cell (or a Shift/Ctrl-drop) places the picture in the cell (#984)
Image properties (brightness/contrast/transparency/crop/z-order)
✅
✅
⚠️
Round-trip only: EmbedImageSpec.crop / brightness / contrast / transparency mirror <a:srcRect> / <a:lum> / <a:alphaModFix> in both engines (image-adjustments-roundtrip.spec.ts, rnc-export xlsx_chart_image.rs); z-order = drawing anchor order = the host's embeds order across images, shapes and text boxes (the XLSX parser recovers document order across oneCellAnchor / twoCellAnchor / absoluteAnchor, so Excel-authored mixed-anchor drawings keep their stacking too); negative <a:srcRect> edges (Excel's out-crop) round-trip. The grid still paints the untouched bitmap (applying the adjustments as CSS filters is the remaining gap); ODS has no picture-adjustment equivalent, so they degrade on ODS export
Shapes (rectangles, lines, arrows, text boxes)
✅
✅
✅
EmbedShapeSpec imports and exports as <xdr:sp> — preset geometry + adjust values, solid/gradient fills, outline (dash), shadow, theme style refs, text, flips, rotation (shape-roundtrip.spec.ts; Rust xlsx_shapes.rs). Connector shapes (<xdr:cxnSp>, lines drawn as connectors) are still dropped on import — the remaining gap
SmartArt
✅
❌
❌
Out of scope
Sparklines — line / column / win-loss
✅
✅
✅
Excel sparkline groups: Insert → Sparklines (Create Sparklines dialog), a Sparkline Design menu (Edit Group Location & Data, type, points, colours, weight, axis, empty cells, Group, Clear) and cell context-menu entries on both engines; a group is a run of SPARKLINE() cells sharing one option set — the exact shape import produces and export folds back into <x14:sparklineGroup>. XLSX + ODS round-trip. Axis options round-trip too: displayEmptyCellsAs (gap/zero/span), dateAxis, markers, high/low/first/last/negative point highlights, displayXAxis, lineWeight, manualMax/manualMin, minAxisType/maxAxisType — all carried through SPARKLINE() formula options. Vertical axis minaxistype / maxaxistype: "group" shares the group's min / max across the member cells at paint time (CanvasGrid.getSparklineGroup); hidden rows are skipped by both engines' SPARKLINE unless displayhidden is TRUE — hidden columns are not tracked and stay plotted (#668 C-55)
Hyperlinks (URL + tooltip)
✅
✅
✅
Internal links (Sheet!A1)
✅
✅
✅
Comments (legacy)
✅
❌
✅
Author + rich text
Comments (threaded with replies)
✅
✅
✅
xl/threadedComments/* + xl/persons/person.xml imported and exported by both the JS toolkit and the Rust engine, with the down-level legacy-note mirror (tc= author) for older Excel
Citations / inline annotations
❌
❌
✅
Custom — surfaces inside =FILTER source attribution & similar
Mentions (@user)
❌
✅
✅
Mentions component bundled
Cell tooltips / popovers
✅
✅
✅
Programmatic via getCellPopoverContent
Cell renderers (custom)
❌
❌
✅
Custom — render React components inside cells
Structured cell renderers
❌
❌
✅
Custom — schema-driven rendering
Workbook structure
Multiple sheets
✅
✅
✅
Sheet tabs UI
✅
✅
✅
Drag-to-reorder
Sheet rename
✅
✅
✅
Sheet duplicate
✅
✅
✅
Sheet move / reorder
✅
✅
✅
Sheet color
✅
✅
✅
Hide / show sheets
✅
✅
✅
Very-hidden sheets
✅
❌
✅
XLSX state="veryHidden" preserved
Chartsheets
✅
❌
✅
A <sheet> whose workbook relationship is a chartsheet (xl/chartsheets/sheetN.xml) imports as Sheet.type: "chartsheet" with no cells / rowCount; worksheets and chartsheets are numbered 1..N in their own folders on export, so a chartsheet leaves no gap in worksheets/sheetN.xml (#668 C-17). The sheet catalogs (getSheetCatalog() / list_xlsx_sheets) expose type: "worksheet" | "chartsheet" from the workbook rels alone, and both exporters write docProps/app.xml HeadingPairs as Worksheets + Charts with TitlesOfParts worksheets first, as Excel does (#668 C-60)
Sheet protection (cell-level locked metadata)
✅
✅
✅
Preserved on round-trip
Sheet protection (<sheetProtection> flags + password hash, <protectedRanges> allow-edit ranges)
✅
⚠️
✅
Imported + exported by both the JS toolkit (Sheet.sheetProtection, getProtectedRanges() / protectedRanges export prop) and the Rust engine (rnc-import / rnc-export); ODS carries only the table:protected master flag (its protection-key digest is not an OOXML hash) and has no allow-edit range equivalent
Sheet protection (enforcement)
✅
✅
✅
Every mutation hook in libs/spreadsheet-state/hooks/ gates through useCanEditCellRange / useCanEditSheet. The host provides the policy via SpreadsheetContext.canEditCell + isSheetProtected (defaults are permissive — protection metadata round-trips but only gets enforced when the host wires a policy). Gates cover: single + batch cell edits, paste, fill, delete cells / rows / columns, insert cells / rows / columns, move, merge / unmerge, sort (range + table), filter table, change formatting, change decimals, change border, clear formatting, paint format, hide / show rows + columns, group / ungroup, resize, conditional-formatting CRUD, data-validation CRUD, named-range create + update. Streamed batch writes are gated up front before the stream starts.
Workbook protection
✅
✅
✅
Structural-protection gate (useCanEditWorkbook) refuses add / delete / rename / move / duplicate / hide / show / tab-color when the host sets isWorkbookProtected={true} on the canvas grid (matches Excel workbookProtection lockStructure="1"). Cell edits remain governed by per-sheet protection. The <workbookProtection> element itself (lockStructure / lockWindows / lockRevision plus the legacy workbookPassword / revisionsPassword hash or the SHA-512 workbookAlgorithmName / workbookHashValue / workbookSaltValue / workbookSpinCount quartet) round-trips verbatim through both the JS toolkit (WorkBook.getWorkbookProtection() on import, the workbookProtection export prop) and the Rust engine (rnc-import → documentFacets.workbookProtection → rnc-export), written in its CT_Workbook slot right after <workbookPr> (#939 X-04)
Named ranges
✅
✅
✅
External links / connections
✅
❌
⚠️
Read-only; not actively refreshed
Themes (light / dark)
✅
❌
✅
Light/dark variants generated from workbook theme
Custom themes
✅
❌
⚠️
Colors preserved; full theme structure simplified
Zoom / scale
✅
✅
✅
Per-sheet zoomScale (10–400) round-trips xlsx; exposed from useSpreadsheetState; canvas scale prop already supports the full 25–400% range via Ctrl+wheel + ScaleSelector
Grid lines toggle
✅
✅
✅
Print setup (page setup, headers, footers)
✅
✅
⚠️
Print preview dialog (PrintPreviewDialog) ships: paper size (Letter / A4 / Legal / Tabloid), portrait/landscape, scale (Normal / Fit width / Fit page / Custom %), margins (Normal / Narrow / Wide / Custom), horizontal + vertical alignment, show-gridlines, scope (Current sheet / Workbook / Selection), Cmd/Ctrl+P shortcut, paginated previews + iframe-driven window.print() with @page rules. Sheet.printSettings (pageSetup / pageMargins / printOptions / headerFooter / printArea / printTitles / rowBreaks / columnBreaks — the Rust engine's printSettings wire shape) round-trips through XLSX (<printOptions> / <pageMargins> / <pageSetup> / <headerFooter> incl. even/first variants and the &L/&C/&R zones + &P/&N/&D/&T/&A/&F/&Z macros, <rowBreaks> / <colBreaks>, sheet-scoped _xlnm.Print_Area / _xlnm.Print_Titles defined names) and ODS (style:page-layout + style:master-page via the table style, table:print-ranges, header/footer regions with <text:page-number>-style fields, <table:table-header-rows> / <table:table-header-columns>, fo:break-before="page"). Remaining gaps: the print preview dialog does not yet seed from / write back to sheet.printSettings, and header/footer font codes (&"Arial,Bold"&12) have no ODS counterpart (dropped on ODS export).
Page breaks
✅
✅
⚠️
Auto-pagination renders correctly in the print preview (greedy fit, down-then-over page order — same as Excel's pageOrder = "downThenOver" default). Drag-to-edit custom page breaks is the remaining gap; manual <rowBreaks> / <colBreaks> round-trip on printSettings.rowBreaks / columnBreaks (XLSX; automatic breaks are dropped, as Excel does on save) and as fo:break-before="page" row / column styles (ODS).
Multi-instance spreadsheets on one page
❌
❌
✅
Custom — instanceId isolates state
Sheet switcher (jump-to-sheet)
❌
❌
✅
Component bundled
Collaboration
Real-time multi-user editing
✅ (online)
✅
✅
Backends: YJS, ShareDB, Supabase Realtime
User presence / avatars
✅
✅
✅
Per-cell active-user overlay
Cursor / selection broadcast
✅
✅
✅
Conflict resolution (OT / CRDT)
✅
✅
✅
OT (ShareDB) + CRDT (YJS) options
Comments threading
✅
✅
✅
Threaded comments round-trip in both engines (see Visual content)
Version history
✅
✅
⚠️
Diff via libs/version-comparison; full version-history UI is host's responsibility
@mentions in cells
❌
✅
✅
Import / export — detailed
XLSX import
Cell values, types, formulas
✅
Including shared strings, inline strings, rich text runs
Cell formatting (fonts, fills, borders, alignment, number formats)
✅
Theme tints resolved; ~68 built-in number formats
Merged cells
✅
Hyperlinks (with tooltips)
✅
Comments (legacy comments.xml)
✅
Threaded xl/threadedComments/* parsed too; the tc= legacy mirror is folded into its thread instead of doubling as a note
Hidden / very-hidden sheets
✅
Row heights / column widths
✅
Hidden rows / columns
✅
Separates hiddenByUser vs hiddenByGroup
Outline / grouping (rows + cols, nested)
✅
outlineLevel, collapsed, outlinePr summary direction
Freeze panes
✅
Tab color
✅
Named ranges (workbook + sheet scope)
✅
Tables (with totals row, banded styling)
✅
AutoFilter (text, number, date, color, icon, blank, error)
✅
blank="1" → the "(Blanks)" checklist entry; <dateGroupItem> date-tree entries → FilterCriteria.dateGroups; <top10> → Top / Bottom N / percent conditions; a notEqual wildcard that is not *x* stays TEXT_NOT_EQ verbatim (#939 X-06)
Data validation (all 9 rule types)
✅
Conditional formatting (boolean, gradient, data bars, icon sets, custom formula)
✅
All rule types round-trip. A rule's dxf keeps its font, fill, border and number format in both toolkits (JS libs/toolkit, Rust rnc-import / rnc-export); a rule that sets only a border and/or a number format is exported like any other (#939 D-07)
Charts (column / bar / line / area / pie / doughnut / scatter / bubble / radar / stock-line)
✅
Charts (treemap / sunburst — Excel 2016+ chartex)
✅
xl/charts/chartEx{N}.xml (cx:chartSpace) parts anchored through mc:AlternateContent / cx:chart; the hierarchy strDim splits into parent columns + leaf labels, size numDim → sizeData. Excel-authored chartex histogram (clusteredColumn + cx:binning → binCount), waterfall, funnel and box & whisker parts import as those chart types
Charts (histogram / box & whisker / waterfall / funnel)
✅
Parser detects both the new mc:AlternateContent + c15 element shape (what we now emit and what Excel emits) and the legacy c:extLst marker (for backwards compat with files we wrote before Phase 5.5b)
Charts (filled map — Excel chartex regionMap)
❌
Not imported: Excel renders map charts from its online geo-data service, which the engine has no equivalent for; the cx:chart anchor is skipped and the cell data it plotted is untouched (rowsncolumns/spreadsheet#546 D-18, deferred)
Charts (3D / surface)
✅
bar3D / line3D / pie3D / area3D / surface / surface3D → the 2-D base chartType plus a sourceChartType token (same mapping as rnc-import)
Embedded images + drawings
✅
Position, size, crop (incl. negative out-crop edges), brightness / contrast, transparency; anchor document order kept as z-order across oneCellAnchor / twoCellAnchor / absoluteAnchor; the anchor kind (and editAs) is the placement mode — twoCellAnchor keeps its to as the second anchor, oneCellAnchor = Move but don't size, absoluteAnchor = Don't move or size — and is written back per mode (#668 C-06)
Shapes (rectangles, lines, arrows, text boxes)
✅
Read and written as <xdr:sp>; <xdr:cxnSp> connectors dropped
Form controls (check box, option button, drop-down, list box, button, spin, scroll bar, label, group box)
✅
Worksheet <controls> entry (+ <controlPr> and cell anchor), the xl/ctrlProps/ctrlPropN.xml properties (checked, fmlaLink, fmlaRange, sel, min / max / inc / page, list items, …) and the caption from the legacy VML shape → formControl embeds, both engines. The <anchor moveWithCells sizeWithCells> flags import as the placement mode (both = Move and size, keeping to as the second anchor; moveWithCells alone = Move but don't size; neither = Don't move or size) and are written back from it (#668 C-46). The grid draws a placeholder; the controls are not interactive
Print settings (page setup, margins, print options, header/footer, manual breaks, print area / titles)
✅
Sheet.printSettings; the builtin _xlnm.Print_Area / _xlnm.Print_Titles names are hoisted out of namedRanges; <customSheetViews> (Excel's saved Custom Views, which carry their own panes / breaks / print setup / header-footer) are skipped in both engines so a view's settings never become the sheet's
Sparklines
✅
Slicers (table-backed)
✅
Pivot slicers not yet
Pivot tables
✅
Parses xl/pivotCache/* + xl/pivotTables/* — A1 source range (worksheetSource@sheet or a Sheet! prefix), agg-fn normalized, sort + filter state hydrated, showDataAs / x14 pivotShowAs, calculated cache fields, pivotTableStyleInfo
Themes (colors + fonts)
✅
Light + dark variants generated
Iterative calculation settings
✅
1904 date system (workbookPr date1904, XLSB fDate1904)
✅
Date-formatted serials converted to the 1900 system on import (see 1904 date system above)
External links / connections
⚠️
Cached; not refreshed
Macros / VBA
❌
Discarded for security
ActiveX / OLE / linked data types / SmartArt / Cube functions
❌
Out of scope
Password-encrypted file decryption
✅
Agile (AES-CBC), Standard (AES-ECB) and RC4 CryptoAPI packages (cfb-decryptor.ts); extensible and legacy BIFF schemes fail with a clear message
Legacy .xls (BIFF8, Excel 97-2003)
⚠️
Decoded via SheetJS: sheet names + visibility, values, number formats, formulas with cached results, merges, column widths / row heights, hyperlinks, notes, defined names, 1904 flag. Not decoded: fonts / fills / borders, conditional formatting, data validation, charts, drawings. An HTML / text export saved with a .xls name → "not an Excel workbook" guidance; an OOXML package mislabelled .xls opens through the xlsx parser; a BIFF container named .xlsx opens through the .xls reader
Legacy comment author + rich-text runs
⚠️
Both engines flatten <comments> to plain CellData.note text (Excel's bold "Author:" prefix survives as text) and drop the <authors> entry; export writes a single System author. Deferred — the note model has no author slot
XLSX export
Cell values, formulas, shared strings, inline strings
✅
Full formatting (fonts/fills/borders/align/number formats)
✅
Merged cells
✅
Hyperlinks
✅
Comments (legacy)
✅
Hidden / very-hidden sheets
✅
Row heights / column widths
✅
Hidden rows / columns
✅
Outline / grouping (rows + cols, nested)
✅
outlinePr only emitted when non-default
Freeze panes
✅
Tab color
✅
Named ranges (workbook + sheet scope)
✅
Tables (with totals row + banded styling)
✅
AutoFilter (incl. color + icon criteria)
✅
One schema-valid child per <filterColumn>; blank="1", <dateGroupItem>, <top10> and every customFilter operator written back verbatim (#939 X-06)
Data validation
✅
Conditional formatting (boolean, gradient, data bars, icon sets, custom formula)
✅
Charts (column / bar / line / area / pie / doughnut / scatter / bubble / radar / stock-line)
✅
Charts (treemap / sunburst)
✅
Written as Excel 2016+ chartex parts (xl/charts/chartEx{N}.xml: cx:chartSpace, layoutId="treemap" / "sunburst", hierarchy strDim merged from adjacent parent + leaf columns, size numDim) with an mc:AlternateContent anchor requiring cx1. Before this they were dropped silently (the c:chartSpace writer has no series to write for them)
Form controls
✅
<controls> entry + xl/ctrlProps/ctrlPropN.xml + VML shape (x:ClientData derived from the control properties), sharing the sheet's <legacyDrawing> part with note shapes; both engines
External workbook links
✅
Preserved xl/externalLinks/* parts + rels re-emitted with <externalReferences>; both engines
Charts (histogram / box & whisker / waterfall / funnel)
✅
Emitted as mc:AlternateContent with full c15:histogramChart/c15:boxWhiskerChart/c15:waterfallChart/c15:funnelChart in mc:Choice (Excel 2016+ renders natively) plus a c:barChart in mc:Fallback for older Excel
Charts (3D / surface)
✅
A 3-D sourceChartType swaps the 2-D chart group for c:bar3DChart / c:line3DChart / c:pie3DChart / c:area3DChart / c:surfaceChart / c:surface3DChart and adds <c:view3D> (+ <c:serAx> where the schema takes three axes)
Embedded images
✅
Base64 + relationship wired
Sparklines
✅
Slicers (table-backed)
✅
Themes (colors + fonts)
✅
Iterative calculation settings
✅
1904 date system
✅
use1904 on createExcelFile / the Rust export model writes <workbookPr date1904="1"/> for a model whose serials are 1904-system; imported 1904 workbooks are exported as 1900-system files since import converts them
Password encryption (CFB)
✅
Optional password arg in createExcelFile
Shapes export
✅
<xdr:sp> with geometry / fill / outline / shadow / style refs / text
Print settings
✅
<printOptions> / <pageMargins> / <pageSetup> / <headerFooter> / <rowBreaks> / <colBreaks> in CT_Worksheet order, <sheetPr><pageSetUpPr fitToPage>, sheet-scoped _xlnm.Print_Area / _xlnm.Print_Titles (localSheetId); a legacy sheet-bound _xlnm.Print_Area in namedRanges is converted to the sheet-scoped name (never emitted globally; printSettings.printArea wins when both exist). firstPageNumber is written bare unless useFirstPageNumber is on (Excel's Auto numbering keeps the number dormant)
Image adjustments
✅
<a:srcRect> (negative edges included) / <a:lum> / <a:alphaModFix>; drawing anchors emitted in embeds order (z-order)
Pivot table cache + definition
✅
Emits xl/pivotCache/pivotCacheDefinitionN.xml + xl/pivotTables/pivotTableN.xml with refreshOnLoad="1"; workbook <pivotCaches> and per-worksheet relationships wired; per-value showDataAs (+ x14 rank extension), calculated cache fields (formula, databaseField="0"), pivotTableStyleInfo
Pivot slicer cache (OLAP)
❌
Deferred — buildSlicerCacheXml() currently returns null for pivot-backed slicers (table-backed slicers ship)
Custom table styles
✅ (round-trip only)
<cellStyles> + <tableStyles> blocks emitted from customTableStyles / namedCellStyles args on createExcelFile; element formats re-registered as dxfs. Toolkit-level preservation only — the in-app pickers (TableStyleSelector / CellStyleSelector) don't surface user-authored styles yet
Real-time collab metadata
❌
Spreadsheet-internal; intentionally not emitted
ODS import
Identical bucket coverage as XLSX import. ODS-specific notes:
Outline (<table:table-row-group> / <table:table-column-group>)
✅
table:display="false" → collapsed; both axes & nested
Sparklines (LibreOffice extension)
✅
calcext:sparkline-groups parsed
Database ranges (ODS's AutoFilter equivalent)
✅
Operator conditions incl. Excel's Top 10 (top values / bottom values / top percent / bottom percent) and a Custom AutoFilter's two clauses (<table:filter-or> for Or, siblings in the enclosing <table:filter-and> for And) round-trip; a criterion ODF has no shape for (relative-date periods, above/below average, custom formulas) is exported as an XML comment naming it and re-imports as no criterion
Conditional formatting (LibreOffice extension)
✅
Data validation
✅
Cell number formats (number:currency-style / percentage-style / number-style)
✅
The pattern follows the data style: the currency symbol on the side its element sits (number:position="after" honoured as a fallback), a space only when a whitespace number:text sits between symbol and digits, decimals / min-integer-digits / number:grouping from number:number (grouping defaults to false, as in ODF) — € #,##0.00, #,##0 €, $#,##0, 0.0% read back as written (#668 C-52)
Charts
✅
Sheet protection metadata
⚠️
table:protected → Sheet.sheetProtection.sheet on import, written back on export; ODF has no allow-edit range equivalent
Pivot tables
⚠️
Structure parsed; same Phase 10 gap as XLSX
outlinePr direction
⚠️
ODS has no native equivalent; defaults applied on import
ODS export
All formatting, formulas, sheet structure
✅
Cell number formats
✅
A CURRENCY / ACCOUNTING / PERCENT / NUMBER cell's pattern is written as its ODF data style — the authored symbol before or after the digits, a whitespace gap as <number:text> </number:text>, the pattern's decimals and thousands separator ([$€-407] keeps the symbol, the locale id drops). Only the numeric grammar converts: dates, fractions, scientific, text and multi-section (negative / colour) patterns fall back to the type's default style (#668 C-52)
Outline groups
✅
Nested <table:table-row-group> / <table:table-column-group> with table:display="false"
Tables → database ranges
✅
Sparklines
✅
LibreOffice extension element emitted
Charts
✅
Chartsheets
⚠️
ODF has no chartsheet — a Sheet.type: "chartsheet" tab is written as an ordinary <table:table> whose <table:shapes> holds the chart frame anchored at A1; re-importing the ODS yields a worksheet with an embedded chart (#668 C-17)
Slicers
❌
Dropped on ODS export — ODF has no slicer element and LibreOffice Calc has no slicer feature. Preserved on XLSX export
Images
✅
Print settings
✅
style:page-layout + style:master-page (via the table style's style:master-page-name), table:print-ranges, header/footer regions with <text:page-number> / <text:page-count> / <text:date> / <text:time> / <text:sheet-name> / <text:file-name> fields, fo:break-before="page" row / column styles, <table:table-header-rows> / <table:table-header-columns> (a band that straddles an outline-group edge is written as several wrappers, as LibreOffice does — ODF allows no group inside header rows — and unioned back on import)
outlinePr.summaryBelow / summaryRight (non-default)
❌
Not persisted — ODS has no equivalent attribute
CSV import / export
Plain values
✅
✅
Type inferred on import (number / date / boolean / string)
Custom delimiter
✅
✅
Comma, semicolon, tab, custom
Quote escaping
✅
✅
RFC 4180
Encoding
✅ UTF-8, UTF-16 LE/BE
✅ UTF-8 only
Import detects a BOM (UTF-8 / UTF-16 LE / UTF-16 BE) and decodes accordingly; BOM-less input is treated as UTF-8. Export always writes UTF-8; opt-in UTF-8 BOM via config.includeBom (helps Excel auto-detect the encoding)
Multi-sheet
❌
❌
CSV is single-sheet by spec
Formatting / formulas
❌
❌
Lost on round-trip
Worker-based parsing for large files
✅
N/A
Engineering capabilities (developer-facing)
These are features that don't have an Excel equivalent — they're framework / SDK capabilities:
Headless UI mode
Components render without forcing a specific UI shell
useSpreadsheetState hook
Single hook that wires every callback + state slice
Imperative spreadsheet API
Programmatic access via ref handle (set value, get range, etc.)
Multi-instance spreadsheets
Multiple independent grids on one page via instanceId
Calculate on-demand
Toggle automatic recalculation; queue dispatcher exposed
Real-time data binding
Stream external values into cells (e.g. live ticker)
Web-worker calculation
Off-main-thread formula engine
Custom cell editors
Replace per-type editor (e.g. richer date picker)
Custom cell renderers
Replace per-cell paint with React component
Structured cell renderer
Schema-driven cell rendering
Custom formula evaluation
Inject custom function implementations
Tokenizer access
Parse formulas independently of the engine
Cell format registry
Register custom number formats globally
Shared strings management
Manual control of the workbook's interned string pool
Mentions
@user mention engine bundled
Localisation
i18n for UI strings (formula names still English)
Lazy loading / infinite scrolling
Render only what's visible; load rows on demand
Export canvas as image
Serialize current viewport to PNG
OpenAI / ChatGPT integration
Inline AI prompt + apply (magic fill, formula generation)
Drag and drop (files)
Drop CSV / XLSX onto the canvas to import
Insert date/time helpers
Toolbar action
Insert link / image editor components
Bundled
Navigate to range / named range editor
Bundled components
Sheet status component
Pre-built bottom-bar status widget
Range selector / selection input components
Reusable picker controls
Version comparison
Diff two snapshots and surface changes
Currently unsupported (deferred)
This list mirrors docs/compat/remaining-gaps.md. Tier 1 items can silently lose data on round-trip:
Tier 1 — data-loss blockers:
Sheet protection enforcement ✅ shipped —
useCanEditCellRange/useCanEditSheetgates threaded through every mutation hook; host provides the policy viaSpreadsheetContext.canEditCell+isSheetProtectedWorkbook protection enforcement ✅ shipped —
useCanEditWorkbookgates structural sheet ops (add / delete / rename / move / duplicate / hide / show / tab-color); host opts in viaisWorkbookProtected={true}on the canvas gridThreaded comments (with replies, resolved state) ✅ shipped — XLSX import + export in both the JS toolkit and the Rust engine (
xl/threadedComments/*,xl/persons/person.xml,tc=legacy mirror)
Phase 6 (formula) remainder:
Cross-workbook references
[Book.xlsx]Sheet!A1— evaluation. The references round-trip opaquely in both engines (link parts,[N]formulas and their cached values), but the linked workbook is never openedGROUPBY / PIVOTBY ✅ shipped —
LambdaFunctionsentries built on top of the existing LAMBDA infra. Tokenizer-aware substitution + Excel-native{a,b,c}array literals; no chevrotain grammar changesFORECAST.ETS* family
Phase 7 — LAMBDA family:
LET ✅ shipped (source-level expansion)
LAMBDA + MAP / REDUCE / BYROW / BYCOL / SCAN / MAKEARRAY ✅ shipped (IIFE inlining + runtime lambda values via
__RNC_LAMBDA__preprocessor; HOFs registered infunsNeedContextand re-invoke the parser per element)LET-bound lambdas callable like functions ✅ shipped (Function-token substitution in
substituteNamesInBody)Closures via LET capture ✅ shipped (LET runs first, bakes values into lambda body strings)
Workbook-named LAMBDAs callable as
=MyFunc(args)✅ shipped —_callFunctionresolves viaonVariableand accepts three shapes: pre-built lambda value,{ref, value}wrapper, or raw"=LAMBDA(...)"source string (parsed + applied). Works for both the main-thread calc and the worker since both use the rebuilt parser distDep parser walks lambda body strings ✅ shipped (
__RNC_LAMBDA__branch in DepParser.callFunction uses a fresh inner instance)Recursive lambdas (e.g.
LET(fact, LAMBDA(n, IF(...)), fact(5))) ❌ deferred — body-reparse at runtime loses the LET binding; needs a runtime registry that survives the per-call re-parseNamed-range formula evaluation (general —
=MyVarwhere MyVar ==SUM(A1:A10)) ✅ shipped — Excel virtual-cell model with real DAG edges, unified topological sort, scope-aware (workbook + per- sheet) keys, order-independent batch registration, and JSON round-trip. See "Formulas & functions" table above for the user- visible rows.
Phase 8 — visual fidelity:
Painting image adjustments (crop / brightness / contrast / transparency) in the grid — the values round-trip (
EmbedImageSpec) but the bitmap paints untouchedConnector shapes (
<xdr:cxnSp>lines / arrows) — dropped on importForm controls ✅ round-trip in both engines (
<controls>/xl/ctrlProps/*/ VML captions →formControlembeds → written back); running them (toggling a linked cell, driving a spin button) and ActiveX controls remain out of scopePlace-in-cell images (
<c vm="…">rich values,IMAGE()in Excel) — the rich-value parts are dropped; the cell keeps its cached#VALUE!Gradient cell fills ✅ shipped —
<gradientFill>round-trip + canvas paint (linear + path)
Phase 9 — view & UX:
RTL layout — the
rightToLeftflag round-trips (XLSX / XLSB / ODS, both engines); grid mirroring still deferredNon-frozen pane split — deferred (single scroll viewport per sheet)
Phase 10 — pivot tables:
Full XLSX cache + definition round-trip ✅ shipped — import (
pivots.ts) + export (pivot-builder.ts); refreshOnLoad rebuilds cache from live sourceAuthoring UI (drag rows/cols/values, hide fields, subtotals + grand-totals toggles, sort, label filter, Top N, refresh, drilldown) ✅ shipped
Show Values As (% of total / row / column, running total, rank ↑/↓) ✅ shipped — PERCENT/NUMBER number-format applied to value + total cells
Field grouping (date by period, numeric by bucket) ✅ shipped via DuckDB EXCLUDE-based SOURCE rewrite
GETPIVOTDATA() ✅ shipped
Slicer ↔ pivot wiring ✅ shipped (label-filter pipeline)
Calculated fields ✅ shipped (both engines + XLSX round-trip; formula arithmetic over field sums — see the feature row for the gaps); calculated items ❌ deferred
Pivot-backed slicer OLAP cube cache export ❌ deferred (table-backed slicers ship)
Pivot table styles ✅ round-trip (
PivotTable.styleInfo↔pivotTableStyleInfo, both toolkits; defaultPivotStyleLight16); the renderer still paints its own pivot formatsConditional formatting on pivot data cells ❌ deferred
Phase 11 — niche:
Bond pricing functions (PRICE, ODDFPRICE, ODDLYIELD)
Custom table style export ⚠️ partial — toolkit round-trip via
customTableStylesarg oncreateExcelFileships, but theTableStyleSelectorpicker doesn't list user-authored styles andTableView.styleNameisn't applied throughuseSpreadsheetStatefor them. UI wiring deferredWorkbook named cell styles UI gallery ⚠️ partial — toolkit round-trip via
namedCellStylesships, butCellStyleSelector'suserDefinedStylesprop isn't auto-populated from imported named styles. UI wiring deferredLocalized function names
Phase 12 — linked data types:
Linked data type cells (Stocks, Geography, Wolfram) — partial: the
entity-shapedstructuredValuemodel +dataTypeProvidersAPI already lets hosts wire custom providers (seestockProviderin the storybook), and=A1.fieldfield-access syntax works. Round-tripping the OOXMLlinkedDataTypeelement + Excel's built-in Stocks/Geography panel is deferred. Not blocked by anything fundamental.
Explicitly out of scope
Macros / VBA / ActiveX / OLE
Power Query / Power Pivot / external data connections
SmartArt graphics
Cube functions (CUBEMEMBER, etc.)
STOCKHISTORY (live market data)
Custom XML parts
Google Apps Script
IMPORTRANGE / GOOGLETRANSLATE / GOOGLEFINANCE (Sheets-specific functions tied to Google services)
Summary
The Rows n Columns Spreadsheet offers high-fidelity compatibility with Excel and Google Sheets across the features most apps use:
Fully compatible:
All basic & advanced editing operations
Full cell formatting palette (fonts, colors, borders, alignment, number formats, rotation)
~378 Excel-compatible functions including dynamic arrays + modern array helpers
Named ranges — range-typed, static-value, formula-typed (
=MyVarwhere MyVar is a formula), and named functions (LAMBDA-typed). Excel virtual-cell DAG model with scope-aware (workbook + sheet) resolution and order-independent batch registrationTables, AutoFilter (incl. color/icon), multi-column sort, data validation
Conditional formatting (boolean, gradient, data bars, icon sets, custom formula)
Charts (column, bar, line, area, pie/doughnut, scatter/bubble, radar, stock-line, treemap, sunburst)
Embedded images, hyperlinks, sparklines, legacy comments, form controls (round-trip)
Multi-sheet workbooks with full sheet management
Row/column outline & grouping (XLSX + ODS round-trip)
Slicers (table-backed)
Themes (light / dark variants)
XLSX + ODS round-trip for all of the above
Password-encrypted XLSX export
CSV import/export
Built-in features Excel/Sheets don't have:
Real-time collaboration (YJS, ShareDB, Supabase) out of the box
Web-worker formula engine
Custom cell editors + renderers
Multi-instance grids on one page
OpenAI / ChatGPT magic fill integration
Schema-based tables
React-native customisation throughout
Known gaps (deferred — see Phase tables above):
Recursive LAMBDA (rest of Phase 7 family + GROUPBY/PIVOTBY shipped)
Pivot table calculated fields/items + pivot-backed slicer OLAP cache (rest of Phase 10 shipped — cache+def round-trip, authoring UI, GETPIVOTDATA, show-values-as, grouping, slicer wiring)
Macros, Power Query, Cube functions, linked data types (out of scope). External-service formulas (
CUBE*,RTD,STOCKHISTORY,WEBSERVICE,CALL,REGISTER.ID,EUROCONVERT,TRANSLATE,DETECTLANGUAGE) evaluate to#N/Aon both engines; an imported workbook keeps Excel's cached result for them until the cell or a precedent is edited (a precedent reached directly, through a whole-column / whole-row reference, a named range or table, or transitively via another formula — what Excel's recalc chain would follow), Calculate Now / F9 runs, or the file declaresfullCalcOnLoadGoogle Sheets extras (
SPLIT,JOIN,REGEXMATCH,SORTN,ARRAYFORMULA) evaluate on both engines with the legacy engine's semantics:SPLITpieces stay text (no number coercion),SORTNignoresdisplay_ties_modeand takes one sort column,REGEXMATCHuses the host regex flavour (JSRegExp/ Rustregex— no look-around or back-references on rnc);QUERYis a recognised call yielding#N/A(no query-language interpreter). Excel has none of these names and shows#NAME?for exported cells
For most spreadsheet application use cases, this provides production-grade compatibility with Excel and Google Sheets — and adds collaboration + extensibility features that neither has natively.
Last updated