空雲 Blog

Eye catchCloudflare Workers で OGP 画像を生成

publication: 2025/02/02
update:2025/02/17

OGP 画像生成に使うライブラリ

ライブラリ用途
satori仮想 DOM を SVG 形式に変換
yoga-wasm-websatori が使用しているレイアウトエンジン
svg2png-wasmSVG を PNG へ変換
wasm-image-optimizationsatori 未対応の WebP や Avif 画像を組み込めるように変換

出回っているサンプルは svg から png の変換に resvg を使っているものが多いですが svg2png-wasm の方が高速で安定しています。

コード

src/createOGP.ts

フォントと絵文字を必要に応じてダウンロードしてキャッシュに保存し、png 形式で OGP 画像を作成するコードです。

1import satori, { init } from "satori/wasm";
2import initYoga from "yoga-wasm-web";
3import yogaWasm from "yoga-wasm-web/dist/yoga.wasm";
4import { svg2png, initialize } from "svg2png-wasm";
5import wasm from "svg2png-wasm/svg2png_wasm_bg.wasm";
6
7init(await initYoga(yogaWasm));
8await initialize(wasm);
9
10const cache = await caches.open("cloudflare-ogp");
11
12type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
13type FontStyle = "normal" | "italic";
14type FontSrc = {
15 data: ArrayBuffer | string;
16 name: string;
17 weight?: Weight;
18 style?: FontStyle;
19 lang?: string;
20};
21type Font = Omit<FontSrc, "data"> & { data: ArrayBuffer | ArrayBufferView };
22
23const downloadFont = async (fontName: string) => {
24 return await fetch(
25 `https://fonts.googleapis.com/css2?family=${encodeURI(fontName)}`
26 )
27 .then((res) => res.text())
28 .then(
29 (css) =>
30 css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/)?.[1]
31 )
32 .then(async (url) => {
33 return url !== undefined
34 ? fetch(url).then((v) =>
35 v.status === 200 ? v.arrayBuffer() : undefined
36 )
37 : undefined;
38 });
39};
40
41const getFonts = async (
42 fontList: string[],
43 ctx: ExecutionContext
44): Promise<Font[]> => {
45 const fonts: Font[] = [];
46 for (const fontName of fontList) {
47 const cacheKey = `http://font/${encodeURI(fontName)}`;
48
49 const response = await cache.match(cacheKey);
50 if (response) {
51 fonts.push({
52 name: fontName,
53 data: await response.arrayBuffer(),
54 weight: 400,
55 style: "normal",
56 });
57 } else {
58 const data = await downloadFont(fontName);
59 if (data) {
60 ctx.waitUntil(cache.put(cacheKey, new Response(data)));
61 fonts.push({ name: fontName, data, weight: 400, style: "normal" });
62 }
63 }
64 }
65 return fonts.flatMap((v): Font[] => (v ? [v] : []));
66};
67
68const createLoadAdditionalAsset = ({
69 ctx,
70 emojis,
71}: {
72 ctx: ExecutionContext;
73 emojis: {
74 url: string;
75 upper?: boolean;
76 }[];
77}) => {
78 const getEmojiSVG = async (code: string) => {
79 const cacheKey = `http://emoji/${encodeURI(
80 JSON.stringify(emojis)
81 )}/${code}`;
82 for (const { url, upper } of emojis) {
83 const emojiURL = `${url}${
84 upper === false ? code.toLocaleLowerCase() : code.toUpperCase()
85 }.svg`;
86 let response = await cache.match(cacheKey);
87 if (!response) {
88 response = await fetch(emojiURL);
89 if (response.status === 200) {
90 ctx.waitUntil(cache.put(cacheKey, response.clone()));
91 }
92 }
93 if (response.status === 200) {
94 return await response.text();
95 }
96 }
97 return undefined;
98 };
99
100 const loadEmoji = async (segment: string): Promise<string | undefined> => {
101 const codes = Array.from(segment).map((char) => char.codePointAt(0));
102 const isZero = codes.includes(0x200d);
103 const code = codes
104 .filter((code) => isZero || code !== 0xfe0f)
105 .map((v) => v?.toString(16))
106 .join("-");
107 return getEmojiSVG(code);
108 };
109
110 const loadAdditionalAsset = async (code: string, segment: string) => {
111 if (code === "emoji") {
112 const svg = await loadEmoji(segment);
113 if (!svg) return segment;
114 return `data:image/svg+xml;base64,${btoa(svg)}`;
115 }
116 return [];
117 };
118
119 return loadAdditionalAsset;
120};
121
122export const createOGP = async (
123 element: JSX.Element,
124 {
125 fonts,
126 emojis,
127 ctx,
128 width,
129 height,
130 scale,
131 }: {
132 ctx: ExecutionContext;
133 fonts: string[];
134 emojis?: {
135 url: string;
136 upper?: boolean;
137 }[];
138 width: number;
139 height?: number;
140 scale?: number;
141 }
142) => {
143 const fontList = await getFonts(fonts, ctx);
144 const svg = await satori(element, {
145 width,
146 height,
147 fonts: fontList,
148 loadAdditionalAsset: emojis
149 ? createLoadAdditionalAsset({ ctx, emojis })
150 : undefined,
151 });
152 return await svg2png(svg, { scale });
153};

src/index.tsx

こちらは OGP 用の仮想 DOM を作っています。外部から受け取る画像に関しては、satori で取り扱えるように png 形式に変換するようにしています。

こちらは必要に応じてカスタマイズしてください。

1import React from "react";
2import { createOGP } from "./createOGP";
3import { optimizeImage } from "wasm-image-optimization";
4
5const convertImage = async (url: string | null) => {
6 const response = url ? await fetch(url) : undefined;
7 if (response) {
8 const contentType = response.headers.get("Content-Type");
9 const imageBuffer = await response.arrayBuffer();
10 if (contentType?.startsWith("image/")) {
11 if (["image/png", "image/jpeg"].includes(contentType)) {
12 return [contentType, imageBuffer as ArrayBuffer] as const;
13 }
14 const image = await optimizeImage({ image: imageBuffer, format: "png" });
15 if (image) {
16 return ["image/png", image] as const;
17 }
18 }
19 }
20 return [];
21};
22
23const outputOGP = async (
24 request: Request,
25 _env: object,
26 ctx: ExecutionContext
27): Promise<Response> => {
28 const url = new URL(request.url);
29 if (url.pathname !== "/") {
30 return new Response(null, { status: 404 });
31 }
32
33 const name = url.searchParams.get("name") ?? "Name";
34 const title = url.searchParams.get("title") ?? "Title";
35 const image = url.searchParams.get("image");
36 const cache = await caches.open("cloudflare-ogp");
37 const cacheKey = new Request(url.toString());
38 const cachedResponse = await cache.match(cacheKey);
39 if (cachedResponse) {
40 return cachedResponse;
41 }
42
43 const [imageType, imageBuffer] = await convertImage(image);
44
45 const ogpNode = (
46 <div
47 style={{
48 display: "flex",
49 flexDirection: "column",
50 width: "100%",
51 height: "100%",
52 padding: "16px 24px",
53 overflow: "hidden",
54 fontFamily: "NotoSansJP",
55 }}
56 >
57 <div
58 style={{
59 display: "flex",
60 flexDirection: "column",
61 height: "100%",
62 border: "solid 16px #0044FF",
63 borderRadius: "24px",
64 boxSizing: "border-box",
65 background: "linear-gradient(to bottom right, #ffffff, #d3eef9)",
66 }}
67 >
68 <div
69 style={{
70 display: "flex",
71 flex: 1,
72 }}
73 >
74 {image && (
75 <img
76 style={{
77 borderRadius: "100%",
78 padding: "8px",
79 marginRight: "16px",
80 position: "absolute",
81 opacity: 0.4,
82 }}
83 width={480}
84 height={480}
85 src={
86 imageBuffer
87 ? `data:${imageType};base64,${btoa(
88 Array.from(new Uint8Array(imageBuffer))
89 .map((v) => String.fromCharCode(v))
90 .join("")
91 )}`
92 : undefined
93 }
94 alt=""
95 />
96 )}
97 <h1
98 style={{
99 display: "block",
100 flex: 1,
101 fontSize: 72,
102 alignItems: "center",
103 justifyContent: "center",
104 padding: "0 42px",
105 wordBreak: "break-all",
106 textOverflow: "ellipsis",
107 lineClamp: 4,
108 lineHeight: "64px",
109 }}
110 >
111 {title}
112 </h1>
113 </div>
114 <div
115 style={{
116 width: "100%",
117 justifyContent: "flex-end",
118 fontSize: 48,
119 padding: "0 32px 32px 0",
120 color: "#CC3344",
121 }}
122 >
123 {name}
124 </div>
125 </div>
126 </div>
127 );
128 const png = await createOGP(ogpNode, {
129 ctx,
130 scale: 0.7,
131 width: 1200,
132 height: 630,
133 fonts: [
134 "Noto Sans",
135 "Noto Sans Math",
136 "Noto Sans Symbols",
137 // 'Noto Sans Symbols 2',
138 "Noto Sans JP",
139 // 'Noto Sans KR',
140 // 'Noto Sans SC',
141 // 'Noto Sans TC',
142 // 'Noto Sans HK',
143 // 'Noto Sans Thai',
144 // 'Noto Sans Bengali',
145 // 'Noto Sans Arabic',
146 // 'Noto Sans Tamil',
147 // 'Noto Sans Malayalam',
148 // 'Noto Sans Hebrew',
149 // 'Noto Sans Telugu',
150 // 'Noto Sans Devanagari',
151 // 'Noto Sans Kannada',
152 ],
153 emojis: [
154 {
155 url: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/",
156 },
157 {
158 url: "https://openmoji.org/data/color/svg/",
159 },
160 ],
161 });
162 const response = new Response(png, {
163 headers: {
164 "Content-Type": "image/png",
165 "Cache-Control": "public, max-age=31536000, immutable",
166 date: new Date().toUTCString(),
167 },
168 cf: {
169 cacheEverything: true,
170 cacheTtl: 31536000,
171 },
172 });
173 ctx.waitUntil(cache.put(cacheKey, response.clone()));
174 return response;
175};
176
177export default {
178 fetch: outputOGP,
179};

パラメータ

parameterdescription
titleThe title of the page
nameThe name of the person or organization
imageURL of the image to be used as the Open Graph image
(WebP and AVIF images are automatically converted to PNG format for processing)

実行結果

デプロイした場合は、そのドメイン名に置き換えてください。

http://127.0.0.1:8787/?title=%E3%82%BF%E3%82%A4%E3%83%88%E3%83%AB&name=%E5%90%8D%E5%89%8D&image=https://raw.githubusercontent.com/SoraKumo001/cloudflare-ogp/refs/heads/master/sample/image.jpg

{"width":"840px","height":"441px"}

まとめ

Cloudflare Workers を使用して OGP を生成する方法について説明しました。無料プランで利用する場合は、10 万回/日まで実行可能です。また、カスタムドメインを使って CDN のキャッシュが使えるようにしておけば、ほぼ無制限で利用できる状況になります。