This post is part of the series behind Clean Copy, an HTML-to-Markdown converter running as browser extensions (Chrome + Firefox), a CLI, a GitHub Action and an Obsidian plugin — one shared zero-dependency core. Table alignment support shipped in v1.4.1.
The problem nobody notices until they paste a spreadsheet
A Markdown table has two parts: the data rows you see, and a separator row you usually don't think about:
| Name | Qty | Price |
|:-----|:---:|------:|
| Bolt | 12 | 0.40 |
| Nut | 24 | 0.15 |
That second line isn't decoration. :--- left-aligns the column, :---: centers it, ---: right-aligns it. A plain --- means "renderer's default" — which for GitHub-flavored Markdown is left.
The HTML you copy almost always has this information. Financial pages right-align numbers. Comparison tables center ratings. But when the converter emits | --- | --- | --- | for every table, all of that is silently lost — and suddenly your pasted price list reads like a phone book.
Where the alignment actually lives
HTML expresses column alignment in two ways, sometimes both on the same cell:
<th style="text-align: right">Price</th> <!-- modern -->
<td align="center">4.5</td> <!-- legacy, still everywhere -->
So step one is reading both, from the cell's opening tag only — the body may contain inline elements with their own unrelated styles:
const alignOf = (tag) => {
const style = (tag.match(/style\s*=\s*["']([^"']*)["']/i) || [])[1] || '';
const attr = (tag.match(/\balign\s*=\s*["']([^"']*)["']/i) || [])[1] || '';
const hay = (style.replace(/;/g, ' ') + ' ' + attr).toLowerCase();
if (/text-align\s*:\s*(center|right|left)|\b(center|right|left)\b/.test(hay)) {
if (/\bleft\b/.test(hay)) return ':---';
if (/\bright\b/.test(hay)) return '---:';
if (/\bcenter\b/.test(hay)) return ':---:';
}
return null;
};
Note what this deliberately does not do: it doesn't try to resolve CSS inheritance or compute defaults. If no explicit declaration exists, we return null and fall back to the plain separator. Explicit beats inferred, every time.
One alignment per column, not per cell
Markdown's separator row applies to a whole column, but HTML alignment is set per cell. Cells in the same column can disagree. You need a merge rule. Two rules that work well in practice:
- The header wins. If any row declares an alignment for a column, prefer the header's (
<th>) declaration — headers are where authors state intent. - First declaration wins otherwise. Scan body rows top-down and take the first non-null value. Don't average, don't vote — first-wins is predictable and matches how these tables are authored.
// aligns[r][c] holds alignOf() results per row/column
const colAlign = Array(cols).fill(null);
for (let c = 0; c < cols; c++) {
for (const a of aligns) { if (a[c]) { colAlign[c] = a[c]; break; } }
}
const sepRow = Array.from({length: cols}, (_, i) => colAlign[i] || ' --- ');
Colspan breaks your column index
This is the subtle one. A cell with colspan="3" occupies three columns but carries one alignment. Which column does it belong to?
Rule: the alignment applies to the cell's first column only; the spanned columns stay null. That keeps the alignment array the same width as the real column count, so the separator row never shifts out of sync with the data rows:
const span = Math.max(1, parseInt(colspanMatch?.[1] ?? '1', 10));
for (let s = 0; s < span; s++) {
rowAligns.push(s === 0 ? alignOf(cellAttrs) : null);
}
Without padding each row's arrays to the true column count afterward, a single wide footer cell would misalign every separator after it.
Nested tables, again
If your converter handles nested tables at all, alignment slots right into that machinery: convert innermost-first, and when the inner table becomes Markdown text inside a cell, its separator rows are just characters — the outer pass never touches them. What you must guard against is the lazy regex that matches an outer <table> up to the first </table> it finds, shredding everything between. Convert repeatedly while the input still changes, innermost first:
do {
prev = md;
md = md.replace(/<table[^>]*>((?:(?!<table[\s>]|<\/table)[\s\S])*)<\/table>/gi, convertTable);
} while (md !== prev);
What good output looks like
Given:
<table>
<tr><th>Item</th><th style="text-align:center">Qty</th><th style="text-align:right">Price</th></tr>
<tr><td>Bolt</td><td align="center">12</td><td align="right">0.40</td></tr>
</table>
Correct output:
| Item | Qty | Price |
|:-----|:---:|------:|
| Bolt | 12 | 0.40 |
Renders exactly as the source page did: left, centered, right-aligned. Paste that into GitHub, Notion, Obsidian or any GFM renderer and the structure survives.
Testing checklist
If you're implementing this, these are the cases worth asserting:
- No alignment anywhere → plain
---separators, unchanged behavior - Alignment only in body rows (no
<th>) → still picked up - Header says center, body says right → header wins
align="RIGHT"uppercase attribute → matched case-insensitivelycolspan="3"with alignment → first column only- Table nested inside a cell of another table → both keep their own separators
style="text-align:right"mixed withalign="left"on the same tag → deterministic result, not garbage
All of these are covered by Clean Copy's test suite across the shared core, the CLI and both extensions.
Try it
v1.4.1 ships table alignment everywhere the converter runs:
- Clean Copy for Chrome and Firefox
- The web converter — paste HTML, get aligned Markdown instantly
- CLI:
npx github:mahope/clean-copy-cli https://example.com/report
Related: CLI guide — Table alignment works in the CLI too — see the terminal guide for examples.