Files
wkhandClaude Opus 5.5 a0680c595e Save and open documents as .num (zipped JSON)
Datei › Speichern writes a .num file: a ZIP (fflate) laid out like
OpenDocument -- an uncompressed "mimetype" entry first
(application/x-numolus), meta.json (format version, ISO 8601 creation
time) and document.json, the same JSON the .json export writes. Opening a
.num (file dialog or drag & drop) unpacks it and offers the same
replace/append choice as a .json. Non-ZIPs, foreign ZIPs, a missing
document.json and a newer format version fail with a clear message.

The zip code is loaded on first use, like xlsx, so the main bundle does
not grow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 02:33:05 +02:00

94 KiB
Raw Permalink Blame History

numolus — Tabellenkalkulation mit eigener Formel-Engine

Eine browserbasierte Tabellenkalkulation nach dem Vorbild von Apple Numbers. Zwei Dinge stehen im Mittelpunkt:

  1. Handwerk — eine von Grund auf selbst geschriebene, schlanke Formel-Engine (Tokenizer, Parser, AST, Abhängigkeitsgraph, Neuberechnung, Zirkelbezug- Erkennung). Dokumentiert in src/engine/README.md.
  2. Produkt — eine polierte, Numbers-nahe Oberfläche: jedes Blatt ist eine freie Fläche (Canvas), auf die man beliebig viele Tabellen- und Diagramm-Objekte setzt, verschiebt, skaliert und befüllt.
npm install
npm run dev        # Vite-Devserver
npm run test       # Vitest-Suite (Engine + Store + IO), 570 Tests
npm run build      # Typecheck + Produktionsbuild
npm run lint       # ESLint (flat config)

Alles läuft rein clientseitig — kein Server, keine Netzwerk-Requests. Datei- I/O über File API und Blob-URLs.


Das Canvas-Modell

Wie in Numbers ist ein Blatt kein einzelnes Endlos-Raster, sondern eine scroll- und zoombare Fläche, die Objekte trägt:

  • Tabellen-Objekt — ein eigenes Gitter mit einem Namen, der pro Blatt eindeutig ist (siehe „Formelgrammatik" unten für Bezüge über mehrere Blätter mit demselben Tabellennamen). Frei positionierbar (Ziehen an der Kopfleiste). Zwei Darstellungen:
    • Automatisch — so groß wie die Zellen, alle Zeilen sichtbar. Der SE-Griff zieht hier direkt an Zeilen-/Spaltenzahl: Vergrößern fügt leere Zeilen/Spalten hinzu, Verkleinern entfernt sie — aber nie über echten Zellinhalt hinweg. Während des Ziehens zeigt ein gestricheltes Vorschau-Rechteck die Zielgröße (die eigentliche Änderung ist teuer — ein voller Neuaufbau der Engine — und committet daher erst beim Loslassen, nicht bei jeder Mausbewegung); dann wird auf die kleinste Größe geklemmt, die den vorhandenen Inhalt noch vollständig zeigt.
    • Feste Größe — ein fester, unabhängig wählbarer Pixel-Viewport mit frei wählbarer Zahl fixierter Kopfzeilen und fixierter Spalten (position: sticky gegen den Scroll-Container) — für eine große Tabelle, die nur einen kleinen Ausschnitt auf dem Canvas belegen soll, mit internem Scrollen für den Rest. Der SE-Griff zieht hier nur den Viewport, unabhängig von der Zeilen-/Spaltenzahl (die bleibt exakt wie sie ist) — dieselben Breiten-/Höhenfelder im Einstellungen-Panel (⚙, s. u.) steuern denselben Wert. Zeilen-Virtualisierung: nur die Zeilen im sichtbaren Ausschnitt (+ etwas Überhang) landen tatsächlich im DOM — alles andere ist ein Platzhalter-<tr> in passender Höhe, damit Scrollbar-Größe/-Position stimmen. Macht auch eine Tabelle mit zehntausenden Zeilen praktikabel (gemessen: PageDown auf 50.000 Zeilen ging von ~7s auf ~15ms); nur für feste Tabellen (die einzige Ansicht mit einem echten, begrenzten Scroll-Container) — eine auto- große Tabelle dieser Größe wäre ohnehin ein unbrauchbar hohes Canvas-Objekt, unabhängig vom DOM. Nur Zeilen, nicht Spalten (in der Praxis wird eher die Zeilenzahl groß). Spaltenbreite per Drag am Spaltenkopf (ab 24 px), Zeilenhöhe per Drag an der Unterkante des Zeilenkopfs (Standard + je Zeile). „Ohne Rahmen/ Kopfzeilen, wenn nicht ausgewählt" (Einstellungen → Objekt, je Tabelle): solange die Tabelle nicht das ausgewählte Canvas-Objekt ist, verschwinden Titelleiste, Spaltenbuchstaben und Zeilennummern komplett — nicht nur optisch, ihr reservierter Platz fällt mit weg, die Tabelle schrumpft auf reines Zellraster. Ein Klick hinein (z. B. auf eine Zelle) wählt sie wieder aus und blendet alles zurück ein.
  • Diagramm-Objekt — Chart.js (Balken/Linie), frei positionier- und skalierbar. Datenquelle = ein Tabellen-Objekt; jede Zelländerung aktualisiert es sofort.
  • Text-Objekt — ein formatierbares Textfeld als Zusatz zu Tabellen und Diagrammen. Doppelklick zum Bearbeiten; Schriftgröße, Fett/Kursiv, Ausrichtung, Text- und Hintergrundfarbe im Inspector; frei skalierbar.

Objekte kommen beim Anklicken in den Vordergrund, werden per Entf gelöscht (Diagramm/Text) bzw. über das × der Kopfleiste, per Doppelklick auf den Namen umbenannt. Je Tabelle, Diagramm und Textfeld sind eigene Hintergrund- und Textfarbe einstellbar. Mehrere Blätter über die Tabs unten (hinzufügen, umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt (Strg/Cmd + Mausrad, Zoom-Leiste oder Einstellungen).

Zellauswahl

  • Klick = eine Zelle, Shift+Klick / Ziehen = ein Rechteck. Das Ziehen ist positions- statt event-target-basiert (elementsFromPoint), übersteht schnelle Bewegungen und das Verlassen der Tabelle, scrollt bei festen Tabellen an der Kante automatisch mit und heilt einen verlorenen mouseup selbst.
  • Kopf-Klick = ganze Spalte / Zeile, Ecke = alles.
  • Strg/Cmd+A (zweistufig, Excel-Verhalten): der erste Druck markiert den zusammenhängenden Bereich um die aktive Zelle — iterative Rechteck-Expansion, die an einer vollständig leeren Zeile/Spalte oder am Tabellenrand stoppt (zwei durch eine leere Spalte/Zeile getrennte Dateninseln bleiben getrennt). Ein zweiter, unmittelbar folgender Druck erweitert auf alle befüllten Zellen der Tabelle — die tatsächlichen Grenzen befüllter Felder, nicht die (oft größeren) Grunddimensionen der Tabelle. Jeder weitere Druck bleibt auf dieser Vollauswahl stehen. Der Zyklus erkennt sich selbst rein über einen Vergleich der aktuellen Auswahl mit der beim letzten Druck gesetzten — jede andere Aktion dazwischen (Klick, Pfeiltaste, Bearbeitung, …) setzt ihn auf Stufe 1 zurück, ganz wie in Excel.
  • Strg/Cmd+Klick = weitere Zelle hinzufügen (erneut = abwählen), Strg/Cmd+Ziehen = weiteres Rechteck; auch am Spalten-/Zeilenkopf.
  • Die diskontinuierliche Mehrfachauswahl trägt alle folgenden Aktionen: Live-Aggregat (Summe/Mittel/Anzahl) in der Statusleiste, die Auswahl-Formatierung im Inspector und Entf (leert alle markierten Zellen).
  • Pfeiltasten / Enter kollabieren wieder auf eine Zelle. Die aktive Zelle (blauer Rahmen, Formelleiste, F2/Tippen zum Bearbeiten) ist stets die zuerst angeklickte/gesetzte Zelle einer Auswahl — sie bleibt beim Ziehen/Erweitern eines Bereichs stehen, statt dem Cursor zu folgen (Excel-/ Numbers-Verhalten). Während einer Tab/Enter-Dateneingabefolge im Bereich wandert sie mit dem Cursor mit.
  • Kopieren/Ausschneiden/Einfügen (Strg/Cmd+C/X/V, Kontextmenü): überträgt Zellinhalte und Formatierung. Einfügen auf eine Auswahl, die ein exaktes Vielfaches der kopierten Größe ist, kachelt; eine kleinere oder Einzelzell-Auswahl verankert die kopierte Fläche dort und läuft darüber hinaus. Formeln werden dabei bezugsangepasst wie in Excel/Numbers — siehe „$-Bezüge sperren" unten; ein gestrichelter Rahmen markiert die Kopierquelle (mit eigenem Rahmen um deren erste Zelle, bleibt stehen, auch wenn die aktuelle Auswahl längst woanders ist), Esc verwirft den Puffer.
  • Ausfüllkästchen (das kleine Quadrat unten rechts an der Auswahl, wie in Excel/Numbers): ziehen wiederholt die Auswahl — Werte, Formeln (bezugsangepasst) und Formatierung — in die überstrichenen Zellen. Nur eine Achse gleichzeitig (rein runter/hoch/rechts/links, je nachdem wohin weiter gezogen wird); ein mehrzeiliger/-spaltiger Ausgangsbereich wird zyklisch wiederholt, auch über eine unvollständige letzte Wiederholung hinweg.

Tastaturnavigation (Excel-nah) — Fokus in der Tabelle:

Taste Verhalten
Pfeiltasten eine Zelle; Shift+Pfeil erweitert die Auswahl
Strg/Cmd+Pfeil zum Rand des Datenblocks springen (leer↔gefüllt)
Tab / Shift+Tab rechts / links
Enter / Shift+Enter runter / hoch — nach einer Tab-Folge zurück zur Startspalte + eine Zeile
bei aktivem Bereich Tab/Enter rotieren den Cursor im Bereich (mit Umbruch), der Bereich bleibt markiert
Tippen / F2 Zelle editieren; Enter/Tab committen und ziehen weiter (auch im Bereich)
Pos1 / Strg+Pos1 / Ende / Strg+Ende Zeilenanfang / A1 / Zeilenende / letzte Zelle
Bild↑ / Bild↓ seitenweise (bei festen Tabellen nach Viewport-Höhe)
Strg/Cmd+A 1. Druck: zusammenhängender Bereich um die aktive Zelle · 2. Druck: alle befüllten Zellen der Tabelle (s. o. „Zellauswahl")
Strg/Cmd+C/X/V Kopieren / Ausschneiden / Einfügen (Zellen + Formatierung, bezugsangepasst)
Strg/Cmd+Z · Strg/Cmd+Y / Strg/Cmd+Shift+Z Rückgängig · Wiederholen (auch als Symbolleisten-Buttons ↶ ↷)
F4 (im Editor/in der Formelleiste) Bezug am Cursor sperren/zyklisch umschalten: A1 → $A$1 → A$1 → $A1 → A1; bei markiertem Text werden alle darin gefundenen Bezüge auf einmal umgeschaltet, jeder ausgehend vom eigenen Zustand
Pfeiltasten (im Editor/in der Formelleiste, an Stellen wo ein Bezug erwartet wird) „Point Mode" per Tastatur — zeigt auf eine Zelle statt den Text-Cursor zu bewegen, genau wie ein Klick es täte; nur direkt nach =, einem Operator, ( oder ; (sonst normale Text-Navigation, mitten in der Formel editierbar). Shift+Pfeil erweitert zu einem Bereich (A1:A5). Ein Zeichen tippen, Tab/Enter oder ; (nächstes Argument) übernehmen den gezeigten Bezug
Alt+Enter bzw. Cmd+Enter (im Editor) Zeilenumbruch in der Zelle einfügen, statt zu committen — Cmd+Enter als Mac-Fallback, da ⌥ (Option) dort eine Compose-Taste ist und nicht überall zuverlässig als reines Modifier ankommt
↑/↓/Tab/Enter/Esc (bei offenem Funktions-Dropdown) navigieren / Vorschlag übernehmen / nur das Dropdown schließen — hat Vorrang vor der sonstigen Bedeutung dieser Tasten (s. u. „Funktions-Autovervollständigung")

Bei festen (scrollbaren) Tabellen wird die aktive Zelle bei jeder Navigation automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter Zeilen/Spalten).

Rückgängig/Wiederholen: dokumentweiter Undo-Stack (Snapshot-basiert, wie der bereits vorhandene JSON-Export/Import-Round-Trip — einfach und nachweisbar korrekt für ein Dokument dieser Größe, statt eines Patch-/ Kommando-Logs). Reine Auswahl-/Cursor-Bewegungen zählen nie als Schritt; eine schnelle Folge von Mutationen (ein Spalten-/Zeilen-Resize-Drag feuert bei jeder Mausbewegung) wird zu einem Schritt zusammengefasst, sofern sie innerhalb von 150 ms aufeinanderfolgen — knapp unter normalem Tipp-/Klick- Tempo, damit zwei tatsächlich getrennte schnelle Aktionen nicht verschmelzen, aber weit über dem Abstand aufeinanderfolgender Drag-Ereignisse (~16–30 ms). Ein neuer Bearbeitungsschritt verwirft sofort einen etwaigen Wiederholen-Ast. „Auf Beispiel zurücksetzen" und JSON-Import „Ersetzen" starten eine neue Historie (das vorherige Dokument ist danach nicht mehr zurückholbar), Undo/ Redo selbst tun das nicht. Symbolleiste zeigt zwei Buttons (↶ ↷, deaktiviert wenn nichts zu tun ist); greift auch ohne Tabellenfokus, außer während eine Texteingabe (Zell-Editor, Formelleiste, Blatt-Umbenennen, …) den nativen Undo dieses Feldes benutzen soll.

Performance bei großen Tabellen: Undo-Tracking und die (debounced) localStorage-Persistenz werden nicht über einen watch(doc, ..., {deep:true}) angestoßen — Vues Deep-Watch muss dafür bei jeder Mutation den kompletten reaktiven Objektgraphen erneut durchlaufen, ein Aufwand proportional zur Gesamtgröße des Dokuments statt zur eigentlichen Änderung (gemessen: bei einer 50.000-Zeilen-Tabelle kostete ein einzelner Zellen-Edit dadurch ~450 ms, unabhängig vom Umfang der Änderung). Stattdessen ruft jede inhaltsverändernde Store-Funktion explizit markDirty() auf (nach demselben Muster wie bump()/recalcTick für den Engine-Cache) — reine Auswahl-/Navigations-Funktionen rufen nur scheduleSave() (Persistenz ohne Undo-Schritt). Der Kompromiss: markDirty() prüft nicht mehr bei jeder Mutation per Fingerprint-Vergleich, ob wirklich etwas geändert wurde (genau dieser Vergleich bei jeder Mutation war der teure Teil) — dieses Sicherheitsnetz bleibt in commitHistoryStep() erhalten, läuft aber nur noch einmal pro debounced Schritt statt pro Mutation. setCellInput hat zusätzlich eine eigene, günstige No-Op-Prüfung (unverändert übernommener Wert, z. B. F2, Enter ohne Änderung), damit der häufigste Fall keinen Schritt erzeugt und den Wiederholen-Ast nicht verwirft.

In-Zellen-Editor: das Eingabefeld liegt als Overlay über der Zelle (nicht in ihr) und wächst mit dem Inhalt — in der Breite bis zu einer angenäherten „sichtbaren Anzeigebreite", danach umbrechend; in der Höhe mit Alt+Enter eingefügten Zeilenumbrüchen, bis zu einem Maximum, danach intern scrollbar. Ein Zeilenumbruch bleibt auch nach dem Committen erhalten und wird in der Zelle als solcher dargestellt (umbrechend statt einzeiliger Ellipse), auf die Zeilenhöhe der Tabelle geklemmt.

Formelleiste (fx, oben): ein <textarea> statt eines einzeiligen Felds — dadurch umbricht eine lange Formel statt seitwärts wegzuscrollen, und an der unteren Kante lässt sie sich per Maus (natives resize: vertical) auf beliebig viele Zeilen aufziehen, um mehr auf einmal zu sehen. Alt+Enter bzw. Cmd+Enter, F4 und Esc verhalten sich wie im In-Zellen-Editor. Das Namensfeld links davon (ebenso das rein lesende Pendant links in der Statuszeile, die mit den Blatt-Reitern eine gemeinsame Zeile am unteren Rand teilt) zeigt bei einer Mehrfachauswahl nicht nur die aktive Zelle, sondern den ganzen markierten Bereich (Umsatz::B2:E6), bei einer diskontinuierlichen Auswahl zusätzlich +N für die weiteren Rechtecke.

Namensfeld als Sprungmarke (Exceleigenschaft, hier zusätzlich mit Autovervollständigung): das Feld selbst ist editierbar. Eingetippt und mit Enter bestätigt springt es zu jedem gültigen Ziel — derselben Syntax, die auch eine Formel verwenden würde:

  • eine Zelle (A1) oder ein Bereich (A1:C10) auf der gerade aktiven Tabelle,
  • ein qualifizierter Bezug auf eine andere Tabelle/ein anderes Blatt (Tabelle::A1, Blatt!Tabelle::A1:B5) — wechselt Blatt/Auswahl mit,
  • ein benannter Bereich (Umsatz_Q1) — markiert dessen Zielbereich,
  • ein bloßer Tabellenname (Kosten) — markiert die ganze Tabelle,
  • ein bloßer Blattname (Blatt 2) — wechselt dorthin.

Während des Tippens schlägt eine Dropdown-Liste passende benannte Bereiche, Tabellennamen und Blattnamen vor (Präfix, groß-/ kleinschreibungsunabhängig; ↑/↓ navigiert, Tab übernimmt den markierten Vorschlag ins Feld, ein Klick springt direkt dorthin). Ein nicht auflösbarer Text (z. B. eine echte Formel wie SUM(A1;A2) — das Feld navigiert, es wertet nicht aus) meldet sich per Toast und lässt den Text zum Korrigieren markiert stehen; Esc verwirft die Eingabe und zeigt wieder die aktuelle Auswahl.

Bezüge per Maus einfügen ("Point-Mode"): sobald der Editor (Zelle oder Formelleiste) eine Formel (=…) enthält, fügt ein Klick auf eine Zelle — in derselben Tabelle oder einer ganz anderen, mit Name!-Qualifier bei Bedarf 'in Quotes' — deren Bezug an der Cursorposition ein; Ziehen fügt stattdessen einen Bereichsbezug (A1:C3) ein, live wachsend während der Zug. Ein weiterer Klick/Zug ersetzt den zuletzt eingefügten Bezug, solange nichts dazwischen getippt wurde — jede Eingabe (ein ; als Argumenttrennzeichen ebenso wie jedes andere Zeichen) beendet dieses Fenster, der nächste Klick fügt dann einen neuen Bezug an. Ein Klick während des Point-Mode ändert nie die Tabellen-/Objektauswahl im Hintergrund.

Benutzerdefinierte Funktionen

Der „fx"-Button in der Symbolleiste öffnet die einzige Stelle, an der eigene, dokumentweite Funktionen angelegt, bearbeitet und gelöscht werden — kein JSON-Handedit, keine Konsole. Danach in jeder Zelle aufrufbar wie ein eingebauter Funktionsname: =MARGE(Umsatz!B2; Umsatz!C2).

  • Liste zeigt jede Funktion als NAME(param1; param2) — exakt die Aufruf-Syntax — mit Bearb. (öffnet dasselbe Formular, vorausgefüllt) und 🗑 (löscht nach Bestätigung).
  • Formular (Neu wie Bearbeiten): Name, eine dynamische Liste von Parameter-Feldern (+ Parameter, × zum Entfernen; auch 0 Parameter sind gültig — dann ist es eine benannte Konstante/Formel), und ein Formel-Feld optisch identisch zur Formelleiste — ohne führendes =. Ein Parametername verhält sich im Funktionskörper wie eine Zelle mit eigenem Wert; er darf selbst nicht wie ein Zellbezug aussehen (A1, $B2 o. ä. sind ungültig). Ein Parameter kann auch ein Bereich sein, solange er im Funktionskörper direkt an eine bereichsfähige Funktion übergeben wird — z. B. SPANNE(bereich) = MAX(bereich) - MIN(bereich), aufgerufen als =SPANNE(Umsatz!B2:E6); das Formular zeigt dieses Muster als Hinweis unter dem Formel-Feld. Wie bei jeder Formel ergibt ein Bereich außerhalb einer Funktionsargument-Position #VALUE!.
  • Validierung beim Speichern, mit Inline-Fehlermeldung direkt im Formular (kein Toast, nichts geht verloren): Name-Kollision mit einer eingebauten oder einer anderen benutzerdefinierten Funktion, ungültiger Name/Parameter, ein nicht parsender Formelkörper, und ein Funktions- Zyklus (A ruft B, B ruft A, auch über mehrere Stufen) — wird abgelehnt, bevor je eine #ERR-Zelle entsteht.
  • Umbenennen/Löschen aktualisiert keine bestehenden Formeltexte. Zellen, die die betroffene Funktion aufrufen, zeigen danach #NAME?. Anders als bei einer Tabelle (s. u. „Referenzen bleiben nach dem Umbenennen konsistent") — eine Funktion hat keine Referenz-Syntax, die sich automatisch umschreiben ließe, nur ihren Aufruf-Namen.
  • Ein Aufruf wird beim Parsen der aufrufenden Zelle in ihre AST eingesetzt (Inlining, keine Laufzeit-Closures) — siehe src/engine/userFunctions.ts und die Architektur-Tabelle unten. Dadurch braucht Evaluator/Graph keine Änderung, und auch Zellen, die nur im Funktionskörper referenziert werden (nicht im Aufruf), lösen bei Änderung korrekt eine Neuberechnung aus.
  • Vollständig undo-/redo-fähig und Teil des JSON-Exports/-Imports.

Mit Schleife: echter Code statt einer einzelnen Formel

Der „Art"-Umschalter im Formular bietet neben „Einfache Formel" (s. o.) auch „Mit Schleife" — echter Code mit Kontrollfluss, für Fälle, die eine einzelne Formel nicht ausdrücken kann: über die Elemente eines Bereichs selbst iterieren, verzweigen, vorzeitig abbrechen. Das Code-Feld akzeptiert eine Folge aus:

  • Zuweisungen — name = ausdruck.
  • FOR-Schleifen — FOR var IN bereich DO … END, mit beliebig vielen Anweisungen dazwischen (auch weiteren Schleifen/IFs); bereich ist ein Parameter, eine Zelle oder ein Bereich (kein berechneter Ausdruck).
  • IF/ELSE — IF bedingung THEN … END bzw. mit ELSE … davor dem END, verschachtelbar in jedem Block (auch innerhalb eines FOR-Körpers).
  • RETURN — RETURN ausdruck, an beliebiger Stelle, auch mitten in einer Schleife oder einem IF-Zweig. Bricht die Ausführung sofort mit diesem Wert ab; nichts danach läuft noch, auch keine weiteren Schleifen-Durchläufe.

— beliebig gemischt und verschachtelt. Jeder eingeführte Name (Zuweisungsziel oder Schleifenvariable) bleibt für den restlichen Code sichtbar, auch nach dem Ende „seines" FOR/IF (mit seinem zuletzt zugewiesenen Wert) — flacher Namensraum, kein Block-Scoping. FOR/IN/DO/END/IF/THEN/ ELSE/RETURN sind reservierte Wörter, nicht als Parameter-/Variablenname erlaubt. Für IF-Bedingungen gibt es Vergleichsoperatoren (= <> > < >= <=, Ergebnis 1/0) — in jeder Formel nutzbar, nicht nur in Blöcken.

Beispiel — Produkt aller Elemente eines Bereichs (PRODUCT gibt es nicht als eingebaute Funktion, nur SUM/AVG/MIN/MAX/COUNT):

ergebnis = 1
FOR x IN bereich DO
  ergebnis = ergebnis * x
END
RETURN ergebnis

aufgerufen als =PRODUCT(Umsatz!B2:E6). Ein zweites Beispiel mit IF und frühem RETURN — die erste Zahl in einem Bereich über einer Schwelle finden, ohne den Rest des Bereichs überhaupt anzufassen:

FOR x IN bereich DO
  IF x > schwelle THEN
    RETURN x
  END
END
RETURN 0

Kein Endlos-Risiko trotz echtem Kontrollfluss. Anders als die einfache Formel-Variante (die vollständig zu einer statischen Formel „inlined" wird) läuft eine FOR/IF/RETURN-Funktion bei jeder Neuberechnung durch einen kleinen, echten Interpreter (evaluator.ts) — nötig, weil ein IF-Zweig bzw. ein frühes RETURN vom tatsächlichen Zellwert abhängt, nicht vorab berechenbar ist. Eine FOR-Schleife über insgesamt mehr als 10.000 Zellen (über alle Schleifen einer Funktion) ergibt #ERR statt zu hängen; eine Funktion, deren Ausführung endet, ohne dass RETURN je gefeuert hat, ebenso — das Formular warnt davor zusätzlich schon beim Speichern, wenn nicht erkennbar jeder Codepfad ein RETURN erreicht. Details: src/engine/README.mds „Schleifen"-Abschnitt.

SUMIF/COUNTIF/AVERAGEIF nachbauen

Es gibt keine eingebauten SUMIF/COUNTIF/AVERAGEIF — aber die „Mit Schleife"-Funktionen reichen aus, um die selbstbezügliche Form nachzubauen: Kriterium und Aggregation laufen über denselben Bereich („Summe/Anzahl/Mittelwert aller Werte in bereich, die eine Bedingung erfüllen"). Das deckt den häufigsten Alltagsfall ab — z. B. „Summe aller Umsätze über 100" oder „Anzahl der Zeilen mit Region = Nord", solange Kriterium und zu summierender Wert in derselben Zelle stehen.

summe = 0
FOR x IN bereich DO
  IF x > schwelle THEN
    summe = summe + x
  END
END
RETURN summe

aufgerufen als =SUMME_WENN(Umsatz!B2:B13; 100) — das Excel-Äquivalent zu SUMIF(Umsatz!B2:B13; ">100") mit gleichem Kriterien- und Summenbereich. COUNTIF/AVERAGEIF folgen demselben Muster, nur mit zaehler = zaehler + 1 statt summe = summe + x, bzw. am Ende RETURN summe / zaehler (mit IF zaehler = 0 THEN RETURN 0 END-Guard gegen #DIV/0!).

Seit dem Fix der Vergleichsoperatoren (s. u. „Formelgrammatik") funktioniert das Kriterium auch mit Text — case-insensitiv und getrimmt, wie bei XLOOKUP:

anzahl = 0
FOR zeile IN bereich DO
  IF zeile = kriterium THEN
    anzahl = anzahl + 1
  END
END
RETURN anzahl

aufgerufen als =ANZAHL_WENN(Umsatz!A2:A13; "Nord") — zählt alle Zellen in Umsatz!A2:A13, deren Text „Nord" ist (unabhängig von Groß-/Kleinschreibung und Leerzeichen).

Grenze, die (noch) bleibt: ein Excel-SUMIF(Kriterienbereich; Kriterium; Summenbereich) mit getrennten Bereichen (Kriterium in Spalte A, zu summierender Wert in Spalte B) ist damit nicht nachbaubar — die Sprache hat kein OFFSET/INDEX-Äquivalent, um „die Zelle an derselben Position im anderen Bereich" zu adressieren; eine FOR-Schleife erreicht nur den Bereich, über den sie selbst iteriert. Siehe „Bekannte Grenzen" unten.

Benannte Bereiche

Der „Benannte Bereiche"-Button (Tag-Symbol) in der Symbolleiste, direkt neben dem „fx"-Button, öffnet die einzige Stelle, an der dokumentweite Namen für eine feste Zelle/einen festen Bereich angelegt, bearbeitet und gelöscht werden — z. B. Umsatz_Q1 statt Umsatz!B2:B6. Danach in jeder Top-Level-Formel unqualifiziert nutzbar, ohne Tabellen-Präfix: =SUM(Umsatz_Q1) oder =Umsatz_Q1 + 1 für eine einzelne Zelle. Das vorbefüllte Dokument bringt Umsatz_Q1 (→ Umsatz!B2:B6) bereits als Beispiel mit.

  • Liste zeigt jeden Namen als Name → Tabelle (Blatt)!Bereich — mit Bearb. und 🗑 wie bei den Funktionen.
  • Formular: Name, eine Tabelle (Auswahl über alle Blätter hinweg) und zwei Zellfelder (Start/Ende als A1-Notation, z. B. B2 und B6); ein leeres zweites Feld bedeutet „nur die erste Zelle". „+ Neuer Name" übernimmt automatisch die aktuelle Zellauswahl (Tabelle + Bereich) als Vorbelegung, wenn eine Zelle/ein Bereich gerade markiert ist — analog zu Excels „Namen aus Auswahl erstellen".
  • Validierung beim Speichern: gültiger Name (keine Zahl/Zellform wie A1), kein reserviertes Schlüsselwort (FOR/IN/DO/RETURN), keine Kollision mit einem eingebauten Namen, einer benutzerdefinierten Funktion oder einem anderen benannten Bereich (case-insensitiv). Kein Zyklus-Check nötig — anders als eine benutzerdefinierte Funktion hat ein benannter Bereich keinen eigenen Ausdruck, nur feste Koordinaten, kann sich also nie selbst referenzieren.
  • Überlebt eine Tabellen-Umbenennung. Intern wird die Tabellen-ID gespeichert, nicht ihr Name — anders als ein normaler Tabelle!Zelle-Bezug im Formeltext (der bricht bei Umbenennung, wie eh und je) wird ein benannter Bereich beim Nachschlagen live auf den aktuellen Tabellennamen aufgelöst. Wird die Tabelle dagegen gelöscht, bleibt der Name bestehen und zeigt in Formeln danach #NAME? — „nichts wird automatisch umgeschrieben", dieselbe Konvention wie beim Löschen einer benutzerdefinierten Funktion oder Tabelle.
  • Vollständig undo-/redo-fähig und Teil des JSON-Exports/-Imports.

Bewusst ohne (Stand jetzt):

  • Nicht innerhalb einer benutzerdefinierten Funktion nutzbar — im Funktionskörper selbst bleibt ein unqualifizierter, nicht-zellförmiger Name ein harter Parse-Fehler, genau wie vor diesem Feature. Grund: sonst würde z. B. eine lokale Variable, die versehentlich vor ihrer eigenen Zuweisung verwendet wird, nicht mehr sofort beim Speichern auffallen, sondern erst zur Laufzeit als #NAME? — oder, schlimmer, sie würde still passend zu einem gleichnamigen benannten Bereich irgendwo im Dokument auflösen. Ein Aufrufer kann einen benannten Bereich aber ganz normal als Argument übergeben (=MEINEFUNKTION(Umsatz_Q1)) — die Einschränkung gilt nur fürs Eintippen des bloßen Namens im Funktionskörper selbst.
  • Keine eigene Formel/kein Ausdruck — nur eine feste Zelle/ein fester Bereich, kein benannter berechneter Wert (dafür gibt es weiterhin benutzerdefinierte Funktionen ohne Parameter, s. o.).
  • Nicht in der Funktions-Autovervollständigung — das Dropdown beim Tippen nach = schlägt bisher nur Funktionsnamen vor, keine benannten Bereiche.
  • Kein „Bereich" als Name:B2 — ein benannter Bereich ist immer die ganze, vordefinierte Adresse; er kann nicht als Startpunkt einer neu getippten :-Bereichsangabe kombiniert werden.

Makros

Ein USP gegenüber gewöhnlichen Tabellenkalkulationen: Zellen, Zeilen/ Spalten, Tabellen und Blätter dokumentweit per Skript adressieren und verändern — Struktur und Inhalt — statt nur per Formel zu lesen. Anders als eine Formel läuft ein Makro außerhalb der Neuberechnung: es wird nie aus einer Zelle heraus aufgerufen, sondern immer explizit ausgelöst — über „▶ Ausführen" in der neuen „▶ Makros"-Symbolleisten-Ansicht oder per Klick auf ein Button-Objekt auf der Fläche (einfügbar über das Symbol neben „Text einfügen"; welches Makro der Klick auslöst, wird im Inspektor oder per Rechtsklick auf den Button direkt gewählt). Diese Trennung ist bewusst: eine Formel, die als Nebeneffekt andere Zellen verändert, würde die Neuberechnungsreihenfolge undefiniert machen (genau das vermeidet dieses Feature) — ein Makro ist stattdessen dieselbe Art Mutation, die auch ein Klick in der UI auslösen würde, nur mit FOR/IF/RETURN-Kontrollfluss verkettet (dieselbe Sprache wie „Mit Schleife"-Funktionen, s. o.).

Elf Schreib-Aktionen, adressiert mit der gewohnten Blatt!Tabelle::Zelle-Syntax:

Aktion Wirkung
SETVALUE(Ziel; Wert) schreibt Wert in eine Zelle oder — als Ziel ein Bereich — denselben Wert in jede Zelle darin (wie Excels Strg+Enter). Beginnt der (ggf. per & zusammengesetzte) Text mit =, wird er wie getippt zur echten Formel.
INSERTROW/DELETEROW/INSERTCOL/DELETECOL(TabellenRef; Index) dieselbe dokumentweite Formel-Referenz-Umschreibung wie das manuelle Einfügen/Löschen (s. o.)
ADDTABLE(BlattRef; Name), RENAMETABLE/DELETETABLE(TabellenRef) Tabellen anlegen/umbenennen/löschen
ADDSHEET(Name), RENAMESHEET/DELETESHEET(BlattRef) Blätter anlegen/umbenennen/löschen

TabellenRef/BlattRef sind Text ("Umsatz" oder "Blatt2!Kosten", identisch zur Formel-Qualifizierung aufgelöst) — auch berechenbar, z. B. ADDTABLE("Blatt1"; "Kopie_" & COUNT(Umsatz!A:A)). Nur SETVALUEs erstes Argument ist ein echter Zell-/Bereichsbezug (Literal, Parameter, Schleifenvariable); alle anderen Argumente sind normale Ausdrücke.

  • Jeder Zell-/Bereichsbezug muss die Tabelle nennen (Umsatz::A1, nie bloß A1) — anders als eine Zelle hat ein Makro keine „Heimat-Tabelle", die ein unqualifizierter Bezug meinen könnte. Beim Speichern geprüft, mit klarer Fehlermeldung.
  • RETURN beendet die Ausführung vorzeitig (z. B. um eine Schleife früh zu verlassen); ein RETURN "text" wird zur Erfolgsmeldung im Toast statt des generischen „Makro ausgeführt.".
  • Ein ganzer Lauf zählt als ein Undo-Schritt — läuft er synchron durch (immer der Fall), fasst die bestehende, zeitbasierte Undo/Redo-Historie ihn automatisch zu einem Strg/Cmd+Z zusammen, ohne eigene Transaktionslogik.
  • Ein Fehler mitten im Lauf bricht sofort ab (unbekannte Tabelle, fehlerhafter Wert, …) und zeigt die Ursache als Fehler-Toast — bereits ausgeführte Schritte bleiben stehen (kein automatisches Rollback, bewusst; per Undo rückgängig machbar).
  • Eigenes, kleineres Schrittbudget (1.000 statt der 10.000 einer gewöhnlichen FOR-Schleife) — eine Makro-Schleife kann pro Durchlauf eine volle Neuberechnung auslösen (bei einer Struktur-Aktion), ein zu großzügiges Budget wäre dort ein echtes Einfrier-Risiko.
  • Kein Makro ruft ein anderes Makro auf (keine solche Aktion existiert) — eliminiert jedes Makro-triggert-Makro-Zyklus-Risiko von vornherein.
  • Eine im Makro aufgerufene benutzerdefinierte Funktion funktioniert normal (berechnet einen Wert), kann aber selbst nie schreiben — ihr Funktionskörper wird immer ohne Schreib-Rechte geparst.

GET/POST + JSON-Ergebnis mit Iteration: r = GET(url) und r = POST(url; body) rufen eine externe Adresse auf und binden die geparste JSON-Antwort an r — genau wie jede andere Zuweisung, aber die einzige Stelle in der Sprache, die wirklich auf ein Netzwerk-Roundtrip wartet. FOR item IN r DO … END (oder direkt FOR item IN JSONGET(r; "pfad") DO … END) iteriert, sobald r ein JSON-Array ist, über dessen Elemente — dieselbe FOR-Syntax wie bei einem Zell-/Bereichsbezug, nur mit einer neuen Quellart. JSONGET(wert; pfad) liest ein Feld heraus ("a.b.0.c" — punktgetrennt, eine Zahl indiziert ein Array, alles andere einen Objekt-Schlüssel; ein fehlender Pfad ergibt JSON-null, kein Fehler) — anders als GET/POST rein synchron und deshalb überall in einem Ausdruck einsetzbar (SETVALUE(Ziel; JSONGET(item; "name")), verschachtelt in einer IF-Bedingung, …), nicht nur als ganze rechte Seite einer Zuweisung. Ein JSON-Wert, direkt (ohne JSONGET) an einer gewöhnlichen Werteposition verwendet, kollabiert automatisch: Text/Zahl bleiben sich selbst, ein Bool wird 1/0, ein Objekt/Array wird sein JSON-Text — praktisch, um z. B. die rohe Antwort unverändert in eine Zelle zu schreiben.

  • Verbindung wird pro Makro einmalig bestätigt. Beim ersten Kontakt eines Makros zu einem neuen Host (Domain inkl. Port) erscheint ein Bestätigungsdialog mit Makro-Name und Ziel-Host; wird zugestimmt, ist dieser Host für dieses Makro dauerhaft freigegeben (gespeichert, Teil des JSON-Exports) — kein erneutes Nachfragen bei künftigen Läufen, auch nicht in einer neuen Sitzung. Bewusst pro Makro statt pro Dokument: ein importiertes Dokument mit mehreren kleinen Makros, die denselben Host kontaktieren, soll nicht durch eine einzige Bestätigung alle auf einmal freischalten — jedes Makro trifft seine eigene, einmalige Entscheidung. Eine Ablehnung bricht das Makro ab, ohne den Host zu speichern.
  • GET/POST sind nur als ganze rechte Seite einer Zuweisung gültig (x = GET(url)), nicht verschachtelt in einem Ausdruck — sie brauchen einen echten await, den nur diese eine Stelle in der Sprache erlaubt. Innerhalb einer normalen benutzerdefinierten Funktion sind GET/POST/ JSONGET von vornherein unerreichbar (Speicher-Zeit-Fehler mit klarer Meldung) — Netzwerkzugriff bleibt strikt auf Makros beschränkt.
  • Nicht-2xx-Antwort oder ungültiges JSON brechen den Lauf mit einer klaren Fehlermeldung ab, wie jede andere Aktion.

Bewusst ohne (Stand jetzt): Makros haben keine Parameter (ein Klick übergibt keine Argumente); mehrere Struktur-Aktionen in einer Schleife lösen weiterhin je eine eigene Neuberechnung aus statt gebündelt einmal am Ende (das kleinere Schrittbudget hält das Restrisiko klein, spätere Optimierung möglich); ein TabellenRef-String wird am ersten ! gesplittet (ein Tabellenname mit eigenem ! darin ist so nicht adressierbar); POST hat keine eigenen Header/Authentifizierung (immer Content-Type: application/json, der Body wird vom Makro-Autor selbst als Text — z. B. per &-Verkettung — zusammengesetzt), GET hat keinen Body; kein eigenes Timeout (folgt dem Browser-fetch()-Standard — ein hängender Request blockiert den Makrolauf unbegrenzt); FOR über ein JSON-Objekt (statt Array) ist ein klarer Fehler, keine implizite „über die Werte/Einträge iterieren"-Zweitbedeutung; GET/POST teilen sich das normale Schrittbudget (kein eigenes, kleineres Netzwerk-Kontingent).

Zeilen & Spalten einfügen/löschen

Rechtsklick auf einen Zeilen- oder Spaltenkopf öffnet ein Kontextmenü mit „Zeile oberhalb/unterhalb einfügen", „Zeile löschen" (analog für Spalten) — anders als „Zeile/Spalte hinzufügen" (die bestehenden Buttons/ Menüpunkte, die nur am Rand wachsen/schrumpfen, reines Resizing) wird hier mitten im Gitter eingefügt/gelöscht: Zellinhalte, Formatierungen und Zeilenhöhen-/Spaltenbreiten-Overrides ab der betroffenen Zeile/Spalte verschieben sich, und jede Formel im gesamten Dokument, die eine Referenz auf die verschobene Tabelle enthält — nicht nur innerhalb der bearbeiteten Tabelle selbst, auch über Tabelle!Zelle/ Blatt!Tabelle::Zelle-Qualifier aus anderen Tabellen — wird automatisch mitgezogen:

  • Ein Bereich, der über die Einfüge-/Löschstelle hinausragt, wächst bzw. schrumpft entsprechend (=SUM(B2:B6) bleibt beim Einfügen einer Zeile mittendrin ein gültiger, nur größerer Bereich) — genau wie in Excel/Numbers.
  • Eine Referenz, die genau auf die gelöschte Zeile/Spalte zeigt (eine Einzelzelle, oder ein Bereich, der komplett darin lag), wird zu einem echten #REF-Literal in der Formel selbst (z. B. =B2+1 wird zu =#REF+1) — dasselbe, was Excel inline anzeigt. #REF ist dabei keine Notation, sondern ein echtes Grammatik-Element (ErrorLiteralNode, s. „Formelgrammatik" unten): jeder der sieben Fehlercodes (#REF, #DIV/0!, #NAME?, #ERR, #CIRC, #VALUE!, #N/A) lässt sich jetzt auch direkt in eine Formel tippen, z. B. =IF(A1=0;#N/A;A1).
  • $-gesperrte Referenzen ($B$5) verschieben sich trotzdem — anders als beim Kopieren ist $ hier irrelevant: die referenzierte Zelle hat sich strukturell bewegt, unabhängig davon, ob ihr Bezug gesperrt war.
  • Eine Referenz vor der Einfüge-/Löschstelle bleibt byte-identisch stehen (auch Groß-/Kleinschreibung, Leerzeichen) — nur das Notwendige wird angefasst.
  • Die aktuelle Auswahl wandert mit, falls sie auf der bearbeiteten Tabelle liegt.
  • Undo/Redo-fähig wie jede andere Änderung.

Abhängigkeiten erkunden (🕸)

Rechtsklick auf eine Zelle → „🕸 Abhängigkeiten anzeigen" öffnet ein andockbares Panel rechts (wie das Einstellungen-Panel links) — kein Vollbild-Dialog, damit Blatt-Tabs, Statusleiste und Canvas die ganze Zeit sichtbar und bedienbar bleiben: der Blattbezug geht beim Erkunden nie verloren. Zeigt für die Zelle:

  • Vorgänger — jede Zelle, die ihre Formel direkt liest (bei einem Bereich wie SUM(B2:E2) jede einzelne Zelle darin).
  • Nachfolger — jede Zelle, die diese Zelle direkt liest, auch über Tabellen-/Blattgrenzen hinweg.
  • Jeder Eintrag zeigt Blatt · Tabelle!Zelle (Blattname immer, nicht nur bei Mehrdeutigkeit) sowie den aktuellen Wert.

Ein Klick auf einen Eintrag navigiert wirklich dorthin — nicht nur innerhalb des Panels: Er wählt die echte Zelle im Dokument aus, wechselt bei Bedarf das Blatt-Tab, und das Panel zentriert sich neu auf sie. Formelleiste, Statusleiste und die Tabellen selbst zeigen danach exakt dieselbe Zelle, die im Panel zentriert ist — das Erkunden des Formel-Netzwerks ist Navigieren, keine isolierte Nebenansicht. „← Zurück" geht die so besuchten Zellen rückwärts durch (auch das mit echter Navigation, nicht nur Panel-intern).

Ein Zirkelbezug (#CIRC) wird rot markiert (⚠), sowohl als Zentrum als auch als Vorgänger/Nachfolger-Eintrag — die Kante existiert im Abhängigkeitsgraphen unabhängig davon, dass sie bei der Neuberechnung übersprungen wird (s. „Fehlermodell" unten).

Bewusst ohne: kein automatisches Scrollen der Zieltabelle zur navigierten Zelle, falls sie außerhalb des sichtbaren Ausschnitts liegt (bei einer „Feste Größe"-Tabelle mit vielen Zeilen); keine Gruppierung vieler Vorgänger/Nachfolger zu einem Bereichs-Eintrag (Umsatz!B2:B100 erscheint als 99 einzelne Zeilen, nicht als ein zusammengefasster Eintrag).

Zell-Formatierung

Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im Inspector unter Objekt · Auswahl: horizontale Ausrichtung (links/mitte/rechts), Zahlenformat (Automatisch, Ohne, Ganzzahl, 1/2 Nachkommastellen, Prozent 0/2 Stellen, Datum/Zeit/Datum + Zeit — kombiniert mit der Locale unten — sowie Datum (ISO), das immer JJJJ-MM-TT zeigt, unabhängig von der Locale), Zell-Textfarbe, Zell-Füllfarbe, Fett/Kursiv, „Formatierung entfernen". Auch exakte Spaltenbreite und Zeilenhöhe der Fokuszelle.

Ein Zahnrad neben der Überschrift öffnet eine linke Inspector-Schublade (SettingsPanel.vue), die die Fläche zur Seite schiebt (auf schmalen Viewports überlagert sie). Sie bündelt alle wichtigen Punkte:

  • Objekt (das gewählte):
    • Tabelle → Name, Zeilen/Spalten (exakt), Darstellung, Viewport-Größe, fixierte Kopfzeilen/Spalten, Position, Zahlenformat-Locale, Standard- Zeilenhöhe, Tabellen-Hintergrund/-Textfarbe, „Ohne Rahmen/ Kopfzeilen, wenn nicht ausgewählt" (s. u.), und – bei aktiver Zellauswahl – die Auswahl-Formatierung (s. o.).
    • Diagramm → Name, Ansicht, Quelltabelle, Größe, Position, Zahlenformat-Locale, Hintergrund, Text-/Achsenfarbe.
    • Textfeld → Name, Text, Schriftgröße, Fett/Kursiv, Ausrichtung, Textfarbe, Hintergrund, Größe, Position.
  • Blatt: Name, Zahlenformat, Hintergrundfarbe, Schriftfarbe, Raster ein-/ausblenden, Rasterweite (↺ setzt eine Farbe auf den Theme-Wert zurück), neues Blatt, Blatt löschen.
  • Ansicht: Zoom-Regler (pro Blatt gespeichert).
  • Dokument: Erscheinungsbild (Auto/Hell/Dunkel), Zahlenformat (Ländereinstellung), CSV-Standard-Trennzeichen, „Auf Beispiel zurücksetzen".

Blatt-Erscheinungsbild

Jedes Blatt kann eine eigene Hintergrundfarbe (Canvas-Fläche hinter den Objekten) und Schriftfarbe (Standard-Textfarbe der Gitter/Labels) haben und das Punkt-Raster ein- oder ausblenden. Die Farben überschreiben die Theme-Variablen --bg / --text nur für dieses Blatt; ohne Wert gilt das Theme. Der Canvas-Zoom wird ebenfalls pro Blatt gemerkt.

Die Rasterweite (Sheet.gridSize, 4–200 px, Standard 24) ist ebenfalls pro Blatt einstellbar (Einstellungen → Blatt) und dient doppelt: sie bestimmt sowohl den Abstand des sichtbaren Punkt-Rasters (skaliert mit dem Zoom, damit die Punkte immer exakt dort liegen, wo ein Objekt tatsächlich einrastet) als auch die Schrittweite beim Einrasten (Snapping), wenn ein Tabellen-, Diagramm-, Text- oder Button-Objekt per Maus verschoben oder (bei „Feste Größe"/Diagramm/Text/Button) frei skaliert wird. Eine „Automatisch"-Tabelle rastet beim Skalieren stattdessen bereits durch ihre Zeilen-/Spaltengrenzen selbst ein und wird nicht zusätzlich pixelweise gerastert.

Alle diese Einstellungen sind Teil des Dokuments (DocumentState / Sheet) und werden damit in localStorage gespeichert und in .json exportiert – Round-Trip verlustfrei, ungültige Werte werden beim Import verworfen bzw. geklemmt.

Zahlenformat (Ländereinstellung)

Die Darstellung von Zahlen (Dezimal- und Tausendertrennzeichen) ist eine Kaskade: Objekt → Blatt → Dokument. Jede Ebene kann die darüberliegende überschreiben oder „erben" (leere Auswahl). Auswahl: Ohne Trennzeichen, de-DE, de-CH, en-US, en-GB, fr-FR, it-IT. Wirkt auf Zellwerte, das Live-Aggregat der Statusleiste und die Wertelabels der Diagramme. CSV-/XLSX- Export bleibt bewusst locale-neutral (.-Dezimal, keine Gruppierung) für einen verlustfreien Round-Trip; der CSV-Import erkennt beide Schreibweisen ohnehin.

Das vorbefüllte „Umsatz" ist bereits als feste, scrollbare Tabelle mit fixierter Kopfzeile und fixierter Spalte A angelegt, um das Feature zu zeigen.


Formelgrammatik (EBNF)

formula     = "=" expression ;

expression  = comparison ;
comparison  = concat [ ("="|"<>"|">"|"<"|">="|"<=") concat ] ;      (* höchstens
                                                       ein Vergleich, nicht
                                                       verkettbar wie
                                                       "a < b < c"; vor allem
                                                       für IF-Bedingungen in
                                                       „Mit Schleife"-Code
                                                       (s. o.), aber in jeder
                                                       Formel nutzbar         *)
concat      = additive { "&" additive } ;          (* links-assoziativ; Text-
                                                       Verkettung, bindet
                                                       schwächer als "+ -",
                                                       stärker als ein
                                                       Vergleich -- derselbe
                                                       Platz wie bei Excel     *)
additive    = term { ("+" | "-") term } ;          (* links-assoziativ *)
term        = unary { ("*" | "/") unary } ;         (* links-assoziativ *)
unary       = ("+" | "-") unary
            | power ;
power       = primary [ "^" unary ] ;               (* rechts-assoziativ; bindet
                                                       stärker als unäres Minus,
                                                       d.h. -2^2 = -(2^2) = -4    *)
primary     = NUMBER | STRING | ERRORLIT | "(" expression ")" | funcall | reference | namedref ;

funcall     = IDENT "(" [ expression { ";" expression } ] ")" ;

namedref    = IDENT ;                               (* ein unqualifizierter,
                                                       nicht zellförmiger,
                                                       nicht in `(` mündender
                                                       Bezeichner -- ein
                                                       benannter-Bereich-
                                                       Kandidat, s. u.; nur
                                                       außerhalb eines
                                                       Funktionskörpers       *)

reference   = [ sheetQualifier ] [ qualifier ]
              ( CELL [ ":" CELL ] | COLPART ":" COLPART | ROWPART ":" ROWPART ) ;
                                                    (* B:B / A:C = ganze
                                                       Spalten, 1:1 / 2:5 =
                                                       ganze Zeilen, s. u.     *)
qualifier   = (IDENT | QUOTED) ("!" | "::") ;       (* Tabellenname *)
sheetQualifier = (IDENT | QUOTED) "!" ;             (* Blattname -- nur erkannt,
                                                       wenn direkt gefolgt von
                                                       einem vollen "Tabelle::"
                                                       (3-teilige Form); ein
                                                       einzelnes "X!Y" ohne
                                                       folgendes "::" bleibt
                                                       die alte 2-teilige Form
                                                       (X = Tabelle)           *)

IDENT       = /[A-Za-z_][A-Za-z0-9_]*/ ;
QUOTED      = /' ( [^'] | '' )* '/ ;                (* Name mit Leerzeichen  *)
CELL        = /\$?[A-Za-z]{1,3}\$?[0-9]{1,7}/ ;
COLPART     = /\$?[A-Za-z]{1,3}/ ;
ROWPART     = /\$?[0-9]{1,7}/ ;                     (* 1-basiert, ab 1 *)
ERRORLIT    = "#REF" | "#DIV/0!" | "#NAME?" | "#ERR" | "#CIRC" |
              "#VALUE!" | "#N/A" ;                  (* ein Fehlercode, direkt
                                                       getippt oder von einem
                                                       Zeilen/Spalten-Löschen
                                                       erzeugt, s. u.         *)

Präzedenz schwach → stark: Vergleich (= <> > < >= <=) < & < + - < * / < unäres + - < ^.

Vergleiche sind numerisch, wenn beide Seiten Zahlen sind (leere Zelle zählt als 0), und textuell — case-insensitiv, getrimmt, wie bei XLOOKUP (s. u.) —, wenn beide Seiten Text sind (leere Zelle zählt dann als ''). Eine echte Zahl gegen echten Text: =/<> antworten eindeutig falsch/wahr, > < >= <= ergeben #VALUE! (kein sinnvoller Vergleich über Typgrenzen). Nützlich z. B. für =A1="Nord" oder als Kriterium einer selbstgebauten COUNTIF/SUMIF-Funktion (s. „SUMIF/COUNTIF/AVERAGEIF nachbauen" oben).

& verkettet als Text, z. B. =A1&" "&B1 oder ="Herr "&Name. Eine Zahl wird dabei ohne Locale-Formatierung dargestellt (5, nicht 5,00), eine leere Zelle trägt '' bei (nicht '0'). Bindet schwächer als +/- (="a"&1+2 = "a3", nicht "a1"+2), aber stärker als ein Vergleich (=A1&B1="ab" prüft das Ergebnis der Verkettung, nicht B1="ab" allein). CONCAT(Text1; Text2; …)/CONCATENATE(...) machen dasselbe als Funktion, ohne Operator-Präzedenz zu beachten — nützlich mit vielen Teilen oder innerhalb eines „Mit Schleife"-Codes.

Ein unqualifizierter Bezeichner, der weder eine Zelle noch (mit paramNames) ein Parameter ist, parst als namedref — ein Kandidat für einen benannten Bereich (s. „Benannte Bereiche" oben), erst zur Auswertung/Abhängigkeits-Ermittlung gegen das Dokument aufgelöst (genau wie ein Tabellen-Qualifier nie beim Parsen selbst geprüft wird). Ein unbekannter Name ergibt #NAME?. Nur außerhalb eines Funktionskörpers — mit paramNames bleibt ein unqualifizierter, nicht-zellförmiger Bezeichner ein harter Parse-Fehler wie zuvor (s. „Benannte Bereiche"s „Bewusst ohne"). Ein qualifizierter nicht-zellförmiger Bezeichner (Tabelle!Foo) ist immer ein Parse-Fehler — ein benannter Bereich ist nie qualifiziert.

Ein Fehlercode lässt sich auch direkt in eine Formel tippen, z. B. =IF(A1=0;#N/A;A1) oder =1+#REF. Wertet unbedingt zu genau diesem Fehler aus, wie eine Zahl- oder Text-Konstante. Alle sieben Codes sind erlaubt: #REF, #DIV/0!, #NAME?, #ERR, #CIRC, #VALUE!, #N/A — ohne das in Excel übliche ! am Ende von #REF!, da diese Engine ihren eigenen Fehlercode konsequent ohne ! schreibt (#REF, nicht #REF!; siehe Fehlermodell unten). Genau das erzeugt auch ein Zeilen-/Spalten-Löschen automatisch für eine dadurch ungültig gewordene Referenz (s. „Zeilen & Spalten einfügen/löschen" oben) — die Formel zeigt danach z. B. =#REF+1 statt einer stillen Fehladressierung.

Der (innere) Qualifier ist der Tabellenname. ! und der Numbers-Stil :: sind dabei austauschbar und case-/whitespace-tolerant: Kosten!A2 = 'Kosten'!A2 = kosten!a2 = Kosten::A2.

Tabellennamen sind nur pro Blatt eindeutig, nicht dokumentweit — zwei verschiedene Blätter dürfen je eine Tabelle „Umsatz" haben (beim Anlegen/ Umbenennen wird nur gegen das eigene Blatt geprüft). Ein unqualifizierter Bezug wie Umsatz!B2 (ohne Blattangabe) löst sich dann wie folgt auf:

  1. Gibt es die Tabelle nur auf einem Blatt (der Normalfall), wird sie genommen — unverändert wie zuvor.
  2. Gibt es sie auf mehreren Blättern, gewinnt die Tabelle auf demselben Blatt wie die Formel selbst (nicht: dem gerade angezeigten Blatt in der UI — das spielt für die Formel keine Rolle).
  3. Bleibt es danach noch mehrdeutig, ergibt sich #REF — hier hilft die explizite 3-teilige Form Blatt!Tabelle::Zelle, z. B. =Analyse!Umsatz::B2 oder mit Leerzeichen ='Blatt 2'!Umsatz::B2, die immer genau die Tabelle auf dem genannten Blatt trifft, unabhängig davon, wo die Formel selbst steht. Die 2-teilige Form (Umsatz!B2 / Umsatz::B2) bleibt für jede bestehende Formel exakt gültig — die 3-teilige Form ist rein additiv.

Die Formel-Point-Mode-Einfügung (s. o.) erkennt eine mehrdeutige Zieltabelle selbst und fügt automatisch die volle 3-teilige Form ein, statt einer Formel, die versehentlich die falsche Tabelle träfe.

Referenzen bleiben nach dem Umbenennen konsistent. Wird eine Tabelle umbenannt (Doppelklick auf ihren Namen in der Kopfleiste), schreibt der Store dokumentweit jede Tabelle!Zelle-/Tabelle::Zelle-Referenz auf sie um — egal in welcher anderen Tabelle sie steht, egal ob 2- oder 3-teilige Form (bei der 3-teiligen Form wird nur der Tabellen-Teil ersetzt, der Blatt-Teil bleibt unangetastet), egal ob Einzelzelle oder Bereich. Nur die betroffene Qualifier-Textspanne wird ersetzt (nötigenfalls mit '...' gequotet, wenn der neue Name Leerzeichen/Sonderzeichen enthält) — der Rest der Formel, inklusive Whitespace, bleibt byte-identisch, dieselbe Technik wie beim dokumentweiten Umschreiben durch Zeilen-/ Spalten-Einfügen (s. u.). Umbenennen + Umschreiben zählen zusammen als ein Undo-Schritt. Damit bleibt eine Formel nach dem Umbenennen exakt so gültig wie vorher — ohne dieses Umschreiben würde jede Formel, die noch den alten Namen nennt, beim nächsten Neuberechnen #REF zeigen (der Tabellenname existiert für sie dann schlicht nicht mehr). Bewusst begrenzt auf Tabellenzellen — Makro-Aktionen adressieren eine Tabelle über einen zur Laufzeit aufgelösten Text-String (s. „Makros" oben), nicht über eine Formel-Qualifier-Syntax, und ein benutzerdefinierter Funktionskörper wird ebenfalls nicht durchsucht (dieselbe, bereits bestehende Begrenzung wie beim dokumentweiten Zeilen-/Spalten-Umschreiben).

$-Bezüge sperren (relativ/gemischt/absolut): A1 (relativ) vs. $A1 / A$1 (Spalte bzw. Zeile gesperrt) vs. $A$1 (beide gesperrt). Für die Auswertung selbst ist $ irrelevant — sie ist rein positionsbasiert. Es zählt beim Kopieren: eine gesperrte Achse bleibt exakt wie eingegeben stehen, eine ungesperrte verschiebt sich um das Einfüge-Delta (siehe „Kopieren/ Ausschneiden/Einfügen" oben). Beim Ausschneiden gilt das nicht — dort verschiebt sich die referenzierte Zelle selbst, also folgt jede Referenz darauf, $ hin oder her. F4 schaltet den Bezug unter dem Cursor durch die vier Kombinationen.

Funktions-Autovervollständigung: sobald in der Formelleiste oder im In-Zellen-Editor ein Funktionsname zu tippen beginnt (nach =, einem Operator, ( oder ; — nicht bei einem Zellbezug wie A1), erscheint ein Dropdown mit den passenden eingebauten und eigenen Funktionen samt Kurzsignatur (z. B. XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; …)). ↑/↓ navigieren, Tab/Enter oder ein Klick übernehmen den markierten Vorschlag (fügt NAME( ein, bereit für die Argumente), Esc schließt nur das Dropdown, ohne die Bearbeitung selbst abzubrechen.

Funktionen (case-insensitiv, Trenner ;): SUM, AVG (Alias AVERAGE), MIN, MAX, COUNT, CONCAT (Alias CONCATENATE), IF(Bedingung; Dann; Sonst?), AND(Bedingung1; Bedingung2; …), OR(Bedingung1; Bedingung2; …), NOT(Bedingung), DATE(Jahr; Monat; Tag), TIME(Std; Min; Sek), TODAY(), NOW(), die Datums-/Zeitfunktionen YEAR, MONTH, DAY, HOUR, MINUTE, SECOND, WEEKDAY, ISOWEEKNUM, WEEKNUM, EDATE, EOMONTH, DAYS, DATEDIF, NETWORKDAYS, WORKDAY, DATEVALUE, TIMEVALUE (s. „Datum & Zeit" unten), XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus]; [Suchmodus]) — plus alle über den „fx"-Button angelegten benutzerdefinierten Funktionen (siehe „Benutzerdefinierte Funktionen" oben), die sich für den Aufrufer identisch verhalten. Argumente gemischt: Einzelzellen, Bereiche (lokal oder Name!…), Zahl-Literale — z. B. =SUM(Umsatz!B2:E2; 100; A1). Ein optionales Argument darf übersprungen werden, indem die Stelle einfach leer bleibt (XLOOKUP(x; a; b; ; -1) lässt „WennNichtGefunden" aus, um „Vergleichsmodus" zu erreichen) — wie in Excel; eine leere Stelle zählt wie eine leere Zelle (0 in Arithmetik, von COUNT nicht mitgezählt).

IF ist die einzige Funktion, die ihre Argumente nicht vorab auswertet: nur die Bedingung wird immer berechnet, danach ausschließlich der tatsächlich genommene Zweig — der andere Zweig wird gar nicht ausgewertet, ein Fehler darin (z. B. =IF(B1=0; 0; A1/B1), wenn B1 0 ist) schlägt also nie durch. Die Bedingung nutzt dieselbe Zahl-Semantik wie die Vergleichsoperatoren = <> > < >= <= (Ergebnis 1/0, keinen eigenen Wahrheitstyp): jede Zahl außer 0 sowie jeder nicht-leere Text gilt als wahr, z. B. =IF(A1>=100; "groß"; "klein"). Ohne Sonst-Zweig liefert eine falsche Bedingung 0. Ein benutzerdefiniertes „IF" ist nicht anlegbar — der Name kollidiert mit der eingebauten Funktion.

AND/OR/NOT verknüpfen mehrere Bedingungen — z. B. =IF(AND(A1>0; A1<10); "im Bereich"; "außerhalb"), da ein einzelner Vergleich (= <> > < >= <=) nicht verkettbar ist (1<A1<10 ist kein gültiger Ausdruck). Anders als IF werten sie alle Argumente aus, auch wenn das Ergebnis schon feststeht — genau wie in Excel: =AND(1=2; 1/0) ergibt #DIV/0!, nicht 0/falsch. AND/OR akzeptieren beliebig viele Argumente inkl. Bereichen (=AND(A1:A5) prüft jede Zelle einzeln), NOT genau eines. Dieselbe Wahrheits-Semantik wie überall sonst: jede Zahl außer 0 sowie jeder nicht-leere Text gilt als wahr, eine leere Zelle als falsch. Funktioniert genauso in einer „Mit Schleife"-IF-Bedingung (s. o.), z. B. IF AND(x > 0; x < schwelle) THEN … END.

Ganze Spalten und Zeilen: B:B (Spalte B), A:C (Spalten A bis C), 1:1 (Zeile 1) und 2:5 (Zeilen 2 bis 5) — auch mit $ ($B:$B) und mit Tabellen-/Blattnamen (Umsatz!B:B, Umsatz::1:1, 'Blatt 2'!Kosten::A:C). So ein Bezug reicht immer bis zum aktuellen Ende der Tabelle: wächst die Tabelle (neue Zeilen per Größenänderung oder durch Eintippen unterhalb), zählt =SUM(B:B) die neuen Zellen automatisch mit. Einfügen/Löschen von Spalten verschiebt B:B wie einen Bereich (Spalte vor B eingefügt → C:C, Spalte B gelöscht → #REF), Zeilen-Änderungen lassen eine ganze Spalte unberührt — für ganze Zeilen umgekehrt. Beim Kopieren verschiebt sich B:B nur seitlich, 1:1 nur nach oben/unten, $-gesperrte Teile gar nicht. Steht die Formel selbst in der summierten Spalte/Zeile, ist das ein Zirkelbezug (#CIRC), wie in Excel — das Ergebnis also in eine andere Spalte bzw. Zeile oder Tabelle schreiben.

Datum & Zeit: ein Datum ist — wie in jeder Tabellenkalkulation — intern einfach eine Zahl (Tage seit dem 30.12.1899, Uhrzeit als Bruchteil eines Tages), keine eigene Zellart. Deshalb funktioniert Datums-Arithmetik ganz ohne Zusatzaufwand: =fällig - rechnungsdatum zählt Tage, `=rechnungsdatum

  • 30` addiert 30 Tage. Zwei Wege zu einem Datumswert:
  • Eingabe erkennen: ein eindeutig lesbares Datum/eine Uhrzeit als reine Zelleingabe (nicht als Formel) wird beim Eintippen automatisch erkannt und sofort mit dem passenden Format angezeigt — genau wie in Excel/Numbers. Erkannt werden ISO (2026-09-02, auch mit 14:30/14:30:15 angehängt) und Punkt-Daten (02.09.2026, 02.09.26, ebenfalls mit Uhrzeit), sowie reine Uhrzeiten (14:30); ab 24 Stunden (37:30) wird daraus eine Dauer. Absichtlich nicht erkannt: Schrägstrich-Daten (02/09/2026) — Tag-zuerst vs. Monat-zuerst ist über die unterstützten Locales (en-US ist monatzuerst, alle anderen tagzuerst) nicht eindeutig auflösbar; falsches Raten würde das Datum lautlos verfälschen, deshalb bleibt so ein Text unangetastet. Die automatische Formatierung überschreibt nie ein bereits explizit gesetztes Zellformat.
  • Formel: DATE(Jahr; Monat; Tag) und TIME(Std; Min; Sek) bauen einen Datums-/Zeitwert (Überlauf wie in Excel erlaubt: DATE(2026;13;1) = 01.01.2027, TIME(25;0;0) = 01:00 des Folgetags-Anteils); TODAY() / NOW() liefern das aktuelle Datum bzw. Datum+Uhrzeit. Formel-Ergebnisse bekommen kein automatisches Zellformat — das Zahlenformat-Dropdown auf „Datum"/„Zeit"/„Datum + Zeit" stellen. Einzige Ausnahme: eine Formel, deren Zahlen-Eingaben alle Zeiten oder Dauern sind (=SUM(B2:B8) über Arbeitszeiten, =C2-B2 für Ende − Beginn), zeigt ihr Ergebnis als Dauer, solange die Zelle auf „Automatisch" steht. TODAY/NOW sind nicht volatil: diese Engine rechnet rein vorgänger-getrieben neu, ohne Timer — eine Zelle mit =TODAY() aktualisiert sich erst bei ihrer eigenen nächsten Neuberechnung, nicht von selbst beim Tageswechsel (siehe „Bekannte Grenzen" unten).

Das Zahlenformat „Dauer" zählt Stunden über 24 hinaus weiter (37:30 statt 13:30) — für Summen von Arbeits- oder Laufzeiten; Sekunden erscheinen nur, wenn welche da sind (100:00:30). „Zeit" bleibt eine Uhrzeit und springt nach 24 Stunden wieder auf 00:00. „Datum + Zeit" zeigt bei einer reinen Uhrzeit (unter einem Tag) nur die Uhrzeit statt des Nullpunkts 30.12.1899.

Datums- und Zeitfunktionen: YEAR/MONTH/DAY/HOUR/MINUTE/SECOND lesen einen Teil aus (auf ganze Sekunden gerundet). Wo Excel eine US-Voreinstellung hat, gilt hier ISO 8601: WEEKDAY(Datum; [Typ]) zählt Montag = 1 … Sonntag = 7 (Typ 1: Sonntag = 1, Typ 3: Montag = 0), ISOWEEKNUM und WEEKNUM(Datum; [Typ]) liefern die ISO-Kalenderwoche (WEEKNUM Typ 1/2: US-Zählung mit Wochenbeginn Sonntag/Montag). EDATE(Datum; Monate) verschiebt um Monate (31.01. + 1 = 28./29.02.), EOMONTH(Datum; Monate) liefert den Monatsletzten, DAYS(Ende; Start) die Kalendertage dazwischen, DATEDIF(Start; Ende; Einheit) den Abstand in "Y"/"M"/"D" bzw. als Rest "YM"/"MD"/"YD". NETWORKDAYS(Start; Ende; [Feiertage]) zählt Arbeitstage (Mo–Fr, beide Enden eingeschlossen), WORKDAY(Start; Tage; [Feiertage]) rechnet eine Frist in Arbeitstagen — Feiertage als Zellen/Bereich. DATEVALUE(Text) und TIMEVALUE(Text) lesen Datum bzw. Uhrzeit aus einem Text in jedem Format, das auch beim Eintippen erkannt wird. Ungültige Argumente ergeben #VALUE!.

Das Zahlenformat „Datum (ISO)" zeigt ein Datum immer als JJJJ-MM-TT (2026-09-02) — unabhängig von der aktiven Locale (Blatt/Dokument oder Zelle), anders als „Datum", das der jeweiligen Locale folgt (02.09.2026 bei de-DE, 09/02/2026 bei en-US). Nützlich für export-nahe oder sortierbare Datumsspalten.

XLOOKUP(Suchwert; Suchbereich; Rückgabebereich; [WennNichtGefunden]; [Vergleichsmodus]; [Suchmodus]): sucht Suchwert in Suchbereich und liefert die Zelle an derselben Position in Rückgabebereich. Text wird case-insensitiv und getrimmt verglichen ("birne" findet „Birne"); Zahl und Text gelten nie als gleich (keine automatische Typumwandlung). Suchbereich/Rückgabebereich müssen gleich viele Zellen haben, sonst #VALUE!. Kein Treffer und kein WennNichtGefunden angegeben → #N/A (dokumentierte Erweiterung, wie schon #VALUE!); WennNichtGefunden wird — wie bei IF — nur ausgewertet, wenn tatsächlich kein Treffer gefunden wurde, ein Fehler darin schlägt bei einem Treffer also nie durch. Vergleichsmodus: 0 (Standard) exakt, -1/1 exakt oder nächstkleinerer/ -größerer numerischer Wert (kein Näherungsvergleich auf Text). Excels Platzhalter-Modus (2) ist nicht unterstützt. Suchmodus: 1 (Standard) erster Treffer zuerst, -1 letzter Treffer zuerst; Excels binäre Suchmodi (2/-2, setzen einen sortierten Bereich voraus) sind nicht unterstützt — bei den Tabellengrößen von numolus ohne Vorteil. Beispiel: =XLOOKUP(B7; A2:A6; C2:C6; "unbekannt").

Fehlermodell (propagiert an abhängige Zellen): #REF (ungültiger/gelöschter Bezug, unbekannter Tabellen-/Blattname, oder ein unqualifizierter Tabellenname, der auf mehreren Blättern existiert und auch über die „gleiches Blatt wie die Formel"-Regel nicht eindeutig wird — siehe oben), #DIV/0!, #NAME? (unbekannte Funktion, eingebaut oder benutzerdefiniert), #ERR (Parse-Fehler; auch eine falsche Argumentanzahl bei einer benutzerdefinierten Funktion, oder bei „Mit Schleife"-Code eine ungültige FOR-Quelle, mehr als 10.000 Schleifenschritte, oder eine Ausführung ohne je erreichtes RETURN), #CIRC (Zirkelbezug, auch tabellenübergreifend), #VALUE! (Typfehler in Arithmetik — dokumentierte Erweiterung), #N/A (XLOOKUP ohne Treffer und ohne WennNichtGefunden — dokumentierte Erweiterung). Referenz auf leere Zelle = 0 in Arithmetik, von COUNT nicht gezählt. Ein Zyklus zwischen benutzerdefinierten Funktionen selbst wird nicht über #CIRC sichtbar — er wird schon beim Speichern der Funktion im UI abgelehnt (kein neuer Fehlercode nötig).


Architektur der Engine-Module

Framework-agnostisches ES-Modul unter src/engine/ — keine Formel-/Spreadsheet-Library. Kennt kein Vue, kein DOM. Die adressierbare Einheit ist eine Tabelle; der Abhängigkeitsgraph läuft dokumentweit über alle Tabellen aller Blätter.

Datei Aufgabe
lexer.ts Tokenizer, flacher Token-Strom (inkl. ! und ::).
parser.ts Recursive-Descent → AST; EBNF im Dateikopf; collectRefs().
evaluator.ts AST + EngineHost → Wert; Fehler-Propagierung.
graph.ts DependencyGraph: affected(), topoOrder(), Zirkel-DFS.
refs.ts A1 ↔ (col,row), Bereichs-Expansion.
functions.ts { NAME → fn(values) } für SUM/AVG/MIN/MAX/COUNT.
userFunctions.ts Benutzerdefinierte Funktionen: expandUserFunctions() setzt einen kind: 'expression'-Aufruf beim Parsen der aufrufenden Zelle als Kopie des (rekursiv expandierten, parametersubstituierten) Funktionskörpers in deren AST ein — kein Laufzeit-Closure. Ein kind: 'block'-Aufruf (FOR/IF/RETURN-Code) wird stattdessen zu einem BlockCallNode mit bereits aufgelösten verschachtelten Aufrufen; sein eigener Kontrollfluss läuft erst zur Auswertungszeit, im kleinen Interpreter in evaluator.ts (echte Zellwerte nötig für IF/frühes RETURN).
workbook.ts Flache Tabellenliste + ASTs + Werte + Graph; inkrementelle Neuberechnung; implementiert EngineHost; verdrahtet userFunctions.ts direkt nach parseFormula().

Neuberechnung. setCell reparst eine Zelle → graph.affected(dirty) → graph.topoOrder() → Zyklus-Zellen #CIRC, Rest topologisch auswerten, nicht betroffene Zellen behalten ihren Wert.

Anbindung an Vue. src/store/document.ts ist das „eigene reactive()-Modul": es hält eine Workbook-Instanz und einen recalcTick-Ref. Die UI liest store.cellValue(...), das recalcTick berührt — nach jeder Neuberechnung wird der Tick erhöht, jede Formelzelle re-rendert also wie ein computed.


Selbst gebaut vs. Library

Selbst gebaut Library
Formel-Engine komplett (Lexer, Parser, AST, Evaluator, dokumentweiter Abhängigkeitsgraph, Topo-Sort, Zirkel-Erkennung, Funktionen) Vue 3 + Vite
Die Canvas: Objekte platzieren, verschieben, skalieren, Z-Order, Zoom Chart.js (Balken + Linie)
Das Raster je Tabelle: Auswahl (Klick/Shift/Drag), Tastaturnavigation, In-Zellen-Edit, Spaltenbreite per Drag xlsx (SheetJS) — nur .xlsx I/O, lazy geladen, nie zur Auswertung
Reaktiver Store / Dokumentmodell (v1 → v2 Migration) —
CSV-Parser + -Writer (RFC 4180, Quoting, Delimiter-Sniffing, Locale-Zahlen)
JSON-Serialisierung + Schema-Validierung
Sheet-Tabs (Drag-Sortierung), Kontextmenü, Modals, Toasts, Theme

Bewusst nicht verwendet: Handsontable, AG Grid, Univer, Luckysheet, HyperFormula, formula-parser. @vueuse/core/splitpanes (in der Aufgabe als optionale Komfort-Libs genannt) wurden nach dem Umbau auf das Canvas-Modell nicht mehr gebraucht und wieder entfernt, um den Abhängigkeitsbaum ehrlich zu halten.


Import / Export

Alles clientseitig (Blob + URL.createObjectURL + <a download> + revoke).

Export

Format Inhalt
.num Speicherformat (Datei › Speichern): ZIP-Container mit demselben JSON wie .json, nur kleiner. Aufbau wie OpenDocument: zuerst mimetype (application/x-numolus, unkomprimiert), dann meta.json (Format-Version, Erstellzeit als ISO 8601) und document.json. Eine Datei aus einer neueren Format-Version wird mit klarer Meldung abgelehnt.
.json Natives Format (unkomprimiert), verlustfreier Round-Trip: alle Blätter, alle Tabellen-/Diagramm-Objekte mit Position & Größe, Rohformeln, Spaltenbreiten, Tab-Reihenfolge, aktives Objekt, Zellauswahl.
.csv Aktive Tabelle, berechnete Werte, RFC-4180-Quoting, Trennzeichen ,/; wählbar, \r\n, optional UTF-8-BOM.
.xlsx Arbeitsmappe via SheetJS, ein Worksheet je Tabellen-Objekt. Formeln als echte Excel-Formeln (;→,, AVG→AVERAGE), immer mit gecachtem Wert.
.png Gewähltes Diagramm-Objekt über chart.toBase64Image().

Import

<input type="file"> und Drag & Drop auf die Canvas; .num .json .csv .xlsx.

  • .num / .json → Dokument ersetzen (Rückfrage) oder Blätter anhängen. v1-Dokumente (altes Ein-Raster-Format) werden automatisch nach v2 migriert (je altes Blatt ein Blatt mit einer gleichnamigen Tabelle). Schema wird validiert; bei Fehler klare Meldung, kein Teil-Import.
  • .csv → „als neue Tabelle (aktuelles Blatt)" / „aktive Tabelle ab A1 ersetzen". Trennzeichen-Sniffing (+ Override). Locale-Erkennung (1.234,56 ↔ 1,234.56). Werte mit führendem = werden zu Formeln.
  • .xlsx → je Worksheet ein neues Blatt mit einer Tabelle.
  • Nach jedem Import: kompletter Reparse + Neuberechnung + Diagramm-Refresh. Schlägt ein Import fehl, bleibt das bestehende Dokument unverändert.

Persistenz

Das gesamte Dokument wird (debounced) nach localStorage gespiegelt, robust gegen leeren/kaputten State — Fallback ist ein leeres Dokument (ein Blatt „Blatt 1" mit einer leeren „Tabelle 1"). Ein v1-State im Storage wird beim Laden migriert. Datei › Neu legt ein neues leeres Dokument an, Datei › Muster · Umsatzanalyse lädt das vorbefüllte Musterdokument (beides nach Rückfrage, die bisherigen Änderungen gehen verloren).

Vorbefülltes Dokument (Datei › Muster)

Blatt „Analyse" mit drei Tabellen-Objekten (Umsatz, Kosten, Übersicht) und einem Diagramm (Umsatz je Region), plus ein leeres Blatt „Blatt 2". „Übersicht" demonstriert tabellenübergreifende Bezüge und die vier Grundrechenarten: Übersicht!B2 = =SUM(Umsatz!B2:E2), D2 = =B2-C2, E2 = =D2/B2*100. Siehe src/data/seed.ts.

Hinweis zur Aufgabenstellung: Die Akzeptanzliste nennt „Übersicht!B2 = 6070". Die explizit angegebene Nord-Zeile ergibt jedoch 1200+1450+1600+1720 = 5970. numolus hält sich an die explizite Tabelle; das demonstrierte Verhalten (Sofort-Propagierung durch Übersicht, „Gesamt" und das Diagramm bei Änderung von Umsatz!B2) ist davon unberührt.

Tests

npm run test — Vitest, 570 Tests:

  • lexer / parser — Präzedenz & Assoziativität (^ vor */ vor +-, ^ rechts, -2^2 = -(2^2)), lokale/qualifizierte/quoted Bezüge, ! und ::, Bereiche, ;-Argumente, Parse-Fehler.
  • refs ($-Sperrachsen, F4) — parseA1/toA1 runden relativ/absolut/ gemischt; cycleReferenceLocks durchläuft die vier Kombinationen, trifft bei Cursor auf einem Bereich die richtige Seite, liefert null ohne Treffer; bei echter Textauswahl schaltet es alle überlappten Bezüge auf einmal um (jeden vom eigenen Zustand aus) und lässt Tabellen-Qualifier, die wie eine Zelle aussehen (AB12!A1), unangetastet.
  • evaluator — Arithmetik, leere Zelle = 0, alle Funktionen inkl. Alias, Cross-Table, Case-/::-Toleranz, jeder Fehlerfall, Fehler-Propagierung, IF (beide Zweige, 2-/3-Argument-Form, Kurzschluss-Beweis, Vergleiche als Bedingung, Fehler in der Bedingung).
  • Leere Argumentstelle (;;) — parst als eigener Knoten, verhält sich überall wie eine leere Zelle (SUM(1;;3) = 4, von COUNT nicht mitgezählt); am Anfang, in der Mitte und am Ende einer Argumentliste, inkl. f() ganz ohne Argumente.
  • XLOOKUP — exakter Text-/Zahl-Treffer (case-insensitiv, kein Zahl-Text- Crossmatch), #N/A ohne/mit WennNichtGefunden, Kurzschluss-Beweis für WennNichtGefunden, Suchmodus 1/-1 bei Duplikaten, Vergleichsmodus -1/1 (exakter Treffer gewinnt immer, nächstkleiner/-größer, kein qualifizierender Kandidat), abgelehnte Modi (2, -2), ungleich große Bereiche → #VALUE!, falsche Argumentanzahl, Einzelzellen statt Bereich, übersprungenes optionales Argument (;;) erreicht ein späteres. Workbook-Ebene: Neuberechnung bei Änderung einer Zelle in entweder Bereich, keine Neuberechnung bei unbeteiligten Zellen.
  • Datum & Zeit — dates.ts: dateToSerial/timeSerial-Rundtrip gegen bekannte Werte, Überlauf-Verhalten, parseDateTimeLiteral für jede erkannte Form (ISO/Punkt, mit/ohne Uhrzeit) sowie Ablehnung von Schrägstrich-Daten und ungültigen/rollierten Daten; literalValue wandelt eine erkannte Eingabe in ihren Serial um; DATE/TIME/TODAY/NOW als Formel (Basisfälle, Arity-/Typ-Fehler, Datums-Arithmetik); formatValue über mehrere Locales inkl. plain und das locale-unabhängige „Datum (ISO)"; Store: Auto-Format beim Eintippen (überschreibt nie ein bereits gesetztes Format), Namenskollision mit den neuen eingebauten Funktionen.
  • graph — affected, topologische Ordnung, Selbst-/2er-/längerer Zyklus.
  • workbook / store — Seed-Aggregate, Cross-Table-Summen, Edit-Ripple durch die ganze Kette, #REF nach Tabellen-/Blatt-Löschung, neues Tabellen-Objekt mit Cross-Sheet-Formel, resetToSeed, Live-Auswahl-Aggregat, Neuberechnungs-Reihenfolge, v1 → v2 Migration.
  • Tabellen-Ansicht — Seed-Umsatz ist fixiert/scrollbar; setTableView klemmt fixierte Zeilen/Spalten an die Gittergröße; Verkleinern löscht Zellen außerhalb und klemmt nach; Ansichts-Einstellungen im JSON-Round-Trip.
  • Zahlenformat-Kaskade — cellDisplay nutzt Dokument-Locale; Blatt-Override schlägt Dokument, Objekt-Override schlägt Blatt; Zurücksetzen fällt die Kaskade hinab; Overrides im JSON-Round-Trip, ungültige Werte werden verworfen.
  • Blatt-Erscheinungsbild — Hintergrund/Schrift/Raster sind pro Blatt und löschbar; Zoom pro Blatt (folgt dem Tab); Erscheinungsbild + Zoom + CSV-Default im JSON-Round-Trip, kaputte Farb-/Zoom-Werte verworfen bzw. geklemmt.
  • Zell-Formatierung — per-Zelle Zahlenformat × Locale (inkl. Prozent); setRangeFormat über einen Bereich, Löschen per Falsy/auto; selectionFormat meldet nur gemeinsame Werte; Zeilenhöhe (Standard + je Zeile), Spaltenbreite ab 24 px; Verkleinern pruned formats/rowHeights; alles im JSON-Round-Trip.
  • Text-Objekte — einfügen/bearbeiten/formatieren + Round-Trip; kaputtes Text-Objekt im Import wird abgelehnt, Dokument bleibt unverändert.
  • Objekt-Farben — je Tabelle/Diagramm/Textfeld eigenes bg/fg, löschbar, im JSON-Round-Trip.
  • Mehrfachauswahl — addToSelection behält frühere Rechtecke & verschiebt die aktive Zelle, erneutes Klicken schaltet eine 1×1-Auswahl ab, extendActiveRange wächst das jüngste Rechteck; Aggregat & Formatierung über alle Rechtecke; #-genaue Zellzahl; JSON-Round-Trip inkl. ranges, Klemmung beim Verkleinern; die aktive Zelle bleibt beim Erweitern eines Bereichs der Anker (erste Zelle), nicht die zuletzt erreichte.
  • Zwischenablage — Kopieren passt jede relative Referenz um das Einfüge- Delta an, $-gesperrte Achsen bleiben stehen; Einfügen kachelt eine exakt passende größere Auswahl bzw. verankert/überläuft eine kleinere; Formatierung wird mitkopiert und am Ziel ersetzt; Ausschneiden folgt trotz $ der verschobenen Zelle, lässt aber unbeteiligte und tabellenfremde Referenzen unangetastet und leert die Quelle; Esc verwirft den Puffer.
  • Ausfüllkästchen — füllt runter/rechts mit bezugsangepassten Formeln; ein mehrzeiliger/-spaltiger Quellbereich wiederholt sich zyklisch, auch bei einer unvollständigen letzten Wiederholung; füllt ebenso hoch/links; Formatierung wird mitkopiert.
  • Rückgängig/Wiederholen — Einzel-Edit rückgäng-/wiederholbar; mehrere Edits schrittweise in der richtigen Reihenfolge zurück; ein neuer Edit nach einem Undo verwirft den Wiederholen-Ast; reine Auswahländerungen sind nie ein Schritt; deckt Kopieren/Einfügen, Ausfüllen und Formatierung ab (nicht nur setCellInput); „Auf Beispiel zurücksetzen" beginnt eine neue Historie.
  • Formel-Point-Mode — Klick fügt einen lokalen Bezug an der Cursorposition ein; ein zweiter Klick ohne Zwischen-Tippen ersetzt ihn, mit Tippen dazwischen hängt er einen neuen an; Ziehen fügt/erweitert einen Bereichsbezug; ein Klick in eine andere Tabelle qualifiziert mit deren Namen (Quoting bei Leerzeichen); ohne Formel im Puffer greift nichts.
  • Funktions-Autovervollständigung — Engine (refs.spec.ts): currentIdentifierAtCaret erkennt den getippten Namen nach =/Operator/(/ ;, ignoriert Zellbezüge (A1) und bereits abgeschlossene Aufrufe (SUM(), schaut nie über den Cursor hinaus, verkraftet einen kaputten Formel-Präfix ohne zu werfen. Store (document.spec.ts): functionSuggestions filtert case-insensitiv, mischt eingebaute und eigene Funktionen sortiert. functions.spec.ts: die Signatur-Tabelle deckt sich exakt mit den registrierten eingebauten Funktionen (kein künftiges Vergessen möglich).
  • Benutzerdefinierte Funktionen — Engine-Ebene (userFunctions.spec.ts): Parametersubstitution, verschachtelte Aufrufe (eingebaut + benutzerdefiniert, auch bei kollidierenden Parameternamen zwischen den Scopes), Arity-Mismatch → #ERR, unbekannter Name → #NAME?, Rekursionstiefen-Sicherheitsnetz, Abhängigkeits-Edges für Zellen, die nur im Funktionskörper vorkommen (nicht im Aufruf) — inkl. korrekter Neuberechnungs-Reihenfolge; ein Bereichs- Parameter funktioniert in Funktionsargument-Position (inkl. Neuberechnung bei Änderung einer Zelle innerhalb des übergebenen Bereichs) und ergibt #VALUE! außerhalb davon. Store-Ebene: Name-/ Parameter-Validierung, Kollision mit eingebauten/anderen Funktionen, Zyklus-Ablehnung beim Speichern (direkt und über mehrere Stufen), Umbenennen/ Löschen lässt bestehende Aufrufe zu #NAME? werden, Undo/Redo einer Funktionsänderung, voller JSON-Round-Trip, customFunctions: [] bei v1→v2-Migration.
  • Vergleichsoperatoren — alle sechs (= <> > < >= <=), Präzedenz unterhalb + -, nicht verkettbar (a < b < c ist ein Parse-Fehler); numerischer Vergleich, Text-Vergleich (case-insensitiv/getrimmt), leere Zelle nimmt den Typ der Gegenseite an, Zahl-gegen-Text-Mismatch (=/<> eindeutig, > < >= <= → #VALUE!).
  • &-Verkettung — Präzedenz zwischen + - und einem Vergleich, links-assoziativ, Zahl rendert locale-neutral, leere Zelle trägt '' bei, Fehler propagieren; CONCAT/CONCATENATE als Funktions-Äquivalent.
  • AND/OR/NOT — korrekte Wahrheitstabellen, Bereichsargument (jede Zelle einzeln), kein Kurzschluss (ein Fehler in einem späteren Argument schlägt durch, selbst wenn das Ergebnis schon feststeht, wie in echtem Excel), Komposition innerhalb eines IF und innerhalb einer „Mit Schleife"-IF-Bedingung, Arity-Fehler → #ERR.
  • Benannte Bereiche — Auflösung als Einzelzelle/Bereich (Skalar- und Funktionsargument-Position), case-insensitives/getrimmtes Namens-Matching, Abhängigkeitsgraph-Kante (Neuberechnung bei Änderung der Zielzelle), Überleben einer Tabellen-Umbenennung, #NAME? bei unbekanntem Namen und bei gelöschter Zieltabelle, addNamedRange/updateNamedRange/ deleteNamedRange-Validierung (Kollision mit Built-in/eigener Funktion/anderem Namen, Zellform), Undo/Redo, JSON-Round-Trip, Nutzung als FOR-Quelle in einer benutzerdefinierten Funktion (als übergebenes Argument), harter Parse-Fehler statt Fallback innerhalb eines Funktionskörpers selbst.
  • Fehler-Literale (ErrorLiteralNode) — alle sieben Codes tokenisieren als ein Token, Komposition mit Operatoren/Funktionen, Fehlerpropagation wie bei jedem anderen Fehlerwert.
  • Zeilen/Spalten einfügen/löschen — Datenverschiebung (Zellen, Formate, Zeilenhöhen/Spaltenbreiten) innerhalb der Tabelle, dokumentweites Umschreiben von Formeln (auch über Tabelle!Zelle- und Blatt!Tabelle::Zelle-Qualifier aus anderen Tabellen), Wachsen/ Schrumpfen eines Bereichs, der die Stelle überspannt, Kollaps zu #REF bei einer exakt getroffenen Einzelzelle/einem vollständig gelöschten Bereich, $-Sperren bleiben wirkungslos (anders als beim Kopieren), unveränderte Referenzen bleiben byte-identisch, Undo/Redo, Schutz gegen Löschen der letzten verbleibenden Zeile/Spalte.
  • Tabellen-Umbenennung schreibt Referenzen um — ein Tabelle!Zelle- und ein Tabelle::Zelle-Bezug aus einer anderen Tabelle werden beide umgeschrieben und liefern danach weiterhin denselben Wert (kein #REF), ein Bereichsbezug behält seinen Bereich (nur der Qualifier ändert sich), bei der 3-teiligen Blatt!Tabelle::Zelle-Form wird ausschließlich der Tabellen-Teil ersetzt, ein neuer Name mit Leerzeichen wird korrekt gequotet, eine Formel, die eine andere, nicht umbenannte Tabelle referenziert, bleibt byte-identisch, ein redundanter Selbst-Qualifier innerhalb der umbenannten Tabelle selbst wird ebenfalls erfasst, Umbenennen + Umschreiben zählen als ein Undo-Schritt.
  • Abhängigkeiten erkunden (graphPrecedents/graphDependents in workbook.spec.ts, cellGraphSelf/cellGraphNeighbors/ openDependencyGraph/closeDependencyGraph in document.spec.ts) — exakte Vorgänger/Nachfolger-Kanten, Auflösung über Tabellen-/Blattgrenzen, bleibt korrekt für eine Zelle auf einem Zirkelbezug, aktualisiert sich nach einer Neuzuordnung der Formel, openDependencyGraph löst wirklich selectCell aus (inkl. Blatt-Tab-Wechsel), closeDependencyGraph rührt die Auswahl nicht an. Das Panel selbst (DependencyGraphView.vue) ist nur live per Playwright verifiziert, kein Komponenten-Test-Setup vorhanden (s. „Bekannte Grenzen").
  • „Mit Schleife"-Code — Parser-Ebene (parseFunctionBlock): ein FOR...DO...END mit mehreren Anweisungen, mehrere unabhängige Schleifen nacheinander, IF...THEN...END und IF...THEN...ELSE...END, IF verschachtelt in FOR mit einem RETURN mittendrin, FOR verschachtelt in FOR, RETURN an beliebiger Stelle mit beliebigem Ausdruck, ein Name aus einer früheren Anweisung in einer späteren Schleife nutzbar, ein Block ganz ohne RETURN parst trotzdem (das ist eine Save-/Laufzeit-Prüfung, kein Parse-Fehler); Fehler bei FOR/IF ohne passendes END/DO/IN/ THEN, fehlendem =, Selbstbezug im eigenen Initialisierer. Der Interpreter (evaluator.ts, Workbook-Integration in userFunctions.spec.ts): korrekter Wert und Neuberechnung bei Änderung einer Zelle innerhalb des übergebenen Bereichs, ein FOR-Körper mit mehreren Anweisungen läuft jede Iteration vollständig, IF/ELSE verzweigt nach dem echten, aktuellen Zellwert, ein frühes RETURN innerhalb eines IF innerhalb eines FOR bricht die Schleife wirklich ab (eine erst nach dem Treffer geänderte Zelle bleibt folgenlos), eine Zelle, die nur in einem nicht genommenen IF-Zweig steht, ist trotzdem eine erfasste Abhängigkeit, verschachtelte FOR-Schleifen rechnen korrekt (nicht nur parsen), ein verschachtelter Aufruf einer anderen Funktion im Schleifenkörper wird mit-expandiert, Arity-Prüfung gilt auch hier, mehr als 10.000 Schleifenschritte (über alle Schleifen einer Auswertung) sowie eine FOR-Quelle, die nach Parametersubstitution kein Zell-/Bereichsbezug ist, sowie eine Ausführung ohne je erreichtes RETURN ergeben alle #ERR statt eines Absturzes. Store-Ebene: reservierte Wörter (FOR/IN/DO/END/ IF/THEN/ELSE/RETURN) als Parameter- oder lokaler Name abgelehnt (auch wenn der lokale Name in einem verschachtelten IF-Zweig deklariert wird), zellartiger lokaler Name abgelehnt, Wiederverwendung desselben lokalen Namens über mehrere Anweisungen (das Akkumulator-Muster) ist keine Kollision, ein lokaler Name, der einen Parameter überschattet, wird abgelehnt, eine berechnete FOR-Quelle (auch verschachtelt in einem IF) wird abgelehnt, ein Codepfad ohne garantiertes RETURN (fehlt ganz, oder nur ein IF-Zweig ohne ELSE returnt) wird beim Speichern abgelehnt — mit ELSE, bei dem beide Zweige returnen, wird akzeptiert — Funktions-Zyklus durch einen Schleifenkörper wird erkannt, Undo/Redo, kind im JSON-Round-Trip, ein älteres gespeichertes Dokument ohne kind-Feld defaultet auf 'expression'.
  • Blatt-Qualifier „Blatt!Tabelle::Zelle" — Parser-Ebene: die explizite 3-teilige Form (bar und quoted, in Bereichen/Funktionsargumenten), volle Rückwärtskompatibilität der 2-teiligen Form (X!Y, X::Y, auch ein zweites ! statt ::), Fehler bei fehlender Zelle nach ::. Workbook- Ebene: zwei gleichnamige Tabellen auf verschiedenen Blättern lösen ein unqualifiziertes Tabelle!Zelle zum gleichen Blatt wie die Formel auf, die explizite Form trifft immer das genannte Blatt unabhängig vom Formel-Standort, ein unbekanntes Blatt/eine weiterhin mehrdeutige Tabelle ergibt #REF, Neuberechnung folgt korrekt der Auflösung. Store- Ebene: Tabellennamen sind nur noch pro Blatt eindeutig (Anlegen/ Umbenennen prüft nur gegen das eigene Blatt), Point-Mode fügt die volle 3-teilige Form ein, sobald ein Klick auf eine mehrdeutig benannte Tabelle auf einem anderen Blatt trifft.
  • IO — JSON verlustfreier Round-Trip (identisch), defekte/leere Datei → Fehler ohne Nebenwirkung, unbekannter Tabellenname → #REF, v1→v2 beim Import; CSV Übersicht-Export→Re-Import gleiche Zahlen, RFC-4180-Quoting, =1+2→3, Locale-Zahlen.
  • Makros — Grammatik (parser.spec.ts): NAME(args) an Anweisungs- position nur mit allowWrites (nie in einem gewöhnlichen Funktionskörper), ;-Argumente inkl. übersprungener Slots, Zuweisung bleibt eindeutig unterscheidbar. Interpreter (macros.spec.ts): SETVALUE-Zielauflösung auf Literal/Bereich/Schleifenvariable, RETURN als Kurzschluss inkl. String-Nachricht, Fall-Through → null, alle elf Aktionen gegen einen Mock-MacroWriteHost, das kleinere Makro-Schrittbudget greift bei einer strukturell-schweren Schleife, ein verschachtelter Aufruf einer benutzerdefinierten Funktion in einem SETVALUE-Wert funktioniert und kann selbst nicht schreiben. Store-Ebene (document.spec.ts): Validierung (unqualifizierte Referenz abgelehnt, Namenskollision, reservierte Aktionsnamen als lokale Variable abgelehnt), alle elf Aktionen live gegen ein echtes Dokument inkl. dokumentweiter Referenz-Umschreibung bei INSERTROW, der wichtigste Test: SETVALUE → INSERTROW → SETVALUE in einem Lauf beweist, dass kein Host-Zugriff nach der strukturellen Aktion auf einer veralteten Workbook-Referenz hängen bleibt, ein ganzer synchroner Lauf ist genau ein Undo-Schritt, DELETESHEET verweigert das letzte Blatt als echten Fehlschlag statt stillem No-op, ADDTABLE auf ein anderes Blatt lässt die aktive Ansicht/Auswahl unangetastet (reiner Seiteneffekt-Fix, live per Playwright gefunden), JSON-Round-Trip.
  • Makros: GET/POST/JSONGET (macros.spec.ts) — Ergebnis-Bindung + Kollaps-Regel (Text/Zahl/Bool/Objekt/Array) gegen einen gemockten MacroNetworkHost, JSONGET-Pfadauflösung (verschachtelt, Array-Index, fehlender Pfad → null, falscher Werttyp → Fehler), FOR über ein JSON-Array direkt und über einen JSONGET(...)-Aufruf direkt als Quelle (letzteres ein live gefundener Lücke-Fix: substitute() allein wertet einen Funktionsaufruf nie aus), FOR über ein JSON-Objekt/ Einzelwert → klarer Fehler, GET/POST außerhalb einer reinen Zuweisung → #NAME? (belegt die eindämmungsfreie Absicherung empirisch), JSONGET dagegen funktioniert überall verschachtelt (rein synchron). Store-Ebene (document.spec.ts): GET/POST/JSONGET in einer benutzerdefinierten Funktion (Ausdruck und Block) abgelehnt; confirm() gemockt — erster Lauf fragt und persistiert, zweiter Lauf zum selben Host fragt nicht mehr, ein anderer Host fragt erneut, eine Ablehnung bricht ab, ohne den Host zu speichern; nicht-2xx-Antwort bricht sauber ab; Pflichttest: SETVALUE → ein absichtlich langsames GET (300 ms, über der 150-ms-Undo-Koaleszenz) → SETVALUE erzeugt trotzdem genau einen Undo-Schritt — beweist den macroRunActive-Fix direkt; JSON-Round-Trip von approvedHosts. Live per Playwright gegen eine echte same-origin-Fixture (public/test-fixtures/sample.json) verifiziert: echter GET, echter Bestätigungsdialog mit Makro-/Host-Namen, FOR/JSONGET über die echte Antwort, kein erneuter Dialog beim zweiten Lauf, null Konsolenfehler.

Bekannte Grenzen

  • Makros — UI-Ebene (ButtonView.vue, MacrosModal.vue, der Button- Zweig in CanvasObject.vue/SettingsPanel.vue) rein UI-seitig implementiert, ohne eigene Unit-Tests — wie bei Tabellen-Größenänderung/ Abhängigkeiten-Panel/Ctrl+A oben, nur live per Playwright verifiziert (Makro anlegen, per Listen-Button und per Button-Objekt ausführen, Rechtsklick-Zuweisung, Fehlerfall zeigt einen Fehler-Toast statt abzustürzen). Die Makro-Sprache selbst (Grammatik, Interpreter, Store-Aktionen) ist dagegen vollständig durch Engine-/Store-Tests abgedeckt (s. „Tests" unten).
  • Zeilen/Spalten einfügen/löschen (s. „Zeilen & Spalten einfügen/löschen" oben) bewusst ohne: Mehrfachauswahl (immer genau eine Zeile/Spalte pro Aufruf), automatisches Nachziehen von CellFormat-Regeln über die Grenze hinaus (eine neue Zeile ist immer unformatiert), und ein OFFSET/INDEX-Äquivalent — ein #REF, das aus einer gelöschten Zeile entsteht, muss von Hand korrigiert werden, es gibt keine „nächstbeste Zelle"-Heuristik.
  • Größenänderung einer „Automatisch"-Tabelle (SE-Griff → Zeilen-/ Spaltenzahl, s. o. „Das Canvas-Modell") ist rein UI-seitig (CanvasObject.vue) implementiert, ohne eigene Unit-Tests — das Projekt hat (noch) kein Komponenten-Test-Setup (z. B. Vue Test Utils), nur Engine-/Store-Tests. Nur live per Playwright verifiziert (Wachsen, Schrumpfen mit Inhaltsschutz, deutliches Überschreiten der bestehenden Größe).
  • Abhängigkeiten-Panel (DependencyGraphView.vue) bewusst ohne: Gruppierung vieler Vorgänger/Nachfolger zu einem Bereichs-Eintrag (jede Zelle einzeln, ungeachtet der Anzahl), automatisches Scrollen der Zieltabelle zur navigierten Zelle. Wie bei der Tabellen-Größenänderung oben: rein UI-seitig, nur live per Playwright verifiziert, kein Komponenten-Test-Setup vorhanden — die zugrundeliegenden Store-/Engine-Funktionen (graphPrecedents/graphDependents/cellGraphNeighbors/ openDependencyGraph) sind es aber (s. „Tests" oben).
  • Strg/Cmd+A-Zyklus (s. o. „Zellauswahl") ist rein UI-seitig (TableGrid.vue) implementiert, ohne eigene Unit-Tests — wie bei Tabellen-Größenänderung und Abhängigkeiten-Panel oben nur live per Playwright verifiziert (Stufe 1 auf eine von zwei durch eine leere Spalte getrennten Dateninseln, Eskalation auf Stufe 2, Verharren auf Stufe 2 bei weiteren Drücken, Rücksetzen des Zyklus durch einen Klick dazwischen).
  • Objekt-Bezüge beim Anhängen. Beim JSON-Anhängen zweier Dokumente werden nur Blatt-/Objekt-IDs neu vergeben, nicht Tabellennamen. Kollidiert ein angehängter Tabellenname mit einem bestehenden, gewinnt bei der Auflösung der erste Treffer. „Ersetzen" (der Round-Trip-Fall) ist davon nicht betroffen.
  • Kein Virtual Scrolling — „Feste Größe" scrollt intern, aber alle Zeilen bleiben im DOM (nur geclippt); sehr große Tabellen/Importe (viele Tausend Zeilen) werden träge.
  • Große Dateien werden per setTimeout-Yield verarbeitet (UI bleibt reaktiv), kein echter Web Worker.
  • xlsx-Formel-Übernahme ist heuristisch (Separator-Swap unter Beachtung von Quotes); exotische Excel-Formeln fallen auf den Wert zurück.
  • Kein Snapping der Canvas-Objekte.
  • Benannte Bereiche bewusst ohne: Nutzung innerhalb einer benutzerdefinierten Funktion (ein Aufrufer kann aber einen benannten Bereich als Argument übergeben), einen eigenen Ausdruck/berechneten Wert (nur feste Koordinaten), Funktions-Autovervollständigungs-Vorschläge, und Name:B2 als kombinierten Bereichsstart — s. „Benannte Bereiche" oben.
  • Datum & Zeit: die automatische Erkennung beim Eintippen erfasst nur ISO- und Punkt-Daten, absichtlich nicht Schrägstrich-Daten (Tag-zuerst vs. Monat-zuerst ist über die unterstützten Locales nicht eindeutig auflösbar, s. o.). Formel-Ergebnisse (=DATE(...), =TODAY(), =A1+30) bekommen kein automatisches Zellformat — außer „Dauer" für Formeln über reine Zeiten. Das gilt auch dann, wenn die Formel die Zeiten in Stunden umrechnet (=A1*24); dort das Format „Automatisch" durch „Ohne Formatierung" o. ä. ersetzen. TODAY()/ NOW() sind nicht volatil: sie aktualisieren sich erst bei der nächsten Neuberechnung ihrer eigenen Zelle, nicht spontan mit der Zeit.
  • XLOOKUP bewusst ohne: Näherungsvergleich (Vergleichsmodus -1/1) auf Text (nur numerisch), Platzhalter-Vergleich (Vergleichsmodus 2), binäre Suchmodi (Suchmodus 2/-2, würden einen sortierten Bereich voraussetzen, den diese Engine nicht prüfen kann). Ein übersprungenes optionales Argument (;;) ist eine allgemeine Grammatik-Erweiterung, nicht XLOOKUP-spezifisch — funktioniert in jeder Funktion.
  • Benutzerdefinierte Funktionen (Phase 1, siehe oben) bewusst ohne: Live-Vorschau des Ergebnisses im Bearbeiten-Dialog, variadische Parameter/ Default-Werte, und automatisches Nachführen bestehender Aufrufe bei Umbenennung — anders als bei einer Tabelle inzwischen (s. o.) bewusst nicht nachgezogen, da eine Funktion nur einen Aufruf-Namen hat, keine umschreibbare Referenz-Syntax. Der Funktionsname selbst wird beim Formeltippen aber inzwischen vervollständigt (s. o. „Funktions- Autovervollständigung"), auch für eigene Funktionen.
  • Funktions-Autovervollständigung bewusst ohne: Zell-/Tabellennamen- Vorschläge (dafür existiert der Point-Mode, s. o.), Vervollständigung, die auch Text nach dem Cursor berücksichtigt (nur der Text bis zum Cursor zählt — wie in den meisten Editoren), Live-Update der Vorschlagsliste bei reiner Cursor-Bewegung ohne Tippen.
  • „Mit Schleife"-Code bewusst ohne: verkettete Vergleiche (a < b < c, nur ein Vergleich pro Ausdruck — AND/OR lösen das aber, s. u.), BREAK/CONTINUE (nur RETURN als Kontrollfluss-Ausstieg), echtes Block-Scoping (Namen bleiben absichtlich bis zum Ende des Codes sichtbar). Verschachtelte Schleifen/IFs und RETURN an beliebiger Stelle sind dagegen unterstützt (s. o.), ebenso Text-Vergleich in IF-Bedingungen (s. „SUMIF/COUNTIF/AVERAGEIF nachbauen" oben).
  • Keine Positions-Adressierung zwischen zwei Bereichen (kein OFFSET/INDEX-Äquivalent) — eine FOR-Schleife kann nur den Bereich erreichen, über den sie selbst iteriert, nicht „die Zelle an derselben Position in einem anderen Bereich". Ein klassisches dreiargumentiges SUMIF(Kriterienbereich; Kriterium; Summenbereich) mit getrennten Bereichen ist deshalb nicht nachbaubar; nur die selbstbezügliche Form (Kriterium und Aggregation auf demselben Bereich) geht (s. o.).

Projektstruktur

src/
  engine/           # Formel-Engine (Eigenbau) + Unit-Tests
  store/            # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
  io/               # csv / json (Eigenbau, inkl. v1→v2), num (ZIP via fflate, lazy), xlsx (SheetJS, lazy)
  components/       # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
                    #   TableGrid, ChartView, TextBox, ButtonView, MacrosModal, SheetTabs,
                    #   StatusBar, TheLogo, …
  numolus-logo.svg  # Marke: drei überlappende Pop-Art-Quadrate + Schriftzug
  config/brand.ts   # Herausgeber-Metadaten (wicom.li) + Social-Media-Liste
  composables/      # Theme, Toasts
  data/seed.ts      # leeres Dokument + Musterdokument
  styles/main.css   # Design-Tokens (Light/Dark)