# 用 Astro 和 Satori 為每篇文章產生 Open Graph 圖卡 — 支援中日英三種語言，以及 X 疊在圖上的標題列

> 在 Astro 建置時，用 Satori 和 resvg 替每篇文章的三種語言版本各產生一張 1200x630 的 Open Graph 圖卡，日文和中文字型都能正確顯示，文字也不會被 X 的標題列擋住。

- Source: https://oharu121.com/zh-tw/blog/astro-satori-open-graph-card-cjk-fonts-x-overlay/
- Published: 2026-10-17T23:01:44+09:00
- Tags: Astro, Satori, Open Graph

---
## 引言

我在 X 上看到有人分享一篇 Qiita 文章，連結預覽卡片的圖片裡直接畫著 Qiita 的 logo 和文章標題；而這個部落格的連結卻只會顯示一張封面圖，顯得有些單調。

這個部落格的封面圖來自一份依主題共用的登錄表，所以所有 Astro 文章不管分享哪一篇，展開後都是同一個黑底的 Astro logo。當初建立這份登錄表時，我認為封面圖可以直接拿來當 Open Graph 圖片，還在文章裡[寫過原本打算用 Satori、結果用不到](/zh-tw/blog/astro-content-collections-no-database-mdx-tags-cjk-fonts/)。

現在，**每篇文章的三種語言版本都各有一張專屬的 1200×630 圖卡，在建置時由 Satori 和 resvg 畫出來**：上面有文章標題、網站的印章，還有縮成小卡的主題封面圖。分享出去的連結一看就知道是哪一篇文章；如果更新文章的標題或封面圖的話，下次建置時圖卡也會跟著更新。

*Image: X 上一則連到這個部落格文章的貼文。圖卡上有印章與網站名稱、縮成小卡的 Astro 封面圖，以及大字的文章標題與副標題；X 的深色標題列落在圖卡底部留白的區域*

*完成後的圖卡在 X 上的樣子。X 自己的標題列剛好落在圖卡刻意留白的底部。*

## 一般的網站通常在 og:image 裡放什麼？

`og:image` 是 Open Graph 的 meta 標籤，用來指定連結被分享時爬蟲要顯示的圖片。**文章本身沒有專屬圖片的網站，多半會自動產生一張帶標題的圖卡；每篇文章都有封面圖的網站，則直接拿封面圖來用**。我請 Claude Code 調查了其他網站的做法。

下面這幾個網站會自動產生圖卡，但做法各不相同。

GitHub：大多數人從來沒有替自己的 repository 上傳自訂圖片，所以 GitHub [建了一套服務](https://github.blog/open-source/git/framework-building-open-graph-images/)，把 repository 的名稱、說明、擁有者頭像和各項數字填進 HTML 範本，再用 Puppeteer 截圖成圖卡。

*Image: repository oharu121/cli-ascii-logo 的 GitHub 圖卡：大字的 repository 名稱、說明、擁有者頭像，以及貢獻者、issue、星星和 fork 的數量*

*我其中一個 repository 的 GitHub 圖卡。*

Zenn 的圖卡上有標題、作者的頭像和名稱，以及 Zenn 的 logo。它的 `og:image` 是一個 Cloudinary 網址，網址參數會把標題當成文字圖層，疊在固定的背景上。

*Image: 一篇日文文章的 Zenn 圖卡：藍紫漸層背景上的白色卡片寫著標題，左下是作者頭像與名稱，右下是 Zenn 的 logo*

*我在 Zenn 上其中一篇文章的圖卡。*

Qiita 的圖卡把 logo 放在標題上方，作者的頭像和帳號放在下方。它的 `og:image` 也是用網址參數合成的，只是換成 imgix：背景、頭像和標題文字都在網址裡指定。

*Image: 同一篇日文文章的 Qiita 圖卡：上方是 Qiita 的 logo，標題折成三行，第三行只有「証」，下面是作者頭像與帳號*

*同一篇文章在 Qiita 上的圖卡，「検証」被拆成了兩行。*

**Zenn 和 Qiita 都不是自己畫圖卡，而是交給第三方的圖片處理服務**：Zenn 用 Cloudinary，Qiita 用 imgix。這類服務會依照網址裡的參數即時合成圖片，再從自己的 CDN 提供出去。

例如在 note 這類每篇文章都有封面圖的網站，作者會替每篇文章上傳封面圖片，而這些圖片就會直接成為 Open Graph 圖片。

### 這個部落格為什麼共用封面圖

**這個部落格的文章沒有專屬圖片，卻和 note 一樣直接拿封面圖當 Open Graph 圖片**。

我製作這個部落格時，沒有為每一篇文章設計專屬的圖片，而是讓同一主題的所有文章共用一張封面圖。一方面是因為我希望從文章列表就能看出每篇文章的主題；另一方面，如果每篇文章的封面風格都不一樣，看起來會很雜亂。

但是分享到社群媒體時，**貼文附上的 Open Graph 圖片缺乏辨識度**，很難看出是我部落格裡的哪一篇文章。下面是同一篇 pnpm 文章在 Slack 上展開的前後對比：

*Image: 一篇 pnpm 12 文章的 Slack 連結預覽：標題與說明以文字顯示，下方的圖片是共用的 pnpm logo 封面圖*

*修改前：圖片是 pnpm 的封面圖，站上每一篇 pnpm 文章都用這一張。*

*Image: 同一個 Slack 預覽換成了產生的圖卡：印章與網站名稱、縮成小卡的 pnpm 封面圖，以及大字的文章標題與副標題*

*修改後：產生的圖卡上有標題，光看圖片就知道是哪一篇文章。*

Slack 會在圖片上方另外顯示 `og:title`，所以在 Slack 上標題會出現兩次，不過我覺得這是可以接受的。

## 圖卡的構成：標題、印章，以及縮成小卡的封面圖

**圖卡把封面圖保留在右上角，縮成一張小卡**。因為每篇文章本來就有封面圖，所以我把標題和封面小卡放在同一張圖卡上。

每張封面圖都是 1200×630 的橫式 logo 卡片。左邊已經放了部落格的 logo 了，所以我把整張封面圖縮小到 320×168，當成**標示主題的徽章**放在右側。

*Figure — CardAnatomy: 標題區能用的高度，就是小卡下緣到 X 標題列起點之間剩下的空間。*

其餘的版面取決於 X 把自己的文字放在哪裡：在大尺寸的連結卡片上，X 會把 `og:title` 疊在卡片底部，就是開頭那張 X 截圖裡的深色橫條。所以**圖卡上的任何內容都不能超出 504px 這條安全線，也就是底部五分之一的上緣**。標題放在封面圖小卡下方和這條安全線之間的空間裡。

這個部落格的文章標題大多是 `Main — subtitle` 的形式。圖卡會在破折號處把標題拆開：主標題用大號粗體，大小介於 80px 到 48px 之間；副標題用 30px 的低調灰色。頂端 12px 的朱紅色色條，顏色和這個部落格 logo 的印章一致。

*Image: 三張產生的圖卡上下排列：英文標題介紹連結圖示，附 Astro 小卡；日文標題介紹 GROUP BY ROLLUP，附 LLM 小卡；繁體中文標題介紹 Claude Code 的 logo skill，附 LLM 小卡*

*每種語言各一張。繁體中文的圖卡用 Noto Sans TC；另外兩張用 Noto Sans JP，因為這個字型也涵蓋了英文的拉丁字母。*

## 每種語言各自使用一個預先渲染的端點

每張 Open Graph 圖卡都是 `pnpm build` 時由 Astro 端點寫出的靜態檔案。這個端點是一條 `.png.ts` 路由：Astro 在建置時會呼叫它的 `GET`，把回傳的 PNG 存成靜態檔案；之後 X 等網站來抓 Open Graph 圖卡時，拿到的就是這個檔案。

**Open Graph 圖卡的路由和文章頁面，都從同一個函式 `getArticlePaths` 取得文章清單**。所以每篇已發布的文章在建置時會產出一個 HTML 頁面和一張 Open Graph 圖卡，草稿則兩者都不會有。

*Figure — CardPipeline: 同一份文章清單同時供給兩條路由，每種語言各執行一次。*

每種語言只需要用一個路由檔，就可以直接呼叫共用的函式：

```ts title="src/pages/ja/og/[slug].png.ts"
import type { APIContext } from "astro";
import { ogCardPaths, ogCardResponse } from "@/lib/og-card";

export const getStaticPaths = () => ogCardPaths("ja");
export const GET = (context: APIContext) => ogCardResponse("ja", context);
```

圖卡由 `src/lib/og-card.ts` 這個模組畫出來，分成兩步：

1. **用 Satori 排版**：Satori 是 Vercel 開源的函式庫，能夠把用 JSX 和 CSS 描述的版面轉換成 SVG。這裡不需要 React，直接用 `{ type, props }` 這種純物件描述版面，交給 Satori 就好。
2. **用 resvg 轉成 PNG**：`@resvg/resvg-js` 把 Satori 輸出的 SVG 點陣化成 PNG，因為 X 的 `og:image` 只接受 JPG、PNG、WEBP 和 GIF，不接受 SVG。

> **話說**
>
> **Satori 幫我們省下了什麼**
>
> 用 CSS 的方式排版、再輸出成點陣圖，成本並不低。直接用 `node-canvas` 之類的工具畫也可以，但換行、多行省略和 flexbox 排版都得自己寫。Satori 包辦了排版，只輸出 SVG；把 SVG 轉成 PNG 則交給 resvg。Vercel 的 `@vercel/og` 也是把同樣這兩步包成一個函式，但我選擇直接使用 Satori 和 resvg，因為這樣有更多彈性。

```ts title="src/lib/og-card.ts"
const tree = el("div", { width: 1200, height: 630, display: "flex", flexDirection: "column", padding: 64 }, [
  el("div", { display: "flex", justifyContent: "space-between" }, [
    sealAndSiteName,
    { type: "img", props: { src: tileDataUri, width: 320, height: 168 } },
  ]),
  el("div", { fontSize: size, fontWeight: 700, lineClamp: maxLines }, title),
  el("div", { fontSize: 30, color: "#475569", lineClamp: 2 }, subtitle),
]);
const svg = await satori(tree, { width: 1200, height: 630, fonts });
const png = new Resvg(svg).render().asPng();
```

Satori、resvg 以及字型都列在 devDependencies，只在建置時使用，不會被打包進網站另外部署的無伺服器函式裡。從建置紀錄來看，每張圖卡大約要花 25ms 產生。

產生一張圖卡時，`og-card.ts` 會依序做這幾件事：

1. 把文章標題在 ` — ` 拆成主標題和副標題。
2. 依照標題長度和剩下的高度，決定標題的字級。
3. 用 `Intl.Segmenter` 把中日文的詞語黏在一起，避免詞語中間換行。
4. 只載入圖卡文字用得到的字型子集，並替每個檔案取不同的名稱。
5. 用 `sharp` 把封面圖縮成小卡，和標題、印章一起組成 `{ type, props }` 版面。
6. 交給 Satori 排版成 SVG，再由 resvg 轉成 PNG。

```ts title="src/lib/og-card.ts"
async function renderOgCard(entry: BlogEntry, locale: Locale): Promise<Buffer> {
  const { title, thumbnail } = entry.data;
  // 1. 拆開「主標題 — 副標題」
  const [rawMain, ...rest] = title.split(/\s+—\s+/);
  const rawSub = rest.length ? rest.join(" — ") : undefined;
  // 2. 決定標題字級
  const { size, maxLines } = fitTitle(rawMain, rawSub, locale);
  // 3. 把中日文詞語黏在一起
  const main = glue(rawMain, locale);
  const sub = rawSub && glue(rawSub, locale);
  // 4. 只載入用得到的子集
  const fonts = [...fontsFor(locale, 700, main + brand + "…" + WJ) /* … */];
  // 5. 組出版面，包含封面圖小卡
  const tree = el("div", { /* … */ }, [/* seal, await thumbnailTile(thumbnail), main, sub */]);
  // 6. 排版成 SVG，再點陣化成 PNG
  const svg = await satori(tree, { width: W, height: H, fonts });
  return new Resvg(svg, { fitTo: { mode: "width", value: W } }).render().asPng();
}
```

**第 3、第 4 步對應下面的兩個陷阱；第 2 步則在最後一節說明**。

封面圖小卡的圖檔，是用登錄表的 key（例如 `astro-generic`）直接在 `src/assets/thumbnails/` 裡找到 `<key>.png` 或 `<key>.webp` 後取得的。找到之後，`sharp` 會把圖縮放到 320×168 並轉成 PNG，再交給 Satori 處理。

最後，在文章版面裡把 `og:image` 從縮放過的封面圖改成這次新增的 Open Graph 圖卡：

```astro title="src/layouts/ArticleLayout.astro" del={1-2} ins={3}
const ogAsset = await getImage({ src: cover.src, width: 1200, height: 630, format: "png" });
const ogImage = new URL(ogAsset.src, Astro.site).href;
const ogImage = new URL(localeUrl(locale, `/og/${slug}.png`), Astro.site).href;
```

## 使用 Satori 時遇到的兩個陷阱

這兩個問題都發生在這個部落格的真實標題上，而且都不會報錯，照樣產出圖片。

### 陷阱 1：Satori 讓 Open Graph 圖卡出現了豆腐字

**Satori 每個字型家族名稱只會載入一個字型檔，所以拆成多個檔案的字型，必須用多個名稱登錄**。Satori 只畫得出有提供字型的字元，建置時也沒有系統字型可以替補；如果字元找不到相對應的字型就會被畫成一個空心方框，也就是俗稱的「豆腐字」。

字型我選擇的是 Fontsource 的套件 `@fontsource/noto-sans-jp` 和 `@fontsource/noto-sans-tc`。Fontsource 把 Google Fonts 等開源字型包成 npm 套件，所以字型檔不必放進 repository，建置時也不用另外下載。加上 Noto Sans TC，是因為 Noto Sans JP 缺少「查」「夠」這類中文字形，這些字在日文裡寫法不同，或根本不存在。

Fontsource 會把每個字重依 `unicode-range`（每個檔案負責的字元範圍）拆成多個子集，光是 Noto Sans JP 的 Bold 就有約 125 個檔案，每個都同時提供 WOFF 和 WOFF2。Satori 能讀 TTF、OTF 和 WOFF，但讀不了 WOFF2。

> **話說**
>
> **字型為什麼要拆成字重和子集**
>
> 字重是字型的粗細，例如 Regular（400）和 Bold（700）；每種粗細的字形都不一樣，所以各自是一組檔案。子集則是把一個字重再依字元範圍切成許多小檔案。日文字型有上萬個字形，整套放在一個檔案裡就有好幾 MB；拆開之後，瀏覽器只會下載頁面上用得到的那幾個子集。Satori 不會自己挑，所以 `og-card.ts` 得替它做瀏覽器原本會做的這件事。

一個字重的 WOFF 檔案加起來約 5.2MB，每張圖卡都全部載入太浪費，所以 `og-card.ts` 會先讀取該字重的 CSS，只挑出字元範圍涵蓋圖卡文字的那些子集交給 Satori。

*Figure — FontSubsets: 只有圖卡文字用得到的子集，會各自取不同的名稱後交給 Satori。*

把這些子集都用共同的字型家族名稱 `Noto Sans JP` 登錄後，畫出來的是這樣：

*Image: 一張日文標題的圖卡，網站名稱、標題和副標題幾乎所有字元都畫成空心方框，只有少數幾個漢字正常顯示*

*共用同一個名稱的子集裡，Satori 只載入了其中一個；只有剛好在那個子集裡的少數字元顯示得出來。*

解決方法是替每個子集取不同的名稱，再把所有名稱都列進 `fontFamily`。遇到多個字型家族組成的清單時，Satori 會一個字一個字往下找，直到找到有這個字的字型為止：

```ts title="src/lib/og-card.ts" ins={6}
return faces(pkg, family, weight)
  .filter(({ ranges }) => cps.some((cp) => ranges.some(([a, b]) => cp >= a && cp <= b)))
  .map(({ family, file }) => {
    let data = fileCache.get(file);
    if (!data) fileCache.set(file, (data = readFileSync(file)));
    return { name: `${family} ${file}`, weight, style: "normal", data };
  });
```

最後，在圖卡最外層的元素上，把這些名稱全部加上引號、用逗號隔開，設定成 `fontFamily`：

```ts title="src/lib/og-card.ts"
const stack = [...new Set(fonts.map((f) => `"${f.name}"`))].join(", ");
// → "Noto Sans JP …/noto-sans-jp-0-700-normal.woff", "Noto Sans JP …/noto-sans-jp-latin-700-normal.woff", …

const tree = el("div", { /* … */ fontFamily: stack }, [/* … */]);
```

### 陷阱 2：Satori 會在日文和中文詞語的任何地方換行

**Satori 把任兩個中日韓字元之間都當成可以換行的位置**，因為它沒有日文或中文的詞典。前面 Qiita 的圖卡把「検証」拆成兩行，也是同樣的問題。下面這個繁體中文標題就在「五次」中間折行，「五」留在第一行行尾，「次」跑到了第二行開頭：

*Image: 一張繁體中文標題的圖卡，第一行結尾是「五」，第二行開頭是「次」，把「五次」這個詞拆開了*

*換行落在「五」和「次」之間，把這個詞給拆開了。*

瀏覽器和 Node.js 都內建了 `Intl.Segmenter`，指定 `granularity: "word"` 時，它會用日文和中文的詞典來斷詞。`og-card.ts` 先用它把標題斷詞，再在每個中日韓詞語的字元之間插入 U+2060 WORD JOINER。這是一個看不見的字元，當它夾在兩個字之間時，那裡就不能換行：

```ts title="src/lib/og-card.ts"
const CJK = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
const WJ = "\u2060";

function glue(text: string, locale: Locale): string {
  const seg = new Intl.Segmenter(FONT_PKG[locale].segmenter, { granularity: "word" });
  return [...seg.segment(text)]
    .map(({ segment }) => (CJK.test(segment) ? [...segment].join(WJ) : segment))
    .join("");
}
```

同一個標題現在會在詞與詞之間換行，變成「是從 / 五次走錯路」。**WORD JOINER 本身也是一個字元，所以也要放進用來挑選字型子集的文字裡**；否則會因為找不到它的字型，而被畫成一個方框。

## 依安全線上方的高度決定標題字級

**標題的字級是根據安全線（504px）上方剩下的高度來選的**。字級越大，標題就會折成越多行，所以要用「行數 × 字級」算出的總高度來判斷放不放得下。

標題區從 272px 開始，上面依序是 12px 的色條、64px 的留白、168px 的小卡和 28px 的間距，所以到安全線為止有 232px。`og-card.ts` 會先扣掉副標題需要的空間，再從 80px 開始，依序試 72、64、56、48px，選出第一個預估高度放得進剩餘空間的字級：

```ts title="src/lib/og-card.ts"
function fitTitle(main: string, sub: string | undefined, locale: Locale) {
  const subH = sub ? SUB_GAP + 2 * SUB_SIZE * SUB_LH : 0;
  const budget = SAFE_BOTTOM - TEXT_TOP - subH;
  const widest = longestWord(main, locale);
  const size =
    [80, 72, 64, 56, 48].find(
      (s) => lines(main, s) * s * lineHeight(s) <= budget && widest * s * 0.5 <= LINE_W,
    ) ?? 48;
  const maxLines = Math.max(1, Math.floor(budget / (size * lineHeight(size))));
  return { size, maxLines };
}
```

**副標題一律預留兩行的空間**，因為它的 `lineClamp`（最多顯示幾行，超過就以刪節號截斷）設成了 2。所以就算副標題比預期多折一行、佔了兩行，也放得下。

標題本身也有上限：`fitTitle` 會算出剩餘空間放得下的行數 `maxLines`，並設成標題的 `lineClamp`。所以**就算預估錯了，文字也只會以刪節號結尾，不會跑進 X 的標題列**。目前這個網站建置出來的所有圖卡中，位置最低的文字（標題或副標題）在 y=488，仍在安全線之上。

`fitTitle` 裡的 `widest`（標題中最長那個詞的寬度）是為了處理 WORD JOINER 的副作用而設的。由於黏在一起的詞無法在中間換行，一個很長的片假名複合詞如果用 80px 排，就會超出右邊界。所以字級會依據最長的那個詞一級一級往下調，直到它不會超出一行的寬度為止。

## 總結

這個部落格的封面圖依主題共用，看得出文章的主題，卻看不出是哪一篇。Open Graph 圖卡主要是在社群媒體分享時使用的，和封面圖本來就是為了不同目的而存在。現在每篇文章都有一張自動產生的 Open Graph 圖卡：**封面圖縮成右上角的小卡，標題直接寫在圖卡上**，分享出去的連結一看就知道是哪一篇文章。

圖卡在建置時，由每種語言各自的預先渲染端點準備好版面，再交給 Satori 和 resvg 畫出來。要讓中日文標題正確顯示，還需要兩件 Satori 自己不會做的事：替每個 Fontsource 子集取不同的字型名稱，以及用 `Intl.Segmenter` 插入 WORD JOINER，讓詞語保持完整。另外，標題字級需要依照 X 標題列上方剩下的高度來決定。

## 參考連結

- [Satori：把 JSX 和 CSS 的版面轉成 SVG 的函式庫，含字型與 CSS 支援說明](https://github.com/vercel/satori)
- [resvg-js：在 Node.js 中把 SVG 轉成 PNG](https://github.com/thx/resvg-js)
- [Fontsource：以 unicode-range 子集自行託管的 Noto Sans JP](https://fontsource.org/fonts/noto-sans-jp)
- [MDN：Intl.Segmenter 的詞語斷詞](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter)
- [GitHub Blog：產生 Open Graph 圖片的框架](https://github.blog/open-source/git/framework-building-open-graph-images/)
- [TechCrunch：X 一度拿掉連結預覽的標題，之後在網頁版改成疊在圖片上放回（1 月 5 日更新）](https://techcrunch.com/2024/01/03/x-briefly-brought-back-headlines-to-link-previews-but-theyre-gone-again/)
