← Back to Blog

From HTML Table to XLSX: rowspan=0, Pinned Column Clones and Formula Injection

October 202612 min read

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:

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:

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:

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:

Documenting these prevents the worst kind of ticket: a user who believes they exported 10,000 rows and actually got 40.

Checklist

  1. Implement rowspan=0 correctly; it is not an invalid value.
  2. Trim phantom rows — except in metadata-only mode.
  3. Detect headers by role and known library markers, not by th presence alone.
  4. Borrow headers only when column counts match.
  5. Group physical rows into logical rows before merging pinned clones.
  6. Clamp aria-colindex, and never trust DOM order in ARIA grids.
  7. Cap cells, columns, merges and characters at Excel's limits; keep counting past the cap.
  8. Sanitise formulas without destroying numbers and phone numbers.
  9. Quote bare \r in CSV, and normalise it upstream too.
  10. Publish the limitations: canvas grids, virtual scrolling, cross-origin iframes.

References

Related Articles