@jokerized/decksmith 0.6.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
@@ -157,6 +157,11 @@ async function copyAudio(from, narration, out, step2) {
157
157
  return names.map((n3) => join2(dir, n3));
158
158
  }
159
159
  async function refreshFont(storyboard, source, out, step2) {
160
+ if (familyFor(storyboard.lang) === null) {
161
+ const css = await vendorInter(join2(out, "assets", "fonts"));
162
+ step2("build: vendored Inter beside the deck");
163
+ return css;
164
+ }
160
165
  try {
161
166
  const bundle = await bundleFont(
162
167
  storyboard.lang,
@@ -172,6 +177,21 @@ async function refreshFont(storyboard, source, out, step2) {
172
177
  return void 0;
173
178
  }
174
179
  }
180
+ async function vendorInter(dir) {
181
+ const pkg = dirname(createRequire(import.meta.url).resolve("@fontsource-variable/inter"));
182
+ const css = await readFile2(join2(pkg, "index.css"), "utf8");
183
+ await mkdir2(dir, { recursive: true });
184
+ for (const [, name] of css.matchAll(/url\(\.\/files\/([^)]+)\)/g)) {
185
+ await cp(join2(pkg, "files", name ?? ""), join2(dir, name ?? ""));
186
+ }
187
+ const ours = css.replaceAll("font-family: 'Inter Variable'", "font-family: 'Inter'").replaceAll("font-display: swap", "font-display: block").replace(/url\(\.\/files\/([^)]+)\) format\('woff2-variations'\)/g, "url($1) format('woff2')");
188
+ if (/Inter Variable|\.\/files\/|swap/.test(ours) || !ours.includes("font-family: 'Inter';")) {
189
+ throw new Error(
190
+ `@fontsource-variable/inter's index.css changed shape; update vendorInter (${pkg})`
191
+ );
192
+ }
193
+ return ours;
194
+ }
175
195
 
176
196
  // src/emit/kit.ts
177
197
  var PAD_X = 110;
@@ -849,35 +869,35 @@ function elbow(sid, from, to, o) {
849
869
  }
850
870
  return path(d, { ...strokeAttrs(o), "marker-end": `url(#${headId(sid, o.stroke)})` });
851
871
  }
852
- function tracks(width, count, gap, x0 = 0) {
853
- const w = count > 1 ? (width - gap * (count - 1)) / count : width;
854
- 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 }));
855
875
  }
856
876
  function fitBoxes(req) {
857
- const count = req.labels.length;
877
+ const count2 = req.labels.length;
858
878
  const minGap = req.minGap ?? 24;
859
879
  const padEm = req.padEm ?? 0.8;
860
880
  const weight = req.weight ?? 600;
861
881
  const x0 = req.x0 ?? 0;
862
882
  const unit = Math.max(1, ...req.labels.map((l) => textWidth(l, 1, weight) + 2 * padEm));
863
- 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;
864
884
  if (sizeAt(req.gap) >= req.size) {
865
- 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) };
866
886
  }
867
- if (count > 1 && sizeAt(minGap) >= req.size) {
868
- const gap = (req.width - count * req.size * unit) / (count - 1);
869
- 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) };
870
890
  }
871
891
  const size3 = sizeAt(minGap);
872
892
  if (size3 >= MIN_FONT) {
873
- 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) };
874
894
  }
875
895
  return {
876
896
  ok: false,
877
897
  size: MIN_FONT,
878
898
  gap: minGap,
879
- boxes: tracks(req.width, count, minGap, x0),
880
- 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)
881
901
  };
882
902
  }
883
903
 
@@ -1210,8 +1230,8 @@ var MID_GAP = 56;
1210
1230
  var BAND_GAP = 34;
1211
1231
  var COL_GAP = 28;
1212
1232
  var MIN_COL = 250;
1213
- function lineBudget(count, stageH) {
1214
- const perSide = Math.max(1, Math.ceil(count / 2));
1233
+ function lineBudget(count2, stageH) {
1234
+ const perSide = Math.max(1, Math.ceil(count2 / 2));
1215
1235
  const room = (stageH - (perSide - 1) * LAB_GAP) / perSide;
1216
1236
  const lines = Math.floor((room - ASCENT - RULE_LIFT - RULE_W) / (LAB * LAB_LH)) + 1;
1217
1237
  return Math.min(LINE_CEIL, Math.max(1, lines));
@@ -1622,7 +1642,7 @@ var HEAD_GAP = 14;
1622
1642
  var barCompare = (beat, ctx) => {
1623
1643
  const { sid, theme } = ctx;
1624
1644
  const p = beat.params;
1625
- const count = p.bars.length;
1645
+ const count2 = p.bars.length;
1626
1646
  const W = contentW(ctx.format);
1627
1647
  const face = faceOf(ctx.theme.fontStack);
1628
1648
  const tall = isPortrait(ctx.format);
@@ -1636,7 +1656,7 @@ var barCompare = (beat, ctx) => {
1636
1656
  0,
1637
1657
  face
1638
1658
  );
1639
- 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)));
1640
1660
  let pitch = bar * (1 + GAP_RATIO);
1641
1661
  const gutterMax = tall ? 0 : share(GUTTER_MAX, W);
1642
1662
  const gutterInner = tall ? W : gutterMax - GUTTER_PAD;
@@ -1658,7 +1678,7 @@ var barCompare = (beat, ctx) => {
1658
1678
  if (tall) {
1659
1679
  bar = Math.min(
1660
1680
  BAR_MAX,
1661
- (avail - unitBand - FOOT - count * head) / (count + GAP_RATIO * (count - 1))
1681
+ (avail - unitBand - FOOT - count2 * head) / (count2 + GAP_RATIO * (count2 - 1))
1662
1682
  );
1663
1683
  pitch = head + bar * (1 + GAP_RATIO);
1664
1684
  } else {
@@ -1669,7 +1689,7 @@ var barCompare = (beat, ctx) => {
1669
1689
  }
1670
1690
  }
1671
1691
  const valueSize = Math.max(MIN_FONT, Math.min(VALUE_MAX, bar * 0.9));
1672
- const barsH = (count - 1) * pitch + head + bar;
1692
+ const barsH = (count2 - 1) * pitch + head + bar;
1673
1693
  const foot = Math.max(
1674
1694
  0,
1675
1695
  Math.ceil(
@@ -1682,7 +1702,7 @@ var barCompare = (beat, ctx) => {
1682
1702
  const H = barsH + unitBand + foot;
1683
1703
  if (H > avail) {
1684
1704
  throw new Error(
1685
- `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.`
1686
1706
  );
1687
1707
  }
1688
1708
  const gutter = tall ? 0 : Math.min(
@@ -1835,7 +1855,7 @@ ${svg(id(sid, "chart"), W, H, body)}
1835
1855
  </div>${note2}`;
1836
1856
  const railsAt = 0.6;
1837
1857
  const barsAt = 0.95;
1838
- const step2 = Math.min(0.4, 2.4 / count);
1858
+ const step2 = Math.min(0.4, 2.4 / count2);
1839
1859
  const grow = 0.85;
1840
1860
  const tl = [
1841
1861
  ...chromeIn(sid, p.eyebrow !== void 0),
@@ -1885,11 +1905,11 @@ ${svg(id(sid, "chart"), W, H, body)}
1885
1905
  );
1886
1906
  }
1887
1907
  }
1888
- const settled = barsAt + (count - 1) * step2 + grow + 0.05;
1908
+ const settled = barsAt + (count2 - 1) * step2 + grow + 0.05;
1889
1909
  const holds = [settled + 0.2];
1890
1910
  const focal = rows.reduce((best, r) => Math.abs(r.value) > Math.abs(best.value) ? r : best);
1891
1911
  const spot = spotlighter(sid, ".bc-bar");
1892
- 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));
1893
1913
  const tailAt = settled + 0.3;
1894
1914
  if (p.unit) {
1895
1915
  tl.push(tween(`#${id(sid, "unit")}`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, tailAt));
@@ -2382,6 +2402,90 @@ var dataTable = (beat, ctx) => {
2382
2402
  };
2383
2403
  };
2384
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
+
2385
2489
  // src/emit/archetypes/equation-walk.ts
2386
2490
  var TRUST = 'function (c) { return c.command === "\\\\htmlClass"; }';
2387
2491
  var OPTS = `{ displayMode: true, trust: ${TRUST}, strict: false, output: "html" }`;
@@ -2396,6 +2500,9 @@ var SYNONYMS = [
2396
2500
  function normalise(tex) {
2397
2501
  let out = "";
2398
2502
  const map = [];
2503
+ const ends = [];
2504
+ const lead = [];
2505
+ let hint = null;
2399
2506
  for (let i = 0; i < tex.length; ) {
2400
2507
  if (/\s/.test(tex[i])) {
2401
2508
  i++;
@@ -2407,19 +2514,19 @@ function normalise(tex) {
2407
2514
  const m = new RegExp(`^(?:${re.source})`).exec(rest);
2408
2515
  return m ? { len: m[0].length, to } : null;
2409
2516
  }).find(Boolean);
2410
- if (hit) {
2411
- for (const ch of hit.to) {
2412
- out += ch;
2413
- map.push(i);
2414
- }
2415
- i += hit.len;
2416
- 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;
2417
2526
  }
2418
- out += tex[i];
2419
- map.push(i);
2420
- i++;
2527
+ i += len;
2421
2528
  }
2422
- return { text: out, map };
2529
+ return { text: out, map, ends, lead };
2423
2530
  }
2424
2531
  function locate(tex, term) {
2425
2532
  const hay = normalise(tex);
@@ -2427,58 +2534,131 @@ function locate(tex, term) {
2427
2534
  if (needle === "") return null;
2428
2535
  const at = hay.text.indexOf(needle);
2429
2536
  if (at < 0) return null;
2430
- const start = hay.map[at];
2431
- const lastNorm = at + needle.length - 1;
2432
- const lastOrig = hay.map[lastNorm];
2433
- let end = lastOrig + 1;
2434
- while (end < tex.length && (hay.map[lastNorm + 1] ?? tex.length) > end && /\s/.test(tex[end])) {
2435
- end++;
2436
- }
2437
- 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];
2438
2539
  return { start, end: Math.min(end, tex.length) };
2439
2540
  }
2440
2541
  function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
2542
+ const checked = texError(tex, true) === null;
2441
2543
  let parts = [{ text: tex, raw: true }];
2442
2544
  const used = [];
2443
2545
  const missing = [];
2546
+ const broken = [];
2547
+ const joined = (ps) => ps.map((p) => p.text).join("");
2444
2548
  for (const term of terms) {
2445
2549
  let placed = false;
2550
+ let found = false;
2446
2551
  for (let i = 0; i < parts.length && !placed; i++) {
2447
2552
  const part = parts[i];
2448
2553
  if (!part.raw) continue;
2449
2554
  const at = locate(part.text, term.tex);
2450
2555
  if (!at) continue;
2451
- parts = parts.toSpliced(
2452
- i,
2453
- 1,
2454
- { text: part.text.slice(0, at.start), raw: true },
2455
- {
2456
- text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
2457
- raw: false
2458
- },
2459
- { text: part.text.slice(at.end), raw: true }
2460
- );
2461
- used.push(term);
2462
- 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);
2463
2576
  }
2464
- if (!placed) missing.push(term);
2465
2577
  }
2466
2578
  if (used.length === 0) {
2467
2579
  throw new Error(
2468
- `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)}`
2469
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
+ };
2470
2612
  }
2471
- 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)}';`;
2472
2652
  }
2473
2653
  function equationSize(tex) {
2474
2654
  return tex.length > 120 ? 68 : tex.length > 90 ? 80 : tex.length > 55 ? 92 : 108;
2475
2655
  }
2476
- 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;
2477
2657
  var GLYPH_EM = 0.9;
2478
2658
  function texUnits(tex) {
2479
2659
  let ems = 0;
2480
2660
  for (const m of tex.matchAll(/\\qquad|\\quad/g)) ems += m[0] === "\\qquad" ? 2 : 1;
2481
- 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, "");
2482
2662
  return ems + glyphs2.length * GLYPH_EM;
2483
2663
  }
2484
2664
  function statements(tex, stacked) {
@@ -2486,6 +2666,100 @@ function statements(tex, stacked) {
2486
2666
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
2487
2667
  return parts.length > 0 ? parts : [tex];
2488
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
+ }
2489
2763
  function legendRows(sid, terms, theme) {
2490
2764
  return terms.map(
2491
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>`
@@ -2501,7 +2775,10 @@ function legendCss(theme) {
2501
2775
  `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
2502
2776
  // A common chip width, so the labels share a spine too — the glyphs inside
2503
2777
  // are one symbol each and their natural widths differ by a few pixels.
2504
- `.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}`
2505
2782
  ].join("\n");
2506
2783
  }
2507
2784
  var equationWalk = (beat, ctx) => {
@@ -2513,37 +2790,53 @@ var equationWalk = (beat, ctx) => {
2513
2790
  `equation-walk ${beat.id}: no equation "${p.equationId}" in source ${ctx.source.id}`
2514
2791
  );
2515
2792
  }
2516
- const walk = wrapTerms(eq.tex, p.terms, beat.id);
2793
+ const walk = prepareWalk(eq, p.terms, beat.id);
2517
2794
  const terms = walk.used;
2518
2795
  const legend = legendRows(sid, terms, theme);
2519
- const stacked = isPortrait(ctx.format);
2520
- const raw2 = statements(eq.tex, stacked);
2521
- const shown = statements(walk.tex, stacked);
2522
- const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
2523
- const size3 = Math.max(
2524
- MIN_FONT,
2525
- Math.min(
2526
- equationSize(longest),
2527
- Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits)))
2528
- )
2529
- );
2530
- 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
+ }
2531
2833
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
2532
2834
  <div class="eqslide">
2533
- <div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size3}px">${body}</div>
2835
+ ${eqHtml}
2534
2836
  <div class="legend">
2535
2837
  ${legend}
2536
2838
  </div>
2537
2839
  </div>`;
2538
- const setup = [
2539
- `var OPTS = ${OPTS};`,
2540
- ...shown.map(
2541
- (part, i) => `katex.render('${js(part)}', document.getElementById("${sid}-eq${shown.length === 1 ? "" : i}"), OPTS);`
2542
- ),
2543
- ...terms.map(
2544
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2545
- )
2546
- ];
2547
2840
  const tl = [
2548
2841
  ...chromeIn(sid, p.eyebrow !== void 0),
2549
2842
  tween(`#${sid}-eq`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8)
@@ -2555,6 +2848,7 @@ var equationWalk = (beat, ctx) => {
2555
2848
  );
2556
2849
  const holds = [];
2557
2850
  const spot = spotlighter(sid, ".term");
2851
+ const swell = (t2) => raw(`dsSwell.${t2.tone}`);
2558
2852
  terms.forEach((term, i) => {
2559
2853
  const at = first + i * step2;
2560
2854
  const colour = theme.tones[term.tone];
@@ -2571,13 +2865,15 @@ var equationWalk = (beat, ctx) => {
2571
2865
  tween(
2572
2866
  `#${sid} .t-${term.tone}`,
2573
2867
  { color: theme.fg, scale: 1 },
2574
- { color: colour, scale: 1.16, duration: 0.5 },
2868
+ { color: colour, scale: swell(term), duration: 0.5 },
2575
2869
  at
2576
2870
  )
2577
2871
  );
2578
2872
  const prev = terms[i - 1];
2579
2873
  if (prev) {
2580
- 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
+ );
2581
2877
  }
2582
2878
  holds.push(at + 0.6);
2583
2879
  });
@@ -2585,7 +2881,7 @@ var equationWalk = (beat, ctx) => {
2585
2881
  if (last) {
2586
2882
  const at = first + terms.length * step2;
2587
2883
  tl.push(
2588
- 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),
2589
2885
  ...spot.restore(at)
2590
2886
  );
2591
2887
  }
@@ -2593,6 +2889,8 @@ var equationWalk = (beat, ctx) => {
2593
2889
  html,
2594
2890
  tl,
2595
2891
  setup,
2892
+ measure: measure4,
2893
+ ...walk.notes.length ? { warnings: walk.notes } : {},
2596
2894
  holds: holdsWithin(holds, beat.seconds),
2597
2895
  css: [
2598
2896
  chromeCss(theme),
@@ -2612,6 +2910,15 @@ var equationWalk = (beat, ctx) => {
2612
2910
  // has its margin zeroed above, so without an explicit gap the two lines
2613
2911
  // would touch and read as one equation broken mid-expression.
2614
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}",
2615
2922
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
2616
2923
  ".term{display:inline-block}",
2617
2924
  legendCss(theme),
@@ -2626,26 +2933,67 @@ var equationWalk = (beat, ctx) => {
2626
2933
 
2627
2934
  // src/emit/archetypes/equation-morph.ts
2628
2935
  var MORPH_SECONDS = 1.6;
2629
- var equationMorph = (beat, ctx) => {
2630
- const { sid, theme } = ctx;
2936
+ function prepareMorph(beat, source) {
2631
2937
  const p = beat.params;
2632
2938
  const find3 = (id2) => {
2633
- const eq = ctx.source.equations.find((e) => e.id === id2);
2939
+ const eq = source.equations.find((e) => e.id === id2);
2634
2940
  if (!eq)
2635
- 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}`);
2636
2942
  return eq;
2637
2943
  };
2638
- const a = find3(p.fromId);
2639
- const b = find3(p.toId);
2640
- const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
2641
- 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) {
2642
2961
  throw new Error(
2643
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)}`
2644
2963
  );
2645
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
+ );
2646
2970
  const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
2647
- const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
2648
- 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);
2649
2997
  const size3 = Math.max(
2650
2998
  MIN_FONT,
2651
2999
  Math.min(
@@ -2667,9 +3015,7 @@ var equationMorph = (beat, ctx) => {
2667
3015
  `var OPTS = ${OPTS};`,
2668
3016
  `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
2669
3017
  `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
2670
- ...both.map(
2671
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2672
- )
3018
+ ...both.map((t2) => chipSetup(sid, t2))
2673
3019
  ];
2674
3020
  const first = 1.8;
2675
3021
  const at = Math.round(
@@ -2702,8 +3048,11 @@ var equationMorph = (beat, ctx) => {
2702
3048
  setup,
2703
3049
  // SEAM B: the plan is browser geometry after fonts, so it is built inside
2704
3050
  // the ready gate, and the plugin tween above finds it on the host.
2705
- 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"));`],
2706
3054
  plugins: ["dsMorph"],
3055
+ ...notes.length ? { warnings: notes } : {},
2707
3056
  holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
2708
3057
  css: [
2709
3058
  chromeCss(theme),
@@ -3284,16 +3633,16 @@ var lineChart = (beat, ctx) => {
3284
3633
  </svg>${readout}
3285
3634
  </div>`;
3286
3635
  const draw2 = 0.8;
3287
- const count = p.points.length;
3288
- 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);
3289
3638
  const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
3290
- const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count - 1) : 0;
3639
+ const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count2 - 1) : 0;
3291
3640
  const tailAfter = (s) => Math.max(
3292
- s * count + 0.4,
3641
+ s * count2 + 0.4,
3293
3642
  // the ring: its walk, then the 0.4s it takes to leave
3294
- 0.3 + s * (count - 1),
3643
+ 0.3 + s * (count2 - 1),
3295
3644
  // the dots
3296
- 0.5 + s * (count - 1),
3645
+ 0.5 + s * (count2 - 1),
3297
3646
  ...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
3298
3647
  );
3299
3648
  const room = beat.seconds - spine;
@@ -3356,8 +3705,8 @@ var lineChart = (beat, ctx) => {
3356
3705
  )
3357
3706
  );
3358
3707
  }
3359
- const walk = cmp ? sec(step2 * count) : DRAW_SECONDS;
3360
- if (count > 1) {
3708
+ const walk = cmp ? sec(step2 * count2) : DRAW_SECONDS;
3709
+ if (count2 > 1) {
3361
3710
  const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
3362
3711
  tl.push(
3363
3712
  tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
@@ -3372,7 +3721,7 @@ var lineChart = (beat, ctx) => {
3372
3721
  )
3373
3722
  );
3374
3723
  }
3375
- const drawn = settled + (cmp ? tailAfter(step2) : step2 * count + 0.4);
3724
+ const drawn = settled + (cmp ? tailAfter(step2) : step2 * count2 + 0.4);
3376
3725
  const holds = cmp ? [lift2, drawn] : [drawn];
3377
3726
  if (p.readout) {
3378
3727
  tl.push(
@@ -3388,7 +3737,7 @@ var lineChart = (beat, ctx) => {
3388
3737
  return {
3389
3738
  ...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
3390
3739
  warnings: [
3391
- `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.`
3392
3741
  ]
3393
3742
  };
3394
3743
  }
@@ -3468,7 +3817,7 @@ var V_BOX_H = 268;
3468
3817
  var V_LOOP_SIDE = 56;
3469
3818
  var V_LOOP_PAD = 18;
3470
3819
  function columnLayout(stageW, stageH, stages, face, loop) {
3471
- const count = stages.length;
3820
+ const count2 = stages.length;
3472
3821
  const loopW = loop ? Math.max(240, Math.round(stageW * 0.32)) : 0;
3473
3822
  const boxX = M;
3474
3823
  const boxW = stageW - 2 * M - (loop ? V_LOOP_SIDE + V_LOOP_PAD + loopW : 0);
@@ -3485,11 +3834,11 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3485
3834
  return Math.ceil(2 * PAD_Y2 + label + (note2 > 0 ? NOTE_TOP + note2 * NOTE * NOTE_LH : 0));
3486
3835
  })
3487
3836
  );
3488
- const room = (stageH - 2 * M - (count - 1) * V_GAP) / count;
3837
+ const room = (stageH - 2 * M - (count2 - 1) * V_GAP) / count2;
3489
3838
  const boxH = Math.max(need, Math.min(room, V_BOX_H));
3490
3839
  return {
3491
3840
  size: size3,
3492
- 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 })),
3493
3842
  boxH,
3494
3843
  boxX,
3495
3844
  boxW,
@@ -3501,7 +3850,7 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3501
3850
  loopLines: loop ? wrap(loop.label, NOTE, loopW, 500, 0, face) : [],
3502
3851
  loopX: boxX + boxW + V_LOOP_SIDE,
3503
3852
  loopW,
3504
- svgH: 2 * M + count * boxH + (count - 1) * V_GAP
3853
+ svgH: 2 * M + count2 * boxH + (count2 - 1) * V_GAP
3505
3854
  };
3506
3855
  }
3507
3856
  function balance(label, k, face) {
@@ -4219,8 +4568,8 @@ function stackLayout(p, format, face = "latin") {
4219
4568
  if (inline2.fits) return inline2;
4220
4569
  return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
4221
4570
  }
4222
- function labelWeight(i, count) {
4223
- return i === count - 1 ? 700 : 600;
4571
+ function labelWeight(i, count2) {
4572
+ return i === count2 - 1 ? 700 : 600;
4224
4573
  }
4225
4574
  function floorFor(p, format) {
4226
4575
  if (!p.tilt) return MIN_FONT;
@@ -4230,7 +4579,7 @@ function solve2(p, format, inline2, face) {
4230
4579
  const width = contentW(format);
4231
4580
  const boxH = contentH(format);
4232
4581
  const floor = floorFor(p, format);
4233
- const count = p.layers.length;
4582
+ const count2 = p.layers.length;
4234
4583
  const k = isPortrait(format) ? "tall" : "wide";
4235
4584
  const riseMax = RISE_MAX[k];
4236
4585
  const syMax = SY_MAX[k];
@@ -4238,7 +4587,7 @@ function solve2(p, format, inline2, face) {
4238
4587
  const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4239
4588
  const want = Math.max(
4240
4589
  ...p.layers.map(
4241
- (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)
4242
4591
  )
4243
4592
  );
4244
4593
  const colCap = inline2 ? width * 0.56 : width * 0.5;
@@ -4247,7 +4596,7 @@ function solve2(p, format, inline2, face) {
4247
4596
  const labelX = width - colW;
4248
4597
  const labelRoom = Math.min(
4249
4598
  ...p.layers.map(
4250
- (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))
4251
4600
  )
4252
4601
  );
4253
4602
  const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
@@ -4255,7 +4604,7 @@ function solve2(p, format, inline2, face) {
4255
4604
  const nw = noteW(l);
4256
4605
  const labelMaxW = Math.max(labelSize, colW - nw);
4257
4606
  return {
4258
- label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4607
+ label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count2), 0, face),
4259
4608
  // Inline notes stay on one line by contract — the schema calls a note "one
4260
4609
  // short line" — and wrapping one would put its second line under the label.
4261
4610
  note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
@@ -4273,13 +4622,13 @@ function solve2(p, format, inline2, face) {
4273
4622
  const noteH = noteHeight(p.note, noteWidth(format), 28, face);
4274
4623
  const free = boxH - chromeH - 20 - noteH;
4275
4624
  const avail = Math.max(300, free);
4276
- 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);
4277
4626
  const rise = clamp(room, RISE_MIN, riseMax);
4278
4627
  const sy = clamp(rise * 0.62, 46, syMax);
4279
4628
  const t2 = clamp(rise * 0.14, 10, tMax);
4280
4629
  const span = width - NUM_W - GAP3 - colW;
4281
4630
  const sx = clamp(span * 0.2, 110, 220);
4282
- const height = 2 * pad + (count - 1) * rise + sy + t2;
4631
+ const height = 2 * pad + (count2 - 1) * rise + sy + t2;
4283
4632
  return {
4284
4633
  // A rise shorter than a label block prints the note through the label below.
4285
4634
  // BOTH directions. A layout that fits the height and not the width is not a
@@ -4304,7 +4653,7 @@ function solve2(p, format, inline2, face) {
4304
4653
  labelSize,
4305
4654
  blockH,
4306
4655
  pad,
4307
- yBase: pad + (count - 1) * rise + sy,
4656
+ yBase: pad + (count2 - 1) * rise + sy,
4308
4657
  lines
4309
4658
  };
4310
4659
  }
@@ -4321,11 +4670,11 @@ var stack = (beat, ctx) => {
4321
4670
  const p = beat.params;
4322
4671
  const face = faceOf(ctx.theme.fontStack);
4323
4672
  const L = stackLayout(p, ctx.format, face);
4324
- const count = p.layers.length;
4325
- const last = count - 1;
4673
+ const count2 = p.layers.length;
4674
+ const last = count2 - 1;
4326
4675
  if (!L.fits) {
4327
4676
  throw new Error(
4328
- `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.`
4329
4678
  );
4330
4679
  }
4331
4680
  const parts = {};
@@ -4353,7 +4702,7 @@ var stack = (beat, ctx) => {
4353
4702
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
4354
4703
  {
4355
4704
  size: L.labelSize,
4356
- weight: labelWeight(i, count),
4705
+ weight: labelWeight(i, count2),
4357
4706
  fill: top ? theme.tones.b : theme.fg,
4358
4707
  maxWidth: block.labelMaxW,
4359
4708
  lineHeight: 1.16,
@@ -4407,10 +4756,10 @@ var stack = (beat, ctx) => {
4407
4756
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4408
4757
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4409
4758
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe2)}</div>${noteHtml}`;
4410
- const centre2 = (count - 1) / 2;
4759
+ const centre2 = (count2 - 1) / 2;
4411
4760
  const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4412
4761
  const first = 0.9;
4413
- 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));
4414
4763
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
4415
4764
  const holds = [];
4416
4765
  const spot = spotlighter(sid);
@@ -4442,11 +4791,11 @@ var stack = (beat, ctx) => {
4442
4791
  );
4443
4792
  }
4444
4793
  if (p.note) {
4445
- const at = first + count * step2;
4794
+ const at = first + count2 * step2;
4446
4795
  tl.push(tween(`#${sid}-note`, { opacity: 0 }, { opacity: 1, duration: 0.6 }, at));
4447
4796
  holds.push(at + 0.7);
4448
4797
  }
4449
- if (count > 1) tl.push(...spot.restore(first + count * step2));
4798
+ if (count2 > 1) tl.push(...spot.restore(first + count2 * step2));
4450
4799
  return {
4451
4800
  html,
4452
4801
  parts,
@@ -5769,8 +6118,8 @@ async function narrate(storyboard, source, prefs, opts) {
5769
6118
  const text2 = beat.narration?.trim();
5770
6119
  if (!text2) continue;
5771
6120
  const segments2 = [];
5772
- const count = stopsFor(beat, source, format, `s${i + 1}`, theme);
5773
- 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));
5774
6123
  for (const [stop, line2] of plan.entries()) {
5775
6124
  if (!line2) continue;
5776
6125
  const speech = await synthesize(line2, {
@@ -5792,7 +6141,7 @@ async function narrate(storyboard, source, prefs, opts) {
5792
6141
  }
5793
6142
  if (segments2.length > 0) {
5794
6143
  beats[beat.id] = segments2;
5795
- staged[beat.id] = count;
6144
+ staged[beat.id] = count2;
5796
6145
  }
5797
6146
  }
5798
6147
  return {
@@ -6687,11 +7036,11 @@ function stageScene(scene, speed) {
6687
7036
  function speechPlan(open, holds, segments2) {
6688
7037
  const starts = [];
6689
7038
  let at = open;
6690
- for (const [i, segment] of segments2.entries()) {
6691
- 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;
6692
7041
  if (i > 0) at = Math.max(at, hold);
6693
7042
  starts.push(at);
6694
- at += segment.seconds;
7043
+ at += segment2.seconds;
6695
7044
  }
6696
7045
  return { starts, end: at };
6697
7046
  }
@@ -8186,8 +8535,13 @@ function renderSource(source) {
8186
8535
  }
8187
8536
  }
8188
8537
  out.push("", "== EQUATIONS ==");
8189
- for (const e of source.equations)
8190
- 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
+ }
8191
8545
  if (!source.equations.length)
8192
8546
  out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
8193
8547
  out.push("", "== TABLES ==");
@@ -8751,10 +9105,10 @@ function drawSvg(req) {
8751
9105
  const seed = createHash5("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
8752
9106
  const rand = mulberry32(seed.readUInt32BE(0));
8753
9107
  const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
8754
- const count = 6 + Math.floor(rand() * 5);
9108
+ const count2 = 6 + Math.floor(rand() * 5);
8755
9109
  const unit = Math.min(W, H);
8756
9110
  const parts = [`<rect width="${W}" height="${H}" fill="${PAPER}"/>`];
8757
- for (let i = 0; i < count; i++) {
9111
+ for (let i = 0; i < count2; i++) {
8758
9112
  const kind = Math.floor(rand() * 3);
8759
9113
  const cx = Math.round(W * (0.15 + 0.7 * rand()));
8760
9114
  const cy = Math.round(H * (0.15 + 0.7 * rand()));
@@ -9179,6 +9533,22 @@ async function resolveChrome(need = "open the deck with") {
9179
9533
  async function chromePath(need = "open the deck with") {
9180
9534
  return (await resolveChrome(need)).path;
9181
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
+ }
9182
9552
  function runtimePath() {
9183
9553
  return createRequire3(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
9184
9554
  }
@@ -9196,10 +9566,9 @@ async function openDeck(dir, opts = {}) {
9196
9566
  const timeout = opts.timeoutMs ?? 6e4;
9197
9567
  const { default: puppeteer } = await import("puppeteer-core");
9198
9568
  const chrome2 = await resolveChrome();
9199
- const browser = await puppeteer.launch({
9200
- executablePath: chrome2.path,
9201
- headless: true,
9202
- args: [
9569
+ const browser = await launchOwnPage(
9570
+ (args) => puppeteer.launch({ executablePath: chrome2.path, headless: true, args }),
9571
+ [
9203
9572
  // A retina host would otherwise hand back a 2x frame, whose clip is not the
9204
9573
  // renderer's.
9205
9574
  "--force-device-scale-factor=1",
@@ -9212,8 +9581,9 @@ async function openDeck(dir, opts = {}) {
9212
9581
  "--use-gl=angle",
9213
9582
  "--use-angle=swiftshader",
9214
9583
  "--enable-unsafe-swiftshader"
9215
- ]
9216
- });
9584
+ ],
9585
+ "the gates' page"
9586
+ );
9217
9587
  try {
9218
9588
  const page = await browser.newPage();
9219
9589
  await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
@@ -9316,10 +9686,10 @@ function pack(words2, width) {
9316
9686
  function splitCue(cue, max = CUE_MAX_CHARS) {
9317
9687
  if (cue.text.length <= max) return [cue];
9318
9688
  const words2 = cue.text.split(/\s+/).filter(Boolean);
9319
- const count = pack(words2, max).length;
9689
+ const count2 = pack(words2, max).length;
9320
9690
  let width = max;
9321
- for (let w = Math.ceil(cue.text.length / count); w < max; w++) {
9322
- 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) {
9323
9693
  width = w;
9324
9694
  break;
9325
9695
  }
@@ -9411,17 +9781,17 @@ function place(scenes, spoken) {
9411
9781
  );
9412
9782
  }
9413
9783
  const { starts } = speechPlan(scene.open, scene.holds, segments2);
9414
- for (const [i, segment] of segments2.entries()) {
9415
- 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);
9416
9786
  out.push({
9417
- id: `${scene.id}.${segment.stop}`,
9787
+ id: `${scene.id}.${segment2.stop}`,
9418
9788
  scene: scene.id,
9419
- stop: segment.stop,
9420
- audio: segment.audio,
9789
+ stop: segment2.stop,
9790
+ audio: segment2.audio,
9421
9791
  hold: round5(scene.start + scene.holds[index]),
9422
9792
  start: round5(scene.start + starts[i]),
9423
- duration: segment.seconds,
9424
- 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)))
9425
9795
  });
9426
9796
  }
9427
9797
  }
@@ -9498,6 +9868,15 @@ function planTiming(input) {
9498
9868
  // `emitDeck` and `writeTiming` are handed the same `format`, so this cannot
9499
9869
  // disagree with the box the archetypes actually drew into.
9500
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
+ },
9501
9880
  scenes,
9502
9881
  segments: segments2
9503
9882
  };
@@ -9524,15 +9903,15 @@ function framePlan(timing, fps) {
9524
9903
  );
9525
9904
  }
9526
9905
  pieces.push({ from: first, motion: last - first, freeze: 0 });
9527
- for (const segment of segments2) {
9528
- const at = f(segment.start);
9906
+ for (const segment2 of segments2) {
9907
+ const at = f(segment2.start);
9529
9908
  audio.push({
9530
- id: segment.id,
9531
- audio: segment.audio,
9909
+ id: segment2.id,
9910
+ audio: segment2.audio,
9532
9911
  startFrame: at,
9533
9912
  delayMs: Math.round(at / fps * 1e3)
9534
9913
  });
9535
- for (const cue of segment.cues) {
9914
+ for (const cue of segment2.cues) {
9536
9915
  const start = at / fps + cue.start;
9537
9916
  const end = at / fps + cue.end;
9538
9917
  if (end > start) cues.push({ start, end, text: cue.text });
@@ -9651,7 +10030,7 @@ ${bands}
9651
10030
  </div>
9652
10031
  `;
9653
10032
  }
9654
- function measure3(count) {
10033
+ function measure3(count2) {
9655
10034
  const doc = document;
9656
10035
  const probe2 = doc.getElementById("c0");
9657
10036
  if (probe2) {
@@ -9664,7 +10043,7 @@ function measure3(count) {
9664
10043
  probe2.classList.remove("on");
9665
10044
  }
9666
10045
  const rects = [];
9667
- for (let i = 0; i < count; i++) {
10046
+ for (let i = 0; i < count2; i++) {
9668
10047
  const el = doc.getElementById(`c${i}`);
9669
10048
  if (!el) continue;
9670
10049
  el.classList.add("on");
@@ -9692,11 +10071,12 @@ async function renderCaptions(cues, style, deck, work) {
9692
10071
  await mkdir8(work, { recursive: true });
9693
10072
  await writeFile10(page, captionPage(cues, style, href));
9694
10073
  const { default: puppeteer } = await import("puppeteer-core");
9695
- const browser = await puppeteer.launch({
9696
- executablePath: await chromePath(CAPTION_NEED),
9697
- headless: true,
9698
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
9699
- });
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
+ );
9700
10080
  try {
9701
10081
  const tab = await browser.newPage();
9702
10082
  await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
@@ -10613,6 +10993,14 @@ async function readInBrowser(html, timeoutMs) {
10613
10993
  // happens to be that run.
10614
10994
  "--host-resolver-rules=MAP * ~NOTFOUND"
10615
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
+ );
10616
11004
  });
10617
11005
  try {
10618
11006
  const page = await browser.newPage();
@@ -11646,10 +12034,10 @@ function readCanvas(html) {
11646
12034
  function profilesFor(width, height, formats = FORMATS) {
11647
12035
  return Object.values(formats).filter((f) => f.width === width && f.height === height);
11648
12036
  }
11649
- function scanBudget(html, storyboard, kept, formats = FORMATS) {
12037
+ function scanBudget(html, storyboard, kept, formats = FORMATS, recorded) {
11650
12038
  const canvas = readCanvas(html);
11651
12039
  if (!canvas) return [];
11652
- 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);
11653
12041
  if (profiles.length === 0) {
11654
12042
  return [
11655
12043
  {
@@ -12374,6 +12762,20 @@ function captionBottom(sid, selector, fallbackPx) {
12374
12762
  }
12375
12763
  return bottom > 0 ? bottom : fallbackPx;
12376
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
+ }
12377
12779
  async function fidelity(dir, opts = {}) {
12378
12780
  const started = Date.now();
12379
12781
  const floor = opts.floor ?? INK_FLOOR;
@@ -12397,6 +12799,10 @@ async function fidelity(dir, opts = {}) {
12397
12799
  try {
12398
12800
  deck = await openDeck(dir, { timeoutMs: opts.timeoutMs });
12399
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);
12400
12806
  const measured = [];
12401
12807
  const collided = [];
12402
12808
  const apparent = [];
@@ -12440,7 +12846,8 @@ async function fidelity(dir, opts = {}) {
12440
12846
  ...gradeFidelity(measured, floor),
12441
12847
  ...gradeReserve(measured),
12442
12848
  ...gradeOverprint(collided),
12443
- ...gradeApparent(apparent)
12849
+ ...gradeApparent(apparent),
12850
+ ...gradeUnfit(unfit)
12444
12851
  ],
12445
12852
  elapsedMs: Date.now() - started
12446
12853
  };
@@ -12787,9 +13194,13 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
12787
13194
  await readFile17(join17(dir, DECK_PAGE), "utf8").catch(() => ""),
12788
13195
  await listFiles(dir)
12789
13196
  );
12790
- 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
+ );
12791
13202
  const type = html.flatMap(([file, text2]) => scanTypeFloor(text2, file));
12792
- const lead = kept ? await readTiming2(dir).then((t2) => t2 ? scanNarrationLead(kept, t2) : []) : [];
13203
+ const lead = kept && timing ? scanNarrationLead(kept, timing) : [];
12793
13204
  const ours = [...determinism, ...narration, ...budget3, ...type, ...lead];
12794
13205
  const stops = await declaredStops(dir);
12795
13206
  const [verdict, frames2] = await Promise.all([
@@ -12978,6 +13389,31 @@ function scanUnusedFigures(storyboard, source) {
12978
13389
  }
12979
13390
  ];
12980
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
+ }
12981
13417
  function figuresShown2(params) {
12982
13418
  const sides = ["left", "right"].map(
12983
13419
  (side) => params[side]?.figureId
@@ -13059,14 +13495,14 @@ function scanNarrationLead(beats, timing) {
13059
13495
  return findings;
13060
13496
  }
13061
13497
  function firstMention(segments2, label) {
13062
- for (const segment of segments2) {
13063
- for (const cue of segment.cues) {
13498
+ for (const segment2 of segments2) {
13499
+ for (const cue of segment2.cues) {
13064
13500
  const words2 = cue.text.split(/\s+/);
13065
13501
  let at = 0;
13066
13502
  for (const word of words2) {
13067
13503
  if (mentions(word, label)) {
13068
13504
  const share2 = cue.text.length > 0 ? at / cue.text.length : 0;
13069
- return segment.start + cue.start + share2 * (cue.end - cue.start);
13505
+ return segment2.start + cue.start + share2 * (cue.end - cue.start);
13070
13506
  }
13071
13507
  at += word.length + 1;
13072
13508
  }
@@ -13334,7 +13770,11 @@ imageFlags(
13334
13770
  ...scanRepeatedObject(storyboard),
13335
13771
  // A figure the plan ignored is cheapest to fix here, where the answer is one
13336
13772
  // more beat in a file the author already has open.
13337
- ...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)
13338
13778
  ])
13339
13779
  step(`plan: ${f.message}`);
13340
13780
  const script = storyboard.beats.map((b) => b.narration?.trim()).filter(Boolean).join(" ");
@@ -13680,12 +14120,12 @@ async function gate(dir, snapshots = false, storyboard, kept, fidelity2 = true,
13680
14120
  if (!verdict.passed) process.exitCode = 1;
13681
14121
  }
13682
14122
  function report(v) {
13683
- const count = (s) => v.findings.filter((f) => f.severity === s).length;
14123
+ const count2 = (s) => v.findings.filter((f) => f.severity === s).length;
13684
14124
  const lines = v.findings.map(
13685
14125
  (f) => ` ${f.severity.padEnd(7)} ${f.gate.padEnd(11)} ${f.rule} ${f.message}`
13686
14126
  );
13687
14127
  lines.push(
13688
- `${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)`
13689
14129
  );
13690
14130
  return `${lines.join("\n")}
13691
14131
  `;