- Use this to help an AI agent generate your first draft.
- - The agent creates a link to a pre-populatedStudio page.
+
- The agent creates a link to a pre-populated Studio page.
- Edit the result on the web, or download as PNG, MP4, or GIF.
diff --git a/package-lock.json b/package-lock.json
index f862e71..bdb8501 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,15 +9,18 @@
"version": "0.9.0",
"license": "AGPL-3.0-only",
"dependencies": {
+ "@modelcontextprotocol/server": "^2.0.0",
"gifenc": "^1.0.3",
"mp4-muxer": "^5.2.2",
- "obscenity": "^0.4.6"
+ "obscenity": "^0.4.6",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.5.4",
"@cloudflare/vitest-pool-workers": "^0.18.8",
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
+ "fontkit": "^2.0.4",
"jsdom": "^29.1.1",
"typescript": "^7.0.2",
"vite": "^8.1.5",
@@ -300,6 +303,16 @@
"vitest": "^4.1.0"
}
},
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/@cloudflare/workerd-darwin-64": {
"version": "1.20260722.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz",
@@ -385,6 +398,15 @@
"node": ">=16"
}
},
+ "node_modules/@cloudflare/workers-types": {
+ "version": "5.20260729.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260729.1.tgz",
+ "integrity": "sha512-X5r/4y0gKMq/B72qkz/tEwNK4c3v2regT3to6Ia8qqC66E0+YIN/fU7x0JG6ej2rLxtU3TV3aVhfK9y8+jMMAw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "peer": true
+ },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -1587,6 +1609,31 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
+ "node_modules/@modelcontextprotocol/core": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz",
+ "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==",
+ "license": "MIT",
+ "dependencies": {
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@modelcontextprotocol/server": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz",
+ "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@modelcontextprotocol/core": "2.0.0",
+ "zod": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1952,6 +1999,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@swc/helpers": {
+ "version": "0.5.23",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
+ "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -2473,6 +2530,27 @@
"node": ">=12"
}
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@@ -2490,6 +2568,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -2507,6 +2595,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2573,6 +2671,13 @@
"node": ">=8"
}
},
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -2665,6 +2770,13 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2683,6 +2795,24 @@
}
}
},
+ "node_modules/fontkit": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.5.12",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "dfa": "^1.2.0",
+ "fast-deep-equal": "^3.1.3",
+ "restructure": "^3.0.0",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.4.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3137,6 +3267,13 @@
"node": ">=12.20.0"
}
},
+ "node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -3280,6 +3417,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/restructure": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -3436,6 +3580,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -3531,8 +3682,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/typescript": {
"version": "7.0.2",
@@ -3596,6 +3746,28 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
"node_modules/vite": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
@@ -3925,6 +4097,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "peer": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/youch": {
"version": "4.1.0-beta.10",
"resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
@@ -3951,10 +4141,9 @@
}
},
"node_modules/zod": {
- "version": "3.25.76",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
- "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
- "dev": true,
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
diff --git a/package.json b/package.json
index 4efdb79..37baa76 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"@cloudflare/vitest-pool-workers": "^0.18.8",
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
+ "fontkit": "^2.0.4",
"jsdom": "^29.1.1",
"typescript": "^7.0.2",
"vite": "^8.1.5",
@@ -48,8 +49,10 @@
"wrangler": "^4.114.0"
},
"dependencies": {
+ "@modelcontextprotocol/server": "^2.0.0",
"gifenc": "^1.0.3",
"mp4-muxer": "^5.2.2",
- "obscenity": "^0.4.6"
+ "obscenity": "^0.4.6",
+ "zod": "^4.4.3"
}
}
diff --git a/plugins/comic-strip/skills/comic-strip/SKILL.md b/plugins/comic-strip/skills/comic-strip/SKILL.md
index b0a9a31..b5e8476 100644
--- a/plugins/comic-strip/skills/comic-strip/SKILL.md
+++ b/plugins/comic-strip/skills/comic-strip/SKILL.md
@@ -51,7 +51,6 @@ Add `--base http://localhost:5173` to point the link at a dev server instead of
| --- | --- | --- |
| strip | `version` | `2` |
| | `columns` | 1 to 8 panels per row, default 4 |
-| | `size` | `classic` or `modern` (default) |
| | `seed` | integer, fixes the engine's random choices |
| panel | `kind` | `scene` (default) or `title` |
| | `background` | a backdrop name, or omit for blank |
diff --git a/plugins/comic-strip/skills/comic-strip/reference/guidance.md b/plugins/comic-strip/skills/comic-strip/reference/guidance.md
index 4762c20..8a8bc2b 100644
--- a/plugins/comic-strip/skills/comic-strip/reference/guidance.md
+++ b/plugins/comic-strip/skills/comic-strip/reference/guidance.md
@@ -65,7 +65,7 @@ MP4 and GIF exports are also available in the editor, and hold each panel about
- It spends a panel on no story. A four panel strip with a card in front is three beats, so write it as (title) 1+ (comic panels) 3 or let the sheet run up to 1+7 etc (4 panels per row) if you want to fill the page.
- You can name the cast that carry the strip, or leave it empty for a simple title card.
- The credit line puts the script chosen names of the characters (the avatar names are generic art identifiers). If keeping it short (e.g Dan the Server Man), you could use those lines for comedic effect or framing.
-- `footer` is an optional bottom line, off by default: an authorship credit ("Written by ...") or a date. Use it when the strip is signed work; leave it out otherwise. It never costs a credit row, though a five-name cast on a `classic` sized strip fills the card and drops it entirely (default `modern` works fine however)
+- `footer` is an optional bottom line, off by default: an authorship credit ("Written by ...") or a date. Use it when the strip is signed work; leave it out otherwise. It never costs a credit row, though a five-name cast can fill the card and drop it entirely
- Whether to use title is a creative choice. Usually it's not needed. e.g skip it for most cases. It can be used to set the framing/stage of the comic in advance where creatively beneficial, or for longer strips (8+)
## Pairings that work
diff --git a/plugins/comic-strip/skills/comic-strip/scripts/strip-link.mjs b/plugins/comic-strip/skills/comic-strip/scripts/strip-link.mjs
index be5d2da..394a8b2 100644
--- a/plugins/comic-strip/skills/comic-strip/scripts/strip-link.mjs
+++ b/plugins/comic-strip/skills/comic-strip/scripts/strip-link.mjs
@@ -51,7 +51,7 @@ function splitName(name) {
function checkKeys(raw, allowed, path) {
for (const key of Object.keys(raw))
if (!allowed.includes(key))
- warn(path ? `${path}.${key}` : key, `unknown field "${key}"`);
+ fail(path ? `${path}.${key}` : key, `unknown field "${key}"`);
}
function checkEnum(raw, key, allowed, path) {
@@ -313,7 +313,8 @@ function checkStrip(raw) {
checkKeys(raw, STRIP_KEYS, "");
if (raw.version !== catalog.version)
warn("version", `expected version ${catalog.version}`);
- checkEnum(raw, "size", catalog.sizes, "");
+ if (raw.size !== undefined)
+ fail("size", 'strips render "modern"; drop the size field');
if (raw.seed !== undefined && !Number.isInteger(raw.seed))
fail("seed", "seed must be an integer");
@@ -336,14 +337,11 @@ function checkStrip(raw) {
checkShape(raw.panels);
}
-// ?s= is deflated, ?script= plain; the studio reads both
+// ?s= carries the deflated strip
function link(strip, base) {
const json = Buffer.from(JSON.stringify(strip));
const packed = deflateRawSync(json, { level: 9 }).toString("base64url");
- const plain = json.toString("base64url");
- const query =
- packed.length < plain.length ? `s=${packed}` : `script=${plain}`;
- return `${base.replace(/\/$/, "")}/studio.html?${query}`;
+ return `${base.replace(/\/$/, "")}/studio.html?s=${packed}`;
}
const args = process.argv.slice(2);
diff --git a/scripts/generate-font-metrics.mjs b/scripts/generate-font-metrics.mjs
new file mode 100644
index 0000000..c1b3b54
--- /dev/null
+++ b/scripts/generate-font-metrics.mjs
@@ -0,0 +1,63 @@
+// Emits src/render/comicNeueMetrics.ts from the shipped woff2 faces, so the worker measures text without a canvas.
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { create } from "fontkit";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+
+// ASCII, Latin-1, and the punctuation strips actually carry
+const CODEPOINTS = [
+ ...range(0x20, 0x7e),
+ ...range(0xa0, 0xff),
+ ...[0x2013, 0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2026],
+];
+
+function range(lo, hi) {
+ return Array.from({ length: hi - lo + 1 }, (_, i) => lo + i);
+}
+
+function faceMetrics(file) {
+ const font = create(readFileSync(join(root, file)));
+ const advances = {};
+ const inkDescents = {};
+ for (const cp of CODEPOINTS) {
+ if (!font.hasGlyphForCodePoint(cp)) continue;
+ const glyph = font.glyphForCodePoint(cp);
+ const char = String.fromCodePoint(cp);
+ advances[char] = Math.round(glyph.advanceWidth);
+ const below = Math.round(-glyph.bbox.minY);
+ if (Number.isFinite(below) && below > 0) inkDescents[char] = below;
+ }
+ return {
+ unitsPerEm: font.unitsPerEm,
+ ascent: Math.round(font.ascent),
+ descent: Math.round(Math.abs(font.descent)),
+ fallbackAdvance: advances.m ?? Math.round(font.unitsPerEm / 2),
+ advances,
+ inkDescents,
+ };
+}
+
+const regular = faceMetrics("public/assets/fonts/comic-neue-400.woff2");
+const italic = faceMetrics("public/assets/fonts/comic-neue-400-italic.woff2");
+
+const out = `// Generated by scripts/generate-font-metrics.mjs; do not edit.
+
+export interface FaceMetrics {
+ unitsPerEm: number;
+ ascent: number;
+ descent: number;
+ fallbackAdvance: number;
+ advances: Record
;
+ inkDescents: Record;
+}
+
+export const COMIC_NEUE_REGULAR: FaceMetrics = ${JSON.stringify(regular)};
+
+export const COMIC_NEUE_ITALIC: FaceMetrics = ${JSON.stringify(italic)};
+`;
+
+writeFileSync(join(root, "src/render/comicNeueMetrics.ts"), out);
+console.log("wrote src/render/comicNeueMetrics.ts");
diff --git a/src/browser/canvasRenderer.ts b/src/browser/canvasRenderer.ts
index 236a367..7e6a173 100644
--- a/src/browser/canvasRenderer.ts
+++ b/src/browser/canvasRenderer.ts
@@ -1,678 +1,9 @@
-import type { Avatar, AvatarBody, PoseData } from "../engine/avatar.js";
-import {
- type Arc,
- boxBalloonTraj,
- normalBalloonTraj,
-} from "../engine/balloonTail.js";
-import type { PanelBalloon, UnitPanel } from "../engine/panel.js";
-import { SM_THINK, SM_WHISPER } from "../engine/panel.js";
-import { BalloonRuntime } from "../engine/panelBalloon.js";
-import { BetaSpline } from "../engine/spline.js";
-import {
- BELOW_STARRING,
- FOOTER_MARGIN,
- FOOTER_MIN_SCALE,
- STAR_ICON_SIZE,
- STAR_ICON_SPACE,
- STARRING,
- shoutFontCell,
- TITLE_LEADING,
- TITLE_UNIT,
- type TitleCard,
- titleFontCell,
-} from "../engine/titlePanel.js";
-import {
- angleBetweenVecs,
- angleToVector,
- dpointNorm,
- dpointScalmult,
- dpointToPoint,
- PI,
- pointAdd,
- pointMagn,
- pointScalmult,
- pointSub,
- pointToDpoint,
- round,
- vectorToAngle,
-} from "../engine/vector2d.js";
-import type { AvatarAtlasCache } from "./avatarAssets.js";
-import { BALLOON_FONT_SIZE, gdiCellFont } from "./canvasText.js";
-
-const TORSOFIRST = 4;
-
-function starRowHeight(unit: number, scale: number): number {
- return Math.max(
- Math.round(STAR_ICON_SIZE * scale),
- shoutFontCell(unit, BALLOON_FONT_SIZE),
- );
-}
-
-function starRows(
- card: TitleCard,
- top: number,
- height: number,
- rowHeight: number,
-): number {
- return Math.min(
- card.stars.length,
- Math.max(0, Math.trunc((height - top) / rowHeight)),
- );
-}
-
-export interface SpriteLayer {
- pose: PoseData;
- left: number;
- right: number;
- top: number;
- bottom: number;
-}
-
-function poseByID(avatar: Avatar, poseID: number): PoseData {
- const pose = avatar.data.poses.find(
- (candidate) => candidate.poseID === poseID,
- );
- if (!pose) throw new Error(`avatar ${avatar.avatarID} has no pose ${poseID}`);
- return pose;
-}
-
-function flipLayer(
- layer: SpriteLayer,
- fullLeft: number,
- fullRight: number,
-): void {
- const width = layer.right - layer.left;
- layer.left = fullRight - (layer.left - fullLeft);
- layer.right = layer.left - width;
-}
-
-export function bodySpriteLayers(
- avatar: Avatar,
- body: AvatarBody,
-): SpriteLayer[] {
- const client = body.bbox;
- const clientWidth = client.right - client.left;
- const clientHeight = client.top - client.bottom;
- if (body.kind === "simple") {
- const bodyRec = avatar.data.bodies[body.bodyIndex];
- if (!bodyRec)
- throw new Error(
- `avatar ${avatar.avatarID} has no body ${body.bodyIndex}`,
- );
- const pose = poseByID(avatar, bodyRec.poseID);
- const widthScale = clientWidth / pose.width;
- const heightScale = clientHeight / pose.height;
- let fullWidth: number;
- let fullHeight: number;
- if (widthScale <= heightScale) {
- fullWidth = clientWidth;
- fullHeight = Math.trunc(widthScale * pose.height);
- } else {
- fullHeight = clientHeight;
- fullWidth = Math.trunc(heightScale * pose.width);
- }
- const layer: SpriteLayer = {
- pose,
- left: client.left + Math.trunc((clientWidth - fullWidth) / 2),
- right: 0,
- // bodycam.cpp:594 adds the slack to top even in y-up space, floating the body above its bbox
- top: client.top + (clientHeight - fullHeight),
- bottom: 0,
- };
- layer.right = layer.left + fullWidth;
- layer.bottom = layer.top - fullHeight;
- if (body.flip) {
- const oldLeft = layer.left;
- layer.left = layer.right;
- layer.right = oldLeft;
- }
- return [layer];
- }
-
- const face = avatar.data.faces[body.faceIndex];
- const torso = avatar.data.torsos[body.torsoIndex];
- if (!face || !torso)
- throw new Error(`avatar ${avatar.avatarID} has incomplete body records`);
- const headPose = poseByID(avatar, face.poseID);
- const torsoPose = poseByID(avatar, torso.poseID);
- const xOffset = torso.xCX + face.deltaXCX - face.xCX;
- const yOffset = torso.yCX + face.deltaYCX - face.yCX;
- const bitLeft = Math.min(0, xOffset);
- const bitRight = Math.max(torsoPose.width, xOffset + headPose.width);
- const bitTop = Math.min(0, yOffset);
- const bitBottom = Math.max(torsoPose.height, yOffset + headPose.height);
- const bitWidth = bitRight - bitLeft;
- const bitHeight = bitBottom - bitTop;
- const scale = Math.min(clientWidth / bitWidth, clientHeight / bitHeight);
- const fullHeight = round(scale * bitHeight);
- const fullWidth = round(scale * bitWidth);
- const fullLeft = client.left + Math.trunc((clientWidth - fullWidth) / 2);
- // bodycam.cpp:559 "centered on bottom" adds the slack upward in y-up space
- const fullTop = client.top + (clientHeight - fullHeight);
- const fullRight = fullLeft + fullWidth;
- const head: SpriteLayer = {
- pose: headPose,
- left: round((xOffset - bitLeft) * scale) + fullLeft,
- right: 0,
- top: round((yOffset - bitTop) * scale) + fullTop,
- bottom: 0,
- };
- head.right = head.left + round(headPose.width * scale) + 1;
- head.bottom = head.top - (round(headPose.height * scale) + 1);
- const torsoLayer: SpriteLayer = {
- pose: torsoPose,
- left: round(-bitLeft * scale) + fullLeft,
- right: 0,
- top: fullTop - round(-bitTop * scale),
- bottom: 0,
- };
- torsoLayer.right = torsoLayer.left + round(torsoPose.width * scale) + 1;
- torsoLayer.bottom = torsoLayer.top - (round(torsoPose.height * scale) + 1);
- if (body.flip) {
- flipLayer(head, fullLeft, fullRight);
- flipLayer(torsoLayer, fullLeft, fullRight);
- }
- return avatar.data.flags && avatar.data.flags & TORSOFIRST
- ? [torsoLayer, head]
- : [head, torsoLayer];
-}
-
-function addArc(
- context: CanvasRenderingContext2D,
- arc: Arc,
- ox: number,
- oy: number,
-): void {
- if (arc.altitude < 1 && arc.altitude > -1) {
- context.lineTo(arc.hi.x + ox, -(arc.hi.y + oy));
- return;
- }
- const mid = pointScalmult(0.5, pointAdd(arc.lo, arc.hi));
- const endToMid = pointSub(mid, arc.hi);
- const endToMidDist = pointMagn(endToMid);
- const radius =
- (endToMidDist * endToMidDist + arc.altitude * arc.altitude) /
- (2 * arc.altitude);
- const midToCenterDist = radius - arc.altitude;
- const perpendicular = { x: endToMid.y, y: -endToMid.x };
- const perpendicularMagnitude = pointMagn(perpendicular);
- const midToCenter = pointScalmult(
- midToCenterDist / perpendicularMagnitude,
- perpendicular,
- );
- const center = pointAdd(pointAdd(arc.hi, endToMid), midToCenter);
- let a = pointToDpoint(pointSub(arc.lo, center));
- const finalC = pointToDpoint(pointSub(arc.hi, center));
- const scanRadius = Math.hypot(a.x, a.y);
- let trueAngle = angleBetweenVecs(finalC, a);
- const ccw = arc.altitude > 0;
- if (ccw) trueAngle = -trueAngle;
- if (trueAngle <= 0) trueAngle += 2 * PI;
- let nextEnd = vectorToAngle(a);
- const direction = ccw ? 1 : -1;
- while (true) {
- const finalSegment = trueAngle <= PI / 2;
- const step = finalSegment ? trueAngle : PI / 2;
- let c = finalC;
- if (!finalSegment) {
- nextEnd += direction * (PI / 2);
- const vector = angleToVector(nextEnd);
- c = { x: vector.x * scanRadius, y: vector.y * scanRadius };
- }
- const s = Math.cos(step / 2);
- const tau = (4 * s) / (3 * (s + 1));
- const divisor = (a.x * c.y - a.y * c.x) / (scanRadius * scanRadius);
- const b = { x: (c.y - a.y) / divisor, y: (a.x - c.x) / divisor };
- const p1 = {
- x: round((1 - tau) * a.x + tau * b.x) + center.x,
- y: round((1 - tau) * a.y + tau * b.y) + center.y,
- };
- const p2 = {
- x: round((1 - tau) * c.x + tau * b.x) + center.x,
- y: round((1 - tau) * c.y + tau * b.y) + center.y,
- };
- const p3 = pointAdd(dpointToPoint(c), center);
- context.bezierCurveTo(
- p1.x + ox,
- -(p1.y + oy),
- p2.x + ox,
- -(p2.y + oy),
- p3.x + ox,
- -(p3.y + oy),
- );
- if (finalSegment) break;
- a = c;
- trueAngle -= PI / 2;
- }
-}
-
-function addBezierPath(
- context: CanvasRenderingContext2D,
- bezier: readonly { x: number; y: number }[],
- ox: number,
- oy: number,
-): boolean {
- const first = bezier[0];
- if (!first) return false;
- context.moveTo(first.x + ox, -(first.y + oy));
- for (let i = 1; i + 2 < bezier.length; i += 3) {
- const p1 = bezier[i];
- const p2 = bezier[i + 1];
- const p3 = bezier[i + 2];
- if (!p1 || !p2 || !p3) continue;
- context.bezierCurveTo(
- p1.x + ox,
- -(p1.y + oy),
- p2.x + ox,
- -(p2.y + oy),
- p3.x + ox,
- -(p3.y + oy),
- );
- }
- return true;
-}
-
-export function drawSpriteLayer(
- context: CanvasRenderingContext2D,
- assets: AvatarAtlasCache,
- layer: SpriteLayer,
-): void {
- if (!layer.pose.sprite)
- throw new Error(`pose ${layer.pose.poseID} has no sprite`);
- const image = assets.get(layer.pose);
- const x = Math.min(layer.left, layer.right);
- const y = -Math.max(layer.top, layer.bottom);
- const width = Math.abs(layer.right - layer.left);
- const height = Math.abs(layer.top - layer.bottom);
- context.save();
- if (layer.left > layer.right) {
- context.translate(2 * x + width, 0);
- context.scale(-1, 1);
- }
- context.drawImage(
- image,
- layer.pose.sprite.x,
- layer.pose.sprite.y,
- layer.pose.width,
- layer.pose.height,
- x,
- y,
- width,
- height,
- );
- context.restore();
-}
-
-export interface CanvasPanelRendererOptions {
- unitWidth: number;
- unitHeight: number;
- background?: string;
- foreground?: string;
- normalFont?: string;
- whisperFont?: string;
- resolveBackdrop?: (name: string) => CanvasImageSource | undefined;
-}
-
-export class CanvasPanelRenderer {
- private normalFontCache?: string;
- private whisperFontCache?: string;
- private titleFontCache?: string;
- private shoutFontCache?: string;
-
- constructor(
- readonly context: CanvasRenderingContext2D,
- readonly assets: AvatarAtlasCache,
- readonly avatars: readonly Avatar[],
- readonly options: CanvasPanelRendererOptions,
- ) {}
-
- private get normalFont(): string {
- this.normalFontCache ??=
- this.options.normalFont ??
- gdiCellFont(this.context, "400", BALLOON_FONT_SIZE);
- return this.normalFontCache;
- }
-
- private get whisperFont(): string {
- this.whisperFontCache ??=
- this.options.whisperFont ??
- gdiCellFont(this.context, "italic 400", BALLOON_FONT_SIZE);
- return this.whisperFontCache;
- }
-
- private drawBody(body: AvatarBody): void {
- const avatar = this.avatars.find(
- (candidate) => candidate.avatarID === body.avatarID,
- );
- if (!avatar) throw new Error(`missing avatar ${body.avatarID}`);
- for (const layer of bodySpriteLayers(avatar, body))
- drawSpriteLayer(this.context, this.assets, layer);
- }
-
- private balloonPath(balloon: PanelBalloon, runtime: BalloonRuntime): void {
- if (!runtime.fInfo) return;
- const ox = runtime.bbox.left;
- const oy = runtime.bbox.top;
- this.context.beginPath();
- if (runtime.isBox) {
- const lines = boxBalloonTraj(runtime.fInfo.fbox);
- const first = lines[0]?.lo;
- if (!first) return;
- this.context.moveTo(first.x + ox, -(first.y + oy));
- for (const line of lines)
- this.context.lineTo(line.hi.x + ox, -(line.hi.y + oy));
- this.context.closePath();
- return;
- }
- if (balloon.mode === SM_THINK && runtime.spline) {
- const cloud = new BetaSpline(
- runtime.spline.cps,
- runtime.spline.cps.length,
- true,
- );
- addBezierPath(this.context, cloud.bezpts ?? [], ox, oy);
- this.context.closePath();
- return;
- }
- const cloud = runtime.cloudBBox();
- const traj = normalBalloonTraj(runtime.fInfo, runtime.style.font, {
- speakerArrowX: balloon.speaker.arrowX,
- speakerTop: balloon.speaker.bbox.top,
- balloonLeft: runtime.bbox.left,
- balloonTop: runtime.bbox.top,
- routeLeft: runtime.routeRgn.left,
- routeRight: runtime.routeRgn.right,
- cloudBottom: cloud.bottom,
- });
- const bezier = traj.spline.bezpts ?? [];
- if (!addBezierPath(this.context, bezier, ox, oy)) return;
- for (const arc of traj.arcs) addArc(this.context, arc, ox, oy);
- this.context.closePath();
- }
-
- private drawThinkBubbles(
- balloon: PanelBalloon,
- runtime: BalloonRuntime,
- ): void {
- if (balloon.mode !== SM_THINK || !runtime.fInfo) return;
- const entry = {
- x: Math.trunc((runtime.routeRgn.left + runtime.routeRgn.right) / 2),
- y: runtime.fInfo.fbox.bottom + runtime.bbox.top,
- };
- const tail = {
- x: balloon.speaker.arrowX,
- y: balloon.speaker.bbox.top + 200,
- };
- const deltaY = entry.y - tail.y;
- if (deltaY < 0) return;
- const count = Math.trunc((deltaY + 100) / 250);
- if (count <= 0) return;
- const spacing =
- count > 1 ? Math.trunc((deltaY - 150 * count) / (count - 1)) : 0;
- const direction = dpointNorm({ x: entry.x - tail.x, y: entry.y - tail.y });
- let center = pointAdd(tail, dpointToPoint(dpointScalmult(75, direction)));
- const increment = dpointToPoint(dpointScalmult(150 + spacing, direction));
- const widthDelta =
- count > 1 ? Math.trunc((400 - 150) / (2 * (count - 1))) : 0;
- let widthAdjustment = 0;
- for (let i = 0; i < count; i++) {
- this.context.beginPath();
- this.context.ellipse(
- center.x,
- -center.y,
- 75 + widthAdjustment,
- 75,
- 0,
- 0,
- 2 * Math.PI,
- );
- this.context.fill();
- this.context.stroke();
- center = pointAdd(center, increment);
- widthAdjustment += widthDelta;
- }
- }
-
- private drawBalloon(balloon: PanelBalloon): void {
- if (!(balloon.runtime instanceof BalloonRuntime)) return;
- const runtime = balloon.runtime;
- const fInfo = runtime.fInfo;
- if (!fInfo) return;
- this.context.save();
- this.context.fillStyle = this.options.background ?? "#fff";
- this.context.strokeStyle = this.options.foreground ?? "#000";
- this.context.lineJoin = "round";
- this.context.lineCap = "round";
- this.balloonPath(balloon, runtime);
- if (balloon.mode === SM_WHISPER) {
- this.context.lineWidth = 100;
- this.context.strokeStyle = this.options.background ?? "#fff";
- this.context.fill();
- this.context.stroke();
- this.context.strokeStyle = this.options.foreground ?? "#000";
- this.context.lineWidth = 28;
- this.context.setLineDash([100, 100]);
- this.context.stroke();
- this.context.setLineDash([]);
- } else {
- this.context.lineWidth = 28;
- this.context.fill();
- this.context.stroke();
- }
- this.drawThinkBubbles(balloon, runtime);
-
- this.context.fillStyle = this.options.foreground ?? "#000";
- this.context.font =
- balloon.mode === SM_WHISPER ? this.whisperFont : this.normalFont;
- this.context.textBaseline = "top";
- for (let i = 0; i < fInfo.nLines; i++) {
- const line = fInfo.lines[i];
- if (!line) continue;
- this.context.fillText(
- balloon.text.slice(line.start, line.start + line.length),
- runtime.bbox.left + (fInfo.leftX[i] ?? 0),
- -(runtime.bbox.top - i * runtime.style.font.lineHeight),
- );
- }
- this.context.restore();
- }
-
- private wrapTitle(text: string, maxWidth: number): string[] {
- const words = text.split(/\s+/).filter((word) => word.length > 0);
- const lines: string[] = [];
- let line = "";
- for (const word of words) {
- const candidate = line === "" ? word : `${line} ${word}`;
- if (line !== "" && this.context.measureText(candidate).width > maxWidth) {
- lines.push(line);
- line = word;
- } else line = candidate;
- }
- if (line !== "") lines.push(line);
- return lines;
- }
-
- private drawStarIcon(
- avatarID: number,
- left: number,
- top: number,
- size: number,
- ): void {
- const avatar = this.avatars.find(
- (candidate) => candidate.avatarID === avatarID,
- );
- if (!avatar) return;
- const pose = avatar.data.poses.find(
- (candidate) => candidate.poseID === avatar.data.iconPoseID,
- );
- if (!pose?.sprite) return;
- const scale = Math.min(size / pose.width, size / pose.height);
- const width = Math.round(pose.width * scale);
- const height = Math.round(pose.height * scale);
- this.context.drawImage(
- this.assets.get(pose),
- pose.sprite.x,
- pose.sprite.y,
- pose.width,
- pose.height,
- left + Math.trunc((size - width) / 2),
- top + Math.trunc((size - height) / 2),
- width,
- height,
- );
- }
-
- // AddStars (panel.cpp:1391-1446): icon+name rows, centered as a block on the widest name
- private drawStars(card: TitleCard, top: number): void {
- const context = this.context;
- const unit = this.options.unitWidth;
- const scale = unit / TITLE_UNIT;
- const iconSize = Math.round(STAR_ICON_SIZE * scale);
- const iconSpace = Math.round(STAR_ICON_SPACE * scale);
- const shoutCell = shoutFontCell(unit, BALLOON_FONT_SIZE);
- const rowHeight = starRowHeight(unit, scale);
- const stars = card.stars.slice(
- 0,
- starRows(card, top, this.options.unitHeight, rowHeight),
- );
- if (stars.length === 0) return;
- let maxWidth = 0;
- for (const star of stars)
- maxWidth = Math.max(maxWidth, context.measureText(star.label).width);
- const blockWidth = maxWidth + iconSize + iconSpace;
- const iconOffset = Math.max(0, Math.trunc((unit - blockWidth) / 2));
- const textOffset = iconOffset + iconSize + iconSpace;
- const iconVdisp = Math.trunc((rowHeight - iconSize) / 2);
- const textVdisp = Math.trunc((rowHeight - shoutCell) / 2);
- let y = top;
- for (const star of stars) {
- this.drawStarIcon(star.avatarID, iconOffset, y + iconVdisp, iconSize);
- context.fillText(star.label, textOffset, y + textVdisp);
- y += rowHeight;
- }
- }
-
- // how far a line's ink hangs below its baseline
- private descender(text: string): number {
- const metrics = this.context.measureText(text);
- return Math.max(
- 0,
- (metrics.actualBoundingBoxDescent ?? 0) -
- (metrics.fontBoundingBoxAscent ?? 0),
- );
- }
-
- // AddTitle (panel.cpp:1279-1297): title, STARRING beneath, then the cast
- private drawTitle(card: TitleCard): void {
- const context = this.context;
- const unit = this.options.unitWidth;
- const scale = unit / TITLE_UNIT;
- this.titleFontCache ??= gdiCellFont(
- context,
- "400",
- titleFontCell(unit, BALLOON_FONT_SIZE),
- );
- this.shoutFontCache ??= gdiCellFont(
- context,
- "400",
- shoutFontCell(unit, BALLOON_FONT_SIZE),
- );
- context.save();
- context.fillStyle = this.options.foreground ?? "#000";
- context.textBaseline = "top";
- context.textAlign = "center";
- context.font = this.titleFontCache;
- let y = Math.round(100 * scale);
- const lineHeight =
- titleFontCell(unit, BALLOON_FONT_SIZE) +
- Math.round(TITLE_LEADING * scale);
- for (const line of this.wrapTitle(card.text, unit)) {
- context.fillText(line, unit / 2, y);
- y += lineHeight + this.descender(line);
- }
- context.font = this.shoutFontCache;
- const starring = card.starring?.trim() ? card.starring : STARRING;
- context.fillText(starring, unit / 2, y);
- const shoutCell = shoutFontCell(unit, BALLOON_FONT_SIZE);
- y +=
- shoutCell + Math.round(BELOW_STARRING * scale) + this.descender(starring);
- const footer = card.footer?.trim();
- context.textAlign = "left";
- this.drawStars(card, y);
- if (footer) this.drawFooter(footer, card, y, scale, shoutCell);
- context.restore();
- }
-
- // the cast keeps every row it would draw without a footer; the line takes what is left under it
- private drawFooter(
- footer: string,
- card: TitleCard,
- starsTop: number,
- scale: number,
- shoutCell: number,
- ): void {
- const context = this.context;
- const unit = this.options.unitWidth;
- const rowHeight = starRowHeight(unit, scale);
- const rows = starRows(card, starsTop, this.options.unitHeight, rowHeight);
- const drop = this.descender(footer);
- const free = this.options.unitHeight - (starsTop + rows * rowHeight) - drop;
- const floor = Math.round(shoutCell * FOOTER_MIN_SCALE);
- if (free < floor) return;
- let margin = Math.round(FOOTER_MARGIN * scale);
- let cell = Math.min(shoutCell, free - margin * 2);
- if (cell < floor) {
- cell = floor;
- margin = Math.trunc((free - floor) / 2);
- }
- let font = gdiCellFont(context, "400", cell);
- context.font = font;
- const maxWidth = unit - margin * 2;
- const width = context.measureText(footer).width;
- if (width > maxWidth) {
- cell = Math.round((cell * maxWidth) / width);
- font = gdiCellFont(context, "400", cell);
- }
- context.textAlign = "center";
- context.font = font;
- context.fillText(
- footer,
- unit / 2,
- this.options.unitHeight - margin - cell - drop,
- );
- }
-
- render(panel: UnitPanel): void {
- const context = this.context;
- context.save();
- context.fillStyle = this.options.background ?? "#fff";
- context.fillRect(0, 0, this.options.unitWidth, this.options.unitHeight);
- context.imageSmoothingEnabled = true;
- context.imageSmoothingQuality = "high";
- // CBackDrop::Draw StretchBlts the DIB across the whole panel rect (backdrop.cpp:330)
- const backdrop = panel.backdrop
- ? this.options.resolveBackdrop?.(panel.backdrop)
- : undefined;
- if (backdrop)
- context.drawImage(
- backdrop,
- 0,
- 0,
- this.options.unitWidth,
- this.options.unitHeight,
- );
- if (panel.title) this.drawTitle(panel.title);
- for (const body of panel.bodies) this.drawBody(body);
- for (let i = panel.balloons.length - 1; i >= 0; i--) {
- const balloon = panel.balloons[i];
- if (balloon) this.drawBalloon(balloon);
- }
- if (panel.hasBorder) {
- context.strokeStyle = this.options.foreground ?? "#000";
- // panel.cpp:1252 strokes a 2*60-wide pen on the panel edge path; the outer half clips away
- context.lineWidth = 120;
- context.strokeRect(0, 0, this.options.unitWidth, this.options.unitHeight);
- }
- context.restore();
- }
-}
+// The panel painter lives in src/render so the worker can drive it; browser callers import it from here.
+
+export {
+ bodySpriteLayers,
+ CanvasPanelRenderer,
+ type CanvasPanelRendererOptions,
+ drawSpriteLayer,
+ type SpriteLayer,
+} from "../render/panelRenderer.js";
diff --git a/src/browser/canvasText.ts b/src/browser/canvasText.ts
index 03f1ccb..9366d54 100644
--- a/src/browser/canvasText.ts
+++ b/src/browser/canvasText.ts
@@ -1,153 +1,20 @@
-// Browser text metrics adapter. Engine geometry remains in v1 logical units.
-
-import type {
- FontMetrics,
- TextMeasure,
- TextSize,
-} from "../engine/balloonText.js";
-import { SM_WHISPER } from "../engine/panel.js";
-import type {
- BalloonStyle,
- BalloonStyleResolver,
-} from "../engine/panelBalloon.js";
-
-export const BALLOON_FONT_SIZE = 400;
-export const BALLOON_FONT_FAMILIES =
- '"Comic Sans MS", "Comic Sans", "Comic Neue", cursive';
-export const NORMAL_FONT_CSS = `400 400px ${BALLOON_FONT_FAMILIES}`;
-export const WHISPER_FONT_CSS = `italic 400 400px ${BALLOON_FONT_FAMILIES}`;
-export const SYSTEM_FONT_CSS = "400 212px system-ui, sans-serif";
-
-// GDI lfHeight requests a character cell (ascent + descent) while CSS px sets the em, so scale the em until the bounding box matches the cell
-export function gdiCellFont(
- context: CanvasMeasureContext,
- style: string,
- cellHeight: number,
- families: string = BALLOON_FONT_FAMILIES,
-): string {
- const probe = `${style} ${cellHeight}px ${families}`.trim();
- context.font = probe;
- const metrics = context.measureText("Mg");
- const box =
- (metrics.fontBoundingBoxAscent ?? 0) +
- (metrics.fontBoundingBoxDescent ?? 0);
- if (!Number.isFinite(box) || box <= 0) return probe;
- const size = Math.round((cellHeight * cellHeight) / box);
- return `${style} ${size}px ${families}`.trim();
-}
-
-export const NORMAL_FONT_METRICS: FontMetrics = {
- lineHeight: 327,
- baseAdd: 60,
- leading: -70,
-};
-
-export const WHISPER_FONT_METRICS: FontMetrics = {
- lineHeight: 314,
- baseAdd: 60,
- leading: -70,
-};
-
-export interface CanvasMeasureContext {
- font: string;
- measureText(text: string): {
- width: number;
- fontBoundingBoxAscent?: number;
- fontBoundingBoxDescent?: number;
- };
-}
-
-export interface CanvasTextMeasurerOptions {
- maxEntries?: number;
- normalFont?: string;
- whisperFont?: string;
- systemFont?: string;
-}
-
-function canvasSafeText(text: string): string {
- // GDI's explicit trailing NUL has no advance; canvas otherwise renders a
- // replacement glyph in some engines.
- return text.replaceAll("\0", "");
-}
-
-export class CanvasTextMeasurer {
- readonly context: CanvasMeasureContext;
- readonly maxEntries: number;
- readonly systemFont: string;
- private readonly options: CanvasTextMeasurerOptions;
- private normalFontCache?: string;
- private whisperFontCache?: string;
- private readonly widths = new Map();
-
- constructor(
- context: CanvasMeasureContext,
- options: CanvasTextMeasurerOptions = {},
- ) {
- this.context = context;
- this.options = options;
- this.maxEntries = Math.max(1, options.maxEntries ?? 4096);
- this.systemFont = options.systemFont ?? SYSTEM_FONT_CSS;
- }
-
- get normalFont(): string {
- this.normalFontCache ??=
- this.options.normalFont ??
- gdiCellFont(this.context, "400", BALLOON_FONT_SIZE);
- return this.normalFontCache;
- }
-
- get whisperFont(): string {
- this.whisperFontCache ??=
- this.options.whisperFont ??
- gdiCellFont(this.context, "italic 400", BALLOON_FONT_SIZE);
- return this.whisperFontCache;
- }
-
- get size(): number {
- return this.widths.size;
- }
-
- clear(): void {
- this.widths.clear();
- }
-
- measure(font: string, height: number): TextMeasure {
- return (text: string): TextSize => {
- const safeText = canvasSafeText(text);
- const key = `${font}\0${safeText}`;
- const cached = this.widths.get(key);
- if (cached !== undefined) {
- this.widths.delete(key);
- this.widths.set(key, cached);
- return { cx: cached, cy: height };
- }
-
- this.context.font = font;
- const width = Math.round(this.context.measureText(safeText).width);
- this.widths.set(key, width);
- if (this.widths.size > this.maxEntries) {
- const oldest = this.widths.keys().next().value;
- if (oldest !== undefined) this.widths.delete(oldest);
- }
- return { cx: width, cy: height };
- };
- }
-
- styleResolver(): BalloonStyleResolver {
- const systemMeasure = this.measure(this.systemFont, 212);
- const normal: BalloonStyle = {
- measure: this.measure(this.normalFont, 397),
- widestMeasure: systemMeasure,
- font: NORMAL_FONT_METRICS,
- };
- const whisper: BalloonStyle = {
- measure: this.measure(this.whisperFont, 384),
- widestMeasure: systemMeasure,
- font: WHISPER_FONT_METRICS,
- };
- return (_text, mode) => (mode === SM_WHISPER ? whisper : normal);
- }
-}
+// Browser text measurement: a real canvas context behind the shared metrics adapter in src/render.
+
+export {
+ BALLOON_FONT_FAMILIES,
+ BALLOON_FONT_SIZE,
+ type CanvasMeasureContext,
+ CanvasTextMeasurer,
+ type CanvasTextMeasurerOptions,
+ gdiCellFont,
+ NORMAL_FONT_CSS,
+ NORMAL_FONT_METRICS,
+ SYSTEM_FONT_CSS,
+ WHISPER_FONT_CSS,
+ WHISPER_FONT_METRICS,
+} from "../render/textMetrics.js";
+
+import { NORMAL_FONT_CSS, WHISPER_FONT_CSS } from "../render/textMetrics.js";
export function createCanvasMeasureContext(): CanvasRenderingContext2D {
if (typeof OffscreenCanvas !== "undefined") {
diff --git a/src/browser/dom.ts b/src/browser/dom.ts
index 5e247ef..8024154 100644
--- a/src/browser/dom.ts
+++ b/src/browser/dom.ts
@@ -6,9 +6,7 @@ export function element(id: string): T {
return found as T;
}
-export function displayName(name: string): string {
- return name.charAt(0).toUpperCase() + name.slice(1);
-}
+export { displayName } from "../render/displayName.js";
// how far a scroller must move to bring an item into view, matching scrollIntoView's "nearest"
export function scrollDelta(
diff --git a/src/browser/stripMark.ts b/src/browser/stripMark.ts
index be22066..6a38063 100644
--- a/src/browser/stripMark.ts
+++ b/src/browser/stripMark.ts
@@ -1,57 +1,12 @@
-// The credit line every exported sheet carries, drawn into the band under the last panel row.
+// Browser wrappers over the shared mark painter, defaulting the host to the page's own.
-const MARK_COLOR = "#222";
-const MARK_FAMILY = '"Segoe UI", system-ui, sans-serif';
-// a one-column sheet is the reference; wider sheets scale by the square root, enough to stay legible without taking over
-const MARK_BASE_WIDTH = 700;
-const MARK_FONT_PX = 12;
-const MARK_MIN_FONT_PX = 9;
+import {
+ drawMarkLine as markLine,
+ drawStripMark as stripMark,
+} from "../render/stripMark.js";
-// the host comes off the page, so a fork's exports name the fork
-export function stripMarkParts(host: string): { left: string; right: string } {
- return {
- left: "Comic Chat 1996 port · original character design by Jim Woodring",
- right: host ? `created at ${host}` : "",
- };
-}
-
-function markFont(width: number): number {
- const scale = Math.max(1, Math.sqrt(width / MARK_BASE_WIDTH));
- return Math.round(MARK_FONT_PX * scale);
-}
-
-// both ends share one line, so step down until four ems of daylight separate them
-function fitFont(
- context: CanvasRenderingContext2D,
- left: string,
- right: string,
- width: number,
- gap: number,
- start: number = gap,
-): number {
- const room = width - start - gap;
- let px = markFont(width);
- while (px > MARK_MIN_FONT_PX) {
- context.font = `${px}px ${MARK_FAMILY}`;
- const used =
- context.measureText(left).width + context.measureText(right).width;
- if (used + px * 4 <= room) break;
- px -= 1;
- }
- return px;
-}
-
-// the band gives the type twice its own height; a small sheet already has that much gutter
-function markBand(width: number, gap: number): number {
- return Math.max(gap, markFont(width) * 2);
-}
-
-// extra sheet height the band needs beyond the gutter under the last row
-export function stripMarkGrowth(width: number, gap: number): number {
- return markBand(width, gap) - gap;
-}
+export { stripMarkGrowth, stripMarkParts } from "../render/stripMark.js";
-// one baseline, each end set flush against its margin; start lets the credit skip a borderless lead panel
export function drawMarkLine(
context: CanvasRenderingContext2D,
width: number,
@@ -60,16 +15,7 @@ export function drawMarkLine(
host: string = location.host,
start: number = margin,
): void {
- const { left, right } = stripMarkParts(host);
- context.save();
- context.font = `${fitFont(context, left, right, width, margin, start)}px ${MARK_FAMILY}`;
- context.fillStyle = MARK_COLOR;
- context.textBaseline = "middle";
- context.textAlign = "left";
- context.fillText(left, start, y);
- context.textAlign = "right";
- context.fillText(right, width - margin, y);
- context.restore();
+ markLine(context, width, margin, y, host, start);
}
export function drawStripMark(
@@ -80,7 +26,5 @@ export function drawStripMark(
host: string = location.host,
start: number = gap,
): void {
- // whole pixels, so "middle" never lands the glyphs on a subpixel boundary
- const y = height - Math.round(markBand(width, gap) / 2);
- drawMarkLine(context, width, gap, y, host, start);
+ stripMark(context, width, height, gap, host, start);
}
diff --git a/src/render/comicNeueMetrics.ts b/src/render/comicNeueMetrics.ts
new file mode 100644
index 0000000..709ec4a
--- /dev/null
+++ b/src/render/comicNeueMetrics.ts
@@ -0,0 +1,719 @@
+// Generated by scripts/generate-font-metrics.mjs; do not edit.
+
+export interface FaceMetrics {
+ unitsPerEm: number;
+ ascent: number;
+ descent: number;
+ fallbackAdvance: number;
+ advances: Record;
+ inkDescents: Record;
+}
+
+export const COMIC_NEUE_REGULAR: FaceMetrics = {
+ unitsPerEm: 1000,
+ ascent: 900,
+ descent: 250,
+ fallbackAdvance: 827,
+ advances: {
+ "0": 609,
+ "1": 396,
+ "2": 523,
+ "3": 506,
+ "4": 484,
+ "5": 542,
+ "6": 542,
+ "7": 473,
+ "8": 515,
+ "9": 531,
+ " ": 334,
+ "!": 245,
+ '"': 275,
+ "#": 740,
+ $: 602,
+ "%": 720,
+ "&": 644,
+ "'": 196,
+ "(": 346,
+ ")": 346,
+ "*": 394,
+ "+": 444,
+ ",": 164,
+ "-": 402,
+ ".": 153,
+ "/": 436,
+ ":": 181,
+ ";": 199,
+ "<": 511,
+ "=": 462,
+ ">": 541,
+ "?": 501,
+ "@": 832,
+ A: 581,
+ B: 620,
+ C: 614,
+ D: 629,
+ E: 603,
+ F: 577,
+ G: 716,
+ H: 723,
+ I: 392,
+ J: 457,
+ K: 560,
+ L: 500,
+ M: 838,
+ N: 658,
+ O: 643,
+ P: 560,
+ Q: 708,
+ R: 596,
+ S: 552,
+ T: 522,
+ U: 691,
+ V: 536,
+ W: 867,
+ X: 685,
+ Y: 594,
+ Z: 582,
+ "[": 318,
+ "\\": 549,
+ "]": 318,
+ "^": 504,
+ _: 494,
+ "`": 181,
+ a: 516,
+ b: 535,
+ c: 482,
+ d: 530,
+ e: 501,
+ f: 279,
+ g: 530,
+ h: 536,
+ i: 227,
+ j: 236,
+ k: 484,
+ l: 219,
+ m: 827,
+ n: 553,
+ o: 529,
+ p: 531,
+ q: 520,
+ r: 397,
+ s: 416,
+ t: 286,
+ u: 511,
+ v: 418,
+ w: 598,
+ x: 473,
+ y: 447,
+ z: 434,
+ "{": 373,
+ "|": 197,
+ "}": 373,
+ "~": 615,
+ " ": 334,
+ "¡": 245,
+ "¢": 480,
+ "£": 661,
+ "¤": 507,
+ "¥": 494,
+ "¦": 223,
+ "§": 421,
+ "¨": 265,
+ "©": 547,
+ ª: 302,
+ "«": 563,
+ "¬": 478,
+ "": 402,
+ "®": 555,
+ "¯": 263,
+ "°": 247,
+ "±": 445,
+ "²": 249,
+ "³": 245,
+ "´": 181,
+ µ: 504,
+ "¶": 557,
+ "·": 176,
+ "¸": 172,
+ "¹": 176,
+ º: 281,
+ "»": 559,
+ "¼": 685,
+ "½": 741,
+ "¾": 734,
+ "¿": 485,
+ À: 581,
+ Á: 581,
+ Â: 581,
+ Ã: 581,
+ Ä: 581,
+ Å: 581,
+ Æ: 1044,
+ Ç: 614,
+ È: 603,
+ É: 603,
+ Ê: 603,
+ Ë: 603,
+ Ì: 392,
+ Í: 392,
+ Î: 392,
+ Ï: 392,
+ Ð: 634,
+ Ñ: 658,
+ Ò: 643,
+ Ó: 643,
+ Ô: 643,
+ Õ: 643,
+ Ö: 643,
+ "×": 376,
+ Ø: 635,
+ Ù: 691,
+ Ú: 691,
+ Û: 691,
+ Ü: 691,
+ Ý: 594,
+ Þ: 542,
+ ß: 526,
+ à: 516,
+ á: 516,
+ â: 516,
+ ã: 516,
+ ä: 516,
+ å: 516,
+ æ: 822,
+ ç: 482,
+ è: 501,
+ é: 501,
+ ê: 501,
+ ë: 501,
+ ì: 228,
+ í: 228,
+ î: 228,
+ ï: 228,
+ ð: 470,
+ ñ: 553,
+ ò: 529,
+ ó: 529,
+ ô: 529,
+ õ: 529,
+ ö: 529,
+ "÷": 432,
+ ø: 529,
+ ù: 511,
+ ú: 511,
+ û: 511,
+ ü: 511,
+ ý: 447,
+ þ: 531,
+ ÿ: 447,
+ "–": 600,
+ "—": 824,
+ "‘": 185,
+ "’": 185,
+ "“": 308,
+ "”": 326,
+ "…": 664,
+ },
+ inkDescents: {
+ "0": 6,
+ "3": 6,
+ "4": 6,
+ "5": 6,
+ "6": 6,
+ "7": 6,
+ "8": 6,
+ "9": 6,
+ "!": 6,
+ "#": 6,
+ $: 80,
+ "%": 14,
+ "&": 10,
+ "(": 112,
+ ")": 112,
+ ",": 98,
+ ".": 6,
+ "/": 122,
+ ":": 6,
+ ";": 98,
+ "?": 6,
+ "@": 100,
+ A: 7,
+ B: 8,
+ C: 6,
+ D: 7,
+ F: 7,
+ G: 7,
+ H: 6,
+ J: 6,
+ K: 7,
+ L: 6,
+ M: 6,
+ N: 6,
+ O: 6,
+ P: 6,
+ Q: 29,
+ R: 6,
+ S: 6,
+ T: 6,
+ U: 6,
+ V: 6,
+ W: 6,
+ X: 6,
+ Y: 6,
+ Z: 1,
+ "[": 128,
+ "\\": 7,
+ "]": 128,
+ _: 61,
+ a: 14,
+ b: 6,
+ c: 6,
+ d: 6,
+ e: 6,
+ f: 6,
+ g: 188,
+ h: 7,
+ i: 6,
+ j: 184,
+ k: 6,
+ l: 6,
+ m: 6,
+ n: 6,
+ o: 6,
+ p: 198,
+ q: 186,
+ r: 6,
+ s: 8,
+ t: 7,
+ u: 9,
+ v: 7,
+ w: 6,
+ x: 6,
+ y: 200,
+ z: 5,
+ "{": 128,
+ "|": 172,
+ "}": 128,
+ "¡": 200,
+ "¢": 6,
+ "£": 16,
+ "¥": 6,
+ "¦": 103,
+ "§": 6,
+ "±": 7,
+ µ: 184,
+ "¶": 85,
+ "¸": 207,
+ "¼": 72,
+ "½": 69,
+ "¾": 72,
+ "¿": 190,
+ À: 7,
+ Á: 7,
+ Â: 7,
+ Ã: 7,
+ Ä: 7,
+ Å: 7,
+ Æ: 9,
+ Ç: 215,
+ Ð: 26,
+ Ñ: 6,
+ Ò: 6,
+ Ó: 6,
+ Ô: 6,
+ Õ: 6,
+ Ö: 6,
+ Ø: 38,
+ Ù: 6,
+ Ú: 6,
+ Û: 6,
+ Ü: 6,
+ Ý: 6,
+ Þ: 6,
+ ß: 31,
+ à: 14,
+ á: 14,
+ â: 14,
+ ã: 14,
+ ä: 14,
+ å: 14,
+ æ: 6,
+ ç: 207,
+ è: 6,
+ é: 6,
+ ê: 6,
+ ë: 6,
+ ì: 6,
+ í: 6,
+ î: 6,
+ ï: 6,
+ ð: 5,
+ ñ: 6,
+ ò: 6,
+ ó: 6,
+ ô: 6,
+ õ: 6,
+ ö: 6,
+ ø: 41,
+ ù: 9,
+ ú: 9,
+ û: 9,
+ ü: 9,
+ ý: 200,
+ þ: 198,
+ ÿ: 200,
+ "…": 6,
+ },
+};
+
+export const COMIC_NEUE_ITALIC: FaceMetrics = {
+ unitsPerEm: 1000,
+ ascent: 900,
+ descent: 250,
+ fallbackAdvance: 829,
+ advances: {
+ "0": 612,
+ "1": 396,
+ "2": 526,
+ "3": 508,
+ "4": 485,
+ "5": 544,
+ "6": 544,
+ "7": 477,
+ "8": 516,
+ "9": 533,
+ " ": 333,
+ "!": 244,
+ '"': 291,
+ "#": 750,
+ $: 603,
+ "%": 706,
+ "&": 647,
+ "'": 174,
+ "(": 345,
+ ")": 345,
+ "*": 374,
+ "+": 444,
+ ",": 162,
+ "-": 411,
+ ".": 144,
+ "/": 478,
+ ":": 174,
+ ";": 198,
+ "<": 479,
+ "=": 462,
+ ">": 474,
+ "?": 487,
+ "@": 812,
+ A: 583,
+ B: 621,
+ C: 614,
+ D: 630,
+ E: 606,
+ F: 577,
+ G: 715,
+ H: 724,
+ I: 392,
+ J: 455,
+ K: 558,
+ L: 493,
+ M: 842,
+ N: 660,
+ O: 635,
+ P: 558,
+ Q: 712,
+ R: 596,
+ S: 549,
+ T: 518,
+ U: 694,
+ V: 535,
+ W: 869,
+ X: 622,
+ Y: 595,
+ Z: 582,
+ "[": 314,
+ "\\": 486,
+ "]": 315,
+ "^": 389,
+ _: 612,
+ "`": 231,
+ a: 514,
+ b: 536,
+ c: 483,
+ d: 531,
+ e: 503,
+ f: 294,
+ g: 523,
+ h: 538,
+ i: 226,
+ j: 259,
+ k: 486,
+ l: 219,
+ m: 829,
+ n: 555,
+ o: 529,
+ p: 534,
+ q: 525,
+ r: 397,
+ s: 415,
+ t: 316,
+ u: 524,
+ v: 418,
+ w: 590,
+ x: 476,
+ y: 446,
+ z: 417,
+ "{": 367,
+ "|": 216,
+ "}": 366,
+ "~": 596,
+ " ": 333,
+ "¡": 237,
+ "¢": 483,
+ "£": 678,
+ "¤": 487,
+ "¥": 499,
+ "¦": 189,
+ "§": 461,
+ "¨": 262,
+ "©": 545,
+ ª: 346,
+ "«": 532,
+ "¬": 472,
+ "": 411,
+ "®": 542,
+ "¯": 260,
+ "°": 280,
+ "±": 434,
+ "²": 371,
+ "³": 371,
+ "´": 171,
+ µ: 526,
+ "¶": 527,
+ "·": 175,
+ "¸": 173,
+ "¹": 308,
+ º: 387,
+ "»": 515,
+ "¼": 674,
+ "½": 738,
+ "¾": 630,
+ "¿": 471,
+ À: 583,
+ Á: 583,
+ Â: 583,
+ Ã: 583,
+ Ä: 583,
+ Å: 583,
+ Æ: 1028,
+ Ç: 614,
+ È: 606,
+ É: 606,
+ Ê: 606,
+ Ë: 606,
+ Ì: 392,
+ Í: 392,
+ Î: 392,
+ Ï: 392,
+ Ð: 630,
+ Ñ: 660,
+ Ò: 635,
+ Ó: 635,
+ Ô: 635,
+ Õ: 635,
+ Ö: 635,
+ "×": 375,
+ Ø: 634,
+ Ù: 694,
+ Ú: 694,
+ Û: 694,
+ Ü: 694,
+ Ý: 595,
+ Þ: 550,
+ ß: 528,
+ à: 514,
+ á: 514,
+ â: 514,
+ ã: 514,
+ ä: 514,
+ å: 514,
+ æ: 830,
+ ç: 483,
+ è: 503,
+ é: 503,
+ ê: 503,
+ ë: 503,
+ ì: 226,
+ í: 226,
+ î: 226,
+ ï: 226,
+ ð: 474,
+ ñ: 555,
+ ò: 529,
+ ó: 529,
+ ô: 529,
+ õ: 529,
+ ö: 529,
+ "÷": 432,
+ ø: 533,
+ ù: 524,
+ ú: 524,
+ û: 524,
+ ü: 524,
+ ý: 446,
+ þ: 533,
+ ÿ: 446,
+ "–": 540,
+ "—": 852,
+ "‘": 175,
+ "’": 175,
+ "“": 312,
+ "”": 310,
+ "…": 735,
+ },
+ inkDescents: {
+ "0": 6,
+ "3": 6,
+ "4": 6,
+ "5": 6,
+ "6": 6,
+ "7": 6,
+ "8": 6,
+ "9": 6,
+ "!": 6,
+ "#": 6,
+ $: 79,
+ "%": 14,
+ "&": 6,
+ "(": 113,
+ ")": 114,
+ ",": 92,
+ "/": 1,
+ ":": 6,
+ ";": 92,
+ "@": 100,
+ A: 6,
+ B: 8,
+ C: 8,
+ D: 16,
+ F: 6,
+ G: 7,
+ H: 6,
+ J: 6,
+ K: 7,
+ L: 6,
+ M: 6,
+ N: 6,
+ O: 7,
+ P: 6,
+ Q: 30,
+ R: 6,
+ S: 6,
+ T: 6,
+ U: 6,
+ V: 6,
+ W: 6,
+ X: 6,
+ Y: 6,
+ "[": 128,
+ "\\": 1,
+ "]": 128,
+ _: 60,
+ a: 14,
+ b: 6,
+ c: 6,
+ d: 6,
+ e: 6,
+ f: 6,
+ g: 189,
+ h: 7,
+ i: 6,
+ j: 185,
+ k: 6,
+ l: 6,
+ m: 6,
+ n: 6,
+ o: 6,
+ p: 199,
+ q: 193,
+ r: 6,
+ s: 6,
+ t: 7,
+ u: 9,
+ v: 7,
+ w: 6,
+ x: 6,
+ y: 201,
+ z: 2,
+ "{": 129,
+ "|": 172,
+ "}": 129,
+ "¡": 196,
+ "£": 6,
+ "¥": 6,
+ "¦": 104,
+ "§": 6,
+ "±": 1,
+ µ: 185,
+ "¶": 85,
+ "¸": 200,
+ "¼": 5,
+ "½": 2,
+ "¾": 29,
+ "¿": 185,
+ À: 6,
+ Á: 6,
+ Â: 6,
+ Ã: 6,
+ Ä: 6,
+ Å: 6,
+ Æ: 9,
+ Ç: 206,
+ Ð: 22,
+ Ñ: 6,
+ Ò: 7,
+ Ó: 7,
+ Ô: 7,
+ Õ: 7,
+ Ö: 7,
+ Ø: 39,
+ Ù: 6,
+ Ú: 6,
+ Û: 6,
+ Ü: 6,
+ Ý: 6,
+ Þ: 6,
+ ß: 30,
+ à: 14,
+ á: 14,
+ â: 14,
+ ã: 14,
+ ä: 14,
+ å: 14,
+ æ: 13,
+ ç: 206,
+ è: 6,
+ é: 6,
+ ê: 6,
+ ë: 6,
+ ì: 6,
+ í: 6,
+ î: 6,
+ ï: 6,
+ ñ: 6,
+ ò: 6,
+ ó: 6,
+ ô: 6,
+ õ: 6,
+ ö: 6,
+ ø: 41,
+ ù: 9,
+ ú: 9,
+ û: 9,
+ ü: 9,
+ ý: 201,
+ þ: 199,
+ ÿ: 201,
+ "…": 6,
+ },
+};
diff --git a/src/render/displayName.ts b/src/render/displayName.ts
new file mode 100644
index 0000000..1cdc2b5
--- /dev/null
+++ b/src/render/displayName.ts
@@ -0,0 +1,3 @@
+export function displayName(name: string): string {
+ return name.charAt(0).toUpperCase() + name.slice(1);
+}
diff --git a/src/render/panelRenderer.ts b/src/render/panelRenderer.ts
new file mode 100644
index 0000000..c2ff06b
--- /dev/null
+++ b/src/render/panelRenderer.ts
@@ -0,0 +1,682 @@
+import type { Avatar, AvatarBody, PoseData } from "../engine/avatar.js";
+import {
+ type Arc,
+ boxBalloonTraj,
+ normalBalloonTraj,
+} from "../engine/balloonTail.js";
+import type { PanelBalloon, UnitPanel } from "../engine/panel.js";
+import { SM_THINK, SM_WHISPER } from "../engine/panel.js";
+import { BalloonRuntime } from "../engine/panelBalloon.js";
+import { BetaSpline } from "../engine/spline.js";
+import {
+ BELOW_STARRING,
+ FOOTER_MARGIN,
+ FOOTER_MIN_SCALE,
+ STAR_ICON_SIZE,
+ STAR_ICON_SPACE,
+ STARRING,
+ shoutFontCell,
+ TITLE_LEADING,
+ TITLE_UNIT,
+ type TitleCard,
+ titleFontCell,
+} from "../engine/titlePanel.js";
+import {
+ angleBetweenVecs,
+ angleToVector,
+ dpointNorm,
+ dpointScalmult,
+ dpointToPoint,
+ PI,
+ pointAdd,
+ pointMagn,
+ pointScalmult,
+ pointSub,
+ pointToDpoint,
+ round,
+ vectorToAngle,
+} from "../engine/vector2d.js";
+import type {
+ AtlasSource,
+ PanelRenderContext,
+ RenderImage,
+} from "./renderContext.js";
+import { BALLOON_FONT_SIZE, gdiCellFont } from "./textMetrics.js";
+
+const TORSOFIRST = 4;
+
+function starRowHeight(unit: number, scale: number): number {
+ return Math.max(
+ Math.round(STAR_ICON_SIZE * scale),
+ shoutFontCell(unit, BALLOON_FONT_SIZE),
+ );
+}
+
+function starRows(
+ card: TitleCard,
+ top: number,
+ height: number,
+ rowHeight: number,
+): number {
+ return Math.min(
+ card.stars.length,
+ Math.max(0, Math.trunc((height - top) / rowHeight)),
+ );
+}
+
+export interface SpriteLayer {
+ pose: PoseData;
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+}
+
+function poseByID(avatar: Avatar, poseID: number): PoseData {
+ const pose = avatar.data.poses.find(
+ (candidate) => candidate.poseID === poseID,
+ );
+ if (!pose) throw new Error(`avatar ${avatar.avatarID} has no pose ${poseID}`);
+ return pose;
+}
+
+function flipLayer(
+ layer: SpriteLayer,
+ fullLeft: number,
+ fullRight: number,
+): void {
+ const width = layer.right - layer.left;
+ layer.left = fullRight - (layer.left - fullLeft);
+ layer.right = layer.left - width;
+}
+
+export function bodySpriteLayers(
+ avatar: Avatar,
+ body: AvatarBody,
+): SpriteLayer[] {
+ const client = body.bbox;
+ const clientWidth = client.right - client.left;
+ const clientHeight = client.top - client.bottom;
+ if (body.kind === "simple") {
+ const bodyRec = avatar.data.bodies[body.bodyIndex];
+ if (!bodyRec)
+ throw new Error(
+ `avatar ${avatar.avatarID} has no body ${body.bodyIndex}`,
+ );
+ const pose = poseByID(avatar, bodyRec.poseID);
+ const widthScale = clientWidth / pose.width;
+ const heightScale = clientHeight / pose.height;
+ let fullWidth: number;
+ let fullHeight: number;
+ if (widthScale <= heightScale) {
+ fullWidth = clientWidth;
+ fullHeight = Math.trunc(widthScale * pose.height);
+ } else {
+ fullHeight = clientHeight;
+ fullWidth = Math.trunc(heightScale * pose.width);
+ }
+ const layer: SpriteLayer = {
+ pose,
+ left: client.left + Math.trunc((clientWidth - fullWidth) / 2),
+ right: 0,
+ // bodycam.cpp:594 adds the slack to top even in y-up space, floating the body above its bbox
+ top: client.top + (clientHeight - fullHeight),
+ bottom: 0,
+ };
+ layer.right = layer.left + fullWidth;
+ layer.bottom = layer.top - fullHeight;
+ if (body.flip) {
+ const oldLeft = layer.left;
+ layer.left = layer.right;
+ layer.right = oldLeft;
+ }
+ return [layer];
+ }
+
+ const face = avatar.data.faces[body.faceIndex];
+ const torso = avatar.data.torsos[body.torsoIndex];
+ if (!face || !torso)
+ throw new Error(`avatar ${avatar.avatarID} has incomplete body records`);
+ const headPose = poseByID(avatar, face.poseID);
+ const torsoPose = poseByID(avatar, torso.poseID);
+ const xOffset = torso.xCX + face.deltaXCX - face.xCX;
+ const yOffset = torso.yCX + face.deltaYCX - face.yCX;
+ const bitLeft = Math.min(0, xOffset);
+ const bitRight = Math.max(torsoPose.width, xOffset + headPose.width);
+ const bitTop = Math.min(0, yOffset);
+ const bitBottom = Math.max(torsoPose.height, yOffset + headPose.height);
+ const bitWidth = bitRight - bitLeft;
+ const bitHeight = bitBottom - bitTop;
+ const scale = Math.min(clientWidth / bitWidth, clientHeight / bitHeight);
+ const fullHeight = round(scale * bitHeight);
+ const fullWidth = round(scale * bitWidth);
+ const fullLeft = client.left + Math.trunc((clientWidth - fullWidth) / 2);
+ // bodycam.cpp:559 "centered on bottom" adds the slack upward in y-up space
+ const fullTop = client.top + (clientHeight - fullHeight);
+ const fullRight = fullLeft + fullWidth;
+ const head: SpriteLayer = {
+ pose: headPose,
+ left: round((xOffset - bitLeft) * scale) + fullLeft,
+ right: 0,
+ top: round((yOffset - bitTop) * scale) + fullTop,
+ bottom: 0,
+ };
+ head.right = head.left + round(headPose.width * scale) + 1;
+ head.bottom = head.top - (round(headPose.height * scale) + 1);
+ const torsoLayer: SpriteLayer = {
+ pose: torsoPose,
+ left: round(-bitLeft * scale) + fullLeft,
+ right: 0,
+ top: fullTop - round(-bitTop * scale),
+ bottom: 0,
+ };
+ torsoLayer.right = torsoLayer.left + round(torsoPose.width * scale) + 1;
+ torsoLayer.bottom = torsoLayer.top - (round(torsoPose.height * scale) + 1);
+ if (body.flip) {
+ flipLayer(head, fullLeft, fullRight);
+ flipLayer(torsoLayer, fullLeft, fullRight);
+ }
+ return avatar.data.flags && avatar.data.flags & TORSOFIRST
+ ? [torsoLayer, head]
+ : [head, torsoLayer];
+}
+
+function addArc(
+ context: PanelRenderContext,
+ arc: Arc,
+ ox: number,
+ oy: number,
+): void {
+ if (arc.altitude < 1 && arc.altitude > -1) {
+ context.lineTo(arc.hi.x + ox, -(arc.hi.y + oy));
+ return;
+ }
+ const mid = pointScalmult(0.5, pointAdd(arc.lo, arc.hi));
+ const endToMid = pointSub(mid, arc.hi);
+ const endToMidDist = pointMagn(endToMid);
+ const radius =
+ (endToMidDist * endToMidDist + arc.altitude * arc.altitude) /
+ (2 * arc.altitude);
+ const midToCenterDist = radius - arc.altitude;
+ const perpendicular = { x: endToMid.y, y: -endToMid.x };
+ const perpendicularMagnitude = pointMagn(perpendicular);
+ const midToCenter = pointScalmult(
+ midToCenterDist / perpendicularMagnitude,
+ perpendicular,
+ );
+ const center = pointAdd(pointAdd(arc.hi, endToMid), midToCenter);
+ let a = pointToDpoint(pointSub(arc.lo, center));
+ const finalC = pointToDpoint(pointSub(arc.hi, center));
+ const scanRadius = Math.hypot(a.x, a.y);
+ let trueAngle = angleBetweenVecs(finalC, a);
+ const ccw = arc.altitude > 0;
+ if (ccw) trueAngle = -trueAngle;
+ if (trueAngle <= 0) trueAngle += 2 * PI;
+ let nextEnd = vectorToAngle(a);
+ const direction = ccw ? 1 : -1;
+ while (true) {
+ const finalSegment = trueAngle <= PI / 2;
+ const step = finalSegment ? trueAngle : PI / 2;
+ let c = finalC;
+ if (!finalSegment) {
+ nextEnd += direction * (PI / 2);
+ const vector = angleToVector(nextEnd);
+ c = { x: vector.x * scanRadius, y: vector.y * scanRadius };
+ }
+ const s = Math.cos(step / 2);
+ const tau = (4 * s) / (3 * (s + 1));
+ const divisor = (a.x * c.y - a.y * c.x) / (scanRadius * scanRadius);
+ const b = { x: (c.y - a.y) / divisor, y: (a.x - c.x) / divisor };
+ const p1 = {
+ x: round((1 - tau) * a.x + tau * b.x) + center.x,
+ y: round((1 - tau) * a.y + tau * b.y) + center.y,
+ };
+ const p2 = {
+ x: round((1 - tau) * c.x + tau * b.x) + center.x,
+ y: round((1 - tau) * c.y + tau * b.y) + center.y,
+ };
+ const p3 = pointAdd(dpointToPoint(c), center);
+ context.bezierCurveTo(
+ p1.x + ox,
+ -(p1.y + oy),
+ p2.x + ox,
+ -(p2.y + oy),
+ p3.x + ox,
+ -(p3.y + oy),
+ );
+ if (finalSegment) break;
+ a = c;
+ trueAngle -= PI / 2;
+ }
+}
+
+function addBezierPath(
+ context: PanelRenderContext,
+ bezier: readonly { x: number; y: number }[],
+ ox: number,
+ oy: number,
+): boolean {
+ const first = bezier[0];
+ if (!first) return false;
+ context.moveTo(first.x + ox, -(first.y + oy));
+ for (let i = 1; i + 2 < bezier.length; i += 3) {
+ const p1 = bezier[i];
+ const p2 = bezier[i + 1];
+ const p3 = bezier[i + 2];
+ if (!p1 || !p2 || !p3) continue;
+ context.bezierCurveTo(
+ p1.x + ox,
+ -(p1.y + oy),
+ p2.x + ox,
+ -(p2.y + oy),
+ p3.x + ox,
+ -(p3.y + oy),
+ );
+ }
+ return true;
+}
+
+export function drawSpriteLayer(
+ context: PanelRenderContext,
+ assets: AtlasSource,
+ layer: SpriteLayer,
+): void {
+ if (!layer.pose.sprite)
+ throw new Error(`pose ${layer.pose.poseID} has no sprite`);
+ const image = assets.get(layer.pose);
+ const x = Math.min(layer.left, layer.right);
+ const y = -Math.max(layer.top, layer.bottom);
+ const width = Math.abs(layer.right - layer.left);
+ const height = Math.abs(layer.top - layer.bottom);
+ context.save();
+ if (layer.left > layer.right) {
+ context.translate(2 * x + width, 0);
+ context.scale(-1, 1);
+ }
+ context.drawImage(
+ image,
+ layer.pose.sprite.x,
+ layer.pose.sprite.y,
+ layer.pose.width,
+ layer.pose.height,
+ x,
+ y,
+ width,
+ height,
+ );
+ context.restore();
+}
+
+export interface CanvasPanelRendererOptions {
+ unitWidth: number;
+ unitHeight: number;
+ background?: string;
+ foreground?: string;
+ normalFont?: string;
+ whisperFont?: string;
+ resolveBackdrop?: (name: string) => RenderImage | undefined;
+}
+
+export class CanvasPanelRenderer {
+ private normalFontCache?: string;
+ private whisperFontCache?: string;
+ private titleFontCache?: string;
+ private shoutFontCache?: string;
+
+ constructor(
+ readonly context: PanelRenderContext,
+ readonly assets: AtlasSource,
+ readonly avatars: readonly Avatar[],
+ readonly options: CanvasPanelRendererOptions,
+ ) {}
+
+ private get normalFont(): string {
+ this.normalFontCache ??=
+ this.options.normalFont ??
+ gdiCellFont(this.context, "400", BALLOON_FONT_SIZE);
+ return this.normalFontCache;
+ }
+
+ private get whisperFont(): string {
+ this.whisperFontCache ??=
+ this.options.whisperFont ??
+ gdiCellFont(this.context, "italic 400", BALLOON_FONT_SIZE);
+ return this.whisperFontCache;
+ }
+
+ private drawBody(body: AvatarBody): void {
+ const avatar = this.avatars.find(
+ (candidate) => candidate.avatarID === body.avatarID,
+ );
+ if (!avatar) throw new Error(`missing avatar ${body.avatarID}`);
+ for (const layer of bodySpriteLayers(avatar, body))
+ drawSpriteLayer(this.context, this.assets, layer);
+ }
+
+ private balloonPath(balloon: PanelBalloon, runtime: BalloonRuntime): void {
+ if (!runtime.fInfo) return;
+ const ox = runtime.bbox.left;
+ const oy = runtime.bbox.top;
+ this.context.beginPath();
+ if (runtime.isBox) {
+ const lines = boxBalloonTraj(runtime.fInfo.fbox);
+ const first = lines[0]?.lo;
+ if (!first) return;
+ this.context.moveTo(first.x + ox, -(first.y + oy));
+ for (const line of lines)
+ this.context.lineTo(line.hi.x + ox, -(line.hi.y + oy));
+ this.context.closePath();
+ return;
+ }
+ if (balloon.mode === SM_THINK && runtime.spline) {
+ const cloud = new BetaSpline(
+ runtime.spline.cps,
+ runtime.spline.cps.length,
+ true,
+ );
+ addBezierPath(this.context, cloud.bezpts ?? [], ox, oy);
+ this.context.closePath();
+ return;
+ }
+ const cloud = runtime.cloudBBox();
+ const traj = normalBalloonTraj(runtime.fInfo, runtime.style.font, {
+ speakerArrowX: balloon.speaker.arrowX,
+ speakerTop: balloon.speaker.bbox.top,
+ balloonLeft: runtime.bbox.left,
+ balloonTop: runtime.bbox.top,
+ routeLeft: runtime.routeRgn.left,
+ routeRight: runtime.routeRgn.right,
+ cloudBottom: cloud.bottom,
+ });
+ const bezier = traj.spline.bezpts ?? [];
+ if (!addBezierPath(this.context, bezier, ox, oy)) return;
+ for (const arc of traj.arcs) addArc(this.context, arc, ox, oy);
+ this.context.closePath();
+ }
+
+ private drawThinkBubbles(
+ balloon: PanelBalloon,
+ runtime: BalloonRuntime,
+ ): void {
+ if (balloon.mode !== SM_THINK || !runtime.fInfo) return;
+ const entry = {
+ x: Math.trunc((runtime.routeRgn.left + runtime.routeRgn.right) / 2),
+ y: runtime.fInfo.fbox.bottom + runtime.bbox.top,
+ };
+ const tail = {
+ x: balloon.speaker.arrowX,
+ y: balloon.speaker.bbox.top + 200,
+ };
+ const deltaY = entry.y - tail.y;
+ if (deltaY < 0) return;
+ const count = Math.trunc((deltaY + 100) / 250);
+ if (count <= 0) return;
+ const spacing =
+ count > 1 ? Math.trunc((deltaY - 150 * count) / (count - 1)) : 0;
+ const direction = dpointNorm({ x: entry.x - tail.x, y: entry.y - tail.y });
+ let center = pointAdd(tail, dpointToPoint(dpointScalmult(75, direction)));
+ const increment = dpointToPoint(dpointScalmult(150 + spacing, direction));
+ const widthDelta =
+ count > 1 ? Math.trunc((400 - 150) / (2 * (count - 1))) : 0;
+ let widthAdjustment = 0;
+ for (let i = 0; i < count; i++) {
+ this.context.beginPath();
+ this.context.ellipse(
+ center.x,
+ -center.y,
+ 75 + widthAdjustment,
+ 75,
+ 0,
+ 0,
+ 2 * Math.PI,
+ );
+ this.context.fill();
+ this.context.stroke();
+ center = pointAdd(center, increment);
+ widthAdjustment += widthDelta;
+ }
+ }
+
+ private drawBalloon(balloon: PanelBalloon): void {
+ if (!(balloon.runtime instanceof BalloonRuntime)) return;
+ const runtime = balloon.runtime;
+ const fInfo = runtime.fInfo;
+ if (!fInfo) return;
+ this.context.save();
+ this.context.fillStyle = this.options.background ?? "#fff";
+ this.context.strokeStyle = this.options.foreground ?? "#000";
+ this.context.lineJoin = "round";
+ this.context.lineCap = "round";
+ this.balloonPath(balloon, runtime);
+ if (balloon.mode === SM_WHISPER) {
+ this.context.lineWidth = 100;
+ this.context.strokeStyle = this.options.background ?? "#fff";
+ this.context.fill();
+ this.context.stroke();
+ this.context.strokeStyle = this.options.foreground ?? "#000";
+ this.context.lineWidth = 28;
+ this.context.setLineDash([100, 100]);
+ this.context.stroke();
+ this.context.setLineDash([]);
+ } else {
+ this.context.lineWidth = 28;
+ this.context.fill();
+ this.context.stroke();
+ }
+ this.drawThinkBubbles(balloon, runtime);
+
+ this.context.fillStyle = this.options.foreground ?? "#000";
+ this.context.font =
+ balloon.mode === SM_WHISPER ? this.whisperFont : this.normalFont;
+ this.context.textBaseline = "top";
+ for (let i = 0; i < fInfo.nLines; i++) {
+ const line = fInfo.lines[i];
+ if (!line) continue;
+ this.context.fillText(
+ balloon.text.slice(line.start, line.start + line.length),
+ runtime.bbox.left + (fInfo.leftX[i] ?? 0),
+ -(runtime.bbox.top - i * runtime.style.font.lineHeight),
+ );
+ }
+ this.context.restore();
+ }
+
+ private wrapTitle(text: string, maxWidth: number): string[] {
+ const words = text.split(/\s+/).filter((word) => word.length > 0);
+ const lines: string[] = [];
+ let line = "";
+ for (const word of words) {
+ const candidate = line === "" ? word : `${line} ${word}`;
+ if (line !== "" && this.context.measureText(candidate).width > maxWidth) {
+ lines.push(line);
+ line = word;
+ } else line = candidate;
+ }
+ if (line !== "") lines.push(line);
+ return lines;
+ }
+
+ private drawStarIcon(
+ avatarID: number,
+ left: number,
+ top: number,
+ size: number,
+ ): void {
+ const avatar = this.avatars.find(
+ (candidate) => candidate.avatarID === avatarID,
+ );
+ if (!avatar) return;
+ const pose = avatar.data.poses.find(
+ (candidate) => candidate.poseID === avatar.data.iconPoseID,
+ );
+ if (!pose?.sprite) return;
+ const scale = Math.min(size / pose.width, size / pose.height);
+ const width = Math.round(pose.width * scale);
+ const height = Math.round(pose.height * scale);
+ this.context.drawImage(
+ this.assets.get(pose),
+ pose.sprite.x,
+ pose.sprite.y,
+ pose.width,
+ pose.height,
+ left + Math.trunc((size - width) / 2),
+ top + Math.trunc((size - height) / 2),
+ width,
+ height,
+ );
+ }
+
+ // AddStars (panel.cpp:1391-1446): icon+name rows, centered as a block on the widest name
+ private drawStars(card: TitleCard, top: number): void {
+ const context = this.context;
+ const unit = this.options.unitWidth;
+ const scale = unit / TITLE_UNIT;
+ const iconSize = Math.round(STAR_ICON_SIZE * scale);
+ const iconSpace = Math.round(STAR_ICON_SPACE * scale);
+ const shoutCell = shoutFontCell(unit, BALLOON_FONT_SIZE);
+ const rowHeight = starRowHeight(unit, scale);
+ const stars = card.stars.slice(
+ 0,
+ starRows(card, top, this.options.unitHeight, rowHeight),
+ );
+ if (stars.length === 0) return;
+ let maxWidth = 0;
+ for (const star of stars)
+ maxWidth = Math.max(maxWidth, context.measureText(star.label).width);
+ const blockWidth = maxWidth + iconSize + iconSpace;
+ const iconOffset = Math.max(0, Math.trunc((unit - blockWidth) / 2));
+ const textOffset = iconOffset + iconSize + iconSpace;
+ const iconVdisp = Math.trunc((rowHeight - iconSize) / 2);
+ const textVdisp = Math.trunc((rowHeight - shoutCell) / 2);
+ let y = top;
+ for (const star of stars) {
+ this.drawStarIcon(star.avatarID, iconOffset, y + iconVdisp, iconSize);
+ context.fillText(star.label, textOffset, y + textVdisp);
+ y += rowHeight;
+ }
+ }
+
+ // how far a line's ink hangs below its baseline
+ private descender(text: string): number {
+ const metrics = this.context.measureText(text);
+ return Math.max(
+ 0,
+ (metrics.actualBoundingBoxDescent ?? 0) -
+ (metrics.fontBoundingBoxAscent ?? 0),
+ );
+ }
+
+ // AddTitle (panel.cpp:1279-1297): title, STARRING beneath, then the cast
+ private drawTitle(card: TitleCard): void {
+ const context = this.context;
+ const unit = this.options.unitWidth;
+ const scale = unit / TITLE_UNIT;
+ this.titleFontCache ??= gdiCellFont(
+ context,
+ "400",
+ titleFontCell(unit, BALLOON_FONT_SIZE),
+ );
+ this.shoutFontCache ??= gdiCellFont(
+ context,
+ "400",
+ shoutFontCell(unit, BALLOON_FONT_SIZE),
+ );
+ context.save();
+ context.fillStyle = this.options.foreground ?? "#000";
+ context.textBaseline = "top";
+ context.textAlign = "center";
+ context.font = this.titleFontCache;
+ let y = Math.round(100 * scale);
+ const lineHeight =
+ titleFontCell(unit, BALLOON_FONT_SIZE) +
+ Math.round(TITLE_LEADING * scale);
+ for (const line of this.wrapTitle(card.text, unit)) {
+ context.fillText(line, unit / 2, y);
+ y += lineHeight + this.descender(line);
+ }
+ context.font = this.shoutFontCache;
+ const starring = card.starring?.trim() ? card.starring : STARRING;
+ context.fillText(starring, unit / 2, y);
+ const shoutCell = shoutFontCell(unit, BALLOON_FONT_SIZE);
+ y +=
+ shoutCell + Math.round(BELOW_STARRING * scale) + this.descender(starring);
+ const footer = card.footer?.trim();
+ context.textAlign = "left";
+ this.drawStars(card, y);
+ if (footer) this.drawFooter(footer, card, y, scale, shoutCell);
+ context.restore();
+ }
+
+ // the cast keeps every row it would draw without a footer; the line takes what is left under it
+ private drawFooter(
+ footer: string,
+ card: TitleCard,
+ starsTop: number,
+ scale: number,
+ shoutCell: number,
+ ): void {
+ const context = this.context;
+ const unit = this.options.unitWidth;
+ const rowHeight = starRowHeight(unit, scale);
+ const rows = starRows(card, starsTop, this.options.unitHeight, rowHeight);
+ const drop = this.descender(footer);
+ const free = this.options.unitHeight - (starsTop + rows * rowHeight) - drop;
+ const floor = Math.round(shoutCell * FOOTER_MIN_SCALE);
+ if (free < floor) return;
+ let margin = Math.round(FOOTER_MARGIN * scale);
+ let cell = Math.min(shoutCell, free - margin * 2);
+ if (cell < floor) {
+ cell = floor;
+ margin = Math.trunc((free - floor) / 2);
+ }
+ let font = gdiCellFont(context, "400", cell);
+ context.font = font;
+ const maxWidth = unit - margin * 2;
+ const width = context.measureText(footer).width;
+ if (width > maxWidth) {
+ cell = Math.round((cell * maxWidth) / width);
+ font = gdiCellFont(context, "400", cell);
+ }
+ context.textAlign = "center";
+ context.font = font;
+ context.fillText(
+ footer,
+ unit / 2,
+ this.options.unitHeight - margin - cell - drop,
+ );
+ }
+
+ render(panel: UnitPanel): void {
+ const context = this.context;
+ context.save();
+ context.fillStyle = this.options.background ?? "#fff";
+ context.fillRect(0, 0, this.options.unitWidth, this.options.unitHeight);
+ context.imageSmoothingEnabled = true;
+ context.imageSmoothingQuality = "high";
+ // CBackDrop::Draw StretchBlts the DIB across the whole panel rect (backdrop.cpp:330)
+ const backdrop = panel.backdrop
+ ? this.options.resolveBackdrop?.(panel.backdrop)
+ : undefined;
+ if (backdrop)
+ context.drawImage(
+ backdrop,
+ 0,
+ 0,
+ this.options.unitWidth,
+ this.options.unitHeight,
+ );
+ if (panel.title) this.drawTitle(panel.title);
+ for (const body of panel.bodies) this.drawBody(body);
+ for (let i = panel.balloons.length - 1; i >= 0; i--) {
+ const balloon = panel.balloons[i];
+ if (balloon) this.drawBalloon(balloon);
+ }
+ if (panel.hasBorder) {
+ context.strokeStyle = this.options.foreground ?? "#000";
+ // panel.cpp:1252 strokes a 2*60-wide pen on the panel edge path; the outer half clips away
+ context.lineWidth = 120;
+ context.strokeRect(0, 0, this.options.unitWidth, this.options.unitHeight);
+ }
+ context.restore();
+ }
+}
diff --git a/src/render/renderContext.ts b/src/render/renderContext.ts
new file mode 100644
index 0000000..4234829
--- /dev/null
+++ b/src/render/renderContext.ts
@@ -0,0 +1,90 @@
+// The slice of Canvas 2D the panel painters touch, so an SVG recorder can stand in for a real context.
+
+import type { PoseData } from "../engine/avatar.js";
+
+// opaque to the renderer: a decoded bitmap in the browser, an href record in the SVG recorder
+export type RenderImage = object;
+
+export interface AtlasSource {
+ get(pose: PoseData): RenderImage;
+}
+
+export interface PanelRenderMetrics {
+ width: number;
+ fontBoundingBoxAscent?: number;
+ fontBoundingBoxDescent?: number;
+ actualBoundingBoxDescent?: number;
+}
+
+export interface PanelRenderContext {
+ fillStyle: string | object;
+ strokeStyle: string | object;
+ lineWidth: number;
+ lineJoin: string;
+ lineCap: string;
+ font: string;
+ textAlign: string;
+ textBaseline: string;
+ imageSmoothingEnabled: boolean;
+ imageSmoothingQuality: string;
+ save(): void;
+ restore(): void;
+ translate(x: number, y: number): void;
+ scale(x: number, y: number): void;
+ setTransform(
+ a: number,
+ b: number,
+ c: number,
+ d: number,
+ e: number,
+ f: number,
+ ): void;
+ beginPath(): void;
+ moveTo(x: number, y: number): void;
+ lineTo(x: number, y: number): void;
+ bezierCurveTo(
+ cp1x: number,
+ cp1y: number,
+ cp2x: number,
+ cp2y: number,
+ x: number,
+ y: number,
+ ): void;
+ ellipse(
+ x: number,
+ y: number,
+ radiusX: number,
+ radiusY: number,
+ rotation: number,
+ startAngle: number,
+ endAngle: number,
+ ): void;
+ rect(x: number, y: number, w: number, h: number): void;
+ closePath(): void;
+ clip(): void;
+ fill(): void;
+ stroke(): void;
+ setLineDash(segments: number[]): void;
+ fillRect(x: number, y: number, w: number, h: number): void;
+ strokeRect(x: number, y: number, w: number, h: number): void;
+ fillText(text: string, x: number, y: number): void;
+ measureText(text: string): PanelRenderMetrics;
+ drawImage(
+ image: RenderImage,
+ dx: number,
+ dy: number,
+ dw: number,
+ dh: number,
+ ): void;
+ drawImage(
+ image: RenderImage,
+ sx: number,
+ sy: number,
+ sw: number,
+ sh: number,
+ dx: number,
+ dy: number,
+ dw: number,
+ dh: number,
+ ): void;
+}
diff --git a/src/render/stripMark.ts b/src/render/stripMark.ts
new file mode 100644
index 0000000..55b3281
--- /dev/null
+++ b/src/render/stripMark.ts
@@ -0,0 +1,88 @@
+// The credit line every exported sheet carries, drawn into the band under the last panel row.
+
+import type { PanelRenderContext } from "./renderContext.js";
+
+const MARK_COLOR = "#222";
+const MARK_FAMILY = '"Segoe UI", system-ui, sans-serif';
+// a one-column sheet is the reference; wider sheets scale by the square root, enough to stay legible without taking over
+const MARK_BASE_WIDTH = 700;
+const MARK_FONT_PX = 12;
+const MARK_MIN_FONT_PX = 9;
+
+// the host comes off the page, so a fork's exports name the fork
+export function stripMarkParts(host: string): { left: string; right: string } {
+ return {
+ left: "Comic Chat 1996 port · original character design by Jim Woodring",
+ right: host ? `created at ${host}` : "",
+ };
+}
+
+function markFont(width: number): number {
+ const scale = Math.max(1, Math.sqrt(width / MARK_BASE_WIDTH));
+ return Math.round(MARK_FONT_PX * scale);
+}
+
+// both ends share one line, so step down until four ems of daylight separate them
+function fitFont(
+ context: PanelRenderContext,
+ left: string,
+ right: string,
+ width: number,
+ gap: number,
+ start: number = gap,
+): number {
+ const room = width - start - gap;
+ let px = markFont(width);
+ while (px > MARK_MIN_FONT_PX) {
+ context.font = `${px}px ${MARK_FAMILY}`;
+ const used =
+ context.measureText(left).width + context.measureText(right).width;
+ if (used + px * 4 <= room) break;
+ px -= 1;
+ }
+ return px;
+}
+
+// the band gives the type twice its own height; a small sheet already has that much gutter
+function markBand(width: number, gap: number): number {
+ return Math.max(gap, markFont(width) * 2);
+}
+
+// extra sheet height the band needs beyond the gutter under the last row
+export function stripMarkGrowth(width: number, gap: number): number {
+ return markBand(width, gap) - gap;
+}
+
+// one baseline, each end set flush against its margin; start lets the credit skip a borderless lead panel
+export function drawMarkLine(
+ context: PanelRenderContext,
+ width: number,
+ margin: number,
+ y: number,
+ host: string,
+ start: number = margin,
+): void {
+ const { left, right } = stripMarkParts(host);
+ context.save();
+ context.font = `${fitFont(context, left, right, width, margin, start)}px ${MARK_FAMILY}`;
+ context.fillStyle = MARK_COLOR;
+ context.textBaseline = "middle";
+ context.textAlign = "left";
+ context.fillText(left, start, y);
+ context.textAlign = "right";
+ context.fillText(right, width - margin, y);
+ context.restore();
+}
+
+export function drawStripMark(
+ context: PanelRenderContext,
+ width: number,
+ height: number,
+ gap: number,
+ host: string,
+ start: number = gap,
+): void {
+ // whole pixels, so "middle" never lands the glyphs on a subpixel boundary
+ const y = height - Math.round(markBand(width, gap) / 2);
+ drawMarkLine(context, width, gap, y, host, start);
+}
diff --git a/src/render/svgContext.ts b/src/render/svgContext.ts
new file mode 100644
index 0000000..9c59027
--- /dev/null
+++ b/src/render/svgContext.ts
@@ -0,0 +1,395 @@
+// Records the panel painters' canvas calls as SVG elements; transforms here are always scale plus translate, never rotation.
+
+import { COMIC_NEUE_REGULAR } from "./comicNeueMetrics.js";
+import type {
+ PanelRenderContext,
+ PanelRenderMetrics,
+} from "./renderContext.js";
+import { parseFontPx, TableMeasureContext } from "./textTable.js";
+
+export interface SvgImage {
+ href: string;
+ width: number;
+ height: number;
+}
+
+interface Matrix {
+ sx: number;
+ sy: number;
+ tx: number;
+ ty: number;
+}
+
+interface DrawState {
+ fillStyle: string;
+ strokeStyle: string;
+ lineWidth: number;
+ lineJoin: string;
+ lineCap: string;
+ font: string;
+ textAlign: string;
+ textBaseline: string;
+ dash: number[];
+}
+
+interface Frame {
+ matrix: Matrix;
+ state: DrawState;
+ openGroups: number;
+}
+
+function fmt(value: number): string {
+ const rounded = Math.round(value * 100) / 100;
+ return String(Object.is(rounded, -0) ? 0 : rounded);
+}
+
+function escapeXml(text: string): string {
+ return text
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """);
+}
+
+// balloon text was measured as Comic Neue, so the embedded face leads and local Comic Sans stays a fallback
+function fontFamily(font: string): string {
+ return font.includes("Comic")
+ ? "'Comic Neue', 'Comic Sans MS', cursive"
+ : "'Segoe UI', system-ui, sans-serif";
+}
+
+export class SvgRenderContext implements PanelRenderContext {
+ imageSmoothingEnabled = true;
+ imageSmoothingQuality = "high";
+ private matrix: Matrix = { sx: 1, sy: 1, tx: 0, ty: 0 };
+ private state: DrawState = {
+ fillStyle: "#000",
+ strokeStyle: "#000",
+ lineWidth: 1,
+ lineJoin: "miter",
+ lineCap: "butt",
+ font: "10px sans-serif",
+ textAlign: "left",
+ textBaseline: "alphabetic",
+ dash: [],
+ };
+ private readonly stack: Frame[] = [];
+ private path: string[] = [];
+ private pathRects: { x: number; y: number; w: number; h: number }[] = [];
+ private pathIsRects = true;
+ private openGroups = 0;
+ private clipCount = 0;
+ private readonly defs: string[] = [];
+ private readonly body: string[] = [];
+ private readonly measurer = new TableMeasureContext();
+
+ get fillStyle(): string {
+ return this.state.fillStyle;
+ }
+ set fillStyle(value: string | object) {
+ this.state.fillStyle = String(value);
+ }
+ get strokeStyle(): string {
+ return this.state.strokeStyle;
+ }
+ set strokeStyle(value: string | object) {
+ this.state.strokeStyle = String(value);
+ }
+ get lineWidth(): number {
+ return this.state.lineWidth;
+ }
+ set lineWidth(value: number) {
+ this.state.lineWidth = value;
+ }
+ get lineJoin(): string {
+ return this.state.lineJoin;
+ }
+ set lineJoin(value: string) {
+ this.state.lineJoin = value;
+ }
+ get lineCap(): string {
+ return this.state.lineCap;
+ }
+ set lineCap(value: string) {
+ this.state.lineCap = value;
+ }
+ get font(): string {
+ return this.state.font;
+ }
+ set font(value: string) {
+ this.state.font = value;
+ }
+ get textAlign(): string {
+ return this.state.textAlign;
+ }
+ set textAlign(value: string) {
+ this.state.textAlign = value;
+ }
+ get textBaseline(): string {
+ return this.state.textBaseline;
+ }
+ set textBaseline(value: string) {
+ this.state.textBaseline = value;
+ }
+
+ save(): void {
+ this.stack.push({
+ matrix: { ...this.matrix },
+ state: { ...this.state, dash: [...this.state.dash] },
+ openGroups: this.openGroups,
+ });
+ }
+
+ restore(): void {
+ const frame = this.stack.pop();
+ if (!frame) return;
+ while (this.openGroups > frame.openGroups) {
+ this.body.push("");
+ this.openGroups--;
+ }
+ this.matrix = frame.matrix;
+ this.state = frame.state;
+ }
+
+ translate(x: number, y: number): void {
+ this.matrix.tx += this.matrix.sx * x;
+ this.matrix.ty += this.matrix.sy * y;
+ }
+
+ scale(x: number, y: number): void {
+ this.matrix.sx *= x;
+ this.matrix.sy *= y;
+ }
+
+ setTransform(
+ a: number,
+ b: number,
+ c: number,
+ d: number,
+ e: number,
+ f: number,
+ ): void {
+ if (b !== 0 || c !== 0)
+ throw new Error("SvgRenderContext does not support rotation or skew");
+ this.matrix = { sx: a, sy: d, tx: e, ty: f };
+ }
+
+ private mapX(x: number): number {
+ return this.matrix.sx * x + this.matrix.tx;
+ }
+
+ private mapY(y: number): number {
+ return this.matrix.sy * y + this.matrix.ty;
+ }
+
+ beginPath(): void {
+ this.path = [];
+ this.pathRects = [];
+ this.pathIsRects = true;
+ }
+
+ moveTo(x: number, y: number): void {
+ this.path.push(`M${fmt(this.mapX(x))} ${fmt(this.mapY(y))}`);
+ this.pathIsRects = false;
+ }
+
+ lineTo(x: number, y: number): void {
+ this.path.push(`L${fmt(this.mapX(x))} ${fmt(this.mapY(y))}`);
+ this.pathIsRects = false;
+ }
+
+ bezierCurveTo(
+ cp1x: number,
+ cp1y: number,
+ cp2x: number,
+ cp2y: number,
+ x: number,
+ y: number,
+ ): void {
+ this.path.push(
+ `C${fmt(this.mapX(cp1x))} ${fmt(this.mapY(cp1y))} ${fmt(this.mapX(cp2x))} ${fmt(this.mapY(cp2y))} ${fmt(this.mapX(x))} ${fmt(this.mapY(y))}`,
+ );
+ this.pathIsRects = false;
+ }
+
+ ellipse(
+ x: number,
+ y: number,
+ radiusX: number,
+ radiusY: number,
+ _rotation: number,
+ _startAngle: number,
+ _endAngle: number,
+ ): void {
+ const cx = this.mapX(x);
+ const cy = this.mapY(y);
+ const rx = Math.abs(this.matrix.sx * radiusX);
+ const ry = Math.abs(this.matrix.sy * radiusY);
+ this.path.push(
+ `M${fmt(cx + rx)} ${fmt(cy)}`,
+ `A${fmt(rx)} ${fmt(ry)} 0 1 0 ${fmt(cx - rx)} ${fmt(cy)}`,
+ `A${fmt(rx)} ${fmt(ry)} 0 1 0 ${fmt(cx + rx)} ${fmt(cy)}`,
+ "Z",
+ );
+ this.pathIsRects = false;
+ }
+
+ rect(x: number, y: number, w: number, h: number): void {
+ const left = this.mapX(x);
+ const top = this.mapY(y);
+ const width = this.matrix.sx * w;
+ const height = this.matrix.sy * h;
+ this.path.push(
+ `M${fmt(left)} ${fmt(top)}`,
+ `L${fmt(left + width)} ${fmt(top)}`,
+ `L${fmt(left + width)} ${fmt(top + height)}`,
+ `L${fmt(left)} ${fmt(top + height)}`,
+ "Z",
+ );
+ this.pathRects.push({ x: left, y: top, w: width, h: height });
+ }
+
+ closePath(): void {
+ this.path.push("Z");
+ }
+
+ clip(): void {
+ if (!this.pathIsRects || this.pathRects.length === 0)
+ throw new Error("SvgRenderContext clips rectangular paths only");
+ const id = `clip${this.clipCount++}`;
+ const rects = this.pathRects
+ .map(
+ (r) =>
+ ``,
+ )
+ .join("");
+ this.defs.push(`${rects}`);
+ this.body.push(``);
+ this.openGroups++;
+ }
+
+ private strokeAttrs(): string {
+ const scale = Math.abs(this.matrix.sy);
+ const parts = [
+ `stroke="${this.state.strokeStyle}"`,
+ `stroke-width="${fmt(this.state.lineWidth * scale)}"`,
+ ];
+ if (this.state.lineJoin !== "miter")
+ parts.push(`stroke-linejoin="${this.state.lineJoin}"`);
+ if (this.state.lineCap !== "butt")
+ parts.push(`stroke-linecap="${this.state.lineCap}"`);
+ if (this.state.dash.length > 0)
+ parts.push(
+ `stroke-dasharray="${this.state.dash.map((d) => fmt(d * scale)).join(" ")}"`,
+ );
+ return parts.join(" ");
+ }
+
+ fill(): void {
+ if (this.path.length === 0) return;
+ this.body.push(
+ ``,
+ );
+ }
+
+ stroke(): void {
+ if (this.path.length === 0) return;
+ this.body.push(
+ ``,
+ );
+ }
+
+ setLineDash(segments: number[]): void {
+ this.state.dash = [...segments];
+ }
+
+ fillRect(x: number, y: number, w: number, h: number): void {
+ this.body.push(
+ ``,
+ );
+ }
+
+ strokeRect(x: number, y: number, w: number, h: number): void {
+ this.body.push(
+ ``,
+ );
+ }
+
+ fillText(text: string, x: number, y: number): void {
+ if (text === "") return;
+ const size = parseFontPx(this.state.font) * Math.abs(this.matrix.sy);
+ const { ascent, descent, unitsPerEm } = COMIC_NEUE_REGULAR;
+ let baseline = this.mapY(y);
+ if (this.state.textBaseline === "top")
+ baseline += (ascent / unitsPerEm) * size;
+ else if (this.state.textBaseline === "middle")
+ baseline += ((ascent - descent) / 2 / unitsPerEm) * size;
+ const anchor =
+ this.state.textAlign === "center"
+ ? "middle"
+ : this.state.textAlign === "right"
+ ? "end"
+ : "start";
+ const style = this.state.font.includes("italic")
+ ? ' font-style="italic"'
+ : "";
+ this.body.push(
+ `${escapeXml(text)}`,
+ );
+ }
+
+ measureText(text: string): PanelRenderMetrics {
+ this.measurer.font = this.state.font;
+ if ("textBaseline" in this.measurer)
+ this.measurer.textBaseline = this.state.textBaseline;
+ return this.measurer.measureText(text);
+ }
+
+ drawImage(image: SvgImage, ...rest: number[]): void {
+ const { sx, sy, tx, ty } = this.matrix;
+ const transform =
+ sx === 1 && sy === 1 && tx === 0 && ty === 0
+ ? ""
+ : ` transform="matrix(${fmt(sx)} 0 0 ${fmt(sy)} ${fmt(tx)} ${fmt(ty)})"`;
+ if (rest.length === 4) {
+ const [dx, dy, dw, dh] = rest as [number, number, number, number];
+ this.body.push(
+ ``,
+ );
+ return;
+ }
+ const [srcX, srcY, srcW, srcH, dx, dy, dw, dh] = rest as [
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ ];
+ const scaleX = dw / srcW;
+ const scaleY = dh / srcH;
+ const id = `clip${this.clipCount++}`;
+ this.defs.push(
+ ``,
+ );
+ this.body.push(
+ ``,
+ );
+ }
+
+ svg(width: number, height: number, extraDefs = ""): string {
+ while (this.openGroups > 0) {
+ this.body.push("");
+ this.openGroups--;
+ }
+ const defs = this.defs.join("") + extraDefs;
+ return [
+ `",
+ ].join("");
+ }
+}
diff --git a/src/render/svgSheet.ts b/src/render/svgSheet.ts
new file mode 100644
index 0000000..12e68ae
--- /dev/null
+++ b/src/render/svgSheet.ts
@@ -0,0 +1,89 @@
+// Lays the composed panels onto one SVG sheet, mirroring the studio's PNG export geometry.
+
+import {
+ type AvatarData,
+ AvatarRegistry,
+ type PoseData,
+} from "../engine/avatar.js";
+import type { ComposeResult } from "../studio/compose.js";
+import { CanvasPanelRenderer } from "./panelRenderer.js";
+import { drawStripMark, stripMarkGrowth } from "./stripMark.js";
+import { type SvgImage, SvgRenderContext } from "./svgContext.js";
+
+const CARD_PIXELS = 600;
+const EXPORT_GAP = 31;
+
+export interface SvgSheetAssets {
+ atlas(url: string): SvgImage;
+ backdrop(name: string): SvgImage | undefined;
+}
+
+export interface SvgSheetOptions {
+ avatars: readonly AvatarData[];
+ columns: number;
+ host: string;
+ assets: SvgSheetAssets;
+ // a ",
+ });
+}
+
+describe("renderSheetSvg", () => {
+ it("lays two panels on one sheet with balloons, backdrop, and the credit", () => {
+ const svg = render({
+ version: 2,
+ panels: [
+ {
+ background: "volcano",
+ actors: [{ avatar: "anna", text: "Hello there" }],
+ },
+ { actors: [{ avatar: "bolo", text: "Hi", mode: "whisper" }] },
+ ],
+ });
+ expect(svg.startsWith('