Turn an HTML table into data
Get the rows and the columns of a table published in a web page, merged cells included.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the HTML specification names the elements and describes the algorithm that turns them into a grid. This entry’s work is to apply that algorithm and to say what it costs. A merged cell is spread over the places it covers, and every repeat is flagged: it is the only way to keep the columns aligned without suggesting the page wrote three times what it wrote once. A table inside a cell is returned separately, its text does not flow into the cell holding it, and that cell is flagged — the classic table-scraping trap. A span is capped at the two maximums the specification allows — a thousand for `colspan`, 65,534 for `rowspan` — and `capped` says how many times the cap was used; a rowspan of zero covers the rest of the row group, as the specification asks. And the caption comes back with the grid, because it is often the caption that says what the columns mean.
The grid the reader sees, with the spans spread out and flagged
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
A table published in a web page, turned into a rectangular grid.
Rung N0. The rows and the cells are elements the HTML specification names, so
reading them is parsing. What is not parsing is the one thing that makes tables
hard: a cell can span several columns or several rows, and the markup grid is
then not the data grid. « Total » written once over three columns is one cell
in the source and three cells in the table a reader sees.
This rung builds the grid a reader sees. A spanned cell is repeated over the
places it covers, and every repeat is flagged — a caller that adds up a column
would otherwise count the same amount twice without knowing it. The other
choice, leaving holes, misaligns every column after the span, which is worse
because nothing tells you.
Two more things are reported rather than smoothed over. A table inside a cell
of another table is a table of its own: its text does not leak into the cell
that holds it, and the holder is flagged. And the caption, when the page wrote
one, comes back with the grid, because it is usually what the columns mean.
"""
from __future__ import annotations
from html.parser import HTMLParser
CELLS = {"td", "th"}
# The largest spans the HTML specification allows, and they are not the same
# number: colspan « must be greater than zero and less than or equal to 1000 »,
# rowspan « must be greater than zero and less than or equal to 65534 ». The
# grid-forming algorithm repeats both caps. A page can claim more than it
# draws, and past these the claim is capped rather than believed — `capped` in
# the report counts how often.
MAX_COLSPAN = 1000
MAX_ROWSPAN = 65534
# « For this attribute, the value zero means that the cell is to span all the
# remaining rows in the row group. » Kept as zero here and resolved in `_grid`,
# which is the only place that knows how many rows follow. Row groups are not
# tracked, so « the row group » is read as « the table », which is the same
# thing on a table with one `tbody` — the ordinary case.
TO_END_OF_GROUP = 0
class _Tables(HTMLParser):
"""Tables, rows and cells, with the nesting kept straight."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.tables, self._stack = [], []
def handle_starttag(self, tag, attrs):
values = dict(attrs)
if tag == "table":
if self._stack:
self._stack[-1]["nested"] = True
table = {"rows": [], "caption": None, "nested": False,
"cell": None, "in_caption": False, "row_open": False,
"capped": 0}
# Listed in the order the page opens them, so a table that holds
# another comes before it — the order a reader would give.
self.tables.append(table)
self._stack.append(table)
elif not self._stack:
return
elif tag == "tr":
self._stack[-1]["rows"].append([])
self._stack[-1]["row_open"] = True
elif tag in CELLS:
# « in table body »: a `td` met outside a `tr` opens one. Adding it
# to the row that just closed would widen every row of the table.
if not self._stack[-1]["rows"] or not self._stack[-1].get("row_open"):
self._stack[-1]["rows"].append([])
self._stack[-1]["row_open"] = True
cell = {"text": [], "header": tag == "th",
"colspan": self._span(values.get("colspan"), MAX_COLSPAN),
"rowspan": self._span(values.get("rowspan"), MAX_ROWSPAN,
zero_allowed=True)}
self._stack[-1]["rows"][-1].append(cell)
self._stack[-1]["cell"] = cell
elif tag == "caption":
self._stack[-1]["in_caption"] = True
def _span(self, value, largest: int, *, zero_allowed: bool = False) -> int:
"""One span attribute, read as the standard reads it."""
try:
number = int(str(value))
except (TypeError, ValueError):
return 1
if zero_allowed and number == 0:
return TO_END_OF_GROUP
if number > largest:
self._stack[-1]["capped"] += 1
return largest
return max(1, number)
def handle_endtag(self, tag):
if not self._stack:
return
if tag == "table":
self._stack.pop()
elif tag == "tr":
self._stack[-1]["row_open"] = False
elif tag in CELLS:
self._stack[-1]["cell"] = None
elif tag == "caption":
self._stack[-1]["in_caption"] = False
def handle_data(self, data):
if not self._stack:
return
top = self._stack[-1]
if top["in_caption"]:
top["caption"] = (top["caption"] or "") + data
elif top["cell"] is not None:
top["cell"]["text"].append(data)
def extract_tables(html) -> dict:
"""
Every table of `html`, as the rectangular grid a reader sees.
A cell that spans is repeated over what it covers, and each repeat says so.
"""
if not isinstance(html, str):
return {"tables": [], "reason": f"expected text, not {type(html).__name__}"}
parser = _Tables()
parser.feed(html)
parser.close()
tables = []
for table in parser.tables:
grid, padded = _grid(table["rows"])
tables.append({"rows": grid, "columns": len(grid[0]) if grid else 0,
# How many cells the rectangular padding added, and how
# many spans the caps cut back. Without them, `columns`
# says a width and never says where it came from.
"padded": padded, "capped": table["capped"],
"caption": " ".join((table["caption"] or "").split()) or None,
"nested": table["nested"]})
return {"tables": tables, "reason": None}
def _grid(rows) -> tuple:
"""The markup rows, spread over the places their spans cover."""
grid: list = []
for index, row in enumerate(rows):
while len(grid) <= index:
grid.append([])
column = 0
for cell in row:
while column < len(grid[index]) and grid[index][column] is not None:
column += 1
value = {"text": " ".join("".join(cell["text"]).split()),
"header": cell["header"], "repeated": False}
# A rowspan of zero covers every row left in the group.
down_to = (len(rows) - index if cell["rowspan"] == TO_END_OF_GROUP
else cell["rowspan"])
for down in range(down_to):
while len(grid) <= index + down:
grid.append([])
line = grid[index + down]
for across in range(cell["colspan"]):
while len(line) <= column + across:
line.append(None)
if line[column + across] is None:
line[column + across] = value if (down or across) == 0 else \
{**value, "repeated": True}
column += cell["colspan"]
width = max((len(line) for line in grid), default=0)
padded = sum(width - len(line) for line in grid) + sum(
1 for line in grid for cell in line if cell is None)
return ([[cell or {"text": "", "header": False, "repeated": False}
for cell in line + [None] * (width - len(line))] for line in grid], padded)JavaScript
/**
* A table published in a web page, turned into a rectangular grid.
*
* Rung N0. The rows and the cells are elements the HTML specification names,
* so reading them is parsing. What is not parsing is the one thing that makes
* tables hard: a cell can span several columns or several rows, and the markup
* grid is then not the data grid. « Total » written once over three columns is
* one cell in the source and three cells in the table a reader sees.
*
* This rung builds the grid a reader sees. A spanned cell is repeated over the
* places it covers, and every repeat is flagged — a caller that adds up a
* column would otherwise count the same amount twice without knowing it. The
* other choice, leaving holes, misaligns every column after the span, which is
* worse because nothing tells you.
*
* Two more things are reported rather than smoothed over. A table inside a
* cell of another table is a table of its own: its text does not leak into the
* cell that holds it, and the holder is flagged. And the caption, when the
* page wrote one, comes back with the grid, because it is usually what the
* columns mean.
*/
import { parseHTML } from 'linkedom';
// The largest spans the HTML specification allows, and they are not the same
// number: colspan « must be greater than zero and less than or equal to 1000 »,
// rowspan « must be greater than zero and less than or equal to 65534 ». The
// grid-forming algorithm repeats both caps. A page can claim more than it
// draws, and past these the claim is capped rather than believed — `capped` in
// the report counts how often.
export const MAX_COLSPAN = 1000;
export const MAX_ROWSPAN = 65534;
// « For this attribute, the value zero means that the cell is to span all the
// remaining rows in the row group. » Kept as zero here and resolved in
// `gridOf`, which is the only place that knows how many rows follow. Row
// groups are not tracked, so « the row group » is read as « the table », which
// is the same thing on a table with one `tbody` — the ordinary case.
export const TO_END_OF_GROUP = 0;
/**
* Every table of `html`, as the rectangular grid a reader sees.
*
* A cell that spans is repeated over what it covers, and each repeat says so.
*/
export function extractTables(html) {
if (typeof html !== 'string') {
return { tables: [], reason: `expected text, not ${typeof html}` };
}
const { document } = parseHTML(`<html><body>${html}</body></html>`);
// Listed in the order the page opens them, so a table that holds another
// comes before it — the order a reader would give.
const found = [];
const collect = (node) => {
if (node.tagName === 'TABLE') found.push(node);
for (const child of node.children ?? []) collect(child);
};
collect(document.body);
const tables = found.map((table) => {
const cells = rowsOf(table).map((row) => row.map(cellOf));
const { grid, padded } = gridOf(cells);
const capped = cells.flat().filter((cell) => cell.capped).length;
const caption = ownCaption(table);
return {
rows: grid,
columns: grid.length ? grid[0].length : 0,
// How many cells the rectangular padding added, and how many spans the
// caps cut back. Without them, `columns` says a width and never says
// where it came from.
padded,
capped,
caption: caption ? caption.split(/\s+/).filter(Boolean).join(' ') : null,
nested: table.querySelector('table') !== null,
};
});
return { tables, reason: null };
}
/** The rows of this table, and not those of a table inside it. */
function rowsOf(table) {
const rows = [];
// True while the last row opened is the implicit one a stray cell created.
let orphan = false;
const walk = (node) => {
for (const child of node.children) {
if (child.tagName === 'TABLE') continue;
if (child.tagName === 'TR') {
rows.push([]);
orphan = false;
} else if (child.tagName === 'TD' || child.tagName === 'TH') {
// « in table body »: a cell met outside a `tr` opens one. Adding it to
// the row that just closed would widen every row of the table.
if (rows.length === 0 || !orphan) { rows.push([]); orphan = true; }
rows[rows.length - 1].push(child);
continue;
}
if (child.tagName === 'TR') {
for (const cell of child.children) {
if (cell.tagName === 'TD' || cell.tagName === 'TH') rows[rows.length - 1].push(cell);
}
orphan = false;
} else {
walk(child);
}
}
};
walk(table);
return rows;
}
function cellOf(cell) {
const colspan = span(cell.getAttribute('colspan'), MAX_COLSPAN);
const rowspan = span(cell.getAttribute('rowspan'), MAX_ROWSPAN, true);
return {
text: ownText(cell),
header: cell.tagName === 'TH',
colspan: colspan.value,
rowspan: rowspan.value,
capped: colspan.capped || rowspan.capped,
};
}
/** The text this element carries itself, a table inside it excepted. */
function ownText(element) {
let out = '';
const walk = (node) => {
for (const child of node.childNodes) {
if (child.nodeType === 3) out += child.nodeValue;
else if (child.nodeType === 1 && child.tagName !== 'TABLE') walk(child);
}
};
walk(element);
return out;
}
function ownCaption(table) {
for (const child of table.children) {
if (child.tagName === 'CAPTION') return ownText(child);
}
return null;
}
/** The markup rows, spread over the places their spans cover. */
function gridOf(rows) {
const grid = [];
rows.forEach((row, index) => {
while (grid.length <= index) grid.push([]);
let column = 0;
for (const cell of row) {
while (column < grid[index].length && grid[index][column] !== null) column += 1;
const value = {
text: cell.text.split(/\s+/).filter(Boolean).join(' '),
header: cell.header,
repeated: false,
};
// A rowspan of zero covers every row left in the group.
const downTo = cell.rowspan === TO_END_OF_GROUP ? rows.length - index : cell.rowspan;
for (let down = 0; down < downTo; down += 1) {
while (grid.length <= index + down) grid.push([]);
const line = grid[index + down];
for (let across = 0; across < cell.colspan; across += 1) {
while (line.length <= column + across) line.push(null);
if (line[column + across] === null) {
line[column + across] = down === 0 && across === 0
? value : { ...value, repeated: true };
}
}
}
column += cell.colspan;
}
});
const width = grid.reduce((most, line) => Math.max(most, line.length), 0);
let padded = 0;
const full = grid.map((line) => {
const complete = [...line];
while (complete.length < width) complete.push(null);
padded += complete.filter((cell) => cell === null).length;
return complete.map((cell) => cell ?? { text: '', header: false, repeated: false });
});
return { grid: full, padded };
}
/** One span attribute, read as the standard reads it. */
function span(value, largest, zeroAllowed = false) {
const number = Number.parseInt(String(value), 10);
if (Number.isNaN(number)) return { value: 1, capped: false };
if (zeroAllowed && number === 0) return { value: TO_END_OF_GROUP, capped: false };
if (number > largest) return { value: largest, capped: true };
return { value: Math.max(1, number), capped: false };
}Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
A cell in brackets is the repeat of a merged cell, not a value written in the page. The six tables below are read by the real snippet, when the site is built.
A table with no row at all
Given
<table><caption>Commande à venir</caption></table>
What it returns
0 row(s) over 0 column(s)
Caption: Commande à venir.
In detail
A row shorter than the others
Given
<table><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td></tr></table>
What it returns
2 row(s) over 2 column(s)
In detail
1 2 Moulin 2 Vélo A plain table, no merged cells
Given
<table><tr><th>Article</th><th>Quantité</th></tr><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td><td>1</td></tr></table>
What it returns
3 row(s) over 2 column(s)
In detail
Article Quantité Moulin 2 Vélo 1 The same, with a caption and sections
Given
<table><caption>Commande 2026-118</caption><thead><tr><th>Article</th><th>Quantité</th></tr></thead><tbody><tr><td>Moulin</td><td>2</td></tr><tr><td>Vélo</td><td>1</td></tr></tbody></table>
What it returns
3 row(s) over 2 column(s)
Caption: Commande 2026-118.
In detail
Article Quantité Moulin 2 Vélo 1 A table inside a cell of another
Given
<table><tr><td>Détail<table><tr><td>interne</td></tr></table></td><td>Voisine</td></tr></table>
What it returns
1 row(s) over 2 column(s) · 2 tables found, the first one is shown
This table holds another one, returned separately.
In detail
1 2 Détail Voisine Merged cells, across and down
Given
<table><tr><th>Article</th><th colspan="2">Prix</th></tr><tr><td>Moulin</td><td>HT</td><td>TTC</td></tr><tr><td rowspan="2">Lot</td><td>1</td><td>2</td></tr><tr><td>3</td><td>4</td></tr><tr><td colspan="3">Total</td></tr></table>
What it returns
5 row(s) over 3 column(s)
In detail
Article Prix Prix Moulin HT TTC Lot 1 2 (Lot) 3 4 Total (Total) (Total) Here it breaksThe grid returned is indeed the one the reader sees — three columns on every row — but it holds values the page wrote only once: “Prix” appears twice, “Lot” twice, “Total” three times. It is the only way to keep the columns aligned, and it is a trap for anyone adding up a column without looking. The repeats are flagged, and shown here in brackets; the snippet does not tell them apart in any other way, because nothing in the page does.
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the page is read inside your own process and is sent to nobody
- Scraping a third-party site is a matter of its terms of use and of applicable law, which the technique used does not change
Breaking point
Spreading a span out means repeating a value the page wrote only once: “Total” written over three columns comes back three times, and a caller adding up that column would count the same amount three times. Witness in the same test: every repeat carries a flag, and a row with no span raises none.
When to move up a level
Your page’s table is not a table: it is a run of `div` elements laid out in columns by the stylesheet, or an image. In the first case there is no specification to read and the page’s own selectors are the authority; in the second, the question becomes character recognition, and that is another entry.
The four options
- RecommendedN0The grid the reader sees, with the spans spread out and flaggedFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is nothing to learn: tr, td, th, colspan and rowspan are named by the HTML specification, and the algorithm that turns them into a grid is written there. A model trained on annotated pages would recover, at best, what that algorithm gives exactly, and it would be wrong on the layouts its sample did not contain.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
Table structure recognition models exist, and they are useful — on images. That is the subject of the entry on tables in a PDF, where there is sometimes no structure to read at all. Here the structure is written down: rendering the page to an image to have a model read it back would mean throwing away what the page declares in order to guess it again.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
A general-purpose model returns a table that resembles the page’s, and the resemblance is paid for exactly where this entry is careful: on merged cells it chooses — repeat, or leave blank — without saying so. Add to that one bill per page and one data egress, for work your language’s parser does in a few milliseconds.
Further reading
- HTML Standard — l’algorithme qui forme un tableau à partir du balisage
- HTML Standard — les attributs colspan et rowspan, et leur maximum
- Extraire les tableaux d’un PDF — la même question, là où la structure n’est pas écrite
- linkedom — le moteur de document léger employé côté JavaScript
Where the figures come from
- HTML Standard, « forming a table » (consulté) : l’algorithme qui déploie les portées sur une grille, et qui traite un tableau imbriqué comme un tableau à part
- HTML Standard, attributs de td et th (consulté) : `colspan` « must be a valid non-negative integer greater than zero and less than or equal to 1000 » — d’où le plafond employé ici
- Python, module html.parser (consulté) : l’analyseur de la bibliothèque standard employé côté Python ; il ne construit pas d’arbre, ce que ce niveau fait lui-même pour les seuls éléments de tableau
- linkedom sur son dépôt (consulté) : version 0.18.13, licence ISC ; il fournit le document dont l’extrait JavaScript a besoin hors navigateur
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.