SVG 轉 PNG 時字型找不到,resvg 不畫方框也不報錯,直接跳過那段文字。彩色 emoji 字型還會多一層陷阱。怎麼在 build 就擋下來。
發布於 2026-09-06・約 4 分鐘
我用 @resvg/resvg-js 在建置時產生分享用的 OG 圖:畫一張 SVG,轉成 PNG,33 個頁面各一張。
@resvg/resvg-js
build 全綠。33 個 PNG 全部產出。檔案大小也正常,每張一百多 KB。
圖上一個字都沒有。
背景漸層佔掉大半的檔案大小,所以「檔案大小正常」這件事完全沒有辨識力。而且這批圖已經空白很久了,我是後來從線上實際抓下來看才發現的。
瀏覽器找不到字型會用 fallback,再不行就畫一個空心方框(豆腐字),你一眼就知道出事了。
resvg 不是。它找不到字型就整段 <text> 不畫。 不報錯、不警告、不留任何痕跡,就像那個元素不存在。
<text>
我的情況是 build 從本機搬進容器跑,容器的基礎映像檔沒有中文字型。裝上去就好了:
RUN apt-get update \ && apt-get install -y --no-install-recommends fontconfig fonts-noto-cjk
fontconfig 也要裝,loadSystemFonts 是透過它找字型的。
fontconfig
loadSystemFonts
中文修好之後,emoji 還是空的。
原因是 Noto Color Emoji 是 CBDT 點陣格式。resvg 認得這個字型檔、解析得出 family 名稱,但畫不出裡面的字符。從程式的角度看它「找到字型了」,從圖片的角度看還是什麼都沒有。
解法是改用單色的 Noto Emoji(輪廓字型),釘住版本裝進容器:
RUN curl -fsSL -o /tmp/NotoEmoji.ttf \ https://github.com/googlefonts/noto-emoji/raw/<釘住的 commit>/fonts/NotoEmoji-Regular.ttf \ && echo "<sha256> /tmp/NotoEmoji.ttf" | sha256sum -c - \ && mkdir -p /usr/share/fonts/truetype/noto-emoji \ && mv /tmp/NotoEmoji.ttf /usr/share/fonts/truetype/noto-emoji/ \ && fc-cache -f
我實測過覆蓋率:33 個工具用到 27 個相異字符,單色 Noto Emoji 中 27/27,Symbola 只有 24/27。挑字型前先量一次自己真的會用到哪些字符,比看字型的宣稱可靠。
這條是最反直覺的。我以為寫成這樣就有 fallback 了:
<text font-family="Noto Sans CJK TC, Noto Emoji">💰 省錢</text>
不會。resvg 挑第一個解析得到的 family 去畫整段,那個 family 沒有的字就不畫。瀏覽器那種「這個字沒有就去下一個字型找」的逐字 fallback,它沒有做。
所以上面那行的實際結果是「省錢」有印出來,emoji 消失。33 張圖的標籤全部少一顆 emoji,而中文都在,看起來就只是「設計上沒放 emoji」,非常不容易發現。
反過來把 emoji 字型排前面:
<text font-family="Noto Emoji, Noto Sans CJK TC">💰 省錢</text>
emoji 出現了。中文也還在,因為中文那邊的反向 fallback 是好的(Noto Emoji 沒有中文字,會往後找)。
但這樣有副作用:中間那個半形空格會吃到 Noto Emoji 的 advance width,被撐出一大段空白。所以要拆成兩個 tspan,間距自己用 dx 控制:
dx
const EMOJI_FONT = 'Noto Emoji, Segoe UI Emoji, Apple Color Emoji' function tagLabelSvg(label) { const i = label.indexOf(' ') if (i < 0) return `<tspan font-family="${EMOJI_FONT}">${esc(label)}</tspan>` return `<tspan font-family="${EMOJI_FONT}">${esc(label.slice(0, i))}</tspan>` + `<tspan dx="9">${esc(label.slice(i + 1))}</tspan>` }
後面那幾個彩色字型是留給本機開發用的(macOS 和 Windows 上沒有 Noto Emoji),容器裡輪不到它們。
上面每一個坑的共同點是不會失敗。所以唯一可靠的辦法是在 build 裡自己驗,驗不過就中斷。
方法很土但很有效:同一張圖畫兩次,一次有文字一次沒文字,比對 PNG 的位元組數。一樣就代表那段文字根本沒畫進去。
function assertFontsAvailable() { const box = '<rect width="600" height="120" fill="#101020"/>' const mk = t => new Resvg( `<svg width="600" height="120" xmlns="http://www.w3.org/2000/svg">${box}${t}</svg>`, { font: { loadSystemFonts: true } }, ).render().asPng().length const blank = mk('') const withText = mk( `<text x="20" y="80" font-family="${TC_FONT}" font-size="48" fill="#fff">退稅計算</text>`) if (withText === blank) { throw new Error('resvg 找不到可用字型,OG 圖會整批沒有文字。請確認有裝 fontconfig 與 fonts-noto-cjk。') } // emoji 一定要分開驗:文字有畫不代表 emoji 有畫。 const sample = [...new Set(TOOLS.map(t => t.emoji))].slice(0, 5) const missing = sample.filter(e => mk(`<text x="60" y="90" font-family="${EMOJI_FONT}" font-size="72" fill="#fff">${e}</text>`) === blank) if (missing.length) { throw new Error(`resvg 畫不出 emoji(${missing.join(' ')})。Noto Color Emoji 單獨用一定失敗,它是 CBDT 點陣。`) } } assertFontsAvailable()
兩關一定要分開。合併成一關的話,「中文正常、emoji 空白」這個組合會整個漏掉,而那正是我實際遇到的狀況。
取樣也要用真的會用到的字符,不要寫死一個剛好有覆蓋的 emoji,那等於驗了個寂寞。
會靜默失效的東西,就是要主動量測的東西。
判斷一個步驟需不需要這種自我驗證,我問兩個問題:它失敗的時候會不會出聲?產出物有沒有人會看? 兩個都是「否」的環節(產圖、產 sitemap、寫快取、寄通知),一定要在流程裡加一道自己驗自己的關卡。
OG 圖剛好兩個都中:它不會出聲,而且圖是給社群平台的爬蟲看的,不是給我看的。這種東西壞掉可以壞好幾個月。
標籤:resvg、SVG、字型、Node.js、OG 圖