@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/README.md +29 -8
- package/dist/cli.js +619 -179
- package/dist/index.js +613 -176
- package/dist/mcp.js +554 -166
- package/dist/types/build/files.d.ts +2 -0
- package/dist/types/emit/archetypes/equation-morph.d.ts +40 -0
- package/dist/types/emit/archetypes/equation-walk.d.ts +60 -1
- package/dist/types/emit/composition.d.ts +3 -2
- package/dist/types/emit/tex.d.ts +39 -0
- package/dist/types/index.d.ts +4 -1
- package/dist/types/plan/prompt.d.ts +0 -12
- package/dist/types/render/capture.d.ts +24 -0
- package/dist/types/render/timing.d.ts +16 -0
- package/dist/types/source/fonts.d.ts +1 -1
- package/dist/types/verify/budget.d.ts +8 -1
- package/dist/types/verify/fidelity.d.ts +19 -0
- package/dist/types/verify/index.d.ts +17 -0
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -157,6 +157,11 @@ async function copyAudio(from, narration, out, step2) {
|
|
|
157
157
|
return names.map((n3) => join2(dir, n3));
|
|
158
158
|
}
|
|
159
159
|
async function refreshFont(storyboard, source, out, step2) {
|
|
160
|
+
if (familyFor(storyboard.lang) === null) {
|
|
161
|
+
const css = await vendorInter(join2(out, "assets", "fonts"));
|
|
162
|
+
step2("build: vendored Inter beside the deck");
|
|
163
|
+
return css;
|
|
164
|
+
}
|
|
160
165
|
try {
|
|
161
166
|
const bundle = await bundleFont(
|
|
162
167
|
storyboard.lang,
|
|
@@ -172,6 +177,21 @@ async function refreshFont(storyboard, source, out, step2) {
|
|
|
172
177
|
return void 0;
|
|
173
178
|
}
|
|
174
179
|
}
|
|
180
|
+
async function vendorInter(dir) {
|
|
181
|
+
const pkg = dirname(createRequire(import.meta.url).resolve("@fontsource-variable/inter"));
|
|
182
|
+
const css = await readFile2(join2(pkg, "index.css"), "utf8");
|
|
183
|
+
await mkdir2(dir, { recursive: true });
|
|
184
|
+
for (const [, name] of css.matchAll(/url\(\.\/files\/([^)]+)\)/g)) {
|
|
185
|
+
await cp(join2(pkg, "files", name ?? ""), join2(dir, name ?? ""));
|
|
186
|
+
}
|
|
187
|
+
const ours = css.replaceAll("font-family: 'Inter Variable'", "font-family: 'Inter'").replaceAll("font-display: swap", "font-display: block").replace(/url\(\.\/files\/([^)]+)\) format\('woff2-variations'\)/g, "url($1) format('woff2')");
|
|
188
|
+
if (/Inter Variable|\.\/files\/|swap/.test(ours) || !ours.includes("font-family: 'Inter';")) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`@fontsource-variable/inter's index.css changed shape; update vendorInter (${pkg})`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return ours;
|
|
194
|
+
}
|
|
175
195
|
|
|
176
196
|
// src/emit/kit.ts
|
|
177
197
|
var PAD_X = 110;
|
|
@@ -849,35 +869,35 @@ function elbow(sid, from, to, o) {
|
|
|
849
869
|
}
|
|
850
870
|
return path(d, { ...strokeAttrs(o), "marker-end": `url(#${headId(sid, o.stroke)})` });
|
|
851
871
|
}
|
|
852
|
-
function tracks(width,
|
|
853
|
-
const w =
|
|
854
|
-
return Array.from({ length:
|
|
872
|
+
function tracks(width, count2, gap, x0 = 0) {
|
|
873
|
+
const w = count2 > 1 ? (width - gap * (count2 - 1)) / count2 : width;
|
|
874
|
+
return Array.from({ length: count2 }, (_, i) => ({ x: x0 + i * (w + gap), w }));
|
|
855
875
|
}
|
|
856
876
|
function fitBoxes(req) {
|
|
857
|
-
const
|
|
877
|
+
const count2 = req.labels.length;
|
|
858
878
|
const minGap = req.minGap ?? 24;
|
|
859
879
|
const padEm = req.padEm ?? 0.8;
|
|
860
880
|
const weight = req.weight ?? 600;
|
|
861
881
|
const x0 = req.x0 ?? 0;
|
|
862
882
|
const unit = Math.max(1, ...req.labels.map((l) => textWidth(l, 1, weight) + 2 * padEm));
|
|
863
|
-
const sizeAt = (gap) =>
|
|
883
|
+
const sizeAt = (gap) => count2 < 1 ? req.size : (req.width - gap * (count2 - 1)) / count2 / unit;
|
|
864
884
|
if (sizeAt(req.gap) >= req.size) {
|
|
865
|
-
return { ok: true, size: req.size, gap: req.gap, boxes: tracks(req.width,
|
|
885
|
+
return { ok: true, size: req.size, gap: req.gap, boxes: tracks(req.width, count2, req.gap, x0) };
|
|
866
886
|
}
|
|
867
|
-
if (
|
|
868
|
-
const gap = (req.width -
|
|
869
|
-
return { ok: true, size: req.size, gap, boxes: tracks(req.width,
|
|
887
|
+
if (count2 > 1 && sizeAt(minGap) >= req.size) {
|
|
888
|
+
const gap = (req.width - count2 * req.size * unit) / (count2 - 1);
|
|
889
|
+
return { ok: true, size: req.size, gap, boxes: tracks(req.width, count2, gap, x0) };
|
|
870
890
|
}
|
|
871
891
|
const size3 = sizeAt(minGap);
|
|
872
892
|
if (size3 >= MIN_FONT) {
|
|
873
|
-
return { ok: true, size: size3, gap: minGap, boxes: tracks(req.width,
|
|
893
|
+
return { ok: true, size: size3, gap: minGap, boxes: tracks(req.width, count2, minGap, x0) };
|
|
874
894
|
}
|
|
875
895
|
return {
|
|
876
896
|
ok: false,
|
|
877
897
|
size: MIN_FONT,
|
|
878
898
|
gap: minGap,
|
|
879
|
-
boxes: tracks(req.width,
|
|
880
|
-
needed:
|
|
899
|
+
boxes: tracks(req.width, count2, minGap, x0),
|
|
900
|
+
needed: count2 * MIN_FONT * unit + minGap * (count2 - 1)
|
|
881
901
|
};
|
|
882
902
|
}
|
|
883
903
|
|
|
@@ -1210,8 +1230,8 @@ var MID_GAP = 56;
|
|
|
1210
1230
|
var BAND_GAP = 34;
|
|
1211
1231
|
var COL_GAP = 28;
|
|
1212
1232
|
var MIN_COL = 250;
|
|
1213
|
-
function lineBudget(
|
|
1214
|
-
const perSide = Math.max(1, Math.ceil(
|
|
1233
|
+
function lineBudget(count2, stageH) {
|
|
1234
|
+
const perSide = Math.max(1, Math.ceil(count2 / 2));
|
|
1215
1235
|
const room = (stageH - (perSide - 1) * LAB_GAP) / perSide;
|
|
1216
1236
|
const lines = Math.floor((room - ASCENT - RULE_LIFT - RULE_W) / (LAB * LAB_LH)) + 1;
|
|
1217
1237
|
return Math.min(LINE_CEIL, Math.max(1, lines));
|
|
@@ -1622,7 +1642,7 @@ var HEAD_GAP = 14;
|
|
|
1622
1642
|
var barCompare = (beat, ctx) => {
|
|
1623
1643
|
const { sid, theme } = ctx;
|
|
1624
1644
|
const p = beat.params;
|
|
1625
|
-
const
|
|
1645
|
+
const count2 = p.bars.length;
|
|
1626
1646
|
const W = contentW(ctx.format);
|
|
1627
1647
|
const face = faceOf(ctx.theme.fontStack);
|
|
1628
1648
|
const tall = isPortrait(ctx.format);
|
|
@@ -1636,7 +1656,7 @@ var barCompare = (beat, ctx) => {
|
|
|
1636
1656
|
0,
|
|
1637
1657
|
face
|
|
1638
1658
|
);
|
|
1639
|
-
let bar = Math.min(BAR_MAX, (avail - unitBand - FOOT) / (
|
|
1659
|
+
let bar = Math.min(BAR_MAX, (avail - unitBand - FOOT) / (count2 + GAP_RATIO * (count2 - 1)));
|
|
1640
1660
|
let pitch = bar * (1 + GAP_RATIO);
|
|
1641
1661
|
const gutterMax = tall ? 0 : share(GUTTER_MAX, W);
|
|
1642
1662
|
const gutterInner = tall ? W : gutterMax - GUTTER_PAD;
|
|
@@ -1658,7 +1678,7 @@ var barCompare = (beat, ctx) => {
|
|
|
1658
1678
|
if (tall) {
|
|
1659
1679
|
bar = Math.min(
|
|
1660
1680
|
BAR_MAX,
|
|
1661
|
-
(avail - unitBand - FOOT -
|
|
1681
|
+
(avail - unitBand - FOOT - count2 * head) / (count2 + GAP_RATIO * (count2 - 1))
|
|
1662
1682
|
);
|
|
1663
1683
|
pitch = head + bar * (1 + GAP_RATIO);
|
|
1664
1684
|
} else {
|
|
@@ -1669,7 +1689,7 @@ var barCompare = (beat, ctx) => {
|
|
|
1669
1689
|
}
|
|
1670
1690
|
}
|
|
1671
1691
|
const valueSize = Math.max(MIN_FONT, Math.min(VALUE_MAX, bar * 0.9));
|
|
1672
|
-
const barsH = (
|
|
1692
|
+
const barsH = (count2 - 1) * pitch + head + bar;
|
|
1673
1693
|
const foot = Math.max(
|
|
1674
1694
|
0,
|
|
1675
1695
|
Math.ceil(
|
|
@@ -1682,7 +1702,7 @@ var barCompare = (beat, ctx) => {
|
|
|
1682
1702
|
const H = barsH + unitBand + foot;
|
|
1683
1703
|
if (H > avail) {
|
|
1684
1704
|
throw new Error(
|
|
1685
|
-
`bar-compare ${beat.id}: ${
|
|
1705
|
+
`bar-compare ${beat.id}: ${count2} bars with labels this long need ${Math.ceil(H)}px of the ${Math.floor(avail)}px this slide has. Shorten the labels or split the beat.`
|
|
1686
1706
|
);
|
|
1687
1707
|
}
|
|
1688
1708
|
const gutter = tall ? 0 : Math.min(
|
|
@@ -1835,7 +1855,7 @@ ${svg(id(sid, "chart"), W, H, body)}
|
|
|
1835
1855
|
</div>${note2}`;
|
|
1836
1856
|
const railsAt = 0.6;
|
|
1837
1857
|
const barsAt = 0.95;
|
|
1838
|
-
const step2 = Math.min(0.4, 2.4 /
|
|
1858
|
+
const step2 = Math.min(0.4, 2.4 / count2);
|
|
1839
1859
|
const grow = 0.85;
|
|
1840
1860
|
const tl = [
|
|
1841
1861
|
...chromeIn(sid, p.eyebrow !== void 0),
|
|
@@ -1885,11 +1905,11 @@ ${svg(id(sid, "chart"), W, H, body)}
|
|
|
1885
1905
|
);
|
|
1886
1906
|
}
|
|
1887
1907
|
}
|
|
1888
|
-
const settled = barsAt + (
|
|
1908
|
+
const settled = barsAt + (count2 - 1) * step2 + grow + 0.05;
|
|
1889
1909
|
const holds = [settled + 0.2];
|
|
1890
1910
|
const focal = rows.reduce((best, r) => Math.abs(r.value) > Math.abs(best.value) ? r : best);
|
|
1891
1911
|
const spot = spotlighter(sid, ".bc-bar");
|
|
1892
|
-
if (
|
|
1912
|
+
if (count2 > 1) tl.push(...spot.lit(`#${id(sid, "bar", focal.i)}`, settled));
|
|
1893
1913
|
const tailAt = settled + 0.3;
|
|
1894
1914
|
if (p.unit) {
|
|
1895
1915
|
tl.push(tween(`#${id(sid, "unit")}`, { opacity: 0 }, { opacity: 1, duration: 0.5 }, tailAt));
|
|
@@ -2382,6 +2402,90 @@ var dataTable = (beat, ctx) => {
|
|
|
2382
2402
|
};
|
|
2383
2403
|
};
|
|
2384
2404
|
|
|
2405
|
+
// src/emit/tex.ts
|
|
2406
|
+
import katex from "katex";
|
|
2407
|
+
var UNFIT_ATTR = "data-ds-unfit";
|
|
2408
|
+
var trust = (c) => c.command === "\\htmlClass";
|
|
2409
|
+
function texError(tex, display) {
|
|
2410
|
+
try {
|
|
2411
|
+
katex.renderToString(tex, {
|
|
2412
|
+
displayMode: display,
|
|
2413
|
+
throwOnError: true,
|
|
2414
|
+
strict: false,
|
|
2415
|
+
trust,
|
|
2416
|
+
output: "html"
|
|
2417
|
+
});
|
|
2418
|
+
return null;
|
|
2419
|
+
} catch (err) {
|
|
2420
|
+
return err instanceof Error ? err.message : String(err);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
var LEFT = /\\left(?![a-zA-Z])/g;
|
|
2424
|
+
var RIGHT = /\\right(?![a-zA-Z])/g;
|
|
2425
|
+
var NO_INK = /\\label\{[^{}]*\}|\\nonumber(?![a-zA-Z])|\\notag(?![a-zA-Z])/g;
|
|
2426
|
+
function braceBalance(tex) {
|
|
2427
|
+
let depth = 0;
|
|
2428
|
+
for (let i = 0; i < tex.length; i++) {
|
|
2429
|
+
const c = tex[i];
|
|
2430
|
+
if (c === "\\") {
|
|
2431
|
+
i++;
|
|
2432
|
+
continue;
|
|
2433
|
+
}
|
|
2434
|
+
if (c === "{") depth++;
|
|
2435
|
+
else if (c === "}") depth--;
|
|
2436
|
+
}
|
|
2437
|
+
return depth;
|
|
2438
|
+
}
|
|
2439
|
+
var count = (tex, re) => (tex.match(re) ?? []).length;
|
|
2440
|
+
function repairTex(input, display) {
|
|
2441
|
+
const repairs = [];
|
|
2442
|
+
let tex = input;
|
|
2443
|
+
let error = texError(tex, display);
|
|
2444
|
+
if (error === null) return { tex, repairs, error };
|
|
2445
|
+
const gone = tex.match(NO_INK) ?? [];
|
|
2446
|
+
if (gone.length) {
|
|
2447
|
+
tex = tex.replace(NO_INK, "").trim();
|
|
2448
|
+
repairs.push(`removed ${gone.join(", ")}, which draw nothing`);
|
|
2449
|
+
error = texError(tex, display);
|
|
2450
|
+
}
|
|
2451
|
+
const lefts = count(tex, LEFT);
|
|
2452
|
+
const rights = count(tex, RIGHT);
|
|
2453
|
+
if (error !== null && lefts !== rights) {
|
|
2454
|
+
const n3 = Math.abs(lefts - rights);
|
|
2455
|
+
tex = lefts > rights ? `${tex} ${"\\right.".repeat(n3)}` : `${"\\left.".repeat(n3)} ${tex}`;
|
|
2456
|
+
repairs.push(
|
|
2457
|
+
`${lefts} \\left against ${rights} \\right: added ${n3} invisible ${lefts > rights ? "\\right." : "\\left."} to balance them`
|
|
2458
|
+
);
|
|
2459
|
+
error = texError(tex, display);
|
|
2460
|
+
}
|
|
2461
|
+
const depth = braceBalance(tex);
|
|
2462
|
+
if (error !== null && depth !== 0) {
|
|
2463
|
+
const n3 = Math.abs(depth);
|
|
2464
|
+
tex = depth > 0 ? `${tex}${"}".repeat(n3)}` : `${"{".repeat(n3)}${tex}`;
|
|
2465
|
+
repairs.push(
|
|
2466
|
+
`${n3} unclosed brace group(s): ${depth > 0 ? "closed at the end" : "opened at the start"}`
|
|
2467
|
+
);
|
|
2468
|
+
error = texError(tex, display);
|
|
2469
|
+
}
|
|
2470
|
+
const undefinedNames = [];
|
|
2471
|
+
for (let round7 = 0; error !== null && round7 < 12; round7++) {
|
|
2472
|
+
const m = /Undefined control sequence: \\([a-zA-Z]+)/.exec(error);
|
|
2473
|
+
if (!m) break;
|
|
2474
|
+
const name = m[1];
|
|
2475
|
+
const next = tex.replace(new RegExp(`\\\\${name}(?![a-zA-Z])`, "g"), `\\operatorname{${name}}`);
|
|
2476
|
+
if (next === tex) break;
|
|
2477
|
+
tex = next;
|
|
2478
|
+
undefinedNames.push(`\\${name}`);
|
|
2479
|
+
error = texError(tex, display);
|
|
2480
|
+
}
|
|
2481
|
+
if (undefinedNames.length) {
|
|
2482
|
+
repairs.push(
|
|
2483
|
+
`${undefinedNames.join(", ")} ${undefinedNames.length === 1 ? "is" : "are"} never defined in the source \u2014 drawn as ${undefinedNames.length === 1 ? "its name" : "their names"} in upright type`
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
return { tex, repairs, error };
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2385
2489
|
// src/emit/archetypes/equation-walk.ts
|
|
2386
2490
|
var TRUST = 'function (c) { return c.command === "\\\\htmlClass"; }';
|
|
2387
2491
|
var OPTS = `{ displayMode: true, trust: ${TRUST}, strict: false, output: "html" }`;
|
|
@@ -2396,6 +2500,9 @@ var SYNONYMS = [
|
|
|
2396
2500
|
function normalise(tex) {
|
|
2397
2501
|
let out = "";
|
|
2398
2502
|
const map = [];
|
|
2503
|
+
const ends = [];
|
|
2504
|
+
const lead = [];
|
|
2505
|
+
let hint = null;
|
|
2399
2506
|
for (let i = 0; i < tex.length; ) {
|
|
2400
2507
|
if (/\s/.test(tex[i])) {
|
|
2401
2508
|
i++;
|
|
@@ -2407,19 +2514,19 @@ function normalise(tex) {
|
|
|
2407
2514
|
const m = new RegExp(`^(?:${re.source})`).exec(rest);
|
|
2408
2515
|
return m ? { len: m[0].length, to } : null;
|
|
2409
2516
|
}).find(Boolean);
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
i
|
|
2416
|
-
|
|
2517
|
+
const len = hit ? hit.len : 1;
|
|
2518
|
+
const chars = hit ? hit.to : tex[i];
|
|
2519
|
+
if (chars === "") hint ??= i;
|
|
2520
|
+
for (const ch of chars) {
|
|
2521
|
+
out += ch;
|
|
2522
|
+
map.push(i);
|
|
2523
|
+
ends.push(i + len);
|
|
2524
|
+
lead.push(hint ?? i);
|
|
2525
|
+
hint = null;
|
|
2417
2526
|
}
|
|
2418
|
-
|
|
2419
|
-
map.push(i);
|
|
2420
|
-
i++;
|
|
2527
|
+
i += len;
|
|
2421
2528
|
}
|
|
2422
|
-
return { text: out, map };
|
|
2529
|
+
return { text: out, map, ends, lead };
|
|
2423
2530
|
}
|
|
2424
2531
|
function locate(tex, term) {
|
|
2425
2532
|
const hay = normalise(tex);
|
|
@@ -2427,58 +2534,131 @@ function locate(tex, term) {
|
|
|
2427
2534
|
if (needle === "") return null;
|
|
2428
2535
|
const at = hay.text.indexOf(needle);
|
|
2429
2536
|
if (at < 0) return null;
|
|
2430
|
-
const start = hay.
|
|
2431
|
-
const
|
|
2432
|
-
const lastOrig = hay.map[lastNorm];
|
|
2433
|
-
let end = lastOrig + 1;
|
|
2434
|
-
while (end < tex.length && (hay.map[lastNorm + 1] ?? tex.length) > end && /\s/.test(tex[end])) {
|
|
2435
|
-
end++;
|
|
2436
|
-
}
|
|
2437
|
-
end = Math.max(end, hay.map[lastNorm + 1] ?? lastOrig + 1);
|
|
2537
|
+
const start = hay.lead[at];
|
|
2538
|
+
const end = hay.ends[at + needle.length - 1];
|
|
2438
2539
|
return { start, end: Math.min(end, tex.length) };
|
|
2439
2540
|
}
|
|
2440
2541
|
function wrapTerms(tex, terms, beatId, cls = (t2) => `term t-${t2.tone}`) {
|
|
2542
|
+
const checked = texError(tex, true) === null;
|
|
2441
2543
|
let parts = [{ text: tex, raw: true }];
|
|
2442
2544
|
const used = [];
|
|
2443
2545
|
const missing = [];
|
|
2546
|
+
const broken = [];
|
|
2547
|
+
const joined = (ps) => ps.map((p) => p.text).join("");
|
|
2444
2548
|
for (const term of terms) {
|
|
2445
2549
|
let placed = false;
|
|
2550
|
+
let found = false;
|
|
2446
2551
|
for (let i = 0; i < parts.length && !placed; i++) {
|
|
2447
2552
|
const part = parts[i];
|
|
2448
2553
|
if (!part.raw) continue;
|
|
2449
2554
|
const at = locate(part.text, term.tex);
|
|
2450
2555
|
if (!at) continue;
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
raw:
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2556
|
+
found = true;
|
|
2557
|
+
const body = `\\htmlClass{${cls(term)}}{${part.text.slice(at.start, at.end)}}`;
|
|
2558
|
+
for (const text2 of [body, `{${body}}`]) {
|
|
2559
|
+
const next = parts.toSpliced(
|
|
2560
|
+
i,
|
|
2561
|
+
1,
|
|
2562
|
+
{ text: part.text.slice(0, at.start), raw: true },
|
|
2563
|
+
{ text: text2, raw: false },
|
|
2564
|
+
{ text: part.text.slice(at.end), raw: true }
|
|
2565
|
+
);
|
|
2566
|
+
if (checked && texError(joined(next), true) !== null) continue;
|
|
2567
|
+
parts = next;
|
|
2568
|
+
used.push(term);
|
|
2569
|
+
placed = true;
|
|
2570
|
+
break;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
if (!placed) {
|
|
2574
|
+
missing.push(term);
|
|
2575
|
+
if (found) broken.push(term);
|
|
2463
2576
|
}
|
|
2464
|
-
if (!placed) missing.push(term);
|
|
2465
2577
|
}
|
|
2466
2578
|
if (used.length === 0) {
|
|
2467
2579
|
throw new Error(
|
|
2468
|
-
`equation-walk ${beatId}: none of its ${terms.length} term(s) occur in the equation. Terms: ${terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. Equation: ${JSON.stringify(tex)}`
|
|
2580
|
+
`equation-walk ${beatId}: none of its ${terms.length} term(s) ${broken.length ? "can be highlighted in" : "occur in"} the equation. Terms: ${terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. Equation: ${JSON.stringify(tex)}`
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
return { tex: joined(parts), used, missing, broken };
|
|
2584
|
+
}
|
|
2585
|
+
function droppedTerms(eqId, missing, broken) {
|
|
2586
|
+
return missing.map(
|
|
2587
|
+
(t2) => broken.includes(t2) ? `term ${JSON.stringify(t2.tex)} is in ${eqId} but no span around it parses, so it is not highlighted and its legend row ("${t2.label}") is left out` : `term ${JSON.stringify(t2.tex)} does not occur in ${eqId}, so its legend row ("${t2.label}") is left out`
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
function prepareWalk(eq, terms, beatId) {
|
|
2591
|
+
const fixed = repairTex(eq.tex, true);
|
|
2592
|
+
const notes = fixed.repairs.map((r) => `${eq.id} repaired before drawing: ${r}`);
|
|
2593
|
+
const repairedTerms = terms.map((t2) => ({ ...t2, tex: repairTex(t2.tex, false).tex }));
|
|
2594
|
+
if (fixed.error !== null) {
|
|
2595
|
+
notes.push(
|
|
2596
|
+
`${eq.id} does not parse even after repair (${fixed.error}), so it is shown as its TeX source in plain text \u2014 ${JSON.stringify(eq.tex)}`
|
|
2469
2597
|
);
|
|
2598
|
+
const parts = segment(eq.tex, [...terms]);
|
|
2599
|
+
if (parts.used.length === 0) {
|
|
2600
|
+
throw new Error(
|
|
2601
|
+
`equation-walk ${beatId}: none of its ${terms.length} term(s) occur in the equation. Terms: ${terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. Equation: ${JSON.stringify(eq.tex)}`
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
notes.push(...droppedTerms(eq.id, parts.missing, []));
|
|
2605
|
+
return {
|
|
2606
|
+
raw: eq.tex,
|
|
2607
|
+
plain: { error: fixed.error },
|
|
2608
|
+
used: parts.used,
|
|
2609
|
+
missing: parts.missing,
|
|
2610
|
+
notes
|
|
2611
|
+
};
|
|
2470
2612
|
}
|
|
2471
|
-
|
|
2613
|
+
const walk = wrapTerms(fixed.tex, repairedTerms, beatId);
|
|
2614
|
+
const back = (ts) => ts.map((t2) => terms[repairedTerms.indexOf(t2)]);
|
|
2615
|
+
const used = back(walk.used);
|
|
2616
|
+
const missing = back(walk.missing);
|
|
2617
|
+
notes.push(...droppedTerms(eq.id, missing, back(walk.broken)));
|
|
2618
|
+
return { tex: walk.tex, raw: fixed.tex, used, missing, notes };
|
|
2619
|
+
}
|
|
2620
|
+
function segment(tex, terms) {
|
|
2621
|
+
let parts = [{ text: tex }];
|
|
2622
|
+
const used = [];
|
|
2623
|
+
const missing = [];
|
|
2624
|
+
for (const term of terms) {
|
|
2625
|
+
const i = parts.findIndex((p) => !p.term && locate(p.text, term.tex));
|
|
2626
|
+
const part = parts[i];
|
|
2627
|
+
const at = part && locate(part.text, term.tex);
|
|
2628
|
+
if (!part || !at) {
|
|
2629
|
+
missing.push(term);
|
|
2630
|
+
continue;
|
|
2631
|
+
}
|
|
2632
|
+
parts = parts.toSpliced(
|
|
2633
|
+
i,
|
|
2634
|
+
1,
|
|
2635
|
+
{ text: part.text.slice(0, at.start) },
|
|
2636
|
+
{ text: part.text.slice(at.start, at.end), term },
|
|
2637
|
+
{ text: part.text.slice(at.end) }
|
|
2638
|
+
);
|
|
2639
|
+
used.push(term);
|
|
2640
|
+
}
|
|
2641
|
+
return { parts, used, missing };
|
|
2642
|
+
}
|
|
2643
|
+
function plainHtml(tex, terms) {
|
|
2644
|
+
return segment(tex, terms).parts.map(
|
|
2645
|
+
(p) => p.term ? `<span class="term t-${p.term.tone}">${esc(p.text)}</span>` : esc(p.text)
|
|
2646
|
+
).join("");
|
|
2647
|
+
}
|
|
2648
|
+
function chipSetup(sid, term) {
|
|
2649
|
+
const fixed = repairTex(term.tex, false);
|
|
2650
|
+
const el = `document.getElementById("${sid}-chip-${term.tone}")`;
|
|
2651
|
+
return fixed.error === null ? `katex.render('${js(fixed.tex)}', ${el}, ${INLINE_OPTS});` : `${el}.textContent = '${js(term.tex)}';`;
|
|
2472
2652
|
}
|
|
2473
2653
|
function equationSize(tex) {
|
|
2474
2654
|
return tex.length > 120 ? 68 : tex.length > 90 ? 80 : tex.length > 55 ? 92 : 108;
|
|
2475
2655
|
}
|
|
2476
|
-
var
|
|
2656
|
+
var NO_INK2 = /\\(math[a-z]+|text[a-z]*|bm|boldsymbol|displaystyle|textstyle|scriptstyle|left|right|operatorname|limits|nolimits|htmlClass|hspace|phantom|[,;:!])/g;
|
|
2477
2657
|
var GLYPH_EM = 0.9;
|
|
2478
2658
|
function texUnits(tex) {
|
|
2479
2659
|
let ems = 0;
|
|
2480
2660
|
for (const m of tex.matchAll(/\\qquad|\\quad/g)) ems += m[0] === "\\qquad" ? 2 : 1;
|
|
2481
|
-
const glyphs2 = tex.replace(/\\q?quad/g, "").replace(
|
|
2661
|
+
const glyphs2 = tex.replace(/\\q?quad/g, "").replace(NO_INK2, "").replace(/[{}\s_^]/g, "");
|
|
2482
2662
|
return ems + glyphs2.length * GLYPH_EM;
|
|
2483
2663
|
}
|
|
2484
2664
|
function statements(tex, stacked) {
|
|
@@ -2486,6 +2666,100 @@ function statements(tex, stacked) {
|
|
|
2486
2666
|
const parts = tex.split(/\\qquad|\\quad|\\\\/).map((s) => s.trim()).filter(Boolean);
|
|
2487
2667
|
return parts.length > 0 ? parts : [tex];
|
|
2488
2668
|
}
|
|
2669
|
+
var PLAIN_FONT = 48;
|
|
2670
|
+
function mathFit(sid, targets, shown, raw2, want, floor) {
|
|
2671
|
+
return [
|
|
2672
|
+
`var dsEqFit = (function () {
|
|
2673
|
+
var box = document.getElementById("${sid}-eq");
|
|
2674
|
+
var slide = box && box.parentNode;
|
|
2675
|
+
var bw = box ? box.getBoundingClientRect().width : 0;
|
|
2676
|
+
if (!(bw > 0) || !slide) return "unmeasured";
|
|
2677
|
+
var targets = [${targets.map((id2) => `"${id2}"`).join(", ")}].map(function (id) { return document.getElementById(id); });
|
|
2678
|
+
var TEX = [${shown.map((s) => `'${js(s)}'`).join(", ")}];
|
|
2679
|
+
var spill0 = slide.scrollHeight - slide.clientHeight;
|
|
2680
|
+
function extent(each) {
|
|
2681
|
+
var w = 0;
|
|
2682
|
+
targets.forEach(function (t) {
|
|
2683
|
+
var lo = Infinity, hi = -Infinity;
|
|
2684
|
+
Array.prototype.forEach.call(t.querySelectorAll(".katex-html > .base"), function (b) {
|
|
2685
|
+
var r = b.getBoundingClientRect();
|
|
2686
|
+
if (each) w = Math.max(w, r.width);
|
|
2687
|
+
else { lo = Math.min(lo, r.left); hi = Math.max(hi, r.right); }
|
|
2688
|
+
});
|
|
2689
|
+
if (!each && hi > lo) w = Math.max(w, hi - lo);
|
|
2690
|
+
});
|
|
2691
|
+
return w;
|
|
2692
|
+
}
|
|
2693
|
+
function fits(each) {
|
|
2694
|
+
return extent(each) <= bw + 0.5 && slide.scrollHeight - slide.clientHeight <= Math.max(1, spill0 + 1);
|
|
2695
|
+
}
|
|
2696
|
+
function settle(each, size) {
|
|
2697
|
+
size = Math.max(${floor}, Math.min(${want}, Math.floor(size)));
|
|
2698
|
+
box.style.fontSize = size + "px";
|
|
2699
|
+
while (!fits(each) && size > ${floor}) {
|
|
2700
|
+
size = Math.max(${floor}, size - 2);
|
|
2701
|
+
box.style.fontSize = size + "px";
|
|
2702
|
+
}
|
|
2703
|
+
return fits(each);
|
|
2704
|
+
}
|
|
2705
|
+
if (fits(false)) return "fits";
|
|
2706
|
+
var size = parseFloat(box.style.fontSize);
|
|
2707
|
+
if (settle(false, (size * bw) / extent(false))) return "scaled";
|
|
2708
|
+
targets.forEach(function (t, i) { katex.render("\\\\displaystyle " + TEX[i], t, ${INLINE_OPTS}); });
|
|
2709
|
+
box.className += " eq-broken";
|
|
2710
|
+
box.style.fontSize = "${want}px";
|
|
2711
|
+
var wb = extent(true);
|
|
2712
|
+
if (settle(true, wb > bw ? (${want} * bw) / wb : ${want})) return "broken";
|
|
2713
|
+
box.setAttribute("${UNFIT_ATTR}", '${js(raw2)}');
|
|
2714
|
+
return "unfit";
|
|
2715
|
+
})()`
|
|
2716
|
+
];
|
|
2717
|
+
}
|
|
2718
|
+
function chipFit(sid, terms) {
|
|
2719
|
+
if (terms.length === 0) return [];
|
|
2720
|
+
return [
|
|
2721
|
+
`[${terms.map((t2) => `"${t2.tone}"`).join(", ")}].forEach(function (tone) {
|
|
2722
|
+
var chip = document.getElementById("${sid}-chip-" + tone);
|
|
2723
|
+
var r = chip && chip.getBoundingClientRect();
|
|
2724
|
+
if (!r || !(r.height > 0)) return;
|
|
2725
|
+
var k = chip.offsetHeight / r.height;
|
|
2726
|
+
var top = r.top, bottom = r.bottom;
|
|
2727
|
+
Array.prototype.forEach.call(chip.querySelectorAll("span"), function (el) {
|
|
2728
|
+
for (var n = el.firstChild; n; n = n.nextSibling) {
|
|
2729
|
+
if (n.nodeType === 3 && n.nodeValue.trim()) {
|
|
2730
|
+
var q = el.getBoundingClientRect();
|
|
2731
|
+
top = Math.min(top, q.top);
|
|
2732
|
+
bottom = Math.max(bottom, q.bottom);
|
|
2733
|
+
return;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
});
|
|
2737
|
+
var cs = getComputedStyle(chip);
|
|
2738
|
+
if (top < r.top) chip.style.paddingTop = parseFloat(cs.paddingTop) + Math.ceil((r.top - top) * k) + "px";
|
|
2739
|
+
if (bottom > r.bottom) chip.style.paddingBottom = parseFloat(cs.paddingBottom) + Math.ceil((bottom - r.bottom) * k) + "px";
|
|
2740
|
+
})`
|
|
2741
|
+
];
|
|
2742
|
+
}
|
|
2743
|
+
var SWELL = 1.16;
|
|
2744
|
+
var SWELL_EM = 0.06;
|
|
2745
|
+
function swellFit(sid, terms) {
|
|
2746
|
+
return [
|
|
2747
|
+
`var dsSwell = (function () {
|
|
2748
|
+
var out = {};
|
|
2749
|
+
var box = document.getElementById("${sid}-eq");
|
|
2750
|
+
var k = box && box.offsetWidth ? box.getBoundingClientRect().width / box.offsetWidth : 1;
|
|
2751
|
+
var em = box ? parseFloat(getComputedStyle(box).fontSize) : 0;
|
|
2752
|
+
[${terms.map((t2) => `"${t2.tone}"`).join(", ")}].forEach(function (tone) {
|
|
2753
|
+
var w = 0;
|
|
2754
|
+
Array.prototype.forEach.call(document.querySelectorAll("#${sid} .t-" + tone), function (el) {
|
|
2755
|
+
w = Math.max(w, el.getBoundingClientRect().width / k);
|
|
2756
|
+
});
|
|
2757
|
+
out[tone] = w > 0 && em > 0 ? Math.min(${SWELL}, Math.round((1 + (2 * ${SWELL_EM} * em) / w) * 1000) / 1000) : ${SWELL};
|
|
2758
|
+
});
|
|
2759
|
+
return out;
|
|
2760
|
+
})()`
|
|
2761
|
+
];
|
|
2762
|
+
}
|
|
2489
2763
|
function legendRows(sid, terms, theme) {
|
|
2490
2764
|
return terms.map(
|
|
2491
2765
|
(t2) => `<div class="leg" id="${sid}-leg-${t2.tone}"><span class="chip" id="${sid}-chip-${t2.tone}" style="color:${theme.tones[t2.tone]}"></span><span>${esc(t2.label)}</span></div>`
|
|
@@ -2501,7 +2775,10 @@ function legendCss(theme) {
|
|
|
2501
2775
|
`.leg{display:flex;gap:26px;align-items:baseline;max-width:1400px;font-size:48px;color:${theme.muted}}`,
|
|
2502
2776
|
// A common chip width, so the labels share a spine too — the glyphs inside
|
|
2503
2777
|
// are one symbol each and their natural widths differ by a few pixels.
|
|
2504
|
-
|
|
2778
|
+
// `flex:none`: beside a long label the chip used to SHRINK below its own
|
|
2779
|
+
// nowrap TeX, which then ran out of the painted box into the label — on a
|
|
2780
|
+
// HypePaper deck, "pixelcoord" 91px past the chip's right edge.
|
|
2781
|
+
`.chip{display:inline-block;flex:none;min-width:72px;text-align:center;background:${theme.panel};border-radius:10px;padding:2px 20px;white-space:nowrap;font-weight:700}`
|
|
2505
2782
|
].join("\n");
|
|
2506
2783
|
}
|
|
2507
2784
|
var equationWalk = (beat, ctx) => {
|
|
@@ -2513,37 +2790,53 @@ var equationWalk = (beat, ctx) => {
|
|
|
2513
2790
|
`equation-walk ${beat.id}: no equation "${p.equationId}" in source ${ctx.source.id}`
|
|
2514
2791
|
);
|
|
2515
2792
|
}
|
|
2516
|
-
const walk =
|
|
2793
|
+
const walk = prepareWalk(eq, p.terms, beat.id);
|
|
2517
2794
|
const terms = walk.used;
|
|
2518
2795
|
const legend = legendRows(sid, terms, theme);
|
|
2519
|
-
const
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2796
|
+
const chips = terms.map((t2) => chipSetup(sid, t2));
|
|
2797
|
+
let eqHtml;
|
|
2798
|
+
let setup;
|
|
2799
|
+
let measure4;
|
|
2800
|
+
if (walk.plain) {
|
|
2801
|
+
eqHtml = `<div class="eq eq-plain" id="${sid}-eq" style="font-size:${PLAIN_FONT}px">${plainHtml(eq.tex, terms)}</div>`;
|
|
2802
|
+
setup = chips;
|
|
2803
|
+
measure4 = [...chipFit(sid, terms), ...swellFit(sid, terms)];
|
|
2804
|
+
} else {
|
|
2805
|
+
const wrapped = walk.tex;
|
|
2806
|
+
const split = statements(wrapped, isPortrait(ctx.format));
|
|
2807
|
+
const stacked = split.length > 1 && split.every((s) => texError(s, true) === null);
|
|
2808
|
+
const raw2 = statements(walk.raw, stacked);
|
|
2809
|
+
const shown = stacked ? split : [wrapped];
|
|
2810
|
+
const longest = raw2.reduce((a, b) => b.length > a.length ? b : a);
|
|
2811
|
+
const want = equationSize(longest);
|
|
2812
|
+
const size3 = Math.max(
|
|
2813
|
+
MIN_FONT,
|
|
2814
|
+
Math.min(want, Math.floor(contentW(ctx.format) / Math.max(...raw2.map(texUnits))))
|
|
2815
|
+
);
|
|
2816
|
+
const body = shown.length === 1 ? "" : shown.map((_, i) => `<div id="${sid}-eq${i}"></div>`).join("\n ");
|
|
2817
|
+
eqHtml = `<div class="eq${shown.length === 1 ? "" : " eqstack"}" id="${sid}-eq" style="font-size:${size3}px">${body}</div>`;
|
|
2818
|
+
const targets = shown.map((_, i) => `${sid}-eq${shown.length === 1 ? "" : i}`);
|
|
2819
|
+
setup = [
|
|
2820
|
+
`var OPTS = ${OPTS};`,
|
|
2821
|
+
...shown.map(
|
|
2822
|
+
(part, i) => `katex.render('${js(part)}', document.getElementById("${targets[i]}"), OPTS);`
|
|
2823
|
+
),
|
|
2824
|
+
...chips
|
|
2825
|
+
];
|
|
2826
|
+
measure4 = [
|
|
2827
|
+
...chipFit(sid, terms),
|
|
2828
|
+
...mathFit(sid, targets, shown, walk.raw, want, MIN_FONT),
|
|
2829
|
+
// Last: it measures each term at the size the fit settled on.
|
|
2830
|
+
...swellFit(sid, terms)
|
|
2831
|
+
];
|
|
2832
|
+
}
|
|
2531
2833
|
const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
|
|
2532
2834
|
<div class="eqslide">
|
|
2533
|
-
|
|
2835
|
+
${eqHtml}
|
|
2534
2836
|
<div class="legend">
|
|
2535
2837
|
${legend}
|
|
2536
2838
|
</div>
|
|
2537
2839
|
</div>`;
|
|
2538
|
-
const setup = [
|
|
2539
|
-
`var OPTS = ${OPTS};`,
|
|
2540
|
-
...shown.map(
|
|
2541
|
-
(part, i) => `katex.render('${js(part)}', document.getElementById("${sid}-eq${shown.length === 1 ? "" : i}"), OPTS);`
|
|
2542
|
-
),
|
|
2543
|
-
...terms.map(
|
|
2544
|
-
(t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
|
|
2545
|
-
)
|
|
2546
|
-
];
|
|
2547
2840
|
const tl = [
|
|
2548
2841
|
...chromeIn(sid, p.eyebrow !== void 0),
|
|
2549
2842
|
tween(`#${sid}-eq`, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.7 }, 0.8)
|
|
@@ -2555,6 +2848,7 @@ var equationWalk = (beat, ctx) => {
|
|
|
2555
2848
|
);
|
|
2556
2849
|
const holds = [];
|
|
2557
2850
|
const spot = spotlighter(sid, ".term");
|
|
2851
|
+
const swell = (t2) => raw(`dsSwell.${t2.tone}`);
|
|
2558
2852
|
terms.forEach((term, i) => {
|
|
2559
2853
|
const at = first + i * step2;
|
|
2560
2854
|
const colour = theme.tones[term.tone];
|
|
@@ -2571,13 +2865,15 @@ var equationWalk = (beat, ctx) => {
|
|
|
2571
2865
|
tween(
|
|
2572
2866
|
`#${sid} .t-${term.tone}`,
|
|
2573
2867
|
{ color: theme.fg, scale: 1 },
|
|
2574
|
-
{ color: colour, scale:
|
|
2868
|
+
{ color: colour, scale: swell(term), duration: 0.5 },
|
|
2575
2869
|
at
|
|
2576
2870
|
)
|
|
2577
2871
|
);
|
|
2578
2872
|
const prev = terms[i - 1];
|
|
2579
2873
|
if (prev) {
|
|
2580
|
-
tl.push(
|
|
2874
|
+
tl.push(
|
|
2875
|
+
tween(`#${sid} .t-${prev.tone}`, { scale: swell(prev) }, { scale: 1, duration: 0.4 }, at)
|
|
2876
|
+
);
|
|
2581
2877
|
}
|
|
2582
2878
|
holds.push(at + 0.6);
|
|
2583
2879
|
});
|
|
@@ -2585,7 +2881,7 @@ var equationWalk = (beat, ctx) => {
|
|
|
2585
2881
|
if (last) {
|
|
2586
2882
|
const at = first + terms.length * step2;
|
|
2587
2883
|
tl.push(
|
|
2588
|
-
tween(`#${sid} .t-${last.tone}`, { scale:
|
|
2884
|
+
tween(`#${sid} .t-${last.tone}`, { scale: swell(last) }, { scale: 1, duration: 0.4 }, at),
|
|
2589
2885
|
...spot.restore(at)
|
|
2590
2886
|
);
|
|
2591
2887
|
}
|
|
@@ -2593,6 +2889,8 @@ var equationWalk = (beat, ctx) => {
|
|
|
2593
2889
|
html,
|
|
2594
2890
|
tl,
|
|
2595
2891
|
setup,
|
|
2892
|
+
measure: measure4,
|
|
2893
|
+
...walk.notes.length ? { warnings: walk.notes } : {},
|
|
2596
2894
|
holds: holdsWithin(holds, beat.seconds),
|
|
2597
2895
|
css: [
|
|
2598
2896
|
chromeCss(theme),
|
|
@@ -2612,6 +2910,15 @@ var equationWalk = (beat, ctx) => {
|
|
|
2612
2910
|
// has its margin zeroed above, so without an explicit gap the two lines
|
|
2613
2911
|
// would touch and read as one equation broken mid-expression.
|
|
2614
2912
|
".eqstack{display:flex;flex-direction:column;gap:32px}",
|
|
2913
|
+
// Set by `mathFit` when one line will not fit at the floor: the display is
|
|
2914
|
+
// re-rendered inline, where KaTeX breaks after a top-level relation or
|
|
2915
|
+
// operator, and the lines it breaks into are centred and spaced as lines.
|
|
2916
|
+
".eq-broken{line-height:1.5}",
|
|
2917
|
+
// An equation KaTeX refuses, shown as its source. Wraps anywhere, because
|
|
2918
|
+
// TeX source has few spaces and must not run off the slide either. The
|
|
2919
|
+
// deck's own face, not a monospace one: a family the bundle does not
|
|
2920
|
+
// declare falls back silently (invariant 9).
|
|
2921
|
+
".eq-plain{white-space:pre-wrap;overflow-wrap:anywhere}",
|
|
2615
2922
|
// Transforms do not apply to inline boxes, and KaTeX spans are inline.
|
|
2616
2923
|
".term{display:inline-block}",
|
|
2617
2924
|
legendCss(theme),
|
|
@@ -2626,26 +2933,67 @@ var equationWalk = (beat, ctx) => {
|
|
|
2626
2933
|
|
|
2627
2934
|
// src/emit/archetypes/equation-morph.ts
|
|
2628
2935
|
var MORPH_SECONDS = 1.6;
|
|
2629
|
-
|
|
2630
|
-
const { sid, theme } = ctx;
|
|
2936
|
+
function prepareMorph(beat, source) {
|
|
2631
2937
|
const p = beat.params;
|
|
2632
2938
|
const find3 = (id2) => {
|
|
2633
|
-
const eq =
|
|
2939
|
+
const eq = source.equations.find((e) => e.id === id2);
|
|
2634
2940
|
if (!eq)
|
|
2635
|
-
throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${
|
|
2941
|
+
throw new Error(`equation-morph ${beat.id}: no equation "${id2}" in source ${source.id}`);
|
|
2636
2942
|
return eq;
|
|
2637
2943
|
};
|
|
2638
|
-
const
|
|
2639
|
-
const
|
|
2640
|
-
|
|
2641
|
-
|
|
2944
|
+
const notes = [];
|
|
2945
|
+
const drawable = (eq) => {
|
|
2946
|
+
const fixed = repairTex(eq.tex, true);
|
|
2947
|
+
if (fixed.error !== null) {
|
|
2948
|
+
throw new Error(
|
|
2949
|
+
`equation-morph ${beat.id}: ${eq.id} does not parse even after repair (${fixed.error}), and a morph cannot be drawn from plain text. Formula: ${JSON.stringify(eq.tex)}`
|
|
2950
|
+
);
|
|
2951
|
+
}
|
|
2952
|
+
notes.push(...fixed.repairs.map((r) => `${eq.id} repaired before drawing: ${r}`));
|
|
2953
|
+
return { id: eq.id, tex: fixed.tex };
|
|
2954
|
+
};
|
|
2955
|
+
const a = drawable(find3(p.fromId));
|
|
2956
|
+
const b = drawable(find3(p.toId));
|
|
2957
|
+
const repaired = p.terms.map((t2) => ({ ...t2, tex: repairTex(t2.tex, false).tex }));
|
|
2958
|
+
const original = (t2) => p.terms[repaired.indexOf(t2)];
|
|
2959
|
+
let keyed = repaired.filter((t2) => locate(a.tex, t2.tex) && locate(b.tex, t2.tex));
|
|
2960
|
+
if (keyed.length === 0) {
|
|
2642
2961
|
throw new Error(
|
|
2643
2962
|
`equation-morph ${beat.id}: none of its ${p.terms.length} term(s) occur in both equations. Terms: ${p.terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}. From: ${JSON.stringify(a.tex)}. To: ${JSON.stringify(b.tex)}`
|
|
2644
2963
|
);
|
|
2645
2964
|
}
|
|
2965
|
+
notes.push(
|
|
2966
|
+
...repaired.filter((t2) => !keyed.includes(t2)).map(
|
|
2967
|
+
(t2) => `term ${JSON.stringify(original(t2).tex)} does not occur in both ${a.id} and ${b.id}, so it does not travel and its legend row ("${t2.label}") is left out`
|
|
2968
|
+
)
|
|
2969
|
+
);
|
|
2646
2970
|
const cls = (t2) => `term t-${t2.tone} ds-k-${t2.tone}`;
|
|
2647
|
-
|
|
2648
|
-
|
|
2971
|
+
let wa = wrapTerms(a.tex, keyed, beat.id, cls);
|
|
2972
|
+
let wb = wrapTerms(b.tex, keyed, beat.id, cls);
|
|
2973
|
+
const paired = keyed.filter((t2) => wa.used.includes(t2) && wb.used.includes(t2));
|
|
2974
|
+
if (paired.length < keyed.length) {
|
|
2975
|
+
const unpaired = keyed.filter((t2) => !paired.includes(t2));
|
|
2976
|
+
notes.push(
|
|
2977
|
+
...unpaired.map(
|
|
2978
|
+
(t2) => `term ${JSON.stringify(original(t2).tex)} cannot be marked on both lines without breaking one, so it does not travel and its legend row ("${t2.label}") is left out`
|
|
2979
|
+
)
|
|
2980
|
+
);
|
|
2981
|
+
if (paired.length === 0) {
|
|
2982
|
+
throw new Error(
|
|
2983
|
+
`equation-morph ${beat.id}: no term can be marked on both lines without breaking one. Terms: ${p.terms.map((t2) => JSON.stringify(t2.tex)).join(", ")}`
|
|
2984
|
+
);
|
|
2985
|
+
}
|
|
2986
|
+
keyed = paired;
|
|
2987
|
+
wa = wrapTerms(a.tex, keyed, beat.id, cls);
|
|
2988
|
+
wb = wrapTerms(b.tex, keyed, beat.id, cls);
|
|
2989
|
+
}
|
|
2990
|
+
const both = keyed.map(original);
|
|
2991
|
+
return { a, b, wa: wa.tex, wb: wb.tex, both, notes };
|
|
2992
|
+
}
|
|
2993
|
+
var equationMorph = (beat, ctx) => {
|
|
2994
|
+
const { sid, theme } = ctx;
|
|
2995
|
+
const p = beat.params;
|
|
2996
|
+
const { a, b, wa, wb, both, notes } = prepareMorph(beat, ctx.source);
|
|
2649
2997
|
const size3 = Math.max(
|
|
2650
2998
|
MIN_FONT,
|
|
2651
2999
|
Math.min(
|
|
@@ -2667,9 +3015,7 @@ var equationMorph = (beat, ctx) => {
|
|
|
2667
3015
|
`var OPTS = ${OPTS};`,
|
|
2668
3016
|
`katex.render('${js(wa)}', document.getElementById("${sid}-eqa"), OPTS);`,
|
|
2669
3017
|
`katex.render('${js(wb)}', document.getElementById("${sid}-eqb"), OPTS);`,
|
|
2670
|
-
...both.map(
|
|
2671
|
-
(t2) => `katex.render('${js(t2.tex)}', document.getElementById("${sid}-chip-${t2.tone}"), ${INLINE_OPTS});`
|
|
2672
|
-
)
|
|
3018
|
+
...both.map((t2) => chipSetup(sid, t2))
|
|
2673
3019
|
];
|
|
2674
3020
|
const first = 1.8;
|
|
2675
3021
|
const at = Math.round(
|
|
@@ -2702,8 +3048,11 @@ var equationMorph = (beat, ctx) => {
|
|
|
2702
3048
|
setup,
|
|
2703
3049
|
// SEAM B: the plan is browser geometry after fonts, so it is built inside
|
|
2704
3050
|
// the ready gate, and the plugin tween above finds it on the host.
|
|
2705
|
-
|
|
3051
|
+
// The chips are fitted before the plan is built: growing one moves the
|
|
3052
|
+
// legend, and nothing the morph measures may move after it has measured.
|
|
3053
|
+
measure: [...chipFit(sid, both), `DSMorph.build(document.getElementById("${sid}-morph"));`],
|
|
2706
3054
|
plugins: ["dsMorph"],
|
|
3055
|
+
...notes.length ? { warnings: notes } : {},
|
|
2707
3056
|
holds: holdsWithin([first, at + MORPH_SECONDS + 0.4], beat.seconds),
|
|
2708
3057
|
css: [
|
|
2709
3058
|
chromeCss(theme),
|
|
@@ -3284,16 +3633,16 @@ var lineChart = (beat, ctx) => {
|
|
|
3284
3633
|
</svg>${readout}
|
|
3285
3634
|
</div>`;
|
|
3286
3635
|
const draw2 = 0.8;
|
|
3287
|
-
const
|
|
3288
|
-
const idealStep = Math.min(0.45, DRAW_SECONDS /
|
|
3636
|
+
const count2 = p.points.length;
|
|
3637
|
+
const idealStep = Math.min(0.45, DRAW_SECONDS / count2);
|
|
3289
3638
|
const spine = draw2 + SEPARATE + RESHAPE_SECONDS + (p.readout ? 0.8 : 0) + 0.15;
|
|
3290
|
-
const shownDeltas = deltas ? Math.min((p.deltas ?? []).length,
|
|
3639
|
+
const shownDeltas = deltas ? Math.min((p.deltas ?? []).length, count2 - 1) : 0;
|
|
3291
3640
|
const tailAfter = (s) => Math.max(
|
|
3292
|
-
s *
|
|
3641
|
+
s * count2 + 0.4,
|
|
3293
3642
|
// the ring: its walk, then the 0.4s it takes to leave
|
|
3294
|
-
0.3 + s * (
|
|
3643
|
+
0.3 + s * (count2 - 1),
|
|
3295
3644
|
// the dots
|
|
3296
|
-
0.5 + s * (
|
|
3645
|
+
0.5 + s * (count2 - 1),
|
|
3297
3646
|
...shownDeltas > 0 ? [0.95 + s * (shownDeltas - 1)] : []
|
|
3298
3647
|
);
|
|
3299
3648
|
const room = beat.seconds - spine;
|
|
@@ -3356,8 +3705,8 @@ var lineChart = (beat, ctx) => {
|
|
|
3356
3705
|
)
|
|
3357
3706
|
);
|
|
3358
3707
|
}
|
|
3359
|
-
const walk = cmp ? sec(step2 *
|
|
3360
|
-
if (
|
|
3708
|
+
const walk = cmp ? sec(step2 * count2) : DRAW_SECONDS;
|
|
3709
|
+
if (count2 > 1) {
|
|
3361
3710
|
const route = p.points.map((pt, i) => ({ x: nv(x(i) - first.x), y: nv(y(pt.y) - first.y) }));
|
|
3362
3711
|
tl.push(
|
|
3363
3712
|
tween(`#${sid}-ring`, { opacity: 0 }, { opacity: 1, duration: 0.25 }, settled),
|
|
@@ -3372,7 +3721,7 @@ var lineChart = (beat, ctx) => {
|
|
|
3372
3721
|
)
|
|
3373
3722
|
);
|
|
3374
3723
|
}
|
|
3375
|
-
const drawn = settled + (cmp ? tailAfter(step2) : step2 *
|
|
3724
|
+
const drawn = settled + (cmp ? tailAfter(step2) : step2 * count2 + 0.4);
|
|
3376
3725
|
const holds = cmp ? [lift2, drawn] : [drawn];
|
|
3377
3726
|
if (p.readout) {
|
|
3378
3727
|
tl.push(
|
|
@@ -3388,7 +3737,7 @@ var lineChart = (beat, ctx) => {
|
|
|
3388
3737
|
return {
|
|
3389
3738
|
...lineChart({ ...beat, params: { ...p, compare: void 0 } }, ctx),
|
|
3390
3739
|
warnings: [
|
|
3391
|
-
`line-chart ${beat.id}: a comparison against "${cmp.label}" over ${
|
|
3740
|
+
`line-chart ${beat.id}: a comparison against "${cmp.label}" over ${count2} points needs ${need}s and the beat is ${beat.seconds}s, so the chart was drawn without it. Lengthen the beat to keep the comparison.`
|
|
3392
3741
|
]
|
|
3393
3742
|
};
|
|
3394
3743
|
}
|
|
@@ -3468,7 +3817,7 @@ var V_BOX_H = 268;
|
|
|
3468
3817
|
var V_LOOP_SIDE = 56;
|
|
3469
3818
|
var V_LOOP_PAD = 18;
|
|
3470
3819
|
function columnLayout(stageW, stageH, stages, face, loop) {
|
|
3471
|
-
const
|
|
3820
|
+
const count2 = stages.length;
|
|
3472
3821
|
const loopW = loop ? Math.max(240, Math.round(stageW * 0.32)) : 0;
|
|
3473
3822
|
const boxX = M;
|
|
3474
3823
|
const boxW = stageW - 2 * M - (loop ? V_LOOP_SIDE + V_LOOP_PAD + loopW : 0);
|
|
@@ -3485,11 +3834,11 @@ function columnLayout(stageW, stageH, stages, face, loop) {
|
|
|
3485
3834
|
return Math.ceil(2 * PAD_Y2 + label + (note2 > 0 ? NOTE_TOP + note2 * NOTE * NOTE_LH : 0));
|
|
3486
3835
|
})
|
|
3487
3836
|
);
|
|
3488
|
-
const room = (stageH - 2 * M - (
|
|
3837
|
+
const room = (stageH - 2 * M - (count2 - 1) * V_GAP) / count2;
|
|
3489
3838
|
const boxH = Math.max(need, Math.min(room, V_BOX_H));
|
|
3490
3839
|
return {
|
|
3491
3840
|
size: size3,
|
|
3492
|
-
boxes: Array.from({ length:
|
|
3841
|
+
boxes: Array.from({ length: count2 }, (_, i) => ({ x: M + i * (boxH + V_GAP), w: boxH })),
|
|
3493
3842
|
boxH,
|
|
3494
3843
|
boxX,
|
|
3495
3844
|
boxW,
|
|
@@ -3501,7 +3850,7 @@ function columnLayout(stageW, stageH, stages, face, loop) {
|
|
|
3501
3850
|
loopLines: loop ? wrap(loop.label, NOTE, loopW, 500, 0, face) : [],
|
|
3502
3851
|
loopX: boxX + boxW + V_LOOP_SIDE,
|
|
3503
3852
|
loopW,
|
|
3504
|
-
svgH: 2 * M +
|
|
3853
|
+
svgH: 2 * M + count2 * boxH + (count2 - 1) * V_GAP
|
|
3505
3854
|
};
|
|
3506
3855
|
}
|
|
3507
3856
|
function balance(label, k, face) {
|
|
@@ -4219,8 +4568,8 @@ function stackLayout(p, format, face = "latin") {
|
|
|
4219
4568
|
if (inline2.fits) return inline2;
|
|
4220
4569
|
return inline2.wide && inline2.blockH < stacked.blockH ? inline2 : stacked;
|
|
4221
4570
|
}
|
|
4222
|
-
function labelWeight(i,
|
|
4223
|
-
return i ===
|
|
4571
|
+
function labelWeight(i, count2) {
|
|
4572
|
+
return i === count2 - 1 ? 700 : 600;
|
|
4224
4573
|
}
|
|
4225
4574
|
function floorFor(p, format) {
|
|
4226
4575
|
if (!p.tilt) return MIN_FONT;
|
|
@@ -4230,7 +4579,7 @@ function solve2(p, format, inline2, face) {
|
|
|
4230
4579
|
const width = contentW(format);
|
|
4231
4580
|
const boxH = contentH(format);
|
|
4232
4581
|
const floor = floorFor(p, format);
|
|
4233
|
-
const
|
|
4582
|
+
const count2 = p.layers.length;
|
|
4234
4583
|
const k = isPortrait(format) ? "tall" : "wide";
|
|
4235
4584
|
const riseMax = RISE_MAX[k];
|
|
4236
4585
|
const syMax = SY_MAX[k];
|
|
@@ -4238,7 +4587,7 @@ function solve2(p, format, inline2, face) {
|
|
|
4238
4587
|
const noteW = (l) => inline2 && l.note !== void 0 ? textWidth(l.note, floor, 400, 0, false, face) + 28 : 0;
|
|
4239
4588
|
const want = Math.max(
|
|
4240
4589
|
...p.layers.map(
|
|
4241
|
-
(l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i,
|
|
4590
|
+
(l, i) => textWidth(l.label, LABEL_SIZE2, labelWeight(i, count2), 0, false, face) + noteW(l)
|
|
4242
4591
|
)
|
|
4243
4592
|
);
|
|
4244
4593
|
const colCap = inline2 ? width * 0.56 : width * 0.5;
|
|
@@ -4247,7 +4596,7 @@ function solve2(p, format, inline2, face) {
|
|
|
4247
4596
|
const labelX = width - colW;
|
|
4248
4597
|
const labelRoom = Math.min(
|
|
4249
4598
|
...p.layers.map(
|
|
4250
|
-
(l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i,
|
|
4599
|
+
(l, i) => (colW - noteW(l)) / Math.max(1, textWidth(l.label, 1, labelWeight(i, count2), 0, false, face))
|
|
4251
4600
|
)
|
|
4252
4601
|
);
|
|
4253
4602
|
const labelSize = Math.max(floor, Math.min(LABEL_SIZE2, labelRoom));
|
|
@@ -4255,7 +4604,7 @@ function solve2(p, format, inline2, face) {
|
|
|
4255
4604
|
const nw = noteW(l);
|
|
4256
4605
|
const labelMaxW = Math.max(labelSize, colW - nw);
|
|
4257
4606
|
return {
|
|
4258
|
-
label: wrap(l.label, labelSize, labelMaxW, labelWeight(i,
|
|
4607
|
+
label: wrap(l.label, labelSize, labelMaxW, labelWeight(i, count2), 0, face),
|
|
4259
4608
|
// Inline notes stay on one line by contract — the schema calls a note "one
|
|
4260
4609
|
// short line" — and wrapping one would put its second line under the label.
|
|
4261
4610
|
note: l.note === void 0 ? [] : inline2 ? [l.note] : wrap(l.note, floor, colW, 400, 0, face),
|
|
@@ -4273,13 +4622,13 @@ function solve2(p, format, inline2, face) {
|
|
|
4273
4622
|
const noteH = noteHeight(p.note, noteWidth(format), 28, face);
|
|
4274
4623
|
const free = boxH - chromeH - 20 - noteH;
|
|
4275
4624
|
const avail = Math.max(300, free);
|
|
4276
|
-
const room = (avail - 2 * pad - syMax - tMax) / Math.max(1,
|
|
4625
|
+
const room = (avail - 2 * pad - syMax - tMax) / Math.max(1, count2 - 1);
|
|
4277
4626
|
const rise = clamp(room, RISE_MIN, riseMax);
|
|
4278
4627
|
const sy = clamp(rise * 0.62, 46, syMax);
|
|
4279
4628
|
const t2 = clamp(rise * 0.14, 10, tMax);
|
|
4280
4629
|
const span = width - NUM_W - GAP3 - colW;
|
|
4281
4630
|
const sx = clamp(span * 0.2, 110, 220);
|
|
4282
|
-
const height = 2 * pad + (
|
|
4631
|
+
const height = 2 * pad + (count2 - 1) * rise + sy + t2;
|
|
4283
4632
|
return {
|
|
4284
4633
|
// A rise shorter than a label block prints the note through the label below.
|
|
4285
4634
|
// BOTH directions. A layout that fits the height and not the width is not a
|
|
@@ -4304,7 +4653,7 @@ function solve2(p, format, inline2, face) {
|
|
|
4304
4653
|
labelSize,
|
|
4305
4654
|
blockH,
|
|
4306
4655
|
pad,
|
|
4307
|
-
yBase: pad + (
|
|
4656
|
+
yBase: pad + (count2 - 1) * rise + sy,
|
|
4308
4657
|
lines
|
|
4309
4658
|
};
|
|
4310
4659
|
}
|
|
@@ -4321,11 +4670,11 @@ var stack = (beat, ctx) => {
|
|
|
4321
4670
|
const p = beat.params;
|
|
4322
4671
|
const face = faceOf(ctx.theme.fontStack);
|
|
4323
4672
|
const L = stackLayout(p, ctx.format, face);
|
|
4324
|
-
const
|
|
4325
|
-
const last =
|
|
4673
|
+
const count2 = p.layers.length;
|
|
4674
|
+
const last = count2 - 1;
|
|
4326
4675
|
if (!L.fits) {
|
|
4327
4676
|
throw new Error(
|
|
4328
|
-
`stack ${beat.id}: ${
|
|
4677
|
+
`stack ${beat.id}: ${count2} layers with ${p.layers.filter((l) => l.note).length} note(s) need ${Math.round(L.blockH)}px of label block against ${Math.round(L.avail)}px of room, at the ${Math.round(L.floor)}px floor and with the notes already moved beside their labels. Nothing here may be set smaller, so the lever is upstream of this beat: drop a layer, or shorten the notes.`
|
|
4329
4678
|
);
|
|
4330
4679
|
}
|
|
4331
4680
|
const parts = {};
|
|
@@ -4353,7 +4702,7 @@ var stack = (beat, ctx) => {
|
|
|
4353
4702
|
{ x: L.labelX, y: L.inline ? mid : mid - noteH / 2 },
|
|
4354
4703
|
{
|
|
4355
4704
|
size: L.labelSize,
|
|
4356
|
-
weight: labelWeight(i,
|
|
4705
|
+
weight: labelWeight(i, count2),
|
|
4357
4706
|
fill: top ? theme.tones.b : theme.fg,
|
|
4358
4707
|
maxWidth: block.labelMaxW,
|
|
4359
4708
|
lineHeight: 1.16,
|
|
@@ -4407,10 +4756,10 @@ var stack = (beat, ctx) => {
|
|
|
4407
4756
|
<div class="stnote" id="${sid}-note">${esc(p.note)}</div>` : "";
|
|
4408
4757
|
const html = `${chrome(sid, p.eyebrow, p.headline, contentW(ctx.format))}
|
|
4409
4758
|
<div class="stackwrap">${svg(id(sid, "stack"), L.width, L.height, body + probe2)}</div>${noteHtml}`;
|
|
4410
|
-
const centre2 = (
|
|
4759
|
+
const centre2 = (count2 - 1) / 2;
|
|
4411
4760
|
const enterFrom = (i) => p.tilt ? (i - centre2) * L.rise : 34;
|
|
4412
4761
|
const first = 0.9;
|
|
4413
|
-
const step2 = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) /
|
|
4762
|
+
const step2 = Math.min(0.8, Math.max(0.4, (beat.seconds - first - 1.5) / count2));
|
|
4414
4763
|
const tl = [...chromeIn(sid, p.eyebrow !== void 0)];
|
|
4415
4764
|
const holds = [];
|
|
4416
4765
|
const spot = spotlighter(sid);
|
|
@@ -4442,11 +4791,11 @@ var stack = (beat, ctx) => {
|
|
|
4442
4791
|
);
|
|
4443
4792
|
}
|
|
4444
4793
|
if (p.note) {
|
|
4445
|
-
const at = first +
|
|
4794
|
+
const at = first + count2 * step2;
|
|
4446
4795
|
tl.push(tween(`#${sid}-note`, { opacity: 0 }, { opacity: 1, duration: 0.6 }, at));
|
|
4447
4796
|
holds.push(at + 0.7);
|
|
4448
4797
|
}
|
|
4449
|
-
if (
|
|
4798
|
+
if (count2 > 1) tl.push(...spot.restore(first + count2 * step2));
|
|
4450
4799
|
return {
|
|
4451
4800
|
html,
|
|
4452
4801
|
parts,
|
|
@@ -5769,8 +6118,8 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
5769
6118
|
const text2 = beat.narration?.trim();
|
|
5770
6119
|
if (!text2) continue;
|
|
5771
6120
|
const segments2 = [];
|
|
5772
|
-
const
|
|
5773
|
-
const plan = planSegments(text2, Math.min(
|
|
6121
|
+
const count2 = stopsFor(beat, source, format, `s${i + 1}`, theme);
|
|
6122
|
+
const plan = planSegments(text2, Math.min(count2, paced.speakingStops));
|
|
5774
6123
|
for (const [stop, line2] of plan.entries()) {
|
|
5775
6124
|
if (!line2) continue;
|
|
5776
6125
|
const speech = await synthesize(line2, {
|
|
@@ -5792,7 +6141,7 @@ async function narrate(storyboard, source, prefs, opts) {
|
|
|
5792
6141
|
}
|
|
5793
6142
|
if (segments2.length > 0) {
|
|
5794
6143
|
beats[beat.id] = segments2;
|
|
5795
|
-
staged[beat.id] =
|
|
6144
|
+
staged[beat.id] = count2;
|
|
5796
6145
|
}
|
|
5797
6146
|
}
|
|
5798
6147
|
return {
|
|
@@ -6687,11 +7036,11 @@ function stageScene(scene, speed) {
|
|
|
6687
7036
|
function speechPlan(open, holds, segments2) {
|
|
6688
7037
|
const starts = [];
|
|
6689
7038
|
let at = open;
|
|
6690
|
-
for (const [i,
|
|
6691
|
-
const hold = holds[Math.min(
|
|
7039
|
+
for (const [i, segment2] of segments2.entries()) {
|
|
7040
|
+
const hold = holds[Math.min(segment2.stop, holds.length - 1)] ?? 0;
|
|
6692
7041
|
if (i > 0) at = Math.max(at, hold);
|
|
6693
7042
|
starts.push(at);
|
|
6694
|
-
at +=
|
|
7043
|
+
at += segment2.seconds;
|
|
6695
7044
|
}
|
|
6696
7045
|
return { starts, end: at };
|
|
6697
7046
|
}
|
|
@@ -8186,8 +8535,13 @@ function renderSource(source) {
|
|
|
8186
8535
|
}
|
|
8187
8536
|
}
|
|
8188
8537
|
out.push("", "== EQUATIONS ==");
|
|
8189
|
-
for (const e of source.equations)
|
|
8190
|
-
|
|
8538
|
+
for (const e of source.equations) {
|
|
8539
|
+
const fixed = repairTex(e.tex, true);
|
|
8540
|
+
const note2 = fixed.error === null ? "" : " (KaTeX cannot draw this one: a walk shows it as plain source, a morph cannot use it)";
|
|
8541
|
+
out.push(
|
|
8542
|
+
`[equation ${e.id}] ${e.display ? "display" : "inline"} \u2014 ${fixed.error === null ? fixed.tex : e.tex}${note2}`
|
|
8543
|
+
);
|
|
8544
|
+
}
|
|
8191
8545
|
if (!source.equations.length)
|
|
8192
8546
|
out.push("(none \u2014 no equation-walk or equation-morph beat is possible)");
|
|
8193
8547
|
out.push("", "== TABLES ==");
|
|
@@ -8751,10 +9105,10 @@ function drawSvg(req) {
|
|
|
8751
9105
|
const seed = createHash5("sha256").update([req.prompt, req.style, req.aspect].join("|")).digest();
|
|
8752
9106
|
const rand = mulberry32(seed.readUInt32BE(0));
|
|
8753
9107
|
const accent = ACCENTS[Math.floor(rand() * ACCENTS.length)];
|
|
8754
|
-
const
|
|
9108
|
+
const count2 = 6 + Math.floor(rand() * 5);
|
|
8755
9109
|
const unit = Math.min(W, H);
|
|
8756
9110
|
const parts = [`<rect width="${W}" height="${H}" fill="${PAPER}"/>`];
|
|
8757
|
-
for (let i = 0; i <
|
|
9111
|
+
for (let i = 0; i < count2; i++) {
|
|
8758
9112
|
const kind = Math.floor(rand() * 3);
|
|
8759
9113
|
const cx = Math.round(W * (0.15 + 0.7 * rand()));
|
|
8760
9114
|
const cy = Math.round(H * (0.15 + 0.7 * rand()));
|
|
@@ -9179,6 +9533,22 @@ async function resolveChrome(need = "open the deck with") {
|
|
|
9179
9533
|
async function chromePath(need = "open the deck with") {
|
|
9180
9534
|
return (await resolveChrome(need)).path;
|
|
9181
9535
|
}
|
|
9536
|
+
function sandboxMissing(err) {
|
|
9537
|
+
return /No usable sandbox/.test(err instanceof Error ? err.message : String(err));
|
|
9538
|
+
}
|
|
9539
|
+
var SANDBOX_FIX = "`sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`, or give Chrome an AppArmor profile";
|
|
9540
|
+
async function launchOwnPage(launch, args, what) {
|
|
9541
|
+
try {
|
|
9542
|
+
return await launch(args);
|
|
9543
|
+
} catch (err) {
|
|
9544
|
+
if (!sandboxMissing(err)) throw err;
|
|
9545
|
+
process.stderr.write(
|
|
9546
|
+
`chrome: this host gives Chrome no sandbox, so ${what} runs without one, as hyperframes' render always does. To keep the sandbox: ${SANDBOX_FIX}.
|
|
9547
|
+
`
|
|
9548
|
+
);
|
|
9549
|
+
return launch([...args, "--no-sandbox"]);
|
|
9550
|
+
}
|
|
9551
|
+
}
|
|
9182
9552
|
function runtimePath() {
|
|
9183
9553
|
return createRequire3(import.meta.url).resolve("hyperframes/dist/hyperframe.runtime.iife.js");
|
|
9184
9554
|
}
|
|
@@ -9196,10 +9566,9 @@ async function openDeck(dir, opts = {}) {
|
|
|
9196
9566
|
const timeout = opts.timeoutMs ?? 6e4;
|
|
9197
9567
|
const { default: puppeteer } = await import("puppeteer-core");
|
|
9198
9568
|
const chrome2 = await resolveChrome();
|
|
9199
|
-
const browser = await
|
|
9200
|
-
executablePath: chrome2.path,
|
|
9201
|
-
|
|
9202
|
-
args: [
|
|
9569
|
+
const browser = await launchOwnPage(
|
|
9570
|
+
(args) => puppeteer.launch({ executablePath: chrome2.path, headless: true, args }),
|
|
9571
|
+
[
|
|
9203
9572
|
// A retina host would otherwise hand back a 2x frame, whose clip is not the
|
|
9204
9573
|
// renderer's.
|
|
9205
9574
|
"--force-device-scale-factor=1",
|
|
@@ -9212,8 +9581,9 @@ async function openDeck(dir, opts = {}) {
|
|
|
9212
9581
|
"--use-gl=angle",
|
|
9213
9582
|
"--use-angle=swiftshader",
|
|
9214
9583
|
"--enable-unsafe-swiftshader"
|
|
9215
|
-
]
|
|
9216
|
-
|
|
9584
|
+
],
|
|
9585
|
+
"the gates' page"
|
|
9586
|
+
);
|
|
9217
9587
|
try {
|
|
9218
9588
|
const page = await browser.newPage();
|
|
9219
9589
|
await page.evaluateOnNewDocument("self.__name = self.__name || ((fn) => fn);");
|
|
@@ -9316,10 +9686,10 @@ function pack(words2, width) {
|
|
|
9316
9686
|
function splitCue(cue, max = CUE_MAX_CHARS) {
|
|
9317
9687
|
if (cue.text.length <= max) return [cue];
|
|
9318
9688
|
const words2 = cue.text.split(/\s+/).filter(Boolean);
|
|
9319
|
-
const
|
|
9689
|
+
const count2 = pack(words2, max).length;
|
|
9320
9690
|
let width = max;
|
|
9321
|
-
for (let w = Math.ceil(cue.text.length /
|
|
9322
|
-
if (pack(words2, w).length <=
|
|
9691
|
+
for (let w = Math.ceil(cue.text.length / count2); w < max; w++) {
|
|
9692
|
+
if (pack(words2, w).length <= count2) {
|
|
9323
9693
|
width = w;
|
|
9324
9694
|
break;
|
|
9325
9695
|
}
|
|
@@ -9411,17 +9781,17 @@ function place(scenes, spoken) {
|
|
|
9411
9781
|
);
|
|
9412
9782
|
}
|
|
9413
9783
|
const { starts } = speechPlan(scene.open, scene.holds, segments2);
|
|
9414
|
-
for (const [i,
|
|
9415
|
-
const index = Math.min(
|
|
9784
|
+
for (const [i, segment2] of segments2.entries()) {
|
|
9785
|
+
const index = Math.min(segment2.stop, scene.holds.length - 1);
|
|
9416
9786
|
out.push({
|
|
9417
|
-
id: `${scene.id}.${
|
|
9787
|
+
id: `${scene.id}.${segment2.stop}`,
|
|
9418
9788
|
scene: scene.id,
|
|
9419
|
-
stop:
|
|
9420
|
-
audio:
|
|
9789
|
+
stop: segment2.stop,
|
|
9790
|
+
audio: segment2.audio,
|
|
9421
9791
|
hold: round5(scene.start + scene.holds[index]),
|
|
9422
9792
|
start: round5(scene.start + starts[i]),
|
|
9423
|
-
duration:
|
|
9424
|
-
cues:
|
|
9793
|
+
duration: segment2.seconds,
|
|
9794
|
+
cues: segment2.cues.flatMap((c) => splitCue(c, cueMax(c.text)))
|
|
9425
9795
|
});
|
|
9426
9796
|
}
|
|
9427
9797
|
}
|
|
@@ -9498,6 +9868,15 @@ function planTiming(input) {
|
|
|
9498
9868
|
// `emitDeck` and `writeTiming` are handed the same `format`, so this cannot
|
|
9499
9869
|
// disagree with the box the archetypes actually drew into.
|
|
9500
9870
|
captionReserve: format.captionReserve ?? 0,
|
|
9871
|
+
// An unbudgeted format's limit is Infinity, which JSON writes as `null`.
|
|
9872
|
+
// Left out instead: recorded as a format with no limit, never with a limit
|
|
9873
|
+
// of null or zero.
|
|
9874
|
+
format: {
|
|
9875
|
+
id: format.id,
|
|
9876
|
+
minWeight: format.minWeight,
|
|
9877
|
+
...Number.isFinite(format.maxSeconds) && { maxSeconds: format.maxSeconds },
|
|
9878
|
+
...format.warnSeconds !== void 0 && { warnSeconds: format.warnSeconds }
|
|
9879
|
+
},
|
|
9501
9880
|
scenes,
|
|
9502
9881
|
segments: segments2
|
|
9503
9882
|
};
|
|
@@ -9524,15 +9903,15 @@ function framePlan(timing, fps) {
|
|
|
9524
9903
|
);
|
|
9525
9904
|
}
|
|
9526
9905
|
pieces.push({ from: first, motion: last - first, freeze: 0 });
|
|
9527
|
-
for (const
|
|
9528
|
-
const at = f(
|
|
9906
|
+
for (const segment2 of segments2) {
|
|
9907
|
+
const at = f(segment2.start);
|
|
9529
9908
|
audio.push({
|
|
9530
|
-
id:
|
|
9531
|
-
audio:
|
|
9909
|
+
id: segment2.id,
|
|
9910
|
+
audio: segment2.audio,
|
|
9532
9911
|
startFrame: at,
|
|
9533
9912
|
delayMs: Math.round(at / fps * 1e3)
|
|
9534
9913
|
});
|
|
9535
|
-
for (const cue of
|
|
9914
|
+
for (const cue of segment2.cues) {
|
|
9536
9915
|
const start = at / fps + cue.start;
|
|
9537
9916
|
const end = at / fps + cue.end;
|
|
9538
9917
|
if (end > start) cues.push({ start, end, text: cue.text });
|
|
@@ -9651,7 +10030,7 @@ ${bands}
|
|
|
9651
10030
|
</div>
|
|
9652
10031
|
`;
|
|
9653
10032
|
}
|
|
9654
|
-
function measure3(
|
|
10033
|
+
function measure3(count2) {
|
|
9655
10034
|
const doc = document;
|
|
9656
10035
|
const probe2 = doc.getElementById("c0");
|
|
9657
10036
|
if (probe2) {
|
|
@@ -9664,7 +10043,7 @@ function measure3(count) {
|
|
|
9664
10043
|
probe2.classList.remove("on");
|
|
9665
10044
|
}
|
|
9666
10045
|
const rects = [];
|
|
9667
|
-
for (let i = 0; i <
|
|
10046
|
+
for (let i = 0; i < count2; i++) {
|
|
9668
10047
|
const el = doc.getElementById(`c${i}`);
|
|
9669
10048
|
if (!el) continue;
|
|
9670
10049
|
el.classList.add("on");
|
|
@@ -9692,11 +10071,12 @@ async function renderCaptions(cues, style, deck, work) {
|
|
|
9692
10071
|
await mkdir8(work, { recursive: true });
|
|
9693
10072
|
await writeFile10(page, captionPage(cues, style, href));
|
|
9694
10073
|
const { default: puppeteer } = await import("puppeteer-core");
|
|
9695
|
-
const
|
|
9696
|
-
|
|
9697
|
-
headless: true,
|
|
9698
|
-
|
|
9699
|
-
|
|
10074
|
+
const executablePath = await chromePath(CAPTION_NEED);
|
|
10075
|
+
const browser = await launchOwnPage(
|
|
10076
|
+
(args) => puppeteer.launch({ executablePath, headless: true, args }),
|
|
10077
|
+
["--force-device-scale-factor=1", "--hide-scrollbars"],
|
|
10078
|
+
"the caption band"
|
|
10079
|
+
);
|
|
9700
10080
|
try {
|
|
9701
10081
|
const tab = await browser.newPage();
|
|
9702
10082
|
await tab.setViewport({ width: style.width, height: style.height, deviceScaleFactor: 1 });
|
|
@@ -10613,6 +10993,14 @@ async function readInBrowser(html, timeoutMs) {
|
|
|
10613
10993
|
// happens to be that run.
|
|
10614
10994
|
"--host-resolver-rules=MAP * ~NOTFOUND"
|
|
10615
10995
|
]
|
|
10996
|
+
// No `--no-sandbox` fallback here, unlike `launchOwnPage`: this is a page
|
|
10997
|
+
// from the web, and a renderer bug it triggers is exactly what the sandbox
|
|
10998
|
+
// contains. Refused, with the fix, rather than Chrome's raw FATAL.
|
|
10999
|
+
}).catch((err) => {
|
|
11000
|
+
if (!sandboxMissing(err)) throw err;
|
|
11001
|
+
throw new Error(
|
|
11002
|
+
`Chrome has no usable sandbox on this host, and a page from the web is not opened without one. ${SANDBOX_FIX}, then ingest again \u2014 or save the page and ingest the file.`
|
|
11003
|
+
);
|
|
10616
11004
|
});
|
|
10617
11005
|
try {
|
|
10618
11006
|
const page = await browser.newPage();
|
|
@@ -11646,10 +12034,10 @@ function readCanvas(html) {
|
|
|
11646
12034
|
function profilesFor(width, height, formats = FORMATS) {
|
|
11647
12035
|
return Object.values(formats).filter((f) => f.width === width && f.height === height);
|
|
11648
12036
|
}
|
|
11649
|
-
function scanBudget(html, storyboard, kept, formats = FORMATS) {
|
|
12037
|
+
function scanBudget(html, storyboard, kept, formats = FORMATS, recorded) {
|
|
11650
12038
|
const canvas = readCanvas(html);
|
|
11651
12039
|
if (!canvas) return [];
|
|
11652
|
-
const profiles = profilesFor(canvas.width, canvas.height, formats);
|
|
12040
|
+
const profiles = recorded && recorded.width === canvas.width && recorded.height === canvas.height ? [recorded] : profilesFor(canvas.width, canvas.height, formats);
|
|
11653
12041
|
if (profiles.length === 0) {
|
|
11654
12042
|
return [
|
|
11655
12043
|
{
|
|
@@ -12374,6 +12762,20 @@ function captionBottom(sid, selector, fallbackPx) {
|
|
|
12374
12762
|
}
|
|
12375
12763
|
return bottom > 0 ? bottom : fallbackPx;
|
|
12376
12764
|
}
|
|
12765
|
+
function collectUnfit(attr) {
|
|
12766
|
+
return Array.from(document.querySelectorAll(`[${attr}]`)).map((el) => ({
|
|
12767
|
+
sid: el.closest("[data-composition-id]")?.getAttribute("data-composition-id") ?? "?",
|
|
12768
|
+
tex: el.getAttribute(attr) ?? ""
|
|
12769
|
+
}));
|
|
12770
|
+
}
|
|
12771
|
+
function gradeUnfit(rows) {
|
|
12772
|
+
return rows.map((r) => ({
|
|
12773
|
+
severity: "error",
|
|
12774
|
+
gate: "fidelity",
|
|
12775
|
+
rule: "math_unfit",
|
|
12776
|
+
message: `${r.sid}: the equation does not fit its box even at the ${TYPE_FLOOR_PX}px floor broken across lines \u2014 ${JSON.stringify(r.tex)}. Shorten it in the source, or walk it across two beats.`
|
|
12777
|
+
}));
|
|
12778
|
+
}
|
|
12377
12779
|
async function fidelity(dir, opts = {}) {
|
|
12378
12780
|
const started = Date.now();
|
|
12379
12781
|
const floor = opts.floor ?? INK_FLOOR;
|
|
@@ -12397,6 +12799,10 @@ async function fidelity(dir, opts = {}) {
|
|
|
12397
12799
|
try {
|
|
12398
12800
|
deck = await openDeck(dir, { timeoutMs: opts.timeoutMs });
|
|
12399
12801
|
const { page, height } = deck;
|
|
12802
|
+
await page.waitForFunction("window.__hfTimelinesBuilding === false", {
|
|
12803
|
+
timeout: opts.timeoutMs ?? 6e4
|
|
12804
|
+
});
|
|
12805
|
+
const unfit = await page.evaluate(collectUnfit, UNFIT_ATTR);
|
|
12400
12806
|
const measured = [];
|
|
12401
12807
|
const collided = [];
|
|
12402
12808
|
const apparent = [];
|
|
@@ -12440,7 +12846,8 @@ async function fidelity(dir, opts = {}) {
|
|
|
12440
12846
|
...gradeFidelity(measured, floor),
|
|
12441
12847
|
...gradeReserve(measured),
|
|
12442
12848
|
...gradeOverprint(collided),
|
|
12443
|
-
...gradeApparent(apparent)
|
|
12849
|
+
...gradeApparent(apparent),
|
|
12850
|
+
...gradeUnfit(unfit)
|
|
12444
12851
|
],
|
|
12445
12852
|
elapsedMs: Date.now() - started
|
|
12446
12853
|
};
|
|
@@ -12787,9 +13194,13 @@ async function verify(dir, opts = {}, storyboard, kept, source) {
|
|
|
12787
13194
|
await readFile17(join17(dir, DECK_PAGE), "utf8").catch(() => ""),
|
|
12788
13195
|
await listFiles(dir)
|
|
12789
13196
|
);
|
|
12790
|
-
const
|
|
13197
|
+
const timing = await readTiming2(dir);
|
|
13198
|
+
const recorded = typeof timing?.format?.id === "string" ? { ...timing.format, width: timing.width, height: timing.height } : void 0;
|
|
13199
|
+
const budget3 = html.flatMap(
|
|
13200
|
+
([, text2]) => scanBudget(text2, storyboard, kept, void 0, recorded)
|
|
13201
|
+
);
|
|
12791
13202
|
const type = html.flatMap(([file, text2]) => scanTypeFloor(text2, file));
|
|
12792
|
-
const lead = kept
|
|
13203
|
+
const lead = kept && timing ? scanNarrationLead(kept, timing) : [];
|
|
12793
13204
|
const ours = [...determinism, ...narration, ...budget3, ...type, ...lead];
|
|
12794
13205
|
const stops = await declaredStops(dir);
|
|
12795
13206
|
const [verdict, frames2] = await Promise.all([
|
|
@@ -12978,6 +13389,31 @@ function scanUnusedFigures(storyboard, source) {
|
|
|
12978
13389
|
}
|
|
12979
13390
|
];
|
|
12980
13391
|
}
|
|
13392
|
+
function scanMath(storyboard, source) {
|
|
13393
|
+
const out = [];
|
|
13394
|
+
const say = (rule, message2) => {
|
|
13395
|
+
out.push({ severity: "warning", gate: "storyboard", rule, message: message2 });
|
|
13396
|
+
};
|
|
13397
|
+
for (const beat of storyboard.beats) {
|
|
13398
|
+
try {
|
|
13399
|
+
let notes = [];
|
|
13400
|
+
if (beat.archetype === "equation-walk") {
|
|
13401
|
+
const params = beat.params;
|
|
13402
|
+
const eq = source.equations.find((e) => e.id === params.equationId);
|
|
13403
|
+
if (eq) notes = prepareWalk(eq, params.terms, beat.id).notes;
|
|
13404
|
+
} else if (beat.archetype === "equation-morph") {
|
|
13405
|
+
notes = prepareMorph(beat, source).notes;
|
|
13406
|
+
}
|
|
13407
|
+
for (const n3 of notes) say("tex_repaired", `${beat.id}: ${n3}`);
|
|
13408
|
+
} catch (err) {
|
|
13409
|
+
say(
|
|
13410
|
+
"tex_unrenderable",
|
|
13411
|
+
`${beat.id}: ${err instanceof Error ? err.message : String(err)} \u2014 build will leave this beat out`
|
|
13412
|
+
);
|
|
13413
|
+
}
|
|
13414
|
+
}
|
|
13415
|
+
return out;
|
|
13416
|
+
}
|
|
12981
13417
|
function figuresShown2(params) {
|
|
12982
13418
|
const sides = ["left", "right"].map(
|
|
12983
13419
|
(side) => params[side]?.figureId
|
|
@@ -13059,14 +13495,14 @@ function scanNarrationLead(beats, timing) {
|
|
|
13059
13495
|
return findings;
|
|
13060
13496
|
}
|
|
13061
13497
|
function firstMention(segments2, label) {
|
|
13062
|
-
for (const
|
|
13063
|
-
for (const cue of
|
|
13498
|
+
for (const segment2 of segments2) {
|
|
13499
|
+
for (const cue of segment2.cues) {
|
|
13064
13500
|
const words2 = cue.text.split(/\s+/);
|
|
13065
13501
|
let at = 0;
|
|
13066
13502
|
for (const word of words2) {
|
|
13067
13503
|
if (mentions(word, label)) {
|
|
13068
13504
|
const share2 = cue.text.length > 0 ? at / cue.text.length : 0;
|
|
13069
|
-
return
|
|
13505
|
+
return segment2.start + cue.start + share2 * (cue.end - cue.start);
|
|
13070
13506
|
}
|
|
13071
13507
|
at += word.length + 1;
|
|
13072
13508
|
}
|
|
@@ -13334,7 +13770,11 @@ imageFlags(
|
|
|
13334
13770
|
...scanRepeatedObject(storyboard),
|
|
13335
13771
|
// A figure the plan ignored is cheapest to fix here, where the answer is one
|
|
13336
13772
|
// more beat in a file the author already has open.
|
|
13337
|
-
...scanUnusedFigures(storyboard, source)
|
|
13773
|
+
...scanUnusedFigures(storyboard, source),
|
|
13774
|
+
// Every formula the deck will draw, parsed here by the KaTeX it vendors. A
|
|
13775
|
+
// formula the browser could not parse used to be found at `verify`, after
|
|
13776
|
+
// the build and the narration had been spent on it.
|
|
13777
|
+
...scanMath(storyboard, source)
|
|
13338
13778
|
])
|
|
13339
13779
|
step(`plan: ${f.message}`);
|
|
13340
13780
|
const script = storyboard.beats.map((b) => b.narration?.trim()).filter(Boolean).join(" ");
|
|
@@ -13680,12 +14120,12 @@ async function gate(dir, snapshots = false, storyboard, kept, fidelity2 = true,
|
|
|
13680
14120
|
if (!verdict.passed) process.exitCode = 1;
|
|
13681
14121
|
}
|
|
13682
14122
|
function report(v) {
|
|
13683
|
-
const
|
|
14123
|
+
const count2 = (s) => v.findings.filter((f) => f.severity === s).length;
|
|
13684
14124
|
const lines = v.findings.map(
|
|
13685
14125
|
(f) => ` ${f.severity.padEnd(7)} ${f.gate.padEnd(11)} ${f.rule} ${f.message}`
|
|
13686
14126
|
);
|
|
13687
14127
|
lines.push(
|
|
13688
|
-
`${v.passed ? "PASS" : "FAIL"} \u2014 ${
|
|
14128
|
+
`${v.passed ? "PASS" : "FAIL"} \u2014 ${count2("error")} error(s), ${count2("warning")} warning(s)`
|
|
13689
14129
|
);
|
|
13690
14130
|
return `${lines.join("\n")}
|
|
13691
14131
|
`;
|