DEV Community

Gu
Gu

Posted on

Reading .xlsx in the browser without a spreadsheet library

I run a small site that converts bank CSV exports into the file format QuickBooks Desktop accepts. The whole thing runs client-side, and the privacy claim it makes is unusually literal: every page ships a Content Security Policy with connect-src 'none', so the browser refuses to let the page make any network request at all. Open the Network tab while you convert a file and it stays empty. That's the feature, not a nice-to-have on top of it.

When I added Excel support last week, the obvious choice was SheetJS. I decided against it, and the reason wasn't bundle size. The claim I want to be able to make is "your file never leaves the browser, and here is the policy that enforces it." Pulling in a large third-party parser turns that into "…and also trust this dependency," which is a materially weaker claim for a tool that handles people's bank statements. So I wanted to find out how much of the format I actually needed.

Less than I expected. An .xlsx file is a ZIP archive containing XML: xl/workbook.xml lists the sheets, xl/worksheets/sheet1.xml holds the cells, xl/sharedStrings.xml is a deduplicated string pool that cells reference by index, and xl/styles.xml carries the number formats. Reading that needs three capabilities, and the browser already provides two of them. Unzipping means walking the ZIP central directory, which is about forty lines. Decompression is DecompressionStream('deflate-raw'), which is native. For the XML I hand-rolled a tag scanner rather than reaching for DOMParser, because my tests run in Node where DOMParser doesn't exist, and I would rather have one code path than two.

Dates

The part that took the most care was dates, because Excel doesn't store them as dates. A cell holding 5 January 2024 contains the number 45296, and whether that number should be displayed as a date depends on the cell's number format — which lives in a different file inside the archive. So the parser has to read styles.xml, work out which style indexes correspond to date formats, and check every numeric cell against that list.

Both failure modes are bad, and neither of them raises an error. Miss a date format and the user's date column arrives as a column of five-digit numbers, which at least looks wrong. Treat a currency format as a date and an amount that happens to be 45296 silently becomes a day in January 2024, which doesn't.

Then there's the epoch, which is 1899-12-30 rather than 1900-01-01. Excel believes 1900 was a leap year and reserves serial 60 for a 29 February that never existed, so starting a day and a half earlier makes everything from March 1900 onward line up. Bank statements don't reach back that far, so the gap never surfaces. Older Mac files are a separate case: they use a 1904 epoch, flagged by date1904="1" on workbookPr, and missing that attribute puts every date in the file off by four years and a day.

The bug that cost me an afternoon

I wrote 26 tests against workbooks the test suite generates itself, covering the date handling, sparse cells, shared strings and sheet selection. All of them passed. Then I opened the real page in a browser, dropped a file on it, and got back a single line:

Failed to fetch
Enter fullscreen mode Exit fullscreen mode

Nothing in the parser fetches anything, and the page it runs on is specifically configured so that fetching is impossible. The line responsible was this one:

const ds = new DecompressionStream('deflate-raw');
const writer = ds.writable.getWriter();
writer.write(bytes);
writer.close();
const out = await new Response(ds.readable).arrayBuffer();
Enter fullscreen mode Exit fullscreen mode

Wrapping a stream in a Response and calling arrayBuffer() is the tidiest way to collect its output, and it's what most examples show. Chrome treats it as a fetch, connect-src 'none' blocks it, and the error it returns reads like a network failure in code that has no network. Because Node doesn't enforce CSP, the test suite could never have caught this, which is the part I'd want to remember: a green suite told me nothing about whether the code worked on the page it was written for.

Draining the stream by hand avoids the problem entirely:

const reader = ds.readable.getReader();
const chunks = [];
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  chunks.push(value);
}
Enter fullscreen mode Exit fullscreen mode

There is a smaller companion problem worth knowing about. When the compressed data is corrupt, the writer side of the stream rejects as well as the reader side, so unless you attach a .catch() to both writer.write() and writer.close() you get an Uncaught (in promise) in the console sitting next to the error you are already handling correctly. A handled failure ends up looking unhandled.

What I left out

I don't read .xls. It's an OLE compound document rather than a ZIP — a genuinely different format that happens to have a similar name — so the converter detects it and asks the user to save as .xlsx first. Zip64 archives bail out with a message rather than being supported, on the grounds that I have yet to meet a four-gigabyte bank statement.

Multiple sheets is the decision I'm least happy with. A workbook often has a cover page, a summary tab and the actual transactions somewhere in the middle, and there's no reliable signal for which one the user meant. I take the first sheet with more than one row of data and name it in the results panel so the choice is at least visible, but that's a compromise rather than a solution; every alternative I considered came down to asking the user a question they don't want to be asked.

One thing I haven't been able to confirm is whether new Response(stream) counts against connect-src in Firefox and Safari the way it does in Chrome. If anyone knows, I'd be glad to hear it — the workaround is harmless either way, but the diagnosis took long enough that I'd rather it were written down somewhere.

The parser is at /src/xlsx/parse.js on qbofile.com, unminified, if you'd like to look at it.

Top comments (0)