💡 完整的工作範例可在 GitHub 上取得:
nodejs-docker-signing-with-fonts

介紹

字型解析是容器簽署的一個環節,決定您的 Node 服務是產生文件還是拋出例外。GroupDocs.Signature 不會自動替代缺失的字族:如果指定的字族在映像檔中不存在,呼叫會拋出例外,且不會產生任何輸出。清除字型也不是解決方案,因為庫會改為使用自己的預設字型,結果同樣失敗。

有三種方式可以決定要傳遞哪個字族,其中只有一種能在容器中存活。本文將比較這三種方式,並說明影響它們的佈署與綁定行為,因為 Node.js 透過 Java 的方式在所有支援平台中最為複雜。

為何在 Node.js 上更重要

此套件是一座橋樑:node-java 會在同一個程序中載入 JVM。因此,Node 簽署映像必須具備 JDK、node-gyp 工具鏈以編譯橋樑,並且 LD_LIBRARY_PATH 必須指向 libjvm.so,這些需求必須在字型相關設定之前完成。node:18-bookworm 只會提供 6 個 DejaVu 字型檔案供 AWT 使用——足以支援拉丁字元,卻沒有任何 CJK 字型。

這樣的組合會產生看似應用程式錯誤的失敗情況。缺少 JVM 路徑、缺少字型或是封送不匹配,都會以 Error running instance method 顯示,因為 node-java 會把 Java 端拋出的任何例外都報告為此訊息。

前置條件

Node 18 – 橋樑會對 NAN 進行編譯,NAN 無法在 Node 20 或 22 上編譯('AccessorSignature' is not a member of 'v8')。JDK 8 至 17:在 JDK 25 上影像層會因 Cannot open an image. The image size can not be 0! 而失敗。

安裝

npm install @groupdocs/groupdocs.signature

在映像中,這個安裝需要 build-essential 與 python3,以及 openjdk-17-jdk-headless 與載入路徑:

ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH="${JAVA_HOME}/bin:${PATH}"
# node-java dlopens libjvm.so at run time; it is not on the default loader path.
ENV LD_LIBRARY_PATH="${JAVA_HOME}/lib/server:${LD_LIBRARY_PATH}"

方法 1 – 硬編碼字族名稱

大家最先寫的版本:直接選 Arial,打包後上線。它在開發機上能正常運作,但在第一次容器執行時失敗,因為 Debian 映像不會安裝 Arial——它會安裝 Liberation Sans,該字型在度量上相容,但字族名稱不同。

此處沒有值得展示的程式碼,這正是重點。此方法的全部內容只是一個在某個環境下恰好成立的字串常量。

方法 2 – 從檔案系統偵測字型

自然的修正方式:掃描字型目錄,看看有哪些字型,然後挑選。這其中有一半是實用的——清單會告訴您映像中是 0 個字型還是 6 個:

const roots = [
  '/usr/share/fonts',
  '/usr/local/share/fonts',
  path.join(home, '.fonts'),
  path.join(home, '.local', 'share', 'fonts'),
  '/System/Library/Fonts',
  '/Library/Fonts',
];

另一半則無法正常運作。字型檔案很少會攜帶呼叫端必須傳遞的字族字串:Debian 的 fonts-noto-cjk 會安裝 NotoSansCJK-Regular.ttc,其字族為 Noto Sans CJK JP。從檔名推導字族會得到 NotoSansCJK-Regular,這根本找不到對應的字族。檔名偵測既會錯過實際存在的字型,也會自信地回報會失敗的字族。

將此清單保留作為診斷工具,切勿用來做選擇。計數可以回答映像是否已經佈署字型,這是一個不同且同樣有用的問題。

方法 3 – 向庫詢問

對每個候選字族嘗試一次拋棄性的簽章,保留第一個不拋例外的字族。每個候選字族會產生一次 PDF 寫入,且這是唯一能給出權威答案的方法,因為它使用的正是實際簽章時會呼叫的同一個介面。

for (const candidate of candidates) {
  if (tryFamily(sourcePath, candidate) === null) {
    return candidate;
  }
}
return null;

在 Node 中,偵測需要額外的兩行程式碼。node-java 會把所有 Java 例外壓縮成 Error running instance method,因此必須從包裹的堆疊追蹤中還原真實訊息:

const stack = err.stack || '';
const match = stack.match(/com\.groupdocs\.signature\.exception\.[^\n]*/);
return match ? match[0].trim() : (err.message || String(err));

若沒有這兩行,沒有字型的容器與 JVM 路徑錯誤會產生完全相同的日誌。我花了比想像中更長的時間比較兩個容器,兩者因完全不同的原因印出相同錯誤,最後才加入正則表達式解決問題。

偵測成本

對偵測的反對意見是它會寫檔,而事實確實如此:每個候選字族會寫入一個小 PDF,寫入後立即刪除。範例中的拉丁字型清單有四筆,CJK 清單有八筆,因此在冷啟動時最多會在暫存目錄寫入十二個單頁文件,然後服務才算準備就緒。

這是啟動成本,而非每次請求的成本,且它會在日誌中留下兩個已解析字族的名稱。相較於一個乾淨啟動卻在第一個客戶文件上因橋接錯誤失敗的容器,十二個暫存檔案並不是難以接受的權衡。

方法比較:何時使用哪一種

方法 最適用情境 主要優勢 限制
硬編碼字族 單一受控環境 簡單,無啟動成本 任何缺少該字族的映像都會失效
檔名偵測 診斷映像內含什麼 快速,無簽章呼叫 檔名不是字族名稱,衍生出的選擇會失敗
庫偵測 任何容器化或可移植的情況 權威,於本機與映像皆可運作 每個候選字族寫入一次 PDF,建議在啟動時解析並快取

兩個值得了解的綁定怪癖

一旦字族解析完成,簽章呼叫本身會呈現 Node 特有的形態。Java API 接受一個選項列表,但 JavaScript 陣列不會自動封送為 java.util.List,直接傳遞會得到 Could not find method "sign(java.lang.String, [Ljava.lang.Object;)"。解法是使用單選項的 overload,先寫入暫存檔再進行第二次簽章:

new signatureLib.Signature(sourcePath)
  .sign(firstOutput, buildTextOptions(LATIN_TEXT, latinFamily, 50));

if (stageTwo) {
  new signatureLib.Signature(firstOutput)
    .sign(outputPath, buildTextOptions(CJK_TEXT, cjkFamily, 120));
}

第二個怪癖是讀回驗證。TextVerifyOptions 透過此綁定無法回傳:verify 會拋出相同的通用橋接錯誤,因此範例會回傳哨兵值並印出 unavailable,而不是假裝簽章失敗。npm 套件的版本為 24.12.0(2024 年 12 月發佈),內含 23.6.1 引擎;而 .NET 版本已到 26.6,Java 版本為 26.5。簽章功能不受影響,只有驗證路徑缺失。

在正式環境仍建議使用 Node.js 綁定嗎?

對於僅需拉丁字元的簽章,答案是肯定的:它能正確簽署,且缺少字型時會直接拋錯,而不是悄悄降級,失敗模式相當明顯。對於混合腳本的工作,則需要衡量缺少讀回驗證的風險,因為此時流程無法確認 CJK 字形是否已正確嵌入而非顯示為方框。可在同一流水線中加入 .NET 或 Java 的小型驗證器以彌補此缺口。

最佳實踐與小技巧

  • 依序佈署:JDK 與工具鏈 → 載入路徑 → 字型 → 應用程式。每一層失敗的表現不同,混合佈署會讓診斷變慢。
  • 在啟動時解析一次所有字族,並將結果與字型數量一起寫入日誌。
  • 鎖定 Node 18 以及 JDK 8~17,將兩者視為固定基礎設施,而非例行升級的對象。
  • 將無字型的 Dockerfile 保留在版本庫中,讓失敗永遠只距離一次建置。

結論

挑選字型的方式有三種,只有一種能在部署後存活。先以庫偵測取得權威答案,快取結果,並將字型清單僅作為診斷工具而非決策依據。然後依照綁定的實際行為進行:一次只簽署一個選項,從堆疊追蹤中抽取 Java 例外訊息,並誠實回報缺少驗證的情況,而不是隱藏它。範例倉庫同時建構兩個映像,所有說法皆可透過兩條指令驗證。

其他資源