How XLS to XLSX Converter is built
XLS to XLSX Converter turns a legacy Excel 97–2003 .xls file into a modern .xlsx workbook. This post is about the format gap between those two — they aren’t just different file extensions — and a subtle library-versioning surprise that turned up while bridging it.
Tech used
BIFF: a binary format from before OOXML existed
Modern .xlsx is a ZIP of XML, as covered in Extract Images from Excel. Legacy .xls predates that entirely: it’s BIFF (Binary Interchange File Format), a compact binary record stream, most commonly wrapped in a Compound File Binary (CFB) container — the same container family Microsoft Office used for .doc and .ppt before the 2007 format overhaul. There is no XML anywhere in a .xls file; every cell value, every formula, every style is a binary record with its own fixed layout.
Reading that format is handled by SheetJS Community Edition (installed from the project’s own CDN, as in the rest of this fleet’s Excel tools), which supports BIFF2 through BIFF8 — every .xls variant from Excel 2.0 up through Excel 97–2003. Writing the resulting .xlsx is a separate library, ExcelJS 4.4 (MIT), since SheetJS’s Community Edition write support is more limited than what building a clean modern workbook calls for. Both are loaded via dynamic import(), alongside a third lazy chunk for legacy code-page data (below) — none of the three reach the initial page bundle.
Code pages: how BIFF stores non-ASCII text
Before Unicode was standard, BIFF stored text using a code page — a fixed mapping from byte values to characters that varies by language and region (Shift_JIS-family mappings for Japanese, Windows-1252 for Western European text, and so on). A .xls file declares which code page it uses in its header; reading its Japanese sheet names and cell values correctly means decoding those bytes according to that specific mapping, not assuming UTF-8. SheetJS ships this as a separate, larger code-page table (cpexcel) that the tool imports and registers with set_cptable() — again as its own lazy chunk, since the vast majority of .xls files in practice use a code page this registration step still needs, but there’s no reason to ship that table to a page whose visitor hasn’t dropped a file yet.
Implementation & operational notes
A library’s compiled type declarations and its actual runtime export didn’t agree, and only running the real code caught it. SheetJS 0.20.3’s TypeScript types describe the code-page module as exporting a single cptable object. The real ECMAScript module it ships also exports utils and version alongside cptable — and registering only cptable without those left the table incomplete for some inputs. This is exactly the kind of drift a type signature alone can’t catch: the fix (destructuring all three named exports and passing them through to set_cptable) came from testing against a real BIFF8 fixture with Japanese sheet names and values, decoding it back out, and checking the actual round-tripped text — not from reading the declaration file and assuming it matched the shipped code.
The conversion is explicit that it’s not lossless, because it isn’t. Cell values transfer; macros, charts, embedded forms, and other legacy workbook objects do not. Rather than a “conversion complete” message that implies parity with the original file, the result screen says plainly what did and didn’t come across — matching this fleet’s general stance of describing what a tool actually does rather than what would sound most impressive.
.xlsx input is accepted too, and processed the same way as .xls. Feeding the tool a workbook already in the modern format doesn’t error out; it goes through the identical value-based rebuild, which the result screen notes explicitly, so a user isn’t left wondering why an already-modern file just got quietly re-saved.