How XLSX to CSV is built
XLSX to CSV splits every sheet in an Excel workbook into its own CSV file, zipped when there’s more than one. This post covers the parsing library behind it, the encoding detail that keeps the output usable, and a Unicode gotcha that only shows up in one browser’s test run.
Tech used
SheetJS, loaded only once a file is chosen
Parsing .xlsx, .xlsm, and legacy .xls workbooks is handled by SheetJS Community Edition — installed from the project’s own CDN rather than its stale npm listing, per the fleet’s Excel-tooling engine decision (see Extract Images from Excel for the OOXML side of that story; this tool is the first in the catalog to actually parse spreadsheet contents rather than just the ZIP container around them). The library — roughly 500 KB — is fetched with a dynamic import('xlsx') inside the conversion function, not at the top of the module, so Astro’s bundler places it in its own chunk that the initial page never requests. Only a file drop triggers the fetch.
XLSX.utils.sheet_to_csv(worksheet, { FS: ',', RS: '\n' }) does the actual cell-grid-to-CSV-text work per sheet, including resolving formula cells to their last-calculated value rather than the formula source — the same default behavior XLSX to Markdown and Excel Workbook Viewer rely on for the same reason: a CSV or a Markdown table has no concept of a formula, only a value.
Implementation & operational notes
A UTF-8 byte-order mark is prepended to every CSV, deliberately. A bare UTF-8 CSV with non-ASCII cell content — Japanese sheet names and values are a common case here — opens as mojibake in Excel’s default CSV import, because Excel’s heuristic for guessing a CSV’s encoding leans on the BOM being present. xlsxToCsvEngine.ts prepends '' to every generated CSV’s text before it’s written out, so a file this tool produces reopens correctly in the application most people will reopen it in.
One sheet skips the ZIP step entirely. Wrapping a single file in a ZIP archive just to unwrap it again is friction with no upside, so the tool only invokes @zip.js/zip.js — already a fleet-wide dependency, used here just for this one case — when a workbook has more than one sheet. A single-sheet workbook downloads its one CSV directly.
Sheet names can collide once they’re sanitized into filenames, and the fix has to preserve every sheet. A worksheet name can contain characters that are illegal in a filename (\ / : * ? " < > |); after those are replaced, two originally-distinct sheet names can end up identical. Rather than silently overwriting one file with another inside the ZIP, uniqueCsvFileName() tracks every name it’s already assigned and appends -2, -3, and so on to any repeat — a small, deliberate addition beyond the original spec, made specifically so a workbook’s data is never lost to a filename collision.
A workbook-format check runs before SheetJS ever touches the bytes. .xlsx/.xlsm are ZIP containers (a PK signature) while a genuine legacy .xls is a Compound File Binary container (or, rarely, a bare BIFF stream) — two structurally different binary formats. hasExpectedWorkbookSignature() checks the file’s first bytes against the signature appropriate to its extension before handing it to XLSX.read(), so a file that isn’t actually a workbook produces a clear, immediate error rather than an obscure parser exception several layers down.
A WebKit/macOS filesystem detail leaked into an e2e assertion, and the fix belongs in the test, not the product. One test downloads a workbook whose single sheet is named データ and asserts on the resulting filename. Chromium and Firefox agreed with the literal string authored in the test; WebKit didn’t — despite the source file, the compiled fixture, and the test’s own string all being confirmed byte-identical, precomposed (NFC) Unicode. The mismatch traces to how Playwright’s WebKit driver materializes a download on macOS: APFS can round-trip non-ASCII filenames through NFD normalization (base characters plus separate combining marks) during that process, independent of what the page’s JavaScript actually set as the filename. The fix is a one-line .normalize('NFC') on the assertion side — comparing what a person would see, not the underlying byte sequence a particular filesystem chose to store it as.
Try it / source
- Tool: XLSX to CSV
- Source: github.com/GeppettoAndRomero/xlsx-to-csv