feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
Restaure et termine le lot tableur resté non committé. Il n'existait que dans un `git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js, color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le stash, créé avant le commit BUG-108, n'a jamais été restauré. #180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint), grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu Fichier au niveau des onglets, barre épinglée pleine largeur. #181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans quitter contenteditable, double-clic / F2 / première frappe qui remplace, Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts. #182 Désélection fiable (clic simple sans dépendre du focus), contours de plage non empilés (sélecteur de classes sans point), couleurs texte/fond sur une plage, clic droit qui préserve la sélection multiple. #183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD, PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était rejetée par normHex). #184 Peinture de format complète (toutes propriétés, source sans format = réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de cellules `ref → td`, court-circuits formule/styles, marqueur data-wired. #185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36 annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème. #186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC (openpyxl) au lieu d'une charge utile texte illisible. Corrections trouvées en restaurant et en exerçant le lot : - fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ; - sorties Markdown / HTML / Imprimer de la barre de menus inertes (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ; - collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ; - curseur col/row-resize absent quand le pointeur est sur la table elle-même ; - l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown, HTML, impression) ; - clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au seul appui long tactile) ; - une entrée indisponible laissait la barre de menus ouverte. Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ; 33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19, xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer 19/19.
This commit is contained in:
@@ -0,0 +1,755 @@
|
||||
/* ObsiGate — minimal client-side spreadsheet formula engine.
|
||||
*
|
||||
* Evaluates `=…` / `@…` cell content locally (same sheet, plus loaded
|
||||
* cross-sheet panels) so functions work without a round-trip: arithmetic,
|
||||
* comparisons, %, cell/range references and ~30 FR/EN functions.
|
||||
*
|
||||
* Safe by construction: own tokenizer + recursive-descent parser, no eval().
|
||||
* Anything the parser rejects keeps its original rendering (Excel stays the
|
||||
* source of truth on save); evaluation failures surface as Excel-style error
|
||||
* codes instead.
|
||||
*
|
||||
* Limits (documented): same-workbook only, no whole-column (A:A) references,
|
||||
* TODAY()/NOW() refresh on edit/render (no timer), array results show their
|
||||
* top-left value.
|
||||
*/
|
||||
|
||||
const MAX_COL = 18278; // XFD
|
||||
const MAX_ROW = 1048576;
|
||||
|
||||
/** FR aliases accepted next to the EN canonical names. */
|
||||
const ALIASES = {
|
||||
SOMME: "SUM", MOYENNE: "AVERAGE", NB: "COUNT", NBVAL: "COUNTA",
|
||||
PRODUIT: "PRODUCT", MEDIANE: "MEDIAN", ARRONDI: "ROUND", ENT: "INT",
|
||||
RACINE: "SQRT", PUISSANCE: "POWER", SI: "IF", ET: "AND", OU: "OR",
|
||||
NON: "NOT", SIERREUR: "IFERROR", CONCATENER: "CONCAT",
|
||||
MAJUSCULES: "UPPER", MINUSCULES: "LOWER", NBCAR: "LEN",
|
||||
SUPPRESPACE: "TRIM", GAUCHE: "LEFT", DROITE: "RIGHT", STXT: "MID",
|
||||
AUJOURDHUI: "TODAY", MAINTENANT: "NOW", JOUR: "DAY", MOIS: "MONTH",
|
||||
ANNEE: "YEAR", LIEN: "HYPERLINK",
|
||||
};
|
||||
|
||||
const ERRORS = {
|
||||
fr: { DIV: "#DIV/0!", VALUE: "#VALEUR!", NAME: "#NOM?", REF: "#REF!", CYCLE: "#CYCLE!" },
|
||||
en: { DIV: "#DIV/0!", VALUE: "#VALUE!", NAME: "#NAME?", REF: "#REF!", CIRC: "#CIRC!" },
|
||||
};
|
||||
|
||||
export function errorCodes(locale) {
|
||||
return ERRORS[locale === "fr" ? "fr" : "en"];
|
||||
}
|
||||
|
||||
export function isFormulaText(s) {
|
||||
if (typeof s !== "string") return false;
|
||||
const t = s.trim();
|
||||
return t.length > 1 && (t[0] === "=" || t[0] === "@");
|
||||
}
|
||||
|
||||
/** Column letters (no $) → 1-based index, or 0 when invalid. */
|
||||
export function colToIndex(letters) {
|
||||
let n = 0;
|
||||
const up = String(letters || "").toUpperCase();
|
||||
if (!/^[A-Z]{1,3}$/.test(up)) return 0;
|
||||
for (const ch of up) n = n * 26 + (ch.charCodeAt(0) - 64);
|
||||
return n > MAX_COL ? 0 : n;
|
||||
}
|
||||
|
||||
export function indexToCol(n) {
|
||||
let s = "";
|
||||
while (n > 0) {
|
||||
const m = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + m) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Parse "[$]A[$]1" (already stripped of any sheet prefix) → {col,row} | null. */
|
||||
export function parseA1(ref) {
|
||||
const m = /^\$?([A-Za-z]{1,3})\$?([0-9]+)$/.exec(String(ref || "").trim());
|
||||
if (!m) return null;
|
||||
const col = colToIndex(m[1]);
|
||||
const row = Number(m[2]);
|
||||
if (!col || !(row >= 1 && row <= MAX_ROW)) return null;
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
// ── Tokenizer ─────────────────────────────────────────────────────────────
|
||||
|
||||
function tokenize(src, sep) {
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
const n = src.length;
|
||||
const isSpace = (c) => c === " " || c === "\t" || c === "\n" || c === "\r";
|
||||
while (i < n) {
|
||||
const c = src[i];
|
||||
if (isSpace(c)) { i++; continue; }
|
||||
if (c === '"') {
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n) {
|
||||
if (src[j] === '"') {
|
||||
if (src[j + 1] === '"') { out += '"'; j += 2; }
|
||||
else break;
|
||||
} else { out += src[j]; j++; }
|
||||
}
|
||||
if (j >= n) throw new Error("string");
|
||||
tokens.push({ t: "str", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
// Quoted sheet name: 'Mon Sheet'!A1
|
||||
let j = i + 1;
|
||||
let out = "";
|
||||
while (j < n && src[j] !== "'") { out += src[j]; j++; }
|
||||
if (j >= n) throw new Error("sheet");
|
||||
tokens.push({ t: "sheet", v: out });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
const numMatch = /^[0-9]+(?:[.,][0-9]+)?/.exec(src.slice(i));
|
||||
if (numMatch && (sep === ";" || !numMatch[0].includes(","))) {
|
||||
tokens.push({ t: "num", v: numMatch[0].replace(",", ".") });
|
||||
i += numMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
const absRef = /^\$?[A-Za-z]{1,3}\$?[0-9]+/.exec(src.slice(i));
|
||||
if (absRef) {
|
||||
tokens.push({ t: "ref", v: absRef[0] });
|
||||
i += absRef[0].length;
|
||||
continue;
|
||||
}
|
||||
const two = src.slice(i, i + 2);
|
||||
if (["<=", ">=", "<>"].includes(two)) {
|
||||
tokens.push({ t: "op", v: two === "<>" ? "!=" : two });
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ("+-*/^%=()<>,;:&!".includes(c)) {
|
||||
tokens.push({ t: c === "," || c === ";" ? "sep" : "op", v: c });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const word = /^[A-Za-zÀ-ÿ_][A-Za-zÀ-ÿ0-9_.]*/.exec(src.slice(i));
|
||||
if (word) {
|
||||
const w = word[0];
|
||||
// A bare cell reference (A1, $B$12) — but not TRUE/FALSE yet (bools).
|
||||
if (/^\$?[A-Za-z]{1,3}\$?[0-9]+$/.test(w)) {
|
||||
tokens.push({ t: "ref", v: w });
|
||||
} else {
|
||||
const up = w.toUpperCase();
|
||||
if (up === "TRUE" || up === "VRAI") tokens.push({ t: "bool", v: true });
|
||||
else if (up === "FALSE" || up === "FAUX") tokens.push({ t: "bool", v: false });
|
||||
else tokens.push({ t: "word", v: w });
|
||||
}
|
||||
i += w.length;
|
||||
continue;
|
||||
}
|
||||
throw new Error("char:" + c);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ── Parser (recursive descent, builds a small AST) ─────────────────────────
|
||||
|
||||
function makeParser(tokens, sep) {
|
||||
let pos = 0;
|
||||
const peek = () => tokens[pos] || null;
|
||||
const next = () => tokens[pos++] || null;
|
||||
const expectOp = (v) => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "op" || t.v !== v) throw new Error("expected " + v);
|
||||
};
|
||||
const expectSep = () => {
|
||||
const t = next();
|
||||
if (!t || t.t !== "sep") throw new Error("expected separator");
|
||||
};
|
||||
|
||||
function parseExpr() { return parseComparison(); }
|
||||
function parseComparison() {
|
||||
let left = parseConcat();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || !["=", "!=", "<", "<=", ">", ">="].includes(t.v)) return left;
|
||||
next();
|
||||
left = [t.v, left, parseConcat()];
|
||||
}
|
||||
}
|
||||
function parseConcat() {
|
||||
let left = parseAdd();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || t.v !== "&") return left;
|
||||
next();
|
||||
left = ["&", left, parseAdd()];
|
||||
}
|
||||
}
|
||||
function parseAdd() {
|
||||
let left = parseMul();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "+" && t.v !== "-")) return left;
|
||||
next();
|
||||
left = [t.v, left, parseMul()];
|
||||
}
|
||||
}
|
||||
function parseMul() {
|
||||
let left = parsePower();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (!t || t.t !== "op" || (t.v !== "*" && t.v !== "/")) return left;
|
||||
next();
|
||||
left = [t.v, left, parsePower()];
|
||||
}
|
||||
}
|
||||
// Excel-compatible: a leading sign binds to the base (-2^2 = (-2)^2 = 4),
|
||||
// and to the exponent on the right (2^-2).
|
||||
function parseSigned() {
|
||||
let neg = false;
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && (t.v === "+" || t.v === "-")) {
|
||||
if (t.v === "-") neg = !neg;
|
||||
next();
|
||||
} else break;
|
||||
}
|
||||
const node = parsePostfix();
|
||||
return neg ? ["neg", node] : node;
|
||||
}
|
||||
function parsePower() {
|
||||
const base = parseSigned();
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "^") {
|
||||
next();
|
||||
return ["^", base, parseSigned()];
|
||||
}
|
||||
return base;
|
||||
}
|
||||
function parsePostfix() {
|
||||
let node = parsePrimary();
|
||||
for (;;) {
|
||||
const t = peek();
|
||||
if (t && t.t === "op" && t.v === "%") { next(); node = ["%", node]; }
|
||||
else return node;
|
||||
}
|
||||
}
|
||||
function parsePrimary() {
|
||||
const t = next();
|
||||
if (!t) throw new Error("empty");
|
||||
if (t.t === "num") return ["n", Number(t.v)];
|
||||
if (t.t === "str") return ["s", t.v];
|
||||
if (t.t === "bool") return ["b", t.v];
|
||||
if (t.t === "ref") {
|
||||
const after = peek();
|
||||
if (after && after.t === "op" && after.v === ":") {
|
||||
next();
|
||||
const t2 = next();
|
||||
if (!t2 || t2.t !== "ref") throw new Error("range");
|
||||
return ["range", normRef(t.v), normRef(t2.v)];
|
||||
}
|
||||
return ["ref", normRef(t.v)];
|
||||
}
|
||||
if (t.t === "word" || t.t === "sheet") {
|
||||
const isSheet = t.t === "sheet";
|
||||
let name = t.v;
|
||||
if (!isSheet) {
|
||||
// Unquoted sheet name: Name!A1
|
||||
const bang = peek();
|
||||
if (bang && bang.t === "op" && bang.v === "!") {
|
||||
next();
|
||||
} else {
|
||||
// Function call?
|
||||
const paren = peek();
|
||||
if (paren && paren.t === "op" && paren.v === "(") {
|
||||
next();
|
||||
const args = [];
|
||||
const closing = peek();
|
||||
if (!(closing && closing.t === "op" && closing.v === ")")) {
|
||||
for (;;) {
|
||||
args.push(parseExpr());
|
||||
const s = peek();
|
||||
if (s && s.t === "sep") { next(); continue; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
expectOp(")");
|
||||
return ["call", canonName(name), args];
|
||||
}
|
||||
throw new Error("name:" + name);
|
||||
}
|
||||
} else {
|
||||
const bang = peek();
|
||||
if (!bang || bang.t !== "op" || bang.v !== "!") throw new Error("sheetref");
|
||||
next();
|
||||
}
|
||||
const r1 = next();
|
||||
if (!r1 || r1.t !== "ref") throw new Error("sheetref");
|
||||
const colon = peek();
|
||||
if (colon && colon.t === "op" && colon.v === ":") {
|
||||
next();
|
||||
const r2 = next();
|
||||
if (!r2 || r2.t !== "ref") throw new Error("sheetrange");
|
||||
return ["sheetrange", name, normRef(r1.v), normRef(r2.v)];
|
||||
}
|
||||
return ["sheetref", name, normRef(r1.v)];
|
||||
}
|
||||
if (t.t === "op" && t.v === "(") {
|
||||
const e = parseExpr();
|
||||
expectOp(")");
|
||||
return e;
|
||||
}
|
||||
throw new Error("primary");
|
||||
}
|
||||
function normRef(r) {
|
||||
return r.replace(/\$/g, "").toUpperCase();
|
||||
}
|
||||
function canonName(name) {
|
||||
const up = String(name).toUpperCase();
|
||||
return ALIASES[up] || up;
|
||||
}
|
||||
return { parseExpr, atEnd: () => pos >= tokens.length };
|
||||
}
|
||||
|
||||
export function parseFormula(text) {
|
||||
const body = String(text || "").trim().replace(/^[=@]/, "");
|
||||
const sep = body.includes(";") ? ";" : ",";
|
||||
// A comma is a decimal mark (FR style) when ';' is the separator, or when
|
||||
// the expression holds no letters at all ("=1,5+1" can only mean 1.5 + 1).
|
||||
const commaDecimal = sep === ";" || !/[A-Za-zÀ-ÿ]/.test(body);
|
||||
const { parseExpr, atEnd } = makeParser(tokenize(body, commaDecimal ? ";" : sep), sep);
|
||||
const ast = parseExpr();
|
||||
if (!atEnd()) throw new Error("trailing");
|
||||
return ast;
|
||||
}
|
||||
|
||||
// ── Evaluator ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Engine scope: resolve raw cell values + locale + cycle guard. */
|
||||
export function createScope({ readRaw, sheet, locale = "en", seen = null } = {}) {
|
||||
const scope = {
|
||||
sheet: sheet || "",
|
||||
locale: locale === "fr" ? "fr" : "en",
|
||||
seen: seen || new Set(),
|
||||
memo: new Map(),
|
||||
/** Engine value of `sheet!ref` (formulas recurse, cycles guarded). */
|
||||
getCell(targetSheet, ref) {
|
||||
const key = `${targetSheet || scope.sheet}!${String(ref).toUpperCase()}`;
|
||||
const raw = readRaw ? readRaw(targetSheet || scope.sheet, ref) : null;
|
||||
return evalRefValue(raw, scope, key);
|
||||
},
|
||||
};
|
||||
return scope;
|
||||
}
|
||||
|
||||
const err = (code) => ({ t: "e", code });
|
||||
const isErr = (v) => v && v.t === "e";
|
||||
const isBlank = (v) => !v || v.t === "blank";
|
||||
|
||||
function toNumber(v) {
|
||||
if (!v || v.t === "blank") return 0;
|
||||
if (v.t === "n") return v.v;
|
||||
if (v.t === "b") return v.v ? 1 : 0;
|
||||
if (v.t === "s") {
|
||||
const s = v.v.trim().replace(",", ".");
|
||||
if (s === "") return 0;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toText(v, locale) {
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function numText(n) {
|
||||
if (!Number.isFinite(n)) return String(n);
|
||||
if (Number.isInteger(n) && Math.abs(n) < 1e15) return String(n);
|
||||
return String(parseFloat(n.toPrecision(10)));
|
||||
}
|
||||
|
||||
function flattenArgs(vals) {
|
||||
const out = [];
|
||||
const walk = (v) => {
|
||||
if (v && v.t === "a") v.rows.forEach((r) => r.forEach(walk));
|
||||
else out.push(v);
|
||||
};
|
||||
vals.forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
function evalRefValue(raw, scope, key) {
|
||||
// Raw storage text → engine value (formulas recurse, cycles guarded).
|
||||
if (raw === null || raw === undefined) return { t: "blank" };
|
||||
if (typeof raw === "number") return { t: "n", v: raw };
|
||||
if (typeof raw === "boolean") return { t: "b", v: raw };
|
||||
const s = String(raw);
|
||||
if (scope.memo.has(key)) return scope.memo.get(key);
|
||||
let out;
|
||||
if (isFormulaText(s)) {
|
||||
if (scope.seen.has(key)) {
|
||||
out = err("CYCLE");
|
||||
} else {
|
||||
scope.seen.add(key);
|
||||
try {
|
||||
out = evaluate(parseFormula(s), scope);
|
||||
} catch (_) {
|
||||
out = err("NAME");
|
||||
}
|
||||
scope.seen.delete(key);
|
||||
}
|
||||
} else if (s.trim() === "") {
|
||||
out = { t: "blank" };
|
||||
} else {
|
||||
const n = Number(s.trim().replace(",", "."));
|
||||
out = s.trim() !== "" && Number.isFinite(n) && /^[-+0-9.,\s]+$/.test(s.trim())
|
||||
? { t: "n", v: n }
|
||||
: { t: "s", v: s };
|
||||
}
|
||||
scope.memo.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function rangeValues(r1, r2, scope) {
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rStart = Math.min(a.row, b.row);
|
||||
const rEnd = Math.max(a.row, b.row);
|
||||
const cStart = Math.min(a.col, b.col);
|
||||
const cEnd = Math.max(a.col, b.col);
|
||||
if ((rEnd - rStart + 1) * (cEnd - cStart + 1) > 100000) return err("VALUE");
|
||||
const rows = [];
|
||||
for (let r = rStart; r <= rEnd; r++) {
|
||||
const line = [];
|
||||
for (let c = cStart; c <= cEnd; c++) {
|
||||
const ref = indexToCol(c) + r;
|
||||
line.push(scope.getCell(scope.sheet, ref));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
|
||||
function firstOf(v) {
|
||||
if (v && v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return cell;
|
||||
}
|
||||
return { t: "blank" };
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function cmpValues(a, b) {
|
||||
// Excel-ish: numbers < text; blank sorts with text "".
|
||||
const rank = (v) => (!v || v.t === "blank" ? 0 : v.t === "n" ? 1 : v.t === "b" ? 2 : 3);
|
||||
const ra = rank(a);
|
||||
const rb = rank(b);
|
||||
if (ra !== rb) return ra - rb;
|
||||
if (ra === 1) return a.v - b.v;
|
||||
if (ra === 2) return (a.v ? 1 : 0) - (b.v ? 1 : 0);
|
||||
const sa = toText(a);
|
||||
const sb = toText(b);
|
||||
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||
}
|
||||
|
||||
const FUNCTIONS = {
|
||||
SUM: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc + n;
|
||||
}, 0)),
|
||||
AVERAGE: (vals, scope) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
if (!nums.length) return err("DIV");
|
||||
return num(nums.reduce((a, b) => a + b, 0) / nums.length);
|
||||
},
|
||||
MIN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.min(...nums)) : num(0);
|
||||
},
|
||||
MAX: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null);
|
||||
return nums.length ? num(Math.max(...nums)) : num(0);
|
||||
},
|
||||
COUNT: (vals) => num(flattenArgs(vals).filter((v) => v && v.t === "n").length),
|
||||
COUNTA: (vals) => num(flattenArgs(vals).filter((v) => !isBlank(v)).length),
|
||||
PRODUCT: (vals) => num(flattenArgs(vals).reduce((acc, v) => {
|
||||
if (isBlank(v)) return acc;
|
||||
const n = toNumber(v);
|
||||
return n === null ? acc : acc * n;
|
||||
}, 1)),
|
||||
MEDIAN: (vals) => {
|
||||
const nums = flattenArgs(vals).map(toNumber).filter((n) => n !== null).sort((a, b) => a - b);
|
||||
if (!nums.length) return err("DIV");
|
||||
const m = Math.floor(nums.length / 2);
|
||||
return num(nums.length % 2 ? nums[m] : (nums[m - 1] + nums[m]) / 2);
|
||||
},
|
||||
ABS: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.abs(n));
|
||||
},
|
||||
ROUND: ([v, d]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
const digits = d === undefined ? 0 : toNumber(firstOf(d));
|
||||
if (n === null || digits === null) return err("VALUE");
|
||||
const f = 10 ** Math.trunc(digits);
|
||||
return num(Math.round(n * f) / f);
|
||||
},
|
||||
INT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
return n === null ? err("VALUE") : num(Math.floor(n));
|
||||
},
|
||||
SQRT: ([v]) => {
|
||||
const n = toNumber(firstOf(v));
|
||||
if (n === null) return err("VALUE");
|
||||
if (n < 0) return err("VALUE");
|
||||
return num(Math.sqrt(n));
|
||||
},
|
||||
POWER: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null) return err("VALUE");
|
||||
const r = x ** y;
|
||||
return Number.isFinite(r) ? num(r) : err("VALUE");
|
||||
},
|
||||
MOD: ([a, b]) => {
|
||||
const x = toNumber(firstOf(a));
|
||||
const y = toNumber(firstOf(b));
|
||||
if (x === null || y === null || y === 0) return y === 0 ? err("DIV") : err("VALUE");
|
||||
return num(((x % y) + y) % y);
|
||||
},
|
||||
CONCAT: (vals, scope) => ({ t: "s", v: flattenArgs(vals).map((v) => toText(v, scope.locale)).join("") }),
|
||||
UPPER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toUpperCase() }),
|
||||
LOWER: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).toLowerCase() }),
|
||||
LEN: ([v], scope) => num([...toText(firstOf(v), scope.locale)].length),
|
||||
TRIM: ([v], scope) => ({ t: "s", v: toText(firstOf(v), scope.locale).trim().replace(/\s+/g, " ") }),
|
||||
LEFT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: s.slice(0, Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
RIGHT: ([v, n]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const k = n === undefined ? 1 : toNumber(firstOf(n));
|
||||
if (k === null) return err("VALUE");
|
||||
return { t: "s", v: k <= 0 ? "" : s.slice(-Math.trunc(k)) };
|
||||
},
|
||||
MID: ([v, start, len]) => {
|
||||
const s = toText(firstOf(v), "en");
|
||||
const a = toNumber(firstOf(start));
|
||||
const k = len === undefined ? s.length : toNumber(firstOf(len));
|
||||
if (a === null || k === null || a < 1) return err("VALUE");
|
||||
return { t: "s", v: s.slice(Math.trunc(a) - 1, Math.trunc(a) - 1 + Math.max(0, Math.trunc(k))) };
|
||||
},
|
||||
TODAY: () => ({ t: "s", v: new Date().toLocaleDateString() }),
|
||||
NOW: () => ({ t: "s", v: new Date().toLocaleString() }),
|
||||
DAY: ([v]) => datePart(v, "day"),
|
||||
MONTH: ([v]) => datePart(v, "month"),
|
||||
YEAR: ([v]) => datePart(v, "year"),
|
||||
NOT: ([v]) => ({ t: "b", v: !toBool(firstOf(v)) }),
|
||||
HYPERLINK: ([url, label], scope) => {
|
||||
const u = toText(firstOf(url), scope.locale).trim();
|
||||
if (!/^https?:\/\//i.test(u)) return err("VALUE");
|
||||
const text = label === undefined ? u : toText(firstOf(label), scope.locale);
|
||||
return { t: "link", url: u, label: text };
|
||||
},
|
||||
};
|
||||
|
||||
function num(n) {
|
||||
return { t: "n", v: n };
|
||||
}
|
||||
|
||||
function toBool(v) {
|
||||
if (!v || v.t === "blank") return false;
|
||||
if (v.t === "b") return v.v;
|
||||
if (v.t === "n") return v.v !== 0;
|
||||
const s = v.t === "s" ? v.v.trim().toUpperCase() : "";
|
||||
if (["TRUE", "VRAI", "1", "YES", "OUI"].includes(s)) return true;
|
||||
if (["FALSE", "FAUX", "0", "", "NO", "NON"].includes(s)) return false;
|
||||
return s !== "";
|
||||
}
|
||||
|
||||
function datePart(v, part) {
|
||||
const first = firstOf(v);
|
||||
let d = null;
|
||||
if (first && first.t === "s") {
|
||||
const parsed = new Date(first.v);
|
||||
if (!Number.isNaN(parsed.getTime())) d = parsed;
|
||||
} else if (first && first.t === "n") {
|
||||
// Excel serial date (1900 system).
|
||||
const base = Date.UTC(1899, 11, 30);
|
||||
d = new Date(base + Math.trunc(first.v) * 86400000);
|
||||
}
|
||||
if (!d) return err("VALUE");
|
||||
if (part === "day") return num(d.getDate());
|
||||
if (part === "month") return num(d.getMonth() + 1);
|
||||
return num(d.getFullYear());
|
||||
}
|
||||
|
||||
function callFunction(name, argNodes, scope) {
|
||||
if (name === "IF") {
|
||||
if (argNodes.length < 2 || argNodes.length > 3) return err("NAME");
|
||||
const cond = evaluate(argNodes[0], scope);
|
||||
if (isErr(cond)) return cond;
|
||||
const branch = toBool(cond) ? argNodes[1] : argNodes[2];
|
||||
if (!branch) return { t: "b", v: false };
|
||||
return evaluate(branch, scope);
|
||||
}
|
||||
if (name === "AND" || name === "OR") {
|
||||
let out = name === "AND";
|
||||
for (const node of argNodes) {
|
||||
const v = evaluate(node, scope);
|
||||
if (isErr(v)) return v;
|
||||
const b = toBool(v);
|
||||
if (name === "AND" && !b) return { t: "b", v: false };
|
||||
if (name === "OR" && b) return { t: "b", v: true };
|
||||
out = b;
|
||||
}
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
if (name === "IFERROR") {
|
||||
if (!argNodes.length || argNodes.length > 2) return err("NAME");
|
||||
const v = evaluate(argNodes[0], scope);
|
||||
if (!isErr(v)) return v;
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") return v;
|
||||
return argNodes[1] ? evaluate(argNodes[1], scope) : { t: "blank" };
|
||||
}
|
||||
const fn = FUNCTIONS[name];
|
||||
if (!fn) return err("NAME");
|
||||
const vals = argNodes.map((node) => evaluate(node, scope));
|
||||
for (const v of vals) {
|
||||
if (isErr(v)) {
|
||||
// Errors propagate, except through aggregations over ranges.
|
||||
if (["SUM", "AVERAGE", "MIN", "MAX", "COUNT", "COUNTA", "PRODUCT", "MEDIAN", "CONCAT"].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return fn(vals, scope);
|
||||
}
|
||||
|
||||
export function evaluate(node, scope) {
|
||||
if (Array.isArray(node)) {
|
||||
const [kind, ...rest] = node;
|
||||
switch (kind) {
|
||||
case "n": return { t: "n", v: rest[0] };
|
||||
case "s": return { t: "s", v: rest[0] };
|
||||
case "b": return { t: "b", v: rest[0] };
|
||||
case "neg": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(-n);
|
||||
}
|
||||
case "%": {
|
||||
const v = firstOf(evaluate(rest[0], scope));
|
||||
if (isErr(v)) return v;
|
||||
const n = toNumber(v);
|
||||
return n === null ? err("VALUE") : num(n / 100);
|
||||
}
|
||||
case "ref": {
|
||||
const p = parseA1(rest[0]);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(scope.sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "range": return rangeValues(rest[0], rest[1], scope);
|
||||
case "sheetref": {
|
||||
const [, sheet, ref] = node;
|
||||
const p = parseA1(ref);
|
||||
if (!p) return err("REF");
|
||||
return scope.getCell(sheet, indexToCol(p.col) + p.row);
|
||||
}
|
||||
case "sheetrange": {
|
||||
const [, sheet, r1, r2] = node;
|
||||
const a = parseA1(r1);
|
||||
const b = parseA1(r2);
|
||||
if (!a || !b) return err("REF");
|
||||
const rows = [];
|
||||
for (let r = Math.min(a.row, b.row); r <= Math.max(a.row, b.row); r++) {
|
||||
const line = [];
|
||||
for (let c = Math.min(a.col, b.col); c <= Math.max(a.col, b.col); c++) {
|
||||
line.push(scope.getCell(sheet, indexToCol(c) + r));
|
||||
}
|
||||
rows.push(line);
|
||||
}
|
||||
return { t: "a", rows };
|
||||
}
|
||||
case "call": return callFunction(rest[0], rest[1], scope);
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
case "^":
|
||||
case "&": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
if (kind === "&") return { t: "s", v: toText(l, scope.locale) + toText(r, scope.locale) };
|
||||
const a = toNumber(l);
|
||||
const b = toNumber(r);
|
||||
if (a === null || b === null) return err("VALUE");
|
||||
if (kind === "+") return num(a + b);
|
||||
if (kind === "-") return num(a - b);
|
||||
if (kind === "*") return num(a * b);
|
||||
if (kind === "/") return b === 0 ? err("DIV") : num(a / b);
|
||||
if (kind === "^") {
|
||||
const v = a ** b;
|
||||
return Number.isFinite(v) ? num(v) : err("VALUE");
|
||||
}
|
||||
return err("VALUE");
|
||||
}
|
||||
case "=":
|
||||
case "!=":
|
||||
case "<":
|
||||
case "<=":
|
||||
case ">":
|
||||
case ">=": {
|
||||
const l = firstOf(evaluate(rest[0], scope));
|
||||
const r = firstOf(evaluate(rest[1], scope));
|
||||
if (isErr(l)) return l;
|
||||
if (isErr(r)) return r;
|
||||
const c = cmpValues(l, r);
|
||||
const out = kind === "=" ? c === 0 : kind === "!=" ? c !== 0
|
||||
: kind === "<" ? c < 0 : kind === "<=" ? c <= 0
|
||||
: kind === ">" ? c > 0 : c >= 0;
|
||||
return { t: "b", v: out };
|
||||
}
|
||||
default:
|
||||
return err("NAME");
|
||||
}
|
||||
}
|
||||
return err("NAME");
|
||||
}
|
||||
|
||||
/** Render an evaluated value for display (localized booleans/errors). */
|
||||
export function displayValue(v, locale = "en") {
|
||||
const codes = errorCodes(locale);
|
||||
if (!v || v.t === "blank") return "";
|
||||
if (v.t === "n") return numText(v.v);
|
||||
if (v.t === "s") return v.v;
|
||||
if (v.t === "b") return locale === "fr" ? (v.v ? "VRAI" : "FAUX") : v.v ? "TRUE" : "FALSE";
|
||||
if (v.t === "link") return v.label;
|
||||
if (v.t === "e") {
|
||||
if (v.code === "CYCLE" || v.code === "CIRC") {
|
||||
return locale === "fr" ? codes.CYCLE : codes.CIRC;
|
||||
}
|
||||
return codes[v.code] || codes.VALUE;
|
||||
}
|
||||
if (v.t === "a") {
|
||||
for (const row of v.rows) for (const cell of row) {
|
||||
if (cell && cell.t !== "blank") return displayValue(cell, locale);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
Reference in New Issue
Block a user