@jokerized/decksmith 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -930,35 +930,35 @@ function elbow(sid, from, to, o) {
930
930
  }
931
931
  return path(d, { ...strokeAttrs(o), "marker-end": `url(#${headId(sid, o.stroke)})` });
932
932
  }
933
- function tracks(width, count, gap, x0 = 0) {
934
- const w = count > 1 ? (width - gap * (count - 1)) / count : width;
935
- 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 }));
936
936
  }
937
937
  function fitBoxes(req) {
938
- const count = req.labels.length;
938
+ const count2 = req.labels.length;
939
939
  const minGap = req.minGap ?? 24;
940
940
  const padEm = req.padEm ?? 0.8;
941
941
  const weight = req.weight ?? 600;
942
942
  const x0 = req.x0 ?? 0;
943
943
  const unit = Math.max(1, ...req.labels.map((l) => textWidth(l, 1, weight) + 2 * padEm));
944
- 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;
945
945
  if (sizeAt(req.gap) >= req.size) {
946
- 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) };
947
947
  }
948
- if (count > 1 && sizeAt(minGap) >= req.size) {
949
- const gap = (req.width - count * req.size * unit) / (count - 1);
950
- 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) };
951
951
  }
952
952
  const size = sizeAt(minGap);
953
953
  if (size >= MIN_FONT) {
954
- 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) };
955
955
  }
956
956
  return {
957
957
  ok: false,
958
958
  size: MIN_FONT,
959
959
  gap: minGap,
960
- boxes: tracks(req.width, count, minGap, x0),
961
- 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)
962
962
  };
963
963
  }
964
964
 
@@ -1291,8 +1291,8 @@ var MID_GAP = 56;
1291
1291
  var BAND_GAP = 34;
1292
1292
  var COL_GAP = 28;
1293
1293
  var MIN_COL = 250;
1294
- function lineBudget(count, stageH) {
1295
- const perSide = Math.max(1, Math.ceil(count / 2));
1294
+ function lineBudget(count2, stageH) {
1295
+ const perSide = Math.max(1, Math.ceil(count2 / 2));
1296
1296
  const room = (stageH - (perSide - 1) * LAB_GAP) / perSide;
1297
1297
  const lines = Math.floor((room - ASCENT - RULE_LIFT - RULE_W) / (LAB * LAB_LH)) + 1;
1298
1298
  return Math.min(LINE_CEIL, Math.max(1, lines));
@@ -1703,7 +1703,7 @@ var HEAD_GAP = 14;
1703
1703
  var barCompare = (beat, ctx) => {
1704
1704
  const { sid, theme } = ctx;
1705
1705
  const p = beat.params;
1706
- const count = p.bars.length;
1706
+ const count2 = p.bars.length;
1707
1707
  const W = contentW(ctx.format);
1708
1708
  const face = faceOf(ctx.theme.fontStack);
1709
1709
  const tall = isPortrait(ctx.format);
@@ -1717,7 +1717,7 @@ var barCompare = (beat, ctx) => {
1717
1717
  0,
1718
1718
  face
1719
1719
  );
1720
- 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)));
1721
1721
  let pitch = bar * (1 + GAP_RATIO);
1722
1722
  const gutterMax = tall ? 0 : share(GUTTER_MAX, W);
1723
1723
  const gutterInner = tall ? W : gutterMax - GUTTER_PAD;
@@ -1739,7 +1739,7 @@ var barCompare = (beat, ctx) => {
1739
1739
  if (tall) {
1740
1740
  bar = Math.min(
1741
1741
  BAR_MAX,
1742
- (avail - unitBand - FOOT - count * head) / (count + GAP_RATIO * (count - 1))
1742
+ (avail - unitBand - FOOT - count2 * head) / (count2 + GAP_RATIO * (count2 - 1))
1743
1743
  );
1744
1744
  pitch = head + bar * (1 + GAP_RATIO);
1745
1745
  } else {
@@ -1750,7 +1750,7 @@ var barCompare = (beat, ctx) => {
1750
1750
  }
1751
1751
  }
1752
1752
  const valueSize = Math.max(MIN_FONT, Math.min(VALUE_MAX, bar * 0.9));
1753
- const barsH = (count - 1) * pitch + head + bar;
1753
+ const barsH = (count2 - 1) * pitch + head + bar;
1754
1754
  const foot = Math.max(
1755
1755
  0,
1756
1756
  Math.ceil(
@@ -1763,7 +1763,7 @@ var barCompare = (beat, ctx) => {
1763
1763
  const H = barsH + unitBand + foot;
1764
1764
  if (H > avail) {
1765
1765
  throw new Error(
1766
- `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.`
1767
1767
  );
1768
1768
  }
1769
1769
  const gutter = tall ? 0 : Math.min(
@@ -1916,7 +1916,7 @@ ${svg(id(sid, "chart"), W, H, body)}
1916
1916
  </div>${note}`;
1917
1917
  const railsAt = 0.6;
1918
1918
  const barsAt = 0.95;
1919
- const step = Math.min(0.4, 2.4 / count);
1919
+ const step = Math.min(0.4, 2.4 / count2);
1920
1920
  const grow = 0.85;
1921
1921
  const tl = [
1922
1922
  ...chromeIn(sid, p.eyebrow !== void 0),
@@ -1966,11 +1966,11 @@ ${svg(id(sid, "chart"), W, H, body)}
1966
1966
  );
1967
1967
  }
1968
1968
  }
1969
- const settled = barsAt + (count - 1) * step + grow + 0.05;
1969
+ const settled = barsAt + (count2 - 1) * step + grow + 0.05;
1970
1970
  const holds = [settled + 0.2];
1971
1971
  const focal = rows.reduce((best, r) => Math.abs(r.value) > Math.abs(best.value) ? r : best);
1972
1972
  const spot = spotlighter(sid, ".bc-bar");
1973
- 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));
1974
1974
  const tailAt = settled + 0.3;
1975
1975
  if (p.unit) {
1976
1976
  tl.push(tween(`#${id(sid, "unit")}`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, tailAt));
@@ -2463,6 +2463,90 @@ var dataTable = (beat, ctx) => {
2463
2463
  };
2464
2464
  };
2465
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
+
2466
2550
  // src/emit/archetypes/equation-walk.ts
2467
2551
  var TRUST = 'function (c) { return c.command === "\\\\htmlClass"; }';
2468
2552
  var OPTS = `{ displayMode: true, trust: ${TRUST}, strict: false, output: "html" }`;
@@ -2477,6 +2561,9 @@ var SYNONYMS = [
2477
2561
  function normalise(tex) {
2478
2562
  let out = "";
2479
2563
  const map = [];
2564
+ const ends = [];
2565
+ const lead = [];
2566
+ let hint = null;
2480
2567
  for (let i = 0; i < tex.length; ) {
2481
2568
  if (/\s/.test(tex[i])) {
2482
2569
  i++;
@@ -2488,19 +2575,19 @@ function normalise(tex) {
2488
2575
  const m = new RegExp(`^(?:${re.source})`).exec(rest);
2489
2576
  return m ? { len: m[0].length, to } : null;
2490
2577
  }).find(Boolean);
2491
- if (hit) {
2492
- for (const ch of hit.to) {
2493
- out += ch;
2494
- map.push(i);
2495
- }
2496
- i += hit.len;
2497
- 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;
2498
2587
  }
2499
- out += tex[i];
2500
- map.push(i);
2501
- i++;
2588
+ i += len;
2502
2589
  }
2503
- return { text: out, map };
2590
+ return { text: out, map, ends, lead };
2504
2591
  }
2505
2592
  function locate(tex, term) {
2506
2593
  const hay = normalise(tex);
@@ -2508,58 +2595,131 @@ function locate(tex, term) {
2508
2595
  if (needle === "") return null;
2509
2596
  const at = hay.text.indexOf(needle);
2510
2597
  if (at < 0) return null;
2511
- const start = hay.map[at];
2512
- const lastNorm = at + needle.length - 1;
2513
- const lastOrig = hay.map[lastNorm];
2514
- let end = lastOrig + 1;
2515
- while (end < tex.length && (hay.map[lastNorm + 1] ?? tex.length) > end && /\s/.test(tex[end])) {
2516
- end++;
2517
- }
2518
- 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];
2519
2600
  return { start, end: Math.min(end, tex.length) };
2520
2601
  }
2521
2602
  function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
2603
+ const checked = texError(tex, true) === null;
2522
2604
  let parts = [{ text: tex, raw: true }];
2523
2605
  const used = [];
2524
2606
  const missing = [];
2607
+ const broken = [];
2608
+ const joined = (ps) => ps.map((p) => p.text).join("");
2525
2609
  for (const term of terms) {
2526
2610
  let placed = false;
2611
+ let found = false;
2527
2612
  for (let i = 0; i < parts.length && !placed; i++) {
2528
2613
  const part = parts[i];
2529
2614
  if (!part.raw) continue;
2530
2615
  const at = locate(part.text, term.tex);
2531
2616
  if (!at) continue;
2532
- parts = parts.toSpliced(
2533
- i,
2534
- 1,
2535
- { text: part.text.slice(0, at.start), raw: true },
2536
- {
2537
- text: `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`,
2538
- raw: false
2539
- },
2540
- { text: part.text.slice(at.end), raw: true }
2541
- );
2542
- used.push(term);
2543
- 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);
2544
2637
  }
2545
- if (!placed) missing.push(term);
2546
2638
  }
2547
2639
  if (used.length === 0) {
2548
2640
  throw new Error(
2549
- `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)}`
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
+ };
2673
+ }
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) }
2550
2699
  );
2700
+ used.push(term);
2551
2701
  }
2552
- return { tex: parts.map((part) => part.text).join(""), used, missing };
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)}';`;
2553
2713
  }
2554
2714
  function equationSize(tex) {
2555
2715
  return tex.length > 120 ? 68 : tex.length > 90 ? 80 : tex.length > 55 ? 92 : 108;
2556
2716
  }
2557
- 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;
2558
2718
  var GLYPH_EM = 0.9;
2559
2719
  function texUnits(tex) {
2560
2720
  let ems = 0;
2561
2721
  for (const m of tex.matchAll(/\\qquad|\\quad/g)) ems += m[0] === "\\qquad" ? 2 : 1;
2562
- 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, "");
2563
2723
  return ems + glyphs.length * GLYPH_EM;
2564
2724
  }
2565
2725
  function statements(tex, stacked) {
@@ -2567,6 +2727,100 @@ function statements(tex, stacked) {
2567
2727
  const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
2568
2728
  return parts.length > 0 ? parts : [tex];
2569
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
+ }
2570
2824
  function legendRows(sid, terms, theme) {
2571
2825
  return terms.map(
2572
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>`
@@ -2582,7 +2836,10 @@ function legendCss(theme) {
2582
2836
  `.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
2583
2837
  // A common chip width, so the labels share a spine too — the glyphs inside
2584
2838
  // are one symbol each and their natural widths differ by a few pixels.
2585
- `.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}`
2586
2843
  ].join("\n");
2587
2844
  }
2588
2845
  var equationWalk = (beat, ctx) => {
@@ -2594,37 +2851,53 @@ var equationWalk = (beat, ctx) => {
2594
2851
  `equation-walk ${beat.id}: no equation "${p.equationId}" in source ${ctx.source.id}`
2595
2852
  );
2596
2853
  }
2597
- const walk = wrapTerms(eq.tex, p.terms, beat.id);
2854
+ const walk = prepareWalk(eq, p.terms, beat.id);
2598
2855
  const terms = walk.used;
2599
2856
  const legend = legendRows(sid, terms, theme);
2600
- const stacked = isPortrait(ctx.format);
2601
- const raw2 = statements(eq.tex, stacked);
2602
- const shown = statements(walk.tex, stacked);
2603
- const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
2604
- const size = Math.max(
2605
- MIN_FONT,
2606
- Math.min(
2607
- equationSize(longest),
2608
- Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits)))
2609
- )
2610
- );
2611
- 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
+ }
2612
2894
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
2613
2895
  <div class="eqslide">
2614
- <div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size}px">${body}</div>
2896
+ ${eqHtml}
2615
2897
  <div class="legend">
2616
2898
  ${legend}
2617
2899
  </div>
2618
2900
  </div>`;
2619
- const setup = [
2620
- `var OPTS = ${OPTS};`,
2621
- ...shown.map(
2622
- (part, i) => `katex.render('${js(part)}', document.getElementById("${sid}-eq${shown.length === 1 ? "" : i}"), OPTS);`
2623
- ),
2624
- ...terms.map(
2625
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2626
- )
2627
- ];
2628
2901
  const tl = [
2629
2902
  ...chromeIn(sid, p.eyebrow !== void 0),
2630
2903
  tween(`#${sid}-eq`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8)
@@ -2636,6 +2909,7 @@ var equationWalk = (beat, ctx) => {
2636
2909
  );
2637
2910
  const holds = [];
2638
2911
  const spot = spotlighter(sid, ".term");
2912
+ const swell = (t2) => raw(`dsSwell.${t2.tone}`);
2639
2913
  terms.forEach((term, i) => {
2640
2914
  const at = first + i * step;
2641
2915
  const colour = theme.tones[term.tone];
@@ -2652,13 +2926,15 @@ var equationWalk = (beat, ctx) => {
2652
2926
  tween(
2653
2927
  `#${sid} .t-${term.tone}`,
2654
2928
  { color: theme.fg, scale: 1 },
2655
- { color: colour, scale: 1.16, duration: 0.5 },
2929
+ { color: colour, scale: swell(term), duration: 0.5 },
2656
2930
  at
2657
2931
  )
2658
2932
  );
2659
2933
  const prev = terms[i - 1];
2660
2934
  if (prev) {
2661
- 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
+ );
2662
2938
  }
2663
2939
  holds.push(at + 0.6);
2664
2940
  });
@@ -2666,7 +2942,7 @@ var equationWalk = (beat, ctx) => {
2666
2942
  if (last) {
2667
2943
  const at = first + terms.length * step;
2668
2944
  tl.push(
2669
- 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),
2670
2946
  ...spot.restore(at)
2671
2947
  );
2672
2948
  }
@@ -2674,6 +2950,8 @@ var equationWalk = (beat, ctx) => {
2674
2950
  html,
2675
2951
  tl,
2676
2952
  setup,
2953
+ measure: measure4,
2954
+ ...walk.notes.length ? { warnings: walk.notes } : {},
2677
2955
  holds: holdsWithin(holds, beat.seconds),
2678
2956
  css: [
2679
2957
  chromeCss(theme),
@@ -2693,6 +2971,15 @@ var equationWalk = (beat, ctx) => {
2693
2971
  // has its margin zeroed above, so without an explicit gap the two lines
2694
2972
  // would touch and read as one equation broken mid-expression.
2695
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}",
2696
2983
  // Transforms do not apply to inline boxes, and KaTeX spans are inline.
2697
2984
  ".term{display:inline-block}",
2698
2985
  legendCss(theme),
@@ -2707,26 +2994,67 @@ var equationWalk = (beat, ctx) => {
2707
2994
 
2708
2995
  // src/emit/archetypes/equation-morph.ts
2709
2996
  var MORPH_SECONDS = 1.6;
2710
- var equationMorph = (beat, ctx) => {
2711
- const { sid, theme } = ctx;
2997
+ function prepareMorph(beat, source) {
2712
2998
  const p = beat.params;
2713
2999
  const find3 = (id2) => {
2714
- const eq = ctx.source.equations.find((e) => e.id === id2);
3000
+ const eq = source.equations.find((e) => e.id === id2);
2715
3001
  if (!eq)
2716
- 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}`);
2717
3003
  return eq;
2718
3004
  };
2719
- const a = find3(p.fromId);
2720
- const b = find3(p.toId);
2721
- const both = p.terms.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
2722
- 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) {
2723
3022
  throw new Error(
2724
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)}`
2725
3024
  );
2726
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
+ );
2727
3031
  const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
2728
- const wa = wrapTerms(a.tex, both, beat.id, cls).tex;
2729
- 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);
2730
3058
  const size = Math.max(
2731
3059
  MIN_FONT,
2732
3060
  Math.min(
@@ -2748,9 +3076,7 @@ var equationMorph = (beat, ctx) => {
2748
3076
  `var OPTS = ${OPTS};`,
2749
3077
  `katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
2750
3078
  `katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
2751
- ...both.map(
2752
- (t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
2753
- )
3079
+ ...both.map((t2) => chipSetup(sid, t2))
2754
3080
  ];
2755
3081
  const first = 1.8;
2756
3082
  const at = Math.round(
@@ -2783,8 +3109,11 @@ var equationMorph = (beat, ctx) => {
2783
3109
  setup,
2784
3110
  // SEAM B: the plan is browser geometry after fonts, so it is built inside
2785
3111
  // the ready gate, and the plugin tween above finds it on the host.
2786
- 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"));`],
2787
3115
  plugins: ["dsMorph"],
3116
+ ...notes.length ? { warnings: notes } : {},
2788
3117
  holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
2789
3118
  css: [
2790
3119
  chromeCss(theme),
@@ -3365,16 +3694,16 @@ var lineChart = (beat, ctx) => {
3365
3694
  </svg>${readout}
3366
3695
  </div>`;
3367
3696
  const draw2 = 0.8;
3368
- const count = p.points.length;
3369
- 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);
3370
3699
  const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
3371
- const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count - 1) : 0;
3700
+ const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count2 - 1) : 0;
3372
3701
  const tailAfter = (s) => Math.max(
3373
- s * count + 0.4,
3702
+ s * count2 + 0.4,
3374
3703
  // the ring: its walk, then the 0.4s it takes to leave
3375
- 0.3 + s * (count - 1),
3704
+ 0.3 + s * (count2 - 1),
3376
3705
  // the dots
3377
- 0.5 + s * (count - 1),
3706
+ 0.5 + s * (count2 - 1),
3378
3707
  ...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
3379
3708
  );
3380
3709
  const room = beat.seconds - spine;
@@ -3437,8 +3766,8 @@ var lineChart = (beat, ctx) => {
3437
3766
  )
3438
3767
  );
3439
3768
  }
3440
- const walk = cmp ? sec(step * count) : DRAW_SECONDS;
3441
- if (count > 1) {
3769
+ const walk = cmp ? sec(step * count2) : DRAW_SECONDS;
3770
+ if (count2 > 1) {
3442
3771
  const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
3443
3772
  tl.push(
3444
3773
  tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
@@ -3453,7 +3782,7 @@ var lineChart = (beat, ctx) => {
3453
3782
  )
3454
3783
  );
3455
3784
  }
3456
- const drawn = settled + (cmp ? tailAfter(step) : step * count + 0.4);
3785
+ const drawn = settled + (cmp ? tailAfter(step) : step * count2 + 0.4);
3457
3786
  const holds = cmp ? [lift2, drawn] : [drawn];
3458
3787
  if (p.readout) {
3459
3788
  tl.push(
@@ -3469,7 +3798,7 @@ var lineChart = (beat, ctx) => {
3469
3798
  return {
3470
3799
  ...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
3471
3800
  warnings: [
3472
- `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.`
3473
3802
  ]
3474
3803
  };
3475
3804
  }
@@ -3549,7 +3878,7 @@ var V_BOX_H = 268;
3549
3878
  var V_LOOP_SIDE = 56;
3550
3879
  var V_LOOP_PAD = 18;
3551
3880
  function columnLayout(stageW, stageH, stages, face, loop) {
3552
- const count = stages.length;
3881
+ const count2 = stages.length;
3553
3882
  const loopW = loop ? Math.max(240, Math.round(stageW * 0.32)) : 0;
3554
3883
  const boxX = M;
3555
3884
  const boxW = stageW - 2 * M - (loop ? V_LOOP_SIDE + V_LOOP_PAD + loopW : 0);
@@ -3566,11 +3895,11 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3566
3895
  return Math.ceil(2 * PAD_Y2 + label + (note > 0 ? NOTE_TOP + note * NOTE * NOTE_LH : 0));
3567
3896
  })
3568
3897
  );
3569
- const room = (stageH - 2 * M - (count - 1) * V_GAP) / count;
3898
+ const room = (stageH - 2 * M - (count2 - 1) * V_GAP) / count2;
3570
3899
  const boxH = Math.max(need, Math.min(room, V_BOX_H));
3571
3900
  return {
3572
3901
  size,
3573
- 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 })),
3574
3903
  boxH,
3575
3904
  boxX,
3576
3905
  boxW,
@@ -3582,7 +3911,7 @@ function columnLayout(stageW, stageH, stages, face, loop) {
3582
3911
  loopLines: loop ? wrap(loop.label, NOTE, loopW, 500, 0, face) : [],
3583
3912
  loopX: boxX + boxW + V_LOOP_SIDE,
3584
3913
  loopW,
3585
- svgH: 2 * M + count * boxH + (count - 1) * V_GAP
3914
+ svgH: 2 * M + count2 * boxH + (count2 - 1) * V_GAP
3586
3915
  };
3587
3916
  }
3588
3917
  function balance(label, k, face) {
@@ -4300,8 +4629,8 @@ function stackLayout(p, format, face = "latin") {
4300
4629
  if (inline2.fits) return inline2;
4301
4630
  return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
4302
4631
  }
4303
- function labelWeight(i, count) {
4304
- return i === count - 1 ? 700 : 600;
4632
+ function labelWeight(i, count2) {
4633
+ return i === count2 - 1 ? 700 : 600;
4305
4634
  }
4306
4635
  function floorFor(p, format) {
4307
4636
  if (!p.tilt) return MIN_FONT;
@@ -4311,7 +4640,7 @@ function solve2(p, format, inline2, face) {
4311
4640
  const width = contentW(format);
4312
4641
  const boxH = contentH(format);
4313
4642
  const floor = floorFor(p, format);
4314
- const count = p.layers.length;
4643
+ const count2 = p.layers.length;
4315
4644
  const k = isPortrait(format) ? "tall" : "wide";
4316
4645
  const riseMax = RISE_MAX[k];
4317
4646
  const syMax = SY_MAX[k];
@@ -4319,7 +4648,7 @@ function solve2(p, format, inline2, face) {
4319
4648
  const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
4320
4649
  const want = Math.max(
4321
4650
  ...p.layers.map(
4322
- (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)
4323
4652
  )
4324
4653
  );
4325
4654
  const colCap = inline2 ? width * 0.56 : width * 0.5;
@@ -4328,7 +4657,7 @@ function solve2(p, format, inline2, face) {
4328
4657
  const labelX = width - colW;
4329
4658
  const labelRoom = Math.min(
4330
4659
  ...p.layers.map(
4331
- (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))
4332
4661
  )
4333
4662
  );
4334
4663
  const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
@@ -4336,7 +4665,7 @@ function solve2(p, format, inline2, face) {
4336
4665
  const nw = noteW(l);
4337
4666
  const labelMaxW = Math.max(labelSize, colW - nw);
4338
4667
  return {
4339
- label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count), 0, face),
4668
+ label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count2), 0, face),
4340
4669
  // Inline notes stay on one line by contract — the schema calls a note "one
4341
4670
  // short line" — and wrapping one would put its second line under the label.
4342
4671
  note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
@@ -4354,13 +4683,13 @@ function solve2(p, format, inline2, face) {
4354
4683
  const noteH = noteHeight(p.note, noteWidth(format), 28, face);
4355
4684
  const free = boxH - chromeH - 20 - noteH;
4356
4685
  const avail = Math.max(300, free);
4357
- 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);
4358
4687
  const rise = clamp(room, RISE_MIN, riseMax);
4359
4688
  const sy = clamp(rise * 0.62, 46, syMax);
4360
4689
  const t2 = clamp(rise * 0.14, 10, tMax);
4361
4690
  const span = width - NUM_W - GAP3 - colW;
4362
4691
  const sx = clamp(span * 0.2, 110, 220);
4363
- const height = 2 * pad + (count - 1) * rise + sy + t2;
4692
+ const height = 2 * pad + (count2 - 1) * rise + sy + t2;
4364
4693
  return {
4365
4694
  // A rise shorter than a label block prints the note through the label below.
4366
4695
  // BOTH directions. A layout that fits the height and not the width is not a
@@ -4385,7 +4714,7 @@ function solve2(p, format, inline2, face) {
4385
4714
  labelSize,
4386
4715
  blockH,
4387
4716
  pad,
4388
- yBase: pad + (count - 1) * rise + sy,
4717
+ yBase: pad + (count2 - 1) * rise + sy,
4389
4718
  lines
4390
4719
  };
4391
4720
  }
@@ -4402,11 +4731,11 @@ var stack = (beat, ctx) => {
4402
4731
  const p = beat.params;
4403
4732
  const face = faceOf(ctx.theme.fontStack);
4404
4733
  const L = stackLayout(p, ctx.format, face);
4405
- const count = p.layers.length;
4406
- const last = count - 1;
4734
+ const count2 = p.layers.length;
4735
+ const last = count2 - 1;
4407
4736
  if (!L.fits) {
4408
4737
  throw new Error(
4409
- `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.`
4410
4739
  );
4411
4740
  }
4412
4741
  const parts = {};
@@ -4434,7 +4763,7 @@ var stack = (beat, ctx) => {
4434
4763
  { x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
4435
4764
  {
4436
4765
  size: L.labelSize,
4437
- weight: labelWeight(i, count),
4766
+ weight: labelWeight(i, count2),
4438
4767
  fill: top ? theme.tones.b : theme.fg,
4439
4768
  maxWidth: block.labelMaxW,
4440
4769
  lineHeight: 1.16,
@@ -4488,10 +4817,10 @@ var stack = (beat, ctx) => {
4488
4817
  <div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
4489
4818
  const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
4490
4819
  <div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe3)}</div>${noteHtml}`;
4491
- const centre2 = (count - 1) / 2;
4820
+ const centre2 = (count2 - 1) / 2;
4492
4821
  const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
4493
4822
  const first = 0.9;
4494
- 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));
4495
4824
  const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
4496
4825
  const holds = [];
4497
4826
  const spot = spotlighter(sid);
@@ -4523,11 +4852,11 @@ var stack = (beat, ctx) => {
4523
4852
  );
4524
4853
  }
4525
4854
  if (p.note) {
4526
- const at = first + count * step;
4855
+ const at = first + count2 * step;
4527
4856
  tl.push(tween(`#${sid}-note`, { opacity: 0 }, { opacity: 1, duration: 0.6 }, at));
4528
4857
  holds.push(at + 0.7);
4529
4858
  }
4530
- if (count > 1) tl.push(...spot.restore(first + count * step));
4859
+ if (count2 > 1) tl.push(...spot.restore(first + count2 * step));
4531
4860
  return {
4532
4861
  html,
4533
4862
  parts,
@@ -5829,8 +6158,8 @@ async function narrate(storyboard, source, prefs, opts) {
5829
6158
  const text2 = beat.narration?.trim();
5830
6159
  if (!text2) continue;
5831
6160
  const segments2 = [];
5832
- const count = stopsFor(beat, source, format, `s${i + 1}`, theme);
5833
- 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));
5834
6163
  for (const [stop, line2] of plan.entries()) {
5835
6164
  if (!line2) continue;
5836
6165
  const speech = await synthesize(line2, {
@@ -5852,7 +6181,7 @@ async function narrate(storyboard, source, prefs, opts) {
5852
6181
  }
5853
6182
  if (segments2.length > 0) {
5854
6183
  beats[beat.id] = segments2;
5855
- staged[beat.id] = count;
6184
+ staged[beat.id] = count2;
5856
6185
  }
5857
6186
  }
5858
6187
  return {
@@ -6722,11 +7051,11 @@ function stageScene(scene, speed) {
6722
7051
  function speechPlan(open, holds, segments2) {
6723
7052
  const starts = [];
6724
7053
  let at = open;
6725
- for (const [i, segment] of segments2.entries()) {
6726
- 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;
6727
7056
  if (i > 0) at = Math.max(at, hold);
6728
7057
  starts.push(at);
6729
- at += segment.seconds;
7058
+ at += segment2.seconds;
6730
7059
  }
6731
7060
  return { starts, end: at };
6732
7061
  }
@@ -6951,10 +7280,10 @@ function pack(words2, width) {
6951
7280
  function splitCue(cue, max = CUE_MAX_CHARS) {
6952
7281
  if (cue.text.length <= max) return [cue];
6953
7282
  const words2 = cue.text.split(/\s+/).filter(Boolean);
6954
- const count = pack(words2, max).length;
7283
+ const count2 = pack(words2, max).length;
6955
7284
  let width = max;
6956
- for (let w = Math.ceil(cue.text.length / count); w < max; w++) {
6957
- 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) {
6958
7287
  width = w;
6959
7288
  break;
6960
7289
  }
@@ -7046,17 +7375,17 @@ function place(scenes, spoken) {
7046
7375
  );
7047
7376
  }
7048
7377
  const { starts } = speechPlan(scene.open, scene.holds, segments2);
7049
- for (const [i, segment] of segments2.entries()) {
7050
- 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);
7051
7380
  out.push({
7052
- id: `${scene.id}.${segment.stop}`,
7381
+ id: `${scene.id}.${segment2.stop}`,
7053
7382
  scene: scene.id,
7054
- stop: segment.stop,
7055
- audio: segment.audio,
7383
+ stop: segment2.stop,
7384
+ audio: segment2.audio,
7056
7385
  hold: round5(scene.start + scene.holds[index]),
7057
7386
  start: round5(scene.start + starts[i]),
7058
- duration: segment.seconds,
7059
- 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)))
7060
7389
  });
7061
7390
  }
7062
7391
  }
@@ -7133,6 +7462,15 @@ function planTiming(input) {
7133
7462
  // `emitDeck` and `writeTiming` are handed the same `format`, so this cannot
7134
7463
  // disagree with the box the archetypes actually drew into.
7135
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
+ },
7136
7474
  scenes,
7137
7475
  segments: segments2
7138
7476
  };
@@ -7159,15 +7497,15 @@ function framePlan(timing, fps) {
7159
7497
  );
7160
7498
  }
7161
7499
  pieces.push({ from: first, motion: last - first, freeze: 0 });
7162
- for (const segment of segments2) {
7163
- const at = f(segment.start);
7500
+ for (const segment2 of segments2) {
7501
+ const at = f(segment2.start);
7164
7502
  audio.push({
7165
- id: segment.id,
7166
- audio: segment.audio,
7503
+ id: segment2.id,
7504
+ audio: segment2.audio,
7167
7505
  startFrame: at,
7168
7506
  delayMs: Math.round(at / fps * 1e3)
7169
7507
  });
7170
- for (const cue of segment.cues) {
7508
+ for (const cue of segment2.cues) {
7171
7509
  const start = at / fps + cue.start;
7172
7510
  const end = at / fps + cue.end;
7173
7511
  if (end > start) cues.push({ start, end, text: cue.text });
@@ -7732,6 +8070,22 @@ async function resolveChrome(need = "open the deck with") {
7732
8070
  async function chromePath(need = "open the deck with") {
7733
8071
  return (await resolveChrome(need)).path;
7734
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
+ }
7735
8089
 
7736
8090
  // src/source/assets.ts
7737
8091
  import { createHash as createHash5 } from "node:crypto";
@@ -8703,6 +9057,14 @@ async function readInBrowser(html, timeoutMs) {
8703
9057
  // happens to be that run.
8704
9058
  "--host-resolver-rules=MAP * ~NOTFOUND"
8705
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
+ );
8706
9068
  });
8707
9069
  try {
8708
9070
  const page = await browser.newPage();
@@ -10059,8 +10421,13 @@ function renderSource(source) {
10059
10421
  }
10060
10422
  }
10061
10423
  out.push("", "== EQUATIONS ==");
10062
- for (const e of source.equations)
10063
- 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
+ }
10064
10431
  if (!source.equations.length)
10065
10432
  out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
10066
10433
  out.push("", "== TABLES ==");
@@ -10592,10 +10959,10 @@ function drawSvg(req) {
10592
10959
  const seed = createHash7("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
10593
10960
  const rand = mulberry32(seed.readUInt32BE(0));
10594
10961
  const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
10595
- const count = 6 + Math.floor(rand() * 5);
10962
+ const count2 = 6 + Math.floor(rand() * 5);
10596
10963
  const unit = Math.min(W, H);
10597
10964
  const parts = [`<rect width="${W}" height="${H}" fill="${PAPER}"/>`];
10598
- for (let i = 0; i < count; i++) {
10965
+ for (let i = 0; i < count2; i++) {
10599
10966
  const kind = Math.floor(rand() * 3);
10600
10967
  const cx = Math.round(W * (0.15 + 0.7 * rand()));
10601
10968
  const cy = Math.round(H * (0.15 + 0.7 * rand()));
@@ -10886,7 +11253,7 @@ ${bands}
10886
11253
  </div>
10887
11254
  `;
10888
11255
  }
10889
- function measure3(count) {
11256
+ function measure3(count2) {
10890
11257
  const doc = document;
10891
11258
  const probe3 = doc.getElementById("c0");
10892
11259
  if (probe3) {
@@ -10899,7 +11266,7 @@ function measure3(count) {
10899
11266
  probe3.classList.remove("on");
10900
11267
  }
10901
11268
  const rects = [];
10902
- for (let i = 0; i < count; i++) {
11269
+ for (let i = 0; i < count2; i++) {
10903
11270
  const el = doc.getElementById(`c${i}`);
10904
11271
  if (!el) continue;
10905
11272
  el.classList.add("on");
@@ -10927,11 +11294,12 @@ async function renderCaptions(cues, style, deck, work2) {
10927
11294
  await mkdir7(work2, { recursive: true });
10928
11295
  await writeFile9(page, captionPage(cues, style, href));
10929
11296
  const { default: puppeteer } = await import("puppeteer-core");
10930
- const browser = await puppeteer.launch({
10931
- executablePath: await chromePath(CAPTION_NEED),
10932
- headless: true,
10933
- args: ["--force-device-scale-factor=1", "--hide-scrollbars"]
10934
- });
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
+ );
10935
11303
  try {
10936
11304
  const tab = await browser.newPage();
10937
11305
  await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
@@ -11356,10 +11724,10 @@ function safeEntryPath(name) {
11356
11724
  const segments2 = name.replace(/\\/g, "/").split("/");
11357
11725
  if (/^[a-zA-Z]:/.test(name) || name.startsWith("/") || name.startsWith("\\")) return null;
11358
11726
  const kept = [];
11359
- for (const segment of segments2) {
11360
- if (segment === "" || segment === ".") continue;
11361
- if (segment === "..") return null;
11362
- kept.push(segment);
11727
+ for (const segment2 of segments2) {
11728
+ if (segment2 === "" || segment2 === ".") continue;
11729
+ if (segment2 === "..") return null;
11730
+ kept.push(segment2);
11363
11731
  }
11364
11732
  if (kept.length === 0) return null;
11365
11733
  const path2 = kept.join("/");