@geml/geml 1.0.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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/attrs.d.ts +9 -0
- package/dist/attrs.js +64 -0
- package/dist/chart.d.ts +28 -0
- package/dist/chart.js +128 -0
- package/dist/from-md.d.ts +5 -0
- package/dist/from-md.js +242 -0
- package/dist/geml.d.ts +70 -0
- package/dist/geml.js +730 -0
- package/dist/history.d.ts +55 -0
- package/dist/history.js +507 -0
- package/dist/inline.d.ts +52 -0
- package/dist/inline.js +418 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.js +508 -0
- package/dist/serialize.d.ts +2 -0
- package/dist/serialize.js +187 -0
- package/dist/table.d.ts +32 -0
- package/dist/table.js +469 -0
- package/dist/to-md.d.ts +5 -0
- package/dist/to-md.js +213 -0
- package/package.json +54 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// GEML serializer (§ round-trip): document model -> GEML source.
|
|
2
|
+
//
|
|
3
|
+
// This is the inverse of `parse`: given a Document (or its Block[]), emit GEML
|
|
4
|
+
// text that parses back to the *same model*. It does not try to reproduce the
|
|
5
|
+
// original bytes — whitespace, attribute quoting, fence length, and footnote
|
|
6
|
+
// shorthand are normalized — so `serialize(parse(src))` is also a canonical
|
|
7
|
+
// formatter (`geml fmt`). The guarantee it is built to keep is model stability:
|
|
8
|
+
//
|
|
9
|
+
// parse(serialize(parse(src))) ≅ parse(src)
|
|
10
|
+
//
|
|
11
|
+
// verified over the conformance corpus by test/roundtrip.test.mjs.
|
|
12
|
+
import { parseInline } from "./inline.js";
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Values & attributes (§4)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// True when a bare string would be re-read by `coerce` as a non-string (a
|
|
17
|
+
// boolean or a number) — in which case it must be quoted to stay a string.
|
|
18
|
+
function looksTyped(s) {
|
|
19
|
+
return (s === "true" ||
|
|
20
|
+
s === "false" ||
|
|
21
|
+
/^[+-]?\d+$/.test(s) ||
|
|
22
|
+
(/^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$/.test(s) && /[.eE]/.test(s)));
|
|
23
|
+
}
|
|
24
|
+
// One `key=value` / `key` / `.class` / `#id` token of an attribute object.
|
|
25
|
+
function serAttrValue(v) {
|
|
26
|
+
if (v === true)
|
|
27
|
+
return ""; // caller emits the bare key (a flag)
|
|
28
|
+
if (v === false)
|
|
29
|
+
return "false";
|
|
30
|
+
if (typeof v === "number")
|
|
31
|
+
return String(v);
|
|
32
|
+
return `"${v}"`; // always quote strings: parses back 1:1
|
|
33
|
+
}
|
|
34
|
+
// `{#id .class key="val" flag}` — or "" when there is nothing to emit.
|
|
35
|
+
function serAttrs(a) {
|
|
36
|
+
const parts = [];
|
|
37
|
+
if (a.id !== undefined)
|
|
38
|
+
parts.push(`#${a.id}`);
|
|
39
|
+
for (const c of a.classes ?? [])
|
|
40
|
+
parts.push(`.${c}`);
|
|
41
|
+
for (const [k, v] of Object.entries(a.attrs ?? {})) {
|
|
42
|
+
parts.push(v === true ? k : `${k}=${serAttrValue(v)}`);
|
|
43
|
+
}
|
|
44
|
+
return parts.length ? `{${parts.join(" ")}}` : "";
|
|
45
|
+
}
|
|
46
|
+
// A `data`-mode (meta) value: a bare word unless quoting is needed to keep it a
|
|
47
|
+
// string. `coerce` takes the whole rest-of-line, so spaces need no quoting.
|
|
48
|
+
function serDataValue(v) {
|
|
49
|
+
if (typeof v === "boolean")
|
|
50
|
+
return String(v);
|
|
51
|
+
if (typeof v === "number")
|
|
52
|
+
return String(v);
|
|
53
|
+
return looksTyped(v) || v.trim() !== v ? `"${v}"` : v;
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Inline (§5)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Escape every character that could open an inline construct, so a literal text
|
|
59
|
+
// run re-parses verbatim. An escaped punctuation byte parses back to that same
|
|
60
|
+
// literal byte (§5.3(1)). Used only on escalation (see serInlines).
|
|
61
|
+
function escText(s) {
|
|
62
|
+
return s.replace(/[\\`*~$\[\]]/g, (c) => "\\" + c);
|
|
63
|
+
}
|
|
64
|
+
function longestRun(s, ch) {
|
|
65
|
+
let max = 0;
|
|
66
|
+
let run = 0;
|
|
67
|
+
for (const c of s) {
|
|
68
|
+
if (c === ch) {
|
|
69
|
+
run++;
|
|
70
|
+
if (run > max)
|
|
71
|
+
max = run;
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
run = 0;
|
|
75
|
+
}
|
|
76
|
+
return max;
|
|
77
|
+
}
|
|
78
|
+
function linkDest(n) {
|
|
79
|
+
if (n.href !== undefined)
|
|
80
|
+
return n.href;
|
|
81
|
+
if (n.doc !== undefined)
|
|
82
|
+
return n.anchor !== undefined ? `${n.doc}#${n.anchor}` : n.doc;
|
|
83
|
+
if (n.anchor !== undefined)
|
|
84
|
+
return `#${n.anchor}`;
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
// `esc` controls whether literal text runs are backslash-escaped. The default
|
|
88
|
+
// pass emits them verbatim — the parser re-literalizes most stray delimiters on
|
|
89
|
+
// its own (an unpaired `*`, a lone `~`), and escaping them would split the run
|
|
90
|
+
// and break a surrounding emphasis span. serInlines escalates to esc=true only
|
|
91
|
+
// when the verbatim form does not round-trip.
|
|
92
|
+
function serInline(n, esc) {
|
|
93
|
+
switch (n.type) {
|
|
94
|
+
case "text": return esc ? escText(n.value) : n.value;
|
|
95
|
+
case "emph": return `*${serSeq(n.children, esc)}*`;
|
|
96
|
+
case "strong": return `**${serSeq(n.children, esc)}**`;
|
|
97
|
+
case "strike": return `~~${serSeq(n.children, esc)}~~`;
|
|
98
|
+
case "code": {
|
|
99
|
+
const f = "`".repeat(longestRun(n.value, "`") + 1);
|
|
100
|
+
return f + n.value + f;
|
|
101
|
+
}
|
|
102
|
+
case "math": return `$${n.value}$`;
|
|
103
|
+
case "break": return "\\\n";
|
|
104
|
+
case "image": return `${serAttrs({ attrs: n.attrs })}`;
|
|
105
|
+
case "link": return `[${serSeq(n.children, esc)}](${linkDest(n)})${serAttrs({ attrs: n.attrs })}`;
|
|
106
|
+
case "autoref": return `[[${n.doc !== undefined ? `${n.doc}#${n.anchor}` : `#${n.anchor}`}]]`;
|
|
107
|
+
case "footnote": return `[^${n.ref}]`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function serSeq(ns, esc) {
|
|
111
|
+
return ns.map((n) => serInline(n, esc)).join("");
|
|
112
|
+
}
|
|
113
|
+
// Serialize an inline sequence so it re-parses to the same tree. Emit verbatim
|
|
114
|
+
// first; if re-parsing that disagrees with the model (a stray delimiter that
|
|
115
|
+
// would spuriously pair, e.g. text `*lit*`), fall back to escaping. The check is
|
|
116
|
+
// what makes this exact rather than heuristic.
|
|
117
|
+
function serInlines(ns) {
|
|
118
|
+
const lazy = serSeq(ns, false);
|
|
119
|
+
if (JSON.stringify(parseInline(lazy, 0, { refs: [] })) === JSON.stringify(ns))
|
|
120
|
+
return lazy;
|
|
121
|
+
return serSeq(ns, true);
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Blocks
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
function serList(list, indent) {
|
|
127
|
+
const out = [];
|
|
128
|
+
const start = list.start ?? 1;
|
|
129
|
+
list.items.forEach((item, k) => {
|
|
130
|
+
const marker = list.ordered ? `${start + k}. ` : "- ";
|
|
131
|
+
const task = item.checked === undefined ? "" : item.checked ? "[x] " : "[ ] ";
|
|
132
|
+
out.push(indent + marker + task + serInlines(item.inlines));
|
|
133
|
+
for (const child of item.children ?? []) {
|
|
134
|
+
out.push(child.kind === "list" ? serList(child, indent + " ") : serBlock(child));
|
|
135
|
+
}
|
|
136
|
+
if (list.loose && k < list.items.length - 1)
|
|
137
|
+
out.push(""); // blank => loose
|
|
138
|
+
});
|
|
139
|
+
return out.join("\n");
|
|
140
|
+
}
|
|
141
|
+
function serTypedBlock(b) {
|
|
142
|
+
let body;
|
|
143
|
+
if (b.mode === "flow") {
|
|
144
|
+
body = (b.children ?? []).map(serBlock).join("\n\n").split("\n");
|
|
145
|
+
}
|
|
146
|
+
else if (b.mode === "data") {
|
|
147
|
+
body = Object.entries(b.data ?? {}).map(([k, v]) => `${k} = ${serDataValue(v)}`);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
body = b.raw ?? [];
|
|
151
|
+
}
|
|
152
|
+
// Pick a fence longer than any bare `=` run in the body, so a body line (or a
|
|
153
|
+
// nested block's close) can never close this block early (§3 longer-fence
|
|
154
|
+
// nesting). The close is the same length, the convention the parser expects.
|
|
155
|
+
let maxEq = 2;
|
|
156
|
+
for (const ln of body) {
|
|
157
|
+
const m = /^(=+)[ \t]*$/.exec(ln);
|
|
158
|
+
if (m)
|
|
159
|
+
maxEq = Math.max(maxEq, m[1].length);
|
|
160
|
+
}
|
|
161
|
+
const fence = "=".repeat(Math.max(3, maxEq + 1));
|
|
162
|
+
const attrs = serAttrs({ id: b.id, classes: b.classes, attrs: b.attrs });
|
|
163
|
+
const open = fence + " " + b.type + (attrs ? " " + attrs : "");
|
|
164
|
+
return [open, ...body, fence].join("\n");
|
|
165
|
+
}
|
|
166
|
+
function serBlock(b) {
|
|
167
|
+
switch (b.kind) {
|
|
168
|
+
case "heading": {
|
|
169
|
+
// The id is always emitted explicitly: it pins the heading's anchor
|
|
170
|
+
// regardless of how the rendered text slugs, and it shields any `{...}`
|
|
171
|
+
// inside the heading text from being read as a trailing attribute object.
|
|
172
|
+
const attrs = serAttrs({ id: b.id, classes: b.classes, attrs: b.attrs });
|
|
173
|
+
return "#".repeat(b.level) + " " + serInlines(b.inlines) + (attrs ? " " + attrs : "");
|
|
174
|
+
}
|
|
175
|
+
case "paragraph": return serInlines(b.inlines);
|
|
176
|
+
case "hidden": return "%%" + (b.text ? " " + b.text : "");
|
|
177
|
+
case "list": return serList(b, "");
|
|
178
|
+
case "block": return serTypedBlock(b);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Public API
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
export function serialize(doc) {
|
|
185
|
+
const blocks = Array.isArray(doc) ? doc : doc.children;
|
|
186
|
+
return blocks.map(serBlock).join("\n\n") + "\n";
|
|
187
|
+
}
|
package/dist/table.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Value } from "./attrs.js";
|
|
2
|
+
import { type Inline, type RefSink } from "./inline.js";
|
|
3
|
+
export type Align = "left" | "right" | "center";
|
|
4
|
+
export interface TableCell {
|
|
5
|
+
text: string;
|
|
6
|
+
inlines: Inline[];
|
|
7
|
+
align?: Align;
|
|
8
|
+
value?: number;
|
|
9
|
+
computed?: boolean;
|
|
10
|
+
span?: {
|
|
11
|
+
rows: number;
|
|
12
|
+
cols: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface TableModel {
|
|
16
|
+
caption?: string;
|
|
17
|
+
header: boolean;
|
|
18
|
+
columns: string[];
|
|
19
|
+
align: (Align | undefined)[];
|
|
20
|
+
rows: TableCell[][];
|
|
21
|
+
summary?: TableCell[];
|
|
22
|
+
src?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface TableDiag {
|
|
25
|
+
severity: "error" | "warning";
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
export interface TableResult {
|
|
29
|
+
model: TableModel;
|
|
30
|
+
diagnostics: TableDiag[];
|
|
31
|
+
}
|
|
32
|
+
export declare function parseTable(body: string[], attrs: Record<string, Value>, line: number, sink: RefSink): TableResult;
|
package/dist/table.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
// GEML reference parser — Milestone 3: tables (§6).
|
|
2
|
+
//
|
|
3
|
+
// A `table` block has two interchangeable body forms that parse to the SAME
|
|
4
|
+
// model: a visual pipe grid, or a data form (`format=csv`/`tsv`). The model
|
|
5
|
+
// carries column names, per-column alignment, body cells (inline-parsed),
|
|
6
|
+
// merged-cell spans (`span="r2c1:2x1"`), and columns produced by `compute`
|
|
7
|
+
// formulas (per-row arithmetic over columns, with sum/avg/min/max/count
|
|
8
|
+
// aggregates). See §6.
|
|
9
|
+
import { coerce } from "./attrs.js";
|
|
10
|
+
import { parseInline } from "./inline.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Body-form parsing
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const SEP_CELL = /^:?-+:?$/;
|
|
15
|
+
function alignOf(sep) {
|
|
16
|
+
const l = sep.startsWith(":");
|
|
17
|
+
const r = sep.endsWith(":");
|
|
18
|
+
if (l && r)
|
|
19
|
+
return "center";
|
|
20
|
+
if (r)
|
|
21
|
+
return "right";
|
|
22
|
+
if (l)
|
|
23
|
+
return "left";
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// Split a visual table row `| a | b |` into trimmed cell strings.
|
|
27
|
+
function splitPipes(line) {
|
|
28
|
+
let s = line.trim();
|
|
29
|
+
if (s.startsWith("|"))
|
|
30
|
+
s = s.slice(1);
|
|
31
|
+
if (s.endsWith("|"))
|
|
32
|
+
s = s.slice(0, -1);
|
|
33
|
+
return s.split("|").map((c) => c.trim());
|
|
34
|
+
}
|
|
35
|
+
function parseVisual(body) {
|
|
36
|
+
const rows = body.filter((l) => l.trim() !== "").map(splitPipes);
|
|
37
|
+
let sepIdx = -1;
|
|
38
|
+
for (let r = 0; r < rows.length; r++) {
|
|
39
|
+
if (rows[r].length > 0 && rows[r].every((c) => SEP_CELL.test(c))) {
|
|
40
|
+
sepIdx = r;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (sepIdx >= 0) {
|
|
45
|
+
const headerRow = sepIdx > 0 ? rows[sepIdx - 1] : [];
|
|
46
|
+
const align = rows[sepIdx].map(alignOf);
|
|
47
|
+
const cells = rows.slice(sepIdx + 1);
|
|
48
|
+
const columns = headerRow.length ? headerRow : letters(cells[0]?.length ?? align.length);
|
|
49
|
+
return { columns, align, header: headerRow.length > 0, cells };
|
|
50
|
+
}
|
|
51
|
+
// No separator: headerless, columns are letters.
|
|
52
|
+
const width = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
|
53
|
+
return { columns: letters(width), align: [], header: false, cells: rows };
|
|
54
|
+
}
|
|
55
|
+
function parseDelimited(body, sep, header) {
|
|
56
|
+
const rows = body.filter((l) => l.trim() !== "").map((l) => l.split(sep).map((c) => c.trim()));
|
|
57
|
+
if (header && rows.length) {
|
|
58
|
+
return { columns: rows[0], align: [], header: true, cells: rows.slice(1) };
|
|
59
|
+
}
|
|
60
|
+
const width = rows.reduce((m, r) => Math.max(m, r.length), 0);
|
|
61
|
+
return { columns: letters(width), align: [], header: false, cells: rows };
|
|
62
|
+
}
|
|
63
|
+
function letters(n) {
|
|
64
|
+
const out = [];
|
|
65
|
+
for (let i = 0; i < n; i++)
|
|
66
|
+
out.push(String.fromCharCode(65 + i));
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
const AGGS = new Set(["sum", "avg", "min", "max", "count"]);
|
|
70
|
+
function lexExpr(s) {
|
|
71
|
+
const out = [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
while (i < s.length) {
|
|
74
|
+
const c = s[i];
|
|
75
|
+
if (/\s/.test(c)) {
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if ("+-*/".includes(c)) {
|
|
80
|
+
out.push({ t: "op", v: c });
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (c === "(") {
|
|
85
|
+
out.push({ t: "lp", v: c });
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (c === ")") {
|
|
90
|
+
out.push({ t: "rp", v: c });
|
|
91
|
+
i++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (c === ",") {
|
|
95
|
+
out.push({ t: "comma", v: c });
|
|
96
|
+
i++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (/[0-9.]/.test(c)) {
|
|
100
|
+
let j = i;
|
|
101
|
+
while (j < s.length && /[0-9.]/.test(s[j]))
|
|
102
|
+
j++;
|
|
103
|
+
out.push({ t: "num", v: s.slice(i, j) });
|
|
104
|
+
i = j;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// quoted column name: 'Unit Price' (single quotes — the GEML attribute
|
|
108
|
+
// value is already double-quoted and has no escape syntax, §4).
|
|
109
|
+
if (c === "'") {
|
|
110
|
+
let j = i + 1;
|
|
111
|
+
while (j < s.length && s[j] !== "'")
|
|
112
|
+
j++;
|
|
113
|
+
out.push({ t: "name", v: s.slice(i + 1, j) });
|
|
114
|
+
i = j + 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// identifier: column name or function — run of non-operator chars
|
|
118
|
+
let j = i;
|
|
119
|
+
while (j < s.length && !/[\s+\-*/(),]/.test(s[j]))
|
|
120
|
+
j++;
|
|
121
|
+
out.push({ t: "name", v: s.slice(i, j) });
|
|
122
|
+
i = j;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Display format: a `[printf]` spec bound to a column/cell name (§6).
|
|
128
|
+
// `FY [%.1f]` → name "FY", fmt "%.1f"; `YoY [%.1f%%]` → "%.1f%%"
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
function splitName(lhs) {
|
|
131
|
+
const m = /^(.*?)\s*\[([^\]]*)\]\s*$/.exec(lhs.trim());
|
|
132
|
+
let name = (m ? m[1] : lhs).trim();
|
|
133
|
+
if (name.startsWith('"') && name.endsWith('"'))
|
|
134
|
+
name = name.slice(1, -1);
|
|
135
|
+
return m ? { name, fmt: m[2] } : { name };
|
|
136
|
+
}
|
|
137
|
+
// Default rendering for an unformatted computed number: drop IEEE-754 display
|
|
138
|
+
// noise (0.1+0.2 → "0.3", sum of 1-dp inputs → "263.6") without altering the
|
|
139
|
+
// stored numeric value.
|
|
140
|
+
function defaultNum(v) {
|
|
141
|
+
return String(parseFloat(v.toPrecision(12)));
|
|
142
|
+
}
|
|
143
|
+
// Minimal printf for a single numeric value: handles %f/%e/%d/%g with optional
|
|
144
|
+
// precision, and `%%` as a literal percent. Width/flags are not padded.
|
|
145
|
+
function applyFormat(fmt, v) {
|
|
146
|
+
return fmt.replace(/%%|%[-+ 0]*\d*(?:\.\d+)?[fFeEgGd]/g, (m) => {
|
|
147
|
+
if (m === "%%")
|
|
148
|
+
return "%";
|
|
149
|
+
const mm = /^%[-+ 0]*\d*(?:\.(\d+))?([fFeEgGd])$/.exec(m);
|
|
150
|
+
if (!mm)
|
|
151
|
+
return m;
|
|
152
|
+
const prec = mm[1] !== undefined ? parseInt(mm[1], 10) : undefined;
|
|
153
|
+
const type = mm[2];
|
|
154
|
+
if (type === "d")
|
|
155
|
+
return String(Math.round(v));
|
|
156
|
+
if (type === "e" || type === "E")
|
|
157
|
+
return v.toExponential(prec);
|
|
158
|
+
if (type === "g" || type === "G")
|
|
159
|
+
return String(v);
|
|
160
|
+
return v.toFixed(prec ?? 6); // f / F
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// Recursive-descent evaluator restricted to + - * / ( ) and aggregate funcs.
|
|
164
|
+
function evalExpr(toks, row, col, agg) {
|
|
165
|
+
let p = 0;
|
|
166
|
+
const peek = () => toks[p];
|
|
167
|
+
const next = () => toks[p++];
|
|
168
|
+
function parseExpr() {
|
|
169
|
+
let v = parseTerm();
|
|
170
|
+
while (peek() && peek().t === "op" && (peek().v === "+" || peek().v === "-")) {
|
|
171
|
+
const op = next().v;
|
|
172
|
+
const r = parseTerm();
|
|
173
|
+
v = op === "+" ? v + r : v - r;
|
|
174
|
+
}
|
|
175
|
+
return v;
|
|
176
|
+
}
|
|
177
|
+
function parseTerm() {
|
|
178
|
+
let v = parseFactor();
|
|
179
|
+
while (peek() && peek().t === "op" && (peek().v === "*" || peek().v === "/")) {
|
|
180
|
+
const op = next().v;
|
|
181
|
+
const r = parseFactor();
|
|
182
|
+
v = op === "*" ? v * r : v / r;
|
|
183
|
+
}
|
|
184
|
+
return v;
|
|
185
|
+
}
|
|
186
|
+
function parseFactor() {
|
|
187
|
+
const tk = peek();
|
|
188
|
+
if (!tk)
|
|
189
|
+
throw new Error("unexpected end of formula");
|
|
190
|
+
if (tk.t === "op" && tk.v === "-") {
|
|
191
|
+
next();
|
|
192
|
+
return -parseFactor();
|
|
193
|
+
}
|
|
194
|
+
if (tk.t === "lp") {
|
|
195
|
+
next();
|
|
196
|
+
const v = parseExpr();
|
|
197
|
+
if (peek()?.t !== "rp")
|
|
198
|
+
throw new Error("missing )");
|
|
199
|
+
next();
|
|
200
|
+
return v;
|
|
201
|
+
}
|
|
202
|
+
if (tk.t === "num") {
|
|
203
|
+
next();
|
|
204
|
+
return parseFloat(tk.v);
|
|
205
|
+
}
|
|
206
|
+
if (tk.t === "name") {
|
|
207
|
+
next();
|
|
208
|
+
if (peek()?.t === "lp" && AGGS.has(tk.v.toLowerCase())) {
|
|
209
|
+
next();
|
|
210
|
+
const arg = peek();
|
|
211
|
+
if (arg?.t !== "name")
|
|
212
|
+
throw new Error(`bad argument to ${tk.v}()`);
|
|
213
|
+
next();
|
|
214
|
+
if (peek()?.t !== "rp")
|
|
215
|
+
throw new Error("missing )");
|
|
216
|
+
next();
|
|
217
|
+
const a = agg(tk.v.toLowerCase(), arg.v);
|
|
218
|
+
if (a === null)
|
|
219
|
+
throw new Error(`unknown column \`${arg.v}\``);
|
|
220
|
+
return a;
|
|
221
|
+
}
|
|
222
|
+
const cv = col(tk.v, row);
|
|
223
|
+
if (cv === null)
|
|
224
|
+
throw new Error(`unknown column \`${tk.v}\``);
|
|
225
|
+
return cv;
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`unexpected token \`${tk.v}\``);
|
|
228
|
+
}
|
|
229
|
+
const v = parseExpr();
|
|
230
|
+
if (p !== toks.length)
|
|
231
|
+
throw new Error("trailing tokens in formula");
|
|
232
|
+
return v;
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Spans
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Parse `r2c1:2x1` → target cell (1-based row/col over body) + size.
|
|
238
|
+
function parseSpan(s) {
|
|
239
|
+
const m = /^r(\d+)c(\d+):(\d+)x(\d+)$/.exec(s.trim());
|
|
240
|
+
if (!m)
|
|
241
|
+
return null;
|
|
242
|
+
return { row: +m[1], col: +m[2], rows: +m[3], cols: +m[4] };
|
|
243
|
+
}
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Public entry
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
export function parseTable(body, attrs, line, sink) {
|
|
248
|
+
const diagnostics = [];
|
|
249
|
+
const fmt = typeof attrs["format"] === "string" ? attrs["format"] : undefined;
|
|
250
|
+
// External data source (§6): `src=` points at a CSV/TSV file or URL, loaded at
|
|
251
|
+
// render time — not read here. So compute/chart column-name checking for an
|
|
252
|
+
// src table also happens at render time, and the inline body must be empty.
|
|
253
|
+
const src = typeof attrs["src"] === "string" ? attrs["src"] : undefined;
|
|
254
|
+
if (src !== undefined) {
|
|
255
|
+
if (body.some((l) => l.trim() !== "")) {
|
|
256
|
+
diagnostics.push({ severity: "error", message: "table has both `src` and an inline body; provide one, not both" });
|
|
257
|
+
}
|
|
258
|
+
const headerAttr = attrs["header"];
|
|
259
|
+
const header = headerAttr === undefined ? true : headerAttr === true || headerAttr === 1 || headerAttr === "1";
|
|
260
|
+
const model = { header, columns: [], align: [], rows: [], src };
|
|
261
|
+
const caption = attrs["caption"];
|
|
262
|
+
if (typeof caption === "string")
|
|
263
|
+
model.caption = caption;
|
|
264
|
+
return { model, diagnostics };
|
|
265
|
+
}
|
|
266
|
+
let raw;
|
|
267
|
+
if (fmt === "csv" || fmt === "tsv") {
|
|
268
|
+
const headerAttr = attrs["header"];
|
|
269
|
+
const header = headerAttr === undefined ? true : headerAttr === true || headerAttr === 1 || headerAttr === "1";
|
|
270
|
+
raw = parseDelimited(body, fmt === "tsv" ? "\t" : ",", header);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
if (fmt !== undefined)
|
|
274
|
+
diagnostics.push({ severity: "warning", message: `unknown table format \`${fmt}\`; parsed as visual grid` });
|
|
275
|
+
raw = parseVisual(body);
|
|
276
|
+
}
|
|
277
|
+
const columns = [...raw.columns];
|
|
278
|
+
const model = { header: raw.header, columns, align: raw.align, rows: [] };
|
|
279
|
+
const caption = attrs["caption"];
|
|
280
|
+
if (typeof caption === "string")
|
|
281
|
+
model.caption = caption;
|
|
282
|
+
// Build body cells with inline content and numeric values.
|
|
283
|
+
for (const r of raw.cells) {
|
|
284
|
+
const row = [];
|
|
285
|
+
for (let c = 0; c < columns.length; c++) {
|
|
286
|
+
const text = r[c] ?? "";
|
|
287
|
+
const cell = { text, inlines: parseInline(text, line, sink) };
|
|
288
|
+
const align = raw.align[c];
|
|
289
|
+
if (align)
|
|
290
|
+
cell.align = align;
|
|
291
|
+
const v = coerce(text);
|
|
292
|
+
if (typeof v === "number")
|
|
293
|
+
cell.value = v;
|
|
294
|
+
row.push(cell);
|
|
295
|
+
}
|
|
296
|
+
model.rows.push(row);
|
|
297
|
+
}
|
|
298
|
+
// Column lookup by header name or single letter (A=0).
|
|
299
|
+
const colIndex = (name) => {
|
|
300
|
+
const byName = columns.indexOf(name);
|
|
301
|
+
if (byName >= 0)
|
|
302
|
+
return byName;
|
|
303
|
+
if (/^[A-Z]$/.test(name))
|
|
304
|
+
return name.charCodeAt(0) - 65;
|
|
305
|
+
return -1;
|
|
306
|
+
};
|
|
307
|
+
const cellNum = (ci, row) => {
|
|
308
|
+
const v = model.rows[row]?.[ci]?.value;
|
|
309
|
+
return typeof v === "number" ? v : null;
|
|
310
|
+
};
|
|
311
|
+
const colResolve = (name, row) => {
|
|
312
|
+
const ci = colIndex(name);
|
|
313
|
+
return ci < 0 ? null : cellNum(ci, row);
|
|
314
|
+
};
|
|
315
|
+
const aggResolve = (fn, name) => {
|
|
316
|
+
const ci = colIndex(name);
|
|
317
|
+
if (ci < 0)
|
|
318
|
+
return null;
|
|
319
|
+
const vals = [];
|
|
320
|
+
for (let r = 0; r < model.rows.length; r++) {
|
|
321
|
+
const v = cellNum(ci, r);
|
|
322
|
+
if (v !== null)
|
|
323
|
+
vals.push(v);
|
|
324
|
+
}
|
|
325
|
+
if (fn === "count")
|
|
326
|
+
return vals.length;
|
|
327
|
+
if (vals.length === 0)
|
|
328
|
+
return 0;
|
|
329
|
+
if (fn === "sum")
|
|
330
|
+
return vals.reduce((a, b) => a + b, 0);
|
|
331
|
+
if (fn === "avg")
|
|
332
|
+
return vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
333
|
+
if (fn === "min")
|
|
334
|
+
return Math.min(...vals);
|
|
335
|
+
if (fn === "max")
|
|
336
|
+
return Math.max(...vals);
|
|
337
|
+
return null;
|
|
338
|
+
};
|
|
339
|
+
// `compute="Name = expr; Name2 = expr2"` — `;`-separated; may also appear as
|
|
340
|
+
// compute, compute2, … Each formula adds/overwrites a per-row column.
|
|
341
|
+
const formulas = Object.entries(attrs)
|
|
342
|
+
.filter(([k]) => k === "compute" || /^compute\d+$/.test(k))
|
|
343
|
+
.map(([, v]) => v)
|
|
344
|
+
.filter((v) => typeof v === "string")
|
|
345
|
+
.flatMap((v) => v.split(";"))
|
|
346
|
+
.map((f) => f.trim())
|
|
347
|
+
.filter((f) => f !== "");
|
|
348
|
+
for (const f of formulas) {
|
|
349
|
+
const eq = f.indexOf("=");
|
|
350
|
+
if (eq <= 0) {
|
|
351
|
+
diagnostics.push({ severity: "error", message: `bad compute formula \`${f}\` (want \`Name = expr\`)` });
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const { name, fmt } = splitName(f.slice(0, eq));
|
|
355
|
+
const expr = f.slice(eq + 1).trim();
|
|
356
|
+
let toks;
|
|
357
|
+
try {
|
|
358
|
+
toks = lexExpr(expr);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
diagnostics.push({ severity: "error", message: `cannot lex formula \`${f}\`` });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
// Target is a header name (never a letter reference): match by name only.
|
|
365
|
+
let ci = columns.indexOf(name);
|
|
366
|
+
if (ci < 0) {
|
|
367
|
+
columns.push(name);
|
|
368
|
+
ci = columns.length - 1;
|
|
369
|
+
}
|
|
370
|
+
let failed = false;
|
|
371
|
+
for (let r = 0; r < model.rows.length && !failed; r++) {
|
|
372
|
+
try {
|
|
373
|
+
const v = evalExpr(toks, r, colResolve, aggResolve);
|
|
374
|
+
const cell = ensureCell(model.rows[r], ci);
|
|
375
|
+
if (Number.isFinite(v)) {
|
|
376
|
+
const text = fmt ? applyFormat(fmt, v) : defaultNum(v);
|
|
377
|
+
cell.value = v;
|
|
378
|
+
cell.text = text;
|
|
379
|
+
cell.computed = true;
|
|
380
|
+
cell.inlines = [{ type: "text", value: text }];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
diagnostics.push({ severity: "error", message: `compute \`${name}\`: ${e.message}` });
|
|
385
|
+
failed = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// `summary="Cell = value; …"` — one foot row. Each value is a string/number
|
|
390
|
+
// literal (a label) or arithmetic over aggregates (the only cross-row op).
|
|
391
|
+
const summaryDecls = Object.entries(attrs)
|
|
392
|
+
.filter(([k]) => k === "summary" || /^summary\d+$/.test(k))
|
|
393
|
+
.map(([, v]) => v)
|
|
394
|
+
.filter((v) => typeof v === "string")
|
|
395
|
+
.flatMap((v) => v.split(";"))
|
|
396
|
+
.map((s) => s.trim())
|
|
397
|
+
.filter((s) => s !== "");
|
|
398
|
+
if (summaryDecls.length > 0) {
|
|
399
|
+
const summary = columns.map(() => ({ text: "", inlines: [] }));
|
|
400
|
+
// In the summary row a bare column has no value: only aggregates resolve.
|
|
401
|
+
const noRow = () => null;
|
|
402
|
+
for (const s of summaryDecls) {
|
|
403
|
+
const eq = s.indexOf("=");
|
|
404
|
+
if (eq <= 0) {
|
|
405
|
+
diagnostics.push({ severity: "error", message: `bad summary \`${s}\` (want \`Cell = value\`)` });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const { name, fmt } = splitName(s.slice(0, eq));
|
|
409
|
+
const rhs = s.slice(eq + 1).trim();
|
|
410
|
+
const ci = colIndex(name);
|
|
411
|
+
if (ci < 0) {
|
|
412
|
+
diagnostics.push({ severity: "error", message: `summary targets unknown column \`${name}\`` });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
// String label: `Cell = 'Total'`.
|
|
416
|
+
if (rhs.startsWith("'") && rhs.endsWith("'") && rhs.length >= 2) {
|
|
417
|
+
const text = rhs.slice(1, -1);
|
|
418
|
+
summary[ci] = { text, inlines: [{ type: "text", value: text }] };
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
// Otherwise an aggregate expression.
|
|
422
|
+
let toks;
|
|
423
|
+
try {
|
|
424
|
+
toks = lexExpr(rhs);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
diagnostics.push({ severity: "error", message: `cannot lex summary \`${s}\`` });
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const v = evalExpr(toks, 0, noRow, aggResolve);
|
|
432
|
+
if (Number.isFinite(v)) {
|
|
433
|
+
const text = fmt ? applyFormat(fmt, v) : defaultNum(v);
|
|
434
|
+
summary[ci] = { text, inlines: [{ type: "text", value: text }], value: v, computed: true };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
const msg = /unknown column `(.+)`/.exec(e.message);
|
|
439
|
+
const hint = msg ? `summary \`${name}\`: column \`${msg[1]}\` must be reduced by an aggregate (e.g. sum(${msg[1]}))` : `summary \`${name}\`: ${e.message}`;
|
|
440
|
+
diagnostics.push({ severity: "error", message: hint });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
model.summary = summary;
|
|
444
|
+
}
|
|
445
|
+
// Spans: `span="r2c1:2x1"` (one or many: span, span2, …).
|
|
446
|
+
const spanDecls = Object.entries(attrs)
|
|
447
|
+
.filter(([k]) => k === "span" || /^span\d+$/.test(k))
|
|
448
|
+
.map(([, v]) => v)
|
|
449
|
+
.filter((v) => typeof v === "string");
|
|
450
|
+
for (const sd of spanDecls) {
|
|
451
|
+
const sp = parseSpan(sd);
|
|
452
|
+
if (!sp) {
|
|
453
|
+
diagnostics.push({ severity: "error", message: `bad span \`${sd}\` (want \`rNcM:RxC\`)` });
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const cell = model.rows[sp.row - 1]?.[sp.col - 1];
|
|
457
|
+
if (!cell) {
|
|
458
|
+
diagnostics.push({ severity: "warning", message: `span \`${sd}\` targets a cell outside the table` });
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
cell.span = { rows: sp.rows, cols: sp.cols };
|
|
462
|
+
}
|
|
463
|
+
return { model, diagnostics };
|
|
464
|
+
}
|
|
465
|
+
function ensureCell(row, ci) {
|
|
466
|
+
while (row.length <= ci)
|
|
467
|
+
row.push({ text: "", inlines: [] });
|
|
468
|
+
return row[ci];
|
|
469
|
+
}
|