💡 完整可執行範例可在 GitHub 上取得:
sign-pdf-in-linux-container-fonts-dotnet
舊有方式令人痛苦
服務會為發票簽章。它在安裝了三百種字型的筆記型電腦上執行、通過審核,然後在星期五容器化。到了星期一,叢集中的第一個工作以非零狀態結束,顯示 Sign document error: Font Arial was not found,有人花了整個上午閱讀堆疊追蹤,才有人想到去查詢 mcr.microsoft.com/dotnet/runtime:8.0 映像實際包含哪些字型。
答案是:沒有。映像中根本沒有字型檔案,本文範例執行的映像亦是如此。
了解其他執行環境的差異也很重要,因為失敗的表現會因映像而異。eclipse-temurin:17-jre 內建 8 個 DejaVu 字型檔,node:18-bookworm 內建 6 個,兩者皆為 AWT 用字型,這就是為什麼 JVM 與 Node 映像在簽署拉丁文字時表現正常,只有在遇到日文或中文字串時才會失敗。python:3.11-slim 與 .NET runtime 映像一樣不含任何字型,因此會在第一個簽章時失敗。沒有任何映像會免費提供 CJK 字型。
在 Linux 映像中使用 GroupDocs.Signature for .NET 進行文字簽章,必須先提供字型。這很重要,因為函式庫不會自動替代缺失的字型族:若指定未安裝的字型,會拋出錯誤且不會產生文件。本文將把沒有字型的映像與已修正的映像並排比較,說明變更內容,並說明在開發機上保持相同程式碼的執行時解析方式。
有更好的方式
必須同時滿足兩件事:映像至少要有一種字型,程式碼則不能再假設是哪一種。
第一件事是 Dockerfile 的一層。第二件事是解析步驟:不要硬編碼 Arial,而是詢問函式庫哪些候選字型族實際可用,並保留第一個可用的。這樣產出的映像在精簡容器、Windows 以及 CI 中都能保持不變,因為它從不對未檢查的環境斷言。
有件事是行不通的,值得直接說明,因為這是大多數人第一個嘗試的做法:不設定字型。若未提供 SignatureFont,GroupDocs.Signature 會使用自己的預設字型 Times New Roman,而字型缺失的映像同樣沒有此字型,呼叫會以相同方式失敗。
新方式:兩個映像,一個差異
步驟 1 - 查看映像內有哪些字型
在簽署任何內容之前,先列出字型檔案。這樣可以把模糊的例外轉化為具體診斷,因為「零字型」與「字型名稱錯誤」需要不同的修正:
string[] roots =
{
"/usr/share/fonts",
"/usr/local/share/fonts",
Path.Combine(home, ".fonts"),
Path.Combine(home, ".local/share/fonts"),
Environment.GetFolderPath(Environment.SpecialFolder.Fonts),
"/System/Library/Fonts",
"/Library/Fonts",
};
注意缺少的項目:System.Drawing。從 .NET 7 起,System.Drawing.Common 僅支援 Windows,於 Linux 上會拋出例外,因此基於它的字型程式碼在容器中會因第二個、無關的原因失敗。
步驟 2 - 加入字型層
四個套件、一個 RUN,即可消除失敗:
RUN apt-get update && apt-get install -y --no-install-recommends \
fontconfig \
fonts-dejavu-core \
fonts-liberation \
fonts-noto-cjk \
&& fc-cache -f \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
fontconfig 為解析器,並提供 fc-list 供除錯使用。fonts-dejavu-core 提供拉丁、希臘與西里爾字型的最小集合。fonts-liberation 提供與 Arial、Times New Roman、Courier New 度量相容的替代字型,這正是 Windows 上文件實際參照的字型。fonts-noto-cjk 則涵蓋中文、日文與韓文。
步驟 3 - 解析字型族而非直接指定
選擇字型的可移植方式是對每個候選字型執行一次拋棄式簽章,保留第一個不拋例外的字型族:
foreach (string candidate in candidates)
{
if (TryFamily(sourcePath, candidate).Ok)
{
return candidate;
}
}
return null;
僅依檔名偵測是常見的捷徑,但卻是錯誤的。Debian 的 fonts-noto-cjk 會安裝 NotoSansCJK-Regular.ttc,其字型族名稱為 Noto Sans CJK JP。僅比對檔名會錯過已存在的字型,且會返回在傳遞給 SignatureFont 時無法解析的族名。
步驟 4 - 簽署已解析的字型,驗證簽署結果
必須先解析出拉丁字型族;CJK 字型族則為可選,若缺失則跳過而非崩潰:
var options = new List<SignOptions>
{
BuildTextOptions(LatinText, latinFamily, top: 50),
};
if (cjkFamily is not null)
{
options.Add(BuildTextOptions(CjkText, cjkFamily, top: 120));
}
SignResult result = signature.Sign(outputPath, options);
接著讀回檔案,因為若缺少 CJK 字型,CJK 文字會顯示為空方塊且不會拋出任何錯誤:
var options = new TextSearchOptions { AllPages = true };
List<TextSignature> found = signature.Search<TextSignature>(options);
並排比較:前後差異
Dockerfile.nofonts |
Dockerfile |
|
|---|---|---|
| 映像中的字型檔案 | 0 | DejaVu、Liberation、Noto CJK |
| 拉丁文字簽章 | 失敗,退出代碼 3 | 成功寫入,讀回驗證成功 |
| CJK 文字簽章 | 失敗 | 成功寫入,讀回驗證成功 |
| 錯誤訊息 | Font <name> was not found |
無 |
| 程式碼差異 | 無 – 二進位相同 | 無 – 二進位相同 |
最後一行說明了重點:兩次執行之間應用程式本身沒有任何變動。範例倉庫同時提供兩個 Dockerfile,讓比較可以透過兩次 docker build 完成,而不必僅憑信任。之後也請保留字型缺失的變體於倉庫中,因為它是最快重現失敗的方式,尤其當有人在六個月後更換基礎映像,簽章悄然消失時。
為什麼不直接安裝所有字型?
因為映像大小是一項實際限制,而上述四個套件已涵蓋大多數文件常用的腳本。fonts-dejavu-core 已足以支援拉丁、希臘與西里爾簽章;當文件以字型名稱引用 Windows 家族時,需要 Liberation;Noto CJK 雖然體積較大,但只有在簽署東亞文字時才會真正被使用。請依文件需求安裝字型,然後以讀回驗證。
真實案例:批次簽章工作者
佇列工作者每晚簽署數千份 PDF。啟動時先解析字型族,並記錄一行使用的族名;若無任何可用族,則在觸碰佇列前即退出,而不是在每筆訊息上失敗。這個啟動檢查可將字型問題從一連串失敗的工作轉變為容器因單行原因拒絕啟動。
探測成本足以在啟動時忽略,但若每份文件都重複探測則成本過高。每次探測會寫入一個臨時檔案作為真實簽章,因此拉丁字型清單最多產生四次,CJK 清單最多產生八次,皆針對單頁 PDF。只需解析一次,快取兩個族名,之後每份文件的流程與之前完全相同:建立選項、呼叫 Sign、讀取結果計數。
我曾因使用「猜測」版本而浪費一個下午。它掃描字型目錄,找到 NotoSansCJK-Regular.ttc,報告 CJK 可用,卻在所有從檔名衍生的族名上失敗。以真實簽章進行探測既更簡單也更正確。
容器中還有什麼會卡住?
還有一件與字型無關的事:InvariantGlobalization=true。這是為了在 .NET 映像中裁剪 ICU 的標準建議,但在使用 GroupDocs.Signature 時會導致第一個 new Signature(...) 拋出 CultureNotFoundException: ... en-US is an invalid culture identifier,因為 SignatureSettings 會建立 CultureInfo("en-US")。請保持全球化功能開啟,讓 ICU 留在映像中。平台支援情況請參考 system requirements 頁面,在選擇基礎映像前先確認。
結論
本地可正常運作、在 Docker 中失敗的簽章服務幾乎總是缺少字型,而解決方案就是加入四個套件的層,並以解析字型族的方式取代硬編碼。從範例建置兩個映像、並排執行,並檢視 [fonts] 行:整個論點就在這一次比較中。