# 中文 OG 圖為什麼變豆腐：satori 同名字型與下載順序

> 中文 OG 卡片會變豆腐，是因為 satori 的子集同名，而且字重、樣式相同。實測 satori 0.26.0 只採用先完成的那份。本文記下重現與修法。

原文：https://sheng.page/posts/og-image-tofu-and-satori-font-race/ · 發布：2026-11-13 · 作者：Sheng

10 月初，這個部落格的中文 OG 圖壞了兩次，中文全變豆腐，英文版正常。第一次我以為 build 時向 Google Fonts 抓字型失敗，加了重試，抓不到便讓 build 失敗，隔天卻又出事。查下去才發現每次下載都成功，前一天修錯了地方。

原因是 satori 會依卡片上缺字的語言片段各要一次字型，我卻把抓回來的 CJK 子集都登記成 `Noto Serif TC`。實測 satori 0.26.0 對名稱、字重和樣式相同的子集，只採用先完成的那份，這跟原始碼一致。我推測全形冒號的子集很小，常常先到，整張卡的中文就沒字型可用。最後改用本機字型切片，各自用不同名稱登記，build 時不再下載，缺字就讓 build 失敗。以下記下修正過程，以及可在本機執行的最小重現。

## 卡片原本怎麼做

本站是 Astro 靜態網站，用 Cloudflare Workers Builds 部署。OG 卡片是 1200x630 的 PNG，build 時由 satori 把節點樹轉成 SVG，再用 resvg 轉成 PNG，程式在 `src/lib/og.ts`，路由在 `src/pages/og/[lang]/[slug].png.ts`。卡片有 Great Vibes 寫的「Just a void」、副標「學海無涯」、標籤框、文章標題，以及年月日格式的日期。

拉丁字型直接用 @fontsource 的 woff 檔。整套 CJK 字型太大，舊版就在 build 時呼叫 Google Fonts 的 CSS2 API，帶上 `text=` 參數，拿到為這次請求最佳化的字型檔，官方文件說某些情況下最多可省下九成的檔案大小。當時用的 satori 不支援 woff2，為了讓 Google 回 woff，User-Agent 假裝成 Chrome 30。

satori 接受 TTF、OTF 和 WOFF 字型，以 `{ name, data, weight, style }` 物件傳入。某段文字找不到字型時，會呼叫 `loadAdditionalAsset(code, segment)`，`code` 是偵測到的語言代碼，emoji 是 `'emoji'`，`segment` 是要畫的文字。舊版的 `loadAdditionalAsset` 把 `segment` 傳給 `loadCJK(segment)`，回傳 `[{ name: 'Noto Serif TC', data, ... }]`。`loadCJK` 固定在要求的字後面加上 `：，。、；！？「」『』（）【】《》〈〉—…·`，讓每份子集都帶著常用全形標點。

每次呼叫都回傳同一個 family name，README 沒有說明兩個同名字型怎麼處理。

## 10 月 2 日：第一次修，方向錯了

〈[同一筆付款送了兩次：從 Santander 的聖誕節講冪等與重送](/posts/idempotency-and-retries/)〉發出後，中文 OG 卡全是豆腐，連副標「學海無涯」和日期裡的「年月日」都成了方框，英文卡正常。貼到 Threads 的預覽也顯示這張壞圖。

壞掉的 PNG 只有 33.5 KB，正常的約 52 KB。當時我以為 Cloudflare build 期間有一次 Google Fonts 請求失敗，`loadCJK` 吃掉錯誤、回傳 null，satori 找不到字型就畫豆腐，build 卻照樣成功。舊的專案說明甚至寫著「偶發、重 build 即可」。

當晚的 commit 040df5c 加了重試，字型抓取失敗時最多試三次，間隔 0.5 秒和 1 秒，並檢查 `res.ok`。正式 build 時抓不到就丟錯，讓部署失敗，commit message 裡寫的用意是讓前一版繼續上線；開發和測試環境維持豆腐 fallback，另外也補了測試。隔天才知道，那次下載沒有失敗。

## 10 月 3 日：下載全部成功，還是豆腐

隔天，44 張中文 OG 卡有 28 張是豆腐，正常的卡也有標題帶冒號的。每次字型下載都成功，前一天加的重試和丟錯都沒觸發。f412d80 的 commit message 記下當時的理解：satori 對每個語言片段各呼叫一次 `loadAdditionalAsset`，卡上的全形「：」或「，」自成一段，每份子集卻都登記成 `Noto Serif TC`，只含標點的子集便遮住了中文子集（shadowed）。訊息沒寫哪份先到，要到後面的重現才確認。satori 其實只對既有字型缺字的片段才呼叫這個函式，當時沒有預先登記 CJK 字型，所以每個 CJK 片段都缺字。

這一版改成整張卡只載一份涵蓋所有文字的子集，satori 問哪一段都回傳同一份。載入後用 @shuding/opentype.js 解析，任何 CJK 字沒有 glyph 就丟錯，所有環境都一樣，也不重試，satori 要求卡片文字以外的字時也丟錯。

之後我記下「這次的豆腐跟下載失敗無關，重 build 不一定會好」（重 build 可能改變下載完成的順序）。動到 OG 或字型，發文前就先看一張標題帶「：」的中文卡。

## 把舊 commit 重新 build 一次

為了寫這篇，我用 `git archive 040df5c` 取出舊版，`npm ci` 後跑 `astro build`，44 張中文卡都產生了，build 以 exit 0 結束，網路正常，每次字型請求都成功。我挑了三張來看：

- idempotency-and-retries：所有 CJK 字都是豆腐，全形冒號「：」正常，檔案是 33,536 bytes。
- jaeger-distributed-tracing-with-otel（標題「Jaeger 入門：用 OpenTelemetry 串起分散式追蹤」）：同樣全部是豆腐，冒號正常。
- postgres-scheduled-sql-pitfalls（標題「PostgreSQL 排程 SQL 的三個坑」，沒有全形標點）：正常。

![舊版 040df5c 重 build 的 idempotency 中文 OG 卡：Just a void 正常，副標、標題和日期的中文全是方框，全形冒號正常](/images/og-image-tofu-and-satori-font-race/old-card-040df5c.png)

我在暫存副本裡加了 `console.log`，印出 satori 向 `loadAdditionalAsset` 要的片段：

```text
OGSEG "同一筆付款送了兩次：從 " "學海無涯同一筆付款送了兩次從的聖誕節講冪等與重年月日"
OGSEG "同一筆付款送了兩次：從 " "："
OGSEG "PostgreSQL 排" "學海無涯排程的三個坑年月日"
OGSEG "Jaeger 入門：用 " "學海無涯入門用串起分散式追蹤年月日"
OGSEG "Jaeger 入門：用 " "："
```

第一組引號是卡片標題開頭，第二組是 satori 要的片段：副標、標題和日期的 CJK 文字合成一段，全形冒號另問一次。這次的記錄裡，標題沒有全形標點的卡只問了一次，跟上面三張的結果吻合。

## 最小重現：兩份字型的完成順序

只看片段數還解釋不通。我另寫一支重現程式，用 satori 節點重建卡片版面，省去把 HTML 轉成節點這一步，照樣向 Google Fonts 抓字型，idempotency 那張卻畫得出來。讓冒號那段不回傳任何內容，PNG 仍和前一張 byte 相同，多問一段不一定會出問題。

接著試下載完成的順序。`race.mjs` 把 @fontsource 的兩個切片都登記成 `Noto Serif TC`，切片 120 有「幾布落」，切片 123 有「：」。兩次執行只差在第二次的 CJK `loadAdditionalAsset` 多等 50 毫秒，用來模擬大子集下載較慢。

```js
// Two segments, two subsets, one family name. The only difference between runs is
// which loadAdditionalAsset promise settles first.
import { readFileSync, writeFileSync } from 'node:fs';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

const slice = (n) => readFileSync(`node_modules/@fontsource/noto-serif-tc/files/noto-serif-tc-${n}-400-normal.woff`);
const latin = readFileSync('node_modules/@fontsource/noto-serif/files/noto-serif-latin-400-normal.woff');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const text = '幾布落：';            // 幾布落 live in slice 120, the full-width colon in slice 123

for (const slowFirst of [false, true]) {
  const order = [];
  const svg = await satori(
    { type: 'div', props: { style: { display: 'flex', fontSize: 64, background: '#fff', padding: 40 }, children: text } },
    {
      width: 600,
      height: 160,
      fonts: [{ name: 'Noto Serif', data: latin, weight: 400, style: 'normal' }],
      loadAdditionalAsset: async (code, segment) => {
        const isColon = segment === '：';
        if (slowFirst && !isColon) await sleep(50);   // the big subset takes longer to download
        order.push(segment);
        return [{ name: 'Noto Serif TC', data: slice(isColon ? 123 : 120), weight: 400, style: 'normal' }];
      },
    },
  );
  const file = `race-${slowFirst ? 'colon-first' : 'han-first'}.png`;
  writeFileSync(file, new Resvg(svg, { font: { loadSystemFonts: false } }).render().asPng());
  console.log(`${file}: settled in order ${order.map((s) => JSON.stringify(s)).join(' then ')}`);
}
```

用 `npm i satori@0.26.0 @resvg/resvg-js@2.6.2 @fontsource/noto-serif-tc@5.3.0 @fontsource/noto-serif@5.3.0` 安裝套件，在 Mac 的 Node 26.13.0 和 Linux 容器的 Node 22.23.4 執行，輸出一致，兩邊產出的 PNG 也跟下圖 byte 相同：

```text
race-han-first.png: settled in order "幾布落" then "："
race-colon-first.png: settled in order "：" then "幾布落"
```

![race-han-first.png：「幾布落」三字正常，全形冒號是方框](/images/og-image-tofu-and-satori-font-race/race-han-first.png)

![race-colon-first.png：「幾布落」是三個方框，全形冒號正常](/images/og-image-tofu-and-satori-font-race/race-colon-first.png)

CJK 子集先回來時，「幾布落」正常，冒號是豆腐；冒號子集先回來，「幾布落」就成了三個方框，冒號正常。兩份子集的字重、樣式相同，也都沒指定語言，satori 在同一個 family name 下選中先 settle 的那份；後到的也存進去了，卻不會用來補缺字。README 沒有寫，原始碼則能解釋：0.26.0 的 `src/satori.ts` 把每個 `loadAdditionalAsset` 的結果依 promise 完成順序放進同一個陣列，全部完成後才交給 `addFonts`；`src/font.ts` 從同名的第一份開始挑，只有字重或樣式更接近要求時才換下一份，兩份都是 400 normal，就一直選先到的那份。原始碼沒有註解說明這是刻意設計還是副作用。f412d80 的 commit message 只寫標點子集遮住了中文子集，實測和原始碼才確認，被選中的是先到的那份。

對照舊版 build，我推測冒號子集只含固定那串標點，檔案很小，通常先回來，帶全形標點的卡便常常壞掉，下載順序偶爾反過來才正常。這個推測能解釋 44 張裡只有 28 張壞掉，也能解釋為什麼有些帶冒號的卡是正常的。但我無法重放 10 月 3 日那次 build 的時序，這部分仍是推測。

## 現在的做法

f412d80 仍在 build 時抓字型，同晚的 1b7f522 才移除這一步，現在的 `og.ts` 沿用這個做法。

Noto Serif TC 改用 @fontsource/noto-serif-tc 的 woff 檔，依 unicode-range 分成一百多個切片，由 package-lock 鎖定版本。每個切片各用不同名稱登記，從 `Noto Serif TC 0`、`Noto Serif TC 1` 排下去，satori 缺字時沿這串名稱往後 fallback。AGENTS.md 把同字重、同樣式的限制簡寫成：「satori 每個 family name 只保留一個字型，其他名稱都當 fallback，所以每個切片要用不同名稱」。Great Vibes 最後登記，讓拉丁和 CJK 字型優先補位。

`loadAdditionalAsset` 現在對 emoji 回傳空字串，其餘情況丟出 `OG card text has no glyph in the bundled fonts`，讓 build 失敗，不產出豆腐。兩條測試分別要求韓文標題「한국어 제목」丟錯，中文卡在 fetch 被 stub 成 reject 時仍正常產出，且未呼叫 fetch。`og.ts` 的註解寫著：「The build never fetches a font, so the same commit always renders the same cards and a network blip cannot produce tofu.」

同一天也關掉 resvg 的系統字型掃描。satori 已把字轉成路徑，resvg 不必再找字型，每張卡省約 0.4 秒，整個 build 從 63 秒降到 28 到 34 秒（commit f777174）。

## 專案裡留下的檢查

這些檢查後來寫進 repo 規則，用 satori 產 CJK 卡片時也可參考。除了看帶冒號的卡，還要看 PNG 大小與 bytes：這次的豆腐卡壓縮後小很多，33 KB 對 52 KB，可當線索；fetch 成功仍可能拿到缺字的子集，要確認 satori 拿到的 bytes 有所需 glyph。修好卡片後，也要檢查 Threads、Facebook 等平台的預覽是否仍是舊圖。Facebook 可用 Meta Sharing Debugger 要求重抓，其他平台依各自支援的方式處理。

## 補充筆記

- 舊版在 build 時向 Google Fonts 抓 CJK 子集，全登記成 `Noto Serif TC`。
- 在這次字型配置下，satori 對缺字的語言片段各呼叫一次 `loadAdditionalAsset`，全形冒號自成一段，帶「：」的卡便抓兩份子集。
- 子集同名且字重、樣式相同，實測 satori 0.26.0 採用先 settle 的那份，後到的無法補位；我推測冒號子集小，通常先到，中文就沒字型可用。
- 10 月 2 日加的重試與丟錯沒觸發，因為每次下載都成功，隔天 44 張卡仍壞了 28 張。
- 現在 build 不抓字型，@fontsource 每個切片各用不同名稱，缺 glyph 就讓 build 失敗。
- 動過字型後先看帶「：」的中文卡與 PNG 大小，修好後再查社群預覽是否需要重新抓取。

## 延伸閱讀

- Vercel〈[satori README](https://github.com/vercel/satori)〉：列出支援的字型格式、`fonts` 物件與 `loadAdditionalAsset` 介面。連結指向現行版，格式支援與本文採用的舊版不同；兩版都未說明同名、同字重與同樣式的字型怎麼取捨。
- Vercel〈[satori 0.26.0 src/font.ts](https://github.com/vercel/satori/blob/0.26.0/src/font.ts)〉：同名字型存成陣列，`get` 從第一份挑，字重與樣式相同時不換；搭配同版本的 [src/satori.ts](https://github.com/vercel/satori/blob/0.26.0/src/satori.ts) 看 `loadAdditionalAsset` 的結果如何依完成順序加入。
- Google Fonts〈[Google Fonts CSS2 API](https://developers.google.com/fonts/docs/css2)〉：舊版用來拿子集的 `text=` 參數。
- Wikipedia〈[Noto fonts](https://en.wikipedia.org/wiki/Noto_fonts)〉：「豆腐」的說法與 Noto 名字的來源 no tofu。
- 維基百科〈[競爭危害](https://zh.wikipedia.org/zh-tw/競爭危害)〉：結果取決於無法控制的事件順序或時機，本文的字型下載便是一例。
- ogp.me〈[The Open Graph protocol](https://ogp.me)〉：`og:image` 是四個必要屬性之一，用來指定代表頁面的圖片。

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 起草 Claude Opus，統籌 Claude Fable，Grok（grok-4.7）查資料與調語氣，Codex（gpt-6-astra）審稿與精簡，Claude Opus 5.5 二審；範例在 satori 0.26.0、@resvg/resvg-js 2.6.2、@fontsource/noto-serif-tc 5.3.0、Node 26.13.0 與 Node 22.23.4 實測。