/* ===================================================================== EXCEL-DATEIEN LESEN — ohne fremdes Paket, mit Bordmitteln (14.09.2026) Filipe: "mach doch bitte so dass man alle dateien hoch laden könne auch excel dateien. gib gas und krieg das hin." --------------------------------------------------------------------- WARUM SELBST GEBAUT UND NICHT `npm i xlsx` Eine .xlsx ist ein ZIP-Archiv mit XML darin. Node kann beides von Haus aus: `zlib.inflateRawSync` packt aus, der Rest ist Textarbeit. Ein Paket dafür wäre eine Abhängigkeit mehr, die gepflegt, geprüft und aktualisiert werden will -- für eine Datei mit neun Zeilen. GEMESSEN AN DEN ECHTEN DATEIEN, nicht an der Dokumentation: Zwei Backstage-Ausgaben vom 11. und 12.09.2026 lagen vor. Was sie zeigen: * 12 Einträge im ZIP, alle mit Verfahren 8 (Deflate). * Ein einziges Blatt. * ALLE Zellen sind `t="s"` -- Backstage legt sogar Zahlen als Zeichenkette ab. Datums-Seriennummern kommen darin also gar nicht vor. (Der Leser kann sie trotzdem, siehe unten: Sobald jemand die Datei in Excel öffnet und speichert, sind sie da.) * Bei "Creator:innen verwalten" steht in Zeile 1 nur ein Titel ("Exportiert am :…"), die Überschriften stehen in Zeile 2. --------------------------------------------------------------------- WAS DIESER LESER NICHT TUT Er formatiert nicht. Eine Zelle wird zu dem Text, der darin steht -- Formeln werden mit ihrem zuletzt berechneten Wert gelesen, nicht ausgerechnet. Das ist Absicht: Wer eine Tabelle mit Formeln hochlädt, will die Zahlen, die er selbst gesehen hat. ===================================================================== */ import { inflateRawSync } from "node:zlib"; /* Ein ZIP-Eintrag, wie ihn das Zentralverzeichnis beschreibt. Gelesen wird IMMER über das Zentralverzeichnis am Dateiende und nie durch Vorwärtslaufen über die lokalen Köpfe: Bei einem Archiv mit Datendeskriptoren (Bit 3 im Merker) steht die gepackte Länge im lokalen Kopf auf 0, und wer sich darauf verlässt, liest nichts. */ const ENDE = 0x06054b50; // "PK\x05\x06" const ZENTRAL = 0x02014b50; // "PK\x01\x02" /** Fehler mit Grund -- der DRITTE AUSGANG, nicht ein stilles `null`. * Wer eine kaputte Datei hochlädt, soll erfahren, WAS daran kaputt * ist; "ging nicht" schickt ihn sonst auf die Suche bei sich selbst. */ export class XlsxFehler extends Error { constructor(text) { super(text); this.name = "XlsxFehler"; } } /** Ist das überhaupt eine ZIP-Datei? Am INHALT erkannt, nicht am Namen. * Eine umbenannte Datei ist keine andere Datei. */ export function istZip(puffer) { return puffer?.length >= 4 && puffer[0] === 0x50 && puffer[1] === 0x4b && (puffer[2] === 0x03 || puffer[2] === 0x05 || puffer[2] === 0x07); } /** Alte .xls (BIFF, vor 2007) -- ein ganz anderes Format, kein ZIP. * Wird nicht gelesen, aber ERKANNT, damit die Meldung stimmt. */ export function istAltesExcel(puffer) { return puffer?.length >= 8 && puffer[0] === 0xd0 && puffer[1] === 0xcf && puffer[2] === 0x11 && puffer[3] === 0xe0; } /** Das Archiv auspacken -- Name -> Inhalt (Buffer). */ function zipLesen(puffer) { /* Das Ende-Kennzeichen steht hinten und kann bis zu 64 KB Kommentar hinter sich haben. Deshalb rückwärts suchen, nicht raten. */ let ende = -1; const frueheste = Math.max(0, puffer.length - 22 - 65535); for (let i = puffer.length - 22; i >= frueheste; i--) { if (puffer.readUInt32LE(i) === ENDE) { ende = i; break; } } if (ende < 0) throw new XlsxFehler("Das ist keine gültige Excel-Datei (ZIP-Ende fehlt)."); const anzahl = puffer.readUInt16LE(ende + 10); let stelle = puffer.readUInt32LE(ende + 16); if (anzahl === 0xffff || stelle === 0xffffffff) { throw new XlsxFehler("Die Datei ist zu groß für dieses Format (ZIP64)."); } const dateien = new Map(); for (let i = 0; i < anzahl; i++) { if (stelle + 46 > puffer.length || puffer.readUInt32LE(stelle) !== ZENTRAL) { throw new XlsxFehler("Das Inhaltsverzeichnis der Datei ist beschädigt."); } const verfahren = puffer.readUInt16LE(stelle + 10); const gepackt = puffer.readUInt32LE(stelle + 20); const nameLang = puffer.readUInt16LE(stelle + 28); const extraLang = puffer.readUInt16LE(stelle + 30); const kommLang = puffer.readUInt16LE(stelle + 32); const lokal = puffer.readUInt32LE(stelle + 42); const name = puffer.subarray(stelle + 46, stelle + 46 + nameLang).toString("utf8"); /* Die Längen im LOKALEN Kopf gelten, nicht die im Zentralverzeichnis: Nur dort steht, wie lang Name und Extrafeld an DIESER Stelle wirklich sind -- sie dürfen sich unterscheiden. */ const lNameLang = puffer.readUInt16LE(lokal + 26); const lExtraLang = puffer.readUInt16LE(lokal + 28); const von = lokal + 30 + lNameLang + lExtraLang; const roh = puffer.subarray(von, von + gepackt); /* Nur die XML-Teile werden gebraucht. Bilder (Backstage legt ein 19-KB-Logo bei) bleiben ungepackt liegen -- das spart bei jeder Datei die Arbeit und kann nicht schiefgehen. */ if (name.endsWith(".xml") || name.endsWith(".rels")) { try { dateien.set(name, verfahren === 0 ? roh : inflateRawSync(roh)); } catch { throw new XlsxFehler(`Ein Teil der Datei ließ sich nicht entpacken (${name}).`); } } stelle += 46 + nameLang + extraLang + kommLang; } return dateien; } /* ---------- XML, so viel wie nötig ------------------------------------ */ const ENTITAETEN = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }; /** Entschärfte Zeichen zurückholen. `ä` und `ä` kommen in * Exporten mit Umlauten wirklich vor -- ohne sie stünde in der * Vorschau "Gltige LIVE-Gehen-Tage". */ function entschluesseln(text) { if (!text.includes("&")) return text; return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (ganz, inhalt) => { if (inhalt[0] === "#") { const n = inhalt[1] === "x" || inhalt[1] === "X" ? parseInt(inhalt.slice(2), 16) : parseInt(inhalt.slice(1), 10); return Number.isFinite(n) ? String.fromCodePoint(n) : ganz; } return ENTITAETEN[inhalt] ?? ganz; }); } /** Die Zeichenkettentabelle. Ein Eintrag kann aus mehreren Stücken * bestehen (``-Läufe, wenn Teile anders formatiert sind) -- die * gehören zusammengesetzt, sonst fehlt die Hälfte des Namens. */ function zeichenkettenLesen(dateien) { const roh = dateien.get("xl/sharedStrings.xml"); if (!roh) return []; const text = roh.toString("utf8"); const liste = []; for (const eintrag of text.matchAll(/]*)?>([\s\S]*?)<\/si>/g)) { let stueck = ""; for (const t of eintrag[1].matchAll(/]*)?>([\s\S]*?)<\/t>/g)) { stueck += entschluesseln(t[1]); } liste.push(stueck); } return liste; } /* ---------- Datum: nur, wenn das Format es sagt ----------------------- */ /* Die eingebauten Zahlenformate, die ein Datum oder eine Uhrzeit sind. Sie stehen in der Norm (ECMA-376) und nicht in der Datei -- deshalb hier, und deshalb als Liste und nicht als Bereich: 23 bis 44 sind KEINE Datumsformate, ein `>= 14 && <= 47` wäre falsch. */ const DATUMS_IDS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]); /** Welche Zellvorlage zeigt ein Datum? Ergebnis: Menge von Vorlagen- * nummern (`s`-Attribut der Zelle). * * WARUM DAS SEIN MUSS: In der Datei steht bei einem Datum die Zahl * 46264. Ohne diese Zuordnung landet genau das in der Vorschau -- eine * Zahl, die aussieht wie eine Zahl und ein Datum ist. */ function datumsVorlagen(dateien) { const roh = dateien.get("xl/styles.xml"); const vorlagen = new Set(); if (!roh) return vorlagen; const text = roh.toString("utf8"); /* Eigene Formate: Ein Format ist ein Datum, wenn darin d/m/y/h/s außerhalb von Anführungszeichen vorkommen. Die Anführungszeichen müssen weg, sonst macht `"Stand:"` aus jedem Format ein Datum. */ const eigene = new Set(); for (const m of text.matchAll(/]*numFmtId="(\d+)"[^>]*formatCode="([^"]*)"/g)) { const code = entschluesseln(m[2]).replace(/"[^"]*"/g, "").replace(/\\./g, ""); if (/[dmyhs]/i.test(code)) eigene.add(Number(m[1])); } const block = /]*>([\s\S]*?)<\/cellXfs>/.exec(text); if (!block) return vorlagen; let nummer = 0; for (const xf of block[1].matchAll(/]*>/g)) { const id = Number(/numFmtId="(\d+)"/.exec(xf[0])?.[1] ?? 0); if (DATUMS_IDS.has(id) || eigene.has(id)) vorlagen.add(nummer); nummer++; } return vorlagen; } /** Excel-Seriennummer -> "JJJJ-MM-TT" (oder mit Uhrzeit). * * DER SPRUNGTAG, DEN ES NIE GAB: Excel hält 1900 für ein Schaltjahr. * Deshalb ist der Nullpunkt der 30.12.1899 und nicht der 31.12. -- * damit stimmen alle Tage ab dem 01.03.1900, und das sind alle, die * hier je vorkommen. */ function ausSeriennummer(zahl, seit1904) { const basis = seit1904 ? Date.UTC(1904, 0, 1) : Date.UTC(1899, 11, 30); const ms = basis + Math.round(zahl * 86400 * 1000); const d = new Date(ms); if (!Number.isFinite(d.getTime())) return String(zahl); const p = (n) => String(n).padStart(2, "0"); const datum = `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; const rest = zahl - Math.floor(zahl); if (rest < 1 / 86400) return datum; return `${datum} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`; } /* ---------- Spaltenname -> Nummer ------------------------------------- */ /** "A" -> 0, "Z" -> 25, "AA" -> 26. * Gebraucht, weil leere Zellen in der Datei FEHLEN: Eine Zeile kann * A, B, dann D enthalten. Wer stur der Reihe nach einliest, verschiebt * ab dort jede Spalte -- und merkt es nicht, weil die Zeile voll * aussieht. */ function spalteAus(bezug) { const m = /^([A-Z]+)/.exec(bezug || ""); if (!m) return -1; let n = 0; for (const z of m[1]) n = n * 26 + (z.charCodeAt(0) - 64); return n - 1; } /* ---------- Das Blatt ------------------------------------------------- */ /** Welches Blatt ist das erste? Der Name kommt aus workbook.xml, der * Dateiname aus den Beziehungen. Fällt eines davon aus, wird auf * sheet1.xml zurückgefallen -- mit Vermerk, nicht stillschweigend. */ function erstesBlatt(dateien) { const mappe = dateien.get("xl/workbook.xml")?.toString("utf8") || ""; const bez = dateien.get("xl/_rels/workbook.xml.rels")?.toString("utf8") || ""; const eintrag = /]*>/.exec(mappe)?.[0] || ""; const name = entschluesseln(/name="([^"]*)"/.exec(eintrag)?.[1] || "Tabelle1"); const rid = /r:id="([^"]*)"/.exec(eintrag)?.[1]; let pfad = null; if (rid) { const ziel = new RegExp(`]*Id="${rid}"[^>]*Target="([^"]*)"`).exec(bez)?.[1]; if (ziel) pfad = ziel.startsWith("/") ? ziel.slice(1) : `xl/${ziel.replace(/^\.\//, "")}`; } if (!pfad || !dateien.has(pfad)) pfad = "xl/worksheets/sheet1.xml"; return { name, pfad }; } /** * Eine .xlsx in Zeilen aus Text verwandeln. * * @param {Buffer} puffer die Datei * @returns {{ blatt: string, zeilen: string[][] }} */ export function xlsxLesen(puffer) { if (istAltesExcel(puffer)) { throw new XlsxFehler( "Das ist eine alte Excel-Datei (.xls). Speichere sie in Excel einmal " + "als .xlsx oder als CSV – dann geht sie durch."); } if (!istZip(puffer)) throw new XlsxFehler("Das ist keine Excel-Datei."); const dateien = zipLesen(puffer); const { name, pfad } = erstesBlatt(dateien); const roh = dateien.get(pfad); if (!roh) throw new XlsxFehler("In der Datei ist kein Tabellenblatt zu finden."); const texte = zeichenkettenLesen(dateien); const datumsZellen = datumsVorlagen(dateien); const seit1904 = /date1904="(1|true)"/.test( dateien.get("xl/workbook.xml")?.toString("utf8") || ""); const blatt = roh.toString("utf8"); const zeilen = []; for (const zeile of blatt.matchAll(/]*>([\s\S]*?)<\/row>/g)) { const felder = []; for (const zelle of zeile[1].matchAll(/]*)(?:\/>|>([\s\S]*?)<\/c>)/g)) { const kopf = zelle[1]; const inhalt = zelle[2] || ""; const spalte = spalteAus(/r="([A-Z]+\d+)"/.exec(kopf)?.[1]); const typ = /t="([^"]*)"/.exec(kopf)?.[1] || "n"; const vorlage = Number(/s="(\d+)"/.exec(kopf)?.[1] ?? -1); let wert = ""; if (typ === "s") { const i = Number(/]*)?>([\s\S]*?)<\/v>/.exec(inhalt)?.[1] ?? -1); wert = texte[i] ?? ""; } else if (typ === "inlineStr") { for (const t of inhalt.matchAll(/]*)?>([\s\S]*?)<\/t>/g)) { wert += entschluesseln(t[1]); } } else { const roh2 = /]*)?>([\s\S]*?)<\/v>/.exec(inhalt)?.[1]; wert = roh2 === undefined ? "" : entschluesseln(roh2); if (typ === "b") wert = wert === "1" ? "WAHR" : "FALSCH"; else if (typ === "e") wert = wert; // #DIV/0! usw. bleibt stehen else if (typ === "n" && wert !== "" && datumsZellen.has(vorlage) && Number.isFinite(Number(wert))) { wert = ausSeriennummer(Number(wert), seit1904); } } /* An die RICHTIGE Stelle, nicht ans Ende. Fehlende Zellen werden zu leeren Feldern -- siehe spalteAus(). */ if (spalte >= 0) { while (felder.length < spalte) felder.push(""); felder[spalte] = wert; } else { felder.push(wert); } } zeilen.push(felder); } return { blatt: name, zeilen }; } /* ===================================================================== WO FÄNGT DIE TABELLE AN? Backstages Ausgabe "Creator:innen verwalten" hat in Zeile 1 nur "Exportiert am :2026-09-11 13:10:35" -- eine einzige Zelle. Die Überschriften stehen in Zeile 2. Wer stur die erste Zeile als Überschrift nimmt, bekommt eine Tabelle mit einer Spalte und meldet "keine Diamanten-Spalte gefunden": eine richtige Meldung auf eine falsche Fährte. DIE REGEL MISST, SIE RÄT NICHT: Überschrift ist die erste Zeile, die mindestens drei gefüllte Felder hat UND mindestens halb so breit ist wie die breiteste Zeile der Datei. Eine Titelzeile ist damit sicher draußen, eine echte schmale Tabelle (drei Spalten) sicher drin. ===================================================================== */ export function tabelleAbKopf(zeilen) { const gefuellt = (z) => z.filter((x) => String(x ?? "").trim() !== "").length; const breiteste = zeilen.reduce((a, z) => Math.max(a, gefuellt(z)), 0); if (breiteste === 0) return { ab: 0, uebersprungen: 0, zeilen: [] }; let ab = 0; for (let i = 0; i < zeilen.length; i++) { const n = gefuellt(zeilen[i]); if (n >= 3 && n * 2 >= breiteste) { ab = i; break; } /* Nur führende Zeilen dürfen wegfallen. Findet sich gar keine passende, bleibt es bei Zeile 0 -- lieber eine schlechte Überschrift als eine verschluckte Tabelle. */ } return { ab, uebersprungen: ab, zeilen: zeilen.slice(ab) }; } /** Zeilen -> TAB-getrennter Text. * * WARUM TAB UND WARUM ÜBERHAUPT TEXT: Dahinter liegt `csvZerlegen`, * seit dem 11.09.2026 auf Tabulatoren eingerichtet und mit 77 * Prüfungen abgesichert. Eine zweite Zerlegung für Excel wäre eine * zweite Fassung derselben Regeln -- und die zweite ist die, in der * ein Sonderfall fehlt. Die Excel-Datei wird also zu genau dem, was * auch beim Einfügen aus dem Browser ankommt. * * Tabulatoren und Zeilenumbrüche IM FELD werden zu Leerzeichen. Sonst * zerfiele ein Feld in zwei Spalten, und ab da stimmt keine Zeile * mehr. */ export function alsText(zeilen) { return zeilen .map((z) => z.map((w) => String(w ?? "").replace(/[\t\r\n]+/g, " ")).join("\t")) .join("\n"); }