Filipe: "mach doch bitte so dass man alle dateien hoch laden könne auch
excel dateien. gib gas und krieg das hin."
server/workspace-xlsx.js liest .xlsx mit Bordmitteln: Eine .xlsx ist ein
ZIP mit XML darin, und Node kann beides (`zlib.inflateRawSync`). Kein
zusaetzliches Paket fuer eine Datei mit neun Zeilen.
GEMESSEN AN ZWEI ECHTEN BACKSTAGE-AUSGABEN vom 11. und 12.09.2026, die
auf dem Rechner lagen -- nicht an der Dokumentation. Sie haben mir an
drei Stellen widersprochen, und JEDE davon waere sonst ein stiller
Fehler geworden:
1. DER ZEITRAUM. In "Datenzeitraum" steht `2026-09-01 ~ 2026-09-11`.
`datumLesen` griff sich davon den ersten Tag -- elf Tage Diamanten
waeren auf den 1. September gebucht worden. Die Zahl steht da, sie
ist gross, sie sieht richtig aus, und niemand kann spaeter sagen,
dass elf Tage darin stecken. Neu: `zeitraumLesen`; eine Zeile mit
einem Zeitraum ueber mehrere Tage wird abgelehnt UND begruendet
("Stell in Backstage den Zeitraum auf EINEN Tag"). Ein Zeitraum von
einem Tag geht durch.
2. DIE EINHEIT. "LIVE-Dauer" enthaelt `86Std. 10Min. 52Sek.`.
`zahlLesen` ergab daraus `null` -- die Dauer fiel weg. Bei einem
anderen Trennzeichen waere es schlimmer gewesen: 86 statt 5171,
Faktor 60 daneben und plausibel. Neu: `dauerLesen`, versteht die
deutsche und englische Schreibweise, die Uhrzeitform und weiterhin
die blosse Zahl.
3. DIE DATUMSSPALTE. `/datum|date|tag|day/i` erklaerte "Tage seit dem
Beitritt" zur Datumsspalte (Wert "65") und traf in der
Leistungstabelle "Gueltige LIVE-Gehen-Tage" genauso. Jetzt nur noch
als ganzes Wort, dafuer mit "zeitraum" -- Backstages Spalte wurde
bisher nur zufaellig gefunden, weil in "Daten" die Silbe "date"
steckt.
Gefunden hat das keine Ueberlegung, sondern der ganze Weg einmal mit
der echten Datei durchlaufen.
WEITER GEBAUT:
- Titelzeilen werden uebersprungen: "Creator:innen verwalten" hat in
Zeile 1 nur "Exportiert am :…", die Ueberschriften stehen darunter.
Die Regel misst (drei gefuellte Felder UND halb so breit wie die
breiteste Zeile), statt eine feste Zahl zu nehmen.
- Fehlende Zellen verschieben nichts: Eine leere Zelle steht in der
Datei gar nicht; wer der Reihe nach liest, verrutscht ab dort jede
Spalte, und die Zeile sieht voll aus.
- Datums-Seriennummern werden nur umgerechnet, wenn das FORMAT es sagt
(sonst stuende 46271 in der Vorschau). Der Nullpunkt ist an zwei
nachschlagbaren Werten festgenagelt.
- Der Backstage-Dialog nimmt die Datei jetzt AUCH -- dort gehoert sie
hin, denn Filipes Ausgabe enthaelt alle Creator auf einmal. Sie fuellt
das Einfuegefeld; ab da laeuft derselbe Weg wie beim Einfuegen. Keine
zweite Fassung derselben Regeln.
- Die alte .xls (BIFF, kein ZIP) wird erkannt und bekommt einen Weg
gezeigt, statt "ging nicht" zu sagen.
NEU: pruef-xlsx.mjs (60) -- baut seine Dateien selbst (ZIP-Schreiber in
helfer-xlsx-bauen.mjs), damit keine Creator-Daten ins Repo wandern und
auch Faelle pruefbar sind, die es als Datei nicht gibt: kaputtes
Verzeichnis, fehlendes Blatt, abgeschnittene Datei. Jeder davon mit
Gegenprobe, dass die heile Datei durchgeht.
pruef-backstage-import 77 -> 82, dabei zwei Pruefungen GEDREHT: Die
.xlsx bekommt keine Absage mehr, sondern eine Vorschau.
DREI EIGENE FEHLER DABEI, alle von einer Messung gefunden:
- Ich hielt Seriennummer 46264 fuer den 06.09.; es ist der 30.08. Der
Code hatte recht. Deshalb stehen jetzt zwei nachschlagbare Anker drin.
- Eine Zeile war gruen, weil mein Muster den SPALTENNAMEN
"Datenzeitraum" traf statt der Begruendung. Jetzt wird auf den Text
der Ablehnung geprueft.
- Beim Umbau habe ich pruef-backstage-import beschaedigt (ein
Suchtreffer weiter oben als gemeint) und aus Git zurueckgeholt.
Co-Authored-By: Claude Opus 5 <[email protected]>
368 lines
16 KiB
JavaScript
368 lines
16 KiB
JavaScript
/* =====================================================================
|
||
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 (`<r>`-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(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)) {
|
||
let stueck = "";
|
||
for (const t of eintrag[1].matchAll(/<t(?:\s[^>]*)?>([\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(/<numFmt[^>]*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 = /<cellXfs[^>]*>([\s\S]*?)<\/cellXfs>/.exec(text);
|
||
if (!block) return vorlagen;
|
||
let nummer = 0;
|
||
for (const xf of block[1].matchAll(/<xf\b[^>]*>/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 = /<sheet\b[^>]*>/.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(`<Relationship[^>]*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(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
|
||
const felder = [];
|
||
for (const zelle of zeile[1].matchAll(/<c\b([^>]*)(?:\/>|>([\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(/<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(inhalt)?.[1] ?? -1);
|
||
wert = texte[i] ?? "";
|
||
} else if (typ === "inlineStr") {
|
||
for (const t of inhalt.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) {
|
||
wert += entschluesseln(t[1]);
|
||
}
|
||
} else {
|
||
const roh2 = /<v(?:\s[^>]*)?>([\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");
|
||
}
|