runlocally

runlocally engineering notes

XLSX to Markdown Table

How XLSX to Markdown Table is built

By Geppetto · · Open XLSX to Markdown Table →

XLSX to Markdown Table turns a worksheet into a GitHub-style Markdown table — copyable or downloadable as .md. This post is about the small but exact rules a spreadsheet’s free-form grid has to follow to survive the trip into Markdown’s much stricter table syntax.

Tech used

SheetJS as a read-only source of rows

Reading the workbook uses SheetJS Community Edition, installed from the project’s own CDN (as covered in Extract Images from Excel and XLS to XLSX Converter), loaded via a dynamic import('xlsx') so its ~500 KB never lands in the initial page bundle. XLSX.utils.sheet_to_json(worksheet, { header: 1 }) returns the sheet as a plain 2D array of display values — the same call Excel Workbook Viewer uses for the same reason: a Markdown table, like a viewer, only needs a formula cell’s last-calculated result, not its formula text.

Markdown tables: a fixed column count, and two characters they can’t contain literally

A GitHub-flavored Markdown table is three things: a header row, a separator row of dashes, and body rows — every row using the same number of |-delimited columns. Unlike a spreadsheet, which tolerates any row having any number of populated cells, a Markdown table’s column count is fixed for the whole table by its header row. And two characters that appear constantly in real spreadsheet data — a literal | and a line break inside a cell — are exactly the two characters Markdown’s table syntax uses as structure, so both need escaping before they reach the output.

function cellToMarkdown(value: unknown): string {
  if (value === null || value === undefined) return '';
  return String(value)
    .replace(/\|/g, '\\|')
    .replace(/\r\n|\r|\n/g, '<br>');
}

A literal pipe becomes \|; a line break — in any of its three common forms — becomes an HTML <br>, which every Markdown renderer that supports tables at all also passes through inside a cell, since raw newlines would otherwise break the row structure outright.

Implementation & operational notes

The widest row in the sheet determines the table’s column count, not the header row. A spreadsheet’s rows are independent of each other; a Markdown table’s aren’t. tableToMarkdown() scans every row first to find the largest cell count, uses that as the fixed width for the header, the separator, and every body row, and pads any shorter row’s missing cells with empty strings — rather than truncating a wide row to match a narrower header, which would silently drop data:

const columnCount = rows.reduce((largest, row) => Math.max(largest, row.length), 0);

This is also what keeps merged cells from corrupting the column alignment: a merged cell reads back from SheetJS as populated only in its top-left position, with the rest of its span simply absent from that row’s array — exactly the same shape as any other row that’s short a few trailing cells, so the same padding logic handles it without special-casing merges at all.

Two output paths for one conversion, since “view it” and “use it elsewhere” are different needs. The generated Markdown is shown as a live preview and offered as both a copy-to-clipboard button and a .md file download — covering pasting straight into a GitHub comment or README as well as keeping the table as a standalone file, without picking one at the expense of the other.

Output naming reflects whether a sheet name is actually informative. A single-sheet workbook downloads as <workbook-name>.md; a multi-sheet selection includes the sheet name too (<workbook-name>-<sheet-name>.md), since a bare workbook name alone would be ambiguous once more than one sheet is in play.

Try it / source

XLSX to Markdown Table

Open the tool → All posts →