@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/index.js CHANGED
@@ -152,6 +152,11 @@ async function copyAudio(from, narration, out, step) {
152
152
  return names.map((n3) => join2(dir, n3));
153
153
  }
154
154
  async function refreshFont(storyboard, source, out, step) {
155
+ if (familyFor(storyboard.lang) === null) {
156
+ const css = await vendorInter(join2(out, "assets", "fonts"));
157
+ step("build: vendored Inter beside the deck");
158
+ return css;
159
+ }
155
160
  try {
156
161
  const bundle = await bundleFont(
157
162
  storyboard.lang,
@@ -167,6 +172,21 @@ async function refreshFont(storyboard, source, out, step) {
167
172
  return void 0;
168
173
  }
169
174
  }
175
+ async function vendorInter(dir) {
176
+ const pkg = dirname(createRequire(import.meta.url).resolve("@fontsource-variable/inter"));
177
+ const css = await readFile2(join2(pkg, "index.css"), "utf8");
178
+ await mkdir2(dir, { recursive: true });
179
+ for (const [, name] of css.matchAll(/url\(\.\/files\/([^)]+)\)/g)) {
180
+ await cp(join2(pkg, "files", name ?? ""), join2(dir, name ?? ""));
181
+ }
182
+ 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')");
183
+ if (/Inter Variable|\.\/files\/|swap/.test(ours) || !ours.includes("font-family: 'Inter';")) {
184
+ throw new Error(
185
+ `@fontsource-variable/inter's index.css changed shape; update vendorInter (${pkg})`
186
+ );
187
+ }
188
+ return ours;
189
+ }
170
190
 
171
191
  // src/emit/kit.ts
172
192
  var PAD_X = 110;
@@ -844,35 +864,35 @@ function elbow(sid, from, to, o) {
844
864
  }
845
865
  return path(d, { ...strokeAttrs(o), "marker-end": `url(#${headId(sid, o.stroke)})` });
846
866
  }
847
- function tracks(width, count, gap, x0 = 0) {
848
- const w = count > 1 ? (width - gap * (count - 1)) / count : width;
849
- return Array.from({ length: count }, (_, i) => ({ x: x0 + i * (w + gap), w }));
867
+ function tracks(width, count2, gap, x0 = 0) {
868
+ const w = count2 > 1 ? (width - gap * (count2 - 1)) / count2 : width;
869
+ return Array.from({ length: count2 }, (_, i) => ({ x: x0 + i * (w + gap), w }));
850
870
  }
851
871
  function fitBoxes(req) {
852
- const count = req.labels.length;
872
+ const count2 = req.labels.length;
853
873
  const minGap = req.minGap ?? 24;
854
874
  const padEm = req.padEm ?? 0.8;
855
875
  const weight = req.weight ?? 600;
856
876
  const x0 = req.x0 ?? 0;
857
877
  const unit = Math.max(1, ...req.labels.map((l) => textWidth(l, 1, weight) + 2 * padEm));
858
- const sizeAt = (gap) => count < 1 ? req.size : (req.width - gap * (count - 1)) / count / unit;
878
+ const sizeAt = (gap) => count2 < 1 ? req.size : (req.width - gap * (count2 - 1)) / count2 / unit;
859
879
  if (sizeAt(req.gap) >= req.size) {
860
- return { ok: true, size: req.size, gap: req.gap, boxes: tracks(req.width, count, req.gap, x0) };
880
+ return { ok: true, size: req.size, gap: req.gap, boxes: tracks(req.width, count2, req.gap, x0) };
861
881
  }
862
- if (count > 1 && sizeAt(minGap) >= req.size) {
863
- const gap = (req.width - count * req.size * unit) / (count - 1);
864
- return { ok: true, size: req.size, gap, boxes: tracks(req.width, count, gap, x0) };
882
+ if (count2 > 1 && sizeAt(minGap) >= req.size) {
883
+ const gap = (req.width - count2 * req.size * unit) / (count2 - 1);
884
+ return { ok: true, size: req.size, gap, boxes: tracks(req.width, count2, gap, x0) };
865
885
  }
866
886
  const size2 = sizeAt(minGap);
867
887
  if (size2 >= MIN_FONT) {
868
- return { ok: true, size: size2, gap: minGap, boxes: tracks(req.width, count, minGap, x0) };
888
+ return { ok: true, size: size2, gap: minGap, boxes: tracks(req.width, count2, minGap, x0) };
869
889
  }
870
890
  return {
871
891
  ok: false,
872
892
  size: MIN_FONT,
873
893
  gap: minGap,
874
- boxes: tracks(req.width, count, minGap, x0),
875
- needed: count * MIN_FONT * unit + minGap * (count - 1)
894
+ boxes: tracks(req.width, count2, minGap, x0),
895
+ needed: count2 * MIN_FONT * unit + minGap * (count2 - 1)
876
896
  };
877
897
  }
878
898
 
@@ -1205,8 +1225,8 @@ var MID_GAP = 56;
1205
1225
  var BAND_GAP = 34;
1206
1226
  var COL_GAP = 28;
1207
1227
  var MIN_COL = 250;
1208
- function lineBudget(count, stageH) {
1209
- const perSide = Math.max(1, Math.ceil(count / 2));
1228
+ function lineBudget(count2, stageH) {
1229
+ const perSide = Math.max(1, Math.ceil(count2 / 2));
1210
1230
  const room = (stageH - (perSide - 1) * LAB_GAP) / perSide;
1211
1231
  const lines = Math.floor((room - ASCENT - RULE_LIFT - RULE_W) / (LAB * LAB_LH)) + 1;
1212
1232
  return Math.min(LINE_CEIL, Math.max(1, lines));
@@ -1617,7 +1637,7 @@ var HEAD_GAP = 14;
1617
1637
  var barCompare = (beat, ctx) => {
1618
1638
  const { sid, theme } = ctx;
1619
1639
  const p = beat.params;
1620
- const count = p.bars.length;
1640
+ const count2 = p.bars.length;
1621
1641
  const W = contentW(ctx.format);
1622
1642
  const face = faceOf(ctx.theme.fontStack);
1623
1643
  const tall = isPortrait(ctx.format);
@@ -1631,7 +1651,7 @@ var barCompare = (beat, ctx) => {
1631
1651
  0,
1632
1652
  face
1633
1653
  );
1634
- let bar = Math.min(BAR_MAX, (avail - unitBand - FOOT) / (count + GAP_RATIO * (count - 1)));
1654
+ let bar = Math.min(BAR_MAX, (avail - unitBand - FOOT) / (count2 + GAP_RATIO * (count2 - 1)));
1635
1655
  let pitch = bar * (1 + GAP_RATIO);
1636
1656
  const gutterMax = tall ? 0 : share(GUTTER_MAX, W);
1637
1657
  const gutterInner = tall ? W : gutterMax - GUTTER_PAD;
@@ -1653,7 +1673,7 @@ var barCompare = (beat, ctx) => {
1653
1673
  if (tall) {
1654
1674
  bar = Math.min(
1655
1675
  BAR_MAX,
1656
- (avail - unitBand - FOOT - count * head) / (count + GAP_RATIO * (count - 1))
1676
+ (avail - unitBand - FOOT - count2 * head) / (count2 + GAP_RATIO * (count2 - 1))
1657
1677
  );
1658
1678
  pitch = head + bar * (1 + GAP_RATIO);
1659
1679
  } else {
@@ -1664,7 +1684,7 @@ var barCompare = (beat, ctx) => {
1664
1684
  }
1665
1685
  }
1666
1686
  const valueSize = Math.max(MIN_FONT, Math.min(VALUE_MAX, bar * 0.9));
1667
- const barsH = (count - 1) * pitch + head + bar;
1687
+ const barsH = (count2 - 1) * pitch + head + bar;
1668
1688
  const foot = Math.max(
1669
1689
  0,
1670
1690
  Math.ceil(
@@ -1677,7 +1697,7 @@ var barCompare = (beat, ctx) => {
1677
1697
  const H = barsH + unitBand + foot;
1678
1698
  if (H > avail) {
1679
1699
  throw new Error(
1680
- `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.`
1700
+ `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.`
1681
1701
  );
1682
1702
  }
1683
1703
  const gutter = tall ? 0 : Math.min(
@@ -1830,7 +1850,7 @@ ${svg(id(sid, "chart"), W, H, body)}
1830
1850
  </div>${note2}`;
1831
1851
  const railsAt = 0.6;
1832
1852
  const barsAt = 0.95;
1833
- const step = Math.min(0.4, 2.4 / count);
1853
+ const step = Math.min(0.4, 2.4 / count2);
1834
1854
  const grow = 0.85;
1835
1855
  const tl = [
1836
1856
  ...chromeIn(sid, p.eyebrow !== void 0),
@@ -1880,11 +1900,11 @@ ${svg(id(sid, "chart"), W, H, body)}
1880
1900
  );
1881
1901
  }
1882
1902
  }
1883
- const settled = barsAt + (count - 1) * step + grow + 0.05;
1903
+ const settled = barsAt + (count2 - 1) * step + grow + 0.05;
1884
1904
  const holds = [settled + 0.2];
1885
1905
  const focal = rows.reduce((best, r) => Math.abs(r.value) > Math.abs(best.value) ? r : best);
1886
1906
  const spot = spotlighter(sid, ".bc-bar");
1887
- if (count > 1) tl.push(...spot.lit(`#${id(sid, "bar", focal.i)}`, settled));
1907
+ if (count2 > 1) tl.push(...spot.lit(`#${id(sid, "bar", focal.i)}`, settled));
1888
1908
  const tailAt = settled + 0.3;
1889
1909
  if (p.unit) {
1890
1910
  tl.push(tween(`#${id(sid, "unit")}`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, tailAt));
@@ -2377,6 +2397,90 @@ var dataTable = (beat, ctx) => {
2377
2397
  };
2378
2398
  };
2379
2399
 
2400
+ // src/emit/tex.ts
2401
+ import katex from "katex";
2402
+ var UNFIT_ATTR = "data-ds-unfit";
2403
+ var trust = (c) => c.command === "\\htmlClass";
2404
+ function texError(tex, display) {
2405
+ try {
2406
+ katex.renderToString(tex, {
2407
+ displayMode: display,
2408
+ throwOnError: true,
2409
+ strict: false,
2410
+ trust,
2411
+ output: "html"
2412
+ });
2413
+ return null;
2414
+ } catch (err) {
2415
+ return err instanceof Error ? err.message : String(err);
2416
+ }
2417
+ }
2418
+ var LEFT = /\\left(?![a-zA-Z])/g;
2419
+ var RIGHT = /\\right(?![a-zA-Z])/g;
2420
+ var NO_INK = /\\label\{[^{}]*\}|\\nonumber(?![a-zA-Z])|\\notag(?![a-zA-Z])/g;
2421
+ function braceBalance(tex) {
2422
+ let depth = 0;
2423
+ for (let i = 0; i < tex.length; i++) {
2424
+ const c = tex[i];
2425
+ if (c === "\\") {
2426
+ i++;
2427
+ continue;
2428
+ }
2429
+ if (c === "{") depth++;
2430
+ else if (c === "}") depth--;
2431
+ }
2432
+ return depth;
2433
+ }
2434
+ var count = (tex, re) => (tex.match(re) ?? []).length;
2435
+ function repairTex(input, display) {
2436
+ const repairs = [];
2437
+ let tex = input;
2438
+ let error = texError(tex, display);
2439
+ if (error === null) return { tex, repairs, error };
2440
+ const gone = tex.match(NO_INK) ?? [];
2441
+ if (gone.length) {
2442
+ tex = tex.replace(NO_INK, "").trim();
2443
+ repairs.push(`removed ${gone.join(", ")}, which draw nothing`);
2444
+ error = texError(tex, display);
2445
+ }
2446
+ const lefts = count(tex, LEFT);
2447
+ const rights = count(tex, RIGHT);
2448
+ if (error !== null && lefts !== rights) {
2449
+ const n3 = Math.abs(lefts - rights);
2450
+ tex = lefts > rights ? `${tex} ${"\\right.".repeat(n3)}` : `${"\\left.".repeat(n3)} ${tex}`;
2451
+ repairs.push(
2452
+ `${lefts} \\left against ${rights} \\right: added ${n3} invisible ${lefts > rights ? "\\right." : "\\left."} to balance them`
2453
+ );
2454
+ error = texError(tex, display);
2455
+ }
2456
+ const depth = braceBalance(tex);
2457
+ if (error !== null && depth !== 0) {
2458
+ const n3 = Math.abs(depth);
2459
+ tex = depth > 0 ? `${tex}${"}".repeat(n3)}` : `${"{".repeat(n3)}${tex}`;
2460
+ repairs.push(
2461
+ `${n3} unclosed brace group(s): ${depth > 0 ? "closed at the end" : "opened at the start"}`
2462
+ );
2463
+ error = texError(tex, display);
2464
+ }
2465
+ const undefinedNames = [];
2466
+ for (let round7 = 0; error !== null && round7 < 12; round7++) {
2467
+ const m = /Undefined control sequence: \\([a-zA-Z]+)/.exec(error);
2468
+ if (!m) break;
2469
+ const name = m[1];
2470
+ const next = tex.replace(new RegExp(`\\\\${name}(?![a-zA-Z])`, "g"), `\\operatorname{${name}}`);
2471
+ if (next === tex) break;
2472
+ tex = next;
2473
+ undefinedNames.push(`\\${name}`);
2474
+ error = texError(tex, display);
2475
+ }
2476
+ if (undefinedNames.length) {
2477
+ repairs.push(
2478
+ `${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`
2479
+ );
2480
+ }
2481
+ return { tex, repairs, error };
2482
+ }
2483
+
2380
2484
  // src/emit/archetypes/equation-walk.ts
2381
2485
  var TRUST = 'function (c) { return c.command === "\\\\htmlClass"; }';
2382
2486
  var OPTS = `{ displayMode: true, trust: ${TRUST}, strict: false, output: "html" }`;
@@ -2391,6 +2495,9 @@ var SYNONYMS = [
2391
2495
  function normalise(tex) {
2392
2496
  let out = "";
2393
2497
  const map = [];
2498
+ const ends = [];
2499
+ const lead = [];
2500
+ let hint = null;
2394
2501
  for (let i = 0; i < tex.length; ) {
2395
2502
  if (/\s/.test(tex[i])) {
2396
2503
  i++;
@@ -2402,19 +2509,19 @@ function normalise(tex) {
2402
2509
  const m = new RegExp(`^(?:${re.source})`).exec(rest);
2403
2510
  return m ? { len: m[0].length, to } : null;
2404
2511
  }).find(Boolean);
2405
- if (hit) {
2406
- for (const ch of hit.to) {
2407
- out += ch;
2408
- map.push(i);
2409
- }
2410
- i += hit.len;
2411
- continue;
2512
+ const len = hit ? hit.len : 1;
2513
+ const chars = hit ? hit.to : tex[i];
2514
+ if (chars === "") hint ??= i;
2515
+ for (const ch of chars) {
2516
+ out += ch;
2517
+ map.push(i);
2518
+ ends.push(i + len);
2519
+ lead.push(hint ?? i);
2520
+ hint = null;
2412
2521
  }
2413
- out += tex[i];
2414
- map.push(i);
2415
- i++;
2522
+ i += len;
2416
2523
  }
2417
- return { text: out, map };
2524
+ return { text: out, map, ends, lead };
2418
2525
  }
2419
2526
  function locate(tex, term) {
2420
2527
  const hay = normalise(tex);
@@ -2422,58 +2529,131 @@ function locate(tex, term) {
2422
2529
  if (needle === "") return null;
2423
2530
  const at = hay.text.indexOf(needle);
2424
2531
  if (at < 0) return null;
2425
- const start = hay.map[at];
2426
- const lastNorm = at + needle.length - 1;
2427
- const lastOrig = hay.map[lastNorm];
2428
- let end = lastOrig + 1;
2429
- while (end < tex.length && (hay.map[lastNorm + 1] ?? tex.length) > end && /\s/.test(tex[end])) {
2430
- end++;
2431
- }
2432
- end = Math.max(end, hay.map[lastNorm + 1] ?? lastOrig + 1);
2532
+ const start = hay.lead[at];
2533
+ const end = hay.ends[at + needle.length - 1];
2433
2534
  return { start, end: Math.min(end, tex.length) };
2434
2535
  }
2435
2536
  function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
2537
+ const checked = texError(tex, true) === null;
2436
2538
  let parts = [{ text: tex, raw: true }];
2437
2539
  const used = [];
2438
2540
  const missing = [];
2541
+ const broken = [];
2542
+ const joined = (ps) => ps.map((p) => p.text).join("");
2439
2543
  for (const term of terms) {
2440
2544
  let placed = false;
2545
+ let found = false;
2441
2546
  for (let i = 0; i < parts.length && !placed; i++) {
2442
2547
  const part = parts[i];
2443
2548
  if (!part.raw) continue;
2444
2549
  const at = locate(part.text, term.tex);
2445
2550
  if (!at) continue;
2446
- parts = parts.toSpliced(
2447
- i,
2448
- 1,
2449
- { text: part.text.slice(0, at.start), raw: true },
2450
- {
2451
- text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
2452
- raw: false
2453
- },
2454
- { text: part.text.slice(at.end), raw: true }
2455
- );
2456
- used.push(term);
2457
- placed = true;
2551
+ found = true;
2552
+ const body = `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`;
2553
+ for (const text2 of [body, `{${body}}`]) {
2554
+ const next = parts.toSpliced(
2555
+ i,
2556
+ 1,
2557
+ { text: part.text.slice(0, at.start), raw: true },
2558
+ { text: text2, raw: false },
2559
+ { text: part.text.slice(at.end), raw: true }
2560
+ );
2561
+ if (checked && texError(joined(next), true) !== null) continue;
2562
+ parts = next;
2563
+ used.push(term);
2564
+ placed = true;
2565
+ break;
2566
+ }
2567
+ }
2568
+ if (!placed) {
2569
+ missing.push(term);
2570
+ if (found) broken.push(term);
2458
2571
  }
2459
- if (!placed) missing.push(term);
2460
2572
  }
2461
2573
  if (used.length === 0) {
2462
2574
  throw new Error(
2463
- `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)}`
2575
+ `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)}`
2576
+ );
2577
+ }
2578
+ return { tex: joined(parts), used, missing, broken };
2579
+ }
2580
+ function droppedTerms(eqId, missing, broken) {
2581
+ return missing.map(
2582
+ (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`
2583
+ );
2584
+ }
2585
+ function prepareWalk(eq, terms, beatId) {
2586
+ const fixed = repairTex(eq.tex, true);
2587
+ const notes = fixed.repairs.map((r) => `${eq.id} repaired before drawing: ${r}`);
2588
+ const repairedTerms = terms.map((t2) => ({ ...t2, tex: repairTex(t2.tex, false).tex }));
2589
+ if (fixed.error !== null) {
2590
+ notes.push(
2591
+ `${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)}`
2464
2592
  );
2593
+ const parts = segment(eq.tex, [...terms]);
2594
+ if (parts.used.length === 0) {
2595
+ throw new Error(
2596
+ `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)}`
2597
+ );
2598
+ }
2599
+ notes.push(...droppedTerms(eq.id, parts.missing, []));
2600
+ return {
2601
+ raw: eq.tex,
2602
+ plain: { error: fixed.error },
2603
+ used: parts.used,
2604
+ missing: parts.missing,
2605
+ notes
2606
+ };
2465
2607
  }
2466
- return { tex: parts.map((part) => part.text).join(""), used, missing };
2608
+ const walk = wrapTerms(fixed.tex, repairedTerms, beatId);
2609
+ const back = (ts) => ts.map((t2) => terms[repairedTerms.indexOf(t2)]);
2610
+ const used = back(walk.used);
2611
+ const missing = back(walk.missing);
2612
+ notes.push(...droppedTerms(eq.id, missing, back(walk.broken)));
2613
+ return { tex: walk.tex, raw: fixed.tex, used, missing, notes };
2614
+ }
2615
+ function segment(tex, terms) {
2616
+ let parts = [{ text: tex }];
2617
+ const used = [];
2618
+ const missing = [];
2619
+ for (const term of terms) {
2620
+ const i = parts.findIndex((p) => !p.term && locate(p.text, term.tex));
2621
+ const part = parts[i];
2622
+ const at = part && locate(part.text, term.tex);
2623
+ if (!part || !at) {
2624
+ missing.push(term);
2625
+ continue;
2626
+ }
2627
+ parts = parts.toSpliced(
2628
+ i,
2629
+ 1,
2630
+ { text: part.text.slice(0, at.start) },
2631
+ { text: part.text.slice(at.start, at.end), term },
2632
+ { text: part.text.slice(at.end) }
2633
+ );
2634
+ used.push(term);
2635
+ }
2636
+ return { parts, used, missing };
2637
+ }
2638
+ function plainHtml(tex, terms) {
2639
+ return segment(tex, terms).parts.map(
2640
+ (p) => p.term ? `<span class="term t-${p.term.tone}">${esc(p.text)}</span>` : esc(p.text)
2641
+ ).join("");
2642
+ }
2643
+ function chipSetup(sid, term) {
2644
+ const fixed = repairTex(term.tex, false);
2645
+ const el = `document.getElementById("${sid}-chip-${term.tone}")`;
2646
+ return fixed.error === null ? `katex.render('${js(fixed.tex)}', ${el}, ${INLINE_OPTS});` : `${el}.textContent = '${js(term.tex)}';`;
2467
2647
  }
2468
2648
  function equationSize(tex) {
2469
2649
  return tex.length > 120 ? 68 : tex.length > 90 ? 80 : tex.length > 55 ? 92 : 108;
2470
2650
  }
2471
- var NO_INK = /\\(math[a-z]+|text[a-z]*|bm|boldsymbol|displaystyle|textstyle|scriptstyle|left|right|operatorname|limits|nolimits|htmlClass|hspace|phantom|[,;:!])/g;
2651
+ var NO_INK2 = /\\(math[a-z]+|text[a-z]*|bm|boldsymbol|displaystyle|textstyle|scriptstyle|left|right|operatorname|limits|nolimits|htmlClass|hspace|phantom|[,;:!])/g;
2472
2652
  var GLYPH_EM = 0.9;
2473
2653
  function texUnits(tex) {
2474
2654
  let ems = 0;
2475
2655
  for (const m of tex.matchAll(/\\qquad|\\quad/g)) ems += m[0] === "\\qquad" ? 2 : 1;
2476
- const glyphs = tex.replace(/\\q?quad/g, "").replace(NO_INK, "").replace(/[{}\s_^]/g, "");
2656
+ const glyphs = tex.replace(/\\q?quad/g, "").replace(NO_INK2, "").replace(/[{}\s_^]/g, "");
2477
2657
  return ems + glyphs.length * GLYPH_EM;
2478
2658
  }
2479
2659
  function statements(tex, stacked) {
@@ -2481,6 +2661,100 @@ function statements(tex, stacked) {
2481
2661
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
2482
2662
  return parts.length > 0 ? parts : [tex];
2483
2663
  }
2664
+ var PLAIN_FONT = 48;
2665
+ function mathFit(sid, targets, shown, raw2, want, floor) {
2666
+ return [
2667
+ `var dsEqFit = (function () {
2668
+ var box = document.getElementById("${sid}-eq");
2669
+ var slide = box && box.parentNode;
2670
+ var bw = box ? box.getBoundingClientRect().width : 0;
2671
+ if (!(bw > 0) || !slide) return "unmeasured";
2672
+ var targets = [${targets.map((id2) => `"${id2}"`).join(", ")}].map(function (id) { return document.getElementById(id); });
2673
+ var TEX = [${shown.map((s) => `'${js(s)}'`).join(", ")}];
2674
+ var spill0 = slide.scrollHeight - slide.clientHeight;
2675
+ function extent(each) {
2676
+ var w = 0;
2677
+ targets.forEach(function (t) {
2678
+ var lo = Infinity, hi = -Infinity;
2679
+ Array.prototype.forEach.call(t.querySelectorAll(".katex-html > .base"), function (b) {
2680
+ var r = b.getBoundingClientRect();
2681
+ if (each) w = Math.max(w, r.width);
2682
+ else { lo = Math.min(lo, r.left); hi = Math.max(hi, r.right); }
2683
+ });
2684
+ if (!each && hi > lo) w = Math.max(w, hi - lo);
2685
+ });
2686
+ return w;
2687
+ }
2688
+ function fits(each) {
2689
+ return extent(each) <= bw + 0.5 && slide.scrollHeight - slide.clientHeight <= Math.max(1, spill0 + 1);
2690
+ }
2691
+ function settle(each, size) {
2692
+ size = Math.max(${floor}, Math.min(${want}, Math.floor(size)));
2693
+ box.style.fontSize = size + "px";
2694
+ while (!fits(each) && size > ${floor}) {
2695
+ size = Math.max(${floor}, size - 2);
2696
+ box.style.fontSize = size + "px";
2697
+ }
2698
+ return fits(each);
2699
+ }
2700
+ if (fits(false)) return "fits";
2701
+ var size = parseFloat(box.style.fontSize);
2702
+ if (settle(false, (size * bw) / extent(false))) return "scaled";
2703
+ targets.forEach(function (t, i) { katex.render("\\\\displaystyle " + TEX[i], t, ${INLINE_OPTS}); });
2704
+ box.className += " eq-broken";
2705
+ box.style.fontSize = "${want}px";
2706
+ var wb = extent(true);
2707
+ if (settle(true, wb > bw ? (${want} * bw) / wb : ${want})) return "broken";
2708
+ box.setAttribute("${UNFIT_ATTR}", '${js(raw2)}');
2709
+ return "unfit";
2710
+ })()`
2711
+ ];
2712
+ }
2713
+ function chipFit(sid, terms) {
2714
+ if (terms.length === 0) return [];
2715
+ return [
2716
+ `[${terms.map((t2) => `"${t2.tone}"`).join(", ")}].forEach(function (tone) {
2717
+ var chip = document.getElementById("${sid}-chip-" + tone);
2718
+ var r = chip && chip.getBoundingClientRect();
2719
+ if (!r || !(r.height > 0)) return;
2720
+ var k = chip.offsetHeight / r.height;
2721
+ var top = r.top, bottom = r.bottom;
2722
+ Array.prototype.forEach.call(chip.querySelectorAll("span"), function (el) {
2723
+ for (var n = el.firstChild; n; n = n.nextSibling) {
2724
+ if (n.nodeType === 3 && n.nodeValue.trim()) {
2725
+ var q = el.getBoundingClientRect();
2726
+ top = Math.min(top, q.top);
2727
+ bottom = Math.max(bottom, q.bottom);
2728
+ return;
2729
+ }
2730
+ }
2731
+ });
2732
+ var cs = getComputedStyle(chip);
2733
+ if (top < r.top) chip.style.paddingTop = parseFloat(cs.paddingTop) + Math.ceil((r.top - top) * k) + "px";
2734
+ if (bottom > r.bottom) chip.style.paddingBottom = parseFloat(cs.paddingBottom) + Math.ceil((bottom - r.bottom) * k) + "px";
2735
+ })`
2736
+ ];
2737
+ }
2738
+ var SWELL = 1.16;
2739
+ var SWELL_EM = 0.06;
2740
+ function swellFit(sid, terms) {
2741
+ return [
2742
+ `var dsSwell = (function () {
2743
+ var out = {};
2744
+ var box = document.getElementById("${sid}-eq");
2745
+ var k = box && box.offsetWidth ? box.getBoundingClientRect().width / box.offsetWidth : 1;
2746
+ var em = box ? parseFloat(getComputedStyle(box).fontSize) : 0;
2747
+ [${terms.map((t2) => `"${t2.tone}"`).join(", ")}].forEach(function (tone) {
2748
+ var w = 0;
2749
+ Array.prototype.forEach.call(document.querySelectorAll("#${sid} .t-" + tone), function (el) {
2750
+ w = Math.max(w, el.getBoundingClientRect().width / k);
2751
+ });
2752
+ out[tone] = w > 0 && em > 0 ? Math.min(${SWELL}, Math.round((1 + (2 * ${SWELL_EM} * em) / w) * 1000) / 1000) : ${SWELL};
2753
+ });
2754
+ return out;
2755
+ })()`
2756
+ ];
2757
+ }
2484
2758
  function legendRows(sid, terms, theme) {
2485
2759
  return terms.map(
2486
2760
  (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>`
@@ -2496,7 +2770,10 @@ function legendCss(theme) {
2496
2770
  `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
2497
2771
  // A common chip width, so the labels share a spine too — the glyphs inside
2498
2772
  // are one symbol each and their natural widths differ by a few pixels.
2499
- `.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}`
2773
+ // `flex:none`: beside a long label the chip used to SHRINK below its own
2774
+ // nowrap TeX, which then ran out of the painted box into the label — on a
2775
+ // HypePaper deck, "pixelcoord" 91px past the chip's right edge.
2776
+ `.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}`
2500
2777
  ].join("\n");
2501
2778
  }
2502
2779
  var equationWalk = (beat, ctx) => {
@@ -2508,37 +2785,53 @@ var equationWalk = (beat, ctx) => {
2508
2785
  `equation-walk ${beat.id}: no equation "${p.equationId}" in source ${ctx.source.id}`
2509
2786
  );
2510
2787
  }
2511
- const walk = wrapTerms(eq.tex, p.terms, beat.id);
2788
+ const walk = prepareWalk(eq, p.terms, beat.id);
2512
2789
  const terms = walk.used;
2513
2790
  const legend = legendRows(sid, terms, theme);
2514
- const stacked = isPortrait(ctx.format);
2515
- const raw2 = statements(eq.tex, stacked);
2516
- const shown = statements(walk.tex, stacked);
2517
- const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
2518
- const size2 = Math.max(
2519
- MIN_FONT,
2520
- Math.min(
2521
- equationSize(longest),
2522
- Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits)))
2523
- )
2524
- );
2525
- const body = shown.length === 1 ? "" : shown.map((_, i) => `<div id="${sid}-eq${i}"></div>`).join("\n ");
2791
+ const chips = terms.map((t2) => chipSetup(sid, t2));
2792
+ let eqHtml;
2793
+ let setup;
2794
+ let measure4;
2795
+ if (walk.plain) {
2796
+ eqHtml = `<div class="eq eq-plain" id="${sid}-eq" style="font-size:${PLAIN_FONT}px">${plainHtml(eq.tex, terms)}</div>`;
2797
+ setup = chips;
2798
+ measure4 = [...chipFit(sid, terms), ...swellFit(sid, terms)];
2799
+ } else {
2800
+ const wrapped = walk.tex;
2801
+ const split = statements(wrapped, isPortrait(ctx.format));
2802
+ const stacked = split.length > 1 && split.every((s) => texError(s, true) === null);
2803
+ const raw2 = statements(walk.raw, stacked);
2804
+ const shown = stacked ? split : [wrapped];
2805
+ const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
2806
+ const want = equationSize(longest);
2807
+ const size2 = Math.max(
2808
+ MIN_FONT,
2809
+ Math.min(want, Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits))))
2810
+ );
2811
+ const body = shown.length === 1 ? "" : shown.map((_, i) => `<div id="${sid}-eq${i}"></div>`).join("\n ");
2812
+ eqHtml = `<div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size2}px">${body}</div>`;
2813
+ const targets = shown.map((_, i) => `${sid}-eq${shown.length === 1 ? "" : i}`);
2814
+ setup = [
2815
+ `var OPTS = ${OPTS};`,
2816
+ ...shown.map(
2817
+ (part, i) => `katex.render('${js(part)}', document.getElementById("${targets[i]}"), OPTS);`
2818
+ ),
2819
+ ...chips
2820
+ ];
2821
+ measure4 = [
2822
+ ...chipFit(sid, terms),
2823
+ ...mathFit(sid, targets, shown, walk.raw, want, MIN_FONT),
2824
+ // Last: it measures each term at the size the fit settled on.
2825
+ ...swellFit(sid, terms)
2826
+ ];
2827
+ }
2526
2828
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
2527
2829
  <div class="eqslide">
2528
- <div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size2}px">${body}</div>
2830
+ ${eqHtml}
2529
2831
  <div class="legend">
2530
2832
  ${legend}
2531
2833
  </div>
2532
2834
  </div>`;
2533
- const setup = [
2534
- `var OPTS = ${OPTS};`,
2535
- ...shown.map(
2536
- (part, i) => `katex.render('${js(part)}', document.getElementById("${sid}-eq${shown.length === 1 ? "" : i}"), OPTS);`
2537
- ),
2538
- ...terms.map(
2539
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2540
- )
2541
- ];
2542
2835
  const tl = [
2543
2836
  ...chromeIn(sid, p.eyebrow !== void 0),
2544
2837
  tween(`#${sid}-eq`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8)
@@ -2550,6 +2843,7 @@ var equationWalk = (beat, ctx) => {
2550
2843
  );
2551
2844
  const holds = [];
2552
2845
  const spot = spotlighter(sid, ".term");
2846
+ const swell = (t2) => raw(`dsSwell.${t2.tone}`);
2553
2847
  terms.forEach((term, i) => {
2554
2848
  const at = first + i * step;
2555
2849
  const colour = theme.tones[term.tone];
@@ -2566,13 +2860,15 @@ var equationWalk = (beat, ctx) => {
2566
2860
  tween(
2567
2861
  `#${sid} .t-${term.tone}`,
2568
2862
  { color: theme.fg, scale: 1 },
2569
- { color: colour, scale: 1.16, duration: 0.5 },
2863
+ { color: colour, scale: swell(term), duration: 0.5 },
2570
2864
  at
2571
2865
  )
2572
2866
  );
2573
2867
  const prev = terms[i - 1];
2574
2868
  if (prev) {
2575
- tl.push(tween(`#${sid} .t-${prev.tone}`, { scale: 1.16 }, { scale: 1, duration: 0.4 }, at));
2869
+ tl.push(
2870
+ tween(`#${sid} .t-${prev.tone}`, { scale: swell(prev) }, { scale: 1, duration: 0.4 }, at)
2871
+ );
2576
2872
  }
2577
2873
  holds.push(at + 0.6);
2578
2874
  });
@@ -2580,7 +2876,7 @@ var equationWalk = (beat, ctx) => {
2580
2876
  if (last) {
2581
2877
  const at = first + terms.length * step;
2582
2878
  tl.push(
2583
- tween(`#${sid} .t-${last.tone}`, { scale: 1.16 }, { scale: 1, duration: 0.4 }, at),
2879
+ tween(`#${sid} .t-${last.tone}`, { scale: swell(last) }, { scale: 1, duration: 0.4 }, at),
2584
2880
  ...spot.restore(at)
2585
2881
  );
2586
2882
  }
@@ -2588,6 +2884,8 @@ var equationWalk = (beat, ctx) => {
2588
2884
  html,
2589
2885
  tl,
2590
2886
  setup,
2887
+ measure: measure4,
2888
+ ...walk.notes.length ? { warnings: walk.notes } : {},
2591
2889
  holds: holdsWithin(holds, beat.seconds),
2592
2890
  css: [
2593
2891
  chromeCss(theme),
@@ -2607,6 +2905,15 @@ var equationWalk = (beat, ctx) => {
2607
2905
  // has its margin zeroed above, so without an explicit gap the two lines
2608
2906
  // would touch and read as one equation broken mid-expression.
2609
2907
  ".eqstack{display:flex;flex-direction:column;gap:32px}",
2908
+ // Set by `mathFit` when one line will not fit at the floor: the display is
2909
+ // re-rendered inline, where KaTeX breaks after a top-level relation or
2910
+ // operator, and the lines it breaks into are centred and spaced as lines.
2911
+ ".eq-broken{line-height:1.5}",
2912
+ // An equation KaTeX refuses, shown as its source. Wraps anywhere, because
2913
+ // TeX source has few spaces and must not run off the slide either. The
2914
+ // deck's own face, not a monospace one: a family the bundle does not
2915
+ // declare falls back silently (invariant 9).
2916
+ ".eq-plain{white-space:pre-wrap;overflow-wrap:anywhere}",
2610
2917
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
2611
2918
  ".term{display:inline-block}",
2612
2919
  legendCss(theme),
@@ -2621,26 +2928,67 @@ var equationWalk = (beat, ctx) => {
2621
2928
 
2622
2929
  // src/emit/archetypes/equation-morph.ts
2623
2930
  var MORPH_SECONDS = 1.6;
2624
- var equationMorph = (beat, ctx) => {
2625
- const { sid, theme } = ctx;
2931
+ function prepareMorph(beat, source) {
2626
2932
  const p = beat.params;
2627
2933
  const find3 = (id2) => {
2628
- const eq = ctx.source.equations.find((e) => e.id === id2);
2934
+ const eq = source.equations.find((e) => e.id === id2);
2629
2935
  if (!eq)
2630
- throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
2936
+ throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${source.id}`);
2631
2937
  return eq;
2632
2938
  };
2633
- const a = find3(p.fromId);
2634
- const b = find3(p.toId);
2635
- const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
2636
- if (both.length === 0) {
2939
+ const notes = [];
2940
+ const drawable = (eq) => {
2941
+ const fixed = repairTex(eq.tex, true);
2942
+ if (fixed.error !== null) {
2943
+ throw new Error(
2944
+ `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)}`
2945
+ );
2946
+ }
2947
+ notes.push(...fixed.repairs.map((r) => `${eq.id} repaired before drawing: ${r}`));
2948
+ return { id: eq.id, tex: fixed.tex };
2949
+ };
2950
+ const a = drawable(find3(p.fromId));
2951
+ const b = drawable(find3(p.toId));
2952
+ const repaired = p.terms.map((t2) => ({ ...t2, tex: repairTex(t2.tex, false).tex }));
2953
+ const original = (t2) => p.terms[repaired.indexOf(t2)];
2954
+ let keyed = repaired.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
2955
+ if (keyed.length === 0) {
2637
2956
  throw new Error(
2638
2957
  `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)}`
2639
2958
  );
2640
2959
  }
2960
+ notes.push(
2961
+ ...repaired.filter((t2) => !keyed.includes(t2)).map(
2962
+ (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`
2963
+ )
2964
+ );
2641
2965
  const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
2642
- const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
2643
- const wb = wrapTerms(b.tex, both, beat.id, cls).tex;
2966
+ let wa = wrapTerms(a.tex, keyed, beat.id, cls);
2967
+ let wb = wrapTerms(b.tex, keyed, beat.id, cls);
2968
+ const paired = keyed.filter((t2) => wa.used.includes(t2) && wb.used.includes(t2));
2969
+ if (paired.length < keyed.length) {
2970
+ const unpaired = keyed.filter((t2) => !paired.includes(t2));
2971
+ notes.push(
2972
+ ...unpaired.map(
2973
+ (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`
2974
+ )
2975
+ );
2976
+ if (paired.length === 0) {
2977
+ throw new Error(
2978
+ `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(", ")}`
2979
+ );
2980
+ }
2981
+ keyed = paired;
2982
+ wa = wrapTerms(a.tex, keyed, beat.id, cls);
2983
+ wb = wrapTerms(b.tex, keyed, beat.id, cls);
2984
+ }
2985
+ const both = keyed.map(original);
2986
+ return { a, b, wa: wa.tex, wb: wb.tex, both, notes };
2987
+ }
2988
+ var equationMorph = (beat, ctx) => {
2989
+ const { sid, theme } = ctx;
2990
+ const p = beat.params;
2991
+ const { a, b, wa, wb, both, notes } = prepareMorph(beat, ctx.source);
2644
2992
  const size2 = Math.max(
2645
2993
  MIN_FONT,
2646
2994
  Math.min(
@@ -2662,9 +3010,7 @@ var equationMorph = (beat, ctx) => {
2662
3010
  `var OPTS = ${OPTS};`,
2663
3011
  `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
2664
3012
  `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
2665
- ...both.map(
2666
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2667
- )
3013
+ ...both.map((t2) => chipSetup(sid, t2))
2668
3014
  ];
2669
3015
  const first = 1.8;
2670
3016
  const at = Math.round(
@@ -2697,8 +3043,11 @@ var equationMorph = (beat, ctx) => {
2697
3043
  setup,
2698
3044
  // SEAM B: the plan is browser geometry after fonts, so it is built inside
2699
3045
  // the ready gate, and the plugin tween above finds it on the host.
2700
- measure: [`DSMorph.build(document.getElementById("${sid}-morph"));`],
3046
+ // The chips are fitted before the plan is built: growing one moves the
3047
+ // legend, and nothing the morph measures may move after it has measured.
3048
+ measure: [...chipFit(sid, both), `DSMorph.build(document.getElementById("${sid}-morph"));`],
2701
3049
  plugins: ["dsMorph"],
3050
+ ...notes.length ? { warnings: notes } : {},
2702
3051
  holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
2703
3052
  css: [
2704
3053
  chromeCss(theme),
@@ -3279,16 +3628,16 @@ var lineChart = (beat, ctx) => {
3279
3628
  </svg>${readout}
3280
3629
  </div>`;
3281
3630
  const draw2 = 0.8;
3282
- const count = p.points.length;
3283
- const idealStep = Math.min(0.45, DRAW_SECONDS / count);
3631
+ const count2 = p.points.length;
3632
+ const idealStep = Math.min(0.45, DRAW_SECONDS / count2);
3284
3633
  const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
3285
- const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count - 1) : 0;
3634
+ const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count2 - 1) : 0;
3286
3635
  const tailAfter = (s) => Math.max(
3287
- s * count + 0.4,
3636
+ s * count2 + 0.4,
3288
3637
  // the ring: its walk, then the 0.4s it takes to leave
3289
- 0.3 + s * (count - 1),
3638
+ 0.3 + s * (count2 - 1),
3290
3639
  // the dots
3291
- 0.5 + s * (count - 1),
3640
+ 0.5 + s * (count2 - 1),
3292
3641
  ...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
3293
3642
  );
3294
3643
  const room = beat.seconds - spine;
@@ -3351,8 +3700,8 @@ var lineChart = (beat, ctx) => {
3351
3700
  )
3352
3701
  );
3353
3702
  }
3354
- const walk = cmp ? sec(step * count) : DRAW_SECONDS;
3355
- if (count > 1) {
3703
+ const walk = cmp ? sec(step * count2) : DRAW_SECONDS;
3704
+ if (count2 > 1) {
3356
3705
  const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
3357
3706
  tl.push(
3358
3707
  tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
@@ -3367,7 +3716,7 @@ var lineChart = (beat, ctx) => {
3367
3716
  )
3368
3717
  );
3369
3718
  }
3370
- const drawn = settled + (cmp ? tailAfter(step) : step * count + 0.4);
3719
+ const drawn = settled + (cmp ? tailAfter(step) : step * count2 + 0.4);
3371
3720
  const holds = cmp ? [lift2, drawn] : [drawn];
3372
3721
  if (p.readout) {
3373
3722
  tl.push(
@@ -3383,7 +3732,7 @@ var lineChart = (beat, ctx) => {
3383
3732
  return {
3384
3733
  ...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
3385
3734
  warnings: [
3386
- `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.`
3735
+ `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.`
3387
3736
  ]
3388
3737
  };
3389
3738
  }
@@ -3463,7 +3812,7 @@ var V_BOX_H = 268;
3463
3812
  var V_LOOP_SIDE = 56;
3464
3813
  var V_LOOP_PAD = 18;
3465
3814
  function columnLayout(stageW, stageH, stages, face, loop) {
3466
- const count = stages.length;
3815
+ const count2 = stages.length;
3467
3816
  const loopW = loop ? Math.max(240, Math.round(stageW * 0.32)) : 0;
3468
3817
  const boxX = M;
3469
3818
  const boxW = stageW - 2 * M - (loop ? V_LOOP_SIDE + V_LOOP_PAD + loopW : 0);
@@ -3480,11 +3829,11 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3480
3829
  return Math.ceil(2 * PAD_Y2 + label + (note2 > 0 ? NOTE_TOP + note2 * NOTE * NOTE_LH : 0));
3481
3830
  })
3482
3831
  );
3483
- const room = (stageH - 2 * M - (count - 1) * V_GAP) / count;
3832
+ const room = (stageH - 2 * M - (count2 - 1) * V_GAP) / count2;
3484
3833
  const boxH = Math.max(need, Math.min(room, V_BOX_H));
3485
3834
  return {
3486
3835
  size: size2,
3487
- boxes: Array.from({ length: count }, (_, i) => ({ x: M + i * (boxH + V_GAP), w: boxH })),
3836
+ boxes: Array.from({ length: count2 }, (_, i) => ({ x: M + i * (boxH + V_GAP), w: boxH })),
3488
3837
  boxH,
3489
3838
  boxX,
3490
3839
  boxW,
@@ -3496,7 +3845,7 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3496
3845
  loopLines: loop ? wrap(loop.label, NOTE, loopW, 500, 0, face) : [],
3497
3846
  loopX: boxX + boxW + V_LOOP_SIDE,
3498
3847
  loopW,
3499
- svgH: 2 * M + count * boxH + (count - 1) * V_GAP
3848
+ svgH: 2 * M + count2 * boxH + (count2 - 1) * V_GAP
3500
3849
  };
3501
3850
  }
3502
3851
  function balance(label, k, face) {
@@ -4214,8 +4563,8 @@ function stackLayout(p, format, face = "latin") {
4214
4563
  if (inline2.fits) return inline2;
4215
4564
  return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
4216
4565
  }
4217
- function labelWeight(i, count) {
4218
- return i === count - 1 ? 700 : 600;
4566
+ function labelWeight(i, count2) {
4567
+ return i === count2 - 1 ? 700 : 600;
4219
4568
  }
4220
4569
  function floorFor(p, format) {
4221
4570
  if (!p.tilt) return MIN_FONT;
@@ -4225,7 +4574,7 @@ function solve2(p, format, inline2, face) {
4225
4574
  const width = contentW(format);
4226
4575
  const boxH = contentH(format);
4227
4576
  const floor = floorFor(p, format);
4228
- const count = p.layers.length;
4577
+ const count2 = p.layers.length;
4229
4578
  const k = isPortrait(format) ? "tall" : "wide";
4230
4579
  const riseMax = RISE_MAX[k];
4231
4580
  const syMax = SY_MAX[k];
@@ -4233,7 +4582,7 @@ function solve2(p, format, inline2, face) {
4233
4582
  const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4234
4583
  const want = Math.max(
4235
4584
  ...p.layers.map(
4236
- (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
4585
+ (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count2), 0, false, face) + noteW(l)
4237
4586
  )
4238
4587
  );
4239
4588
  const colCap = inline2 ? width * 0.56 : width * 0.5;
@@ -4242,7 +4591,7 @@ function solve2(p, format, inline2, face) {
4242
4591
  const labelX = width - colW;
4243
4592
  const labelRoom = Math.min(
4244
4593
  ...p.layers.map(
4245
- (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count), 0, false, face))
4594
+ (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count2), 0, false, face))
4246
4595
  )
4247
4596
  );
4248
4597
  const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
@@ -4250,7 +4599,7 @@ function solve2(p, format, inline2, face) {
4250
4599
  const nw = noteW(l);
4251
4600
  const labelMaxW = Math.max(labelSize, colW - nw);
4252
4601
  return {
4253
- label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4602
+ label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count2), 0, face),
4254
4603
  // Inline notes stay on one line by contract — the schema calls a note "one
4255
4604
  // short line" — and wrapping one would put its second line under the label.
4256
4605
  note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
@@ -4268,13 +4617,13 @@ function solve2(p, format, inline2, face) {
4268
4617
  const noteH = noteHeight(p.note, noteWidth(format), 28, face);
4269
4618
  const free = boxH - chromeH - 20 - noteH;
4270
4619
  const avail = Math.max(300, free);
4271
- const room = (avail - 2 * pad - syMax - tMax) / Math.max(1, count - 1);
4620
+ const room = (avail - 2 * pad - syMax - tMax) / Math.max(1, count2 - 1);
4272
4621
  const rise = clamp(room, RISE_MIN, riseMax);
4273
4622
  const sy = clamp(rise * 0.62, 46, syMax);
4274
4623
  const t2 = clamp(rise * 0.14, 10, tMax);
4275
4624
  const span = width - NUM_W - GAP3 - colW;
4276
4625
  const sx = clamp(span * 0.2, 110, 220);
4277
- const height = 2 * pad + (count - 1) * rise + sy + t2;
4626
+ const height = 2 * pad + (count2 - 1) * rise + sy + t2;
4278
4627
  return {
4279
4628
  // A rise shorter than a label block prints the note through the label below.
4280
4629
  // BOTH directions. A layout that fits the height and not the width is not a
@@ -4299,7 +4648,7 @@ function solve2(p, format, inline2, face) {
4299
4648
  labelSize,
4300
4649
  blockH,
4301
4650
  pad,
4302
- yBase: pad + (count - 1) * rise + sy,
4651
+ yBase: pad + (count2 - 1) * rise + sy,
4303
4652
  lines
4304
4653
  };
4305
4654
  }
@@ -4316,11 +4665,11 @@ var stack = (beat, ctx) => {
4316
4665
  const p = beat.params;
4317
4666
  const face = faceOf(ctx.theme.fontStack);
4318
4667
  const L = stackLayout(p, ctx.format, face);
4319
- const count = p.layers.length;
4320
- const last = count - 1;
4668
+ const count2 = p.layers.length;
4669
+ const last = count2 - 1;
4321
4670
  if (!L.fits) {
4322
4671
  throw new Error(
4323
- `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.`
4672
+ `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.`
4324
4673
  );
4325
4674
  }
4326
4675
  const parts = {};
@@ -4348,7 +4697,7 @@ var stack = (beat, ctx) => {
4348
4697
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
4349
4698
  {
4350
4699
  size: L.labelSize,
4351
- weight: labelWeight(i, count),
4700
+ weight: labelWeight(i, count2),
4352
4701
  fill: top ? theme.tones.b : theme.fg,
4353
4702
  maxWidth: block.labelMaxW,
4354
4703
  lineHeight: 1.16,
@@ -4402,10 +4751,10 @@ var stack = (beat, ctx) => {
4402
4751
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4403
4752
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4404
4753
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe2)}</div>${noteHtml}`;
4405
- const centre2 = (count - 1) / 2;
4754
+ const centre2 = (count2 - 1) / 2;
4406
4755
  const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4407
4756
  const first = 0.9;
4408
- const step = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count));
4757
+ const step = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count2));
4409
4758
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
4410
4759
  const holds = [];
4411
4760
  const spot = spotlighter(sid);
@@ -4437,11 +4786,11 @@ var stack = (beat, ctx) => {
4437
4786
  );
4438
4787
  }
4439
4788
  if (p.note) {
4440
- const at = first + count * step;
4789
+ const at = first + count2 * step;
4441
4790
  tl.push(tween(`#${sid}-note`, { opacity: 0 }, { opacity: 1, duration: 0.6 }, at));
4442
4791
  holds.push(at + 0.7);
4443
4792
  }
4444
- if (count > 1) tl.push(...spot.restore(first + count * step));
4793
+ if (count2 > 1) tl.push(...spot.restore(first + count2 * step));
4445
4794
  return {
4446
4795
  html,
4447
4796
  parts,
@@ -5782,8 +6131,8 @@ async function narrate(storyboard, source, prefs, opts) {
5782
6131
  const text2 = beat.narration?.trim();
5783
6132
  if (!text2) continue;
5784
6133
  const segments2 = [];
5785
- const count = stopsFor(beat, source, format, `s${i + 1}`, theme);
5786
- const plan = planSegments(text2, Math.min(count, paced.speakingStops));
6134
+ const count2 = stopsFor(beat, source, format, `s${i + 1}`, theme);
6135
+ const plan = planSegments(text2, Math.min(count2, paced.speakingStops));
5787
6136
  for (const [stop, line2] of plan.entries()) {
5788
6137
  if (!line2) continue;
5789
6138
  const speech = await synthesize(line2, {
@@ -5805,7 +6154,7 @@ async function narrate(storyboard, source, prefs, opts) {
5805
6154
  }
5806
6155
  if (segments2.length > 0) {
5807
6156
  beats[beat.id] = segments2;
5808
- staged[beat.id] = count;
6157
+ staged[beat.id] = count2;
5809
6158
  }
5810
6159
  }
5811
6160
  return {
@@ -6695,11 +7044,11 @@ function stageScene(scene, speed) {
6695
7044
  function speechPlan(open, holds, segments2) {
6696
7045
  const starts = [];
6697
7046
  let at = open;
6698
- for (const [i, segment] of segments2.entries()) {
6699
- const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
7047
+ for (const [i, segment2] of segments2.entries()) {
7048
+ const hold = holds[Math.min(segment2.stop, holds.length - 1)] ?? 0;
6700
7049
  if (i > 0) at = Math.max(at, hold);
6701
7050
  starts.push(at);
6702
- at += segment.seconds;
7051
+ at += segment2.seconds;
6703
7052
  }
6704
7053
  return { starts, end: at };
6705
7054
  }
@@ -6927,10 +7276,10 @@ function pack(words2, width) {
6927
7276
  function splitCue(cue, max = CUE_MAX_CHARS) {
6928
7277
  if (cue.text.length <= max) return [cue];
6929
7278
  const words2 = cue.text.split(/\s+/).filter(Boolean);
6930
- const count = pack(words2, max).length;
7279
+ const count2 = pack(words2, max).length;
6931
7280
  let width = max;
6932
- for (let w = Math.ceil(cue.text.length / count); w < max; w++) {
6933
- if (pack(words2, w).length <= count) {
7281
+ for (let w = Math.ceil(cue.text.length / count2); w < max; w++) {
7282
+ if (pack(words2, w).length <= count2) {
6934
7283
  width = w;
6935
7284
  break;
6936
7285
  }
@@ -7022,17 +7371,17 @@ function place(scenes, spoken) {
7022
7371
  );
7023
7372
  }
7024
7373
  const { starts } = speechPlan(scene.open, scene.holds, segments2);
7025
- for (const [i, segment] of segments2.entries()) {
7026
- const index = Math.min(segment.stop, scene.holds.length - 1);
7374
+ for (const [i, segment2] of segments2.entries()) {
7375
+ const index = Math.min(segment2.stop, scene.holds.length - 1);
7027
7376
  out.push({
7028
- id: `${scene.id}.${segment.stop}`,
7377
+ id: `${scene.id}.${segment2.stop}`,
7029
7378
  scene: scene.id,
7030
- stop: segment.stop,
7031
- audio: segment.audio,
7379
+ stop: segment2.stop,
7380
+ audio: segment2.audio,
7032
7381
  hold: round5(scene.start + scene.holds[index]),
7033
7382
  start: round5(scene.start + starts[i]),
7034
- duration: segment.seconds,
7035
- cues: segment.cues.flatMap((c) => splitCue(c, cueMax(c.text)))
7383
+ duration: segment2.seconds,
7384
+ cues: segment2.cues.flatMap((c) => splitCue(c, cueMax(c.text)))
7036
7385
  });
7037
7386
  }
7038
7387
  }
@@ -7109,6 +7458,15 @@ function planTiming(input) {
7109
7458
  // `emitDeck` and `writeTiming` are handed the same `format`, so this cannot
7110
7459
  // disagree with the box the archetypes actually drew into.
7111
7460
  captionReserve: format.captionReserve ?? 0,
7461
+ // An unbudgeted format's limit is Infinity, which JSON writes as `null`.
7462
+ // Left out instead: recorded as a format with no limit, never with a limit
7463
+ // of null or zero.
7464
+ format: {
7465
+ id: format.id,
7466
+ minWeight: format.minWeight,
7467
+ ...Number.isFinite(format.maxSeconds) && { maxSeconds: format.maxSeconds },
7468
+ ...format.warnSeconds !== void 0 && { warnSeconds: format.warnSeconds }
7469
+ },
7112
7470
  scenes,
7113
7471
  segments: segments2
7114
7472
  };
@@ -7135,15 +7493,15 @@ function framePlan(timing, fps) {
7135
7493
  );
7136
7494
  }
7137
7495
  pieces.push({ from: first, motion: last - first, freeze: 0 });
7138
- for (const segment of segments2) {
7139
- const at = f(segment.start);
7496
+ for (const segment2 of segments2) {
7497
+ const at = f(segment2.start);
7140
7498
  audio.push({
7141
- id: segment.id,
7142
- audio: segment.audio,
7499
+ id: segment2.id,
7500
+ audio: segment2.audio,
7143
7501
  startFrame: at,
7144
7502
  delayMs: Math.round(at / fps * 1e3)
7145
7503
  });
7146
- for (const cue of segment.cues) {
7504
+ for (const cue of segment2.cues) {
7147
7505
  const start = at / fps + cue.start;
7148
7506
  const end = at / fps + cue.end;
7149
7507
  if (end > start) cues.push({ start, end, text: cue.text });
@@ -7717,6 +8075,22 @@ async function resolveChrome(need = "open the deck with") {
7717
8075
  async function chromePath(need = "open the deck with") {
7718
8076
  return (await resolveChrome(need)).path;
7719
8077
  }
8078
+ function sandboxMissing(err) {
8079
+ return /No usable sandbox/.test(err instanceof Error ? err.message : String(err));
8080
+ }
8081
+ var SANDBOX_FIX = "`sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, or give Chrome an AppArmor profile";
8082
+ async function launchOwnPage(launch, args, what) {
8083
+ try {
8084
+ return await launch(args);
8085
+ } catch (err) {
8086
+ if (!sandboxMissing(err)) throw err;
8087
+ process.stderr.write(
8088
+ `chrome: this host gives Chrome no sandbox, so ${what} runs without one, as hyperframes' render always does. To keep the sandbox: ${SANDBOX_FIX}.
8089
+ `
8090
+ );
8091
+ return launch([...args, "--no-sandbox"]);
8092
+ }
8093
+ }
7720
8094
  function runtimePath() {
7721
8095
  return createRequire3(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
7722
8096
  }
@@ -7734,10 +8108,9 @@ async function openDeck(dir, opts = {}) {
7734
8108
  const timeout = opts.timeoutMs ?? 6e4;
7735
8109
  const { default: puppeteer } = await import("puppeteer-core");
7736
8110
  const chrome2 = await resolveChrome();
7737
- const browser = await puppeteer.launch({
7738
- executablePath: chrome2.path,
7739
- headless: true,
7740
- args: [
8111
+ const browser = await launchOwnPage(
8112
+ (args) => puppeteer.launch({ executablePath: chrome2.path, headless: true, args }),
8113
+ [
7741
8114
  // A retina host would otherwise hand back a 2x frame, whose clip is not the
7742
8115
  // renderer's.
7743
8116
  "--force-device-scale-factor=1",
@@ -7750,8 +8123,9 @@ async function openDeck(dir, opts = {}) {
7750
8123
  "--use-gl=angle",
7751
8124
  "--use-angle=swiftshader",
7752
8125
  "--enable-unsafe-swiftshader"
7753
- ]
7754
- });
8126
+ ],
8127
+ "the gates' page"
8128
+ );
7755
8129
  try {
7756
8130
  const page = await browser.newPage();
7757
8131
  await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
@@ -8772,6 +9146,14 @@ async function readInBrowser(html, timeoutMs) {
8772
9146
  // happens to be that run.
8773
9147
  "--host-resolver-rules=MAP * ~NOTFOUND"
8774
9148
  ]
9149
+ // No `--no-sandbox` fallback here, unlike `launchOwnPage`: this is a page
9150
+ // from the web, and a renderer bug it triggers is exactly what the sandbox
9151
+ // contains. Refused, with the fix, rather than Chrome's raw FATAL.
9152
+ }).catch((err) => {
9153
+ if (!sandboxMissing(err)) throw err;
9154
+ throw new Error(
9155
+ `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.`
9156
+ );
8775
9157
  });
8776
9158
  try {
8777
9159
  const page = await browser.newPage();
@@ -10230,8 +10612,13 @@ function renderSource(source) {
10230
10612
  }
10231
10613
  }
10232
10614
  out.push("", "== EQUATIONS ==");
10233
- for (const e of source.equations)
10234
- out.push(`[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${e.tex}`);
10615
+ for (const e of source.equations) {
10616
+ const fixed = repairTex(e.tex, true);
10617
+ const note2 = fixed.error === null ? "" : " (KaTeX cannot draw this one: a walk shows it as plain source, a morph cannot use it)";
10618
+ out.push(
10619
+ `[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${fixed.error === null ? fixed.tex : e.tex}${note2}`
10620
+ );
10621
+ }
10235
10622
  if (!source.equations.length)
10236
10623
  out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
10237
10624
  out.push("", "== TABLES ==");
@@ -10805,10 +11192,10 @@ function drawSvg(req) {
10805
11192
  const seed = createHash7("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
10806
11193
  const rand = mulberry32(seed.readUInt32BE(0));
10807
11194
  const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
10808
- const count = 6 + Math.floor(rand() * 5);
11195
+ const count2 = 6 + Math.floor(rand() * 5);
10809
11196
  const unit = Math.min(W, H);
10810
11197
  const parts = [`<rect width="${W}" height="${H}" fill="${PAPER}"/>`];
10811
- for (let i = 0; i < count; i++) {
11198
+ for (let i = 0; i < count2; i++) {
10812
11199
  const kind = Math.floor(rand() * 3);
10813
11200
  const cx = Math.round(W * (0.15 + 0.7 * rand()));
10814
11201
  const cy = Math.round(H * (0.15 + 0.7 * rand()));
@@ -11031,10 +11418,10 @@ function readCanvas(html) {
11031
11418
  function profilesFor(width, height, formats = FORMATS) {
11032
11419
  return Object.values(formats).filter((f) => f.width === width && f.height === height);
11033
11420
  }
11034
- function scanBudget(html, storyboard, kept, formats = FORMATS) {
11421
+ function scanBudget(html, storyboard, kept, formats = FORMATS, recorded) {
11035
11422
  const canvas = readCanvas(html);
11036
11423
  if (!canvas) return [];
11037
- const profiles = profilesFor(canvas.width, canvas.height, formats);
11424
+ const profiles = recorded && recorded.width === canvas.width && recorded.height === canvas.height ? [recorded] : profilesFor(canvas.width, canvas.height, formats);
11038
11425
  if (profiles.length === 0) {
11039
11426
  return [
11040
11427
  {
@@ -11762,6 +12149,20 @@ function captionBottom(sid, selector, fallbackPx) {
11762
12149
  }
11763
12150
  return bottom > 0 ? bottom : fallbackPx;
11764
12151
  }
12152
+ function collectUnfit(attr) {
12153
+ return Array.from(document.querySelectorAll(`[${attr}]`)).map((el) => ({
12154
+ sid: el.closest("[data-composition-id]")?.getAttribute("data-composition-id") ?? "?",
12155
+ tex: el.getAttribute(attr) ?? ""
12156
+ }));
12157
+ }
12158
+ function gradeUnfit(rows) {
12159
+ return rows.map((r) => ({
12160
+ severity: "error",
12161
+ gate: "fidelity",
12162
+ rule: "math_unfit",
12163
+ 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.`
12164
+ }));
12165
+ }
11765
12166
  async function fidelity(dir, opts = {}) {
11766
12167
  const started = Date.now();
11767
12168
  const floor = opts.floor ?? INK_FLOOR;
@@ -11785,6 +12186,10 @@ async function fidelity(dir, opts = {}) {
11785
12186
  try {
11786
12187
  deck = await openDeck(dir, { timeoutMs: opts.timeoutMs });
11787
12188
  const { page, height } = deck;
12189
+ await page.waitForFunction("window.__hfTimelinesBuilding === false", {
12190
+ timeout: opts.timeoutMs ?? 6e4
12191
+ });
12192
+ const unfit = await page.evaluate(collectUnfit, UNFIT_ATTR);
11788
12193
  const measured = [];
11789
12194
  const collided = [];
11790
12195
  const apparent = [];
@@ -11828,7 +12233,8 @@ async function fidelity(dir, opts = {}) {
11828
12233
  ...gradeFidelity(measured, floor),
11829
12234
  ...gradeReserve(measured),
11830
12235
  ...gradeOverprint(collided),
11831
- ...gradeApparent(apparent)
12236
+ ...gradeApparent(apparent),
12237
+ ...gradeUnfit(unfit)
11832
12238
  ],
11833
12239
  elapsedMs: Date.now() - started
11834
12240
  };
@@ -12175,9 +12581,13 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
12175
12581
  await readFile14(join13(dir, DECK_PAGE), "utf8").catch(() => ""),
12176
12582
  await listFiles(dir)
12177
12583
  );
12178
- const budget2 = html.flatMap(([, text2]) => scanBudget(text2, storyboard, kept));
12584
+ const timing = await readTiming(dir);
12585
+ const recorded = typeof timing?.format?.id === "string" ? { ...timing.format, width: timing.width, height: timing.height } : void 0;
12586
+ const budget2 = html.flatMap(
12587
+ ([, text2]) => scanBudget(text2, storyboard, kept, void 0, recorded)
12588
+ );
12179
12589
  const type = html.flatMap(([file, text2]) => scanTypeFloor(text2, file));
12180
- const lead = kept ? await readTiming(dir).then((t2) => t2 ? scanNarrationLead(kept, t2) : []) : [];
12590
+ const lead = kept && timing ? scanNarrationLead(kept, timing) : [];
12181
12591
  const ours = [...determinism, ...narration, ...budget2, ...type, ...lead];
12182
12592
  const stops = await declaredStops(dir);
12183
12593
  const [verdict, frames2] = await Promise.all([
@@ -12366,6 +12776,31 @@ function scanUnusedFigures(storyboard, source) {
12366
12776
  }
12367
12777
  ];
12368
12778
  }
12779
+ function scanMath(storyboard, source) {
12780
+ const out = [];
12781
+ const say = (rule, message2) => {
12782
+ out.push({ severity: "warning", gate: "storyboard", rule, message: message2 });
12783
+ };
12784
+ for (const beat of storyboard.beats) {
12785
+ try {
12786
+ let notes = [];
12787
+ if (beat.archetype === "equation-walk") {
12788
+ const params = beat.params;
12789
+ const eq = source.equations.find((e) => e.id === params.equationId);
12790
+ if (eq) notes = prepareWalk(eq, params.terms, beat.id).notes;
12791
+ } else if (beat.archetype === "equation-morph") {
12792
+ notes = prepareMorph(beat, source).notes;
12793
+ }
12794
+ for (const n3 of notes) say("tex_repaired", `${beat.id}: ${n3}`);
12795
+ } catch (err) {
12796
+ say(
12797
+ "tex_unrenderable",
12798
+ `${beat.id}: ${err instanceof Error ? err.message : String(err)} \u2014 build will leave this beat out`
12799
+ );
12800
+ }
12801
+ }
12802
+ return out;
12803
+ }
12369
12804
  function figuresShown2(params) {
12370
12805
  const sides = ["left", "right"].map(
12371
12806
  (side) => params[side]?.figureId
@@ -12447,14 +12882,14 @@ function scanNarrationLead(beats, timing) {
12447
12882
  return findings;
12448
12883
  }
12449
12884
  function firstMention(segments2, label) {
12450
- for (const segment of segments2) {
12451
- for (const cue of segment.cues) {
12885
+ for (const segment2 of segments2) {
12886
+ for (const cue of segment2.cues) {
12452
12887
  const words2 = cue.text.split(/\s+/);
12453
12888
  let at = 0;
12454
12889
  for (const word of words2) {
12455
12890
  if (mentions(word, label)) {
12456
12891
  const share2 = cue.text.length > 0 ? at / cue.text.length : 0;
12457
- return segment.start + cue.start + share2 * (cue.end - cue.start);
12892
+ return segment2.start + cue.start + share2 * (cue.end - cue.start);
12458
12893
  }
12459
12894
  at += word.length + 1;
12460
12895
  }
@@ -12589,7 +13024,7 @@ ${bands}
12589
13024
  </div>
12590
13025
  `;
12591
13026
  }
12592
- function measure3(count) {
13027
+ function measure3(count2) {
12593
13028
  const doc = document;
12594
13029
  const probe2 = doc.getElementById("c0");
12595
13030
  if (probe2) {
@@ -12602,7 +13037,7 @@ function measure3(count) {
12602
13037
  probe2.classList.remove("on");
12603
13038
  }
12604
13039
  const rects = [];
12605
- for (let i = 0; i < count; i++) {
13040
+ for (let i = 0; i < count2; i++) {
12606
13041
  const el = doc.getElementById(`c${i}`);
12607
13042
  if (!el) continue;
12608
13043
  el.classList.add("on");
@@ -12630,11 +13065,12 @@ async function renderCaptions(cues, style, deck, work) {
12630
13065
  await mkdir8(work, { recursive: true });
12631
13066
  await writeFile10(page, captionPage(cues, style, href));
12632
13067
  const { default: puppeteer } = await import("puppeteer-core");
12633
- const browser = await puppeteer.launch({
12634
- executablePath: await chromePath(CAPTION_NEED),
12635
- headless: true,
12636
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
12637
- });
13068
+ const executablePath = await chromePath(CAPTION_NEED);
13069
+ const browser = await launchOwnPage(
13070
+ (args) => puppeteer.launch({ executablePath, headless: true, args }),
13071
+ ["--force-device-scale-factor=1", "--hide-scrollbars"],
13072
+ "the caption band"
13073
+ );
12638
13074
  try {
12639
13075
  const tab = await browser.newPage();
12640
13076
  await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
@@ -13294,6 +13730,7 @@ export {
13294
13730
  sampleTimes,
13295
13731
  scanBeatCount,
13296
13732
  scanHeadlines,
13733
+ scanMath,
13297
13734
  scanNarrationDrift,
13298
13735
  scanNarrationLead,
13299
13736
  scanPaperArc,