@jokerized/decksmith 0.7.0 → 0.8.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/cli.js CHANGED
@@ -869,35 +869,35 @@ function elbow(sid, from, to, o) {
869
869
  }
870
870
  return path(d, { ...strokeAttrs(o), "marker-end": `url(#${headId(sid, o.stroke)})` });
871
871
  }
872
- function tracks(width, count, gap, x0 = 0) {
873
- const w = count > 1 ? (width - gap * (count - 1)) / count : width;
874
- return Array.from({ length: count }, (_, i) => ({ x: x0 + i * (w + gap), w }));
872
+ function tracks(width, count2, gap, x0 = 0) {
873
+ const w = count2 > 1 ? (width - gap * (count2 - 1)) / count2 : width;
874
+ return Array.from({ length: count2 }, (_, i) => ({ x: x0 + i * (w + gap), w }));
875
875
  }
876
876
  function fitBoxes(req) {
877
- const count = req.labels.length;
877
+ const count2 = req.labels.length;
878
878
  const minGap = req.minGap ?? 24;
879
879
  const padEm = req.padEm ?? 0.8;
880
880
  const weight = req.weight ?? 600;
881
881
  const x0 = req.x0 ?? 0;
882
882
  const unit = Math.max(1, ...req.labels.map((l) => textWidth(l, 1, weight) + 2 * padEm));
883
- const sizeAt = (gap) => count < 1 ? req.size : (req.width - gap * (count - 1)) / count / unit;
883
+ const sizeAt = (gap) => count2 < 1 ? req.size : (req.width - gap * (count2 - 1)) / count2 / unit;
884
884
  if (sizeAt(req.gap) >= req.size) {
885
- return { ok: true, size: req.size, gap: req.gap, boxes: tracks(req.width, count, req.gap, x0) };
885
+ return { ok: true, size: req.size, gap: req.gap, boxes: tracks(req.width, count2, req.gap, x0) };
886
886
  }
887
- if (count > 1 && sizeAt(minGap) >= req.size) {
888
- const gap = (req.width - count * req.size * unit) / (count - 1);
889
- return { ok: true, size: req.size, gap, boxes: tracks(req.width, count, gap, x0) };
887
+ if (count2 > 1 && sizeAt(minGap) >= req.size) {
888
+ const gap = (req.width - count2 * req.size * unit) / (count2 - 1);
889
+ return { ok: true, size: req.size, gap, boxes: tracks(req.width, count2, gap, x0) };
890
890
  }
891
891
  const size3 = sizeAt(minGap);
892
892
  if (size3 >= MIN_FONT) {
893
- return { ok: true, size: size3, gap: minGap, boxes: tracks(req.width, count, minGap, x0) };
893
+ return { ok: true, size: size3, gap: minGap, boxes: tracks(req.width, count2, minGap, x0) };
894
894
  }
895
895
  return {
896
896
  ok: false,
897
897
  size: MIN_FONT,
898
898
  gap: minGap,
899
- boxes: tracks(req.width, count, minGap, x0),
900
- needed: count * MIN_FONT * unit + minGap * (count - 1)
899
+ boxes: tracks(req.width, count2, minGap, x0),
900
+ needed: count2 * MIN_FONT * unit + minGap * (count2 - 1)
901
901
  };
902
902
  }
903
903
 
@@ -1230,8 +1230,8 @@ var MID_GAP = 56;
1230
1230
  var BAND_GAP = 34;
1231
1231
  var COL_GAP = 28;
1232
1232
  var MIN_COL = 250;
1233
- function lineBudget(count, stageH) {
1234
- const perSide = Math.max(1, Math.ceil(count / 2));
1233
+ function lineBudget(count2, stageH) {
1234
+ const perSide = Math.max(1, Math.ceil(count2 / 2));
1235
1235
  const room = (stageH - (perSide - 1) * LAB_GAP) / perSide;
1236
1236
  const lines = Math.floor((room - ASCENT - RULE_LIFT - RULE_W) / (LAB * LAB_LH)) + 1;
1237
1237
  return Math.min(LINE_CEIL, Math.max(1, lines));
@@ -1642,7 +1642,7 @@ var HEAD_GAP = 14;
1642
1642
  var barCompare = (beat, ctx) => {
1643
1643
  const { sid, theme } = ctx;
1644
1644
  const p = beat.params;
1645
- const count = p.bars.length;
1645
+ const count2 = p.bars.length;
1646
1646
  const W = contentW(ctx.format);
1647
1647
  const face = faceOf(ctx.theme.fontStack);
1648
1648
  const tall = isPortrait(ctx.format);
@@ -1656,7 +1656,7 @@ var barCompare = (beat, ctx) => {
1656
1656
  0,
1657
1657
  face
1658
1658
  );
1659
- let bar = Math.min(BAR_MAX, (avail - unitBand - FOOT) / (count + GAP_RATIO * (count - 1)));
1659
+ let bar = Math.min(BAR_MAX, (avail - unitBand - FOOT) / (count2 + GAP_RATIO * (count2 - 1)));
1660
1660
  let pitch = bar * (1 + GAP_RATIO);
1661
1661
  const gutterMax = tall ? 0 : share(GUTTER_MAX, W);
1662
1662
  const gutterInner = tall ? W : gutterMax - GUTTER_PAD;
@@ -1678,7 +1678,7 @@ var barCompare = (beat, ctx) => {
1678
1678
  if (tall) {
1679
1679
  bar = Math.min(
1680
1680
  BAR_MAX,
1681
- (avail - unitBand - FOOT - count * head) / (count + GAP_RATIO * (count - 1))
1681
+ (avail - unitBand - FOOT - count2 * head) / (count2 + GAP_RATIO * (count2 - 1))
1682
1682
  );
1683
1683
  pitch = head + bar * (1 + GAP_RATIO);
1684
1684
  } else {
@@ -1689,7 +1689,7 @@ var barCompare = (beat, ctx) => {
1689
1689
  }
1690
1690
  }
1691
1691
  const valueSize = Math.max(MIN_FONT, Math.min(VALUE_MAX, bar * 0.9));
1692
- const barsH = (count - 1) * pitch + head + bar;
1692
+ const barsH = (count2 - 1) * pitch + head + bar;
1693
1693
  const foot = Math.max(
1694
1694
  0,
1695
1695
  Math.ceil(
@@ -1702,7 +1702,7 @@ var barCompare = (beat, ctx) => {
1702
1702
  const H = barsH + unitBand + foot;
1703
1703
  if (H > avail) {
1704
1704
  throw new Error(
1705
- `bar-compare ${beat.id}: ${count} bars with labels this long need ${Math.ceil(H)}px of the ${Math.floor(avail)}px this slide has. Shorten the labels or split the beat.`
1705
+ `bar-compare ${beat.id}: ${count2} bars with labels this long need ${Math.ceil(H)}px of the ${Math.floor(avail)}px this slide has. Shorten the labels or split the beat.`
1706
1706
  );
1707
1707
  }
1708
1708
  const gutter = tall ? 0 : Math.min(
@@ -1855,7 +1855,7 @@ ${svg(id(sid, "chart"), W, H, body)}
1855
1855
  </div>${note2}`;
1856
1856
  const railsAt = 0.6;
1857
1857
  const barsAt = 0.95;
1858
- const step2 = Math.min(0.4, 2.4 / count);
1858
+ const step2 = Math.min(0.4, 2.4 / count2);
1859
1859
  const grow = 0.85;
1860
1860
  const tl = [
1861
1861
  ...chromeIn(sid, p.eyebrow !== void 0),
@@ -1905,11 +1905,11 @@ ${svg(id(sid, "chart"), W, H, body)}
1905
1905
  );
1906
1906
  }
1907
1907
  }
1908
- const settled = barsAt + (count - 1) * step2 + grow + 0.05;
1908
+ const settled = barsAt + (count2 - 1) * step2 + grow + 0.05;
1909
1909
  const holds = [settled + 0.2];
1910
1910
  const focal = rows.reduce((best, r) => Math.abs(r.value) > Math.abs(best.value) ? r : best);
1911
1911
  const spot = spotlighter(sid, ".bc-bar");
1912
- if (count > 1) tl.push(...spot.lit(`#${id(sid, "bar", focal.i)}`, settled));
1912
+ if (count2 > 1) tl.push(...spot.lit(`#${id(sid, "bar", focal.i)}`, settled));
1913
1913
  const tailAt = settled + 0.3;
1914
1914
  if (p.unit) {
1915
1915
  tl.push(tween(`#${id(sid, "unit")}`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, tailAt));
@@ -2402,6 +2402,90 @@ var dataTable = (beat, ctx) => {
2402
2402
  };
2403
2403
  };
2404
2404
 
2405
+ // src/emit/tex.ts
2406
+ import katex from "katex";
2407
+ var UNFIT_ATTR = "data-ds-unfit";
2408
+ var trust = (c) => c.command === "\\htmlClass";
2409
+ function texError(tex, display) {
2410
+ try {
2411
+ katex.renderToString(tex, {
2412
+ displayMode: display,
2413
+ throwOnError: true,
2414
+ strict: false,
2415
+ trust,
2416
+ output: "html"
2417
+ });
2418
+ return null;
2419
+ } catch (err) {
2420
+ return err instanceof Error ? err.message : String(err);
2421
+ }
2422
+ }
2423
+ var LEFT = /\\left(?![a-zA-Z])/g;
2424
+ var RIGHT = /\\right(?![a-zA-Z])/g;
2425
+ var NO_INK = /\\label\{[^{}]*\}|\\nonumber(?![a-zA-Z])|\\notag(?![a-zA-Z])/g;
2426
+ function braceBalance(tex) {
2427
+ let depth = 0;
2428
+ for (let i = 0; i < tex.length; i++) {
2429
+ const c = tex[i];
2430
+ if (c === "\\") {
2431
+ i++;
2432
+ continue;
2433
+ }
2434
+ if (c === "{") depth++;
2435
+ else if (c === "}") depth--;
2436
+ }
2437
+ return depth;
2438
+ }
2439
+ var count = (tex, re) => (tex.match(re) ?? []).length;
2440
+ function repairTex(input, display) {
2441
+ const repairs = [];
2442
+ let tex = input;
2443
+ let error = texError(tex, display);
2444
+ if (error === null) return { tex, repairs, error };
2445
+ const gone = tex.match(NO_INK) ?? [];
2446
+ if (gone.length) {
2447
+ tex = tex.replace(NO_INK, "").trim();
2448
+ repairs.push(`removed ${gone.join(", ")}, which draw nothing`);
2449
+ error = texError(tex, display);
2450
+ }
2451
+ const lefts = count(tex, LEFT);
2452
+ const rights = count(tex, RIGHT);
2453
+ if (error !== null && lefts !== rights) {
2454
+ const n3 = Math.abs(lefts - rights);
2455
+ tex = lefts > rights ? `${tex} ${"\\right.".repeat(n3)}` : `${"\\left.".repeat(n3)} ${tex}`;
2456
+ repairs.push(
2457
+ `${lefts} \\left against ${rights} \\right: added ${n3} invisible ${lefts > rights ? "\\right." : "\\left."} to balance them`
2458
+ );
2459
+ error = texError(tex, display);
2460
+ }
2461
+ const depth = braceBalance(tex);
2462
+ if (error !== null && depth !== 0) {
2463
+ const n3 = Math.abs(depth);
2464
+ tex = depth > 0 ? `${tex}${"}".repeat(n3)}` : `${"{".repeat(n3)}${tex}`;
2465
+ repairs.push(
2466
+ `${n3} unclosed brace group(s): ${depth > 0 ? "closed at the end" : "opened at the start"}`
2467
+ );
2468
+ error = texError(tex, display);
2469
+ }
2470
+ const undefinedNames = [];
2471
+ for (let round7 = 0; error !== null && round7 < 12; round7++) {
2472
+ const m = /Undefined control sequence: \\([a-zA-Z]+)/.exec(error);
2473
+ if (!m) break;
2474
+ const name = m[1];
2475
+ const next = tex.replace(new RegExp(`\\\\${name}(?![a-zA-Z])`, "g"), `\\operatorname{${name}}`);
2476
+ if (next === tex) break;
2477
+ tex = next;
2478
+ undefinedNames.push(`\\${name}`);
2479
+ error = texError(tex, display);
2480
+ }
2481
+ if (undefinedNames.length) {
2482
+ repairs.push(
2483
+ `${undefinedNames.join(", ")} ${undefinedNames.length === 1 ? "is" : "are"} never defined in the source \u2014 drawn as ${undefinedNames.length === 1 ? "its name" : "their names"} in upright type`
2484
+ );
2485
+ }
2486
+ return { tex, repairs, error };
2487
+ }
2488
+
2405
2489
  // src/emit/archetypes/equation-walk.ts
2406
2490
  var TRUST = 'function (c) { return c.command === "\\\\htmlClass"; }';
2407
2491
  var OPTS = `{ displayMode: true, trust: ${TRUST}, strict: false, output: "html" }`;
@@ -2416,6 +2500,9 @@ var SYNONYMS = [
2416
2500
  function normalise(tex) {
2417
2501
  let out = "";
2418
2502
  const map = [];
2503
+ const ends = [];
2504
+ const lead = [];
2505
+ let hint = null;
2419
2506
  for (let i = 0; i < tex.length; ) {
2420
2507
  if (/\s/.test(tex[i])) {
2421
2508
  i++;
@@ -2427,19 +2514,19 @@ function normalise(tex) {
2427
2514
  const m = new RegExp(`^(?:${re.source})`).exec(rest);
2428
2515
  return m ? { len: m[0].length, to } : null;
2429
2516
  }).find(Boolean);
2430
- if (hit) {
2431
- for (const ch of hit.to) {
2432
- out += ch;
2433
- map.push(i);
2434
- }
2435
- i += hit.len;
2436
- continue;
2517
+ const len = hit ? hit.len : 1;
2518
+ const chars = hit ? hit.to : tex[i];
2519
+ if (chars === "") hint ??= i;
2520
+ for (const ch of chars) {
2521
+ out += ch;
2522
+ map.push(i);
2523
+ ends.push(i + len);
2524
+ lead.push(hint ?? i);
2525
+ hint = null;
2437
2526
  }
2438
- out += tex[i];
2439
- map.push(i);
2440
- i++;
2527
+ i += len;
2441
2528
  }
2442
- return { text: out, map };
2529
+ return { text: out, map, ends, lead };
2443
2530
  }
2444
2531
  function locate(tex, term) {
2445
2532
  const hay = normalise(tex);
@@ -2447,58 +2534,131 @@ function locate(tex, term) {
2447
2534
  if (needle === "") return null;
2448
2535
  const at = hay.text.indexOf(needle);
2449
2536
  if (at < 0) return null;
2450
- const start = hay.map[at];
2451
- const lastNorm = at + needle.length - 1;
2452
- const lastOrig = hay.map[lastNorm];
2453
- let end = lastOrig + 1;
2454
- while (end < tex.length && (hay.map[lastNorm + 1] ?? tex.length) > end && /\s/.test(tex[end])) {
2455
- end++;
2456
- }
2457
- end = Math.max(end, hay.map[lastNorm + 1] ?? lastOrig + 1);
2537
+ const start = hay.lead[at];
2538
+ const end = hay.ends[at + needle.length - 1];
2458
2539
  return { start, end: Math.min(end, tex.length) };
2459
2540
  }
2460
2541
  function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
2542
+ const checked = texError(tex, true) === null;
2461
2543
  let parts = [{ text: tex, raw: true }];
2462
2544
  const used = [];
2463
2545
  const missing = [];
2546
+ const broken = [];
2547
+ const joined = (ps) => ps.map((p) => p.text).join("");
2464
2548
  for (const term of terms) {
2465
2549
  let placed = false;
2550
+ let found = false;
2466
2551
  for (let i = 0; i < parts.length && !placed; i++) {
2467
2552
  const part = parts[i];
2468
2553
  if (!part.raw) continue;
2469
2554
  const at = locate(part.text, term.tex);
2470
2555
  if (!at) continue;
2471
- parts = parts.toSpliced(
2472
- i,
2473
- 1,
2474
- { text: part.text.slice(0, at.start), raw: true },
2475
- {
2476
- text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
2477
- raw: false
2478
- },
2479
- { text: part.text.slice(at.end), raw: true }
2480
- );
2481
- used.push(term);
2482
- placed = true;
2556
+ found = true;
2557
+ const body = `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`;
2558
+ for (const text2 of [body, `{${body}}`]) {
2559
+ const next = parts.toSpliced(
2560
+ i,
2561
+ 1,
2562
+ { text: part.text.slice(0, at.start), raw: true },
2563
+ { text: text2, raw: false },
2564
+ { text: part.text.slice(at.end), raw: true }
2565
+ );
2566
+ if (checked && texError(joined(next), true) !== null) continue;
2567
+ parts = next;
2568
+ used.push(term);
2569
+ placed = true;
2570
+ break;
2571
+ }
2572
+ }
2573
+ if (!placed) {
2574
+ missing.push(term);
2575
+ if (found) broken.push(term);
2483
2576
  }
2484
- if (!placed) missing.push(term);
2485
2577
  }
2486
2578
  if (used.length === 0) {
2487
2579
  throw new Error(
2488
- `equation-walk ${beatId}: none of its ${terms.length} term(s) occur in the equation. Terms: ${terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. Equation: ${JSON.stringify(tex)}`
2580
+ `equation-walk ${beatId}: none of its ${terms.length} term(s) ${broken.length ? "can be highlighted in" : "occur in"} the equation. Terms: ${terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. Equation: ${JSON.stringify(tex)}`
2581
+ );
2582
+ }
2583
+ return { tex: joined(parts), used, missing, broken };
2584
+ }
2585
+ function droppedTerms(eqId, missing, broken) {
2586
+ return missing.map(
2587
+ (t2) => broken.includes(t2) ? `term ${JSON.stringify(t2.tex)} is in ${eqId} but no span around it parses, so it is not highlighted and its legend row ("${t2.label}") is left out` : `term ${JSON.stringify(t2.tex)} does not occur in ${eqId}, so its legend row ("${t2.label}") is left out`
2588
+ );
2589
+ }
2590
+ function prepareWalk(eq, terms, beatId) {
2591
+ const fixed = repairTex(eq.tex, true);
2592
+ const notes = fixed.repairs.map((r) => `${eq.id} repaired before drawing: ${r}`);
2593
+ const repairedTerms = terms.map((t2) => ({ ...t2, tex: repairTex(t2.tex, false).tex }));
2594
+ if (fixed.error !== null) {
2595
+ notes.push(
2596
+ `${eq.id} does not parse even after repair (${fixed.error}), so it is shown as its TeX source in plain text \u2014 ${JSON.stringify(eq.tex)}`
2489
2597
  );
2598
+ const parts = segment(eq.tex, [...terms]);
2599
+ if (parts.used.length === 0) {
2600
+ throw new Error(
2601
+ `equation-walk ${beatId}: none of its ${terms.length} term(s) occur in the equation. Terms: ${terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. Equation: ${JSON.stringify(eq.tex)}`
2602
+ );
2603
+ }
2604
+ notes.push(...droppedTerms(eq.id, parts.missing, []));
2605
+ return {
2606
+ raw: eq.tex,
2607
+ plain: { error: fixed.error },
2608
+ used: parts.used,
2609
+ missing: parts.missing,
2610
+ notes
2611
+ };
2490
2612
  }
2491
- return { tex: parts.map((part) => part.text).join(""), used, missing };
2613
+ const walk = wrapTerms(fixed.tex, repairedTerms, beatId);
2614
+ const back = (ts) => ts.map((t2) => terms[repairedTerms.indexOf(t2)]);
2615
+ const used = back(walk.used);
2616
+ const missing = back(walk.missing);
2617
+ notes.push(...droppedTerms(eq.id, missing, back(walk.broken)));
2618
+ return { tex: walk.tex, raw: fixed.tex, used, missing, notes };
2619
+ }
2620
+ function segment(tex, terms) {
2621
+ let parts = [{ text: tex }];
2622
+ const used = [];
2623
+ const missing = [];
2624
+ for (const term of terms) {
2625
+ const i = parts.findIndex((p) => !p.term && locate(p.text, term.tex));
2626
+ const part = parts[i];
2627
+ const at = part && locate(part.text, term.tex);
2628
+ if (!part || !at) {
2629
+ missing.push(term);
2630
+ continue;
2631
+ }
2632
+ parts = parts.toSpliced(
2633
+ i,
2634
+ 1,
2635
+ { text: part.text.slice(0, at.start) },
2636
+ { text: part.text.slice(at.start, at.end), term },
2637
+ { text: part.text.slice(at.end) }
2638
+ );
2639
+ used.push(term);
2640
+ }
2641
+ return { parts, used, missing };
2642
+ }
2643
+ function plainHtml(tex, terms) {
2644
+ return segment(tex, terms).parts.map(
2645
+ (p) => p.term ? `<span class="term t-${p.term.tone}">${esc(p.text)}</span>` : esc(p.text)
2646
+ ).join("");
2647
+ }
2648
+ function chipSetup(sid, term) {
2649
+ const fixed = repairTex(term.tex, false);
2650
+ const el = `document.getElementById("${sid}-chip-${term.tone}")`;
2651
+ return fixed.error === null ? `katex.render('${js(fixed.tex)}', ${el}, ${INLINE_OPTS});` : `${el}.textContent = '${js(term.tex)}';`;
2492
2652
  }
2493
2653
  function equationSize(tex) {
2494
2654
  return tex.length > 120 ? 68 : tex.length > 90 ? 80 : tex.length > 55 ? 92 : 108;
2495
2655
  }
2496
- var NO_INK = /\\(math[a-z]+|text[a-z]*|bm|boldsymbol|displaystyle|textstyle|scriptstyle|left|right|operatorname|limits|nolimits|htmlClass|hspace|phantom|[,;:!])/g;
2656
+ var NO_INK2 = /\\(math[a-z]+|text[a-z]*|bm|boldsymbol|displaystyle|textstyle|scriptstyle|left|right|operatorname|limits|nolimits|htmlClass|hspace|phantom|[,;:!])/g;
2497
2657
  var GLYPH_EM = 0.9;
2498
2658
  function texUnits(tex) {
2499
2659
  let ems = 0;
2500
2660
  for (const m of tex.matchAll(/\\qquad|\\quad/g)) ems += m[0] === "\\qquad" ? 2 : 1;
2501
- const glyphs2 = tex.replace(/\\q?quad/g, "").replace(NO_INK, "").replace(/[{}\s_^]/g, "");
2661
+ const glyphs2 = tex.replace(/\\q?quad/g, "").replace(NO_INK2, "").replace(/[{}\s_^]/g, "");
2502
2662
  return ems + glyphs2.length * GLYPH_EM;
2503
2663
  }
2504
2664
  function statements(tex, stacked) {
@@ -2506,6 +2666,100 @@ function statements(tex, stacked) {
2506
2666
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
2507
2667
  return parts.length > 0 ? parts : [tex];
2508
2668
  }
2669
+ var PLAIN_FONT = 48;
2670
+ function mathFit(sid, targets, shown, raw2, want, floor) {
2671
+ return [
2672
+ `var dsEqFit = (function () {
2673
+ var box = document.getElementById("${sid}-eq");
2674
+ var slide = box && box.parentNode;
2675
+ var bw = box ? box.getBoundingClientRect().width : 0;
2676
+ if (!(bw > 0) || !slide) return "unmeasured";
2677
+ var targets = [${targets.map((id2) => `"${id2}"`).join(", ")}].map(function (id) { return document.getElementById(id); });
2678
+ var TEX = [${shown.map((s) => `'${js(s)}'`).join(", ")}];
2679
+ var spill0 = slide.scrollHeight - slide.clientHeight;
2680
+ function extent(each) {
2681
+ var w = 0;
2682
+ targets.forEach(function (t) {
2683
+ var lo = Infinity, hi = -Infinity;
2684
+ Array.prototype.forEach.call(t.querySelectorAll(".katex-html > .base"), function (b) {
2685
+ var r = b.getBoundingClientRect();
2686
+ if (each) w = Math.max(w, r.width);
2687
+ else { lo = Math.min(lo, r.left); hi = Math.max(hi, r.right); }
2688
+ });
2689
+ if (!each && hi > lo) w = Math.max(w, hi - lo);
2690
+ });
2691
+ return w;
2692
+ }
2693
+ function fits(each) {
2694
+ return extent(each) <= bw + 0.5 && slide.scrollHeight - slide.clientHeight <= Math.max(1, spill0 + 1);
2695
+ }
2696
+ function settle(each, size) {
2697
+ size = Math.max(${floor}, Math.min(${want}, Math.floor(size)));
2698
+ box.style.fontSize = size + "px";
2699
+ while (!fits(each) && size > ${floor}) {
2700
+ size = Math.max(${floor}, size - 2);
2701
+ box.style.fontSize = size + "px";
2702
+ }
2703
+ return fits(each);
2704
+ }
2705
+ if (fits(false)) return "fits";
2706
+ var size = parseFloat(box.style.fontSize);
2707
+ if (settle(false, (size * bw) / extent(false))) return "scaled";
2708
+ targets.forEach(function (t, i) { katex.render("\\\\displaystyle " + TEX[i], t, ${INLINE_OPTS}); });
2709
+ box.className += " eq-broken";
2710
+ box.style.fontSize = "${want}px";
2711
+ var wb = extent(true);
2712
+ if (settle(true, wb > bw ? (${want} * bw) / wb : ${want})) return "broken";
2713
+ box.setAttribute("${UNFIT_ATTR}", '${js(raw2)}');
2714
+ return "unfit";
2715
+ })()`
2716
+ ];
2717
+ }
2718
+ function chipFit(sid, terms) {
2719
+ if (terms.length === 0) return [];
2720
+ return [
2721
+ `[${terms.map((t2) => `"${t2.tone}"`).join(", ")}].forEach(function (tone) {
2722
+ var chip = document.getElementById("${sid}-chip-" + tone);
2723
+ var r = chip && chip.getBoundingClientRect();
2724
+ if (!r || !(r.height > 0)) return;
2725
+ var k = chip.offsetHeight / r.height;
2726
+ var top = r.top, bottom = r.bottom;
2727
+ Array.prototype.forEach.call(chip.querySelectorAll("span"), function (el) {
2728
+ for (var n = el.firstChild; n; n = n.nextSibling) {
2729
+ if (n.nodeType === 3 && n.nodeValue.trim()) {
2730
+ var q = el.getBoundingClientRect();
2731
+ top = Math.min(top, q.top);
2732
+ bottom = Math.max(bottom, q.bottom);
2733
+ return;
2734
+ }
2735
+ }
2736
+ });
2737
+ var cs = getComputedStyle(chip);
2738
+ if (top < r.top) chip.style.paddingTop = parseFloat(cs.paddingTop) + Math.ceil((r.top - top) * k) + "px";
2739
+ if (bottom > r.bottom) chip.style.paddingBottom = parseFloat(cs.paddingBottom) + Math.ceil((bottom - r.bottom) * k) + "px";
2740
+ })`
2741
+ ];
2742
+ }
2743
+ var SWELL = 1.16;
2744
+ var SWELL_EM = 0.06;
2745
+ function swellFit(sid, terms) {
2746
+ return [
2747
+ `var dsSwell = (function () {
2748
+ var out = {};
2749
+ var box = document.getElementById("${sid}-eq");
2750
+ var k = box && box.offsetWidth ? box.getBoundingClientRect().width / box.offsetWidth : 1;
2751
+ var em = box ? parseFloat(getComputedStyle(box).fontSize) : 0;
2752
+ [${terms.map((t2) => `"${t2.tone}"`).join(", ")}].forEach(function (tone) {
2753
+ var w = 0;
2754
+ Array.prototype.forEach.call(document.querySelectorAll("#${sid} .t-" + tone), function (el) {
2755
+ w = Math.max(w, el.getBoundingClientRect().width / k);
2756
+ });
2757
+ out[tone] = w > 0 && em > 0 ? Math.min(${SWELL}, Math.round((1 + (2 * ${SWELL_EM} * em) / w) * 1000) / 1000) : ${SWELL};
2758
+ });
2759
+ return out;
2760
+ })()`
2761
+ ];
2762
+ }
2509
2763
  function legendRows(sid, terms, theme) {
2510
2764
  return terms.map(
2511
2765
  (t2) => `<div class="leg" id="${sid}-leg-${t2.tone}"><span class="chip" id="${sid}-chip-${t2.tone}" style="color:${theme.tones[t2.tone]}"></span><span>${esc(t2.label)}</span></div>`
@@ -2521,7 +2775,10 @@ function legendCss(theme) {
2521
2775
  `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
2522
2776
  // A common chip width, so the labels share a spine too — the glyphs inside
2523
2777
  // are one symbol each and their natural widths differ by a few pixels.
2524
- `.chip{display:inline-block;min-width:72px;text-align:center;background:${theme.panel};border-radius:10px;padding:2px 20px;white-space:nowrap;font-weight:700}`
2778
+ // `flex:none`: beside a long label the chip used to SHRINK below its own
2779
+ // nowrap TeX, which then ran out of the painted box into the label — on a
2780
+ // HypePaper deck, "pixelcoord" 91px past the chip's right edge.
2781
+ `.chip{display:inline-block;flex:none;min-width:72px;text-align:center;background:${theme.panel};border-radius:10px;padding:2px 20px;white-space:nowrap;font-weight:700}`
2525
2782
  ].join("\n");
2526
2783
  }
2527
2784
  var equationWalk = (beat, ctx) => {
@@ -2533,37 +2790,53 @@ var equationWalk = (beat, ctx) => {
2533
2790
  `equation-walk ${beat.id}: no equation "${p.equationId}" in source ${ctx.source.id}`
2534
2791
  );
2535
2792
  }
2536
- const walk = wrapTerms(eq.tex, p.terms, beat.id);
2793
+ const walk = prepareWalk(eq, p.terms, beat.id);
2537
2794
  const terms = walk.used;
2538
2795
  const legend = legendRows(sid, terms, theme);
2539
- const stacked = isPortrait(ctx.format);
2540
- const raw2 = statements(eq.tex, stacked);
2541
- const shown = statements(walk.tex, stacked);
2542
- const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
2543
- const size3 = Math.max(
2544
- MIN_FONT,
2545
- Math.min(
2546
- equationSize(longest),
2547
- Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits)))
2548
- )
2549
- );
2550
- const body = shown.length === 1 ? "" : shown.map((_, i) => `<div id="${sid}-eq${i}"></div>`).join("\n ");
2796
+ const chips = terms.map((t2) => chipSetup(sid, t2));
2797
+ let eqHtml;
2798
+ let setup;
2799
+ let measure4;
2800
+ if (walk.plain) {
2801
+ eqHtml = `<div class="eq eq-plain" id="${sid}-eq" style="font-size:${PLAIN_FONT}px">${plainHtml(eq.tex, terms)}</div>`;
2802
+ setup = chips;
2803
+ measure4 = [...chipFit(sid, terms), ...swellFit(sid, terms)];
2804
+ } else {
2805
+ const wrapped = walk.tex;
2806
+ const split = statements(wrapped, isPortrait(ctx.format));
2807
+ const stacked = split.length > 1 && split.every((s) => texError(s, true) === null);
2808
+ const raw2 = statements(walk.raw, stacked);
2809
+ const shown = stacked ? split : [wrapped];
2810
+ const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
2811
+ const want = equationSize(longest);
2812
+ const size3 = Math.max(
2813
+ MIN_FONT,
2814
+ Math.min(want, Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits))))
2815
+ );
2816
+ const body = shown.length === 1 ? "" : shown.map((_, i) => `<div id="${sid}-eq${i}"></div>`).join("\n ");
2817
+ eqHtml = `<div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size3}px">${body}</div>`;
2818
+ const targets = shown.map((_, i) => `${sid}-eq${shown.length === 1 ? "" : i}`);
2819
+ setup = [
2820
+ `var OPTS = ${OPTS};`,
2821
+ ...shown.map(
2822
+ (part, i) => `katex.render('${js(part)}', document.getElementById("${targets[i]}"), OPTS);`
2823
+ ),
2824
+ ...chips
2825
+ ];
2826
+ measure4 = [
2827
+ ...chipFit(sid, terms),
2828
+ ...mathFit(sid, targets, shown, walk.raw, want, MIN_FONT),
2829
+ // Last: it measures each term at the size the fit settled on.
2830
+ ...swellFit(sid, terms)
2831
+ ];
2832
+ }
2551
2833
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
2552
2834
  <div class="eqslide">
2553
- <div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size3}px">${body}</div>
2835
+ ${eqHtml}
2554
2836
  <div class="legend">
2555
2837
  ${legend}
2556
2838
  </div>
2557
2839
  </div>`;
2558
- const setup = [
2559
- `var OPTS = ${OPTS};`,
2560
- ...shown.map(
2561
- (part, i) => `katex.render('${js(part)}', document.getElementById("${sid}-eq${shown.length === 1 ? "" : i}"), OPTS);`
2562
- ),
2563
- ...terms.map(
2564
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2565
- )
2566
- ];
2567
2840
  const tl = [
2568
2841
  ...chromeIn(sid, p.eyebrow !== void 0),
2569
2842
  tween(`#${sid}-eq`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8)
@@ -2575,6 +2848,7 @@ var equationWalk = (beat, ctx) => {
2575
2848
  );
2576
2849
  const holds = [];
2577
2850
  const spot = spotlighter(sid, ".term");
2851
+ const swell = (t2) => raw(`dsSwell.${t2.tone}`);
2578
2852
  terms.forEach((term, i) => {
2579
2853
  const at = first + i * step2;
2580
2854
  const colour = theme.tones[term.tone];
@@ -2591,13 +2865,15 @@ var equationWalk = (beat, ctx) => {
2591
2865
  tween(
2592
2866
  `#${sid} .t-${term.tone}`,
2593
2867
  { color: theme.fg, scale: 1 },
2594
- { color: colour, scale: 1.16, duration: 0.5 },
2868
+ { color: colour, scale: swell(term), duration: 0.5 },
2595
2869
  at
2596
2870
  )
2597
2871
  );
2598
2872
  const prev = terms[i - 1];
2599
2873
  if (prev) {
2600
- tl.push(tween(`#${sid} .t-${prev.tone}`, { scale: 1.16 }, { scale: 1, duration: 0.4 }, at));
2874
+ tl.push(
2875
+ tween(`#${sid} .t-${prev.tone}`, { scale: swell(prev) }, { scale: 1, duration: 0.4 }, at)
2876
+ );
2601
2877
  }
2602
2878
  holds.push(at + 0.6);
2603
2879
  });
@@ -2605,7 +2881,7 @@ var equationWalk = (beat, ctx) => {
2605
2881
  if (last) {
2606
2882
  const at = first + terms.length * step2;
2607
2883
  tl.push(
2608
- tween(`#${sid} .t-${last.tone}`, { scale: 1.16 }, { scale: 1, duration: 0.4 }, at),
2884
+ tween(`#${sid} .t-${last.tone}`, { scale: swell(last) }, { scale: 1, duration: 0.4 }, at),
2609
2885
  ...spot.restore(at)
2610
2886
  );
2611
2887
  }
@@ -2613,6 +2889,8 @@ var equationWalk = (beat, ctx) => {
2613
2889
  html,
2614
2890
  tl,
2615
2891
  setup,
2892
+ measure: measure4,
2893
+ ...walk.notes.length ? { warnings: walk.notes } : {},
2616
2894
  holds: holdsWithin(holds, beat.seconds),
2617
2895
  css: [
2618
2896
  chromeCss(theme),
@@ -2632,6 +2910,15 @@ var equationWalk = (beat, ctx) => {
2632
2910
  // has its margin zeroed above, so without an explicit gap the two lines
2633
2911
  // would touch and read as one equation broken mid-expression.
2634
2912
  ".eqstack{display:flex;flex-direction:column;gap:32px}",
2913
+ // Set by `mathFit` when one line will not fit at the floor: the display is
2914
+ // re-rendered inline, where KaTeX breaks after a top-level relation or
2915
+ // operator, and the lines it breaks into are centred and spaced as lines.
2916
+ ".eq-broken{line-height:1.5}",
2917
+ // An equation KaTeX refuses, shown as its source. Wraps anywhere, because
2918
+ // TeX source has few spaces and must not run off the slide either. The
2919
+ // deck's own face, not a monospace one: a family the bundle does not
2920
+ // declare falls back silently (invariant 9).
2921
+ ".eq-plain{white-space:pre-wrap;overflow-wrap:anywhere}",
2635
2922
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
2636
2923
  ".term{display:inline-block}",
2637
2924
  legendCss(theme),
@@ -2646,26 +2933,67 @@ var equationWalk = (beat, ctx) => {
2646
2933
 
2647
2934
  // src/emit/archetypes/equation-morph.ts
2648
2935
  var MORPH_SECONDS = 1.6;
2649
- var equationMorph = (beat, ctx) => {
2650
- const { sid, theme } = ctx;
2936
+ function prepareMorph(beat, source) {
2651
2937
  const p = beat.params;
2652
2938
  const find3 = (id2) => {
2653
- const eq = ctx.source.equations.find((e) => e.id === id2);
2939
+ const eq = source.equations.find((e) => e.id === id2);
2654
2940
  if (!eq)
2655
- throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
2941
+ throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${source.id}`);
2656
2942
  return eq;
2657
2943
  };
2658
- const a = find3(p.fromId);
2659
- const b = find3(p.toId);
2660
- const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
2661
- if (both.length === 0) {
2944
+ const notes = [];
2945
+ const drawable = (eq) => {
2946
+ const fixed = repairTex(eq.tex, true);
2947
+ if (fixed.error !== null) {
2948
+ throw new Error(
2949
+ `equation-morph ${beat.id}: ${eq.id} does not parse even after repair (${fixed.error}), and a morph cannot be drawn from plain text. Formula: ${JSON.stringify(eq.tex)}`
2950
+ );
2951
+ }
2952
+ notes.push(...fixed.repairs.map((r) => `${eq.id} repaired before drawing: ${r}`));
2953
+ return { id: eq.id, tex: fixed.tex };
2954
+ };
2955
+ const a = drawable(find3(p.fromId));
2956
+ const b = drawable(find3(p.toId));
2957
+ const repaired = p.terms.map((t2) => ({ ...t2, tex: repairTex(t2.tex, false).tex }));
2958
+ const original = (t2) => p.terms[repaired.indexOf(t2)];
2959
+ let keyed = repaired.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
2960
+ if (keyed.length === 0) {
2662
2961
  throw new Error(
2663
2962
  `equation-morph ${beat.id}: none of its ${p.terms.length} term(s) occur in both equations. Terms: ${p.terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. From: ${JSON.stringify(a.tex)}. To: ${JSON.stringify(b.tex)}`
2664
2963
  );
2665
2964
  }
2965
+ notes.push(
2966
+ ...repaired.filter((t2) => !keyed.includes(t2)).map(
2967
+ (t2) => `term ${JSON.stringify(original(t2).tex)} does not occur in both ${a.id} and ${b.id}, so it does not travel and its legend row ("${t2.label}") is left out`
2968
+ )
2969
+ );
2666
2970
  const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
2667
- const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
2668
- const wb = wrapTerms(b.tex, both, beat.id, cls).tex;
2971
+ let wa = wrapTerms(a.tex, keyed, beat.id, cls);
2972
+ let wb = wrapTerms(b.tex, keyed, beat.id, cls);
2973
+ const paired = keyed.filter((t2) => wa.used.includes(t2) && wb.used.includes(t2));
2974
+ if (paired.length < keyed.length) {
2975
+ const unpaired = keyed.filter((t2) => !paired.includes(t2));
2976
+ notes.push(
2977
+ ...unpaired.map(
2978
+ (t2) => `term ${JSON.stringify(original(t2).tex)} cannot be marked on both lines without breaking one, so it does not travel and its legend row ("${t2.label}") is left out`
2979
+ )
2980
+ );
2981
+ if (paired.length === 0) {
2982
+ throw new Error(
2983
+ `equation-morph ${beat.id}: no term can be marked on both lines without breaking one. Terms: ${p.terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}`
2984
+ );
2985
+ }
2986
+ keyed = paired;
2987
+ wa = wrapTerms(a.tex, keyed, beat.id, cls);
2988
+ wb = wrapTerms(b.tex, keyed, beat.id, cls);
2989
+ }
2990
+ const both = keyed.map(original);
2991
+ return { a, b, wa: wa.tex, wb: wb.tex, both, notes };
2992
+ }
2993
+ var equationMorph = (beat, ctx) => {
2994
+ const { sid, theme } = ctx;
2995
+ const p = beat.params;
2996
+ const { a, b, wa, wb, both, notes } = prepareMorph(beat, ctx.source);
2669
2997
  const size3 = Math.max(
2670
2998
  MIN_FONT,
2671
2999
  Math.min(
@@ -2687,9 +3015,7 @@ var equationMorph = (beat, ctx) => {
2687
3015
  `var OPTS = ${OPTS};`,
2688
3016
  `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
2689
3017
  `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
2690
- ...both.map(
2691
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2692
- )
3018
+ ...both.map((t2) => chipSetup(sid, t2))
2693
3019
  ];
2694
3020
  const first = 1.8;
2695
3021
  const at = Math.round(
@@ -2722,8 +3048,11 @@ var equationMorph = (beat, ctx) => {
2722
3048
  setup,
2723
3049
  // SEAM B: the plan is browser geometry after fonts, so it is built inside
2724
3050
  // the ready gate, and the plugin tween above finds it on the host.
2725
- measure: [`DSMorph.build(document.getElementById("${sid}-morph"));`],
3051
+ // The chips are fitted before the plan is built: growing one moves the
3052
+ // legend, and nothing the morph measures may move after it has measured.
3053
+ measure: [...chipFit(sid, both), `DSMorph.build(document.getElementById("${sid}-morph"));`],
2726
3054
  plugins: ["dsMorph"],
3055
+ ...notes.length ? { warnings: notes } : {},
2727
3056
  holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
2728
3057
  css: [
2729
3058
  chromeCss(theme),
@@ -3304,16 +3633,16 @@ var lineChart = (beat, ctx) => {
3304
3633
  </svg>${readout}
3305
3634
  </div>`;
3306
3635
  const draw2 = 0.8;
3307
- const count = p.points.length;
3308
- const idealStep = Math.min(0.45, DRAW_SECONDS / count);
3636
+ const count2 = p.points.length;
3637
+ const idealStep = Math.min(0.45, DRAW_SECONDS / count2);
3309
3638
  const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
3310
- const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count - 1) : 0;
3639
+ const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count2 - 1) : 0;
3311
3640
  const tailAfter = (s) => Math.max(
3312
- s * count + 0.4,
3641
+ s * count2 + 0.4,
3313
3642
  // the ring: its walk, then the 0.4s it takes to leave
3314
- 0.3 + s * (count - 1),
3643
+ 0.3 + s * (count2 - 1),
3315
3644
  // the dots
3316
- 0.5 + s * (count - 1),
3645
+ 0.5 + s * (count2 - 1),
3317
3646
  ...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
3318
3647
  );
3319
3648
  const room = beat.seconds - spine;
@@ -3376,8 +3705,8 @@ var lineChart = (beat, ctx) => {
3376
3705
  )
3377
3706
  );
3378
3707
  }
3379
- const walk = cmp ? sec(step2 * count) : DRAW_SECONDS;
3380
- if (count > 1) {
3708
+ const walk = cmp ? sec(step2 * count2) : DRAW_SECONDS;
3709
+ if (count2 > 1) {
3381
3710
  const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
3382
3711
  tl.push(
3383
3712
  tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
@@ -3392,7 +3721,7 @@ var lineChart = (beat, ctx) => {
3392
3721
  )
3393
3722
  );
3394
3723
  }
3395
- const drawn = settled + (cmp ? tailAfter(step2) : step2 * count + 0.4);
3724
+ const drawn = settled + (cmp ? tailAfter(step2) : step2 * count2 + 0.4);
3396
3725
  const holds = cmp ? [lift2, drawn] : [drawn];
3397
3726
  if (p.readout) {
3398
3727
  tl.push(
@@ -3408,7 +3737,7 @@ var lineChart = (beat, ctx) => {
3408
3737
  return {
3409
3738
  ...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
3410
3739
  warnings: [
3411
- `line-chart ${beat.id}: a comparison against "${cmp.label}" over ${count} points needs ${need}s and the beat is ${beat.seconds}s, so the chart was drawn without it. Lengthen the beat to keep the comparison.`
3740
+ `line-chart ${beat.id}: a comparison against "${cmp.label}" over ${count2} points needs ${need}s and the beat is ${beat.seconds}s, so the chart was drawn without it. Lengthen the beat to keep the comparison.`
3412
3741
  ]
3413
3742
  };
3414
3743
  }
@@ -3488,7 +3817,7 @@ var V_BOX_H = 268;
3488
3817
  var V_LOOP_SIDE = 56;
3489
3818
  var V_LOOP_PAD = 18;
3490
3819
  function columnLayout(stageW, stageH, stages, face, loop) {
3491
- const count = stages.length;
3820
+ const count2 = stages.length;
3492
3821
  const loopW = loop ? Math.max(240, Math.round(stageW * 0.32)) : 0;
3493
3822
  const boxX = M;
3494
3823
  const boxW = stageW - 2 * M - (loop ? V_LOOP_SIDE + V_LOOP_PAD + loopW : 0);
@@ -3505,11 +3834,11 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3505
3834
  return Math.ceil(2 * PAD_Y2 + label + (note2 > 0 ? NOTE_TOP + note2 * NOTE * NOTE_LH : 0));
3506
3835
  })
3507
3836
  );
3508
- const room = (stageH - 2 * M - (count - 1) * V_GAP) / count;
3837
+ const room = (stageH - 2 * M - (count2 - 1) * V_GAP) / count2;
3509
3838
  const boxH = Math.max(need, Math.min(room, V_BOX_H));
3510
3839
  return {
3511
3840
  size: size3,
3512
- boxes: Array.from({ length: count }, (_, i) => ({ x: M + i * (boxH + V_GAP), w: boxH })),
3841
+ boxes: Array.from({ length: count2 }, (_, i) => ({ x: M + i * (boxH + V_GAP), w: boxH })),
3513
3842
  boxH,
3514
3843
  boxX,
3515
3844
  boxW,
@@ -3521,7 +3850,7 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3521
3850
  loopLines: loop ? wrap(loop.label, NOTE, loopW, 500, 0, face) : [],
3522
3851
  loopX: boxX + boxW + V_LOOP_SIDE,
3523
3852
  loopW,
3524
- svgH: 2 * M + count * boxH + (count - 1) * V_GAP
3853
+ svgH: 2 * M + count2 * boxH + (count2 - 1) * V_GAP
3525
3854
  };
3526
3855
  }
3527
3856
  function balance(label, k, face) {
@@ -4239,8 +4568,8 @@ function stackLayout(p, format, face = "latin") {
4239
4568
  if (inline2.fits) return inline2;
4240
4569
  return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
4241
4570
  }
4242
- function labelWeight(i, count) {
4243
- return i === count - 1 ? 700 : 600;
4571
+ function labelWeight(i, count2) {
4572
+ return i === count2 - 1 ? 700 : 600;
4244
4573
  }
4245
4574
  function floorFor(p, format) {
4246
4575
  if (!p.tilt) return MIN_FONT;
@@ -4250,7 +4579,7 @@ function solve2(p, format, inline2, face) {
4250
4579
  const width = contentW(format);
4251
4580
  const boxH = contentH(format);
4252
4581
  const floor = floorFor(p, format);
4253
- const count = p.layers.length;
4582
+ const count2 = p.layers.length;
4254
4583
  const k = isPortrait(format) ? "tall" : "wide";
4255
4584
  const riseMax = RISE_MAX[k];
4256
4585
  const syMax = SY_MAX[k];
@@ -4258,7 +4587,7 @@ function solve2(p, format, inline2, face) {
4258
4587
  const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4259
4588
  const want = Math.max(
4260
4589
  ...p.layers.map(
4261
- (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
4590
+ (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count2), 0, false, face) + noteW(l)
4262
4591
  )
4263
4592
  );
4264
4593
  const colCap = inline2 ? width * 0.56 : width * 0.5;
@@ -4267,7 +4596,7 @@ function solve2(p, format, inline2, face) {
4267
4596
  const labelX = width - colW;
4268
4597
  const labelRoom = Math.min(
4269
4598
  ...p.layers.map(
4270
- (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count), 0, false, face))
4599
+ (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count2), 0, false, face))
4271
4600
  )
4272
4601
  );
4273
4602
  const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
@@ -4275,7 +4604,7 @@ function solve2(p, format, inline2, face) {
4275
4604
  const nw = noteW(l);
4276
4605
  const labelMaxW = Math.max(labelSize, colW - nw);
4277
4606
  return {
4278
- label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4607
+ label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count2), 0, face),
4279
4608
  // Inline notes stay on one line by contract — the schema calls a note "one
4280
4609
  // short line" — and wrapping one would put its second line under the label.
4281
4610
  note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
@@ -4293,13 +4622,13 @@ function solve2(p, format, inline2, face) {
4293
4622
  const noteH = noteHeight(p.note, noteWidth(format), 28, face);
4294
4623
  const free = boxH - chromeH - 20 - noteH;
4295
4624
  const avail = Math.max(300, free);
4296
- const room = (avail - 2 * pad - syMax - tMax) / Math.max(1, count - 1);
4625
+ const room = (avail - 2 * pad - syMax - tMax) / Math.max(1, count2 - 1);
4297
4626
  const rise = clamp(room, RISE_MIN, riseMax);
4298
4627
  const sy = clamp(rise * 0.62, 46, syMax);
4299
4628
  const t2 = clamp(rise * 0.14, 10, tMax);
4300
4629
  const span = width - NUM_W - GAP3 - colW;
4301
4630
  const sx = clamp(span * 0.2, 110, 220);
4302
- const height = 2 * pad + (count - 1) * rise + sy + t2;
4631
+ const height = 2 * pad + (count2 - 1) * rise + sy + t2;
4303
4632
  return {
4304
4633
  // A rise shorter than a label block prints the note through the label below.
4305
4634
  // BOTH directions. A layout that fits the height and not the width is not a
@@ -4324,7 +4653,7 @@ function solve2(p, format, inline2, face) {
4324
4653
  labelSize,
4325
4654
  blockH,
4326
4655
  pad,
4327
- yBase: pad + (count - 1) * rise + sy,
4656
+ yBase: pad + (count2 - 1) * rise + sy,
4328
4657
  lines
4329
4658
  };
4330
4659
  }
@@ -4341,11 +4670,11 @@ var stack = (beat, ctx) => {
4341
4670
  const p = beat.params;
4342
4671
  const face = faceOf(ctx.theme.fontStack);
4343
4672
  const L = stackLayout(p, ctx.format, face);
4344
- const count = p.layers.length;
4345
- const last = count - 1;
4673
+ const count2 = p.layers.length;
4674
+ const last = count2 - 1;
4346
4675
  if (!L.fits) {
4347
4676
  throw new Error(
4348
- `stack ${beat.id}: ${count} layers with ${p.layers.filter((l) => l.note).length} note(s) need ${Math.round(L.blockH)}px of label block against ${Math.round(L.avail)}px of room, at the ${Math.round(L.floor)}px floor and with the notes already moved beside their labels. Nothing here may be set smaller, so the lever is upstream of this beat: drop a layer, or shorten the notes.`
4677
+ `stack ${beat.id}: ${count2} layers with ${p.layers.filter((l) => l.note).length} note(s) need ${Math.round(L.blockH)}px of label block against ${Math.round(L.avail)}px of room, at the ${Math.round(L.floor)}px floor and with the notes already moved beside their labels. Nothing here may be set smaller, so the lever is upstream of this beat: drop a layer, or shorten the notes.`
4349
4678
  );
4350
4679
  }
4351
4680
  const parts = {};
@@ -4373,7 +4702,7 @@ var stack = (beat, ctx) => {
4373
4702
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
4374
4703
  {
4375
4704
  size: L.labelSize,
4376
- weight: labelWeight(i, count),
4705
+ weight: labelWeight(i, count2),
4377
4706
  fill: top ? theme.tones.b : theme.fg,
4378
4707
  maxWidth: block.labelMaxW,
4379
4708
  lineHeight: 1.16,
@@ -4427,10 +4756,10 @@ var stack = (beat, ctx) => {
4427
4756
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4428
4757
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4429
4758
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe2)}</div>${noteHtml}`;
4430
- const centre2 = (count - 1) / 2;
4759
+ const centre2 = (count2 - 1) / 2;
4431
4760
  const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4432
4761
  const first = 0.9;
4433
- const step2 = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count));
4762
+ const step2 = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count2));
4434
4763
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
4435
4764
  const holds = [];
4436
4765
  const spot = spotlighter(sid);
@@ -4462,11 +4791,11 @@ var stack = (beat, ctx) => {
4462
4791
  );
4463
4792
  }
4464
4793
  if (p.note) {
4465
- const at = first + count * step2;
4794
+ const at = first + count2 * step2;
4466
4795
  tl.push(tween(`#${sid}-note`, { opacity: 0 }, { opacity: 1, duration: 0.6 }, at));
4467
4796
  holds.push(at + 0.7);
4468
4797
  }
4469
- if (count > 1) tl.push(...spot.restore(first + count * step2));
4798
+ if (count2 > 1) tl.push(...spot.restore(first + count2 * step2));
4470
4799
  return {
4471
4800
  html,
4472
4801
  parts,
@@ -5789,8 +6118,8 @@ async function narrate(storyboard, source, prefs, opts) {
5789
6118
  const text2 = beat.narration?.trim();
5790
6119
  if (!text2) continue;
5791
6120
  const segments2 = [];
5792
- const count = stopsFor(beat, source, format, `s${i + 1}`, theme);
5793
- const plan = planSegments(text2, Math.min(count, paced.speakingStops));
6121
+ const count2 = stopsFor(beat, source, format, `s${i + 1}`, theme);
6122
+ const plan = planSegments(text2, Math.min(count2, paced.speakingStops));
5794
6123
  for (const [stop, line2] of plan.entries()) {
5795
6124
  if (!line2) continue;
5796
6125
  const speech = await synthesize(line2, {
@@ -5812,7 +6141,7 @@ async function narrate(storyboard, source, prefs, opts) {
5812
6141
  }
5813
6142
  if (segments2.length > 0) {
5814
6143
  beats[beat.id] = segments2;
5815
- staged[beat.id] = count;
6144
+ staged[beat.id] = count2;
5816
6145
  }
5817
6146
  }
5818
6147
  return {
@@ -6707,11 +7036,11 @@ function stageScene(scene, speed) {
6707
7036
  function speechPlan(open, holds, segments2) {
6708
7037
  const starts = [];
6709
7038
  let at = open;
6710
- for (const [i, segment] of segments2.entries()) {
6711
- const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
7039
+ for (const [i, segment2] of segments2.entries()) {
7040
+ const hold = holds[Math.min(segment2.stop, holds.length - 1)] ?? 0;
6712
7041
  if (i > 0) at = Math.max(at, hold);
6713
7042
  starts.push(at);
6714
- at += segment.seconds;
7043
+ at += segment2.seconds;
6715
7044
  }
6716
7045
  return { starts, end: at };
6717
7046
  }
@@ -8206,8 +8535,13 @@ function renderSource(source) {
8206
8535
  }
8207
8536
  }
8208
8537
  out.push("", "== EQUATIONS ==");
8209
- for (const e of source.equations)
8210
- out.push(`[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${e.tex}`);
8538
+ for (const e of source.equations) {
8539
+ const fixed = repairTex(e.tex, true);
8540
+ const note2 = fixed.error === null ? "" : " (KaTeX cannot draw this one: a walk shows it as plain source, a morph cannot use it)";
8541
+ out.push(
8542
+ `[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${fixed.error === null ? fixed.tex : e.tex}${note2}`
8543
+ );
8544
+ }
8211
8545
  if (!source.equations.length)
8212
8546
  out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
8213
8547
  out.push("", "== TABLES ==");
@@ -8771,10 +9105,10 @@ function drawSvg(req) {
8771
9105
  const seed = createHash5("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
8772
9106
  const rand = mulberry32(seed.readUInt32BE(0));
8773
9107
  const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
8774
- const count = 6 + Math.floor(rand() * 5);
9108
+ const count2 = 6 + Math.floor(rand() * 5);
8775
9109
  const unit = Math.min(W, H);
8776
9110
  const parts = [`<rect width="${W}" height="${H}" fill="${PAPER}"/>`];
8777
- for (let i = 0; i < count; i++) {
9111
+ for (let i = 0; i < count2; i++) {
8778
9112
  const kind = Math.floor(rand() * 3);
8779
9113
  const cx = Math.round(W * (0.15 + 0.7 * rand()));
8780
9114
  const cy = Math.round(H * (0.15 + 0.7 * rand()));
@@ -9199,6 +9533,22 @@ async function resolveChrome(need = "open the deck with") {
9199
9533
  async function chromePath(need = "open the deck with") {
9200
9534
  return (await resolveChrome(need)).path;
9201
9535
  }
9536
+ function sandboxMissing(err) {
9537
+ return /No usable sandbox/.test(err instanceof Error ? err.message : String(err));
9538
+ }
9539
+ var SANDBOX_FIX = "`sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, or give Chrome an AppArmor profile";
9540
+ async function launchOwnPage(launch, args, what) {
9541
+ try {
9542
+ return await launch(args);
9543
+ } catch (err) {
9544
+ if (!sandboxMissing(err)) throw err;
9545
+ process.stderr.write(
9546
+ `chrome: this host gives Chrome no sandbox, so ${what} runs without one, as hyperframes' render always does. To keep the sandbox: ${SANDBOX_FIX}.
9547
+ `
9548
+ );
9549
+ return launch([...args, "--no-sandbox"]);
9550
+ }
9551
+ }
9202
9552
  function runtimePath() {
9203
9553
  return createRequire3(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
9204
9554
  }
@@ -9216,10 +9566,9 @@ async function openDeck(dir, opts = {}) {
9216
9566
  const timeout = opts.timeoutMs ?? 6e4;
9217
9567
  const { default: puppeteer } = await import("puppeteer-core");
9218
9568
  const chrome2 = await resolveChrome();
9219
- const browser = await puppeteer.launch({
9220
- executablePath: chrome2.path,
9221
- headless: true,
9222
- args: [
9569
+ const browser = await launchOwnPage(
9570
+ (args) => puppeteer.launch({ executablePath: chrome2.path, headless: true, args }),
9571
+ [
9223
9572
  // A retina host would otherwise hand back a 2x frame, whose clip is not the
9224
9573
  // renderer's.
9225
9574
  "--force-device-scale-factor=1",
@@ -9232,8 +9581,9 @@ async function openDeck(dir, opts = {}) {
9232
9581
  "--use-gl=angle",
9233
9582
  "--use-angle=swiftshader",
9234
9583
  "--enable-unsafe-swiftshader"
9235
- ]
9236
- });
9584
+ ],
9585
+ "the gates' page"
9586
+ );
9237
9587
  try {
9238
9588
  const page = await browser.newPage();
9239
9589
  await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
@@ -9336,10 +9686,10 @@ function pack(words2, width) {
9336
9686
  function splitCue(cue, max = CUE_MAX_CHARS) {
9337
9687
  if (cue.text.length <= max) return [cue];
9338
9688
  const words2 = cue.text.split(/\s+/).filter(Boolean);
9339
- const count = pack(words2, max).length;
9689
+ const count2 = pack(words2, max).length;
9340
9690
  let width = max;
9341
- for (let w = Math.ceil(cue.text.length / count); w < max; w++) {
9342
- if (pack(words2, w).length <= count) {
9691
+ for (let w = Math.ceil(cue.text.length / count2); w < max; w++) {
9692
+ if (pack(words2, w).length <= count2) {
9343
9693
  width = w;
9344
9694
  break;
9345
9695
  }
@@ -9431,17 +9781,17 @@ function place(scenes, spoken) {
9431
9781
  );
9432
9782
  }
9433
9783
  const { starts } = speechPlan(scene.open, scene.holds, segments2);
9434
- for (const [i, segment] of segments2.entries()) {
9435
- const index = Math.min(segment.stop, scene.holds.length - 1);
9784
+ for (const [i, segment2] of segments2.entries()) {
9785
+ const index = Math.min(segment2.stop, scene.holds.length - 1);
9436
9786
  out.push({
9437
- id: `${scene.id}.${segment.stop}`,
9787
+ id: `${scene.id}.${segment2.stop}`,
9438
9788
  scene: scene.id,
9439
- stop: segment.stop,
9440
- audio: segment.audio,
9789
+ stop: segment2.stop,
9790
+ audio: segment2.audio,
9441
9791
  hold: round5(scene.start + scene.holds[index]),
9442
9792
  start: round5(scene.start + starts[i]),
9443
- duration: segment.seconds,
9444
- cues: segment.cues.flatMap((c) => splitCue(c, cueMax(c.text)))
9793
+ duration: segment2.seconds,
9794
+ cues: segment2.cues.flatMap((c) => splitCue(c, cueMax(c.text)))
9445
9795
  });
9446
9796
  }
9447
9797
  }
@@ -9518,6 +9868,15 @@ function planTiming(input) {
9518
9868
  // `emitDeck` and `writeTiming` are handed the same `format`, so this cannot
9519
9869
  // disagree with the box the archetypes actually drew into.
9520
9870
  captionReserve: format.captionReserve ?? 0,
9871
+ // An unbudgeted format's limit is Infinity, which JSON writes as `null`.
9872
+ // Left out instead: recorded as a format with no limit, never with a limit
9873
+ // of null or zero.
9874
+ format: {
9875
+ id: format.id,
9876
+ minWeight: format.minWeight,
9877
+ ...Number.isFinite(format.maxSeconds) && { maxSeconds: format.maxSeconds },
9878
+ ...format.warnSeconds !== void 0 && { warnSeconds: format.warnSeconds }
9879
+ },
9521
9880
  scenes,
9522
9881
  segments: segments2
9523
9882
  };
@@ -9544,15 +9903,15 @@ function framePlan(timing, fps) {
9544
9903
  );
9545
9904
  }
9546
9905
  pieces.push({ from: first, motion: last - first, freeze: 0 });
9547
- for (const segment of segments2) {
9548
- const at = f(segment.start);
9906
+ for (const segment2 of segments2) {
9907
+ const at = f(segment2.start);
9549
9908
  audio.push({
9550
- id: segment.id,
9551
- audio: segment.audio,
9909
+ id: segment2.id,
9910
+ audio: segment2.audio,
9552
9911
  startFrame: at,
9553
9912
  delayMs: Math.round(at / fps * 1e3)
9554
9913
  });
9555
- for (const cue of segment.cues) {
9914
+ for (const cue of segment2.cues) {
9556
9915
  const start = at / fps + cue.start;
9557
9916
  const end = at / fps + cue.end;
9558
9917
  if (end > start) cues.push({ start, end, text: cue.text });
@@ -9671,7 +10030,7 @@ ${bands}
9671
10030
  </div>
9672
10031
  `;
9673
10032
  }
9674
- function measure3(count) {
10033
+ function measure3(count2) {
9675
10034
  const doc = document;
9676
10035
  const probe2 = doc.getElementById("c0");
9677
10036
  if (probe2) {
@@ -9684,7 +10043,7 @@ function measure3(count) {
9684
10043
  probe2.classList.remove("on");
9685
10044
  }
9686
10045
  const rects = [];
9687
- for (let i = 0; i < count; i++) {
10046
+ for (let i = 0; i < count2; i++) {
9688
10047
  const el = doc.getElementById(`c${i}`);
9689
10048
  if (!el) continue;
9690
10049
  el.classList.add("on");
@@ -9712,11 +10071,12 @@ async function renderCaptions(cues, style, deck, work) {
9712
10071
  await mkdir8(work, { recursive: true });
9713
10072
  await writeFile10(page, captionPage(cues, style, href));
9714
10073
  const { default: puppeteer } = await import("puppeteer-core");
9715
- const browser = await puppeteer.launch({
9716
- executablePath: await chromePath(CAPTION_NEED),
9717
- headless: true,
9718
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
9719
- });
10074
+ const executablePath = await chromePath(CAPTION_NEED);
10075
+ const browser = await launchOwnPage(
10076
+ (args) => puppeteer.launch({ executablePath, headless: true, args }),
10077
+ ["--force-device-scale-factor=1", "--hide-scrollbars"],
10078
+ "the caption band"
10079
+ );
9720
10080
  try {
9721
10081
  const tab = await browser.newPage();
9722
10082
  await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
@@ -10633,6 +10993,14 @@ async function readInBrowser(html, timeoutMs) {
10633
10993
  // happens to be that run.
10634
10994
  "--host-resolver-rules=MAP * ~NOTFOUND"
10635
10995
  ]
10996
+ // No `--no-sandbox` fallback here, unlike `launchOwnPage`: this is a page
10997
+ // from the web, and a renderer bug it triggers is exactly what the sandbox
10998
+ // contains. Refused, with the fix, rather than Chrome's raw FATAL.
10999
+ }).catch((err) => {
11000
+ if (!sandboxMissing(err)) throw err;
11001
+ throw new Error(
11002
+ `Chrome has no usable sandbox on this host, and a page from the web is not opened without one. ${SANDBOX_FIX}, then ingest again \u2014 or save the page and ingest the file.`
11003
+ );
10636
11004
  });
10637
11005
  try {
10638
11006
  const page = await browser.newPage();
@@ -11666,10 +12034,10 @@ function readCanvas(html) {
11666
12034
  function profilesFor(width, height, formats = FORMATS) {
11667
12035
  return Object.values(formats).filter((f) => f.width === width && f.height === height);
11668
12036
  }
11669
- function scanBudget(html, storyboard, kept, formats = FORMATS) {
12037
+ function scanBudget(html, storyboard, kept, formats = FORMATS, recorded) {
11670
12038
  const canvas = readCanvas(html);
11671
12039
  if (!canvas) return [];
11672
- const profiles = profilesFor(canvas.width, canvas.height, formats);
12040
+ const profiles = recorded && recorded.width === canvas.width && recorded.height === canvas.height ? [recorded] : profilesFor(canvas.width, canvas.height, formats);
11673
12041
  if (profiles.length === 0) {
11674
12042
  return [
11675
12043
  {
@@ -12394,6 +12762,20 @@ function captionBottom(sid, selector, fallbackPx) {
12394
12762
  }
12395
12763
  return bottom > 0 ? bottom : fallbackPx;
12396
12764
  }
12765
+ function collectUnfit(attr) {
12766
+ return Array.from(document.querySelectorAll(`[${attr}]`)).map((el) => ({
12767
+ sid: el.closest("[data-composition-id]")?.getAttribute("data-composition-id") ?? "?",
12768
+ tex: el.getAttribute(attr) ?? ""
12769
+ }));
12770
+ }
12771
+ function gradeUnfit(rows) {
12772
+ return rows.map((r) => ({
12773
+ severity: "error",
12774
+ gate: "fidelity",
12775
+ rule: "math_unfit",
12776
+ message: `${r.sid}: the equation does not fit its box even at the ${TYPE_FLOOR_PX}px floor broken across lines \u2014 ${JSON.stringify(r.tex)}. Shorten it in the source, or walk it across two beats.`
12777
+ }));
12778
+ }
12397
12779
  async function fidelity(dir, opts = {}) {
12398
12780
  const started = Date.now();
12399
12781
  const floor = opts.floor ?? INK_FLOOR;
@@ -12417,6 +12799,10 @@ async function fidelity(dir, opts = {}) {
12417
12799
  try {
12418
12800
  deck = await openDeck(dir, { timeoutMs: opts.timeoutMs });
12419
12801
  const { page, height } = deck;
12802
+ await page.waitForFunction("window.__hfTimelinesBuilding === false", {
12803
+ timeout: opts.timeoutMs ?? 6e4
12804
+ });
12805
+ const unfit = await page.evaluate(collectUnfit, UNFIT_ATTR);
12420
12806
  const measured = [];
12421
12807
  const collided = [];
12422
12808
  const apparent = [];
@@ -12460,7 +12846,8 @@ async function fidelity(dir, opts = {}) {
12460
12846
  ...gradeFidelity(measured, floor),
12461
12847
  ...gradeReserve(measured),
12462
12848
  ...gradeOverprint(collided),
12463
- ...gradeApparent(apparent)
12849
+ ...gradeApparent(apparent),
12850
+ ...gradeUnfit(unfit)
12464
12851
  ],
12465
12852
  elapsedMs: Date.now() - started
12466
12853
  };
@@ -12807,9 +13194,13 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
12807
13194
  await readFile17(join17(dir, DECK_PAGE), "utf8").catch(() => ""),
12808
13195
  await listFiles(dir)
12809
13196
  );
12810
- const budget3 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
13197
+ const timing = await readTiming2(dir);
13198
+ const recorded = typeof timing?.format?.id === "string" ? { ...timing.format, width: timing.width, height: timing.height } : void 0;
13199
+ const budget3 = html.flatMap(
13200
+ ([, text2]) => scanBudget(text2, storyboard, kept, void 0, recorded)
13201
+ );
12811
13202
  const type = html.flatMap(([file, text2]) => scanTypeFloor(text2, file));
12812
- const lead = kept ? await readTiming2(dir).then((t2) => t2 ? scanNarrationLead(kept, t2) : []) : [];
13203
+ const lead = kept && timing ? scanNarrationLead(kept, timing) : [];
12813
13204
  const ours = [...determinism, ...narration, ...budget3, ...type, ...lead];
12814
13205
  const stops = await declaredStops(dir);
12815
13206
  const [verdict, frames2] = await Promise.all([
@@ -12998,6 +13389,31 @@ function scanUnusedFigures(storyboard, source) {
12998
13389
  }
12999
13390
  ];
13000
13391
  }
13392
+ function scanMath(storyboard, source) {
13393
+ const out = [];
13394
+ const say = (rule, message2) => {
13395
+ out.push({ severity: "warning", gate: "storyboard", rule, message: message2 });
13396
+ };
13397
+ for (const beat of storyboard.beats) {
13398
+ try {
13399
+ let notes = [];
13400
+ if (beat.archetype === "equation-walk") {
13401
+ const params = beat.params;
13402
+ const eq = source.equations.find((e) => e.id === params.equationId);
13403
+ if (eq) notes = prepareWalk(eq, params.terms, beat.id).notes;
13404
+ } else if (beat.archetype === "equation-morph") {
13405
+ notes = prepareMorph(beat, source).notes;
13406
+ }
13407
+ for (const n3 of notes) say("tex_repaired", `${beat.id}: ${n3}`);
13408
+ } catch (err) {
13409
+ say(
13410
+ "tex_unrenderable",
13411
+ `${beat.id}: ${err instanceof Error ? err.message : String(err)} \u2014 build will leave this beat out`
13412
+ );
13413
+ }
13414
+ }
13415
+ return out;
13416
+ }
13001
13417
  function figuresShown2(params) {
13002
13418
  const sides = ["left", "right"].map(
13003
13419
  (side) => params[side]?.figureId
@@ -13079,14 +13495,14 @@ function scanNarrationLead(beats, timing) {
13079
13495
  return findings;
13080
13496
  }
13081
13497
  function firstMention(segments2, label) {
13082
- for (const segment of segments2) {
13083
- for (const cue of segment.cues) {
13498
+ for (const segment2 of segments2) {
13499
+ for (const cue of segment2.cues) {
13084
13500
  const words2 = cue.text.split(/\s+/);
13085
13501
  let at = 0;
13086
13502
  for (const word of words2) {
13087
13503
  if (mentions(word, label)) {
13088
13504
  const share2 = cue.text.length > 0 ? at / cue.text.length : 0;
13089
- return segment.start + cue.start + share2 * (cue.end - cue.start);
13505
+ return segment2.start + cue.start + share2 * (cue.end - cue.start);
13090
13506
  }
13091
13507
  at += word.length + 1;
13092
13508
  }
@@ -13354,7 +13770,11 @@ imageFlags(
13354
13770
  ...scanRepeatedObject(storyboard),
13355
13771
  // A figure the plan ignored is cheapest to fix here, where the answer is one
13356
13772
  // more beat in a file the author already has open.
13357
- ...scanUnusedFigures(storyboard, source)
13773
+ ...scanUnusedFigures(storyboard, source),
13774
+ // Every formula the deck will draw, parsed here by the KaTeX it vendors. A
13775
+ // formula the browser could not parse used to be found at `verify`, after
13776
+ // the build and the narration had been spent on it.
13777
+ ...scanMath(storyboard, source)
13358
13778
  ])
13359
13779
  step(`plan: ${f.message}`);
13360
13780
  const script = storyboard.beats.map((b) => b.narration?.trim()).filter(Boolean).join(" ");
@@ -13700,12 +14120,12 @@ async function gate(dir, snapshots = false, storyboard, kept, fidelity2 = true,
13700
14120
  if (!verdict.passed) process.exitCode = 1;
13701
14121
  }
13702
14122
  function report(v) {
13703
- const count = (s) => v.findings.filter((f) => f.severity === s).length;
14123
+ const count2 = (s) => v.findings.filter((f) => f.severity === s).length;
13704
14124
  const lines = v.findings.map(
13705
14125
  (f) => ` ${f.severity.padEnd(7)} ${f.gate.padEnd(11)} ${f.rule} ${f.message}`
13706
14126
  );
13707
14127
  lines.push(
13708
- `${v.passed ? "PASS" : "FAIL"} \u2014 ${count("error")} error(s), ${count("warning")} warning(s)`
14128
+ `${v.passed ? "PASS" : "FAIL"} \u2014 ${count2("error")} error(s), ${count2("warning")} warning(s)`
13709
14129
  );
13710
14130
  return `${lines.join("\n")}
13711
14131
  `;