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

https://github.com/SoraKumo001/cloudflare-ogp

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.

http://127.0.0.1:8787/?title=タイトル&name=名前&image=https://raw.githubusercontent.com/SoraKumo001/cloudflare-ogp/refs/heads/master/sample/image.jpg

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.

GitHubで編集を提案

Discussion