@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
  ![A](a.png =48%) ![B](b.png =48%)
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** — \`@setup\` does not exist in this file:
724
+ **Wrong** — \`@extension\` looks for a local section, not one in \`foundations.sdoc\`:
697
725
 
698
726
  ```
699
- See `getting-started.sdoc` @setup for installation steps.
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 [Setup](./getting-started.sdoc#setup) for installation steps.
733
+ See [Extension](./foundations.sdoc#extension) for the design rationale.
706
734
  ```
707
735
 
708
- The fragment (\`#setup\`) matches the target scope's \`@id\`. Use \`\\@\` if you need a literal \`@\` in text without triggering reference resolution.
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
@@ -4,7 +4,7 @@
4
4
  {
5
5
  type: skill
6
6
 
7
- sdoc-version: 0.1
7
+ sdoc-version: 0.2
8
8
  }
9
9
 
10
10
  # About @about
@@ -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.4",
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
- if (ch === "@" && next && isIdentStart(next)) {
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) => `<td class="sdoc-table-td">${renderInline(cell)}</td>`)
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 {