@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.
@@ -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 `![${n.alt}](${n.src})${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
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import type { Document } from "./geml.js";
2
+ export declare function gemlToMd(doc: Document): {
3
+ md: string;
4
+ notes: string[];
5
+ };