The Easy 80% and the Expensive 20%
Companion to Parsing HTML Tables with Merged Cells, which covers grid normalisation. This article starts from a normalised grid and covers the export boundary: rowspan=0, pinned clones, Excel limits, and injection.
Reading a simple <table> and writing CSV is a weekend project. Getting the same exporter to survive real dashboards — Ant Design, Element Plus, MUI DataGrid, AG Grid — is weeks of edge cases, because each library encodes the same visual table in a different DOM shape and each one breaks a different assumption.
This is the catalogue from shipping a table exporter: every item below corresponds to a failing test that now passes, and most of them produced a quiet data corruption rather than an exception.
1. rowspan="0" Is Not "No Span"
Almost every parser treats rowspan=0 as invalid and coerces it to 1. The HTML spec says otherwise: a zero rowspan means span to the end of the row group. In practice this shows up in generated tables (report tools, exported spreadsheets) and the symptom is that every cell below the zero-span cell lands one column to the left.
function _effectiveRowspan(cell, trs, rowIdx) {
const raw = parseInt(_attr(cell, 'rowspan'), 10);
if (raw === 0) {
// HTML: rowspan=0 spans the rest of the row group.
return Math.max(1, trs.length - rowIdx);
}
return Number.isFinite(raw) && raw > 0 ? raw : 1;
}
One extra branch; without it, an entire exported sheet is shifted.
2. Phantom Rows Produced by rowspan
A rowspan that reaches past the last physical row makes the grid taller than the row count implies. If you allocate the output from the rowspan arithmetic, you emit trailing empty rows — Excel shows a sheet that "has 3 blank rows at the bottom" and the user files a bug about your parser.
The fix is an explicit trim, with one exception that matters: in a metadata-only mode (headers plus the first N rows), those trailing rows are not phantom — they were never materialised. Trimming unconditionally in that mode silently deletes real data.
3. "It Has a TH" Is Not a Header Test
Real dashboards use a wide variety of header encodings. A header detector that only checks thead or <th> will produce wrong output on at least four of them:
role="columnheader"without anythelement (ARIA grids).- A single stray
<th>in a data row, used for a row label. - Ant Design's measure row — a virtual-table spacer row that exists purely to measure column widths. It looks exactly like a header row and contains no user data.
aria-hiddenduplicate rows that exist for screen readers or sticky-header cloning.
const ARIA_NOISE_RE = /\b(?:ant-table-measure-row|ant-table-placeholder|ag-measure|ag-hidden|ag-header-cell-resize|ag-header-row-filter|ag-floating-filter|MuiDataGrid-filler|MuiDataGrid-columnHeader--empty)\b/;
This regex is the least elegant line in the project and the one that saved the most support tickets.
4. Element Plus Splits the Table in Two
Element Plus renders the header and the body as separate <table> elements (to support fixed headers). The body table alone has no header at all. Exporting the body gives a sheet with a header row of data values.
The pragmatic fix: when the visible table has no header rows, search the nearest known container for a sibling table and borrow its header — provided the column count matches:
const HEADER_DONOR_CONTAINERS = [
'.el-table', '.ant-table-wrapper', '.ant-table', '.p-datatable',
'.k-grid', '.dt-container', '.dataTables_wrapper'
];
// findHeaderDonor(tableEl, cols) — returns header rows only when
// the donor's column span equals the body's column count.
The column-count check is not optional. Borrowing a 7-column header for a 6-column body produces plausible-looking, completely wrong output.
5. Fixed Columns Are Whole-Row Clones
MUI DataGrid and AG Grid implement pinned columns by rendering the same logical row multiple times: a pinned-left group, a scrolling group, and a pinned-right group. Each has role="row" and its own cells.
If you iterate role="row" naively, every row is exported three times with a third of its columns each. The correct model is: group physical rows into logical rows by row key, then merge their cells:
// Fixed-column clone of a row we already collected: merge its cells.
if (map.has(key)) {
map.get(key).cells.push(...cells);
continue;
}
Two sub-cases break the simple version. Sparse header rows with disjoint columns have no row index at all, so they must be merged by column space overlap instead of by key. And AG Grid 36 wraps each cell in many presentation containers, so cell collection has to descend through layout wrappers while stopping at another row, grid, or native table.
6. aria-colindex Can Be Pathological
ARIA grids may report column positions rather than DOM order — which is exactly what you want with pinned columns. But the attribute is written by application code, and application code can be wrong: zero, negatives, indices far beyond the column count.
Clamp, then write by position, and skip when the target is already occupied by a merged cell. Without clamps, a single bad attribute allocates a sparse array with thousands of empty columns and the export hangs while serialising them.
7. Excel's Hard Limits Are Your Budget
Exporters need caps, and the caps should be Excel's own numbers so truncation is predictable rather than arbitrary:
const DEFAULT_MAX_CELLS = 500000;
const DEFAULT_MAX_COLS = 2000;
const MAX_MERGES = 50000;
const MAX_CELL_CHARS = 32767; // Excel's hard cell limit
Two practical details:
- Counting must continue past the cap even when collection stops, so the UI can say "showing 500,000 of 812,441 cells" instead of silently truncating.
MAX_CELL_CHARSmatters before export, not after: a single pathological cell (a pasted log, a base64 blob) otherwise dominates every message you pass between content script, worker and UI.
8. Formula Injection, Without Breaking Phone Numbers
Any cell beginning with =, +, - or @ is a formula when Excel opens the file. A cell containing =HYPERLINK(...) or =cmd|'/c calc'!A0 is a genuine attack vector for exported CSVs shared with colleagues.
The naive guard — prefix everything starting with those characters — corrupts the most common real values in commerce data: -5, +8613800138000, -12.5%. The version that survives contact with real data checks whether anything after the leading character is non-numeric:
function sanitizeCell(val) {
// Prefix a quote only when the payload is not a plain number.
if (/^[=+\-@\t\r]/.test(val) && /[^0-9.,%()\s]/.test(val.slice(1))) return "'" + val;
return val;
}
-5 stays -5. +86 138 0013 8000 stays a phone number. =cmd|'/c calc'!A0 becomes inert text.
9. A Bare CR Desynchronises Excel's Columns
CSV quoting usually checks for commas, quotes and newlines. Excel additionally treats a bare carriage return (\r) as a row terminator. A field containing \r that is left unquoted splits into a new row mid-record, and every subsequent column shifts.
if (val.includes('"') || val.includes(',') || /[\r\n]/.test(val)) {
val = '"' + val.replace(/"/g, '""') + '"';
}
The same normalisation belongs upstream in text cleaning (\r\n? → \n), so this quoting rule only has to protect callers that assemble rows themselves.
10. The Tests Are the Documentation
All of the above is encoded in 57 unit tests in one file, and the test names are the real specification:
parseTable treats rowspan=0 as spanning the rest of the row groupparseAriaGrid merges pinned fixed-column clones into logical rowstableToCSV quotes a bare CR (Excel line-terminator desync)sanitizeCell keeps negative numbers readable
Two reasons this matters beyond coverage. First, every one of these bugs is invisible in a screenshot — the export "looks fine" until someone opens it in Excel. Second, the DOM shapes differ per library version, so a regression test with a captured fixture is the only cheap way to notice when AG Grid or Ant Design changes its markup.
Known Limitations Worth Publishing
An exporter's honesty section is part of its API:
- Canvas-rendered grids (some charts and WebGL tables) have no DOM text — nothing to export.
- Virtual scrolling means only the current DOM window exists; "export all rows" is impossible without driving the scroll container.
- Cross-origin iframes cannot be read at all.
Documenting these prevents the worst kind of ticket: a user who believes they exported 10,000 rows and actually got 40.
Checklist
- Implement
rowspan=0correctly; it is not an invalid value. - Trim phantom rows — except in metadata-only mode.
- Detect headers by role and known library markers, not by
thpresence alone. - Borrow headers only when column counts match.
- Group physical rows into logical rows before merging pinned clones.
- Clamp
aria-colindex, and never trust DOM order in ARIA grids. - Cap cells, columns, merges and characters at Excel's limits; keep counting past the cap.
- Sanitise formulas without destroying numbers and phone numbers.
- Quote bare
\rin CSV, and normalise it upstream too. - Publish the limitations: canvas grids, virtual scrolling, cross-origin iframes.
References
- HTML spec — rowspan and colspan
- WAI-ARIA Grid Pattern
- CSV Injection — OWASP
- Excel specifications and limits — Microsoft