TECHNICAL DEEP DIVE

Preserving Table Column Alignment in HTML to Markdown

Markdown tables can express column alignment — but most converters throw it away. Here's how to carry text-align from HTML into the separator row, and the edge cases that trip up naive implementations.

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:

  1. The header wins. If any row declares an alignment for a column, prefer the header's (<th>) declaration — headers are where authors state intent.
  2. 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:

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:

Related: CLI guide — Table alignment works in the CLI too — see the terminal guide for examples.