Skip to content

Commit 91c6c85

Browse files
seanghayclaude
andcommitted
examples/live-editor: default font, crisp preview, TTF fonts
Fixes found running the migrated editor: - No fonts registered: CanvasKit has no system fonts, so the first render (the default code) threw. The worker now auto-downloads a default sans (Inter variable TTF from jsDelivr's google/fonts mirror, CORS-enabled) and awaits it before rendering. Registered first, it also backs the generic `sans-serif`. - Blurry preview: the preview stretches the canvas to fill the pane, so it needs render headroom. Render at devicePixelRatio + 1 (matching the old editor) instead of exactly devicePixelRatio; fix the size label accordingly. - Font picker didn't render: this skia build parses TTF/OTF/WOFF but not woff2, and fontsource serves variable fonts as woff2. Switch the picker to static TTF (latin regular + bold, other subsets regular); the CSS <link> still drives the editor's own HTML rendering. Verified TTF fetch + parse against Skia. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5d92619 commit 91c6c85

3 files changed

Lines changed: 54 additions & 43 deletions

File tree

examples/live-editor/src/components/Preview.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Loader2, ZoomIn, ZoomOut } from "lucide-react";
22
import { useEffect, useRef, useState } from "react";
33

4-
// The worker renders at device density, so divide displayed pixels back to
5-
// logical CSS pixels for the size label.
6-
const DPR = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
4+
// The worker renders at (devicePixelRatio + 1) — see render-worker.ts — so
5+
// divide the bitmap's pixels back to logical CSS pixels for the size label.
6+
const RENDER_DENSITY =
7+
(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1) + 1;
78

89
interface PreviewProps {
910
canvas: HTMLCanvasElement | null;
@@ -31,8 +32,8 @@ export function Preview({ canvas, isRunning, borderless = false }: PreviewProps)
3132
<span className="font-medium text-neutral-700">Preview</span>
3233
{canvas && (
3334
<span className="text-neutral-400">
34-
{Math.round(canvas.width / DPR)} ×{" "}
35-
{Math.round(canvas.height / DPR)} px
35+
{Math.round(canvas.width / RENDER_DENSITY)} ×{" "}
36+
{Math.round(canvas.height / RENDER_DENSITY)} px
3637
</span>
3738
)}
3839
<div className="ml-auto flex items-center gap-1">

examples/live-editor/src/fonts.ts

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ type LoadedFontRecord =
3737
| { id: string; name: string; kind: "cdn"; link: HTMLLinkElement }
3838
| { id: string; name: string; kind: "custom" };
3939

40-
/** Static font URL: {id}@{version}/{subset}-{weight}-{style}.woff2 */
40+
/**
41+
* Static font URL as TTF: {id}@{version}/{subset}-{weight}-{style}.ttf
42+
*
43+
* TTF, not woff2: sone's WASM backend parses TTF/OTF/WOFF but not woff2, and
44+
* fontsource only publishes its variable fonts as woff2.
45+
*/
4146
function staticUrl(id: string, subset: string, weight = 400, style = "normal", version = "latest") {
42-
return `${CDN}/${id}@${version}/${subset}-${weight}-${style}.woff2`;
43-
}
44-
45-
/** Variable font URL: {id}:vf@{version}/{subset}-{axes}-{style}.woff2 */
46-
function variableUrl(id: string, subset: string, axes = "wght", style = "normal", version = "latest") {
47-
return `${CDN}/${id}:vf@${version}/${subset}-${axes}-${style}.woff2`;
47+
return `${CDN}/${id}@${version}/${subset}-${weight}-${style}.ttf`;
4848
}
4949

5050
// Ordered by likelihood — latin first so the primary registration succeeds fast
@@ -94,47 +94,35 @@ export async function loadFontFromCDN(
9494
): Promise<void> {
9595
if (loadedFonts.has(fontId)) return;
9696

97-
// Inject CSS stylesheet — defines @font-face rules with unicode-range for HTML rendering
97+
// Inject CSS stylesheet — @font-face rules with unicode-range for the editor's
98+
// own HTML rendering (the browser handles woff2 there).
9899
const link = document.createElement("link");
99100
link.rel = "stylesheet";
100101
link.href = `${CDN}/${fontId}@latest/index.css`;
101102
link.crossOrigin = "anonymous";
102103
document.head.appendChild(link);
103104
loadedFonts.set(fontId, { id: fontId, name, kind: "cdn", link });
104105

105-
// Register the latin subset in the render worker so sone can shape with it.
106-
const latinUrl = variableUrl(fontId, "latin");
107-
workerBridge.registerFont(name, latinUrl);
108-
try {
109-
const face = new FontFace(name, `url(${latinUrl})`);
110-
await face.load();
111-
document.fonts.add(face);
112-
} catch {
113-
// Fall back to the static latin file for HTML rendering.
114-
const url = staticUrl(fontId, "latin", weight);
106+
// Register static TTF in the render worker (sone can't parse woff2). Latin
107+
// gets regular + bold; other subsets get regular. Missing files 404 and are
108+
// skipped silently in the worker's Font.load.
109+
const registerTtf = async (subset: string, w: number) => {
110+
const url = staticUrl(fontId, subset, w);
115111
workerBridge.registerFont(name, url);
116112
try {
117-
const face = new FontFace(name, `url(${url})`);
113+
const face = new FontFace(name, `url(${url})`, { weight: String(w) });
118114
await face.load();
119115
document.fonts.add(face);
120-
} catch { /* latin unavailable */ }
121-
}
116+
} catch {
117+
/* subset/weight unavailable */
118+
}
119+
};
122120

123-
// Load remaining subsets directly in parallel (skipping latin, already done above)
124-
await Promise.all(
125-
SUBSETS.filter((s) => s !== "latin").map(async (subset) => {
126-
const urls = [variableUrl(fontId, subset), staticUrl(fontId, subset, weight)];
127-
for (const url of urls) {
128-
try {
129-
const face = new FontFace(name, `url(${url})`);
130-
await face.load();
131-
document.fonts.add(face);
132-
workerBridge.registerFont(name, url); // sync to worker
133-
return;
134-
} catch { /* subset unavailable */ }
135-
}
136-
})
137-
);
121+
await Promise.all([
122+
registerTtf("latin", 400),
123+
registerTtf("latin", 700),
124+
...SUBSETS.filter((s) => s !== "latin").map((s) => registerTtf(s, weight)),
125+
]);
138126

139127
await document.fonts.ready;
140128
}
@@ -146,6 +134,8 @@ export async function loadCustomFontFile(
146134
): Promise<void> {
147135
if (loadedFonts.has(fontId)) return;
148136

137+
// sone's WASM backend parses TTF/OTF/WOFF (not woff2); a woff2 upload will be
138+
// rejected by the worker and simply won't render.
149139
const source = await fileToDataUrl(file);
150140
workerBridge.registerFont(name, source);
151141
try {

examples/live-editor/src/render-worker.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ import { transformCode } from "./execute";
2121
// Point CanvasKit at the bundled wasm before the first render.
2222
configureSkia({ wasmFile: wasmUrl });
2323

24+
// Auto-register a default sans font so the editor renders out of the box —
25+
// CanvasKit has no system fonts. jsDelivr serves the google/fonts TTF with
26+
// CORS; this build parses TTF/OTF/WOFF (not woff2), so a variable TTF is used
27+
// to get the full weight range. Registered first, it also backs the generic
28+
// `sans-serif` family that untyped text falls back to.
29+
const DEFAULT_FONT_NAME = "Inter";
30+
const DEFAULT_FONT_URL =
31+
"https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/inter/Inter%5Bopsz,wght%5D.ttf";
32+
33+
const fontsReady = Font.load(DEFAULT_FONT_NAME, DEFAULT_FONT_URL).catch(
34+
(err) => {
35+
console.error("Failed to load the default font:", err);
36+
},
37+
);
38+
2439
// Image decode cache, shared across renders (keyed by url / bytes).
2540
const renderCache = new Map<string | Uint8Array, SoneImage>();
2641

@@ -45,6 +60,7 @@ self.onmessage = async (e: MessageEvent) => {
4560
if (msg.type === "render") {
4661
const { id, code, dpr } = msg as { id: number; code: string; dpr: number };
4762
try {
63+
await fontsReady;
4864
const node = await buildNode(code);
4965
if (node == null) {
5066
self.postMessage({
@@ -55,8 +71,11 @@ self.onmessage = async (e: MessageEvent) => {
5571
});
5672
return;
5773
}
58-
// Render at device density so the preview is crisp on hi-dpi screens.
59-
const png = await sone(node, { cache: renderCache }).png({ density: dpr });
74+
// Render above device density: the preview stretches the canvas to fill
75+
// the pane, so the extra resolution keeps it crisp when upscaled.
76+
const png = await sone(node, { cache: renderCache }).png({
77+
density: dpr + 1,
78+
});
6079
const bitmap = await createImageBitmap(new Blob([png as BlobPart]));
6180
(self as unknown as Worker).postMessage(
6281
{ type: "result", id, bitmap, width: bitmap.width, height: bitmap.height },
@@ -79,6 +98,7 @@ self.onmessage = async (e: MessageEvent) => {
7998
format: "png" | "jpeg" | "pdf" | "svg";
8099
};
81100
try {
101+
await fontsReady;
82102
const node = await buildNode(code);
83103
if (node == null) {
84104
self.postMessage({ type: "exportError", id, message: "Nothing to export." });

0 commit comments

Comments
 (0)