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

本頁目錄
引言
我在 X 上看到有人分享一篇 Qiita 文章,連結預覽卡片的圖片裡直接畫著 Qiita 的 logo 和文章標題;而這個部落格的連結卻只會顯示一張封面圖,顯得有些單調。
這個部落格的封面圖來自一份依主題共用的登錄表,所以所有 Astro 文章不管分享哪一篇,展開後都是同一個黑底的 Astro logo。當初建立這份登錄表時,我認為封面圖可以直接拿來當 Open Graph 圖片,還在文章裡寫過原本打算用 Satori、結果用不到。
現在,每篇文章的三種語言版本都各有一張專屬的 1200×630 圖卡,在建置時由 Satori 和 resvg 畫出來:上面有文章標題、網站的印章,還有縮成小卡的主題封面圖。分享出去的連結一看就知道是哪一篇文章;如果更新文章的標題或封面圖的話,下次建置時圖卡也會跟著更新。

一般的網站通常在 og:image 裡放什麼?
og:image 是 Open Graph 的 meta 標籤,用來指定連結被分享時爬蟲要顯示的圖片。文章本身沒有專屬圖片的網站,多半會自動產生一張帶標題的圖卡;每篇文章都有封面圖的網站,則直接拿封面圖來用。我請 Claude Code 調查了其他網站的做法。
下面這幾個網站會自動產生圖卡,但做法各不相同。
GitHub:大多數人從來沒有替自己的 repository 上傳自訂圖片,所以 GitHub 建了一套服務,把 repository 的名稱、說明、擁有者頭像和各項數字填進 HTML 範本,再用 Puppeteer 截圖成圖卡。

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

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

Zenn 和 Qiita 都不是自己畫圖卡,而是交給第三方的圖片處理服務:Zenn 用 Cloudinary,Qiita 用 imgix。這類服務會依照網址裡的參數即時合成圖片,再從自己的 CDN 提供出去。
例如在 note 這類每篇文章都有封面圖的網站,作者會替每篇文章上傳封面圖片,而這些圖片就會直接成為 Open Graph 圖片。
這個部落格為什麼共用封面圖
這個部落格的文章沒有專屬圖片,卻和 note 一樣直接拿封面圖當 Open Graph 圖片。
我製作這個部落格時,沒有為每一篇文章設計專屬的圖片,而是讓同一主題的所有文章共用一張封面圖。一方面是因為我希望從文章列表就能看出每篇文章的主題;另一方面,如果每篇文章的封面風格都不一樣,看起來會很雜亂。
但是分享到社群媒體時,貼文附上的 Open Graph 圖片缺乏辨識度,很難看出是我部落格裡的哪一篇文章。下面是同一篇 pnpm 文章在 Slack 上展開的前後對比:


Slack 會在圖片上方另外顯示 og:title,所以在 Slack 上標題會出現兩次,不過我覺得這是可以接受的。
圖卡的構成:標題、印章,以及縮成小卡的封面圖
圖卡把封面圖保留在右上角,縮成一張小卡。因為每篇文章本來就有封面圖,所以我把標題和封面小卡放在同一張圖卡上。
每張封面圖都是 1200×630 的橫式 logo 卡片。左邊已經放了部落格的 logo 了,所以我把整張封面圖縮小到 320×168,當成標示主題的徽章放在右側。
其餘的版面取決於 X 把自己的文字放在哪裡:在大尺寸的連結卡片上,X 會把 og:title 疊在卡片底部,就是開頭那張 X 截圖裡的深色橫條。所以圖卡上的任何內容都不能超出 504px 這條安全線,也就是底部五分之一的上緣。標題放在封面圖小卡下方和這條安全線之間的空間裡。
這個部落格的文章標題大多是 Main — subtitle 的形式。圖卡會在破折號處把標題拆開:主標題用大號粗體,大小介於 80px 到 48px 之間;副標題用 30px 的低調灰色。頂端 12px 的朱紅色色條,顏色和這個部落格 logo 的印章一致。

每種語言各自使用一個預先渲染的端點
每張 Open Graph 圖卡都是 pnpm build 時由 Astro 端點寫出的靜態檔案。這個端點是一條 .png.ts 路由:Astro 在建置時會呼叫它的 GET,把回傳的 PNG 存成靜態檔案;之後 X 等網站來抓 Open Graph 圖卡時,拿到的就是這個檔案。
Open Graph 圖卡的路由和文章頁面,都從同一個函式 getArticlePaths 取得文章清單。所以每篇已發布的文章在建置時會產出一個 HTML 頁面和一張 Open Graph 圖卡,草稿則兩者都不會有。
每種語言只需要用一個路由檔,就可以直接呼叫共用的函式:
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 這個模組畫出來,分成兩步:
- 用 Satori 排版:Satori 是 Vercel 開源的函式庫,能夠把用 JSX 和 CSS 描述的版面轉換成 SVG。這裡不需要 React,直接用
{ type, props }這種純物件描述版面,交給 Satori 就好。 - 用 resvg 轉成 PNG:
@resvg/resvg-js把 Satori 輸出的 SVG 點陣化成 PNG,因為 X 的og:image只接受 JPG、PNG、WEBP 和 GIF,不接受 SVG。
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 會依序做這幾件事:
- 把文章標題在
—拆成主標題和副標題。 - 依照標題長度和剩下的高度,決定標題的字級。
- 用
Intl.Segmenter把中日文的詞語黏在一起,避免詞語中間換行。 - 只載入圖卡文字用得到的字型子集,並替每個檔案取不同的名稱。
- 用
sharp把封面圖縮成小卡,和標題、印章一起組成{ type, props }版面。 - 交給 Satori 排版成 SVG,再由 resvg 轉成 PNG。
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 圖卡:
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。
一個字重的 WOFF 檔案加起來約 5.2MB,每張圖卡都全部載入太浪費,所以 og-card.ts 會先讀取該字重的 CSS,只挑出字元範圍涵蓋圖卡文字的那些子集交給 Satori。
把這些子集都用共同的字型家族名稱 Noto Sans JP 登錄後,畫出來的是這樣:

解決方法是替每個子集取不同的名稱,再把所有名稱都列進 fontFamily。遇到多個字型家族組成的清單時,Satori 會一個字一個字往下找,直到找到有這個字的字型為止:
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:
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 的圖卡把「検証」拆成兩行,也是同樣的問題。下面這個繁體中文標題就在「五次」中間折行,「五」留在第一行行尾,「次」跑到了第二行開頭:

瀏覽器和 Node.js 都內建了 Intl.Segmenter,指定 granularity: "word" 時,它會用日文和中文的詞典來斷詞。og-card.ts 先用它把標題斷詞,再在每個中日韓詞語的字元之間插入 U+2060 WORD JOINER。這是一個看不見的字元,當它夾在兩個字之間時,那裡就不能換行:
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,選出第一個預估高度放得進剩餘空間的字級:
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 標題列上方剩下的高度來決定。



