sdocs-dev 1.6.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ // sdocs-cells-formula.js - a small spreadsheet formula engine.
2
+ //
3
+ // Pure, dependency-free, shared between the browser (window.SDocCellsFormula)
4
+ // and Node tests (module.exports) via the UMD pattern used by the other cells
5
+ // modules. It evaluates a single cell's formula string (anything whose raw
6
+ // text starts with "=") against a grid of other cells, and recalc() resolves
7
+ // a whole model at once with cycle detection.
8
+ //
9
+ // Supported, deliberately small but useful:
10
+ // numbers 12, 3.5, -2, 1e3
11
+ // operators + - * / ^ % and unary minus, with parentheses
12
+ // cell references A1, B12 (column letters + 1-based row, like the UI)
13
+ // ranges A1:B3 (only inside a function's arguments)
14
+ // functions SUM AVERAGE/AVG MIN MAX COUNT COUNTA PRODUCT
15
+ // ROUND ABS IF
16
+ // comparisons = <> < <= > >= (mainly for IF)
17
+ //
18
+ // Values: a referenced empty cell is 0 in arithmetic; referenced text is an
19
+ // error (#VALUE!) in arithmetic but is counted by COUNTA and ignored by SUM.
20
+ // Anything that goes wrong yields an error string (#VALUE!, #DIV/0!, #NAME?,
21
+ // #REF!, #CIRC!) which the renderer shows in the cell, just like a real sheet.
22
+ (function (exports) {
23
+ 'use strict';
24
+
25
+ // Column letters -> 0-based index (mirror sdocs-cells.js so refs line up).
26
+ function colIndex(letters) {
27
+ var n = 0;
28
+ for (var i = 0; i < letters.length; i++) {
29
+ n = n * 26 + (letters.charCodeAt(i) - 64); // 'A' = 65 -> 1
30
+ }
31
+ return n - 1;
32
+ }
33
+
34
+ // 0-based index -> column letters (inverse of colIndex): 0 -> A, 26 -> AA.
35
+ function colName(index) {
36
+ var name = '';
37
+ var n = index + 1;
38
+ while (n > 0) {
39
+ var rem = (n - 1) % 26;
40
+ name = String.fromCharCode(65 + rem) + name;
41
+ n = Math.floor((n - 1) / 26);
42
+ }
43
+ return name;
44
+ }
45
+
46
+ function isFormula(raw) {
47
+ return typeof raw === 'string' && raw.charAt(0) === '=' && raw.length > 1;
48
+ }
49
+
50
+ function mkErr(code) { var e = new Error(code); e.isFormulaError = true; e.code = code; return e; }
51
+
52
+ // ── Tokenizer ────────────────────────────────────────────
53
+ function tokenize(src) {
54
+ var toks = [];
55
+ var i = 0, n = src.length;
56
+ function isDigit(c) { return c >= '0' && c <= '9'; }
57
+ function isAlpha(c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); }
58
+ while (i < n) {
59
+ var c = src[i];
60
+ if (c === ' ' || c === '\t') { i++; continue; }
61
+ if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
62
+ var num = '';
63
+ while (i < n && (isDigit(src[i]) || src[i] === '.')) num += src[i++];
64
+ if (i < n && (src[i] === 'e' || src[i] === 'E')) {
65
+ num += src[i++];
66
+ if (src[i] === '+' || src[i] === '-') num += src[i++];
67
+ while (i < n && isDigit(src[i])) num += src[i++];
68
+ }
69
+ toks.push({ t: 'num', v: parseFloat(num) });
70
+ continue;
71
+ }
72
+ if (isAlpha(c)) {
73
+ var word = '';
74
+ while (i < n && (isAlpha(src[i]) || isDigit(src[i]))) word += src[i++];
75
+ // A sheet-qualified reference: Sheet!A1. The word before '!' is the
76
+ // sheet name (any letters/digits run - Sales, Summary, Sheet1, Q1);
77
+ // the part after '!' must be a plain cell ref. Emitted as ONE ref
78
+ // token carrying `sheet`, so the parser's range branch (the next ':'
79
+ // check) keeps the sheet on the qualified endpoint.
80
+ if (src[i] === '!') {
81
+ var sheet = word;
82
+ i++; // consume '!'
83
+ var cellWord = '';
84
+ while (i < n && (isAlpha(src[i]) || isDigit(src[i]))) cellWord += src[i++];
85
+ var cm = /^([A-Za-z]+)([0-9]+)$/.exec(cellWord);
86
+ if (!cm) throw mkErr('#REF!');
87
+ toks.push({ t: 'ref', sheet: sheet, col: colIndex(cm[1].toUpperCase()), row: parseInt(cm[2], 10) - 1 });
88
+ continue;
89
+ }
90
+ var m = /^([A-Za-z]+)([0-9]+)$/.exec(word);
91
+ if (m && src[i] !== '(') {
92
+ toks.push({ t: 'ref', col: colIndex(m[1].toUpperCase()), row: parseInt(m[2], 10) - 1 });
93
+ } else {
94
+ toks.push({ t: 'name', v: word.toUpperCase() });
95
+ }
96
+ continue;
97
+ }
98
+ if (c === '<' || c === '>') {
99
+ var op = c; i++;
100
+ if (src[i] === '=' || (c === '<' && src[i] === '>')) op += src[i++];
101
+ toks.push({ t: 'op', v: op });
102
+ continue;
103
+ }
104
+ if ('+-*/^%(),:='.indexOf(c) !== -1) { toks.push({ t: 'op', v: c }); i++; continue; }
105
+ // A literal #REF! (left behind by shiftFormula when a reference was
106
+ // pushed off the sheet) evaluates to that error.
107
+ if (c === '#') throw mkErr(src.slice(i, i + 5).toUpperCase() === '#REF!' ? '#REF!' : '#NAME?');
108
+ throw mkErr('#NAME?');
109
+ }
110
+ return toks;
111
+ }
112
+
113
+ // ── Recursive-descent parser -> AST ──────────────────────
114
+ function parse(toks) {
115
+ var pos = 0;
116
+ function peek() { return toks[pos]; }
117
+ function next() { return toks[pos++]; }
118
+ function expectOp(v) { var t = next(); if (!t || t.t !== 'op' || t.v !== v) throw mkErr('#VALUE!'); }
119
+
120
+ function parseCompare() {
121
+ var left = parseAdd();
122
+ var t = peek();
123
+ if (t && t.t === 'op' && ['=', '<>', '<', '<=', '>', '>='].indexOf(t.v) !== -1) {
124
+ next();
125
+ return { k: 'cmp', op: t.v, a: left, b: parseAdd() };
126
+ }
127
+ return left;
128
+ }
129
+ function parseAdd() {
130
+ var node = parseMul();
131
+ while (peek() && peek().t === 'op' && (peek().v === '+' || peek().v === '-')) {
132
+ var op = next().v; node = { k: 'bin', op: op, a: node, b: parseMul() };
133
+ }
134
+ return node;
135
+ }
136
+ function parseMul() {
137
+ var node = parsePow();
138
+ while (peek() && peek().t === 'op' && (peek().v === '*' || peek().v === '/')) {
139
+ var op = next().v; node = { k: 'bin', op: op, a: node, b: parsePow() };
140
+ }
141
+ return node;
142
+ }
143
+ function parsePow() {
144
+ var node = parseUnary();
145
+ if (peek() && peek().t === 'op' && peek().v === '^') {
146
+ next(); return { k: 'bin', op: '^', a: node, b: parsePow() };
147
+ }
148
+ return node;
149
+ }
150
+ function parseUnary() {
151
+ var t = peek();
152
+ if (t && t.t === 'op' && (t.v === '-' || t.v === '+')) {
153
+ next(); return { k: 'unary', op: t.v, a: parseUnary() };
154
+ }
155
+ return parsePostfix();
156
+ }
157
+ function parsePostfix() {
158
+ var node = parsePrimary();
159
+ if (peek() && peek().t === 'op' && peek().v === '%') { next(); node = { k: 'percent', a: node }; }
160
+ return node;
161
+ }
162
+ function parsePrimary() {
163
+ var t = next();
164
+ if (!t) throw mkErr('#VALUE!');
165
+ if (t.t === 'num') return { k: 'num', v: t.v };
166
+ if (t.t === 'ref') {
167
+ if (peek() && peek().t === 'op' && peek().v === ':') {
168
+ next(); var end = next();
169
+ if (!end || end.t !== 'ref') throw mkErr('#REF!');
170
+ // A range stays within one sheet. A qualified start (Sales!A1:B3)
171
+ // applies its sheet to both ends; a range that names two different
172
+ // sheets (Sheet1!A1:Sheet2!B2) has no coherent rectangle -> #REF!.
173
+ var startKey = (t.sheet || '').toLowerCase();
174
+ var endKey = (end.sheet || '').toLowerCase();
175
+ if (end.sheet != null && endKey !== startKey) throw mkErr('#REF!');
176
+ return { k: 'range', sheet: t.sheet, c0: t.col, r0: t.row, c1: end.col, r1: end.row };
177
+ }
178
+ return { k: 'ref', col: t.col, row: t.row, sheet: t.sheet };
179
+ }
180
+ if (t.t === 'name') {
181
+ if (peek() && peek().t === 'op' && peek().v === '(') {
182
+ next();
183
+ var args = [];
184
+ if (!(peek() && peek().t === 'op' && peek().v === ')')) {
185
+ args.push(parseCompare());
186
+ while (peek() && peek().t === 'op' && peek().v === ',') { next(); args.push(parseCompare()); }
187
+ }
188
+ expectOp(')');
189
+ return { k: 'call', name: t.v, args: args };
190
+ }
191
+ if (t.v === 'TRUE') return { k: 'num', v: 1 };
192
+ if (t.v === 'FALSE') return { k: 'num', v: 0 };
193
+ throw mkErr('#NAME?');
194
+ }
195
+ if (t.t === 'op' && t.v === '(') { var e = parseCompare(); expectOp(')'); return e; }
196
+ throw mkErr('#VALUE!');
197
+ }
198
+
199
+ var ast = parseCompare();
200
+ if (pos !== toks.length) throw mkErr('#VALUE!');
201
+ return ast;
202
+ }
203
+
204
+ // ── Evaluator ────────────────────────────────────────────
205
+ function evalAst(node, ctx) {
206
+ switch (node.k) {
207
+ case 'num': return node.v;
208
+ case 'unary': { var v = num(evalAst(node.a, ctx)); return node.op === '-' ? -v : v; }
209
+ case 'percent': return num(evalAst(node.a, ctx)) / 100;
210
+ case 'bin': {
211
+ var a = num(evalAst(node.a, ctx)), b = num(evalAst(node.b, ctx));
212
+ switch (node.op) {
213
+ case '+': return a + b;
214
+ case '-': return a - b;
215
+ case '*': return a * b;
216
+ case '/': if (b === 0) throw mkErr('#DIV/0!'); return a / b;
217
+ case '^': return Math.pow(a, b);
218
+ }
219
+ throw mkErr('#VALUE!');
220
+ }
221
+ case 'cmp': {
222
+ var x = num(evalAst(node.a, ctx)), y = num(evalAst(node.b, ctx)), r;
223
+ switch (node.op) {
224
+ case '=': r = x === y; break;
225
+ case '<>': r = x !== y; break;
226
+ case '<': r = x < y; break;
227
+ case '<=': r = x <= y; break;
228
+ case '>': r = x > y; break;
229
+ case '>=': r = x >= y; break;
230
+ default: throw mkErr('#VALUE!');
231
+ }
232
+ return r ? 1 : 0;
233
+ }
234
+ case 'ref': return refValue(ctx.cell(node.col, node.row, node.sheet));
235
+ case 'range': throw mkErr('#VALUE!');
236
+ case 'call': return callFn(node, ctx);
237
+ }
238
+ throw mkErr('#VALUE!');
239
+ }
240
+
241
+ function refValue(cell) {
242
+ if (!cell || cell.kind === 'empty') return 0;
243
+ if (cell.kind === 'number') return cell.value;
244
+ if (cell.kind === 'error') throw mkErr(cell.code || '#VALUE!');
245
+ throw mkErr('#VALUE!');
246
+ }
247
+
248
+ function num(v) { if (typeof v !== 'number' || !isFinite(v)) throw mkErr('#VALUE!'); return v; }
249
+
250
+ function argCells(arg, ctx) {
251
+ if (arg.k === 'range') {
252
+ var out = [];
253
+ var r0 = Math.min(arg.r0, arg.r1), r1 = Math.max(arg.r0, arg.r1);
254
+ var c0 = Math.min(arg.c0, arg.c1), c1 = Math.max(arg.c0, arg.c1);
255
+ for (var r = r0; r <= r1; r++) {
256
+ for (var c = c0; c <= c1; c++) out.push(ctx.cell(c, r, arg.sheet));
257
+ }
258
+ return out;
259
+ }
260
+ return [{ kind: 'number', value: num(evalAst(arg, ctx)) }];
261
+ }
262
+
263
+ function callFn(node, ctx) {
264
+ var name = node.name, args = node.args;
265
+ switch (name) {
266
+ case 'SUM': case 'PRODUCT': case 'MIN': case 'MAX':
267
+ case 'AVERAGE': case 'AVG': case 'COUNT': case 'COUNTA': {
268
+ var nums = [], counted = 0, errored = null;
269
+ for (var i = 0; i < args.length; i++) {
270
+ var cells = argCells(args[i], ctx);
271
+ for (var j = 0; j < cells.length; j++) {
272
+ var cl = cells[j];
273
+ if (cl.kind === 'error') { errored = cl.code || '#VALUE!'; }
274
+ else if (cl.kind === 'number') { nums.push(cl.value); counted++; }
275
+ else if (cl.kind === 'text') { counted++; }
276
+ }
277
+ }
278
+ if (errored && name !== 'COUNTA') throw mkErr(errored);
279
+ if (name === 'COUNT') return nums.length;
280
+ if (name === 'COUNTA') return counted;
281
+ if (!nums.length) {
282
+ if (name === 'SUM') return 0;
283
+ if (name === 'PRODUCT') return 0;
284
+ throw mkErr('#DIV/0!');
285
+ }
286
+ if (name === 'SUM') return nums.reduce(function (a, b) { return a + b; }, 0);
287
+ if (name === 'PRODUCT') return nums.reduce(function (a, b) { return a * b; }, 1);
288
+ if (name === 'MIN') return Math.min.apply(null, nums);
289
+ if (name === 'MAX') return Math.max.apply(null, nums);
290
+ return nums.reduce(function (a, b) { return a + b; }, 0) / nums.length; // AVERAGE
291
+ }
292
+ case 'ROUND': {
293
+ if (args.length < 1) throw mkErr('#VALUE!');
294
+ var x = num(evalAst(args[0], ctx));
295
+ var d = args.length > 1 ? num(evalAst(args[1], ctx)) : 0;
296
+ var f = Math.pow(10, d);
297
+ return Math.round(x * f) / f;
298
+ }
299
+ case 'ABS':
300
+ if (args.length !== 1) throw mkErr('#VALUE!');
301
+ return Math.abs(num(evalAst(args[0], ctx)));
302
+ case 'IF': {
303
+ if (args.length < 2) throw mkErr('#VALUE!');
304
+ var cond = num(evalAst(args[0], ctx));
305
+ return cond !== 0 ? evalAst(args[1], ctx)
306
+ : (args.length > 2 ? evalAst(args[2], ctx) : 0);
307
+ }
308
+ }
309
+ throw mkErr('#NAME?');
310
+ }
311
+
312
+ // ── Relative reference shifting (fill handle / copy-paste) ──
313
+ // Rewrite every cell reference in a formula by (dr, dc) rows/columns:
314
+ // shiftFormula('=B2*C2', 1, 0) -> '=B3*C3'. Function names (SUM, IF...) are
315
+ // left alone - a word is only a reference when it is letters+digits and not
316
+ // followed by '('. A reference pushed past row 1 / column A becomes the
317
+ // literal #REF!, which evaluates to a #REF! error. Non-formula strings pass
318
+ // through unchanged.
319
+ function shiftFormula(formula, dr, dc) {
320
+ if (!isFormula(formula)) return formula;
321
+ var src = formula.slice(1);
322
+ var out = '';
323
+ var i = 0, n = src.length;
324
+ function isAlpha(ch) { return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); }
325
+ function isDigit(ch) { return ch >= '0' && ch <= '9'; }
326
+ while (i < n) {
327
+ var c = src[i];
328
+ if (isAlpha(c)) {
329
+ var j = i;
330
+ while (j < n && (isAlpha(src[j]) || isDigit(src[j]))) j++;
331
+ var word = src.slice(i, j);
332
+ // A sheet qualifier (Sheet!A1) - emit the name + '!' untouched and let
333
+ // the next iteration shift the cell ref that follows. Without this a
334
+ // numeric-suffixed sheet name (Q1, Sheet1) would be shifted as if it
335
+ // were a cell reference.
336
+ if (src[j] === '!') {
337
+ out += word + '!';
338
+ i = j + 1;
339
+ continue;
340
+ }
341
+ var m = /^([A-Za-z]+)([0-9]+)$/.exec(word);
342
+ if (m && src[j] !== '(') {
343
+ var col = colIndex(m[1].toUpperCase()) + dc;
344
+ var row = parseInt(m[2], 10) - 1 + dr;
345
+ out += (col < 0 || row < 0) ? '#REF!' : colName(col) + (row + 1);
346
+ } else {
347
+ out += word;
348
+ }
349
+ i = j;
350
+ continue;
351
+ }
352
+ out += c;
353
+ i++;
354
+ }
355
+ return '=' + out;
356
+ }
357
+
358
+ // Evaluate one formula string against ctx. Returns { value } or { error }.
359
+ function evaluate(formula, ctx) {
360
+ try {
361
+ var src = formula.charAt(0) === '=' ? formula.slice(1) : formula;
362
+ var ast = parse(tokenize(src));
363
+ var v = evalAst(ast, ctx);
364
+ if (typeof v !== 'number' || !isFinite(v)) return { error: '#VALUE!' };
365
+ return { value: v };
366
+ } catch (e) {
367
+ return { error: e && e.isFormulaError ? e.code : '#VALUE!' };
368
+ }
369
+ }
370
+
371
+ // ── Whole-workbook recalc with cross-sheet cycle detection ──
372
+ //
373
+ // recalcWorkbook resolves a list of sheets at once so a formula in one
374
+ // sheet can read a cell in another via a qualified reference (Sheet!A1).
375
+ // One shared memo (`results`/`state`) is keyed by (sheetIndex, r, c): the
376
+ // in-progress guard that catches A1->A1 within a sheet then also catches a
377
+ // cycle that spans sheets. The cycle detector never learns about sheets,
378
+ // it just gets a wider address space.
379
+ //
380
+ // sheets: [{ name, model }, ...] name optional; '' for an anonymous
381
+ // single sheet (see recalc below).
382
+ //
383
+ // Returns one results grid per sheet, same shape and order as the input:
384
+ // [ results0, results1, ... ] resultsN[r][c] = {kind, value/code}
385
+ //
386
+ // Sheet names resolve to a stable index up front (case-insensitive; on a
387
+ // name collision the FIRST sheet with that name wins). A qualified ref to a
388
+ // name that does not exist is reported as #REF! by the ctx and never enters
389
+ // resolve - so a missing sheet can neither hang nor bypass the cycle guard.
390
+ function recalcWorkbook(sheets) {
391
+ var n = sheets.length;
392
+ var results = []; // results[s][r][c]
393
+ var state = []; // state[s][r][c]: 1 = in progress, 2 = done
394
+ var nameToIndex = {};
395
+ for (var s = 0; s < n; s++) {
396
+ var sres = [], sstate = [];
397
+ var model0 = sheets[s].model;
398
+ var srows = model0 && model0.cells ? model0.cells.length : 0;
399
+ for (var r0 = 0; r0 < srows; r0++) { sres.push([]); sstate.push([]); }
400
+ results.push(sres); state.push(sstate);
401
+ var nm = sheets[s].name;
402
+ if (nm) {
403
+ var nkey = String(nm).toLowerCase();
404
+ if (!(nkey in nameToIndex)) nameToIndex[nkey] = s; // first wins
405
+ }
406
+ }
407
+
408
+ function rowsOf(si) {
409
+ var model = sheets[si].model;
410
+ return model && model.cells ? model.cells.length : 0;
411
+ }
412
+ function rawAt(si, c, r) {
413
+ var line = sheets[si].model.cells[r];
414
+ var cell = line && line[c];
415
+ return cell ? cell.raw : '';
416
+ }
417
+ function baseKind(si, c, r) {
418
+ var line = sheets[si].model.cells[r];
419
+ var cell = line && line[c];
420
+ if (!cell || cell.type === 'empty') return { kind: 'empty' };
421
+ if (cell.type === 'number') return { kind: 'number', value: cell.value };
422
+ return { kind: 'text', value: cell.raw };
423
+ }
424
+
425
+ // Resolve cell (c, r) within sheet index `si`. Bounds are checked against
426
+ // THIS sheet's row count (rowsOf(si)), not any caller's - a cross-sheet
427
+ // ref to a short sheet reads empty, not the wrong row.
428
+ function resolve(si, c, r) {
429
+ if (r < 0 || r >= rowsOf(si)) return { kind: 'empty' };
430
+ var rRes = results[si][r], rState = state[si][r];
431
+ if (rRes[c]) return rRes[c];
432
+ if (rState && rState[c] === 1) {
433
+ return (rRes[c] = { kind: 'error', code: '#CIRC!' });
434
+ }
435
+ var raw = rawAt(si, c, r);
436
+ if (!isFormula(raw)) return (rRes[c] = baseKind(si, c, r));
437
+ rState[c] = 1;
438
+ // ctx.cell(col, row, sheet): an undefined `sheet` means the formula's
439
+ // own sheet (si); a named sheet is looked up in nameToIndex; a name with
440
+ // no matching sheet is #REF! and never enters resolve.
441
+ var ctx = {
442
+ cell: function (cc, rr, sheetName) {
443
+ if (sheetName == null) return resolve(si, cc, rr);
444
+ var key = String(sheetName).toLowerCase();
445
+ if (!(key in nameToIndex)) return { kind: 'error', code: '#REF!' };
446
+ return resolve(nameToIndex[key], cc, rr);
447
+ },
448
+ };
449
+ var out = evaluate(raw, ctx);
450
+ rState[c] = 2;
451
+ return (rRes[c] = out.error
452
+ ? { kind: 'error', code: out.error }
453
+ : { kind: 'number', value: out.value });
454
+ }
455
+
456
+ for (var si2 = 0; si2 < n; si2++) {
457
+ var rows = rowsOf(si2);
458
+ for (var rr = 0; rr < rows; rr++) {
459
+ var line = sheets[si2].model.cells[rr];
460
+ var cols = line ? line.length : 0;
461
+ for (var cc = 0; cc < cols; cc++) resolve(si2, cc, rr);
462
+ }
463
+ }
464
+ return results;
465
+ }
466
+
467
+ // Whole-model recalc for a single sheet. A thin adapter over
468
+ // recalcWorkbook so every existing caller (renderer, editor, xlsx exporter,
469
+ // tests) keeps the same `recalc(model) -> results[r][c]` contract. With one
470
+ // anonymous sheet there are no qualified references to resolve, so the
471
+ // output is identical to the pre-workbook recalc.
472
+ function recalc(model) {
473
+ return recalcWorkbook([{ name: '', model: model }])[0];
474
+ }
475
+
476
+ exports.colIndex = colIndex;
477
+ exports.colName = colName;
478
+ exports.isFormula = isFormula;
479
+ exports.tokenize = tokenize;
480
+ exports.parse = parse;
481
+ exports.evaluate = evaluate;
482
+ exports.recalc = recalc;
483
+ exports.recalcWorkbook = recalcWorkbook;
484
+ exports.shiftFormula = shiftFormula;
485
+ })(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocCellsFormula = {}));