iTranslated by AI
The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📚
Generating OGP Images with Cloudflare Workers
Libraries for OGP Image Generation
| Library | Purpose |
|---|---|
| satori | Converts Virtual DOM to SVG format |
| yoga-wasm-web | Layout engine used by satori |
| svg2png-wasm | Converts SVG to PNG |
| wasm-image-optimization | Converts WebP or Avif images (unsupported by satori) for integration |
Many available samples use resvg for SVG to PNG conversion, but svg2png-wasm is faster and more stable.
Code
src/createOGP.ts
This code downloads and caches fonts and emojis as needed to create OGP images in PNG format.
import satori, { init } from "satori/wasm";
import initYoga from "yoga-wasm-web";
import yogaWasm from "yoga-wasm-web/dist/yoga.wasm";
import { svg2png, initialize } from "svg2png-wasm";
import wasm from "svg2png-wasm/svg2png_wasm_bg.wasm";
init(await initYoga(yogaWasm));
await initialize(wasm);
const cache = await caches.open("cloudflare-ogp");
type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
type FontStyle = "normal" | "italic";
type FontSrc = {
data: ArrayBuffer | string;
name: string;
weight?: Weight;
style?: FontStyle;
lang?: string;
};
type Font = Omit<FontSrc, "data"> & { data: ArrayBuffer | ArrayBufferView };
const downloadFont = async (fontName: string) => {
return await fetch(
`https://fonts.googleapis.com/css2?family=${encodeURI(fontName)}`
)
.then((res) => res.text())
.then(
(css) =>
css.match(/src: url\\((.+)\\) format\\('(opentype|truetype)'\\)/)?.[1]
)
.then(async (url) => {
return url !== undefined
? fetch(url).then((v) =>
v.status === 200 ? v.arrayBuffer() : undefined
)
: undefined;
});
};
const getFonts = async (
fontList: string[],
ctx: ExecutionContext
): Promise<Font[]> => {
const fonts: Font[] = [];
for (const fontName of fontList) {
const cacheKey = `http://font/${encodeURI(fontName)}`;
const response = await cache.match(cacheKey);
if (response) {
fonts.push({
name: fontName,
data: await response.arrayBuffer(),
weight: 400,
style: "normal",
});
} else {
const data = await downloadFont(fontName);
if (data) {
ctx.waitUntil(cache.put(cacheKey, new Response(data)));
fonts.push({ name: fontName, data, weight: 400, style: "normal" });
}
}
}
return fonts.flatMap((v): Font[] => (v ? [v] : []));
};
const createLoadAdditionalAsset = ({
ctx,
emojis,
}: {
ctx: ExecutionContext;
emojis: {
url: string;
upper?: boolean;
}[];
}) => {
const getEmojiSVG = async (code: string) => {
const cacheKey = `http://emoji/${encodeURI(
JSON.stringify(emojis)
)}/${code}`;
for (const { url, upper } of emojis) {
const emojiURL = `${url}${
upper === false ? code.toLocaleLowerCase() : code.toUpperCase()
}.svg`;
let response = await cache.match(cacheKey);
if (!response) {
response = await fetch(emojiURL);
if (response.status === 200) {
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
}
if (response.status === 200) {
return await response.text();
}
}
return undefined;
};
const loadEmoji = async (segment: string): Promise<string | undefined> => {
const codes = Array.from(segment).map((char) => char.codePointAt(0));
const isZero = codes.includes(0x200d);
const code = codes
.filter((code) => isZero || code !== 0xfe0f)
.map((v) => v?.toString(16))
.join("-");
return getEmojiSVG(code);
};
const loadAdditionalAsset = async (code: string, segment: string) => {
if (code === "emoji") {
const svg = await loadEmoji(segment);
if (!svg) return segment;
return `data:image/svg+xml;base64,${btoa(svg)}`;
}
return [];
};
return loadAdditionalAsset;
};
export const createOGP = async (
element: JSX.Element,
{
fonts,
emojis,
ctx,
width,
height,
scale,
}: {
ctx: ExecutionContext;
fonts: string[];
emojis?: {
url: string;
upper?: boolean;
}[];
width: number;
height?: number;
scale?: number;
}
) => {
const fontList = await getFonts(fonts, ctx);
const svg = await satori(element, {
width,
height,
fonts: fontList,
loadAdditionalAsset: emojis
? createLoadAdditionalAsset({ ctx, emojis })
: undefined,
});
return await svg2png(svg, { scale });
};
src/index.tsx
This section creates a virtual DOM for the OGP. Images received from external sources are converted to PNG format so they can be processed by satori.
Customize this as needed.
import React from "react";
import { createOGP } from "./createOGP";
import { optimizeImage } from "wasm-image-optimization";
const convertImage = async (url: string | null) => {
const response = url ? await fetch(url) : undefined;
if (response) {
const contentType = response.headers.get("Content-Type");
const imageBuffer = await response.arrayBuffer();
if (contentType?.startsWith("image/")) {
if (["image/png", "image/jpeg"].includes(contentType)) {
return [contentType, imageBuffer as ArrayBuffer] as const;
}
const image = await optimizeImage({ image: imageBuffer, format: "png" });
if (image) {
return ["image/png", image] as const;
}
}
}
return [];
};
const outputOGP = async (
request: Request,
_env: object,
ctx: ExecutionContext
): Promise<Response> => {
const url = new URL(request.url);
if (url.pathname !== "/") {
return new Response(null, { status: 404 });
}
const name = url.searchParams.get("name") ?? "Name";
const title = url.searchParams.get("title") ?? "Title";
const image = url.searchParams.get("image");
const cache = await caches.open("cloudflare-ogp");
const cacheKey = new Request(url.toString());
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
const [imageType, imageBuffer] = await convertImage(image);
const ogpNode = (
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
padding: "16px 24px",
overflow: "hidden",
fontFamily: "NotoSansJP",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
border: "solid 16px #0044FF",
borderRadius: "24px",
boxSizing: "border-box",
background: "linear-gradient(to bottom right, #ffffff, #d3eef9)",
}}
>
<div
style={{
display: "flex",
flex: 1,
}}
>
{image && (
<img
style={{
borderRadius: "100%",
padding: "8px",
marginRight: "16px",
position: "absolute",
opacity: 0.4,
}}
width={480}
height={480}
src={
imageBuffer
? `data:${imageType};base64,${btoa(
Array.from(new Uint8Array(imageBuffer))
.map((v) => String.fromCharCode(v))
.join("")
)}`
: undefined
}
alt=""
/>
)}
<h1
style={{
display: "block",
flex: 1,
fontSize: 72,
alignItems: "center",
justifyContent: "center",
padding: "0 42px",
wordBreak: "break-all",
textOverflow: "ellipsis",
lineClamp: 4,
lineHeight: "64px",
}}
>
{title}
</h1>
</div>
<div
style={{
width: "100%",
justifyContent: "flex-end",
fontSize: 48,
padding: "0 32px 32px 0",
color: "#CC3344",
}}
>
{name}
</div>
</div>
</div>
);
const png = await createOGP(ogpNode, {
ctx,
scale: 0.7,
width: 1200,
height: 630,
fonts: [
"Noto Sans",
"Noto Sans Math",
"Noto Sans Symbols",
// 'Noto Sans Symbols 2',
"Noto Sans JP",
// 'Noto Sans KR',
// 'Noto Sans SC',
// 'Noto Sans TC',
// 'Noto Sans HK',
// 'Noto Sans Thai',
// 'Noto Sans Bengali',
// 'Noto Sans Arabic',
// 'Noto Sans Tamil',
// 'Noto Sans Malayalam',
// 'Noto Sans Hebrew',
// 'Noto Sans Telugu',
// 'Noto Sans Devanagari',
// 'Noto Sans Kannada',
],
emojis: [
{
url: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/",
},
{
url: "https://openmoji.org/data/color/svg/",
},
],
});
const response = new Response(png, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
date: new Date().toUTCString(),
},
cf: {
cacheEverything: true,
cacheTtl: 31536000,
},
});
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
};
export default {
fetch: outputOGP,
};
Parameters
| parameter | description |
|---|---|
| title | The title of the page |
| name | The name of the person or organization |
| image | URL of the image to be used as the Open Graph image (WebP and AVIF images are automatically converted to PNG format for processing) |
Execution Result
If deployed, please replace it with your domain name.

Conclusion
We have explained how to generate OGP images using Cloudflare Workers. When using the free plan, it can be executed up to 100,000 times per day. Additionally, if you use a custom domain and enable CDN caching, you can use it almost without limits.
Discussion