From 59b92f29917fe9de4c2def0b8f5c0b4e9faf2580 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 13 Oct 2026 12:37:36 +0200 Subject: [PATCH] fix(spreadsheet-preview): open openpyxl workbooks that have a comment, table or chart The XLSX preview hands an admitted copy of the workbook to ExcelJS 4.4.0, which links each worksheet to its comments, comment notes (VML), tables and drawing by looking the relationship Target up verbatim in maps keyed by its own spelling, then dereferences the result unchecked. openpyxl, the library most agents write .xlsx files with, uses absolute Targets (/xl/tables/table1.xml), its own comment part name (xl/comments/comment1.xml, which ExcelJS never loads) and a drawing XML ExcelJS parses to nothing. One comment, table or chart was enough for "Spreadsheet preview failed". repairExcelJsModel() (spreadsheet-xlsx-core.js) runs on ExcelJS's parsed model right before its reconcile: it re-points a link to the key ExcelJS stored for the same part (so an openpyxl table is kept) and drops a link, tablePart, drawing or hyperlink reference that would dereference nothing. It adds nothing and resolves keys as own properties only. The worker wraps workbook.xlsx.reconcile per instance to call it, and throws if a future ExcelJS renames that method. SPREADSHEET_ASSET_VERSION is refreshed. Tests: unit tests on model objects, and three real openpyxl 3.1.5 workbooks in test/fixtures/xlsx that fail the worker tests without the hook. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/xlsx-openpyxl-preview.md | 5 + docs/architecture-invariants.md | 2 + src/web/public/spreadsheet-preview-worker.js | 11 ++ src/web/public/spreadsheet-preview.js | 2 +- src/web/public/spreadsheet-xlsx-core.js | 97 ++++++++++++++ test/fixtures/xlsx/openpyxl-chart.xlsx | Bin 0 -> 6495 bytes test/fixtures/xlsx/openpyxl-comment.xlsx | Bin 0 -> 6245 bytes test/fixtures/xlsx/openpyxl-table.xlsx | Bin 0 -> 5579 bytes test/spreadsheet-preview-worker.test.ts | 38 ++++++ test/spreadsheet-xlsx-core.test.ts | 125 +++++++++++++++++++ 10 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 .changeset/xlsx-openpyxl-preview.md create mode 100644 test/fixtures/xlsx/openpyxl-chart.xlsx create mode 100644 test/fixtures/xlsx/openpyxl-comment.xlsx create mode 100644 test/fixtures/xlsx/openpyxl-table.xlsx diff --git a/.changeset/xlsx-openpyxl-preview.md b/.changeset/xlsx-openpyxl-preview.md new file mode 100644 index 00000000..150b76d4 --- /dev/null +++ b/.changeset/xlsx-openpyxl-preview.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Spreadsheet preview: workbooks written by openpyxl (the Python library most agents use to make .xlsx files) now open when they contain a cell comment, a table or a chart. Before, any one of those made the whole preview fail with "Spreadsheet preview failed". diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ce51923b..39db22ba 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -381,6 +381,8 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **XLSX previews parse in the browser worker, and ExcelJS only ever sees what admission checked.** An `.xlsx` joins the raw routes like any other file (`?preview=true` caps it at 10 MB with a 413); the server never parses it. `spreadsheet-preview.js` hands the bytes to `spreadsheet-preview-worker.js`, the ONLY place the pinned `exceljs`/`fflate` vendor bundles load: fflate and `spreadsheet-xlsx-core.js` at worker start, ExcelJS only after `admitXlsx()` passes, and the page itself never loads either. `admitXlsx()` streams every entry through fflate and enforces the entry count, per-entry and total inflated bytes (64 MB), compression ratio, worksheet, cell, merge and style caps on the actual inflated output, refusing (never truncating) a workbook that trips one. ⚠️ Admission walks LOCAL headers while ExcelJS (JSZip) reads the CENTRAL directory, so overlapping entries (a stored entry hiding a whole `sheet1.xml`, a one-cell decoy later in the stream) once let a file be admitted as 1 cell and parsed as 300k. The worker therefore never hands ExcelJS the fetched bytes: it gets `buildAdmittedArchive()`, a STORE-only `fflate.zipSync(entries, { level: 0 })` of exactly the entries admission inflated, and a name streamed twice is refused. That costs one transient copy of the inflated entries (bounded by the same 64 MB cap) and is pinned by the overlapping-entry fixture in `test/spreadsheet-preview-worker.test.ts`. ⚠️ Admission also has to bound what ExcelJS EXPANDS, not just what it reads: ExcelJS 4.4.0 builds one object per covered cell of a ``, per address of a `` and per index up to ``, so a 6.5 KB file admitted as one cell once cost a gigabyte. The counter therefore charges each merge its full AREA against the cell caps (an unparseable `ref` is refused), refuses a `` whose `min`/`max` is past 16384, and the worker loads with `ignoreNodes: ['dataValidations']` (the preview never shows validations). Defined names expand the same way and live in `xl/workbook.xml`, where admission reads only the `` ids: ExcelJS's `DefinedNames` model setter builds one object per cell of every range (a whole-sheet name exhausted a 4 GB heap), so right after `new ExcelJS.Workbook()` the worker replaces `_definedNames.model` with an `Object.defineProperty` stub whose getter returns `[]` and whose setter drops the value. Print areas and titles are split off in the workbook xform's reconcile, before that setter runs, so they are unaffected, and `defineProperty` throws if a future ExcelJS renames `_definedNames` rather than silently expanding again. ⚠️ Every name admission uses is the one ExcelJS will SEE, not the stored one: JSZip resolves each entry name on load (`utils.resolve`: `.` and empty middle segments dropped, `..` pops a segment) and ExcelJS then strips one leading `/` and matches worksheets with an UNANCHORED `xl/worksheets/sheet.xml`. Checking the stored name once let `/xl/worksheets/sheet1.xml`, `xl/./...`, `xl//...`, `xl/xl/worksheets/sheet1.xml` and `xl/worksheets/sheet1.xml.x` skip every counter. `excelJsEntryName()` mirrors both steps; admission refuses two entries that resolve to one name, keys `inflatedEntries` (so the rebuilt archive) on the resolved name, picks the worksheet and styles counters from it, and counts any name matching ExcelJS's unanchored worksheet pattern as a worksheet. The local-versus-central consistency checks still compare the stored names. The counter reads ``/`` attributes IN ORDER from the tag name with a sticky regex that consumes each quoted value whole (a raw `>` or the other quote character is legal inside a value, so a first-match search could be fed a fake `ref`/`max`), and refuses a tag whose attributes do not parse up to `>` or repeat a name. It counts every `` (ExcelJS keeps one Row object per element, cells or not) against per-sheet and total row caps, and reads each ``'s attributes the same way. ⚠️ The INDEX a row or sheet claims is a cost too, not just how many there are: ExcelJS stores a row at `_rows[r - 1]` and walks `_rows` up to the largest index on every `eachRow` and `sheet.model` (five rows plus one `` took 5 s to load and 1.7 s per tile), and stores a sheet at `_worksheets[sheetId]`, which its `worksheets` getter slices and sorts (`sheetId="30000000"` on a one-cell workbook took 1.6 s and 557 MB). So a `` that is not plain digits in 1 to 1,048,576 is refused (an absent `r` is fine), and a counter for the resolved `xl/workbook.xml` reads every `` tag in order and refuses one that does not parse or whose `sheetId` is not plain digits up to `LIMITS.maxSheetId` (65535; real ids are small, and the `(?=[\s/>])` lookahead keeps the `` container out). The counter also counts every `` in styles.xml with no list-tracking state (a `` inside a comment used to desync it). It carries everything from the last `<` into the next inflate chunk, since `<` can never appear inside an attribute value, and a central-directory `compressedSize` that runs past the file is refused, since the ratio cap divides by it. Fixtures for each are in `test/spreadsheet-preview-worker.test.ts` and `test/spreadsheet-xlsx-core.test.ts`. ⚠️ `sendTile` must never read `sheet.model`, which rebuilds every row and cell model (10-19 ms per tile on a 100k-cell sheet, once per animation frame while scrolling): each sheet's merges are read once in `worksheetMetadata` and kept in `mergesById` next to `populatedRowsById` (replaced on load, cleared on dispose), and a worker test makes the `model` getter throw before requesting a tile. ⚠️ The COLUMN index a cell claims costs the same way: ExcelJS keeps a row's cells at `_cells[col - 1]`, so a row whose only cell sits in `XFD` is a dictionary-mode array, and ExcelJS's `row.eachCell` (`_cells.forEach`) and `row.hasValues` (which `sheet.eachRow` calls) visit every index up to 16,384, about 0.5 ms per row (10,000 such rows took 13 s to load in Chromium, and each tile paid it again; `XFD` is a legal column, so admission cannot refuse it). `worksheetMetadata` therefore never calls ExcelJS's dense `eachRow`/`eachCell`: `populatedRowIndex()` walks `Object.keys(sheet._rows)` and, per row, `Object.keys(row._cells)`, sorted numerically, skipping falsy and `ValueType.Null` cells exactly as `eachCell({ includeEmpty: false })` does and keeping a row only when it holds one such cell (ExcelJS's `hasValues`). Styles, the extent and row heights come from that one pass, merges from `sheet._merges` (the map the `model` getter itself lists), and the per-row cell lists are what `populatedRowsById` keeps, so `sendTile` reads a row's cells from the index instead of `row.eachCell`. Worker tests make `eachRow`, `eachCell`, `hasValues` and the `model` getter throw across load and a tile, and load and tile 3,000 one-`XFD`-cell rows. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters of the decoded XML; real themes are under 10 KB): its `clrScheme` and slot patterns rescan to the end of the text for every unclosed opening tag, and a 1 MB theme of repeated `` took 21.8 s in Chromium. Number formats cap decimals at 30, as Excel does, since `toLocaleString` throws above 100. ⚠️ Merges are also capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) and across the workbook (`LIMITS.maxMerges`, 10,000), both refused as `merge-limit` before ExcelJS loads: on top of the area each merge costs, ExcelJS's `Worksheet._mergeCellsInternal` checks every new merge against every earlier one on its sheet (`_.each(this._merges)`, rebuilding `Object.keys` per call), so a sheet's cost is quadratic in its merge count (one sheet at the old 5,000 cap took 1.6 s in the worker harness, and 20 such sheets ran past the 20 s load timeout in Chromium; the new caps keep the worst case near 1.3 s). ⚠️ The styles counter reads every `` (cellXfs' and dxfs' alike; the lookahead skips ``) with `readTagAttributes()` and refuses, as `number-format`, a `formatCode` that is over 255 characters (Excel's own limit) or has a `[` after its last `]`, checked on the value as ExcelJS's XML parser hands it over (entities and character references decoded, so `[` cannot hide a `[`; ExcelJS's own backslash unescape only removes characters, so it cannot reopen a bracket). ExcelJS runs `utils.isDateFmt` once per numeric cell at load, and its `fmt.replace(/\[[^\]]*]/g, '')` rescans to the end of the code for every `[` with no later `]` (a 60,000-character code of `[` cost 2.2 s per numeric cell; even 255 unclosed characters add up at the cell cap), while a closed code is linear. The length cap also bounds the `Unsupported number format: ` notice, and the refusal messages never echo the code. ⚠️ Cell text reaching the page is bounded in the worker: `formatCellValue` caps every display string at `LIMITS.maxCellTextChars` (1,000; the last kept character is an ellipsis and a cut never splits a surrogate pair) for every value shape, strings, rich text (runs are joined only until the cap is passed), hyperlink text, formula source and results, and errors. Without it each tile cell carried its whole string and structured clone copied it once per cell, after the load timeout had already been cleared by the metadata reply: one 1 MB shared string over a 60 x 20 block left the page unresponsive for 150 s at 9.6 GB. A cell is one `nowrap` line with an ellipsis, so nothing past the column width was ever shown. A worker test asserts every tile cell's `text` is within the cap for a long shared string, rich text and a hyperlink. ⚠️ Bounding the text kept is not enough for rich text: `richTextPrefix` also visits at most `LIMITS.maxCellTextChars + 1` runs, since any that many non-empty runs already pass the cap. An empty run (``, 4 bytes) adds no text, so a loop that stopped only on length walked every run, and ExcelJS hands every cell referencing a shared string the same `richText` array, so the walk repeated per cell on every tile: one shared string of 200,000 empty runs (a 96 KB file) referenced by a 50 x 50 block took 11.2 s per tile, a million runs 56 s, with tiles requested once per animation frame while scrolling. With the bound the same tiles take about 24 ms. A core test gives a 5,000-run array whose entries past the bound throw on read and asserts `formatCellValue` returns. ⚠️ Per-tag counters can never be complete, so admission also budgets the elements themselves: `createXmlCounter` counts every start tag (`<` followed by a letter or `_`, on the same chunk-carried text the other counters read) in EVERY entry except the ones ExcelJS keeps as raw bytes, exactly the resolved names matching `^xl/media/.$` (ExcelJS's own patterns are unanchored, so `xl/media/xl/drawings/a.xml` is still parsed as a drawing and is counted), into `counts.elements`, and refuses above `LIMITS.maxElements` (2,000,000) as `element-limit`. ExcelJS builds one object per element in every part it parses: each `` run and `` in `xl/sharedStrings.xml`, each ``, `` and `` in styles, comments, drawings, VML and tables, which only the inflated-byte caps bounded, and the 100:1 ratio cap is no help because empty stored deflate blocks (5 bytes, inflating to nothing) pad a stream to any ratio. One shared string of 8.3M empty `` runs (a 375 KB file) took 4.7 s and about 570 MB of heap; 4.6M `` in styles about 9 s and 1 GB. A three-sheet 249k-cell workbook with rich text has 589k to 839k elements, well inside the budget. A tag inside an XML comment is counted too, which errs toward refusing. Parts that read no attributes (everything but worksheets, `xl/styles.xml` and `xl/workbook.xml`) carry only a trailing `<` between chunks, so a long text node or a binary part is never mistaken for an oversized tag. A worker fixture of 2,000,001 empty runs, padded past the ratio cap and proven to pass every other cap, is refused before ExcelJS is imported. Notices are bounded too: the worker folds two or more `Unsupported number format` warnings in a tile into one `N unsupported number formats` entry (`foldWarnings`), and `.spreadsheet-preview-notice` has a `max-height` and scrolls, so a sheet with a distinct code per cell cannot push the grid out of view. The worker is a stable URL cache-busted by `SPREADSHEET_ASSET_VERSION` in `spreadsheet-preview.js`, the content hash of the worker, the core and both vendor bundles; `npm run check:public-assets` fails when it drifts, so editing any of those (or bumping either package) means updating the token, or a deploy pairs a new worker with a year-cached old core. +⚠️ **ExcelJS follows a worksheet's comment, comment-note (VML), table and drawing links UNCHECKED, so the worker repairs its parsed model before it links it.** ExcelJS 4.4.0's `reconcile` looks each relationship `Target` up verbatim in maps keyed by its own spelling (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`, `drawing1`) and dereferences the result. openpyxl, which most agents write spreadsheets with, uses absolute Targets, its own part names (`/xl/comments/comment1.xml`, which ExcelJS never even loads) and a drawing XML ExcelJS parses to nothing, so a workbook with ONE comment, table or chart threw (`reading 'comments'`, `'name'`, `'anchors'`) and the whole preview failed. Right after `new ExcelJS.Workbook()` the worker wraps `workbook.xlsx.reconcile` (which `load()` calls once on the parsed model) to run `repairExcelJsModel()` (core) first: it re-points a link to the key ExcelJS stored for the same part (openpyxl's `/xl/tables/table1.xml` becomes `../tables/table1.xml`, so the table is kept), and drops a link, ``, `` or `` reference, or parsed-to-nothing drawing/table that would dereference nothing. It never adds anything, looks keys up as own properties only (a `__proto__` Target resolves to nothing), and the preview shows none of these parts. The wrap throws if a future ExcelJS renames `reconcile`. Real openpyxl 3.1.5 fixtures (`test/fixtures/xlsx/openpyxl-{comment,table,chart}.xlsx`) fail the worker tests without it. + ### Filesystem path picker **Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. diff --git a/src/web/public/spreadsheet-preview-worker.js b/src/web/public/spreadsheet-preview-worker.js index 13da63b1..b800a933 100644 --- a/src/web/public/spreadsheet-preview-worker.js +++ b/src/web/public/spreadsheet-preview-worker.js @@ -171,6 +171,17 @@ async function loadWorkbook(bytes) { // defineProperty throws if a future ExcelJS renames `_definedNames`, rather // than silently expanding again. Object.defineProperty(nextWorkbook._definedNames, 'model', { configurable: true, get: () => [], set: () => {} }); + // ExcelJS follows a worksheet's comment, VML, table and drawing links + // unchecked, and openpyxl spells them so the lookup finds nothing: one comment, + // table or chart failed the whole preview. `load()` calls `this.reconcile()` + // once on the parsed model; repair the model first (core.repairExcelJsModel). + // Throws if a future ExcelJS renames reconcile, rather than failing again. + const reconcile = nextWorkbook.xlsx.reconcile; + if (typeof reconcile !== 'function') throw new Error('ExcelJS reconcile is unavailable'); + nextWorkbook.xlsx.reconcile = function reconcileRepaired(model, options) { + core.repairExcelJsModel(model); + return reconcile.call(this, model, options); + }; // ExcelJS expands every address of a `` into its own // object (a whole-column dropdown is a million), and the preview never shows // validations, so they are not parsed at all. `maxRows` is a per-sheet diff --git a/src/web/public/spreadsheet-preview.js b/src/web/public/spreadsheet-preview.js index 9096ed9b..0f60015b 100644 --- a/src/web/public/spreadsheet-preview.js +++ b/src/web/public/spreadsheet-preview.js @@ -20,7 +20,7 @@ (function initSpreadsheetPreview(global) { 'use strict'; - const SPREADSHEET_ASSET_VERSION = '911680fac09d'; + const SPREADSHEET_ASSET_VERSION = '0833a414e785'; const MAX_PREVIEW_BYTES = 10 * 1024 * 1024; const DEFAULT_TIMEOUT_MS = 20000; const MAX_SCROLL_PX = 8000000; diff --git a/src/web/public/spreadsheet-xlsx-core.js b/src/web/public/spreadsheet-xlsx-core.js index d1e052c9..8fe0e5e4 100644 --- a/src/web/public/spreadsheet-xlsx-core.js +++ b/src/web/public/spreadsheet-xlsx-core.js @@ -472,6 +472,102 @@ return zipApi.zipSync(admission.entries, { level: 0 }); } + // ExcelJS (4.4.0, lib/xlsx/xlsx.js + xform/sheet/worksheet-xform.js `reconcile`) + // links a worksheet to its comments, comment notes (VML), tables and drawing by + // looking the relationship Target up VERBATIM in maps keyed by its OWN spelling + // (`../comments1.xml`, `../drawings/vmlDrawing1.vml`, `../tables/table1.xml`, + // `drawing1`), then dereferences the result unchecked. openpyxl, which most + // agents write spreadsheets with, uses absolute Targets, its own part names + // (`/xl/comments/comment1.xml`) and a drawing XML ExcelJS parses to nothing, so + // a workbook with ONE comment, table or chart threw ("reading 'comments'", + // "'name'", "'anchors'") before a single cell was shown. + // + // This runs on ExcelJS's parsed model right before that reconcile. It only + // re-points a link to the key ExcelJS itself stored for the same part, or drops + // a link (or a drawing) that would dereference nothing; nothing is added. The + // preview shows none of these parts. Lookups are own-property only, so a Target + // or rId such as `__proto__` cannot resolve through Object.prototype. + const REL_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/'; + const EXCELJS_DRAWING_TARGET = /\/drawings\/([a-zA-Z0-9]+)[.][a-zA-Z]{3,4}$/; + + function ownValue(map, key) { + return map && typeof key === 'string' && Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined; + } + + // The key ExcelJS gives a part it loaded from `xl/` is `../` + // (relative to xl/worksheets/). A Target that names the same part another way + // (`/xl/tables/table1.xml`) is rewritten to that key. + function worksheetRelativeTarget(target) { + const path = excelJsEntryName(target[0] === '/' ? target : `xl/worksheets/${target}`); + return path.startsWith('xl/') ? `../${path.slice(3)}` : null; + } + + function resolveTarget(rel, map, usable) { + if (typeof rel.Target !== 'string') return false; + if (usable(ownValue(map, rel.Target))) return true; + const key = worksheetRelativeTarget(rel.Target); + if (key && usable(ownValue(map, key))) { + rel.Target = key; + return true; + } + return false; + } + + function repairExcelJsModel(model) { + if (!model || typeof model !== 'object') return model; + const isObject = (value) => Boolean(value) && typeof value === 'object'; + const drawings = isObject(model.drawings) ? model.drawings : {}; + // ExcelJS reconciles every drawing that has rels and reads `.anchors` off it. + for (const name of Object.keys(drawings)) { + if (!isObject(drawings[name]) || !Array.isArray(drawings[name].anchors)) { + delete drawings[name]; + if (isObject(model.drawingRels)) delete model.drawingRels[name]; + } + } + // ExcelJS also reconciles every table it parsed, so one it parsed to nothing goes. + if (isObject(model.tables)) { + for (const key of Object.keys(model.tables)) if (!isObject(model.tables[key])) delete model.tables[key]; + } + const worksheetRels = isObject(model.worksheetRels) ? model.worksheetRels : {}; + for (const worksheet of Array.isArray(model.worksheets) ? model.worksheets : []) { + if (!isObject(worksheet)) continue; + const list = ownValue(worksheetRels, String(worksheet.sheetNo)); + const rels = Array.isArray(list) ? list : []; + const kept = rels.filter((rel) => { + if (!isObject(rel)) return false; + if (rel.Type === `${REL_TYPE}comments`) return resolveTarget(rel, model.comments, isObject); + if (rel.Type === `${REL_TYPE}vmlDrawing`) { + return resolveTarget(rel, model.vmlDrawings, (vml) => isObject(vml) && Array.isArray(vml.comments)); + } + if (rel.Type === `${REL_TYPE}table`) return resolveTarget(rel, model.tables, isObject); + return true; + }); + if (Array.isArray(list)) worksheetRels[String(worksheet.sheetNo)] = kept; + const byId = new Map(kept.map((rel) => [rel.Id, rel])); + // ``, `` and `` are followed + // through the rels unchecked, so each must name a rel that is still there. + if (Array.isArray(worksheet.tables)) { + worksheet.tables = worksheet.tables.filter((part) => { + const rel = isObject(part) ? byId.get(part.rId) : undefined; + return Boolean(rel) && rel.Type === `${REL_TYPE}table`; + }); + } + if (isObject(worksheet.drawing)) { + const rel = byId.get(worksheet.drawing.rId); + const match = rel && typeof rel.Target === 'string' ? rel.Target.match(EXCELJS_DRAWING_TARGET) : null; + if (!rel || typeof rel.Target !== 'string' || (match && !ownValue(drawings, match[1]))) { + worksheet.drawing = undefined; + } + } + if (Array.isArray(worksheet.hyperlinks)) { + worksheet.hyperlinks = worksheet.hyperlinks.filter( + (link) => isObject(link) && (!link.rId || byId.has(link.rId)) + ); + } + } + return model; + } + function parseCellRef(ref) { const match = /^\$?([A-Z]{1,3})\$?([1-9]\d*)$/i.exec(String(ref || '')); if (!match) return null; @@ -983,6 +1079,7 @@ createXmlCounter, admitXlsx, buildAdmittedArchive, + repairExcelJsModel, excelJsEntryName, parseCellRef, parseRange, diff --git a/test/fixtures/xlsx/openpyxl-chart.xlsx b/test/fixtures/xlsx/openpyxl-chart.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..93f348b3b32784d0544e83af678a316fe172d5dd GIT binary patch literal 6495 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%0ATg6^(B~x37rV-` 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 G>VE*MERW0p literal 0 HcmV?d00001 diff --git a/test/fixtures/xlsx/openpyxl-table.xlsx b/test/fixtures/xlsx/openpyxl-table.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..29013e2471ccb5d70206182cf688f65f8dadc016 GIT binary patch literal 5579 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 literal 0 HcmV?d00001 diff --git a/test/spreadsheet-preview-worker.test.ts b/test/spreadsheet-preview-worker.test.ts index c9a87b24..c805f48a 100644 --- a/test/spreadsheet-preview-worker.test.ts +++ b/test/spreadsheet-preview-worker.test.ts @@ -1070,3 +1070,41 @@ describe('spreadsheet preview worker: element budget', () => { expect(harness.imports.some((url) => url.includes('exceljs'))).toBe(false); }, 60_000); }); + +// Real files written by openpyxl 3.1.5, the library most agents write +// spreadsheets with: a "Data" sheet (month, revenue x6) plus ONE feature each. +// ExcelJS threw on all three before repairExcelJsModel, so the preview failed +// on the whole workbook. Regenerate with openpyxl's Comment, Table and BarChart. +function openpyxlFixture(name: string): ArrayBuffer { + return toArrayBuffer(new Uint8Array(readFileSync(resolve(root, 'test/fixtures/xlsx', name)))); +} + +describe('spreadsheet preview worker: workbooks written by openpyxl', () => { + for (const [file, feature] of [ + ['openpyxl-comment.xlsx', 'a cell comment'], + ['openpyxl-table.xlsx', 'a table'], + ['openpyxl-chart.xlsx', 'a bar chart'], + ] as const) { + it(`previews a sheet that has ${feature}`, async () => { + const harness = createHarness(); + const metadata = await loadMetadata(harness, openpyxlFixture(file)); + expect(metadata.sheets.map((sheet: { name: string }) => sheet.name)).toEqual(['Data']); + expect(metadata.sheets[0]).toMatchObject({ rows: 7, cols: 2 }); + const tile = await requestTile(harness, metadata.sheets[0].id, { r1: 1, c1: 1, r2: 7, c2: 2 }); + expect(tile.type).toBe('tile'); + expect(tile.cells).toEqual( + expect.arrayContaining([ + expect.objectContaining({ row: 1, col: 1, text: 'month' }), + expect.objectContaining({ row: 7, col: 1, text: '2026-06' }), + expect.objectContaining({ row: 7, col: 2, text: '600' }), + ]) + ); + }); + } + + it('still flags the chart it cannot show', async () => { + const harness = createHarness(); + const metadata = await loadMetadata(harness, openpyxlFixture('openpyxl-chart.xlsx')); + expect(metadata.warnings).toEqual(expect.arrayContaining(['charts', 'drawings'])); + }); +}); diff --git a/test/spreadsheet-xlsx-core.test.ts b/test/spreadsheet-xlsx-core.test.ts index 41b9ad13..30df6997 100644 --- a/test/spreadsheet-xlsx-core.test.ts +++ b/test/spreadsheet-xlsx-core.test.ts @@ -17,6 +17,7 @@ type Core = { limits: Record ): { push(chunk: Uint8Array, final: boolean): void }; buildAdmittedArchive(admission: unknown, zip: typeof fflate): Uint8Array; + repairExcelJsModel(model: unknown): unknown; excelJsEntryName(name: string): string; parseCellRef(ref: string): { row: number; col: number } | null; deriveExtent(cells: string[], merges: string[]): { rows: number; cols: number }; @@ -803,3 +804,127 @@ describe('spreadsheet XLSX colour resolution', () => { expect(core.resolveCellColors({ argb: 'bogus' }, { argb: '' })).toEqual({}); }); }); + +// ExcelJS 4.4.0's parsed model at the moment it reconciles, reduced to the parts +// repairExcelJsModel reads. `excelShaped()` is what Excel itself writes (and +// ExcelJS links cleanly); the openpyxl cases below are what made it throw. +const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/'; +const rel = (Id: string, type: string, Target: string) => ({ Id, Type: REL + type, Target }); +type Model = Record; + +function excelShaped(): Model { + return { + worksheets: [ + { + sheetNo: '1', + tables: [{ rId: 'rId3' }], + drawing: { rId: 'rId4' }, + hyperlinks: [{ rId: 'rId5', address: 'A1' }, { address: 'B2' }], + }, + ], + worksheetRels: { + '1': [ + rel('rId1', 'comments', '../comments1.xml'), + rel('rId2', 'vmlDrawing', '../drawings/vmlDrawing1.vml'), + rel('rId3', 'table', '../tables/table1.xml'), + rel('rId4', 'drawing', '../drawings/drawing1.xml'), + rel('rId5', 'hyperlink', 'https://example.com'), + ], + }, + comments: { '../comments1.xml': { comments: [{ ref: 'B2' }] } }, + vmlDrawings: { '../drawings/vmlDrawing1.vml': { comments: [{}] } }, + tables: { '../tables/table1.xml': { name: 'Sales' } }, + drawings: { drawing1: { anchors: [] } }, + drawingRels: { drawing1: [] }, + }; +} + +describe('repairExcelJsModel: links ExcelJS would dereference unchecked', () => { + it('leaves a model Excel itself wrote untouched', () => { + const model = excelShaped(); + const before = structuredClone(model); + core.repairExcelJsModel(model); + expect(model).toEqual(before); + }); + + it("re-points openpyxl's absolute table Target to the key ExcelJS stored that table under", () => { + const model = excelShaped(); + model.worksheetRels['1'][2].Target = '/xl/tables/table1.xml'; + core.repairExcelJsModel(model); + expect(model.worksheetRels['1'][2].Target).toBe('../tables/table1.xml'); + expect(model.worksheets[0].tables).toEqual([{ rId: 'rId3' }]); + }); + + it('drops comment and note links that resolve to nothing (openpyxl names the parts its own way)', () => { + const model = excelShaped(); + model.worksheetRels['1'][0].Target = '/xl/comments/comment1.xml'; + model.worksheetRels['1'][1].Target = '/xl/drawings/commentsDrawing1.vml'; + model.comments = {}; + model.vmlDrawings = {}; + core.repairExcelJsModel(model); + expect(model.worksheetRels['1'].map((r: { Id: string }) => r.Id)).toEqual(['rId3', 'rId4', 'rId5']); + }); + + it('drops a note link whose VML has no comments list, as ExcelJS would index into it', () => { + const model = excelShaped(); + model.vmlDrawings['../drawings/vmlDrawing1.vml'] = {}; + core.repairExcelJsModel(model); + expect(model.worksheetRels['1'].some((r: { Id: string }) => r.Id === 'rId2')).toBe(false); + }); + + it('drops a drawing ExcelJS parsed to nothing and unhooks the sheet from it (openpyxl charts)', () => { + const model = excelShaped(); + model.drawings.drawing1 = undefined; + model.worksheetRels['1'][3].Target = '/xl/drawings/drawing1.xml'; + core.repairExcelJsModel(model); + expect(model.drawings).toEqual({}); + expect(model.drawingRels).toEqual({}); + expect(model.worksheets[0].drawing).toBeUndefined(); + }); + + it('keeps a drawing link whose Target ExcelJS does not try to resolve', () => { + const model = excelShaped(); + model.worksheetRels['1'][3].Target = 'drawing-without-extension'; + core.repairExcelJsModel(model); + expect(model.worksheets[0].drawing).toEqual({ rId: 'rId4' }); + }); + + it('unhooks a tablePart, drawing or hyperlink whose rId names no relationship', () => { + const model = excelShaped(); + model.worksheets[0].tables = [{ rId: 'missing' }, { rId: 'rId5' }]; + model.worksheets[0].drawing = { rId: 'missing' }; + model.worksheets[0].hyperlinks = [{ rId: 'missing', address: 'A1' }, { address: 'B2' }]; + core.repairExcelJsModel(model); + expect(model.worksheets[0].tables).toEqual([]); + expect(model.worksheets[0].drawing).toBeUndefined(); + expect(model.worksheets[0].hyperlinks).toEqual([{ address: 'B2' }]); + }); + + it('drops a table rel whose part ExcelJS never stored, and its tablePart with it', () => { + const model = excelShaped(); + model.worksheetRels['1'][2].Target = '/xl/somewhere/else.xml'; + model.tables = { '../tables/table1.xml': undefined }; + core.repairExcelJsModel(model); + expect(model.tables).toEqual({}); + expect(model.worksheetRels['1'].some((r: { Id: string }) => r.Id === 'rId3')).toBe(false); + expect(model.worksheets[0].tables).toEqual([]); + }); + + it('never resolves a Target or rId through Object.prototype', () => { + const model = excelShaped(); + model.worksheetRels['1'][0].Target = '__proto__'; + model.worksheetRels['1'][2].Target = 'constructor'; + model.worksheets[0].tables = [{ rId: 'toString' }]; + model.worksheets[0].hyperlinks = [{ rId: 'hasOwnProperty', address: 'A1' }]; + core.repairExcelJsModel(model); + expect(model.worksheetRels['1'].map((r: { Id: string }) => r.Id)).toEqual(['rId2', 'rId4', 'rId5']); + expect(model.worksheets[0].tables).toEqual([]); + expect(model.worksheets[0].hyperlinks).toEqual([]); + }); + + it('tolerates a model with no worksheets, rels or part maps', () => { + expect(() => core.repairExcelJsModel({})).not.toThrow(); + expect(() => core.repairExcelJsModel({ worksheets: [null, { sheetNo: '9' }] })).not.toThrow(); + expect(core.repairExcelJsModel(null)).toBeNull(); + }); +});