@entropicwarrior/sdoc 0.2.4 → 0.2.6
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.
|
@@ -274,6 +274,34 @@ Content of Section B.
|
|
|
274
274
|
\|x\| + \|y\| | 8
|
|
275
275
|
}
|
|
276
276
|
```
|
|
277
|
+
|
|
278
|
+
# Table Formulas @table-formulas
|
|
279
|
+
{
|
|
280
|
+
Cells starting with \`=\` are evaluated as formulas. Hover a computed cell in preview to see the original formula.
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
{[table]
|
|
284
|
+
Investor | Shares | Ownership
|
|
285
|
+
Seed Fund | 500,000 | 25%
|
|
286
|
+
Founder A | 1,000,000 | 50%
|
|
287
|
+
Founder B | 500,000 | 25%
|
|
288
|
+
**Total** | =SUM(B1:B3) | =SUM(C1:C3)
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Cell references use A1 notation (column letter + row number). Headers are excluded from row numbering, so the first data row is row 1.
|
|
293
|
+
|
|
294
|
+
{[.]
|
|
295
|
+
- \`=SUM(range)\` — sum of values
|
|
296
|
+
- \`=AVG(range)\` — arithmetic mean
|
|
297
|
+
- \`=COUNT(range)\` — number of cells
|
|
298
|
+
- Arithmetic: \`=B1+B2\`, \`=B1*3\`, \`=(A1+A2)/2\`, \`=-B1\`
|
|
299
|
+
- Function names are uppercase only
|
|
300
|
+
- Escape with \`\\=\` for a literal equals sign at the start of a cell
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
Errors display in red italic: \`#DIV/0!\` (division by zero), \`#VALUE!\` (bad reference), \`#REF!\` (invalid syntax), \`#NAME!\` (unknown function), \`#CIRCULAR!\` (circular dependency).
|
|
304
|
+
}
|
|
277
305
|
}
|
|
278
306
|
|
|
279
307
|
# Inline Formatting @inline-formatting
|
|
@@ -309,7 +337,7 @@ Content of Section B.
|
|
|
309
337
|
 
|
|
310
338
|
```
|
|
311
339
|
|
|
312
|
-
Autolinks: \`\<https://example.com\>\` — angle brackets are optional; bare URLs starting with \`http://\`, \`https://\`, or \`mailto:\` are also auto-linked.
|
|
340
|
+
Autolinks: \`\<https://example.com\>\` — angle brackets are optional; bare URLs starting with \`http://\`, \`https://\`, or \`mailto:\` are also auto-linked. Bare email addresses (e.g. \`hello@example.com\`) are automatically rendered as \`mailto:\` links.
|
|
313
341
|
|
|
314
342
|
Math: Use \`\$...\$\` for inline math and \`\$\$...\$\$\` for display math. Use \`\\\`\\\`\\\`math\` code fences for multi-line equations. A plain \`\$\` followed by a digit (e.g. \`\$100\`) does not trigger math mode.
|
|
315
343
|
}
|
|
@@ -691,21 +719,21 @@ Content of Section B.
|
|
|
691
719
|
|
|
692
720
|
# Cross-Document @slug References @cross-doc-refs
|
|
693
721
|
{
|
|
694
|
-
\`@slug\` references are document-local. Using \`@slug\` to refer to a section in another file produces a broken reference error
|
|
722
|
+
\`@slug\` references are document-local. Using \`@slug\` to refer to a section in another file produces a broken reference error. This is the most common reference mistake — especially in audits, reviews, and documents that discuss other documents, where writing \`file.sdoc @section\` reads naturally but is wrong.
|
|
695
723
|
|
|
696
|
-
**Wrong** — \`@
|
|
724
|
+
**Wrong** — \`@extension\` looks for a local section, not one in \`foundations.sdoc\`:
|
|
697
725
|
|
|
698
726
|
```
|
|
699
|
-
See `
|
|
727
|
+
See `foundations.sdoc` @extension for the design rationale.
|
|
700
728
|
```
|
|
701
729
|
|
|
702
730
|
**Right** — use a link with a fragment:
|
|
703
731
|
|
|
704
732
|
```
|
|
705
|
-
See [
|
|
733
|
+
See [Extension](./foundations.sdoc#extension) for the design rationale.
|
|
706
734
|
```
|
|
707
735
|
|
|
708
|
-
The fragment (\`#
|
|
736
|
+
The fragment (\`#extension\`) matches the target scope's \`@id\`. Use \`\\@\` if you need a literal \`@\` in text without triggering reference resolution.
|
|
709
737
|
}
|
|
710
738
|
|
|
711
739
|
# @References Inside Link Labels @refs-in-link-labels
|
|
@@ -378,6 +378,78 @@ Content of Section B.
|
|
|
378
378
|
}
|
|
379
379
|
```
|
|
380
380
|
}
|
|
381
|
+
|
|
382
|
+
# Table Formulas @table-formulas
|
|
383
|
+
{
|
|
384
|
+
Table cells whose text begins with `=` (but not `==`) are evaluated as formulas. The result replaces the cell text in rendered output; the original formula is preserved as a tooltip.
|
|
385
|
+
|
|
386
|
+
```
|
|
387
|
+
{[table]
|
|
388
|
+
Item | Qty | Price
|
|
389
|
+
Widget | 10 | 5.00
|
|
390
|
+
Gadget | 5 | 12.50
|
|
391
|
+
**Total** | =SUM(B1:B2) | =SUM(C1:C2)
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
# Cell References
|
|
396
|
+
{
|
|
397
|
+
{[.]
|
|
398
|
+
- References use spreadsheet-style A1 notation: column letter (A-Z) followed by row number (1-based)
|
|
399
|
+
- Row numbering starts at 1 for the first data row; headers are excluded from numbering
|
|
400
|
+
- A range is two references separated by a colon: `A1:B3`
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
# Supported Functions
|
|
405
|
+
{
|
|
406
|
+
{[.]
|
|
407
|
+
- `=SUM(range)` — sum of all values in the range
|
|
408
|
+
- `=AVG(range)` — arithmetic mean of the range
|
|
409
|
+
- `=COUNT(range)` — number of cells in the range
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
Function names are case-sensitive (uppercase only). Multiple ranges can be separated by commas: `=SUM(A1:A3,B1:B3)`.
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# Arithmetic Expressions
|
|
416
|
+
{
|
|
417
|
+
Cells may also contain arithmetic expressions using `+`, `-`, `*`, `/`, parentheses, and cell references:
|
|
418
|
+
|
|
419
|
+
```
|
|
420
|
+
=B1+B2
|
|
421
|
+
=B1*3
|
|
422
|
+
=(A1+A2)/2
|
|
423
|
+
=-B1
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Standard operator precedence applies (multiplication and division before addition and subtraction). Unary minus is supported.
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
# Percentage Values
|
|
430
|
+
{
|
|
431
|
+
Cells ending in `%` are stored internally as decimals (e.g. `25%` = 0.25). When all values in a `SUM` or `AVG` are percentages, the result displays as a percentage. Arithmetic between percentages and plain numbers produces a plain number.
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
# Error Values
|
|
435
|
+
{
|
|
436
|
+
{[.]
|
|
437
|
+
- `#DIV/0!` — division by zero
|
|
438
|
+
- `#VALUE!` — unresolvable reference (e.g. reference to a text cell or out-of-bounds)
|
|
439
|
+
- `#REF!` — invalid cell reference syntax
|
|
440
|
+
- `#NAME!` — unknown function name
|
|
441
|
+
- `#SYNTAX!` — malformed formula expression
|
|
442
|
+
- `#CIRCULAR!` — circular dependency between formula cells
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
Error cells are styled distinctly (red italic) and display the original formula as a tooltip.
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Escaping
|
|
449
|
+
{
|
|
450
|
+
To display a literal `=` at the start of a cell, escape it: `\=SUM(...)` renders as plain text `=SUM(...)`.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
381
453
|
}
|
|
382
454
|
|
|
383
455
|
# Paragraphs @paragraphs
|
|
@@ -431,6 +503,8 @@ Content of Section B.
|
|
|
431
503
|
Only `http`, `https`, and `mailto` schemes are recognised.
|
|
432
504
|
|
|
433
505
|
Bare URLs starting with `http://`, `https://`, or `mailto:` are also auto-linked without angle brackets.
|
|
506
|
+
|
|
507
|
+
Bare email addresses (e.g. `hello@example.com`) are automatically detected and rendered as `mailto:` links. The detection requires at least one character before and after the `@`, with a domain containing a dot. Email auto-detection does not apply inside code spans, links, or angle-bracket autolinks.
|
|
434
508
|
}
|
|
435
509
|
|
|
436
510
|
# Images @images
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@entropicwarrior/sdoc",
|
|
3
3
|
"displayName": "SDOC",
|
|
4
4
|
"description": "A plain-text documentation format with explicit brace scoping — deterministic parsing, AI-agent efficiency, and 10-50x token savings vs Markdown.",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.6",
|
|
6
6
|
"publisher": "entropicwarrior",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
package/src/sdoc.js
CHANGED
|
@@ -1309,7 +1309,35 @@ function parseInline(text) {
|
|
|
1309
1309
|
}
|
|
1310
1310
|
}
|
|
1311
1311
|
|
|
1312
|
-
|
|
1312
|
+
// Bare email autolink: local@domain.tld
|
|
1313
|
+
if (ch === "@" && next && /[A-Za-z0-9]/.test(next)) {
|
|
1314
|
+
const prevCh = i > 0 ? text[i - 1] : "";
|
|
1315
|
+
if (/[A-Za-z0-9._+-]/.test(prevCh)) {
|
|
1316
|
+
// Scan forward for domain part (letters, digits, dots, hyphens)
|
|
1317
|
+
let j = i + 1;
|
|
1318
|
+
while (j < text.length && /[A-Za-z0-9.-]/.test(text[j])) j++;
|
|
1319
|
+
// Strip trailing dots/hyphens
|
|
1320
|
+
while (j > i + 1 && /[.-]/.test(text[j - 1])) j--;
|
|
1321
|
+
const domain = text.slice(i + 1, j);
|
|
1322
|
+
// Must have at least one dot with content on both sides
|
|
1323
|
+
if (/^[A-Za-z0-9]([A-Za-z0-9-]*\.)+[A-Za-z]{2,}$/.test(domain)) {
|
|
1324
|
+
// Extract local part from buffer
|
|
1325
|
+
let k = buffer.length;
|
|
1326
|
+
while (k > 0 && /[A-Za-z0-9._+\-]/.test(buffer[k - 1])) k--;
|
|
1327
|
+
const local = buffer.slice(k);
|
|
1328
|
+
if (local.length > 0) {
|
|
1329
|
+
buffer = buffer.slice(0, k);
|
|
1330
|
+
const email = local + "@" + domain;
|
|
1331
|
+
flush();
|
|
1332
|
+
nodes.push({ type: "link", href: "mailto:" + email, children: [{ type: "text", value: email }] });
|
|
1333
|
+
i = j;
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (ch === "@" && next && isIdentStart(next) && !(i > 0 && /[A-Za-z0-9._+-]/.test(text[i - 1]))) {
|
|
1313
1341
|
let j = i + 1;
|
|
1314
1342
|
while (j < text.length && isIdentChar(text[j])) {
|
|
1315
1343
|
j += 1;
|
|
@@ -1522,6 +1550,307 @@ function renderListItem(scope, listType, depth) {
|
|
|
1522
1550
|
return `<section class="${scopeClass}">${heading}${bodyWrapper}</section>`;
|
|
1523
1551
|
}
|
|
1524
1552
|
|
|
1553
|
+
// ── Table formula engine ──────────────────────────────────────────────
|
|
1554
|
+
|
|
1555
|
+
function isFormulaCell(text) {
|
|
1556
|
+
const t = text.trim();
|
|
1557
|
+
return t.length > 1 && t[0] === "=" && t[1] !== "=" && !t.startsWith("\\=");
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function parseCellRef(ref) {
|
|
1561
|
+
const m = ref.match(/^([A-Z])(\d+)$/);
|
|
1562
|
+
if (!m) return null;
|
|
1563
|
+
return { col: m[1].charCodeAt(0) - 65, row: parseInt(m[2], 10) - 1 };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function expandRange(rangeStr) {
|
|
1567
|
+
const parts = rangeStr.split(":");
|
|
1568
|
+
if (parts.length !== 2) return null;
|
|
1569
|
+
const start = parseCellRef(parts[0]);
|
|
1570
|
+
const end = parseCellRef(parts[1]);
|
|
1571
|
+
if (!start || !end) return null;
|
|
1572
|
+
const refs = [];
|
|
1573
|
+
for (let r = start.row; r <= end.row; r++) {
|
|
1574
|
+
for (let c = start.col; c <= end.col; c++) {
|
|
1575
|
+
refs.push({ col: c, row: r });
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return refs;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function parseCellValue(text) {
|
|
1582
|
+
const t = text.trim();
|
|
1583
|
+
if (/^-?\d+(\.\d+)?%$/.test(t)) {
|
|
1584
|
+
return { value: parseFloat(t) / 100, isPercent: true };
|
|
1585
|
+
}
|
|
1586
|
+
// Strip commas for numbers like 1,000,000
|
|
1587
|
+
const stripped = t.replace(/,/g, "");
|
|
1588
|
+
if (/^-?\d+(\.\d+)?$/.test(stripped)) {
|
|
1589
|
+
return { value: parseFloat(stripped), isPercent: false };
|
|
1590
|
+
}
|
|
1591
|
+
return { value: NaN, isPercent: false };
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function buildCellGrid(rows) {
|
|
1595
|
+
return rows.map((row) => row.map((cell) => {
|
|
1596
|
+
if (isFormulaCell(cell)) return { value: NaN, isPercent: false, formula: cell.trim().slice(1) };
|
|
1597
|
+
return parseCellValue(cell);
|
|
1598
|
+
}));
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function resolveRef(grid, ref) {
|
|
1602
|
+
if (ref.row < 0 || ref.row >= grid.length) return null;
|
|
1603
|
+
if (ref.col < 0 || ref.col >= grid[ref.row].length) return null;
|
|
1604
|
+
return grid[ref.row][ref.col];
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function resolveRefs(grid, refs) {
|
|
1608
|
+
const values = [];
|
|
1609
|
+
for (const ref of refs) {
|
|
1610
|
+
const cell = resolveRef(grid, ref);
|
|
1611
|
+
if (!cell || isNaN(cell.value)) return null;
|
|
1612
|
+
values.push(cell);
|
|
1613
|
+
}
|
|
1614
|
+
return values;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function tokenizeFormula(expr) {
|
|
1618
|
+
const tokens = [];
|
|
1619
|
+
let i = 0;
|
|
1620
|
+
while (i < expr.length) {
|
|
1621
|
+
if (/\s/.test(expr[i])) { i++; continue; }
|
|
1622
|
+
// Function name
|
|
1623
|
+
if (/[A-Z]/.test(expr[i]) && i + 1 < expr.length && /[A-Z]/.test(expr[i + 1])) {
|
|
1624
|
+
let j = i;
|
|
1625
|
+
while (j < expr.length && /[A-Z]/.test(expr[j])) j++;
|
|
1626
|
+
tokens.push({ type: "func", value: expr.slice(i, j) });
|
|
1627
|
+
i = j;
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
// Cell ref or range (e.g. A1, A1:B3)
|
|
1631
|
+
if (/[A-Z]/.test(expr[i]) && i + 1 < expr.length && /\d/.test(expr[i + 1])) {
|
|
1632
|
+
let j = i;
|
|
1633
|
+
while (j < expr.length && /[A-Z0-9:]/.test(expr[j])) j++;
|
|
1634
|
+
tokens.push({ type: "ref", value: expr.slice(i, j) });
|
|
1635
|
+
i = j;
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
// Number
|
|
1639
|
+
if (/[\d.]/.test(expr[i])) {
|
|
1640
|
+
let j = i;
|
|
1641
|
+
while (j < expr.length && /[\d.]/.test(expr[j])) j++;
|
|
1642
|
+
const numStr = expr.slice(i, j);
|
|
1643
|
+
if ((numStr.match(/\./g) || []).length > 1) return { error: "#SYNTAX!" };
|
|
1644
|
+
if (j < expr.length && expr[j] === "%") {
|
|
1645
|
+
tokens.push({ type: "num", value: parseFloat(numStr) / 100, isPercent: true });
|
|
1646
|
+
j++;
|
|
1647
|
+
} else {
|
|
1648
|
+
tokens.push({ type: "num", value: parseFloat(numStr), isPercent: false });
|
|
1649
|
+
}
|
|
1650
|
+
i = j;
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
if ("+-*/(),".includes(expr[i])) {
|
|
1654
|
+
tokens.push({ type: "op", value: expr[i] });
|
|
1655
|
+
i++;
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
return { error: "#SYNTAX!" };
|
|
1659
|
+
}
|
|
1660
|
+
return { tokens };
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function evaluateFormula(formula, grid) {
|
|
1664
|
+
const { tokens, error } = tokenizeFormula(formula);
|
|
1665
|
+
if (error) return { value: NaN, isPercent: false, error };
|
|
1666
|
+
|
|
1667
|
+
let pos = 0;
|
|
1668
|
+
const peek = () => pos < tokens.length ? tokens[pos] : null;
|
|
1669
|
+
const consume = () => tokens[pos++];
|
|
1670
|
+
|
|
1671
|
+
function resolveArg() {
|
|
1672
|
+
const tok = peek();
|
|
1673
|
+
if (!tok) return null;
|
|
1674
|
+
if (tok.type === "ref") {
|
|
1675
|
+
consume();
|
|
1676
|
+
if (tok.value.includes(":")) {
|
|
1677
|
+
const refs = expandRange(tok.value);
|
|
1678
|
+
if (!refs) return { error: "#REF!" };
|
|
1679
|
+
const cells = resolveRefs(grid, refs);
|
|
1680
|
+
if (!cells) return { error: "#VALUE!" };
|
|
1681
|
+
return { cells };
|
|
1682
|
+
} else {
|
|
1683
|
+
const ref = parseCellRef(tok.value);
|
|
1684
|
+
if (!ref) return { error: "#REF!" };
|
|
1685
|
+
const cell = resolveRef(grid, ref);
|
|
1686
|
+
if (!cell || isNaN(cell.value)) return { error: "#VALUE!" };
|
|
1687
|
+
return { cells: [cell] };
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function parseFuncArgs() {
|
|
1694
|
+
const allCells = [];
|
|
1695
|
+
if (!peek() || peek().value !== "(") return { error: "#SYNTAX!" };
|
|
1696
|
+
consume(); // (
|
|
1697
|
+
while (peek() && peek().value !== ")") {
|
|
1698
|
+
const arg = resolveArg();
|
|
1699
|
+
if (!arg) return { error: "#SYNTAX!" };
|
|
1700
|
+
if (arg.error) return arg;
|
|
1701
|
+
allCells.push(...arg.cells);
|
|
1702
|
+
if (peek() && peek().value === ",") consume();
|
|
1703
|
+
}
|
|
1704
|
+
if (!peek() || peek().value !== ")") return { error: "#SYNTAX!" };
|
|
1705
|
+
consume(); // )
|
|
1706
|
+
return { cells: allCells };
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function parseAtom() {
|
|
1710
|
+
const tok = peek();
|
|
1711
|
+
if (!tok) return { error: "#SYNTAX!" };
|
|
1712
|
+
|
|
1713
|
+
if (tok.type === "func") {
|
|
1714
|
+
const fname = consume().value;
|
|
1715
|
+
const args = parseFuncArgs();
|
|
1716
|
+
if (args.error) return args;
|
|
1717
|
+
const allPercent = args.cells.every((c) => c.isPercent);
|
|
1718
|
+
const vals = args.cells.map((c) => c.value);
|
|
1719
|
+
if (fname === "SUM") {
|
|
1720
|
+
return { value: vals.reduce((a, b) => a + b, 0), isPercent: allPercent };
|
|
1721
|
+
} else if (fname === "AVG") {
|
|
1722
|
+
if (vals.length === 0) return { error: "#DIV/0!" };
|
|
1723
|
+
return { value: vals.reduce((a, b) => a + b, 0) / vals.length, isPercent: allPercent };
|
|
1724
|
+
} else if (fname === "COUNT") {
|
|
1725
|
+
return { value: vals.length, isPercent: false };
|
|
1726
|
+
}
|
|
1727
|
+
return { error: "#NAME!" };
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (tok.type === "num") {
|
|
1731
|
+
consume();
|
|
1732
|
+
return { value: tok.value, isPercent: tok.isPercent };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (tok.type === "ref") {
|
|
1736
|
+
consume();
|
|
1737
|
+
const ref = parseCellRef(tok.value);
|
|
1738
|
+
if (!ref) return { error: "#REF!" };
|
|
1739
|
+
const cell = resolveRef(grid, ref);
|
|
1740
|
+
if (!cell || isNaN(cell.value)) return { error: "#VALUE!" };
|
|
1741
|
+
return { value: cell.value, isPercent: cell.isPercent };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
if (tok.type === "op" && tok.value === "(") {
|
|
1745
|
+
consume();
|
|
1746
|
+
const result = parseExpr();
|
|
1747
|
+
if (result.error) return result;
|
|
1748
|
+
if (!peek() || peek().value !== ")") return { error: "#SYNTAX!" };
|
|
1749
|
+
consume();
|
|
1750
|
+
return result;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Unary minus
|
|
1754
|
+
if (tok.type === "op" && tok.value === "-") {
|
|
1755
|
+
consume();
|
|
1756
|
+
const operand = parseAtom();
|
|
1757
|
+
if (operand.error) return operand;
|
|
1758
|
+
return { value: -operand.value, isPercent: operand.isPercent };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
return { error: "#SYNTAX!" };
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function parseTerm() {
|
|
1765
|
+
let left = parseAtom();
|
|
1766
|
+
if (left.error) return left;
|
|
1767
|
+
while (peek() && (peek().value === "*" || peek().value === "/")) {
|
|
1768
|
+
const op = consume().value;
|
|
1769
|
+
const right = parseAtom();
|
|
1770
|
+
if (right.error) return right;
|
|
1771
|
+
if (op === "/") {
|
|
1772
|
+
if (right.value === 0) return { error: "#DIV/0!" };
|
|
1773
|
+
left = { value: left.value / right.value, isPercent: false };
|
|
1774
|
+
} else {
|
|
1775
|
+
left = { value: left.value * right.value, isPercent: false };
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
return left;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function parseExpr() {
|
|
1782
|
+
let left = parseTerm();
|
|
1783
|
+
if (left.error) return left;
|
|
1784
|
+
while (peek() && (peek().value === "+" || peek().value === "-")) {
|
|
1785
|
+
const op = consume().value;
|
|
1786
|
+
const right = parseTerm();
|
|
1787
|
+
if (right.error) return right;
|
|
1788
|
+
const bothPercent = left.isPercent && right.isPercent;
|
|
1789
|
+
left = {
|
|
1790
|
+
value: op === "+" ? left.value + right.value : left.value - right.value,
|
|
1791
|
+
isPercent: bothPercent,
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
return left;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const result = parseExpr();
|
|
1798
|
+
if (result.error) return { value: NaN, isPercent: false, error: result.error };
|
|
1799
|
+
if (peek()) return { value: NaN, isPercent: false, error: "#SYNTAX!" };
|
|
1800
|
+
return { value: result.value, isPercent: result.isPercent, error: null };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function evaluateGrid(grid) {
|
|
1804
|
+
// Track which cells started as formulas for circular reference detection
|
|
1805
|
+
const formulaCells = [];
|
|
1806
|
+
for (let r = 0; r < grid.length; r++) {
|
|
1807
|
+
for (let c = 0; c < grid[r].length; c++) {
|
|
1808
|
+
if (grid[r][c].formula) formulaCells.push([r, c]);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// Topological evaluation: resolve formulas that depend on other formulas
|
|
1812
|
+
const maxPasses = formulaCells.length + 1;
|
|
1813
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
1814
|
+
let pending = false;
|
|
1815
|
+
for (let r = 0; r < grid.length; r++) {
|
|
1816
|
+
for (let c = 0; c < grid[r].length; c++) {
|
|
1817
|
+
const cell = grid[r][c];
|
|
1818
|
+
if (!cell.formula) continue;
|
|
1819
|
+
const result = evaluateFormula(cell.formula, grid);
|
|
1820
|
+
if (result.error === "#VALUE!" && pass < maxPasses - 1) {
|
|
1821
|
+
// Might resolve on a later pass when dependencies are computed
|
|
1822
|
+
pending = true;
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
cell.value = result.value;
|
|
1826
|
+
cell.isPercent = result.isPercent;
|
|
1827
|
+
cell.error = result.error;
|
|
1828
|
+
delete cell.formula;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
if (!pending) break;
|
|
1832
|
+
}
|
|
1833
|
+
// Any formula cells still showing #VALUE! after all passes are circular
|
|
1834
|
+
for (const [r, c] of formulaCells) {
|
|
1835
|
+
if (grid[r][c].error === "#VALUE!") {
|
|
1836
|
+
grid[r][c].error = "#CIRCULAR!";
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
return grid;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function formatFormulaResult(cell) {
|
|
1843
|
+
if (cell.error) return cell.error;
|
|
1844
|
+
if (cell.isPercent) {
|
|
1845
|
+
const pct = cell.value * 100;
|
|
1846
|
+
return (Number.isInteger(pct) ? pct.toString() : pct.toFixed(2).replace(/\.?0+$/, "")) + "%";
|
|
1847
|
+
}
|
|
1848
|
+
if (Number.isInteger(cell.value)) return cell.value.toLocaleString();
|
|
1849
|
+
return cell.value.toFixed(2).replace(/\.?0+$/, "");
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// ── End formula engine ───────────────────────────────────────────────
|
|
1853
|
+
|
|
1525
1854
|
function renderTable(table) {
|
|
1526
1855
|
const dl = dataLineAttrs(table);
|
|
1527
1856
|
const opts = table.options || {};
|
|
@@ -1538,10 +1867,24 @@ function renderTable(table) {
|
|
|
1538
1867
|
thead = `<thead class="sdoc-table-head"><tr>${headerCells}</tr></thead>`;
|
|
1539
1868
|
}
|
|
1540
1869
|
|
|
1870
|
+
// Formula evaluation
|
|
1871
|
+
const grid = evaluateGrid(buildCellGrid(table.rows));
|
|
1872
|
+
|
|
1541
1873
|
const bodyRows = table.rows
|
|
1542
|
-
.map((row) => {
|
|
1874
|
+
.map((row, r) => {
|
|
1543
1875
|
const cells = row
|
|
1544
|
-
.map((cell) =>
|
|
1876
|
+
.map((cell, c) => {
|
|
1877
|
+
if (isFormulaCell(cell)) {
|
|
1878
|
+
const result = grid[r][c];
|
|
1879
|
+
const display = escapeHtml(formatFormulaResult(result));
|
|
1880
|
+
const formula = escapeAttr(cell.trim());
|
|
1881
|
+
if (result.error) {
|
|
1882
|
+
return `<td class="sdoc-table-td sdoc-formula-error" title="${formula}">${display}</td>`;
|
|
1883
|
+
}
|
|
1884
|
+
return `<td class="sdoc-table-td sdoc-formula-cell" title="${formula}">${display}</td>`;
|
|
1885
|
+
}
|
|
1886
|
+
return `<td class="sdoc-table-td">${renderInline(cell)}</td>`;
|
|
1887
|
+
})
|
|
1545
1888
|
.join("");
|
|
1546
1889
|
return `<tr>${cells}</tr>`;
|
|
1547
1890
|
})
|
|
@@ -1871,6 +2214,19 @@ const DEFAULT_STYLE = `
|
|
|
1871
2214
|
border-bottom: none;
|
|
1872
2215
|
}
|
|
1873
2216
|
|
|
2217
|
+
td.sdoc-formula-cell {
|
|
2218
|
+
font-variant-numeric: tabular-nums;
|
|
2219
|
+
font-weight: 600;
|
|
2220
|
+
color: #2a7a8a !important;
|
|
2221
|
+
cursor: help;
|
|
2222
|
+
border-bottom: 1px dotted #2a7a8a40;
|
|
2223
|
+
}
|
|
2224
|
+
td.sdoc-formula-error {
|
|
2225
|
+
color: #c33 !important;
|
|
2226
|
+
font-style: italic;
|
|
2227
|
+
cursor: help;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
1874
2230
|
.sdoc-table-borderless,
|
|
1875
2231
|
.sdoc-table-borderless th,
|
|
1876
2232
|
.sdoc-table-borderless td {
|