@farcaster/snap-hono 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,878 @@
1
+ import type { SnapHandlerResult } from "@farcaster/snap";
2
+ import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX } from "@farcaster/snap";
3
+ import satori from "satori";
4
+ import { Resvg, initWasm } from "@resvg/resvg-wasm";
5
+
6
+ // ─── Public types ────────────────────────────────────────
7
+
8
+ export type OgFontSpec = {
9
+ /** Absolute path to a .woff2 (or .woff / .ttf) file on disk. */
10
+ path: string;
11
+ weight: 400 | 700;
12
+ style?: "normal" | "italic";
13
+ };
14
+
15
+ export type OgCacheAdapter = {
16
+ get(key: string): Promise<{ png: Uint8Array; etag: string } | null>;
17
+ set(
18
+ key: string,
19
+ value: { png: Uint8Array; etag: string },
20
+ ttlSeconds: number,
21
+ ): Promise<void>;
22
+ };
23
+
24
+ export type OgOptions = {
25
+ /** OG image width in pixels. @default card width + outer margin (~508) */
26
+ width?: number;
27
+ /** OG image height in pixels. @default derived from snap content + margins */
28
+ height?: number;
29
+ /**
30
+ * Font files to use for OG image rendering. Pass absolute disk paths to
31
+ * woff2/ttf files. Falls back to a CDN-loaded Inter if omitted or unavailable.
32
+ */
33
+ fonts?: OgFontSpec[];
34
+ /**
35
+ * Optional distributed cache adapter (e.g. Upstash Redis).
36
+ * When omitted the function relies entirely on CDN Cache-Control headers.
37
+ */
38
+ cache?: OgCacheAdapter;
39
+ /** CDN s-maxage in seconds. @default 86400 */
40
+ cdnMaxAge?: number;
41
+ /** Browser max-age in seconds. @default 60 */
42
+ browserMaxAge?: number;
43
+ };
44
+
45
+ /** Content width inside the OG card (`OG_CARD_OUTER_WIDTH_PX` − horizontal padding 24px×2). */
46
+ const OG_CARD_INNER_WIDTH_PX = 412;
47
+ /** White card width (border-box), matches in-app snap preview (~420px). */
48
+ const OG_CARD_OUTER_WIDTH_PX = 460;
49
+ /** Gray margin between PNG edge and card on all sides. */
50
+ const OG_OUTER_MARGIN_PX = 24;
51
+ const OG_CARD_PADDING_PX = 24;
52
+ const OG_ELEMENT_GAP_PX = 12;
53
+ const OG_BUTTONS_TOP_GAP_PX = 12;
54
+ /** Padding to avoid Yoga/Satori rounding clipping stacked content. */
55
+ const OG_HEIGHT_SAFETY_PX = 8;
56
+ const OG_MIN_HEIGHT_PX = 200;
57
+ const OG_MAX_HEIGHT_PX = 2400;
58
+
59
+ const DEFAULT_OG_WIDTH_PX = OG_CARD_OUTER_WIDTH_PX + 2 * OG_OUTER_MARGIN_PX;
60
+
61
+ // ─── ETag ────────────────────────────────────────────────
62
+
63
+ export function etagForPage(snapJson: string): string {
64
+ let h = 0;
65
+ for (let i = 0; i < snapJson.length; i++) {
66
+ h = (Math.imul(31, h) + snapJson.charCodeAt(i)) | 0;
67
+ }
68
+ return `"snap-${(h >>> 0).toString(36)}"`;
69
+ }
70
+
71
+ // ─── Singleflight ─────────────────────────────────────────
72
+
73
+ const inflight = new Map<string, Promise<{ png: Uint8Array; etag: string }>>();
74
+
75
+ export async function renderWithDedup(
76
+ key: string,
77
+ render: () => Promise<{ png: Uint8Array; etag: string }>,
78
+ ): Promise<{ png: Uint8Array; etag: string }> {
79
+ const existing = inflight.get(key);
80
+ if (existing) return existing;
81
+ const promise = render().finally(() => inflight.delete(key));
82
+ inflight.set(key, promise);
83
+ return promise;
84
+ }
85
+
86
+ // ─── SSRF-safe image fetching ─────────────────────────────
87
+
88
+ const IMAGE_FETCH_TIMEOUT_MS = 3_000;
89
+ const IMAGE_MAX_BYTES = 2 * 1024 * 1024;
90
+
91
+ async function safeFetchImage(url: string): Promise<string | null> {
92
+ try {
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), IMAGE_FETCH_TIMEOUT_MS);
95
+ const res = await fetch(url, {
96
+ signal: controller.signal,
97
+ redirect: "follow",
98
+ });
99
+ clearTimeout(timer);
100
+ if (!res.ok) return null;
101
+ const cl = res.headers.get("content-length");
102
+ if (cl && parseInt(cl, 10) > IMAGE_MAX_BYTES) return null;
103
+ const buf = await res.arrayBuffer();
104
+ if (buf.byteLength > IMAGE_MAX_BYTES) return null;
105
+ const mime = res.headers.get("content-type") ?? "image/png";
106
+ const bytes = new Uint8Array(buf);
107
+ let binary = "";
108
+ for (let i = 0; i < bytes.length; i++)
109
+ binary += String.fromCharCode(bytes[i]!);
110
+ return `data:${mime};base64,${btoa(binary)}`;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ // ─── Font loading ──────────────────────────────────────────
117
+
118
+ const fontCache = new Map<string, ArrayBuffer>();
119
+ // Satori requires TTF, OTF, or WOFF (v1). WOFF2 is not supported by its
120
+ // underlying opentype.js parser. @fontsource/inter v4 ships WOFF v1 files.
121
+ const CDN_FONT_URL_400 =
122
+ "https://cdn.jsdelivr.net/npm/@fontsource/inter@4.5.15/files/inter-latin-400-normal.woff";
123
+ const CDN_FONT_URL_700 =
124
+ "https://cdn.jsdelivr.net/npm/@fontsource/inter@4.5.15/files/inter-latin-700-normal.woff";
125
+ let cdnFontData400: ArrayBuffer | undefined;
126
+ let cdnFontData700: ArrayBuffer | undefined;
127
+
128
+ async function tryLoadFontFromPath(path: string): Promise<ArrayBuffer | null> {
129
+ try {
130
+ const { readFile } = await import("node:fs/promises");
131
+ const cached = fontCache.get(path);
132
+ if (cached) return cached;
133
+ const buf = (await readFile(path)).buffer as ArrayBuffer;
134
+ fontCache.set(path, buf);
135
+ return buf;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ type FontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
142
+
143
+ type FontStyle = "normal" | "italic";
144
+
145
+ type SatoriFont = {
146
+ name: string;
147
+ data: ArrayBuffer;
148
+ weight: FontWeight;
149
+ style: FontStyle;
150
+ };
151
+
152
+ async function buildFontList(
153
+ specs: OgFontSpec[] | undefined,
154
+ ): Promise<SatoriFont[]> {
155
+ if (specs && specs.length > 0) {
156
+ const fonts: SatoriFont[] = [];
157
+ for (const spec of specs) {
158
+ const data = await tryLoadFontFromPath(spec.path);
159
+ if (data) {
160
+ fonts.push({
161
+ name: "Inter",
162
+ data,
163
+ weight: spec.weight as FontWeight,
164
+ style: (spec.style ?? "normal") as FontStyle,
165
+ });
166
+ }
167
+ }
168
+ if (fonts.length > 0) return fonts;
169
+ }
170
+ // CDN fallback: Inter 400 + 700 so title/labels match the HTML card weights
171
+ if (!cdnFontData400) {
172
+ const res = await fetch(CDN_FONT_URL_400);
173
+ if (!res.ok) throw new Error("Failed to load Inter 400 fallback from CDN");
174
+ cdnFontData400 = await res.arrayBuffer();
175
+ }
176
+ if (!cdnFontData700) {
177
+ const res = await fetch(CDN_FONT_URL_700);
178
+ if (!res.ok) throw new Error("Failed to load Inter 700 fallback from CDN");
179
+ cdnFontData700 = await res.arrayBuffer();
180
+ }
181
+ return [
182
+ {
183
+ name: "Inter",
184
+ data: cdnFontData400,
185
+ weight: 400 as FontWeight,
186
+ style: "normal" as FontStyle,
187
+ },
188
+ {
189
+ name: "Inter",
190
+ data: cdnFontData700,
191
+ weight: 700 as FontWeight,
192
+ style: "normal" as FontStyle,
193
+ },
194
+ ];
195
+ }
196
+
197
+ // ─── resvg-wasm init ───────────────────────────────────────
198
+
199
+ let resvgInitPromise: Promise<void> | undefined;
200
+
201
+ async function ensureResvg(): Promise<void> {
202
+ if (!resvgInitPromise) {
203
+ // In Node.js, resolve wasm from node_modules to avoid network round-trip
204
+ let wasmSource: Parameters<typeof initWasm>[0];
205
+ try {
206
+ const { readFile } = await import("node:fs/promises");
207
+ const { createRequire } = await import("node:module");
208
+ const req = createRequire(import.meta.url);
209
+ const wasmPath = req.resolve("@resvg/resvg-wasm/index_bg.wasm");
210
+ wasmSource = (await readFile(wasmPath)).buffer as ArrayBuffer;
211
+ } catch {
212
+ wasmSource = fetch(
213
+ "https://cdn.jsdelivr.net/npm/@resvg/resvg-wasm@2.6.2/index_bg.wasm",
214
+ );
215
+ }
216
+ resvgInitPromise = initWasm(wasmSource);
217
+ }
218
+ await resvgInitPromise;
219
+ }
220
+
221
+ // ─── VNode helpers ─────────────────────────────────────────
222
+
223
+ type VNode = { type: string; props: Record<string, unknown> };
224
+ type Child = VNode | string | null | undefined;
225
+
226
+ function h(
227
+ type: string,
228
+ style: Record<string, unknown>,
229
+ ...children: Child[]
230
+ ): VNode {
231
+ const kids = children.filter((c): c is VNode | string => c != null);
232
+ const childValue =
233
+ kids.length === 0 ? undefined : kids.length === 1 ? kids[0] : kids;
234
+ return {
235
+ type,
236
+ props:
237
+ childValue !== undefined ? { style, children: childValue } : { style },
238
+ };
239
+ }
240
+
241
+ // ─── Element renderers ─────────────────────────────────────
242
+
243
+ type El = Record<string, unknown>;
244
+
245
+ function accentHex(accent: string | undefined): string {
246
+ return (
247
+ PALETTE_LIGHT_HEX[accent as keyof typeof PALETTE_LIGHT_HEX] ??
248
+ PALETTE_LIGHT_HEX[DEFAULT_THEME_ACCENT]
249
+ );
250
+ }
251
+
252
+ function colorHex(color: string | undefined, accent: string): string {
253
+ if (!color || color === "accent") return accent;
254
+ return PALETTE_LIGHT_HEX[color as keyof typeof PALETTE_LIGHT_HEX] ?? accent;
255
+ }
256
+
257
+ function mapText(el: El): VNode {
258
+ const style = el.style as string;
259
+ const align = (el.align as string) ?? "left";
260
+ let content = String(el.content ?? "");
261
+ // Inter WOFF subset: normalize arrows / punctuation so glyphs don't substitute badly in Satori.
262
+ if (style === "caption" || style === "body") {
263
+ content = content
264
+ .replace(/\u2192/g, "->")
265
+ .replace(/\u2190/g, "<-")
266
+ .replace(/\u27a1/gi, "->");
267
+ }
268
+ // Match `renderSnapPage` `renderText` (card HTML): title 20px #111, body/caption/list tones.
269
+ const styleMap: Record<string, Record<string, unknown>> = {
270
+ title: { fontSize: 20, fontWeight: 700, color: "#111111", lineHeight: 1.3 },
271
+ body: { fontSize: 15, color: "#374151", lineHeight: 1.5 },
272
+ caption: { fontSize: 13, color: "#9CA3AF", lineHeight: 1.4 },
273
+ label: {
274
+ fontSize: 13,
275
+ fontWeight: 600,
276
+ color: "#6B7280",
277
+ textTransform: "uppercase",
278
+ letterSpacing: "0.5px",
279
+ },
280
+ };
281
+ const ts = styleMap[style] ?? styleMap["body"]!;
282
+ return h(
283
+ "div",
284
+ {
285
+ display: "flex",
286
+ width: OG_CARD_INNER_WIDTH_PX,
287
+ textAlign: align,
288
+ ...ts,
289
+ },
290
+ content,
291
+ );
292
+ }
293
+
294
+ function mapImage(el: El, imageMap: Map<string, string>): VNode {
295
+ const url = el.url as string;
296
+ const dataUri = imageMap.get(url);
297
+ if (dataUri) {
298
+ return h(
299
+ "div",
300
+ {
301
+ display: "flex",
302
+ borderRadius: 8,
303
+ overflow: "hidden",
304
+ backgroundColor: "#F3F4F6",
305
+ width: "100%",
306
+ },
307
+ {
308
+ type: "img",
309
+ props: { src: dataUri, style: { width: "100%", objectFit: "cover" } },
310
+ },
311
+ );
312
+ }
313
+ return h(
314
+ "div",
315
+ {
316
+ display: "flex",
317
+ borderRadius: 8,
318
+ backgroundColor: "#E5E7EB",
319
+ height: 80,
320
+ width: "100%",
321
+ alignItems: "center",
322
+ justifyContent: "center",
323
+ },
324
+ h("div", { display: "flex", fontSize: 12, color: "#9CA3AF" }, "image"),
325
+ );
326
+ }
327
+
328
+ function mapDivider(): VNode {
329
+ return h("div", {
330
+ display: "flex",
331
+ height: 1,
332
+ backgroundColor: "#E5E7EB",
333
+ width: "100%",
334
+ });
335
+ }
336
+
337
+ function mapProgress(el: El, accent: string): VNode {
338
+ const value = el.value as number;
339
+ const max = el.max as number;
340
+ const color = colorHex(el.color as string | undefined, accent);
341
+ const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
342
+ const labelNode = el.label
343
+ ? h(
344
+ "div",
345
+ { display: "flex", fontSize: 13, color: "#6B7280" },
346
+ String(el.label),
347
+ )
348
+ : null;
349
+ return h(
350
+ "div",
351
+ { display: "flex", flexDirection: "column", gap: 4 },
352
+ labelNode,
353
+ h(
354
+ "div",
355
+ {
356
+ display: "flex",
357
+ height: 8,
358
+ backgroundColor: "#E5E7EB",
359
+ borderRadius: 4,
360
+ overflow: "hidden",
361
+ width: "100%",
362
+ },
363
+ h("div", {
364
+ display: "flex",
365
+ height: "100%",
366
+ width: `${pct}%`,
367
+ backgroundColor: color,
368
+ borderRadius: 4,
369
+ }),
370
+ ),
371
+ );
372
+ }
373
+
374
+ function mapList(el: El): VNode {
375
+ const style = (el.style as string) ?? "ordered";
376
+ const items =
377
+ (el.items as Array<{ content: string; trailing?: string }>) ?? [];
378
+ const rows = items.slice(0, 4).map((item, i) => {
379
+ const prefix =
380
+ style === "ordered" ? `${i + 1}.` : style === "unordered" ? "•" : "";
381
+ const prefixNode =
382
+ prefix !== ""
383
+ ? h(
384
+ "div",
385
+ { display: "flex", color: "#9CA3AF", fontSize: 13, minWidth: 20 },
386
+ prefix,
387
+ )
388
+ : null;
389
+ return h(
390
+ "div",
391
+ {
392
+ display: "flex",
393
+ flexDirection: "row",
394
+ gap: 8,
395
+ alignItems: "center",
396
+ paddingTop: 6,
397
+ paddingBottom: 6,
398
+ },
399
+ prefixNode,
400
+ h(
401
+ "div",
402
+ { display: "flex", flex: 1, fontSize: 14, color: "#374151" },
403
+ item.content,
404
+ ),
405
+ item.trailing
406
+ ? h(
407
+ "div",
408
+ { display: "flex", fontSize: 13, color: "#9CA3AF" },
409
+ item.trailing,
410
+ )
411
+ : null,
412
+ );
413
+ });
414
+ return h("div", { display: "flex", flexDirection: "column" }, ...rows);
415
+ }
416
+
417
+ function mapButtonGroup(el: El, _accent: string): VNode {
418
+ const options = (el.options as string[]) ?? [];
419
+ const layout = (el.style as string) ?? "row";
420
+ const isRow = layout !== "stack";
421
+ const sliced = options.slice(0, 4);
422
+ const n = sliced.length;
423
+ const gapPx = 8;
424
+
425
+ // Match `renderSnapPage` `renderButtonGroup`: white pills, gray border, dark text.
426
+ // Do not use flexGrow/flexBasis for row pills — Yoga in Satori overlaps labels.
427
+ const children = sliced.map((opt) => {
428
+ const pill: Record<string, unknown> = {
429
+ display: "flex",
430
+ alignItems: "center",
431
+ justifyContent: "center",
432
+ paddingTop: 10,
433
+ paddingBottom: 10,
434
+ paddingLeft: 12,
435
+ paddingRight: 12,
436
+ borderRadius: 8,
437
+ border: "1px solid #E5E7EB",
438
+ backgroundColor: "#FFFFFF",
439
+ color: "#374151",
440
+ fontSize: 14,
441
+ fontWeight: 400,
442
+ overflow: "hidden",
443
+ textAlign: "center",
444
+ boxSizing: "border-box",
445
+ flexShrink: 0,
446
+ };
447
+
448
+ if (isRow && n > 0) {
449
+ const totalGaps = (n - 1) * gapPx;
450
+ pill.width = Math.floor((OG_CARD_INNER_WIDTH_PX - totalGaps) / n);
451
+ } else {
452
+ pill.width = OG_CARD_INNER_WIDTH_PX;
453
+ pill.alignSelf = "center";
454
+ }
455
+
456
+ return h("div", pill, opt);
457
+ });
458
+
459
+ return h(
460
+ "div",
461
+ {
462
+ display: "flex",
463
+ flexDirection: isRow ? "row" : "column",
464
+ alignItems: isRow ? "flex-start" : "stretch",
465
+ width: OG_CARD_INNER_WIDTH_PX,
466
+ gap: gapPx,
467
+ },
468
+ ...children,
469
+ );
470
+ }
471
+
472
+ function mapBarChart(el: El, accent: string): VNode {
473
+ const bars =
474
+ (el.bars as Array<{ label: string; value: number; color?: string }>) ?? [];
475
+ const maxVal =
476
+ (el.max as number | undefined) ?? Math.max(...bars.map((b) => b.value), 1);
477
+ const chartDefault = colorHex(el.color as string | undefined, accent);
478
+ const barNodes = bars.slice(0, 6).map((bar) => {
479
+ const color =
480
+ bar.color !== undefined && bar.color !== ""
481
+ ? colorHex(bar.color as string, accent)
482
+ : chartDefault;
483
+ const pct = maxVal > 0 ? (bar.value / maxVal) * 100 : 0;
484
+ return h(
485
+ "div",
486
+ {
487
+ display: "flex",
488
+ flex: 1,
489
+ flexDirection: "column",
490
+ alignItems: "center",
491
+ height: "100%",
492
+ justifyContent: "flex-end",
493
+ },
494
+ h(
495
+ "div",
496
+ { display: "flex", fontSize: 11, color: "#6B7280", marginBottom: 4 },
497
+ String(bar.value),
498
+ ),
499
+ h("div", {
500
+ display: "flex",
501
+ width: "100%",
502
+ height: `${pct}%`,
503
+ backgroundColor: color,
504
+ borderRadius: "4px 4px 0 0",
505
+ minHeight: 4,
506
+ }),
507
+ h(
508
+ "div",
509
+ { display: "flex", fontSize: 11, color: "#9CA3AF", marginTop: 4 },
510
+ bar.label.slice(0, 12),
511
+ ),
512
+ );
513
+ });
514
+ return h(
515
+ "div",
516
+ {
517
+ display: "flex",
518
+ flexDirection: "row",
519
+ alignItems: "flex-end",
520
+ gap: 12,
521
+ height: 100,
522
+ width: "100%",
523
+ },
524
+ ...barNodes,
525
+ );
526
+ }
527
+
528
+ function mapElement(
529
+ el: El,
530
+ accent: string,
531
+ imageMap: Map<string, string>,
532
+ ): VNode | null {
533
+ const type = el.type as string;
534
+ switch (type) {
535
+ case "text":
536
+ return mapText(el);
537
+ case "image":
538
+ return mapImage(el, imageMap);
539
+ case "divider":
540
+ return mapDivider();
541
+ case "progress":
542
+ return mapProgress(el, accent);
543
+ case "list":
544
+ return mapList(el);
545
+ case "button_group":
546
+ return mapButtonGroup(el, accent);
547
+ case "bar_chart":
548
+ return mapBarChart(el, accent);
549
+ case "group": {
550
+ const children = (el.children as El[]) ?? [];
551
+ const childNodes = children
552
+ .map((c) => mapElement(c, accent, imageMap))
553
+ .filter((n): n is VNode => n != null);
554
+ return h(
555
+ "div",
556
+ { display: "flex", flexDirection: "row", gap: 12 },
557
+ ...childNodes.map((c) => h("div", { display: "flex", flex: 1 }, c)),
558
+ );
559
+ }
560
+ default:
561
+ return null;
562
+ }
563
+ }
564
+
565
+ function mapButton(btn: El, accent: string, i: number): VNode {
566
+ const label = String(btn.label ?? "");
567
+ const style = (btn.style as string) ?? (i === 0 ? "primary" : "secondary");
568
+ const isPrimary = style === "primary";
569
+ // Primary CTA: generous vertical padding + minHeight so Satori/Yoga renders a tall tap target
570
+ // (small padding deltas are easy to miss; flexBasis:0 rows can also under-measure height).
571
+ const py = isPrimary ? 18 : 10;
572
+ const btnStyle: Record<string, unknown> = {
573
+ display: "flex",
574
+ flexGrow: 1,
575
+ flexShrink: 1,
576
+ flexBasis: 0,
577
+ minWidth: 0,
578
+ alignItems: "center",
579
+ justifyContent: "center",
580
+ paddingTop: py,
581
+ paddingBottom: py,
582
+ paddingLeft: 16,
583
+ paddingRight: 16,
584
+ borderRadius: 10,
585
+ backgroundColor: isPrimary ? accent : "transparent",
586
+ color: isPrimary ? "#fff" : accent,
587
+ border: isPrimary ? "none" : `2px solid ${accent}`,
588
+ fontSize: 14,
589
+ fontWeight: 600,
590
+ boxSizing: "border-box",
591
+ };
592
+ if (isPrimary) {
593
+ btnStyle.minHeight = 52;
594
+ }
595
+ return h("div", btnStyle, label);
596
+ }
597
+
598
+ function linesForWrappedText(
599
+ charCount: number,
600
+ innerWidthPx: number,
601
+ avgCharPx: number,
602
+ ): number {
603
+ if (charCount <= 0) return 1;
604
+ const cpl = Math.max(8, Math.floor(innerWidthPx / avgCharPx));
605
+ return Math.max(1, Math.ceil((charCount * 1.12) / cpl));
606
+ }
607
+
608
+ function estimateTextHeight(el: El): number {
609
+ const style = (el.style as string) ?? "body";
610
+ const content = String(el.content ?? "");
611
+ const w = OG_CARD_INNER_WIDTH_PX;
612
+ switch (style) {
613
+ case "title":
614
+ return linesForWrappedText(content.length, w, 11) * 26;
615
+ case "body":
616
+ return linesForWrappedText(content.length, w, 7.5) * 23;
617
+ case "caption":
618
+ return linesForWrappedText(content.length, w, 7) * 18;
619
+ case "label":
620
+ return linesForWrappedText(content.length, w, 7) * 18;
621
+ default:
622
+ return linesForWrappedText(content.length, w, 7.5) * 23;
623
+ }
624
+ }
625
+
626
+ function estimateImageHeight(el: El, imageMap: Map<string, string>): number {
627
+ const url = el.url as string;
628
+ if (!imageMap.has(url)) return 80;
629
+ const aspect = (el.aspect as string) ?? "16:9";
630
+ const parts = aspect.split(":").map(Number);
631
+ const aw = parts[0];
632
+ const ah = parts[1];
633
+ if (!aw || !ah || aw <= 0 || ah <= 0) {
634
+ return Math.round((OG_CARD_INNER_WIDTH_PX * 9) / 16);
635
+ }
636
+ return Math.round((OG_CARD_INNER_WIDTH_PX * ah) / aw);
637
+ }
638
+
639
+ function estimateProgressHeight(el: El): number {
640
+ const label = el.label ? String(el.label) : "";
641
+ let h = 0;
642
+ if (label) {
643
+ const lines = linesForWrappedText(label.length, OG_CARD_INNER_WIDTH_PX, 7);
644
+ h += lines * 18 + 4;
645
+ }
646
+ h += 8;
647
+ return h;
648
+ }
649
+
650
+ function estimateListHeight(el: El): number {
651
+ const items =
652
+ (el.items as Array<{ content: string; trailing?: string }>) ?? [];
653
+ const textWidth = OG_CARD_INNER_WIDTH_PX - 28;
654
+ let total = 0;
655
+ for (const item of items.slice(0, 4)) {
656
+ const text = item.content ?? "";
657
+ const lines = linesForWrappedText(text.length, textWidth, 7.5);
658
+ total += 12 + Math.max(20, lines * 21);
659
+ }
660
+ return total;
661
+ }
662
+
663
+ function estimateButtonGroupHeight(el: El): number {
664
+ const options = (el.options as string[]) ?? [];
665
+ const layout = (el.style as string) ?? "row";
666
+ const isRow = layout !== "stack";
667
+ const sliced = options.slice(0, 4);
668
+ const n = sliced.length;
669
+ if (n === 0) return 0;
670
+ const pillW =
671
+ isRow && n > 0
672
+ ? Math.floor((OG_CARD_INNER_WIDTH_PX - (n - 1) * 8) / n)
673
+ : OG_CARD_INNER_WIDTH_PX;
674
+ const pillHeights = sliced.map((opt) => {
675
+ const lines = linesForWrappedText(
676
+ opt.length,
677
+ Math.max(40, pillW - 24),
678
+ 7.5,
679
+ );
680
+ return Math.max(42, 10 + 10 + lines * 16 + 2);
681
+ });
682
+ if (isRow) return Math.max(...pillHeights);
683
+ return pillHeights.reduce((a, b) => a + b, 0) + (n - 1) * 8;
684
+ }
685
+
686
+ function estimateElementHeight(el: El, imageMap: Map<string, string>): number {
687
+ const type = el.type as string;
688
+ switch (type) {
689
+ case "text":
690
+ return estimateTextHeight(el);
691
+ case "image":
692
+ return estimateImageHeight(el, imageMap);
693
+ case "divider":
694
+ return 1;
695
+ case "progress":
696
+ return estimateProgressHeight(el);
697
+ case "list":
698
+ return estimateListHeight(el);
699
+ case "button_group":
700
+ return estimateButtonGroupHeight(el);
701
+ case "bar_chart":
702
+ return 100;
703
+ case "group": {
704
+ const children = (el.children as El[]) ?? [];
705
+ if (children.length === 0) return 0;
706
+ return Math.max(
707
+ ...children.map((c) => estimateElementHeight(c, imageMap)),
708
+ );
709
+ }
710
+ default:
711
+ return 0;
712
+ }
713
+ }
714
+
715
+ function estimateButtonsBlockHeight(
716
+ buttons: El[],
717
+ buttonLayout: string | undefined,
718
+ ): number {
719
+ if (buttons.length === 0) return 0;
720
+ const isRow = buttonLayout === "row";
721
+ const heights = buttons.map((btn, i) => {
722
+ const style = (btn.style as string) ?? (i === 0 ? "primary" : "secondary");
723
+ const isPrimary = style === "primary";
724
+ const label = String(btn.label ?? "");
725
+ const inner = isRow
726
+ ? Math.floor(
727
+ (OG_CARD_INNER_WIDTH_PX - (buttons.length - 1) * 8) / buttons.length,
728
+ )
729
+ : OG_CARD_INNER_WIDTH_PX;
730
+ const lines = linesForWrappedText(
731
+ label.length,
732
+ Math.max(40, inner - 24),
733
+ 7,
734
+ );
735
+ if (isPrimary) {
736
+ return Math.max(52, 18 + 18 + lines * 17);
737
+ }
738
+ return Math.max(44, 10 + 10 + lines * 15 + 4);
739
+ });
740
+ if (isRow) {
741
+ return Math.max(...heights);
742
+ }
743
+ return heights.reduce((a, b) => a + b, 0) + (buttons.length - 1) * 8;
744
+ }
745
+
746
+ function estimateDefaultOgHeight(
747
+ elements: El[],
748
+ imageMap: Map<string, string>,
749
+ buttons: El[],
750
+ buttonLayout: string | undefined,
751
+ ): number {
752
+ let innerColumn = 0;
753
+ for (const el of elements) {
754
+ innerColumn += estimateElementHeight(el, imageMap) + OG_ELEMENT_GAP_PX;
755
+ }
756
+ if (buttons.length > 0) {
757
+ innerColumn +=
758
+ OG_BUTTONS_TOP_GAP_PX + estimateButtonsBlockHeight(buttons, buttonLayout);
759
+ }
760
+ const cardH = OG_CARD_PADDING_PX * 2 + innerColumn;
761
+ const outerH = 2 * OG_OUTER_MARGIN_PX + cardH + OG_HEIGHT_SAFETY_PX;
762
+ return Math.min(
763
+ OG_MAX_HEIGHT_PX,
764
+ Math.max(OG_MIN_HEIGHT_PX, Math.ceil(outerH)),
765
+ );
766
+ }
767
+
768
+ // ─── Main PNG renderer ─────────────────────────────────────
769
+
770
+ export async function renderSnapPageToPng(
771
+ snap: SnapHandlerResult,
772
+ options?: OgOptions,
773
+ ): Promise<Uint8Array> {
774
+ const page = snap.page;
775
+ const accent = accentHex(page.theme?.accent as string | undefined);
776
+
777
+ // Pre-fetch all image URLs (SSRF-safe)
778
+ const imageUrls = (page.elements.children as El[])
779
+ .filter((el) => el.type === "image")
780
+ .map((el) => el.url as string);
781
+ const unique = [...new Set(imageUrls)];
782
+ const fetched = await Promise.all(
783
+ unique.map(async (url) => [url, await safeFetchImage(url)] as const),
784
+ );
785
+ const imageMap = new Map(
786
+ fetched.filter(([, v]) => v != null) as [string, string][],
787
+ );
788
+
789
+ const buttonLayout = page.button_layout as string | undefined;
790
+ const pageButtons = (page.buttons as El[] | undefined) ?? [];
791
+ const W = options?.width ?? DEFAULT_OG_WIDTH_PX;
792
+ const H =
793
+ options?.height ??
794
+ estimateDefaultOgHeight(
795
+ page.elements.children as El[],
796
+ imageMap,
797
+ pageButtons,
798
+ buttonLayout,
799
+ );
800
+
801
+ // Build element VNodes
802
+ const elementNodes = (page.elements.children as El[])
803
+ .map((el) => mapElement(el, accent, imageMap))
804
+ .filter((n): n is VNode => n != null);
805
+
806
+ // Build button VNodes
807
+ const buttonNodes = pageButtons.map((btn, i) => mapButton(btn, accent, i));
808
+
809
+ const cardChildren: VNode[] = [
810
+ ...elementNodes.map((n) =>
811
+ h(
812
+ "div",
813
+ {
814
+ display: "flex",
815
+ marginBottom: 12,
816
+ width: OG_CARD_INNER_WIDTH_PX,
817
+ },
818
+ n,
819
+ ),
820
+ ),
821
+ ...(buttonNodes.length > 0
822
+ ? [
823
+ h(
824
+ "div",
825
+ {
826
+ display: "flex",
827
+ flexDirection: buttonLayout === "row" ? "row" : "column",
828
+ gap: 8,
829
+ marginTop: 12,
830
+ width: OG_CARD_INNER_WIDTH_PX,
831
+ },
832
+ ...buttonNodes,
833
+ ),
834
+ ]
835
+ : []),
836
+ ];
837
+
838
+ const root = h(
839
+ "div",
840
+ {
841
+ display: "flex",
842
+ width: W,
843
+ height: H,
844
+ backgroundColor: "#F3F4F6",
845
+ alignItems: "center",
846
+ justifyContent: "center",
847
+ },
848
+ h(
849
+ "div",
850
+ {
851
+ display: "flex",
852
+ flexDirection: "column",
853
+ backgroundColor: "#FFFFFF",
854
+ borderRadius: 16,
855
+ padding: 24,
856
+ width: OG_CARD_OUTER_WIDTH_PX,
857
+ boxSizing: "border-box",
858
+ boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
859
+ },
860
+ ...cardChildren,
861
+ ),
862
+ );
863
+
864
+ // Load fonts (disk paths → CDN fallback)
865
+ const fonts = await buildFontList(options?.fonts);
866
+
867
+ // Render SVG via satori (VNode is compatible with satori's element shape)
868
+ const svg = await satori(root as Parameters<typeof satori>[0], {
869
+ width: W,
870
+ height: H,
871
+ fonts,
872
+ });
873
+
874
+ // Convert SVG → PNG via resvg-wasm
875
+ await ensureResvg();
876
+ const renderer = new Resvg(svg, { background: "#F3F4F6" });
877
+ return renderer.render().asPng();
878
+ }