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

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

白色卡片上的 Open Graph 協定標誌,深灰色六角形裡是藍色地球儀,兩側各連著一個圓形節點,旁邊是深灰色的 Open Graph 字樣
本頁目錄

引言

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

這個部落格的封面圖來自一份依主題共用的登錄表,所以所有 Astro 文章不管分享哪一篇,展開後都是同一個黑底的 Astro logo。當初建立這份登錄表時,我認為封面圖可以直接拿來當 Open Graph 圖片,還在文章裡寫過原本打算用 Satori、結果用不到。

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

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

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

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

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

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

GitHub:大多數人從來沒有替自己的 repository 上傳自訂圖片,所以 GitHub 建了一套服務,把 repository 的名稱、說明、擁有者頭像和各項數字填進 HTML 範本,再用 Puppeteer 截圖成圖卡。

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

我其中一個 repository 的 GitHub 圖卡。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

圖卡的構成:標題、印章,以及縮成小卡的封面圖

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

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

Open Graph 圖卡的構成按比例畫出的 1200×630 卡片。最上方有一條細細的朱紅色色條。左上是印章和網站名稱,右上是 320×168 的封面圖小卡。下方從 y=272 開始是標題區,接著是副標題區,兩者一起使用到 y=504 虛線安全線為止的 232px。安全線以下的底部五分之一加上了網底,是 X 顯示 og:title 的區域。12px 色條桜oharu tech blog封面圖小卡320×168標題 80–48px副標題 30px預留 2 行安全線 y=504X 會在這裡疊上 og:title文字可用高度 232px
標題區能用的高度,就是小卡下緣到 X 標題列起點之間剩下的空間。

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

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

三張產生的圖卡上下排列:英文標題介紹連結圖示,附 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 圖卡,草稿則兩者都不會有。

同一份文章清單同時產生頁面和圖卡左邊的 getArticlePaths(locale) 會回傳某個語言下所有已發布的文章。從這裡分出兩個箭頭:一個指向輸出 HTML 文章頁面的路由 blog/[slug].astro,另一個指向輸出 PNG 格式 Open Graph 圖卡的路由 og/[slug].png.ts。底部的橫條說明 en、ja、zh-tw 各執行一次同樣的流程。getArticlePaths(locale)某個語言下所有已發布的文章blog/[slug].astroog/[slug].png.ts文章頁面HTMLOpen Graph 圖卡PNGen、ja、zh-tw 各執行一次同樣的流程
同一份文章清單同時供給兩條路由,每種語言各執行一次。

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

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。
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。
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 圖卡:

src/layouts/ArticleLayout.astro
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。

從 Fontsource 的子集到 Satori 拿到的字型左邊是 Fontsource 依 unicode-range 把 Noto Sans JP Bold 拆成的約 125 個子集檔案,例如拉丁字母、假名和兩段漢字。每個檔案都有 WOFF 和 WOFF2,WOFF2 被劃掉了。中間是 og-card.ts,只留下涵蓋圖卡文字的子集,並替每個檔案取不同的字型名稱。右邊是 Satori,從約 125 個檔案中只拿到 3 個有名稱的檔案。標題用不到的漢字(一)以淡色顯示。FontsourceNoto Sans JP Bold拉丁字母WOFF2WOFF假名WOFF2WOFF漢字(一)WOFF2WOFF漢字(二)WOFF2WOFF⋯⋯約 125 個檔案og-card.ts1. 只留下涵蓋圖卡文字的子集2. 替每個檔案取不同的字型名稱SatoriNoto Sans JP · 拉丁字母Noto Sans JP · 假名Noto Sans JP · 漢字二約 125 個檔案中的 3 個略過 WOFF2:Satori 只讀得了 TTF、OTF 和 WOFF
只有圖卡文字用得到的子集,會各自取不同的名稱後交給 Satori。

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

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

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

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

src/lib/og-card.ts
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:

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 的圖卡把「検証」拆成兩行,也是同樣的問題。下面這個繁體中文標題就在「五次」中間折行,「五」留在第一行行尾,「次」跑到了第二行開頭:

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

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

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

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,選出第一個預估高度放得進剩餘空間的字級:

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 標題列上方剩下的高度來決定。

參考連結

分享這篇文章