@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/mcp.js CHANGED
@@ -218,6 +218,11 @@ async function copyAudio(from, narration, out, step) {
218
218
  return names.map((n3) => join3(dir, n3));
219
219
  }
220
220
  async function refreshFont(storyboard, source, out, step) {
221
+ if (familyFor(storyboard.lang) === null) {
222
+ const css = await vendorInter(join3(out, "assets", "fonts"));
223
+ step("build: vendored Inter beside the deck");
224
+ return css;
225
+ }
221
226
  try {
222
227
  const bundle = await bundleFont(
223
228
  storyboard.lang,
@@ -233,6 +238,21 @@ async function refreshFont(storyboard, source, out, step) {
233
238
  return void 0;
234
239
  }
235
240
  }
241
+ async function vendorInter(dir) {
242
+ const pkg = dirname2(createRequire3(import.meta.url).resolve("@fontsource-variable/inter"));
243
+ const css = await readFile2(join3(pkg, "index.css"), "utf8");
244
+ await mkdir2(dir, { recursive: true });
245
+ for (const [, name] of css.matchAll(/url\(\.\/files\/([^)]+)\)/g)) {
246
+ await cp(join3(pkg, "files", name ?? ""), join3(dir, name ?? ""));
247
+ }
248
+ 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')");
249
+ if (/Inter Variable|\.\/files\/|swap/.test(ours) || !ours.includes("font-family: 'Inter';")) {
250
+ throw new Error(
251
+ `@fontsource-variable/inter's index.css changed shape; update vendorInter (${pkg})`
252
+ );
253
+ }
254
+ return ours;
255
+ }
236
256
 
237
257
  // src/emit/kit.ts
238
258
  var PAD_X = 110;
@@ -910,35 +930,35 @@ function elbow(sid, from, to, o) {
910
930
  }
911
931
  return path(d, { ...strokeAttrs(o), "marker-end": `url(#${headId(sid, o.stroke)})` });
912
932
  }
913
- function tracks(width, count, gap, x0 = 0) {
914
- const w = count > 1 ? (width - gap * (count - 1)) / count : width;
915
- return Array.from({ length: count }, (_, i) => ({ x: x0 + i * (w + gap), w }));
933
+ function tracks(width, count2, gap, x0 = 0) {
934
+ const w = count2 > 1 ? (width - gap * (count2 - 1)) / count2 : width;
935
+ return Array.from({ length: count2 }, (_, i) => ({ x: x0 + i * (w + gap), w }));
916
936
  }
917
937
  function fitBoxes(req) {
918
- const count = req.labels.length;
938
+ const count2 = req.labels.length;
919
939
  const minGap = req.minGap ?? 24;
920
940
  const padEm = req.padEm ?? 0.8;
921
941
  const weight = req.weight ?? 600;
922
942
  const x0 = req.x0 ?? 0;
923
943
  const unit = Math.max(1, ...req.labels.map((l) => textWidth(l, 1, weight) + 2 * padEm));
924
- const sizeAt = (gap) => count < 1 ? req.size : (req.width - gap * (count - 1)) / count / unit;
944
+ const sizeAt = (gap) => count2 < 1 ? req.size : (req.width - gap * (count2 - 1)) / count2 / unit;
925
945
  if (sizeAt(req.gap) >= req.size) {
926
- return { ok: true, size: req.size, gap: req.gap, boxes: tracks(req.width, count, req.gap, x0) };
946
+ return { ok: true, size: req.size, gap: req.gap, boxes: tracks(req.width, count2, req.gap, x0) };
927
947
  }
928
- if (count > 1 && sizeAt(minGap) >= req.size) {
929
- const gap = (req.width - count * req.size * unit) / (count - 1);
930
- return { ok: true, size: req.size, gap, boxes: tracks(req.width, count, gap, x0) };
948
+ if (count2 > 1 && sizeAt(minGap) >= req.size) {
949
+ const gap = (req.width - count2 * req.size * unit) / (count2 - 1);
950
+ return { ok: true, size: req.size, gap, boxes: tracks(req.width, count2, gap, x0) };
931
951
  }
932
952
  const size = sizeAt(minGap);
933
953
  if (size >= MIN_FONT) {
934
- return { ok: true, size, gap: minGap, boxes: tracks(req.width, count, minGap, x0) };
954
+ return { ok: true, size, gap: minGap, boxes: tracks(req.width, count2, minGap, x0) };
935
955
  }
936
956
  return {
937
957
  ok: false,
938
958
  size: MIN_FONT,
939
959
  gap: minGap,
940
- boxes: tracks(req.width, count, minGap, x0),
941
- needed: count * MIN_FONT * unit + minGap * (count - 1)
960
+ boxes: tracks(req.width, count2, minGap, x0),
961
+ needed: count2 * MIN_FONT * unit + minGap * (count2 - 1)
942
962
  };
943
963
  }
944
964
 
@@ -1271,8 +1291,8 @@ var MID_GAP = 56;
1271
1291
  var BAND_GAP = 34;
1272
1292
  var COL_GAP = 28;
1273
1293
  var MIN_COL = 250;
1274
- function lineBudget(count, stageH) {
1275
- const perSide = Math.max(1, Math.ceil(count / 2));
1294
+ function lineBudget(count2, stageH) {
1295
+ const perSide = Math.max(1, Math.ceil(count2 / 2));
1276
1296
  const room = (stageH - (perSide - 1) * LAB_GAP) / perSide;
1277
1297
  const lines = Math.floor((room - ASCENT - RULE_LIFT - RULE_W) / (LAB * LAB_LH)) + 1;
1278
1298
  return Math.min(LINE_CEIL, Math.max(1, lines));
@@ -1683,7 +1703,7 @@ var HEAD_GAP = 14;
1683
1703
  var barCompare = (beat, ctx) => {
1684
1704
  const { sid, theme } = ctx;
1685
1705
  const p = beat.params;
1686
- const count = p.bars.length;
1706
+ const count2 = p.bars.length;
1687
1707
  const W = contentW(ctx.format);
1688
1708
  const face = faceOf(ctx.theme.fontStack);
1689
1709
  const tall = isPortrait(ctx.format);
@@ -1697,7 +1717,7 @@ var barCompare = (beat, ctx) => {
1697
1717
  0,
1698
1718
  face
1699
1719
  );
1700
- let bar = Math.min(BAR_MAX, (avail - unitBand - FOOT) / (count + GAP_RATIO * (count - 1)));
1720
+ let bar = Math.min(BAR_MAX, (avail - unitBand - FOOT) / (count2 + GAP_RATIO * (count2 - 1)));
1701
1721
  let pitch = bar * (1 + GAP_RATIO);
1702
1722
  const gutterMax = tall ? 0 : share(GUTTER_MAX, W);
1703
1723
  const gutterInner = tall ? W : gutterMax - GUTTER_PAD;
@@ -1719,7 +1739,7 @@ var barCompare = (beat, ctx) => {
1719
1739
  if (tall) {
1720
1740
  bar = Math.min(
1721
1741
  BAR_MAX,
1722
- (avail - unitBand - FOOT - count * head) / (count + GAP_RATIO * (count - 1))
1742
+ (avail - unitBand - FOOT - count2 * head) / (count2 + GAP_RATIO * (count2 - 1))
1723
1743
  );
1724
1744
  pitch = head + bar * (1 + GAP_RATIO);
1725
1745
  } else {
@@ -1730,7 +1750,7 @@ var barCompare = (beat, ctx) => {
1730
1750
  }
1731
1751
  }
1732
1752
  const valueSize = Math.max(MIN_FONT, Math.min(VALUE_MAX, bar * 0.9));
1733
- const barsH = (count - 1) * pitch + head + bar;
1753
+ const barsH = (count2 - 1) * pitch + head + bar;
1734
1754
  const foot = Math.max(
1735
1755
  0,
1736
1756
  Math.ceil(
@@ -1743,7 +1763,7 @@ var barCompare = (beat, ctx) => {
1743
1763
  const H = barsH + unitBand + foot;
1744
1764
  if (H > avail) {
1745
1765
  throw new Error(
1746
- `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.`
1766
+ `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.`
1747
1767
  );
1748
1768
  }
1749
1769
  const gutter = tall ? 0 : Math.min(
@@ -1896,7 +1916,7 @@ ${svg(id(sid, "chart"), W, H, body)}
1896
1916
  </div>${note}`;
1897
1917
  const railsAt = 0.6;
1898
1918
  const barsAt = 0.95;
1899
- const step = Math.min(0.4, 2.4 / count);
1919
+ const step = Math.min(0.4, 2.4 / count2);
1900
1920
  const grow = 0.85;
1901
1921
  const tl = [
1902
1922
  ...chromeIn(sid, p.eyebrow !== void 0),
@@ -1946,11 +1966,11 @@ ${svg(id(sid, "chart"), W, H, body)}
1946
1966
  );
1947
1967
  }
1948
1968
  }
1949
- const settled = barsAt + (count - 1) * step + grow + 0.05;
1969
+ const settled = barsAt + (count2 - 1) * step + grow + 0.05;
1950
1970
  const holds = [settled + 0.2];
1951
1971
  const focal = rows.reduce((best, r) => Math.abs(r.value) > Math.abs(best.value) ? r : best);
1952
1972
  const spot = spotlighter(sid, ".bc-bar");
1953
- if (count > 1) tl.push(...spot.lit(`#${id(sid, "bar", focal.i)}`, settled));
1973
+ if (count2 > 1) tl.push(...spot.lit(`#${id(sid, "bar", focal.i)}`, settled));
1954
1974
  const tailAt = settled + 0.3;
1955
1975
  if (p.unit) {
1956
1976
  tl.push(tween(`#${id(sid, "unit")}`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, tailAt));
@@ -2443,6 +2463,90 @@ var dataTable = (beat, ctx) => {
2443
2463
  };
2444
2464
  };
2445
2465
 
2466
+ // src/emit/tex.ts
2467
+ import katex from "katex";
2468
+ var UNFIT_ATTR = "data-ds-unfit";
2469
+ var trust = (c) => c.command === "\\htmlClass";
2470
+ function texError(tex, display) {
2471
+ try {
2472
+ katex.renderToString(tex, {
2473
+ displayMode: display,
2474
+ throwOnError: true,
2475
+ strict: false,
2476
+ trust,
2477
+ output: "html"
2478
+ });
2479
+ return null;
2480
+ } catch (err) {
2481
+ return err instanceof Error ? err.message : String(err);
2482
+ }
2483
+ }
2484
+ var LEFT = /\\left(?![a-zA-Z])/g;
2485
+ var RIGHT = /\\right(?![a-zA-Z])/g;
2486
+ var NO_INK = /\\label\{[^{}]*\}|\\nonumber(?![a-zA-Z])|\\notag(?![a-zA-Z])/g;
2487
+ function braceBalance(tex) {
2488
+ let depth = 0;
2489
+ for (let i = 0; i < tex.length; i++) {
2490
+ const c = tex[i];
2491
+ if (c === "\\") {
2492
+ i++;
2493
+ continue;
2494
+ }
2495
+ if (c === "{") depth++;
2496
+ else if (c === "}") depth--;
2497
+ }
2498
+ return depth;
2499
+ }
2500
+ var count = (tex, re) => (tex.match(re) ?? []).length;
2501
+ function repairTex(input, display) {
2502
+ const repairs = [];
2503
+ let tex = input;
2504
+ let error = texError(tex, display);
2505
+ if (error === null) return { tex, repairs, error };
2506
+ const gone = tex.match(NO_INK) ?? [];
2507
+ if (gone.length) {
2508
+ tex = tex.replace(NO_INK, "").trim();
2509
+ repairs.push(`removed ${gone.join(", ")}, which draw nothing`);
2510
+ error = texError(tex, display);
2511
+ }
2512
+ const lefts = count(tex, LEFT);
2513
+ const rights = count(tex, RIGHT);
2514
+ if (error !== null && lefts !== rights) {
2515
+ const n3 = Math.abs(lefts - rights);
2516
+ tex = lefts > rights ? `${tex} ${"\\right.".repeat(n3)}` : `${"\\left.".repeat(n3)} ${tex}`;
2517
+ repairs.push(
2518
+ `${lefts} \\left against ${rights} \\right: added ${n3} invisible ${lefts > rights ? "\\right." : "\\left."} to balance them`
2519
+ );
2520
+ error = texError(tex, display);
2521
+ }
2522
+ const depth = braceBalance(tex);
2523
+ if (error !== null && depth !== 0) {
2524
+ const n3 = Math.abs(depth);
2525
+ tex = depth > 0 ? `${tex}${"}".repeat(n3)}` : `${"{".repeat(n3)}${tex}`;
2526
+ repairs.push(
2527
+ `${n3} unclosed brace group(s): ${depth > 0 ? "closed at the end" : "opened at the start"}`
2528
+ );
2529
+ error = texError(tex, display);
2530
+ }
2531
+ const undefinedNames = [];
2532
+ for (let round6 = 0; error !== null && round6 < 12; round6++) {
2533
+ const m = /Undefined control sequence: \\([a-zA-Z]+)/.exec(error);
2534
+ if (!m) break;
2535
+ const name = m[1];
2536
+ const next = tex.replace(new RegExp(`\\\\${name}(?![a-zA-Z])`, "g"), `\\operatorname{${name}}`);
2537
+ if (next === tex) break;
2538
+ tex = next;
2539
+ undefinedNames.push(`\\${name}`);
2540
+ error = texError(tex, display);
2541
+ }
2542
+ if (undefinedNames.length) {
2543
+ repairs.push(
2544
+ `${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`
2545
+ );
2546
+ }
2547
+ return { tex, repairs, error };
2548
+ }
2549
+
2446
2550
  // src/emit/archetypes/equation-walk.ts
2447
2551
  var TRUST = 'function (c) { return c.command === "\\\\htmlClass"; }';
2448
2552
  var OPTS = `{ displayMode: true, trust: ${TRUST}, strict: false, output: "html" }`;
@@ -2457,6 +2561,9 @@ var SYNONYMS = [
2457
2561
  function normalise(tex) {
2458
2562
  let out = "";
2459
2563
  const map = [];
2564
+ const ends = [];
2565
+ const lead = [];
2566
+ let hint = null;
2460
2567
  for (let i = 0; i < tex.length; ) {
2461
2568
  if (/\s/.test(tex[i])) {
2462
2569
  i++;
@@ -2468,19 +2575,19 @@ function normalise(tex) {
2468
2575
  const m = new RegExp(`^(?:${re.source})`).exec(rest);
2469
2576
  return m ? { len: m[0].length, to } : null;
2470
2577
  }).find(Boolean);
2471
- if (hit) {
2472
- for (const ch of hit.to) {
2473
- out += ch;
2474
- map.push(i);
2475
- }
2476
- i += hit.len;
2477
- continue;
2578
+ const len = hit ? hit.len : 1;
2579
+ const chars = hit ? hit.to : tex[i];
2580
+ if (chars === "") hint ??= i;
2581
+ for (const ch of chars) {
2582
+ out += ch;
2583
+ map.push(i);
2584
+ ends.push(i + len);
2585
+ lead.push(hint ?? i);
2586
+ hint = null;
2478
2587
  }
2479
- out += tex[i];
2480
- map.push(i);
2481
- i++;
2588
+ i += len;
2482
2589
  }
2483
- return { text: out, map };
2590
+ return { text: out, map, ends, lead };
2484
2591
  }
2485
2592
  function locate(tex, term) {
2486
2593
  const hay = normalise(tex);
@@ -2488,58 +2595,131 @@ function locate(tex, term) {
2488
2595
  if (needle === "") return null;
2489
2596
  const at = hay.text.indexOf(needle);
2490
2597
  if (at < 0) return null;
2491
- const start = hay.map[at];
2492
- const lastNorm = at + needle.length - 1;
2493
- const lastOrig = hay.map[lastNorm];
2494
- let end = lastOrig + 1;
2495
- while (end < tex.length && (hay.map[lastNorm + 1] ?? tex.length) > end && /\s/.test(tex[end])) {
2496
- end++;
2497
- }
2498
- end = Math.max(end, hay.map[lastNorm + 1] ?? lastOrig + 1);
2598
+ const start = hay.lead[at];
2599
+ const end = hay.ends[at + needle.length - 1];
2499
2600
  return { start, end: Math.min(end, tex.length) };
2500
2601
  }
2501
2602
  function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
2603
+ const checked = texError(tex, true) === null;
2502
2604
  let parts = [{ text: tex, raw: true }];
2503
2605
  const used = [];
2504
2606
  const missing = [];
2607
+ const broken = [];
2608
+ const joined = (ps) => ps.map((p) => p.text).join("");
2505
2609
  for (const term of terms) {
2506
2610
  let placed = false;
2611
+ let found = false;
2507
2612
  for (let i = 0; i < parts.length && !placed; i++) {
2508
2613
  const part = parts[i];
2509
2614
  if (!part.raw) continue;
2510
2615
  const at = locate(part.text, term.tex);
2511
2616
  if (!at) continue;
2512
- parts = parts.toSpliced(
2513
- i,
2514
- 1,
2515
- { text: part.text.slice(0, at.start), raw: true },
2516
- {
2517
- text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
2518
- raw: false
2519
- },
2520
- { text: part.text.slice(at.end), raw: true }
2521
- );
2522
- used.push(term);
2523
- placed = true;
2617
+ found = true;
2618
+ const body = `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`;
2619
+ for (const text2 of [body, `{${body}}`]) {
2620
+ const next = parts.toSpliced(
2621
+ i,
2622
+ 1,
2623
+ { text: part.text.slice(0, at.start), raw: true },
2624
+ { text: text2, raw: false },
2625
+ { text: part.text.slice(at.end), raw: true }
2626
+ );
2627
+ if (checked && texError(joined(next), true) !== null) continue;
2628
+ parts = next;
2629
+ used.push(term);
2630
+ placed = true;
2631
+ break;
2632
+ }
2633
+ }
2634
+ if (!placed) {
2635
+ missing.push(term);
2636
+ if (found) broken.push(term);
2524
2637
  }
2525
- if (!placed) missing.push(term);
2526
2638
  }
2527
2639
  if (used.length === 0) {
2528
2640
  throw new Error(
2529
- `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)}`
2641
+ `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)}`
2642
+ );
2643
+ }
2644
+ return { tex: joined(parts), used, missing, broken };
2645
+ }
2646
+ function droppedTerms(eqId, missing, broken) {
2647
+ return missing.map(
2648
+ (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`
2649
+ );
2650
+ }
2651
+ function prepareWalk(eq, terms, beatId) {
2652
+ const fixed = repairTex(eq.tex, true);
2653
+ const notes = fixed.repairs.map((r) => `${eq.id} repaired before drawing: ${r}`);
2654
+ const repairedTerms = terms.map((t2) => ({ ...t2, tex: repairTex(t2.tex, false).tex }));
2655
+ if (fixed.error !== null) {
2656
+ notes.push(
2657
+ `${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)}`
2530
2658
  );
2659
+ const parts = segment(eq.tex, [...terms]);
2660
+ if (parts.used.length === 0) {
2661
+ throw new Error(
2662
+ `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)}`
2663
+ );
2664
+ }
2665
+ notes.push(...droppedTerms(eq.id, parts.missing, []));
2666
+ return {
2667
+ raw: eq.tex,
2668
+ plain: { error: fixed.error },
2669
+ used: parts.used,
2670
+ missing: parts.missing,
2671
+ notes
2672
+ };
2531
2673
  }
2532
- return { tex: parts.map((part) => part.text).join(""), used, missing };
2674
+ const walk = wrapTerms(fixed.tex, repairedTerms, beatId);
2675
+ const back = (ts) => ts.map((t2) => terms[repairedTerms.indexOf(t2)]);
2676
+ const used = back(walk.used);
2677
+ const missing = back(walk.missing);
2678
+ notes.push(...droppedTerms(eq.id, missing, back(walk.broken)));
2679
+ return { tex: walk.tex, raw: fixed.tex, used, missing, notes };
2680
+ }
2681
+ function segment(tex, terms) {
2682
+ let parts = [{ text: tex }];
2683
+ const used = [];
2684
+ const missing = [];
2685
+ for (const term of terms) {
2686
+ const i = parts.findIndex((p) => !p.term && locate(p.text, term.tex));
2687
+ const part = parts[i];
2688
+ const at = part && locate(part.text, term.tex);
2689
+ if (!part || !at) {
2690
+ missing.push(term);
2691
+ continue;
2692
+ }
2693
+ parts = parts.toSpliced(
2694
+ i,
2695
+ 1,
2696
+ { text: part.text.slice(0, at.start) },
2697
+ { text: part.text.slice(at.start, at.end), term },
2698
+ { text: part.text.slice(at.end) }
2699
+ );
2700
+ used.push(term);
2701
+ }
2702
+ return { parts, used, missing };
2703
+ }
2704
+ function plainHtml(tex, terms) {
2705
+ return segment(tex, terms).parts.map(
2706
+ (p) => p.term ? `<span class="term t-${p.term.tone}">${esc(p.text)}</span>` : esc(p.text)
2707
+ ).join("");
2708
+ }
2709
+ function chipSetup(sid, term) {
2710
+ const fixed = repairTex(term.tex, false);
2711
+ const el = `document.getElementById("${sid}-chip-${term.tone}")`;
2712
+ return fixed.error === null ? `katex.render('${js(fixed.tex)}', ${el}, ${INLINE_OPTS});` : `${el}.textContent = '${js(term.tex)}';`;
2533
2713
  }
2534
2714
  function equationSize(tex) {
2535
2715
  return tex.length > 120 ? 68 : tex.length > 90 ? 80 : tex.length > 55 ? 92 : 108;
2536
2716
  }
2537
- var NO_INK = /\\(math[a-z]+|text[a-z]*|bm|boldsymbol|displaystyle|textstyle|scriptstyle|left|right|operatorname|limits|nolimits|htmlClass|hspace|phantom|[,;:!])/g;
2717
+ var NO_INK2 = /\\(math[a-z]+|text[a-z]*|bm|boldsymbol|displaystyle|textstyle|scriptstyle|left|right|operatorname|limits|nolimits|htmlClass|hspace|phantom|[,;:!])/g;
2538
2718
  var GLYPH_EM = 0.9;
2539
2719
  function texUnits(tex) {
2540
2720
  let ems = 0;
2541
2721
  for (const m of tex.matchAll(/\\qquad|\\quad/g)) ems += m[0] === "\\qquad" ? 2 : 1;
2542
- const glyphs = tex.replace(/\\q?quad/g, "").replace(NO_INK, "").replace(/[{}\s_^]/g, "");
2722
+ const glyphs = tex.replace(/\\q?quad/g, "").replace(NO_INK2, "").replace(/[{}\s_^]/g, "");
2543
2723
  return ems + glyphs.length * GLYPH_EM;
2544
2724
  }
2545
2725
  function statements(tex, stacked) {
@@ -2547,6 +2727,100 @@ function statements(tex, stacked) {
2547
2727
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
2548
2728
  return parts.length > 0 ? parts : [tex];
2549
2729
  }
2730
+ var PLAIN_FONT = 48;
2731
+ function mathFit(sid, targets, shown, raw2, want, floor) {
2732
+ return [
2733
+ `var dsEqFit = (function () {
2734
+ var box = document.getElementById("${sid}-eq");
2735
+ var slide = box && box.parentNode;
2736
+ var bw = box ? box.getBoundingClientRect().width : 0;
2737
+ if (!(bw > 0) || !slide) return "unmeasured";
2738
+ var targets = [${targets.map((id2) => `"${id2}"`).join(", ")}].map(function (id) { return document.getElementById(id); });
2739
+ var TEX = [${shown.map((s) => `'${js(s)}'`).join(", ")}];
2740
+ var spill0 = slide.scrollHeight - slide.clientHeight;
2741
+ function extent(each) {
2742
+ var w = 0;
2743
+ targets.forEach(function (t) {
2744
+ var lo = Infinity, hi = -Infinity;
2745
+ Array.prototype.forEach.call(t.querySelectorAll(".katex-html > .base"), function (b) {
2746
+ var r = b.getBoundingClientRect();
2747
+ if (each) w = Math.max(w, r.width);
2748
+ else { lo = Math.min(lo, r.left); hi = Math.max(hi, r.right); }
2749
+ });
2750
+ if (!each && hi > lo) w = Math.max(w, hi - lo);
2751
+ });
2752
+ return w;
2753
+ }
2754
+ function fits(each) {
2755
+ return extent(each) <= bw + 0.5 && slide.scrollHeight - slide.clientHeight <= Math.max(1, spill0 + 1);
2756
+ }
2757
+ function settle(each, size) {
2758
+ size = Math.max(${floor}, Math.min(${want}, Math.floor(size)));
2759
+ box.style.fontSize = size + "px";
2760
+ while (!fits(each) && size > ${floor}) {
2761
+ size = Math.max(${floor}, size - 2);
2762
+ box.style.fontSize = size + "px";
2763
+ }
2764
+ return fits(each);
2765
+ }
2766
+ if (fits(false)) return "fits";
2767
+ var size = parseFloat(box.style.fontSize);
2768
+ if (settle(false, (size * bw) / extent(false))) return "scaled";
2769
+ targets.forEach(function (t, i) { katex.render("\\\\displaystyle " + TEX[i], t, ${INLINE_OPTS}); });
2770
+ box.className += " eq-broken";
2771
+ box.style.fontSize = "${want}px";
2772
+ var wb = extent(true);
2773
+ if (settle(true, wb > bw ? (${want} * bw) / wb : ${want})) return "broken";
2774
+ box.setAttribute("${UNFIT_ATTR}", '${js(raw2)}');
2775
+ return "unfit";
2776
+ })()`
2777
+ ];
2778
+ }
2779
+ function chipFit(sid, terms) {
2780
+ if (terms.length === 0) return [];
2781
+ return [
2782
+ `[${terms.map((t2) => `"${t2.tone}"`).join(", ")}].forEach(function (tone) {
2783
+ var chip = document.getElementById("${sid}-chip-" + tone);
2784
+ var r = chip && chip.getBoundingClientRect();
2785
+ if (!r || !(r.height > 0)) return;
2786
+ var k = chip.offsetHeight / r.height;
2787
+ var top = r.top, bottom = r.bottom;
2788
+ Array.prototype.forEach.call(chip.querySelectorAll("span"), function (el) {
2789
+ for (var n = el.firstChild; n; n = n.nextSibling) {
2790
+ if (n.nodeType === 3 && n.nodeValue.trim()) {
2791
+ var q = el.getBoundingClientRect();
2792
+ top = Math.min(top, q.top);
2793
+ bottom = Math.max(bottom, q.bottom);
2794
+ return;
2795
+ }
2796
+ }
2797
+ });
2798
+ var cs = getComputedStyle(chip);
2799
+ if (top < r.top) chip.style.paddingTop = parseFloat(cs.paddingTop) + Math.ceil((r.top - top) * k) + "px";
2800
+ if (bottom > r.bottom) chip.style.paddingBottom = parseFloat(cs.paddingBottom) + Math.ceil((bottom - r.bottom) * k) + "px";
2801
+ })`
2802
+ ];
2803
+ }
2804
+ var SWELL = 1.16;
2805
+ var SWELL_EM = 0.06;
2806
+ function swellFit(sid, terms) {
2807
+ return [
2808
+ `var dsSwell = (function () {
2809
+ var out = {};
2810
+ var box = document.getElementById("${sid}-eq");
2811
+ var k = box && box.offsetWidth ? box.getBoundingClientRect().width / box.offsetWidth : 1;
2812
+ var em = box ? parseFloat(getComputedStyle(box).fontSize) : 0;
2813
+ [${terms.map((t2) => `"${t2.tone}"`).join(", ")}].forEach(function (tone) {
2814
+ var w = 0;
2815
+ Array.prototype.forEach.call(document.querySelectorAll("#${sid} .t-" + tone), function (el) {
2816
+ w = Math.max(w, el.getBoundingClientRect().width / k);
2817
+ });
2818
+ out[tone] = w > 0 && em > 0 ? Math.min(${SWELL}, Math.round((1 + (2 * ${SWELL_EM} * em) / w) * 1000) / 1000) : ${SWELL};
2819
+ });
2820
+ return out;
2821
+ })()`
2822
+ ];
2823
+ }
2550
2824
  function legendRows(sid, terms, theme) {
2551
2825
  return terms.map(
2552
2826
  (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>`
@@ -2562,7 +2836,10 @@ function legendCss(theme) {
2562
2836
  `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
2563
2837
  // A common chip width, so the labels share a spine too — the glyphs inside
2564
2838
  // are one symbol each and their natural widths differ by a few pixels.
2565
- `.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}`
2839
+ // `flex:none`: beside a long label the chip used to SHRINK below its own
2840
+ // nowrap TeX, which then ran out of the painted box into the label — on a
2841
+ // HypePaper deck, "pixelcoord" 91px past the chip's right edge.
2842
+ `.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}`
2566
2843
  ].join("\n");
2567
2844
  }
2568
2845
  var equationWalk = (beat, ctx) => {
@@ -2574,37 +2851,53 @@ var equationWalk = (beat, ctx) => {
2574
2851
  `equation-walk ${beat.id}: no equation "${p.equationId}" in source ${ctx.source.id}`
2575
2852
  );
2576
2853
  }
2577
- const walk = wrapTerms(eq.tex, p.terms, beat.id);
2854
+ const walk = prepareWalk(eq, p.terms, beat.id);
2578
2855
  const terms = walk.used;
2579
2856
  const legend = legendRows(sid, terms, theme);
2580
- const stacked = isPortrait(ctx.format);
2581
- const raw2 = statements(eq.tex, stacked);
2582
- const shown = statements(walk.tex, stacked);
2583
- const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
2584
- const size = Math.max(
2585
- MIN_FONT,
2586
- Math.min(
2587
- equationSize(longest),
2588
- Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits)))
2589
- )
2590
- );
2591
- const body = shown.length === 1 ? "" : shown.map((_, i) => `<div id="${sid}-eq${i}"></div>`).join("\n ");
2857
+ const chips = terms.map((t2) => chipSetup(sid, t2));
2858
+ let eqHtml;
2859
+ let setup;
2860
+ let measure4;
2861
+ if (walk.plain) {
2862
+ eqHtml = `<div class="eq eq-plain" id="${sid}-eq" style="font-size:${PLAIN_FONT}px">${plainHtml(eq.tex, terms)}</div>`;
2863
+ setup = chips;
2864
+ measure4 = [...chipFit(sid, terms), ...swellFit(sid, terms)];
2865
+ } else {
2866
+ const wrapped = walk.tex;
2867
+ const split = statements(wrapped, isPortrait(ctx.format));
2868
+ const stacked = split.length > 1 && split.every((s) => texError(s, true) === null);
2869
+ const raw2 = statements(walk.raw, stacked);
2870
+ const shown = stacked ? split : [wrapped];
2871
+ const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
2872
+ const want = equationSize(longest);
2873
+ const size = Math.max(
2874
+ MIN_FONT,
2875
+ Math.min(want, Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits))))
2876
+ );
2877
+ const body = shown.length === 1 ? "" : shown.map((_, i) => `<div id="${sid}-eq${i}"></div>`).join("\n ");
2878
+ eqHtml = `<div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size}px">${body}</div>`;
2879
+ const targets = shown.map((_, i) => `${sid}-eq${shown.length === 1 ? "" : i}`);
2880
+ setup = [
2881
+ `var OPTS = ${OPTS};`,
2882
+ ...shown.map(
2883
+ (part, i) => `katex.render('${js(part)}', document.getElementById("${targets[i]}"), OPTS);`
2884
+ ),
2885
+ ...chips
2886
+ ];
2887
+ measure4 = [
2888
+ ...chipFit(sid, terms),
2889
+ ...mathFit(sid, targets, shown, walk.raw, want, MIN_FONT),
2890
+ // Last: it measures each term at the size the fit settled on.
2891
+ ...swellFit(sid, terms)
2892
+ ];
2893
+ }
2592
2894
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
2593
2895
  <div class="eqslide">
2594
- <div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size}px">${body}</div>
2896
+ ${eqHtml}
2595
2897
  <div class="legend">
2596
2898
  ${legend}
2597
2899
  </div>
2598
2900
  </div>`;
2599
- const setup = [
2600
- `var OPTS = ${OPTS};`,
2601
- ...shown.map(
2602
- (part, i) => `katex.render('${js(part)}', document.getElementById("${sid}-eq${shown.length === 1 ? "" : i}"), OPTS);`
2603
- ),
2604
- ...terms.map(
2605
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2606
- )
2607
- ];
2608
2901
  const tl = [
2609
2902
  ...chromeIn(sid, p.eyebrow !== void 0),
2610
2903
  tween(`#${sid}-eq`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8)
@@ -2616,6 +2909,7 @@ var equationWalk = (beat, ctx) => {
2616
2909
  );
2617
2910
  const holds = [];
2618
2911
  const spot = spotlighter(sid, ".term");
2912
+ const swell = (t2) => raw(`dsSwell.${t2.tone}`);
2619
2913
  terms.forEach((term, i) => {
2620
2914
  const at = first + i * step;
2621
2915
  const colour = theme.tones[term.tone];
@@ -2632,13 +2926,15 @@ var equationWalk = (beat, ctx) => {
2632
2926
  tween(
2633
2927
  `#${sid} .t-${term.tone}`,
2634
2928
  { color: theme.fg, scale: 1 },
2635
- { color: colour, scale: 1.16, duration: 0.5 },
2929
+ { color: colour, scale: swell(term), duration: 0.5 },
2636
2930
  at
2637
2931
  )
2638
2932
  );
2639
2933
  const prev = terms[i - 1];
2640
2934
  if (prev) {
2641
- tl.push(tween(`#${sid} .t-${prev.tone}`, { scale: 1.16 }, { scale: 1, duration: 0.4 }, at));
2935
+ tl.push(
2936
+ tween(`#${sid} .t-${prev.tone}`, { scale: swell(prev) }, { scale: 1, duration: 0.4 }, at)
2937
+ );
2642
2938
  }
2643
2939
  holds.push(at + 0.6);
2644
2940
  });
@@ -2646,7 +2942,7 @@ var equationWalk = (beat, ctx) => {
2646
2942
  if (last) {
2647
2943
  const at = first + terms.length * step;
2648
2944
  tl.push(
2649
- tween(`#${sid} .t-${last.tone}`, { scale: 1.16 }, { scale: 1, duration: 0.4 }, at),
2945
+ tween(`#${sid} .t-${last.tone}`, { scale: swell(last) }, { scale: 1, duration: 0.4 }, at),
2650
2946
  ...spot.restore(at)
2651
2947
  );
2652
2948
  }
@@ -2654,6 +2950,8 @@ var equationWalk = (beat, ctx) => {
2654
2950
  html,
2655
2951
  tl,
2656
2952
  setup,
2953
+ measure: measure4,
2954
+ ...walk.notes.length ? { warnings: walk.notes } : {},
2657
2955
  holds: holdsWithin(holds, beat.seconds),
2658
2956
  css: [
2659
2957
  chromeCss(theme),
@@ -2673,6 +2971,15 @@ var equationWalk = (beat, ctx) => {
2673
2971
  // has its margin zeroed above, so without an explicit gap the two lines
2674
2972
  // would touch and read as one equation broken mid-expression.
2675
2973
  ".eqstack{display:flex;flex-direction:column;gap:32px}",
2974
+ // Set by `mathFit` when one line will not fit at the floor: the display is
2975
+ // re-rendered inline, where KaTeX breaks after a top-level relation or
2976
+ // operator, and the lines it breaks into are centred and spaced as lines.
2977
+ ".eq-broken{line-height:1.5}",
2978
+ // An equation KaTeX refuses, shown as its source. Wraps anywhere, because
2979
+ // TeX source has few spaces and must not run off the slide either. The
2980
+ // deck's own face, not a monospace one: a family the bundle does not
2981
+ // declare falls back silently (invariant 9).
2982
+ ".eq-plain{white-space:pre-wrap;overflow-wrap:anywhere}",
2676
2983
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
2677
2984
  ".term{display:inline-block}",
2678
2985
  legendCss(theme),
@@ -2687,26 +2994,67 @@ var equationWalk = (beat, ctx) => {
2687
2994
 
2688
2995
  // src/emit/archetypes/equation-morph.ts
2689
2996
  var MORPH_SECONDS = 1.6;
2690
- var equationMorph = (beat, ctx) => {
2691
- const { sid, theme } = ctx;
2997
+ function prepareMorph(beat, source) {
2692
2998
  const p = beat.params;
2693
2999
  const find3 = (id2) => {
2694
- const eq = ctx.source.equations.find((e) => e.id === id2);
3000
+ const eq = source.equations.find((e) => e.id === id2);
2695
3001
  if (!eq)
2696
- throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${ctx.source.id}`);
3002
+ throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${source.id}`);
2697
3003
  return eq;
2698
3004
  };
2699
- const a = find3(p.fromId);
2700
- const b = find3(p.toId);
2701
- const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
2702
- if (both.length === 0) {
3005
+ const notes = [];
3006
+ const drawable = (eq) => {
3007
+ const fixed = repairTex(eq.tex, true);
3008
+ if (fixed.error !== null) {
3009
+ throw new Error(
3010
+ `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)}`
3011
+ );
3012
+ }
3013
+ notes.push(...fixed.repairs.map((r) => `${eq.id} repaired before drawing: ${r}`));
3014
+ return { id: eq.id, tex: fixed.tex };
3015
+ };
3016
+ const a = drawable(find3(p.fromId));
3017
+ const b = drawable(find3(p.toId));
3018
+ const repaired = p.terms.map((t2) => ({ ...t2, tex: repairTex(t2.tex, false).tex }));
3019
+ const original = (t2) => p.terms[repaired.indexOf(t2)];
3020
+ let keyed = repaired.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
3021
+ if (keyed.length === 0) {
2703
3022
  throw new Error(
2704
3023
  `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)}`
2705
3024
  );
2706
3025
  }
3026
+ notes.push(
3027
+ ...repaired.filter((t2) => !keyed.includes(t2)).map(
3028
+ (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`
3029
+ )
3030
+ );
2707
3031
  const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
2708
- const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
2709
- const wb = wrapTerms(b.tex, both, beat.id, cls).tex;
3032
+ let wa = wrapTerms(a.tex, keyed, beat.id, cls);
3033
+ let wb = wrapTerms(b.tex, keyed, beat.id, cls);
3034
+ const paired = keyed.filter((t2) => wa.used.includes(t2) && wb.used.includes(t2));
3035
+ if (paired.length < keyed.length) {
3036
+ const unpaired = keyed.filter((t2) => !paired.includes(t2));
3037
+ notes.push(
3038
+ ...unpaired.map(
3039
+ (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`
3040
+ )
3041
+ );
3042
+ if (paired.length === 0) {
3043
+ throw new Error(
3044
+ `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(", ")}`
3045
+ );
3046
+ }
3047
+ keyed = paired;
3048
+ wa = wrapTerms(a.tex, keyed, beat.id, cls);
3049
+ wb = wrapTerms(b.tex, keyed, beat.id, cls);
3050
+ }
3051
+ const both = keyed.map(original);
3052
+ return { a, b, wa: wa.tex, wb: wb.tex, both, notes };
3053
+ }
3054
+ var equationMorph = (beat, ctx) => {
3055
+ const { sid, theme } = ctx;
3056
+ const p = beat.params;
3057
+ const { a, b, wa, wb, both, notes } = prepareMorph(beat, ctx.source);
2710
3058
  const size = Math.max(
2711
3059
  MIN_FONT,
2712
3060
  Math.min(
@@ -2728,9 +3076,7 @@ var equationMorph = (beat, ctx) => {
2728
3076
  `var OPTS = ${OPTS};`,
2729
3077
  `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
2730
3078
  `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
2731
- ...both.map(
2732
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2733
- )
3079
+ ...both.map((t2) => chipSetup(sid, t2))
2734
3080
  ];
2735
3081
  const first = 1.8;
2736
3082
  const at = Math.round(
@@ -2763,8 +3109,11 @@ var equationMorph = (beat, ctx) => {
2763
3109
  setup,
2764
3110
  // SEAM B: the plan is browser geometry after fonts, so it is built inside
2765
3111
  // the ready gate, and the plugin tween above finds it on the host.
2766
- measure: [`DSMorph.build(document.getElementById("${sid}-morph"));`],
3112
+ // The chips are fitted before the plan is built: growing one moves the
3113
+ // legend, and nothing the morph measures may move after it has measured.
3114
+ measure: [...chipFit(sid, both), `DSMorph.build(document.getElementById("${sid}-morph"));`],
2767
3115
  plugins: ["dsMorph"],
3116
+ ...notes.length ? { warnings: notes } : {},
2768
3117
  holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
2769
3118
  css: [
2770
3119
  chromeCss(theme),
@@ -3345,16 +3694,16 @@ var lineChart = (beat, ctx) => {
3345
3694
  </svg>${readout}
3346
3695
  </div>`;
3347
3696
  const draw2 = 0.8;
3348
- const count = p.points.length;
3349
- const idealStep = Math.min(0.45, DRAW_SECONDS / count);
3697
+ const count2 = p.points.length;
3698
+ const idealStep = Math.min(0.45, DRAW_SECONDS / count2);
3350
3699
  const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
3351
- const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count - 1) : 0;
3700
+ const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count2 - 1) : 0;
3352
3701
  const tailAfter = (s) => Math.max(
3353
- s * count + 0.4,
3702
+ s * count2 + 0.4,
3354
3703
  // the ring: its walk, then the 0.4s it takes to leave
3355
- 0.3 + s * (count - 1),
3704
+ 0.3 + s * (count2 - 1),
3356
3705
  // the dots
3357
- 0.5 + s * (count - 1),
3706
+ 0.5 + s * (count2 - 1),
3358
3707
  ...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
3359
3708
  );
3360
3709
  const room = beat.seconds - spine;
@@ -3417,8 +3766,8 @@ var lineChart = (beat, ctx) => {
3417
3766
  )
3418
3767
  );
3419
3768
  }
3420
- const walk = cmp ? sec(step * count) : DRAW_SECONDS;
3421
- if (count > 1) {
3769
+ const walk = cmp ? sec(step * count2) : DRAW_SECONDS;
3770
+ if (count2 > 1) {
3422
3771
  const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
3423
3772
  tl.push(
3424
3773
  tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
@@ -3433,7 +3782,7 @@ var lineChart = (beat, ctx) => {
3433
3782
  )
3434
3783
  );
3435
3784
  }
3436
- const drawn = settled + (cmp ? tailAfter(step) : step * count + 0.4);
3785
+ const drawn = settled + (cmp ? tailAfter(step) : step * count2 + 0.4);
3437
3786
  const holds = cmp ? [lift2, drawn] : [drawn];
3438
3787
  if (p.readout) {
3439
3788
  tl.push(
@@ -3449,7 +3798,7 @@ var lineChart = (beat, ctx) => {
3449
3798
  return {
3450
3799
  ...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
3451
3800
  warnings: [
3452
- `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.`
3801
+ `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.`
3453
3802
  ]
3454
3803
  };
3455
3804
  }
@@ -3529,7 +3878,7 @@ var V_BOX_H = 268;
3529
3878
  var V_LOOP_SIDE = 56;
3530
3879
  var V_LOOP_PAD = 18;
3531
3880
  function columnLayout(stageW, stageH, stages, face, loop) {
3532
- const count = stages.length;
3881
+ const count2 = stages.length;
3533
3882
  const loopW = loop ? Math.max(240, Math.round(stageW * 0.32)) : 0;
3534
3883
  const boxX = M;
3535
3884
  const boxW = stageW - 2 * M - (loop ? V_LOOP_SIDE + V_LOOP_PAD + loopW : 0);
@@ -3546,11 +3895,11 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3546
3895
  return Math.ceil(2 * PAD_Y2 + label + (note > 0 ? NOTE_TOP + note * NOTE * NOTE_LH : 0));
3547
3896
  })
3548
3897
  );
3549
- const room = (stageH - 2 * M - (count - 1) * V_GAP) / count;
3898
+ const room = (stageH - 2 * M - (count2 - 1) * V_GAP) / count2;
3550
3899
  const boxH = Math.max(need, Math.min(room, V_BOX_H));
3551
3900
  return {
3552
3901
  size,
3553
- boxes: Array.from({ length: count }, (_, i) => ({ x: M + i * (boxH + V_GAP), w: boxH })),
3902
+ boxes: Array.from({ length: count2 }, (_, i) => ({ x: M + i * (boxH + V_GAP), w: boxH })),
3554
3903
  boxH,
3555
3904
  boxX,
3556
3905
  boxW,
@@ -3562,7 +3911,7 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3562
3911
  loopLines: loop ? wrap(loop.label, NOTE, loopW, 500, 0, face) : [],
3563
3912
  loopX: boxX + boxW + V_LOOP_SIDE,
3564
3913
  loopW,
3565
- svgH: 2 * M + count * boxH + (count - 1) * V_GAP
3914
+ svgH: 2 * M + count2 * boxH + (count2 - 1) * V_GAP
3566
3915
  };
3567
3916
  }
3568
3917
  function balance(label, k, face) {
@@ -4280,8 +4629,8 @@ function stackLayout(p, format, face = "latin") {
4280
4629
  if (inline2.fits) return inline2;
4281
4630
  return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
4282
4631
  }
4283
- function labelWeight(i, count) {
4284
- return i === count - 1 ? 700 : 600;
4632
+ function labelWeight(i, count2) {
4633
+ return i === count2 - 1 ? 700 : 600;
4285
4634
  }
4286
4635
  function floorFor(p, format) {
4287
4636
  if (!p.tilt) return MIN_FONT;
@@ -4291,7 +4640,7 @@ function solve2(p, format, inline2, face) {
4291
4640
  const width = contentW(format);
4292
4641
  const boxH = contentH(format);
4293
4642
  const floor = floorFor(p, format);
4294
- const count = p.layers.length;
4643
+ const count2 = p.layers.length;
4295
4644
  const k = isPortrait(format) ? "tall" : "wide";
4296
4645
  const riseMax = RISE_MAX[k];
4297
4646
  const syMax = SY_MAX[k];
@@ -4299,7 +4648,7 @@ function solve2(p, format, inline2, face) {
4299
4648
  const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4300
4649
  const want = Math.max(
4301
4650
  ...p.layers.map(
4302
- (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count), 0, false, face) + noteW(l)
4651
+ (l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count2), 0, false, face) + noteW(l)
4303
4652
  )
4304
4653
  );
4305
4654
  const colCap = inline2 ? width * 0.56 : width * 0.5;
@@ -4308,7 +4657,7 @@ function solve2(p, format, inline2, face) {
4308
4657
  const labelX = width - colW;
4309
4658
  const labelRoom = Math.min(
4310
4659
  ...p.layers.map(
4311
- (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count), 0, false, face))
4660
+ (l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count2), 0, false, face))
4312
4661
  )
4313
4662
  );
4314
4663
  const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
@@ -4316,7 +4665,7 @@ function solve2(p, format, inline2, face) {
4316
4665
  const nw = noteW(l);
4317
4666
  const labelMaxW = Math.max(labelSize, colW - nw);
4318
4667
  return {
4319
- label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4668
+ label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count2), 0, face),
4320
4669
  // Inline notes stay on one line by contract — the schema calls a note "one
4321
4670
  // short line" — and wrapping one would put its second line under the label.
4322
4671
  note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
@@ -4334,13 +4683,13 @@ function solve2(p, format, inline2, face) {
4334
4683
  const noteH = noteHeight(p.note, noteWidth(format), 28, face);
4335
4684
  const free = boxH - chromeH - 20 - noteH;
4336
4685
  const avail = Math.max(300, free);
4337
- const room = (avail - 2 * pad - syMax - tMax) / Math.max(1, count - 1);
4686
+ const room = (avail - 2 * pad - syMax - tMax) / Math.max(1, count2 - 1);
4338
4687
  const rise = clamp(room, RISE_MIN, riseMax);
4339
4688
  const sy = clamp(rise * 0.62, 46, syMax);
4340
4689
  const t2 = clamp(rise * 0.14, 10, tMax);
4341
4690
  const span = width - NUM_W - GAP3 - colW;
4342
4691
  const sx = clamp(span * 0.2, 110, 220);
4343
- const height = 2 * pad + (count - 1) * rise + sy + t2;
4692
+ const height = 2 * pad + (count2 - 1) * rise + sy + t2;
4344
4693
  return {
4345
4694
  // A rise shorter than a label block prints the note through the label below.
4346
4695
  // BOTH directions. A layout that fits the height and not the width is not a
@@ -4365,7 +4714,7 @@ function solve2(p, format, inline2, face) {
4365
4714
  labelSize,
4366
4715
  blockH,
4367
4716
  pad,
4368
- yBase: pad + (count - 1) * rise + sy,
4717
+ yBase: pad + (count2 - 1) * rise + sy,
4369
4718
  lines
4370
4719
  };
4371
4720
  }
@@ -4382,11 +4731,11 @@ var stack = (beat, ctx) => {
4382
4731
  const p = beat.params;
4383
4732
  const face = faceOf(ctx.theme.fontStack);
4384
4733
  const L = stackLayout(p, ctx.format, face);
4385
- const count = p.layers.length;
4386
- const last = count - 1;
4734
+ const count2 = p.layers.length;
4735
+ const last = count2 - 1;
4387
4736
  if (!L.fits) {
4388
4737
  throw new Error(
4389
- `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.`
4738
+ `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.`
4390
4739
  );
4391
4740
  }
4392
4741
  const parts = {};
@@ -4414,7 +4763,7 @@ var stack = (beat, ctx) => {
4414
4763
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
4415
4764
  {
4416
4765
  size: L.labelSize,
4417
- weight: labelWeight(i, count),
4766
+ weight: labelWeight(i, count2),
4418
4767
  fill: top ? theme.tones.b : theme.fg,
4419
4768
  maxWidth: block.labelMaxW,
4420
4769
  lineHeight: 1.16,
@@ -4468,10 +4817,10 @@ var stack = (beat, ctx) => {
4468
4817
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4469
4818
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4470
4819
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe3)}</div>${noteHtml}`;
4471
- const centre2 = (count - 1) / 2;
4820
+ const centre2 = (count2 - 1) / 2;
4472
4821
  const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4473
4822
  const first = 0.9;
4474
- const step = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count));
4823
+ const step = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count2));
4475
4824
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
4476
4825
  const holds = [];
4477
4826
  const spot = spotlighter(sid);
@@ -4503,11 +4852,11 @@ var stack = (beat, ctx) => {
4503
4852
  );
4504
4853
  }
4505
4854
  if (p.note) {
4506
- const at = first + count * step;
4855
+ const at = first + count2 * step;
4507
4856
  tl.push(tween(`#${sid}-note`, { opacity: 0 }, { opacity: 1, duration: 0.6 }, at));
4508
4857
  holds.push(at + 0.7);
4509
4858
  }
4510
- if (count > 1) tl.push(...spot.restore(first + count * step));
4859
+ if (count2 > 1) tl.push(...spot.restore(first + count2 * step));
4511
4860
  return {
4512
4861
  html,
4513
4862
  parts,
@@ -5809,8 +6158,8 @@ async function narrate(storyboard, source, prefs, opts) {
5809
6158
  const text2 = beat.narration?.trim();
5810
6159
  if (!text2) continue;
5811
6160
  const segments2 = [];
5812
- const count = stopsFor(beat, source, format, `s${i + 1}`, theme);
5813
- const plan = planSegments(text2, Math.min(count, paced.speakingStops));
6161
+ const count2 = stopsFor(beat, source, format, `s${i + 1}`, theme);
6162
+ const plan = planSegments(text2, Math.min(count2, paced.speakingStops));
5814
6163
  for (const [stop, line2] of plan.entries()) {
5815
6164
  if (!line2) continue;
5816
6165
  const speech = await synthesize(line2, {
@@ -5832,7 +6181,7 @@ async function narrate(storyboard, source, prefs, opts) {
5832
6181
  }
5833
6182
  if (segments2.length > 0) {
5834
6183
  beats[beat.id] = segments2;
5835
- staged[beat.id] = count;
6184
+ staged[beat.id] = count2;
5836
6185
  }
5837
6186
  }
5838
6187
  return {
@@ -6702,11 +7051,11 @@ function stageScene(scene, speed) {
6702
7051
  function speechPlan(open, holds, segments2) {
6703
7052
  const starts = [];
6704
7053
  let at = open;
6705
- for (const [i, segment] of segments2.entries()) {
6706
- const hold = holds[Math.min(segment.stop, holds.length - 1)] ?? 0;
7054
+ for (const [i, segment2] of segments2.entries()) {
7055
+ const hold = holds[Math.min(segment2.stop, holds.length - 1)] ?? 0;
6707
7056
  if (i > 0) at = Math.max(at, hold);
6708
7057
  starts.push(at);
6709
- at += segment.seconds;
7058
+ at += segment2.seconds;
6710
7059
  }
6711
7060
  return { starts, end: at };
6712
7061
  }
@@ -6931,10 +7280,10 @@ function pack(words2, width) {
6931
7280
  function splitCue(cue, max = CUE_MAX_CHARS) {
6932
7281
  if (cue.text.length <= max) return [cue];
6933
7282
  const words2 = cue.text.split(/\s+/).filter(Boolean);
6934
- const count = pack(words2, max).length;
7283
+ const count2 = pack(words2, max).length;
6935
7284
  let width = max;
6936
- for (let w = Math.ceil(cue.text.length / count); w < max; w++) {
6937
- if (pack(words2, w).length <= count) {
7285
+ for (let w = Math.ceil(cue.text.length / count2); w < max; w++) {
7286
+ if (pack(words2, w).length <= count2) {
6938
7287
  width = w;
6939
7288
  break;
6940
7289
  }
@@ -7026,17 +7375,17 @@ function place(scenes, spoken) {
7026
7375
  );
7027
7376
  }
7028
7377
  const { starts } = speechPlan(scene.open, scene.holds, segments2);
7029
- for (const [i, segment] of segments2.entries()) {
7030
- const index = Math.min(segment.stop, scene.holds.length - 1);
7378
+ for (const [i, segment2] of segments2.entries()) {
7379
+ const index = Math.min(segment2.stop, scene.holds.length - 1);
7031
7380
  out.push({
7032
- id: `${scene.id}.${segment.stop}`,
7381
+ id: `${scene.id}.${segment2.stop}`,
7033
7382
  scene: scene.id,
7034
- stop: segment.stop,
7035
- audio: segment.audio,
7383
+ stop: segment2.stop,
7384
+ audio: segment2.audio,
7036
7385
  hold: round5(scene.start + scene.holds[index]),
7037
7386
  start: round5(scene.start + starts[i]),
7038
- duration: segment.seconds,
7039
- cues: segment.cues.flatMap((c) => splitCue(c, cueMax(c.text)))
7387
+ duration: segment2.seconds,
7388
+ cues: segment2.cues.flatMap((c) => splitCue(c, cueMax(c.text)))
7040
7389
  });
7041
7390
  }
7042
7391
  }
@@ -7113,6 +7462,15 @@ function planTiming(input) {
7113
7462
  // `emitDeck` and `writeTiming` are handed the same `format`, so this cannot
7114
7463
  // disagree with the box the archetypes actually drew into.
7115
7464
  captionReserve: format.captionReserve ?? 0,
7465
+ // An unbudgeted format's limit is Infinity, which JSON writes as `null`.
7466
+ // Left out instead: recorded as a format with no limit, never with a limit
7467
+ // of null or zero.
7468
+ format: {
7469
+ id: format.id,
7470
+ minWeight: format.minWeight,
7471
+ ...Number.isFinite(format.maxSeconds) && { maxSeconds: format.maxSeconds },
7472
+ ...format.warnSeconds !== void 0 && { warnSeconds: format.warnSeconds }
7473
+ },
7116
7474
  scenes,
7117
7475
  segments: segments2
7118
7476
  };
@@ -7139,15 +7497,15 @@ function framePlan(timing, fps) {
7139
7497
  );
7140
7498
  }
7141
7499
  pieces.push({ from: first, motion: last - first, freeze: 0 });
7142
- for (const segment of segments2) {
7143
- const at = f(segment.start);
7500
+ for (const segment2 of segments2) {
7501
+ const at = f(segment2.start);
7144
7502
  audio.push({
7145
- id: segment.id,
7146
- audio: segment.audio,
7503
+ id: segment2.id,
7504
+ audio: segment2.audio,
7147
7505
  startFrame: at,
7148
7506
  delayMs: Math.round(at / fps * 1e3)
7149
7507
  });
7150
- for (const cue of segment.cues) {
7508
+ for (const cue of segment2.cues) {
7151
7509
  const start = at / fps + cue.start;
7152
7510
  const end = at / fps + cue.end;
7153
7511
  if (end > start) cues.push({ start, end, text: cue.text });
@@ -7712,6 +8070,22 @@ async function resolveChrome(need = "open the deck with") {
7712
8070
  async function chromePath(need = "open the deck with") {
7713
8071
  return (await resolveChrome(need)).path;
7714
8072
  }
8073
+ function sandboxMissing(err) {
8074
+ return /No usable sandbox/.test(err instanceof Error ? err.message : String(err));
8075
+ }
8076
+ var SANDBOX_FIX = "`sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, or give Chrome an AppArmor profile";
8077
+ async function launchOwnPage(launch, args, what) {
8078
+ try {
8079
+ return await launch(args);
8080
+ } catch (err) {
8081
+ if (!sandboxMissing(err)) throw err;
8082
+ process.stderr.write(
8083
+ `chrome: this host gives Chrome no sandbox, so ${what} runs without one, as hyperframes' render always does. To keep the sandbox: ${SANDBOX_FIX}.
8084
+ `
8085
+ );
8086
+ return launch([...args, "--no-sandbox"]);
8087
+ }
8088
+ }
7715
8089
 
7716
8090
  // src/source/assets.ts
7717
8091
  import { createHash as createHash5 } from "node:crypto";
@@ -8683,6 +9057,14 @@ async function readInBrowser(html, timeoutMs) {
8683
9057
  // happens to be that run.
8684
9058
  "--host-resolver-rules=MAP * ~NOTFOUND"
8685
9059
  ]
9060
+ // No `--no-sandbox` fallback here, unlike `launchOwnPage`: this is a page
9061
+ // from the web, and a renderer bug it triggers is exactly what the sandbox
9062
+ // contains. Refused, with the fix, rather than Chrome's raw FATAL.
9063
+ }).catch((err) => {
9064
+ if (!sandboxMissing(err)) throw err;
9065
+ throw new Error(
9066
+ `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.`
9067
+ );
8686
9068
  });
8687
9069
  try {
8688
9070
  const page = await browser.newPage();
@@ -10039,8 +10421,13 @@ function renderSource(source) {
10039
10421
  }
10040
10422
  }
10041
10423
  out.push("", "== EQUATIONS ==");
10042
- for (const e of source.equations)
10043
- out.push(`[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${e.tex}`);
10424
+ for (const e of source.equations) {
10425
+ const fixed = repairTex(e.tex, true);
10426
+ const note = fixed.error === null ? "" : " (KaTeX cannot draw this one: a walk shows it as plain source, a morph cannot use it)";
10427
+ out.push(
10428
+ `[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${fixed.error === null ? fixed.tex : e.tex}${note}`
10429
+ );
10430
+ }
10044
10431
  if (!source.equations.length)
10045
10432
  out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
10046
10433
  out.push("", "== TABLES ==");
@@ -10572,10 +10959,10 @@ function drawSvg(req) {
10572
10959
  const seed = createHash7("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
10573
10960
  const rand = mulberry32(seed.readUInt32BE(0));
10574
10961
  const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
10575
- const count = 6 + Math.floor(rand() * 5);
10962
+ const count2 = 6 + Math.floor(rand() * 5);
10576
10963
  const unit = Math.min(W, H);
10577
10964
  const parts = [`<rect width="${W}" height="${H}" fill="${PAPER}"/>`];
10578
- for (let i = 0; i < count; i++) {
10965
+ for (let i = 0; i < count2; i++) {
10579
10966
  const kind = Math.floor(rand() * 3);
10580
10967
  const cx = Math.round(W * (0.15 + 0.7 * rand()));
10581
10968
  const cy = Math.round(H * (0.15 + 0.7 * rand()));
@@ -10866,7 +11253,7 @@ ${bands}
10866
11253
  </div>
10867
11254
  `;
10868
11255
  }
10869
- function measure3(count) {
11256
+ function measure3(count2) {
10870
11257
  const doc = document;
10871
11258
  const probe3 = doc.getElementById("c0");
10872
11259
  if (probe3) {
@@ -10879,7 +11266,7 @@ function measure3(count) {
10879
11266
  probe3.classList.remove("on");
10880
11267
  }
10881
11268
  const rects = [];
10882
- for (let i = 0; i < count; i++) {
11269
+ for (let i = 0; i < count2; i++) {
10883
11270
  const el = doc.getElementById(`c${i}`);
10884
11271
  if (!el) continue;
10885
11272
  el.classList.add("on");
@@ -10907,11 +11294,12 @@ async function renderCaptions(cues, style, deck, work2) {
10907
11294
  await mkdir7(work2, { recursive: true });
10908
11295
  await writeFile9(page, captionPage(cues, style, href));
10909
11296
  const { default: puppeteer } = await import("puppeteer-core");
10910
- const browser = await puppeteer.launch({
10911
- executablePath: await chromePath(CAPTION_NEED),
10912
- headless: true,
10913
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
10914
- });
11297
+ const executablePath = await chromePath(CAPTION_NEED);
11298
+ const browser = await launchOwnPage(
11299
+ (args) => puppeteer.launch({ executablePath, headless: true, args }),
11300
+ ["--force-device-scale-factor=1", "--hide-scrollbars"],
11301
+ "the caption band"
11302
+ );
10915
11303
  try {
10916
11304
  const tab = await browser.newPage();
10917
11305
  await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
@@ -11336,10 +11724,10 @@ function safeEntryPath(name) {
11336
11724
  const segments2 = name.replace(/\\/g, "/").split("/");
11337
11725
  if (/^[a-zA-Z]:/.test(name) || name.startsWith("/") || name.startsWith("\\")) return null;
11338
11726
  const kept = [];
11339
- for (const segment of segments2) {
11340
- if (segment === "" || segment === ".") continue;
11341
- if (segment === "..") return null;
11342
- kept.push(segment);
11727
+ for (const segment2 of segments2) {
11728
+ if (segment2 === "" || segment2 === ".") continue;
11729
+ if (segment2 === "..") return null;
11730
+ kept.push(segment2);
11343
11731
  }
11344
11732
  if (kept.length === 0) return null;
11345
11733
  const path2 = kept.join("/");