feat(editor): open .xlsx and .xlsm workbooks in a sheet viewer - #13532
feat(editor): open .xlsx and .xlsm workbooks in a sheet viewer#13532SrAlvarado wants to merge 24 commits into
Conversation
The virtualized sheet grid CsvViewer renders is about to have a second consumer, so it moves into SpreadsheetGrid with its column sizing split into a pure, testable module. CsvViewer keeps delimiter sniffing, parsing and the row/column counts. No behavior change: the markup, class names and virtualization settings are the ones CsvViewer already used.
Opening a workbook showed "Binary file — cannot display": the read path had no mime type for .xlsx, so it fell through to the generic binary placeholder. Workbooks now render read-only in the editor, one tab per worksheet, on the same virtualized grid CsvViewer uses. The parser is hand-rolled, like csv-parse.ts, rather than pulling in SheetJS: a workbook is a zip of XML parts, and reading the few parts a viewer needs (workbook, rels, sharedStrings, styles, worksheets) is a small well-specified slice. Inflating goes through the platform's DecompressionStream, so there is no new dependency and no native code. Values are shown as stored, with two interpretations: shared/inline/rich text is joined into the cell value, and date-formatted numbers are rendered as dates because a bare serial like 45658 is meaningless to a reader. The 1900 phantom leap day and the 1904 date system are both handled. Sparse rows and columns are padded so values stay in place. Remote parity: the relay read path gets the same mime types, so an .xlsx in an SSH worktree opens the same way. Workbooks get a tighter 20MB read budget than the 50MB image/PDF one, because they are parsed into renderer objects rather than streamed to a viewer, and sheets are capped at 200k rows with the cap surfaced in the footer. Legacy .xls and .xlsb are deliberately not claimed — they are a different, non-OOXML format this parser cannot read, and claiming them would replace a truthful placeholder with an error.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe change adds 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (10)
src/renderer/src/components/editor/EditorContent.tsx (1)
46-46: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winUse retryable loading for
XlsxViewer.
lazy()caches a rejected chunk import. A transient chunk-load failure can prevent later workbook previews until restart. UselazyWithRetryfor this viewer, and verify its retry defaults match other lazy editor viewers.src/renderer/src/components/editor/xlsx-workbook-test-fixtures.ts (1)
156-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueEscape the sheet name in the
nameattribute.
buildSharedStringsXmlescapes its text at line 175, butbuildWorkbookXmlinterpolatessheet.nameraw. If a test uses a sheet name that contains&,<, or", the fixture emits malformed XML. The resulting test failure would point at the parser instead of the fixture.
escapeXmlTextalready covers the four characters an attribute value needs.♻️ Proposed change
- `<sheet name="${sheet.name}" sheetId="${index + 1}"${sheet.hidden === true ? ' state="hidden"' : ''} r:id="rId${index + 1}"/>` + `<sheet name="${escapeXmlText(sheet.name)}" sheetId="${index + 1}"${sheet.hidden === true ? ' state="hidden"' : ''} r:id="rId${index + 1}"/>`src/renderer/src/components/editor/xlsx-xml-elements.ts (1)
26-55: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueSelf-closing detection can misread a quoted attribute value.
Line 36 treats the element as self-closing when the character before
>is/. A quoted attribute value that ends with/produces the same character, for example<Relationship Id="rId1" Target="http://host/">…</Relationship>. The scanner then reports an emptyinnerand resumes inside the element. The same heuristic repeats at Line 164 infindCloseTagStart, so nesting depth can also be wrong for that input.The current consumers (
row,c,t,si,sheet,Relationship) are self-closing or attribute-only in real producer output, so the impact is limited. If you want to close the gap, track quote state while scanning to>instead of matching the last character.src/renderer/src/components/editor/xlsx-part-paths.ts (1)
8-16: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider ignoring external relationship targets.
resolveXlsxPartPathtreats every target as a package-relative part name. A relationship withTargetMode="External"holds a URI, for examplehttps://host/book.xlsx. The function then produces a meaningless part name such ashttps:/host/book.xlsx.
readRelationshipTargetsinsrc/renderer/src/components/editor/xlsx-workbook.ts(Lines 110-116) stores every relationship, so an external target can enter the map. A sheetr:idnever points at an external part, so no current path is broken. Skipping entries withTargetMode="External"would make the contract explicit.src/renderer/src/components/editor/xlsx-number-formats.ts (2)
25-52: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
numFmtelements outside<numFmts>also reach this scan.
forEachXlsxXmlElementscans the wholestylesXmlfornumFmt.<dxfs>differential formats can also contain<numFmt>. If such an element reuses a built-in id with a non-date code, Line 38 removes that built-in id for every cell format. Scoping the scan to thenumFmtsblock, asparseCellFormatNumberFormatIdsdoes forcellXfs, removes the ambiguity.
72-82: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueElapsed-time formats are classified as dates.
[h]:mm:ssand[mm]:ssare elapsed-duration formats. Line 77 strips the bracketed section, and the remainingmm:ssmatchesDATE_FORMAT_TOKEN_PATTERN. The viewer then renders serial1.5as a calendar date instead of36:00:00. Detecting a leading[h],[m], or[s]section and returningfalsekeeps those cells as their stored number.src/renderer/src/components/editor/xlsx-shared-strings.ts (1)
3-17: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueA self-closing
<rPh/>can delete real text.
PHONETIC_RUN_PATTERNrequires a</rPh>close tag. If a producer writes<rPh sb="0" eb="1"/>and a later</rPh>exists in the same<si>, the lazy match spans both and removes the text between them. The schema requires a<t>child, so Excel output is unaffected. Adding the self-closing alternative to the pattern removes the risk.src/renderer/src/components/editor/xlsx-worksheet-grid.ts (1)
36-62: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueRow-cap stop conflicts with the out-of-order row comment.
Line 44 returns
falseand ends the scan on the first row whose index reachesmaxRows. The comment at Lines 51-53 states that rows can arrive out of order. With out-of-order rows, one highrvalue discards every remaining row and marks the sheet truncated. Excel writes rows in ascending order, so normal files are unaffected. If you want both behaviors, continue scanning and only marktruncatedfor skipped rows.src/renderer/src/components/editor/xlsx-worksheet-grid.test.ts (1)
76-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a regression test for an oversized column reference.
The suite covers an unparseable reference but not a syntactically valid reference with a very large column, for example
r="AAAAAAAA1". That input drives the padding loop inreadRowCells. A test that asserts the parser rejects or clamps the column would lock in the bound requested onsrc/renderer/src/components/editor/xlsx-worksheet-grid.tsLines 64-80.src/renderer/src/components/editor/xlsx-workbook.ts (1)
121-135: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winResolve
sharedStrings.xmlandstyles.xmlthrough the relationship map.Both helpers assume the conventional file name next to the workbook part.
parseXlsxWorkbookalready buildsrelationshipTargetsat Line 44. A producer that names the parts differently, but declares thesharedStringsandstylesrelationship types, then loses all strings and all date formatting. Looking the targets up by relationship type, and keeping the current name as the fallback, matches the approach already used for worksheets.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 6d91c42e-a3f6-4285-be67-8ad5c2e34c30
📒 Files selected for processing (39)
src/main/ipc/filesystem.test.tssrc/main/ipc/filesystem.tssrc/relay/fs-handler-file-read.tssrc/relay/fs-handler-previewable-binary.test.tssrc/relay/fs-handler-utils.tssrc/renderer/src/components/editor/CsvViewer.tsxsrc/renderer/src/components/editor/EditorContent.test.tsxsrc/renderer/src/components/editor/EditorContent.tsxsrc/renderer/src/components/editor/SpreadsheetGrid.tsxsrc/renderer/src/components/editor/XlsxViewer.test.tsxsrc/renderer/src/components/editor/XlsxViewer.tsxsrc/renderer/src/components/editor/binary-file-preview-kind.test.tssrc/renderer/src/components/editor/binary-file-preview-kind.tssrc/renderer/src/components/editor/spreadsheet-grid-columns.test.tssrc/renderer/src/components/editor/spreadsheet-grid-columns.tssrc/renderer/src/components/editor/xlsx-cell-reference.test.tssrc/renderer/src/components/editor/xlsx-cell-reference.tssrc/renderer/src/components/editor/xlsx-number-formats.test.tssrc/renderer/src/components/editor/xlsx-number-formats.tssrc/renderer/src/components/editor/xlsx-part-paths.test.tssrc/renderer/src/components/editor/xlsx-part-paths.tssrc/renderer/src/components/editor/xlsx-serial-date.test.tssrc/renderer/src/components/editor/xlsx-serial-date.tssrc/renderer/src/components/editor/xlsx-shared-strings.test.tssrc/renderer/src/components/editor/xlsx-shared-strings.tssrc/renderer/src/components/editor/xlsx-workbook-test-fixtures.tssrc/renderer/src/components/editor/xlsx-workbook.test.tssrc/renderer/src/components/editor/xlsx-workbook.tssrc/renderer/src/components/editor/xlsx-worksheet-grid.test.tssrc/renderer/src/components/editor/xlsx-worksheet-grid.tssrc/renderer/src/components/editor/xlsx-xml-elements.test.tssrc/renderer/src/components/editor/xlsx-xml-elements.tssrc/renderer/src/components/editor/xlsx-zip-archive.test.tssrc/renderer/src/components/editor/xlsx-zip-archive.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/lib/base64-bytes.test.tssrc/renderer/src/lib/base64-bytes.tssrc/shared/spreadsheet-file-extensions.test.tssrc/shared/spreadsheet-file-extensions.ts
| function readRowCells(rowXml: string, context: XlsxWorksheetContext): string[] { | ||
| const cells: string[] = [] | ||
| let nextColumnIndex = 0 | ||
|
|
||
| forEachXlsxXmlElement(rowXml, 'c', (cellElement) => { | ||
| const reference = cellElement.attributes.r | ||
| const parsed = reference === undefined ? null : parseXlsxCellReference(reference) | ||
| const columnIndex = parsed?.columnIndex ?? nextColumnIndex | ||
| nextColumnIndex = columnIndex + 1 | ||
| while (cells.length < columnIndex) { | ||
| cells.push('') | ||
| } | ||
| cells[columnIndex] = readCellText(cellElement.attributes, cellElement.inner, context) | ||
| }) | ||
|
|
||
| return cells | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
No upper bound on the parsed column index. readRowCells pads a row up to the column index returned by parseXlsxCellReference, so a crafted reference such as r="AAAAAAAA1" can drive an allocation loop of billions of entries. The compressed-workbook limit does not prevent this, because one short reference produces the large index.
src/renderer/src/components/editor/xlsx-worksheet-grid.ts#L64-L80: reject or clamp a column index above the SpreadsheetML maximum of 16384 before the padding loop, unlessparseXlsxCellReferencealready does so.src/renderer/src/components/editor/xlsx-worksheet-grid.test.ts#L76-L82: add a test with a valid but oversized column reference that asserts the parser clamps or drops the cell instead of allocating.
📍 Affects 2 files
src/renderer/src/components/editor/xlsx-worksheet-grid.ts#L64-L80(this comment)src/renderer/src/components/editor/xlsx-worksheet-grid.test.ts#L76-L82
Addresses the review on stablyai#13532: - Bound the inflated size of a zip part. The 20MB read budget only capped the compressed file, so a deflate bomb (roughly 1000:1) could expand to gigabytes in renderer memory before any error. The declared size now lowers the ceiling, an absolute 256MB cap applies regardless of what the archive claims, and inflate aborts mid-stream once either is exceeded. Real workbooks inflate 4-9x, so this leaves an order of magnitude of room. - Drop phonetic runs from inline strings, not just shared strings. The same Japanese value rendered differently depending on which form the producer used; the stripping moved into the shared text-run reader so every form gets it. - Do not classify elapsed-duration formats as dates. `[h]:mm:ss` means 36 hours for serial 1.5, not a calendar day, so those cells keep their stored number instead of being rendered as a date. - Scope the numFmt scan to <numFmts>. A <numFmt> under <dxfs> belongs to a single conditional-format rule and was clearing built-in date ids for every cell format. - Find the end of an open tag with quote state instead of indexOf('>'). XML only requires < and & to be escaped in an attribute value, so a legal > there truncated attribute parsing. - Resolve sharedStrings.xml and styles.xml through their relationship types with the conventional name as fallback, matching what worksheets already did, and skip TargetMode="External" relationships, whose target is a URI rather than a part name. - Keep scanning after a row lands past the row cap, so one out-of-order row no longer discards every row after it. - Add role="rowgroup" to the virtualized grid body, so assistive technology keeps the table's owned-row relationship (also fixes CsvViewer). - Format row and column counts with getIntlLocale() so separators follow the app language, not the OS locale. - Escape the sheet name in the test fixture builder. Regression tests added for each, including a valid-but-oversized column reference (`r="AAAAAAAA1"`), which was already bounded at the SpreadsheetML 16384-column limit but had no test pinning it.
|
Thanks — this was a genuinely useful review. Pushed FixedBound the inflated size of a part (🟠 Major, raised twice) — correct, and the most important finding here. The 20MB budget only capped the compressed file, so a deflate bomb could expand to gigabytes before the viewer said anything, and On the number: real workbooks inflate 4–9x (measured on a 325KB file here, largest part 1.4MB), so 256MB keeps an order of magnitude of headroom over the 20MB budget while removing the unbounded case. Three tests: inflates-past-declared-size, declared-size-past-ceiling, and declared-size-of-zero still readable. Inline strings kept phonetic runs (🟡) — real inconsistency, and the right diagnosis. Rather than duplicating the strip at the inline-string call site, the Elapsed-duration formats classified as dates (🔵, but really a correctness bug) — you're right and my test had the wrong expectation pinned: I asserted
Self-closing detection (🔵) — the underlying gap is real, though not via your example.
External relationship targets (🔵) — taken. Row-cap stop vs. out-of-order rows (🔵) — fair catch on the contradiction with my own comment. The scan now skips the over-cap row and keeps going, so one high
Escape the sheet name in the fixture builder (🔵) — taken; a fixture that emits malformed XML would point the failure at the parser. Doesn't reproduce"No upper bound on the parsed column index" (🔴 Critical) — this one doesn't hold, and your own hedge is the reason: "unless The suggested test was still worth adding, though: the bound lived in one module and the padding loop in another, with nothing pinning the relationship. "Use retryable loading for Verification
Also re-validated against a real Excel-authored workbook (13 sheets, 8073 rows, shared strings, date columns) after the changes: identical output, ~72ms. |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/renderer/src/components/editor/xlsx-zip-archive.ts (1)
211-228: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueConsider a lower absolute ceiling than 256 MiB.
The cancel-on-exit and pre-push cap checks are correct. The peak memory is still notable in the worst case. The chunk list can reach
inflatedSizeLimit, and the final contiguous copy adds the same amount again. With a declared size of 256 MiB, a crafted part can force roughly 512 MiB in the renderer before the read completes.The comment at Line 23 states that real workbooks inflate 4-9x over a 20 MB budget. A ceiling near 64 MiB would keep an order of magnitude of headroom and halve the worst-case peak. This is a tuning choice, so treat it as optional.
src/renderer/src/components/editor/xlsx-zip-archive.test.ts (1)
5-7: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueBind the
DataViewto the view window in the test helper.
new DataView(bytes.buffer)ignoresbytes.byteOffset. The helper also derives the end-record position frombytes.length. The two disagree if the argument is a subarray of a larger buffer.This file adds a test that reads an archive from a non-zero byte offset. That test does not use this helper today, so the current assertions are correct. If a later test passes an offset view here, the helper reads unrelated bytes and the test fails for the wrong reason. The same applies to the
new DataView(bytes.buffer)writes at Lines 177, 187, and 196.♻️ Proposed offset-safe helper
-function centralDirectoryOffset(bytes: Uint8Array): number { - return new DataView(bytes.buffer).getUint32(bytes.length - 22 + 16, true) -} +function archiveView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) +} + +function centralDirectoryOffset(bytes: Uint8Array): number { + return archiveView(bytes).getUint32(bytes.byteLength - 22 + 16, true) +}
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: a935f58d-ae8b-4f47-b0e6-d716af9c5e07
📒 Files selected for processing (14)
src/renderer/src/components/editor/SpreadsheetGrid.tsxsrc/renderer/src/components/editor/XlsxViewer.tsxsrc/renderer/src/components/editor/xlsx-number-formats.test.tssrc/renderer/src/components/editor/xlsx-number-formats.tssrc/renderer/src/components/editor/xlsx-shared-strings.tssrc/renderer/src/components/editor/xlsx-workbook-test-fixtures.tssrc/renderer/src/components/editor/xlsx-workbook.test.tssrc/renderer/src/components/editor/xlsx-workbook.tssrc/renderer/src/components/editor/xlsx-worksheet-grid.test.tssrc/renderer/src/components/editor/xlsx-worksheet-grid.tssrc/renderer/src/components/editor/xlsx-xml-elements.test.tssrc/renderer/src/components/editor/xlsx-xml-elements.tssrc/renderer/src/components/editor/xlsx-zip-archive.test.tssrc/renderer/src/components/editor/xlsx-zip-archive.ts
🚧 Files skipped from review as they are similar to previous changes (9)
- src/renderer/src/components/editor/xlsx-number-formats.ts
- src/renderer/src/components/editor/xlsx-worksheet-grid.test.ts
- src/renderer/src/components/editor/xlsx-shared-strings.ts
- src/renderer/src/components/editor/SpreadsheetGrid.tsx
- src/renderer/src/components/editor/xlsx-xml-elements.test.ts
- src/renderer/src/components/editor/xlsx-worksheet-grid.ts
- src/renderer/src/components/editor/XlsxViewer.tsx
- src/renderer/src/components/editor/xlsx-workbook-test-fixtures.ts
- src/renderer/src/components/editor/xlsx-workbook.ts
The grid read as a list of rows rather than a sheet of cells: the cell
hairlines used the border token with an opacity modifier (border/40), and
in dark mode --border is already rgb(255 255 255 / 0.07), so the vertical
gridlines landed near 0.03 alpha and were effectively invisible.
Gridlines now use the border token at full strength on every cell, which
is what STYLEGUIDE.md specifies it for ("all hairlines"), and the row-number
gutter uses the same muted band as the column header — so both read as
headers the way a spreadsheet's do.
Cells also take their alignment from the value: numbers, dates and times
right, booleans and cached error codes centered, text left. That is the cue
that tells a reader which columns are numeric, and it has to be derived from
the rendered text because the grid only receives strings. Workbook column
letters are centered over their column; a CSV keeps its heading row
left-aligned, since that row is the file's own text rather than a label.
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: bdbf8a2e-3290-4bba-9d1d-ef4ff11cf69c
📒 Files selected for processing (4)
src/renderer/src/components/editor/SpreadsheetGrid.tsxsrc/renderer/src/components/editor/XlsxViewer.tsxsrc/renderer/src/components/editor/spreadsheet-cell-alignment.test.tssrc/renderer/src/components/editor/spreadsheet-cell-alignment.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/renderer/src/components/editor/XlsxViewer.tsx
Two changes that both come out of looking at a real styled workbook. Cell styling. A workbook's fills, font colours and bold now render, which the PR description previously listed as out of scope. styles.xml is read for <fills>, <fonts> and the cellXfs a cell's `s` attribute indexes; colours resolve from rgb (ARGB), theme (against theme1.xml, whose first two index pairs are swapped relative to clrScheme order) and the legacy indexed palette, with tint applied in luminance space the way Excel's palette shades are. Only solid patterns become a background: a hatch like gray125 is a texture, and painting it as a solid block would invent a colour. The ink is derived rather than copied. An author picks a fill against Excel's white sheet, so on a dark theme the app's foreground is frequently unreadable on top of it. The workbook's font colour wins when it clears WCAG AA against its own fill — the white-on-dark-header case — and otherwise the ink falls back to whichever of black or white contrasts more. On an unfilled cell the font colour is left alone entirely, because the background is whatever the theme uses and contrast cannot be checked. Column virtualization. `columnCount` comes from the widest used row, so a sheet with one far-right used cell reports thousands of columns; the grid rendered a cell per column in every visible row, and worse, put a grid-template-columns string with one track per column into the inline style of every row. Both axes are virtualized now, with the off-screen columns collapsed into one spacer track on each side. It stays a CSS grid rather than absolutely-positioned cells so the sticky row-number gutter keeps its normal-flow position and the header and body keep sharing one template. Cells carry aria-colindex, since the DOM no longer reflects real column positions. Both apply to CsvViewer through the shared grid; the wide-sheet problem was there too.
|
Good catch on the column virtualization — that one was real and worse than the comment describes. Fixed, plus this push adds the cell styling the PR previously declared out of scope. Column virtualization (🟠 Major)Confirmed. Rather than bound the displayed columns, the grid now virtualizes both axes. The off-screen columns collapse into one spacer track on each side of the template: Keeping it a grid — instead of absolutely positioning cells — is deliberate: the sticky row-number gutter has to stay a normal-flow grid item, and the header and body keep sharing one template, which is what stops them drifting apart. The template string is now a few dozen characters regardless of sheet width, and the rendered cell count follows the viewport. Also added Regression tests in This applies to Cell styling (new in this push)Workbook fills, font colours and bold now render, which the description previously listed as out of scope. Reviewed against a real Excel-authored workbook: the dark blue header bands, the yellow input cells and the light blue bands all come through as declared. Two things worth a reviewer's attention: Ink is derived, not just copied. A fill is chosen by its author against Excel's white sheet, so on a dark theme the app's own foreground is frequently unreadable on top of it. The workbook's font colour wins when it clears WCAG AA on that fill (the white-on-dark-header case), otherwise the ink falls back to whichever of black or white contrasts more. Font colour is only honoured on filled cells. Without a fill we do not know the background — it is whatever the active theme uses — so we cannot verify contrast, and a workbook's white font on an unfilled cell would vanish on the light theme. Those cells keep the theme foreground. That loses colour on unfilled text; I judged an unreadable cell worse than a monochrome one, but say the word if you would rather be faithful there. Two hex constants live in Also in this push: the cell hairlines were using Verification
Two tests flake under full-suite load on this machine, and I want to be precise rather than wave them off: |
The grid inherited the app's dark chrome, which fought the file it was showing: a workbook's cell fills are authored against a white sheet, so on a dark canvas they read as arbitrary blocks of colour rather than as the highlighting the author meant. Adds a spreadsheet surface token family to main.css — surface, foreground, header band, header foreground, gridline, strong gridline and row hover — and uses it in SpreadsheetGrid and the two viewers' footers. The values follow Excel and Google Sheets: white sheet, light grey header bands for the column letters and the row-number gutter, a stronger divider under the header row and beside the gutter, hairline gridlines between cells, and the active sheet tab lifted onto the sheet surface. The tokens hold the same values in light and dark on purpose, which is what both applications do inside a dark chrome, for the same reason the cell ink is derived from the fill rather than the theme. It is one place to change if a maintainer wants a dark sheet variant instead. Cells also move off the monospace stack onto the app's proportional font with tabular-nums, so text reads like a spreadsheet while digits still line up in a column. Worth noting for whoever touches this next: `bg-editor-surface` is not a utility this project has — the token is not exposed in the `@theme inline` block, so that class compiles to nothing. The working form elsewhere in the codebase is `bg-[var(--editor-surface)]`. Every utility added here was checked against the compiled CSS.
a401d7a to
1f1dcba
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/renderer/src/components/editor/xlsx-worksheet-grid.test.ts (1)
280-286: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winMerge styles when repeated rows merge values.
Line 280 adds repeated-row coverage.
parseXlsxWorksheetGridmergesrows[rowIndex], but it replacesstyles[rowIndex]with the later sparse row. A repeated row with styled cells in different columns loses the earlier cell styles.Merge the style row with the same positional semantics as the value row. Add an assertion for styles from both repeated row elements.
🧹 Nitpick comments (2)
src/renderer/src/components/editor/SpreadsheetGrid.tsx (1)
40-53: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueReduce implementation-comment length.
Keep only the non-obvious constraint. The identifiers and tests already describe most implementation details.
src/renderer/src/components/editor/SpreadsheetGrid.tsx#L40-L53: reduce the component overview to concise virtualization and reset rationale.src/renderer/src/components/editor/SpreadsheetGrid.test.tsx#L34-L37: remove the repeated DOM and style-size explanation.src/renderer/src/components/editor/spreadsheet-grid-columns.ts#L68-L76: retain a short spacer-template contract description.As per coding guidelines: “Comments must be concise, limited to non-obvious information, and preferably one line.”
Source: Coding guidelines
src/renderer/src/components/editor/xlsx-workbook.test.ts (1)
164-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse a font color that differs from the fallback ink.
The expected
#ffffffis also the readable fallback for the dark#4472c4fill. This test passes if declared font colors are ignored.Use a legible non-white font color on a dark fill, then assert that exact color.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9f6b501b-613b-432b-8b6d-b78f382f45d1
📒 Files selected for processing (21)
src/renderer/src/components/editor/SpreadsheetGrid.test.tsxsrc/renderer/src/components/editor/SpreadsheetGrid.tsxsrc/renderer/src/components/editor/XlsxViewer.test.tsxsrc/renderer/src/components/editor/XlsxViewer.tsxsrc/renderer/src/components/editor/spreadsheet-cell-contrast.test.tssrc/renderer/src/components/editor/spreadsheet-cell-contrast.tssrc/renderer/src/components/editor/spreadsheet-grid-columns.test.tssrc/renderer/src/components/editor/spreadsheet-grid-columns.tssrc/renderer/src/components/editor/xlsx-cell-formats.test.tssrc/renderer/src/components/editor/xlsx-cell-formats.tssrc/renderer/src/components/editor/xlsx-cell-styles.test.tssrc/renderer/src/components/editor/xlsx-cell-styles.tssrc/renderer/src/components/editor/xlsx-color.test.tssrc/renderer/src/components/editor/xlsx-color.tssrc/renderer/src/components/editor/xlsx-number-formats.tssrc/renderer/src/components/editor/xlsx-theme-palette.tssrc/renderer/src/components/editor/xlsx-workbook-test-fixtures.tssrc/renderer/src/components/editor/xlsx-workbook.test.tssrc/renderer/src/components/editor/xlsx-workbook.tssrc/renderer/src/components/editor/xlsx-worksheet-grid.test.tssrc/renderer/src/components/editor/xlsx-worksheet-grid.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- src/renderer/src/components/editor/spreadsheet-grid-columns.test.ts
- src/renderer/src/components/editor/xlsx-number-formats.ts
- src/renderer/src/components/editor/xlsx-workbook-test-fixtures.ts
| function readIdAttribute(value: string | undefined, fallback: number): number { | ||
| const parsed = Number.parseInt(value ?? '', 10) | ||
| return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject partially numeric XML attributes.
Number.parseInt and Number.parseFloat accept valid prefixes. Values such as numFmtId="14x", theme="4x", indexed="2.5", and tint="0.5x" therefore produce invented formatting or colors instead of using the safe fallback.
src/renderer/src/components/editor/xlsx-cell-formats.ts#L39-L41: require the complete ID string to be a non-negative integer before converting it.src/renderer/src/components/editor/xlsx-color.ts#L97-L119: require complete numeric values fortint,theme, andindexedbefore resolving them.src/renderer/src/components/editor/xlsx-cell-formats.test.ts#L16-L27: add partial-number ID cases and assert fallback IDs.src/renderer/src/components/editor/xlsx-color.test.ts#L47-L50: add partial-number theme, indexed, and tint cases and assertnullor the untinted base color.
📍 Affects 4 files
src/renderer/src/components/editor/xlsx-cell-formats.ts#L39-L41(this comment)src/renderer/src/components/editor/xlsx-color.ts#L97-L119src/renderer/src/components/editor/xlsx-cell-formats.test.ts#L16-L27src/renderer/src/components/editor/xlsx-color.test.ts#L47-L50
| if (backgroundColor !== undefined) { | ||
| style.backgroundColor = backgroundColor | ||
| style.textColor = pickReadableCellTextColor(backgroundColor, font?.color) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Preserve declared font colors when a cell has no fill.
The current implementation drops an explicit font color unless the cell also has a solid fill. This makes common no-fill styles, such as red values or white title text, render with the application foreground instead of the workbook font color.
src/renderer/src/components/editor/xlsx-cell-styles.ts#L54-L57: assignfont.colorwhen no background exists. UsepickReadableCellTextColoronly when a resolved fill provides a contrast background.src/renderer/src/components/editor/xlsx-cell-styles.test.ts#L50-L57: expect style index3to includetextColor: '#ffffff', and add a no-fill colored-font case.
Proposed implementation
if (backgroundColor !== undefined) {
style.backgroundColor = backgroundColor
style.textColor = pickReadableCellTextColor(backgroundColor, font?.color)
+ } else if (font?.color !== undefined) {
+ style.textColor = font.color
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (backgroundColor !== undefined) { | |
| style.backgroundColor = backgroundColor | |
| style.textColor = pickReadableCellTextColor(backgroundColor, font?.color) | |
| } | |
| if (backgroundColor !== undefined) { | |
| style.backgroundColor = backgroundColor | |
| style.textColor = pickReadableCellTextColor(backgroundColor, font?.color) | |
| } else if (font?.color !== undefined) { | |
| style.textColor = font.color | |
| } |
📍 Affects 2 files
src/renderer/src/components/editor/xlsx-cell-styles.ts#L54-L57(this comment)src/renderer/src/components/editor/xlsx-cell-styles.test.ts#L50-L57
Opening a styled workbook showed three gaps against how the same file reads in Excel or Google Sheets, all visible in a downloaded budget template. Merged cells. `<mergeCells>` was ignored, so a merged banner rendered as separate cells with its text clipped into the first one. Rows are virtualized independently, so a real row span is not available; instead a merge renders as a band — every row it covers gets one cell spanning the merge's columns, and only the anchor carries the value. The span is clamped to the columns actually rendered, because with columns virtualized a merge can start or end outside the window and a longer span would eat the spacer track and knock the row out of alignment with the header. The index is bucketed per covered row rather than per covered cell, since a merge may legally span a whole column. Column widths. `<cols customWidth>` was ignored and every column was sized from its content, so the layout did not match the file. Declared widths now win over content sizing, converted from Excel's character units. A `<col>` without `customWidth` is skipped: Excel writes those for a style span or an outline level, and adopting the default width it repeats there would override content sizing with a value the author never chose. Zoom. The sheet had none. It now follows `editorFontZoomLevel`, the same store value Monaco and the notebook viewer use, so the existing editor zoom shortcuts work on a sheet and the level is shared across surfaces. Font size, row height, the row-number gutter and the column widths all scale together. Still not read from the file, and visible in that same template: number formats beyond dates (a currency cell shows 1000 where Excel shows 1.000 €), row heights, wrapped text, and charts or images.
A currency cell showed 1000 where the same file reads 1.000 € in Excel and Google Sheets. Only date formats were interpreted; every other numeric format fell through to the stored value, which is the most visible way a viewer can misrepresent a sheet. Format codes are now parsed into the parts a value needs — literals, digit placeholders, grouping, percent and thousands scaling, section colours — and rendered through Intl.NumberFormat. Intl rather than assembling digits by hand because the separators a code implies belong to the viewer, not the file: the same `#,##0.00` reads 1,000.00 in English and 1.000,00 in Spanish. The active app locale is passed into the parse, so a sheet reformats if the language changes. Covered: the four section forms (positive, negative, zero and their colours), `[$€-2]` currency symbols, escaped and quoted literals, accounting codes with their fill and reserved-width placeholders, percent, trailing-comma scaling, and the numeric built-in ids Excel never writes into `<numFmts>`. Verified against a downloaded budget template: every currency cell now renders as its source application does.
Three more things the file said that the viewer ignored. An author-set horizontal alignment now wins over inferring one from the value, which is only the fallback for a cell whose format leaves it general. `justify` and `distributed` are skipped: neither has a counterpart in a read-only cell, and pretending otherwise would move text the author did not move. `wrapText` cells now wrap instead of clipping to an ellipsis, which is what made a merged instruction paragraph unreadable. Row heights come from `<row ht customHeight>`, converted from points at 96 over 72, and feed the row virtualizer per index so a sheet keeps its own rhythm. As with column widths, a row that only records the default height is ignored — Excel writes `ht` on every row of some files.
Opening a downloaded budget template made clear what was still missing, and it was not what it looked like. The bar chart and the large percentage block in that file are not drawings at all — its two drawing parts are empty `<xdr:wsDr/>` shells. The visuals come from cell-level features the viewer ignored: the file declares 46 fonts and 27 borders. Borders. `<borders>` is read per edge, with weights and dash styles mapped to their CSS equivalents and the edge colour resolved through the same theme palette as fills. A declared edge replaces the default gridline on that side only, so a cell with one underline keeps the grid intact everywhere else. An empty `<border/>` and `style="none"` both draw nothing, which matters because Excel writes the empty element on every cell that sets any other edge. Font size and italic. Sizes are stored relative to the workbook's own default font, so a sheet keeps its typographic hierarchy while still following the app's base size and the zoom level. The scale is clamped so one absurd size cannot push a row out of the viewport, and a difference too small to see is dropped. Italic reads its toggle the same way bold does, honouring an explicit `val="0"` override. Images. Worksheet drawings are followed through their relationships to the media part and inlined as data URLs, positioned over the cell range their anchor names. Both `twoCellAnchor` and `oneCellAnchor` are read; an absolute anchor is skipped since it has no cell to hang on. They render in a separate pointer-events-none layer rather than as cells, so a drawing spanning a range cannot disturb the grid tracks the header and rows share. Count and byte size are both bounded, since each image is inlined into renderer memory. Charts are still not rendered. A DrawingML chart renderer is a separate surface of its own — chart types, series, axes, legends and styling — and no part of it is shared with what this commit adds.
The contrast rule applied the WCAG body-text ratio of 4.5:1 to every filled cell, which discarded exactly the colours an author picks deliberately. A brand orange heading on white is 3.1:1: below the body threshold, above the large-text one, and legible — it is what both Excel and Google Sheets show. The viewer was repainting it black. The threshold now follows WCAG's own distinction: 3:1 once the text is large, meaning 18pt or 14pt bold, measured from the font's declared point size rather than the rendered pixel size so it does not shift with the zoom level. Body text keeps the 4.5:1 rule, and a colour that fails even the large-text ratio is still replaced. Found by reading a downloaded budget template rather than a fixture: its 18pt bold title came through black instead of its own orange.
Charts anchored in a worksheet are now drawn instead of skipped, so opening a workbook shows what the file shows. Column, horizontal bar, line, area, scatter, pie and doughnut are plotted; a type outside that set renders a frame naming it rather than an empty box the reader cannot explain. Charts reach the sheet through a graphic frame rather than a picture, so the anchor reader now follows `<c:chart r:id>` as well as a picture's blip, and the sheet exposes one list of drawings — charts and images together — since both occupy a cell range and share the same overlay layer. Series, categories and titles come from the cached values the file stores, read by point index rather than document order: a sparse cache lists only the points that have values, so reading them in sequence shifts every later value onto the wrong category. A series colour is taken from its own `spPr`, or from the workbook theme's accents in order — which is what Excel does when a series declares none, so reproducing it is faithful rather than a palette choice. One theme parse now serves cell fills, font colours and chart series alike. On the rendering, the split of authority is deliberate. The form and the series colours belong to the file; recolouring an author's series to satisfy a palette would misreport the document, the same way reformatting their numbers would. So the project's visualization guidance is applied to everything the file does *not* specify: hairline recessive gridlines, axis ticks on round numbers, a value axis anchored at zero for the forms that grow from a baseline, 2px lines with round caps, markers of at least 8px carrying a surface ring, a 2px surface gap between touching marks, an area drawn as a wash rather than a saturated block, a legend whenever there are two or more series and never for one, no value printed on every point, and text in text tokens rather than the series colour. Every mark carries a `<title>` and the chart an `aria-label`, so identity never rests on colour alone. A chart declaring a second value axis is plotted on one scale and says so on the parsed model rather than drawing two, and a frame too small to label legibly says that instead of drawing an illegible thumbnail. Validated against fixtures modelled on Excel's own chart XML — no workbook with a real chart was available on this machine, which is worth knowing when reading the test coverage.
… text
A budget template downloaded from Google Sheets shows two balance columns and
four progress bars. None of them is a chart object: the file's drawing parts are
empty shells and it declares no chart at all. The bars are `SPARKLINE()` calls,
Sheets' in-cell micro-charts, and the export wraps each one in
`__xludf.DUMMYFUNCTION("…")` — a marker that preserves the formula text — with an
empty cached result. So the cell carries the author's intent and no value, and
opening that file in Excel shows blank cells too.
This draws them. Deliberately more than Excel parity, and worth calling out as a
product decision rather than a fix: the chart is fully described in the file, and
a reader opening the workbook wants to see what its author drew. It sits in its
own commit so it can be dropped without touching the rest.
The formula is parsed for its chart type, its data reference and its options,
undoing the quote doubling the export introduced. A bound may be a literal or a
`MAX`/`MIN` over a range, which is how sibling sparklines share a scale — the two
balance columns are pinned to `MAX(D17:E17)`, so without evaluating it each would
fill its own cell and the comparison the author built would be lost. Anything else
falls back to the data's own bounds rather than guessing. Colours come from the
formula, including `firstcolor` for a single-value column.
The numbers behind the plot do not survive as rendered text, so the sheet parser
also collects raw numeric values and sparkline formulas by cell — gated on a cheap
text probe, so a sheet without sparklines pays nothing.
Column, bar, line and win/loss are drawn, each filling its cell, with the mark
titled and the plot labelled so it is not colour-alone.
Verified against the real template: six sparklines resolve to exactly what Sheets
renders — a navy column at 1000 and an orange one at 1500 sharing a 1500 ceiling,
and grey/navy pairs for planned against actual.
…pty columns Two defects visible on the first render of a real workbook, side by side with how the same file reads in its source application. A sparkline in a merged cell was drawn four times. The merge band is painted one row at a time — rows are virtualized independently, so a real row span is not available — and the sparkline was drawn inside each of those rows, turning one column into a stack of four blocks. Sparklines now live in the same overlay layer as charts and images, positioned over the whole merge, which is the block its author sized for it. The overlay's geometry and its layer moved into their own module and component, since the grid was over the line limit. Labels were clipped where a spreadsheet overflows them. A sheet does not truncate a long label to its column: the text runs across the neighbours while they are empty and stops at the first one holding something. "Presupuesto mensual" came out as "Presupuesto mens…" with four empty columns beside it. A left-aligned label now reaches across following empty, unfilled columns, bounded so one label in a sparse sheet cannot widen the grid. A neighbour that carries a fill stops it, because the text would otherwise run over a coloured block. The grid's own tests were passing against an empty document: happy-dom reports every element as zero-sized, so the virtualizers concluded nothing was on screen and rendered no cells, which made "fewer cells than columns" true of nothing. The viewport is now stubbed, so those assertions run against a grid that actually rendered.
Opening a real Excel-authored workbook beside the same file in Excel online showed four differences. Three were the viewer's, and one was a regression from the previous commit. Dates ignored the author's format code. Every date rendered ISO, so a cell formatted `dd/mm/yyyy` read 2025-01-01, and a chart axis cached as serials with `d\-m` beside them read 46168 where the file says 26-5. Format codes are now rendered token by token — padded and unpadded day, month and year, month and weekday names through the viewer's locale, a twelve-hour clock when the code asks for a meridiem, quoted and escaped literals kept verbatim. The one genuine ambiguity is handled: `m` is a month on its own and a minute beside an hour or a second. The ISO form stays as the fallback for a code with no date token, and for the phantom 1900 leap day, which has no real date behind it. Area and line charts no longer force zero onto the value axis. Excel starts a column or bar at zero but auto-scales these, and a weight series between 172 and 178 drawn from zero is a flat line — which is how that chart came out. Area series gradients are read and painted. Excel writes `a:gradFill` for an area far more often than a flat colour, and it is the plot's dominant visual: dropping it left a chart that reads as unfilled. Stops resolve through the theme like every other colour, and a gradient with fewer than two stops is ignored rather than treated as one. The regression: letting a label overflow into empty columns also let it bleed vertically, so a 24pt title ran over the band beneath it. The span now clips its own height while still being allowed to be wider than its cell. Verified against the file that surfaced all of this: the axis reads 26-5, 2-6, 9-6 as Excel shows it, and the series carries its teal-to-amber gradient. Still not drawn from that chart: its second plot. The file overlays a scatter on the area to draw a target line, and only the first plot is rendered.
… indented A stylesheet sets `vertical` on almost every cell — 131 times in one real budget — and the viewer ignored all of them, centering every value instead. Read `vertical` and `indent` from `<alignment>` and default to bottom, which is where Excel and Sheets put a value when the author sets nothing. Numbers were also being left-aligned once a number format grouped their thousands or added a currency symbol, so a whole budget column read as text. The rendered string is all the grid gets for a CSV, so widen the inference to the forms a number format actually writes. Extracts the cell into SpreadsheetCell.tsx: the grid was at its line limit and the vertical-alignment and indent branches had nowhere left to go.
A sheet's highlights live in `<conditionalFormatting>` rather than in a cell's own style, so a budget that marks every filled category and reddens every negative total rendered flat. Read the rules, resolve the `<dxf>` each one paints, and apply them in priority order with `stopIfTrue`. Scoped to the rules a viewer can decide on its own: cellIs, the blank tests and the text tests. `expression` rules need a formula evaluator, and colorScale, dataBar and iconSet render a scale rather than a differential format — all are dropped rather than guessed at, so a cell keeps its own style. A `<dxf>` inverts the pattern colours of a normal `<fill>`: the visible fill is in bgColor and fgColor is left as the system default, so this needs a reader of its own rather than the one `<fills>` uses. Also stops `numericValues` collecting from non-numeric cells. It held the `<v>` of any cell, which for a shared string is its index into the string table — now that conditional rules compare against those numbers, a `< 5` rule would have highlighted text cells by their position in the table.
A viewer that sizes columns from content alone has no answer for a column whose values are wider than any sample, or for one the reader simply wants narrower. Adds a grip on each heading's right edge: drag to size, double-click to size from content again. The grip is focusable and takes arrow keys, so column widths are reachable without a pointer, and it uses pointer capture rather than window listeners because the pointer leaves a 6px target constantly mid-drag. A reader's width is stored unzoomed and wins over both the file's declared width and sizing from content, so a column keeps its proportion across zoom levels.
… range Two ways text was being cut that a spreadsheet does not cut. A merge is drawn as a band, because rows are virtualized independently and a real row span is not available — but the cell owning the value was still bound to the height of the merge's first row. A 24pt title merged down two rows had 24px to render 33px of text, so its bottom half disappeared. The anchor now takes the height of every row the merge covers. And a neighbour's fill was treated as occupying it, which clipped any heading that sat beside a banded but empty range. A spreadsheet paints an overflowing label straight over the colour; only content stops it. A cell belonging to a merge does count as occupied, though — this row reads it as empty because the value lives in the anchor — so the test is now for a merge, not for a fill.
Excel builds a target line by laying a scatterChart over an areaChart in one plot area. The parser read only the first plot, so a weight chart lost the orange target line its author added — the series was there in the file with a name and a colour and never reached the screen. Every plot is now read, in document order, and each series carries the kind of the plot it came from so the renderer can draw an area fill and a line in the same frame. A chart's own kind stays the first plot's, which is what sets the axis orientation. Three things a scatter series needs that the others do not: its values live in `c:yVal` rather than `c:val`, its x positions in `c:xVal` — without which two points meant to span the plot were squeezed into the first category step — and `c:scatterStyle` decides whether it is a line or bare points, with `c:symbol` deciding whether the points show at all. A target line is exactly the case where all three matter: two points, a line, no markers. Splits the chart module in three on the way past its line limit: the low-level part readers, the series reader, and the chart assembler.
…sized ones Row heights had no answer for content taller than the row: the grip only existed on column headings, so a reader who could see text being clipped could do nothing about it. The row heading now carries the same grip on its bottom edge, dragged with the pointer or sized with Up and Down from the keyboard. Generalizes the resize hook and the grip over both axes rather than copying them, with rows bounded tighter than columns — 12 to 400 pixels — because a sheet has far more of them and a runaway row hides every one after it. Also auto-fits a row the file leaves without a customHeight, from the largest font in it. Excel measures those rows itself, which is why the author never set a height, and using one default for all of them clipped exactly the rows a heading made taller. A row the file does size explicitly keeps that size, clipping included: that height is the author's decision, and Excel honours it too.
…ts text The merged title was still being clipped after being given the height of every row its merge covers. The cause is CSS, not arithmetic: each virtualized row carries a `transform` to position itself, and a transform makes the element its own stacking context. That trapped the cell's `z-index` inside its own row, so the next row — a later sibling — painted straight over the half of the text that reached into it. The lift has to happen on the row, where the elements really are siblings, so a row owning a merge that reaches downwards is raised as a whole. Also stops the row virtualizer re-measuring the sheet on every render: the effect depended on the identity of a callback that changes with cellStyles, so it fired continuously rather than when a height actually changed.
…de a row My last two attempts at the clipped merged title were both wrong, and the second made things worse: giving the anchor cell the merged height made it overflow into the rows below, and because each virtualized row carries a `transform` and is therefore its own stacking context, whichever row won the paint order hid the other's row number. Lifting the anchor row only swapped which numbers vanished — the anchors' before, the covered rows' after. The overlay layer already existed for exactly this, and says so: anything taller than a row has to be positioned over the grid rather than inside it. A merge that spans rows now hands its value to that layer, which knows the true rectangle, so no row overflows and no row needs lifting. The cells underneath keep painting the band's fill and borders. The anchor cell carries the value as its aria-label, since the text itself now lives outside the table, and the overlay copy is aria-hidden so it is announced once. Removes the two abandoned mechanisms rather than leaving them to be found later.
…erge Overflow only ever ran rightwards, so every right-aligned heading was clipped even with an empty column beside it — "SALDO INICIAL" became "SAL…" while the two columns to its left sat empty. Excel spills a label the way its alignment points: right-aligned text leftwards, centred text both ways. A merge did not spill at all, so a title merged across four columns was clipped at the merge's own edge rather than running into the empty columns after it. That is the same rule, and it is why a wide title fits a merge narrower than itself; the merge's own columns are excluded from the test that stops the spill, or it would stop itself immediately. Verified against a real budget: the title's box grows from 344px to 663px, and "SALDO INICIAL" gains 307px of reach where it previously had none.
Summary
Opening an
.xlsxfile in Orca showed "Binary file — cannot display". The read path had no mime type for workbooks, so they fell through to the generic binary placeholder — even though the editor already renders CSV/TSV as a table.Workbooks now open read-only in the editor: one tab per worksheet, on a sheet surface that follows Excel and Google Sheets, with the layout and formatting the file itself declares.
.xlsmis included because it is the same OOXML container (macros are never read or evaluated).No new dependency. The parser is hand-rolled, the same trade-off
csv-parse.tsalready documents for CSV instead of papaparse. A workbook is a zip of XML parts, and a viewer only needs a few of them — a small, fully specified slice. Inflating goes through the platform'sDecompressionStream('deflate-raw'), so there is no new package and no native code.What it reads from the file
<rPh>runs dropped); booleans; cached formula results and error codes; numbers as stored[$€-2]symbols, accounting codes, and the numeric built-in ids Excel never writes outcellIscomparisons, the blank tests and the text tests, resolved against<dxfs>and applied in priority order withstopIfTrueColours resolve from
rgb,theme(againsttheme1.xml, whose first two index pairs are swapped relative toclrSchemeorder) and the legacyindexedpalette, withtintapplied in luminance space.Both axes are virtualized, so a sheet reporting thousands of columns or hundreds of thousands of rows stays responsive. Zoom follows
editorFontZoomLevel, the same store value Monaco and the notebook viewer use, so the existing editor shortcuts work on a sheet.Columns and rows are both resizable by dragging the grip on a heading's trailing edge, with double-click to size from content again; the grip is focusable and takes arrow keys, so the sizes are reachable without a pointer. A dragged size is stored unzoomed, so it keeps its proportion when the reader then zooms. Rows are bounded tighter than columns — 12 to 400 pixels — because a sheet has far more of them and a runaway row hides every one after it.
A row the file leaves without a
customHeightis auto-fitted from the largest font in it, since those are the rows Excel measures itself and one shared default clipped exactly the ones a heading made taller. A row the file does size keeps that size, clipping included — that height is the author's decision, and Excel honours it the same way, which is why the grip exists.Text is clipped where a spreadsheet clips it, and not before. An overflowing label runs over a neighbour that is merely filled, because that is what Excel does; only content stops it, plus a cell belonging to a merge, which this row reads as empty because the value lives in the anchor. It spills the way its alignment points — right-aligned text leftwards, centred text both ways — and a merge spills past its own edge into the empty columns after it, which is how a wide title fits a merge narrower than itself.
A merge that spans rows is a harder case, and the two obvious fixes are both wrong. Letting the anchor cell take the merge's full height makes it overflow into the rows below; because each virtualized row carries a
transformand is therefore its own stacking context, whichever row wins the paint order hides the other's row number, and lifting the anchor row only swaps which numbers vanish. So the value goes to the overlay layer instead, which is where anything taller than one row already belonged — the cells underneath keep painting the band's fill and borders, no row overflows, and nothing needs lifting. The anchor cell carries the value as itsaria-label, since the text itself now sits outside the table, and the overlay copy isaria-hiddenso it is announced once.A chart's overlaid plots are all drawn. Excel builds a target line by laying a
scatterChartover anareaChartin one plot area, so the mark type belongs to the series rather than to the chart. Three things a scatter series needs that no other does: its values live inc:yVal, its x positions inc:xVal— two points meant to span the plot are otherwise squeezed into the first category step — andc:scatterStyleplusc:symboldecide whether it is a line, bare points, or both. A target line is the case where all three matter at once.What it deliberately does not read.
expressionconditional rules need a formula evaluator, andcolorScale,dataBarandiconSetrender a scale rather than a differential format — all four are dropped so the cell keeps its own style rather than being painted on a guess. Also absent: data validation dropdowns, text rotation, and mixed-format runs within one cell.Judgement calls worth a reviewer's attention
The sheet canvas is light in both themes. A workbook's fills are authored against a white sheet, so on a dark canvas they read as arbitrary blocks of colour rather than as the highlighting the author meant — which is why Excel and Sheets both keep a white sheet inside dark application chrome. The values live in one token family in
main.css, so a dark-sheet variant is a six-value change if you would rather have one.Cell ink is derived, not copied. The workbook's font colour wins when it clears WCAG AA against its own fill, using the large-text ratio of 3:1 once the text is 18pt or 14pt bold — otherwise a deliberate accent colour gets repainted, which is a real bug I hit on a real file. When the declared colour fails, the ink falls back to whichever of black or white contrasts more. On an unfilled cell the font colour is left alone entirely, because the background is whatever the theme uses and contrast cannot be checked; that loses colour on unfilled text, and I took an unreadable cell to be worse than a monochrome one.
Cells sit at the bottom of their row by default. That is what Excel and Sheets do when the author sets no vertical alignment, and one real stylesheet set
verticalon 131 of its cells, so honouring it changes far more of a sheet's look than the count of lines suggests.CsvViewerkeeps centring, since a CSV has no such default to be faithful to.Numeric alignment is inferred from the rendered text. The grid only ever receives strings, so a right-aligned column — the main cue that a column is numeric — has to be recognised from the formatted value. That means the inference has to accept grouped thousands and currency symbols (
1.234,50 €), which it previously did not, left-aligning whole currency columns as though they were text. It also means text that merely looks numeric can be right-aligned; the alternative is threading the cell type through, which is the better fix and a larger one.spreadsheet-cell-contrast.tsholds two hex constants (#000000/#ffffff). STYLEGUIDE says not to hardcode a hex where a variable covers it — no token covers "maximal-contrast ink over an arbitrary colour from a data file", which is why they are there rather than inmain.css.Structure
xlsx-zip-archive.tsxlsx-xml-elements.tsxlsx-part-paths.tsxlsx-color.ts,xlsx-theme-palette.tsxlsx-cell-formats.ts<cellXfs>table, shared by every style readerxlsx-number-format.ts,xlsx-serial-date.ts,xlsx-shared-strings.tsxlsx-cell-styles.ts,xlsx-cell-borders.tsxlsx-chart-xml.ts,xlsx-chart-series.ts,xlsx-chart.tsxlsx-differential-formats.ts,xlsx-conditional-formatting.ts,xlsx-conditional-styles.ts<dxf>overrides, rule parsing and evaluation, and painting them over a cell's own stylexlsx-worksheet-grid.ts,xlsx-worksheet-layout.ts,xlsx-drawings.tsxlsx-workbook.tsSpreadsheetGrid.tsx,SpreadsheetCell.tsxCsvViewerSpreadsheetGridOverlay.tsx,spreadsheet-grid-overlay.tsuse-spreadsheet-resize.ts,SpreadsheetResizeHandle.tsxspreadsheet-row-heights.tsXlsxViewer.tsxThe first commit is a pure extraction of the shared grid out of
CsvViewerwith no behaviour change; reviewing the commits in order should be easier than the whole diff.Screenshots
New UI, so this needs a capture — attaching one of a multi-sheet workbook to the conversation rather than committing it, per the contributor guide.
Testing
pnpm lintpnpm typecheckpnpm test— 49871 passed (one unrelated load-sensitive flake inright-sidebar, green in isolation)pnpm build22 of the 44 new modules are their own test file. The tests build real zip bytes with
node:zlibthrough a fixture builder rather than checking in binary blobs, so each case varies one detail of the package and still goes through the same container Excel writes.Beyond the happy path: stored vs deflated entries, incompressible payloads, UTF-8 part names, an archive comment containing the end-of-central-directory signature, zip64 rejection, a corrupt or truncated central directory, a zeroed local header, deflate-bomb bounds, tag-prefix collisions (
<c>vs<cols>), a legal>inside an attribute value, out-of-grid cell references, the 1900/1904/leap-day edges,cellXfsvscellStyleXfs, anumFmtunder<dxfs>, missing rels parts, a relationship id that does not match the sheet ordinal, merge placement against a scrolled column window, and the row and column caps.Six bugs were found by these tests or by reading real files rather than fixtures, and each is fixed with a regression test:
_relsfolder instead of the owning part, so any producer not using the exact layout we guessed would have opened as an empty workbook.[h]:mm:ss) were classified as dates, turning 36 hours into a calendar day.>inside an attribute value truncated attribute parsing (legal XML — only<and&must be escaped).AI Review Report
Reviewed with Claude Code across several rounds, including a CodeRabbit pass whose findings are answered in the conversation.
pathuse in the new code: OPC part names are always/-separated inside the package regardless of host, andxlsx-part-paths.tsdoes its own segment handling rather than usingnode:path, which would corrupt them on Windows.fs:readFilewould work locally and show a placeholder over SSH. The relay read path uses the same previewable-binary map on both its single-shot and streaming metadata paths.IMAGE_MIME_TYPESis deliberately left alone because terminal artifact previews use it for image-shaped payloads, and the mobile file-explorer path is untouched since mobile cannot render a workbook.nodeVitest environment, which has noDOMParser). Both axes virtualize; off-screen columns collapse into one spacer track per side, which also removed a per-columngrid-template-columnsstring from every row's inline style. Column sizing samples the first 200 rows; number formats and merge lookups are cached per style and bucketed per row. A 13-sheet, 8073-row real workbook parses in about 70ms.main.cssfor:root,.darkand the@theme inlineblock, and every utility added was checked against the compiled CSS — twice during this work a Tailwind class compiled to nothing (bg-editor-surfaceis not a utility this project has, andtext-[…]arbitrary values cannot be disambiguated between a size and a colour), so the compiled output is now the check rather than the class name looking plausible. Tabs userole="tablist"/role="tab"witharia-selected; cells carryaria-colindexbecause virtualization means the DOM no longer reflects real column positions; the virtualized body is arole="rowgroup". Nomax-linesdisable was added.Security Audit
..collapsed and unable to escape the package root (asserted by test), and only ever index the in-memory zip entry map — no filesystem access.TargetMode="External"relationships are skipped, since their target is a URI. Authorization is unchanged: reads still go throughresolveAuthorizedPath..xlsmare never read; formulas are not executed, only their cached result is shown. NoinnerHTML— cell text goes through React as text, and images are inlined asdata:URLs from bytes already in the archive. External references, DDE links and OLE objects are ignored.Notes
SPARKLINE()calls, which Sheets' export wraps in__xludf.DUMMYFUNCTION("…")with an empty cached result — so the cell holds the author's intent and no value, and Excel shows blank cells there too. This renders them, which is a product decision rather than a fix: the chart is fully described in the file and a reader wants to see what its author drew. It is isolated in its own commit so it can be dropped without touching anything else. Evaluating theMAX/MINbound that sibling sparklines share is the part that matters — without it each bar fills its own cell and the comparison the author built is lost..xlsand.xlsb. They are a different, non-OOXML binary format this parser cannot read, and adding them to the mime map would swap a truthful "cannot display" for a parse error.