DEV Community

Cover image for Die versteckte Komplexität von HTML-Tabellen (Warum das Parsen schwieriger ist, als man denkt)
circobit
circobit

Posted on

Die versteckte Komplexität von HTML-Tabellen (Warum das Parsen schwieriger ist, als man denkt)

HTML-Tabellen sehen einfach aus. <table>, <tr>, <td>. Was soll da schiefgehen?

Nachdem ich HTML Table Exporter gebaut habe – ein Tabellenexport-Tool, das Tausende von Real-World-Tabellen verarbeitet hat – kann ich sagen: eine ganze Menge. Dieser Beitrag behandelt die Edge Cases, die naive Parser zum Scheitern bringen, und wie man damit umgeht.

Der trügerisch einfache Fall

Eine perfekte Tabelle sieht so aus:

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Umsatz</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Acme GmbH</td>
      <td>1,2 Mio. €</td>
    </tr>
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

Das Parsen ist trivial:

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

Fertig, oder? Nicht mal annähernd.

Problem 1: Verbundene Zellen (colspan/rowspan)

Echte Tabellen haben verbundene Zellen. Sehr viele davon.

<tr>
  <td rowspan="3">Q1 2024</td>
  <td>Januar</td>
  <td>100.000 €</td>
</tr>
<tr>
  <td>Februar</td>
  <td>120.000 €</td>
</tr>
<tr>
  <td>März</td>
  <td>90.000 €</td>
</tr>
Enter fullscreen mode Exit fullscreen mode

Beim naiven Parsen erhält man:

Zeile 1: ["Q1 2024", "Januar", "100.000 €"]
Zeile 2: ["Februar", "120.000 €"]          // Erste Spalte fehlt!
Zeile 3: ["März", "90.000 €"]              // Erste Spalte fehlt!
Enter fullscreen mode Exit fullscreen mode

Die Lösung: Eine Positionsmatrix aufbauen

Man muss nachverfolgen, welche Zellen durch Rowspans aus vorherigen Zeilen „belegt" sind:

function parseTableWithMergedCells(table) {
  const rows = table.querySelectorAll('tr');
  const matrix = [];
  const rowspanTracker = []; // Aktive Rowspans pro Spalte tracken

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

    // Spalten überspringen, die durch vorherige Rowspans belegt sind
    while (rowspanTracker[colIndex] > 0) {
      matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
      rowspanTracker[colIndex]--;
      colIndex++;
    }

    row.querySelectorAll('td, th').forEach(cell => {
      // Belegte Spalten überspringen
      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 füllen
      for (let c = 0; c < colspan; c++) {
        matrix[rowIndex][colIndex] = value;

        // Rowspan für zukünftige Zeilen tracken
        if (rowspan > 1) {
          rowspanTracker[colIndex] = rowspan - 1;
        }
        colIndex++;
      }
    });
  });

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Das ist vereinfacht – die echte Implementierung muss auch verschachtelte Rowspans innerhalb von Colspans handhaben, was schnell unübersichtlich wird.

Problem 2: Tabellen, die keine Datentabellen sind

Nicht jedes <table> enthält Daten. Viele Websites (ja, auch heute noch) verwenden Tabellen fürs Layout:

<table>
  <tr>
    <td><nav>Menü hier</nav></td>
    <td><main>Inhalt hier</main></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Oder für Formulare:

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

Die Lösung: Heuristiken

Ich verwende mehrere Signale, um „echte" Datentabellen zu erkennen:

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

  // Zu wenige Zeilen oder Zellen
  if (rows.length < 2 || cells.length < 4) return false;

  // Enthält Formularelemente (wahrscheinlich ein Formular-Layout)
  if (table.querySelector('input, select, textarea, button')) return false;

  // Überwiegend Navigationslinks
  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;

  // Spaltenkonsistenz prüfen
  const colCounts = [...rows].map(row => 
    row.querySelectorAll('td, th').length
  );
  const variance = Math.max(...colCounts) - Math.min(...colCounts);
  if (variance > 3) return false; // Inkonsistente Spalten = wahrscheinlich Layout

  return true;
}
Enter fullscreen mode Exit fullscreen mode

Keine dieser Heuristiken ist perfekt. Edge Cases wird es immer geben.

Problem 3: Versteckte Inhalte

Zellen enthalten oft mehr als den sichtbaren Text:

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

Wikipedia macht das häufig bei sortierbaren Tabellen. Wenn man einfach textContent nimmt, bekommt man „1.234 1234".

Die Lösung: Nur sichtbaren Text extrahieren

function getVisibleText(element) {
  // Klonen, um das Original nicht zu verändern
  const clone = element.cloneNode(true);

  // Versteckte Elemente entfernen
  clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());

  // Optional: computed style für dynamisch versteckte Elemente prüfen
  // (aufwändiger, sparsam einsetzen)

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

Problem 4: Zahlen, die keine Zahlen sind

„1.234,56 €" ist eine Zahl. „$1,234.56" (US-Format) ebenso. „(1.234)" (buchhalterisch negativ) auch. Und „1.234 Mio." (mit Suffix) auch.

Die Tabellenkalkulation braucht echte Zahlen zum Rechnen.

Die Lösung: Locale-bewusstes Parsen

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

  // Währungssymbole und Whitespace entfernen
  let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();

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

  // Suffixe: 1,5M, 2,3Mrd, 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()];
  }

  // Europäisches vs. US-Format erkennen
  // Europäisch: 1.234,56 (Punkt für Tausender, Komma für Dezimal)
  // US: 1,234.56 (Komma für Tausender, Punkt für Dezimal)
  const lastComma = cleaned.lastIndexOf(',');
  const lastDot = cleaned.lastIndexOf('.');

  if (lastComma > lastDot && lastComma > cleaned.length - 4) {
    // Europäisches Format
    cleaned = cleaned.replace(/\./g, '').replace(',', '.');
  } else {
    // US-Format
    cleaned = cleaned.replace(/,/g, '');
  }

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

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

Das deckt vielleicht 90 % der Fälle ab. Die restlichen 10 % werden dich überraschen.

Problem 5: Zeichenkodierungs-Hölle

Man sollte meinen, UTF-8 hätte das gelöst. Hat es nicht.

Echte Tabellen enthalten:

  • Geschützte Leerzeichen (&nbsp; / \u00A0), die wie Leerzeichen aussehen, aber keine sind
  • Zero-Width-Zeichen, die String-Vergleiche kaputt machen
  • Windows-1252-Zeichen, die beim Konvertieren zu UTF-8 verstümmelt wurden
  • Emoji, die ältere Parser zum Absturz bringen
  • Right-to-Left-Markierungen in mehrsprachigen Tabellen

Die Lösung: Alles normalisieren

function normalizeText(text) {
  return text
    // Unicode normalisieren (zusammengesetzte vs. zerlegte Zeichen)
    .normalize('NFC')
    // Geschützte Leerzeichen durch reguläre ersetzen
    .replace(/\u00A0/g, ' ')
    // Zero-Width-Zeichen entfernen
    .replace(/[\u200B-\u200D\uFEFF]/g, '')
    // Whitespace normalisieren
    .replace(/\s+/g, ' ')
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

Und beim Export als CSV für Excel den UTF-8 BOM voranstellen:

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

Ohne BOM interpretiert Excel deine UTF-8-Datei möglicherweise als Windows-1252 und verstümmelt Sonderzeichen.

Problem 6: Verschachtelte Tabellen

Ja, Tabellen in Tabellen. Meist fürs Layout, aber manchmal auch für Daten:

<table>
  <tr>
    <td>Produkt A</td>
    <td>
      <table>
        <tr><td>Größe S</td><td>10 €</td></tr>
        <tr><td>Größe M</td><td>12 €</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Die Lösung: Strategie wählen

Optionen:

  1. Flatten: Verschachtelte Tabelle zu Text konvertieren („Größe S: 10 €, Größe M: 12 €")
  2. Separat extrahieren: Verschachtelte Tabellen als eigene Exporte behandeln
  3. Zeilen erweitern: Mehrere Elternzeilen erstellen, eine pro verschachtelter Zeile

Ich habe mich für Option 2 (separat extrahieren) entschieden, mit Option 1 als Fallback für tief verschachtelte Fälle. Es gibt keine perfekte Lösung – es hängt vom Anwendungsfall ab.

Die Realität

Nach der Behandlung all dieser Fälle umfasst mein Tabellen-Parser circa 800 Zeilen JavaScript. Und er handhabt trotzdem nicht alles perfekt.

Ein paar ehrliche Erkenntnisse:

  • Kein Parser ist perfekt. Real-World-HTML ist chaotisch.
  • Heuristiken versagen. Man braucht immer Notausgänge für Nutzer.
  • Performance zählt. Manche Seiten haben 50+ Tabellen. Das Parsen muss schnell sein.
  • Edge Cases sind unendlich. Liefere etwas, das für 95 % der Fälle funktioniert, dann iteriere.

Tools und Ressourcen

Wenn du etwas Ähnliches baust:

  • SheetJS (xlsx) – Solide Bibliothek zum Erzeugen von Excel-Dateien
  • Papa Parse – Schnelles CSV-Parsen und -Erzeugen
  • Chrome DevTools$('table') in der Konsole zum schnellen Inspizieren von Tabellen

Für eine Schritt-für-Schritt-Anleitung zum Tabellenexport, siehe unseren Leitfaden zur besten Chrome-Erweiterung zum Kopieren von Tabellen nach Excel.

Oder wenn du einfach nur Tabellen exportieren willst, ohne selbst etwas zu bauen: Ich habe HTML Table Exporter genau deshalb entwickelt, weil ich es leid war, Einmal-Scraper zu schreiben. Es behandelt alle oben genannten Edge Cases.

Mehr erfahren auf gauchogrid.com/de/html-table-exporter oder kostenlos im Chrome Web Store ausprobieren.


Welche merkwürdigen Tabellen-Edge-Cases sind dir begegnet? Ich bin immer auf der Suche nach neuen Testfällen, um meinen Parser zu brechen.

Top comments (0)