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.
756 lines
25 KiB
JavaScript
756 lines
25 KiB
JavaScript
/* 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 "";
|
|
}
|