DEV Community

Cover image for De Verborgen Complexiteit van HTML-Tabellen (Waarom Parsen Moeilijker Is Dan Je Denkt)
circobit
circobit

Posted on

De Verborgen Complexiteit van HTML-Tabellen (Waarom Parsen Moeilijker Is Dan Je Denkt)

HTML-tabellen zien er simpel uit. <table>, <tr>, <td>. Wat kan er misgaan?

Na het bouwen van HTML Table Exporter, een tabelexport-tool die duizenden real-world tabellen heeft verwerkt, kan ik je vertellen: heel veel. Dit artikel behandelt de edge cases die naïeve parsers breken en hoe je ze aanpakt.

De Bedrieglijk Eenvoudige Case

Een perfecte tabel ziet er zo uit:

<table>
  <thead>
    <tr>
      <th>Naam</th>
      <th>Omzet</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Acme Inc</td>
      <td>€1,2M</td>
    </tr>
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

Dit parsen is triviaal:

const rows = table.querySelectorAll('tr');
const data = [...rows].map(row => 
  [...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
Enter fullscreen mode Exit fullscreen mode

Klaar, toch? Nog lang niet.

Probleem 1: Samengevoegde Cellen (colspan/rowspan)

Echte tabellen hebben samengevoegde cellen. Heel veel.

<tr>
  <td rowspan="3">Q1 2024</td>
  <td>Januari</td>
  <td>€100k</td>
</tr>
<tr>
  <td>Februari</td>
  <td>€120k</td>
</tr>
<tr>
  <td>Maart</td>
  <td>€90k</td>
</tr>
Enter fullscreen mode Exit fullscreen mode

Als je dit naïef parset, krijg je:

Rij 1: ["Q1 2024", "Januari", "€100k"]
Rij 2: ["Februari", "€120k"]          // Eerste kolom ontbreekt!
Rij 3: ["Maart", "€90k"]              // Eerste kolom ontbreekt!
Enter fullscreen mode Exit fullscreen mode

De Oplossing: Bouw een Positiematrix

Je moet bijhouden welke cellen "bezet" zijn door rowspans van vorige rijen:

function parseTableWithMergedCells(table) {
  const rows = table.querySelectorAll('tr');
  const matrix = [];
  const rowspanTracker = []; // Actieve rowspans per kolom bijhouden

  rows.forEach((row, rowIndex) => {
    matrix[rowIndex] = [];
    let colIndex = 0;

    // Kolommen overslaan die bezet zijn door eerdere rowspans
    while (rowspanTracker[colIndex] > 0) {
      matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
      rowspanTracker[colIndex]--;
      colIndex++;
    }

    row.querySelectorAll('td, th').forEach(cell => {
      // Bezette kolommen overslaan
      while (rowspanTracker[colIndex] > 0) {
        matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
        rowspanTracker[colIndex]--;
        colIndex++;
      }

      const colspan = parseInt(cell.getAttribute('colspan')) || 1;
      const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
      const value = cell.textContent.trim();

      // Colspan vullen
      for (let c = 0; c < colspan; c++) {
        matrix[rowIndex][colIndex] = value;

        // Rowspan bijhouden voor toekomstige rijen
        if (rowspan > 1) {
          rowspanTracker[colIndex] = rowspan - 1;
        }
        colIndex++;
      }
    });
  });

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Dit is vereenvoudigd—de echte implementatie moet ook geneste rowspans binnen colspans verwerken, en dat wordt al snel complex.

Probleem 2: Tabellen Die Geen Datatabellen Zijn

Niet elke <table> bevat data. Veel sites (ja, nog steeds in 2024) gebruiken tabellen voor layout:

<table>
  <tr>
    <td><nav>Menu hier</nav></td>
    <td><main>Content hier</main></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Of voor formulieren:

<table>
  <tr>
    <td><label>E-mail:</label></td>
    <td><input type="email"></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

De Oplossing: Heuristieken

Ik gebruik meerdere signalen om "echte" datatabellen te detecteren:

function isDataTable(table) {
  const rows = table.querySelectorAll('tr');
  const cells = table.querySelectorAll('td, th');

  // Te weinig rijen of cellen
  if (rows.length < 2 || cells.length < 4) return false;

  // Bevat formulierelementen (waarschijnlijk formulierlayout)
  if (table.querySelector('input, select, textarea, button')) return false;

  // Voornamelijk navigatielinks
  const links = table.querySelectorAll('a');
  const textContent = table.textContent.length;
  const linkText = [...links].reduce((sum, a) => sum + a.textContent.length, 0);
  if (linkText / textContent > 0.7) return false;

  // Kolomconsistentie controleren
  const colCounts = [...rows].map(row => 
    row.querySelectorAll('td, th').length
  );
  const variance = Math.max(...colCounts) - Math.min(...colCounts);
  if (variance > 3) return false; // Inconsistente kolommen = waarschijnlijk layout

  return true;
}
Enter fullscreen mode Exit fullscreen mode

Geen van deze is perfect. Er zullen altijd edge cases zijn.

Probleem 3: Verborgen Content

Cellen bevatten vaak meer dan zichtbare tekst:

<td>
  <span class="value">1.234</span>
  <span class="sort-key" style="display:none">1234</span>
</td>
Enter fullscreen mode Exit fullscreen mode

Wikipedia doet dit veel voor sorteerbare tabellen. Als je gewoon textContent pakt, krijg je "1.234 1234".

De Oplossing: Alleen Zichtbare Tekst Extraheren

function getVisibleText(element) {
  // Kloon om het origineel niet aan te passen
  const clone = element.cloneNode(true);

  // Verborgen elementen verwijderen
  clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());

  // Ook computed style controleren voor dynamisch verborgen elementen
  // (duurder, spaarzaam gebruiken)

  return clone.textContent.trim();
}
Enter fullscreen mode Exit fullscreen mode

Probleem 4: Getallen Die Geen Getallen Zijn

"€1.234,56" is een getal. Net als "1,234.56" (Amerikaans formaat). Net als "(1.234)" (boekhoudkundig negatief). Net als "1.234 M" (met suffix).

Je spreadsheet heeft echte getallen nodig om te rekenen.

De Oplossing: Lokalisatiebewust Parsen

function parseNumber(value) {
  if (!value || typeof value !== 'string') return value;

  // Valutasymbolen en witruimte verwijderen
  let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();

  // Boekhoudkundig negatief: (1.234) -> -1234
  if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
    cleaned = '-' + cleaned.slice(1, -1);
  }

  // Suffixen: 1,5M, 2,3B, 100K
  const suffixes = { 'K': 1e3, 'M': 1e6, 'B': 1e9, 'T': 1e12 };
  const suffixMatch = cleaned.match(/([0-9.,]+)\s*([KMBT])$/i);
  if (suffixMatch) {
    cleaned = suffixMatch[1];
    var multiplier = suffixes[suffixMatch[2].toUpperCase()];
  }

  // Europees vs Amerikaans formaat detecteren
  // Europees: 1.234,56 (punt voor duizendtallen, komma voor decimalen)
  // Amerikaans: 1,234.56 (komma voor duizendtallen, punt voor decimalen)
  const lastComma = cleaned.lastIndexOf(',');
  const lastDot = cleaned.lastIndexOf('.');

  if (lastComma > lastDot && lastComma > cleaned.length - 4) {
    // Europees formaat
    cleaned = cleaned.replace(/\./g, '').replace(',', '.');
  } else {
    // Amerikaans formaat
    cleaned = cleaned.replace(/,/g, '');
  }

  let num = parseFloat(cleaned);
  if (multiplier) num *= multiplier;

  return isNaN(num) ? value : num;
}
Enter fullscreen mode Exit fullscreen mode

Dit vangt misschien 90% van de gevallen op. De overige 10% zullen je verrassen.

Probleem 5: Tekencodering-Hel

Je zou denken dat UTF-8 dit heeft opgelost. Dat klopt niet.

Echte tabellen bevatten:

  • Vaste spaties (&nbsp; / \u00A0) die eruitzien als spaties maar dat niet zijn
  • Zero-width tekens die stringvergelijking breken
  • Windows-1252 tekens die verminkt zijn naar UTF-8
  • Emoji die oudere parsers breken
  • Rechts-naar-links markeringen in meertalige tabellen

De Oplossing: Normaliseer Alles

function normalizeText(text) {
  return text
    // Unicode normaliseren (composed vs decomposed tekens)
    .normalize('NFC')
    // Vaste spaties vervangen door reguliere spaties
    .replace(/\u00A0/g, ' ')
    // Zero-width tekens verwijderen
    .replace(/[\u200B-\u200D\uFEFF]/g, '')
    // Witruimte normaliseren
    .replace(/\s+/g, ' ')
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

En bij het exporteren naar CSV voor Excel, de UTF-8 BOM toevoegen:

const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
Enter fullscreen mode Exit fullscreen mode

Zonder de BOM kan Excel je UTF-8 bestand interpreteren als Windows-1252 en speciale tekens verminken.

Probleem 6: Geneste Tabellen

Ja, tabellen in tabellen. Meestal voor layout, maar soms voor data:

<table>
  <tr>
    <td>Product A</td>
    <td>
      <table>
        <tr><td>Maat S</td><td>€10</td></tr>
        <tr><td>Maat M</td><td>€12</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

De Oplossing: Kies Je Strategie

Opties:

  1. Platslaan: Geneste tabel omzetten naar tekst ("Maat S: €10, Maat M: €12")
  2. Apart extraheren: Geneste tabellen als afzonderlijke exports behandelen
  3. Rijen uitbreiden: Meerdere bovenliggende rijen aanmaken, één per geneste rij

Ik koos voor optie 2 (apart extraheren) met optie 1 als fallback voor diep geneste gevallen. Er is geen perfect antwoord—het hangt af van de use case.

De Realiteit

Na al deze cases te hebben afgehandeld, is mijn tabelparser ~800 regels JavaScript. En hij vangt nog steeds niet alles perfect op.

Een paar harde waarheden:

  • Geen parser is perfect. Real-world HTML is rommelig.
  • Heuristieken falen. Je hebt altijd nooduitgangen voor gebruikers nodig.
  • Performance telt. Sommige pagina's hebben meer dan 50 tabellen. Parsen moet snel zijn.
  • Edge cases zijn oneindig. Ship iets dat werkt voor 95% van de gevallen, en itereer daarna.

Tools en Bronnen

Als je iets vergelijkbaars bouwt:

  • SheetJS (xlsx) - Solide bibliotheek voor het genereren van Excel-bestanden
  • Papa Parse - Snelle CSV-parsing en -generatie
  • Chrome DevTools - $('table') in de console om tabellen snel te inspecteren

Of als je gewoon tabellen wilt exporteren zonder iets te bouwen: ik heb HTML Table Exporter gemaakt, specifiek omdat ik het zat was om eenmalige scrapers te schrijven. De extensie handelt alle bovenstaande edge cases af.

Meer informatie op gauchogrid.com/nl/html-table-exporter of probeer het gratis in de Chrome Web Store.


Welke rare tabel-edge-cases ben jij tegengekomen? Ik ben altijd op zoek naar nieuwe testcases om mijn parser te breken.

Top comments (0)