@geml/geml 1.5.0 → 1.6.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/dist/table.js CHANGED
@@ -127,22 +127,34 @@ function lexExpr(s) {
127
127
  // Display format: a `[printf]` spec bound to a column/cell name (§6).
128
128
  // `FY [%.1f]` → name "FY", fmt "%.1f"; `YoY [%.1f%%]` → "%.1f%%"
129
129
  // ---------------------------------------------------------------------------
130
+ // The bracket suffix has to contain a `%` to be a format (§6). Without that
131
+ // test, a column whose own name is bracketed — `[Data] = …` — parses as an
132
+ // empty name plus the format `Data`, and the formula silently targets nothing.
130
133
  function splitName(lhs) {
131
- const m = /^(.*?)\s*\[([^\]]*)\]\s*$/.exec(lhs.trim());
134
+ const m = /^(.*?)\s*\[([^\]]*%[^\]]*)\]\s*$/.exec(lhs.trim());
132
135
  let name = (m ? m[1] : lhs).trim();
133
136
  if (name.startsWith('"') && name.endsWith('"'))
134
137
  name = name.slice(1, -1);
135
138
  return m ? { name, fmt: m[2] } : { name };
136
139
  }
140
+ // A result IEEE-754 produces but a table cannot hold (§6): `x / 0` is ±∞, `0 / 0`
141
+ // is NaN. The cell keeps no value and displays `-`, which is what a reader sees;
142
+ // naming the cause is the difference between "this row had no data" and "this
143
+ // row divided by zero", so it is said out loud like a substituted cell is.
144
+ const nanMsg = (where, v) => `${where}: ${Number.isNaN(v) ? "result is not a number (0/0)" : "division by zero"}; the cell holds no value and shows \`-\``;
137
145
  // Default rendering for an unformatted computed number: drop IEEE-754 display
138
146
  // noise (0.1+0.2 → "0.3", sum of 1-dp inputs → "263.6") without altering the
139
147
  // stored numeric value.
140
148
  function defaultNum(v) {
149
+ if (!isFinite(v))
150
+ return "-";
141
151
  return String(parseFloat(v.toPrecision(12)));
142
152
  }
143
153
  // Minimal printf for a single numeric value: handles %f/%e/%d/%g with optional
144
154
  // precision, and `%%` as a literal percent. Width/flags are not padded.
145
155
  function applyFormat(fmt, v) {
156
+ if (!isFinite(v))
157
+ return "-";
146
158
  return fmt.replace(/%%|%[-+ 0]*\d*(?:\.\d+)?[fFeEgGd]/g, (m) => {
147
159
  if (m === "%%")
148
160
  return "%";
@@ -232,16 +244,6 @@ function evalExpr(toks, row, col, agg) {
232
244
  return v;
233
245
  }
234
246
  // ---------------------------------------------------------------------------
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
247
  // Public entry
246
248
  // ---------------------------------------------------------------------------
247
249
  export function parseTable(body, attrs, line, sink) {
@@ -310,19 +312,47 @@ export function parseTable(body, attrs, line, sink) {
310
312
  const v = model.rows[row]?.[ci]?.value;
311
313
  return typeof v === "number" ? v : null;
312
314
  };
315
+ // A cell a formula reads but cannot read as a number (`x`, `N/A`, `TBD`, an
316
+ // empty cell) counts as 0, so one dirty row does not void the whole column.
317
+ // But counting it silently is how a table quietly reports the wrong total, so
318
+ // say which cell was substituted. One warning per cell, not per mention: a
319
+ // formula naming the same column twice describes one substitution.
320
+ const substituted = new Set();
313
321
  const colResolve = (name, row) => {
314
322
  const ci = colIndex(name);
315
- return ci < 0 ? null : cellNum(ci, row);
323
+ if (ci < 0)
324
+ return null;
325
+ const n = cellNum(ci, row);
326
+ if (n !== null)
327
+ return n;
328
+ const key = `${ci}\0${row}`;
329
+ if (!substituted.has(key)) {
330
+ substituted.add(key);
331
+ const text = model.rows[row]?.[ci]?.text ?? "";
332
+ diagnostics.push({
333
+ severity: "warning",
334
+ code: "compute-non-numeric-cell",
335
+ message: `column \`${name}\` row ${row + 1} is not a number (${text === "" ? "empty" : `\`${text}\``}); counted as 0`,
336
+ });
337
+ }
338
+ return 0;
316
339
  };
317
340
  const computeAgg = (fn, ci) => {
341
+ if (fn === "count") {
342
+ let c = 0;
343
+ for (let r = 0; r < model.rows.length; r++) {
344
+ const text = model.rows[r]?.[ci]?.text;
345
+ if (text !== undefined && text !== "")
346
+ c++;
347
+ }
348
+ return c;
349
+ }
318
350
  const vals = [];
319
351
  for (let r = 0; r < model.rows.length; r++) {
320
352
  const v = cellNum(ci, r);
321
353
  if (v !== null)
322
354
  vals.push(v);
323
355
  }
324
- if (fn === "count")
325
- return vals.length;
326
356
  if (vals.length === 0)
327
357
  return 0;
328
358
  if (fn === "sum")
@@ -398,13 +428,14 @@ export function parseTable(body, attrs, line, sink) {
398
428
  try {
399
429
  const v = evalExpr(toks, r, colResolve, aggResolve);
400
430
  const cell = ensureCell(model.rows[r], ci);
401
- if (Number.isFinite(v)) {
402
- const text = fmt ? applyFormat(fmt, v) : defaultNum(v);
431
+ const text = fmt ? applyFormat(fmt, v) : defaultNum(v);
432
+ cell.text = text;
433
+ cell.computed = true;
434
+ cell.inlines = [{ type: "text", value: text }];
435
+ if (Number.isFinite(v))
403
436
  cell.value = v;
404
- cell.text = text;
405
- cell.computed = true;
406
- cell.inlines = [{ type: "text", value: text }];
407
- }
437
+ else
438
+ diagnostics.push({ severity: "warning", code: "compute-not-a-number", message: nanMsg(`compute \`${name}\` row ${r + 1}`, v) });
408
439
  }
409
440
  catch (e) {
410
441
  diagnostics.push({ severity: "error", code: "compute-error", message: `compute \`${name}\`: ${e.message}` });
@@ -458,10 +489,12 @@ export function parseTable(body, attrs, line, sink) {
458
489
  }
459
490
  try {
460
491
  const v = evalExpr(toks, 0, noRow, aggResolve);
461
- if (Number.isFinite(v)) {
462
- const text = fmt ? applyFormat(fmt, v) : defaultNum(v);
463
- summary[ci] = { text, inlines: [{ type: "text", value: text }], value: v, computed: true };
464
- }
492
+ const text = fmt ? applyFormat(fmt, v) : defaultNum(v);
493
+ summary[ci] = { text, inlines: [{ type: "text", value: text }], computed: true };
494
+ if (Number.isFinite(v))
495
+ summary[ci].value = v;
496
+ else
497
+ diagnostics.push({ severity: "warning", code: "compute-not-a-number", message: nanMsg(`summary \`${name}\``, v) });
465
498
  }
466
499
  catch (e) {
467
500
  const msg = /unknown column `(.+)`/.exec(e.message);
@@ -471,32 +504,6 @@ export function parseTable(body, attrs, line, sink) {
471
504
  }
472
505
  model.summary = summary;
473
506
  }
474
- // Spans: `span="r2c1:2x1"` (one or many: span, span2, …).
475
- const spanDecls = Object.entries(attrs)
476
- .filter(([k]) => k === "span" || /^span\d+$/.test(k))
477
- .map(([, v]) => v)
478
- .filter((v) => typeof v === "string");
479
- for (const sd of spanDecls) {
480
- const sp = parseSpan(sd);
481
- if (!sp) {
482
- diagnostics.push({ severity: "error", code: "bad-span", message: `bad span \`${sd}\` (want \`rNcM:RxC\`)` });
483
- continue;
484
- }
485
- const cell = model.rows[sp.row - 1]?.[sp.col - 1];
486
- if (!cell) {
487
- diagnostics.push({ severity: "warning", code: "span-outside-table", message: `span \`${sd}\` targets a cell outside the table` });
488
- continue;
489
- }
490
- // A span can never extend past the grid: clamp its extent to the rows/cols
491
- // actually available from the target cell. Without this, `span="r1c1:9e6x9e6"`
492
- // makes the renderer's O(rows×cols) coverage sweep hang (DoS). Every row has
493
- // exactly `columns.length` cells (built above), so the column bound is exact.
494
- const maxRows = model.rows.length - (sp.row - 1);
495
- const maxCols = columns.length - (sp.col - 1);
496
- const rows = Math.max(1, Math.min(sp.rows, maxRows));
497
- const cols = Math.max(1, Math.min(sp.cols, maxCols));
498
- cell.span = { rows, cols };
499
- }
500
507
  return { model, diagnostics };
501
508
  }
502
509
  function ensureCell(row, ci) {
package/dist/to-md.js CHANGED
@@ -125,7 +125,10 @@ function typedToMd(b, notes) {
125
125
  return "";
126
126
  }
127
127
  if (b.mode === "flow") {
128
- // Footnote definition: a `note.footnote` carrying its ref as the id.
128
+ // A note the author marked `.footnote` projects to a Markdown footnote
129
+ // definition. The parser no longer synthesizes this class — the `[^id]: text`
130
+ // definition line was withdrawn from §5.2 — but an author still writes it,
131
+ // and it is the only way this projection can be produced.
129
132
  if (b.type === "note" && b.classes.includes("footnote") && b.id) {
130
133
  const text = (b.children ?? []).map((c) => block(c, notes)).join(" ").replace(/\n+/g, " ").trim();
131
134
  return `[^${b.id}]: ${text}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geml/geml",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "mcpName": "io.github.geml-spec/geml",
5
5
  "publishConfig": {
6
6
  "access": "public"