跳到內容
getnextpdf.com

在正式環境佈建字型

你的 PDF 在筆電上正確算繪,接著出貨到容器,卻變成一排空盒子——「豆腐」字符——或是少了重音與非拉丁字元。原因幾乎總是相同:你所選的字型並未存在於已部署的映像中。

原生且在處理程序內的 NextPDF 引擎,會從字型登錄表能讀取的字型檔解析字型。它不會自動探索 OS 或 fontconfig 字型——OS 已安裝的字型檔只有在你明確註冊那些檔案,或把它們所在目錄加入 FontRegistry 搜尋路徑時才有幫助。由精簡基底映像建置的容器並沒有 apt/apk 安裝的字型;就算有,原生引擎也會忽略它們,除非你把登錄表指向它們的檔案。修正之道是把實際的字型檔打包進你的應用程式或映像中,並向引擎註冊它們。登錄表會讀取 TrueType(.ttf)、OpenType(.otf)與 TrueType Collection(.ttc)檔; 舊式的 Type1(.pfb)也可接受,但新作品鮮少需要。

開始前,請確認下列要件都已就位:

  • NextPDF core 已安裝。
  • 你擁有你打算使用的實際字型檔,且你有權嵌入它們。嵌入權利是你的責任——請見 嵌入並子集化 TrueType 字型
  • 你的建置能把那些檔案複製進已部署的成品中。

這是一篇維運操作指南。程式碼很少;工作落在建置與檔案系統佈局上。關於註冊與子集化單一字面的 API 層級機制,請閱讀上方連結的嵌入與子集化範例。本頁涵蓋如何把檔案弄到機器上並把引擎指向它們。

為什麼原生引擎不會自動找到 OS 字型

標題為「為什麼原生引擎不會自動找到 OS 字型」的區段

有兩條不同的算繪路徑,而字型的處理方式在兩者之間有所不同。

  • 原生的處理程序內引擎(預設,Document / writeHtml):引擎不會為了探索而呼叫作業系統的字型系統或 fontconfig。它透過字型登錄表解析字面,登錄表會讀取你註冊的特定字型檔,或在你設為搜尋路徑的目錄中尋找。以 apt-get install fonts-noto 安裝字型或執行 fc-cache 本身毫無作用——原生引擎只有在你註冊那些檔案或把它們的目錄加入登錄表搜尋路徑時,才會看見它們。
  • Chrome 橋接(驅動無頭瀏覽器的 HTML 轉 PDF 算繪器):這條路徑確實透過瀏覽器一般的字型探索使用主機已安裝的字型,因此 apt/apk 字型套件與 fontconfig 在那裡是重要的。

如果你讀到一般「在你的 Dockerfile 中安裝這些系統字型套件」的指引,那適用於 Chrome 橋接,而非本頁所涵蓋的原生引擎。對原生產生而言,請打包檔案並註冊它們。

把字型檔放進你的應用程式樹中,讓它們受版本控管並隨每次建置出貨。慣用位置是一個 resources/fonts/ 目錄。

your-app/
├── resources/
│ └── fonts/
│ ├── DejaVuSans.ttf
│ ├── DejaVuSans-B.ttf
│ └── NotoSansCJK-Regular.ttc
└── src/

為這些檔案命名,讓引擎的目錄搜尋能依家族與樣式找到它們。當你註冊一個目錄(而非特定檔案)並隨後呼叫 setFont('DejaVuSans', 'B', 12) 時,引擎會在每個已設定的目錄中尋找諸如 DejaVuSans-B.ttfDejaVuSansB.ttfDejaVuSans.ttf 的檔案。目錄搜尋會從你傳給 setFont同一個單字母樣式碼建立那些候選名稱(B 代表粗體、I 代表斜體、 BI 代表粗斜體),而非一個拼寫出來的字詞——因此可靠的形式是 Family-<StyleCode>.ttf(例如 DejaVuSans-B.ttfDejaVuSans-BI.ttf), 而非 Family-Bold.ttf。名為 DejaVuSans-Bold.ttf 的檔案永遠不會被目錄搜尋找到;若要使用這樣的檔案,請以 register() 明確註冊它——它會剖析字型,並依檔案自己 name 表中讀到的家族與樣式為其建立索引,因此拼寫出來的檔名便不再重要(見步驟 2)。

你有兩種等效方式可讓檔案被看見。兩者都經由 NextPDF\Typography\FontRegistry,它實作了 NextPDF\Contracts\FontRegistryInterface

以別名註冊特定檔案,當你掌控確切的字面時:

use NextPDF\Typography\FontRegistry;
$registry = new FontRegistry();
$registry->register(__DIR__ . '/../resources/fonts/DejaVuSans.ttf', alias: 'DejaVuSans');

register(string $fontFile, string $alias = '', int $fontIndex = 0) 接受 .ttf.otf.ttc 檔,外加舊式的 Type1 .pfb(它會從相同路徑載入其伴隨的 .afm 度量);$fontIndex 會選取 TrueType Collection(.ttc)中的子字型。register() 會剖析檔案,並依其自己 name 表中讀到的家族與樣式為字面建立索引,因此一旦註冊,實體檔名便無關緊要。選用的 $alias 只是字面的額外查詢名稱——它不是樣式碼,也不會改變該檔案所提供的樣式;當你想以字型內嵌家族名稱以外的名稱呼叫 setFont() 時才傳入它。它會回傳已剖析的 FontInfo

註冊一個目錄,當你想要引擎從你掌控的資料夾中依名稱解析字面時:

$registry = new FontRegistry('/var/www/app/resources/fonts');
// or, equivalently, after construction:
$registry->addFontDirectory('/var/www/app/resources/fonts');

FontRegistry 建構子把該目錄當作它的第一個引數,而 addFontDirectory() 加入更多搜尋路徑。一個裸的 Document 也為獨立情境揭露 addFontDirectory()

若要使用你自行填充的登錄表,請透過 DocumentFactory 建立文件,它會把那個確切的登錄表接入它所建立的每份文件中:

use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
$doc = $factory->create();
$doc->addPage();
$doc->setFont('DejaVuSans', '', 12);
$doc->cell(0, 10, 'Réndéred wîth a bundled face — no tofu.', newLine: true);
$doc->save('/tmp/out.pdf');

Document::createStandalone() 會建立它自己的內部登錄表,因此你註冊在另一個 FontRegistry 上的字面對它而言不可見。在正式環境中,請透過 DocumentFactory(或你框架的工廠),讓被填充的登錄表正是使用中的那一個。

每個框架整合都把這兩個概念揭露為設定,因此你鮮少直接動到登錄表。在 Laravel 套件的 nextpdf.php 中,fonts_path(預設 NEXTPDF_FONTS_PATH,回退至 resource_path('fonts'))是搜尋目錄,而 preload_fonts 是一份在 worker 啟動時剖析的絕對字型檔路徑清單。把 fonts_path 指向你打包的目錄,你註冊的字面便會自動解析。

在容器中,字型檔必須是映像層的一部分,於建置時複製進去。由於當你把字型打包在 resources/fonts/ 下時,應用程式碼與字型會一起出貨,一個普通的 COPY . . 已會帶上它們。如果你把字型放在建置情境之外,請明確複製它們,並確保你註冊的路徑與映像內的路徑相符。

# Native engine: NO system font packages are required.
# The native engine does not discover OS-installed fonts automatically; install OS
# font packages (`apt-get install fonts-*`) only if you also register them or point
# the font registry's search directory at their files.
FROM php:8.4-cli
WORKDIR /var/www/app
# Bundle the application, including resources/fonts/, into the image.
COPY . /var/www/app
# Make the bundled directory the engine's font search path.
ENV NEXTPDF_FONTS_PATH=/var/www/app/resources/fonts
CMD ["php", "bin/generate.php"]

不可變或唯讀的檔案系統上(readOnlyRootFilesystem 容器、無伺服器映像,或加固過的主機),字型檔在產生時被讀取且永不被寫入,因此唯讀掛載沒有問題。引擎可能想要的唯一寫入是它的已剖析字型快取:請給那個目錄一個小型可寫卷,或在啟動時暖機並鎖定登錄表(下一節),讓任何執行時寫入或註冊都不會被嘗試。

在一個長存的 worker 中,於啟動時剖析每個字面一次,接著鎖定登錄表,讓每請求的註冊不會發生,並讓設定錯誤大聲失敗而非默默回退:

$registry = new FontRegistry('/var/www/app/resources/fonts');
$registry->warmup([
'/var/www/app/resources/fonts/DejaVuSans.ttf',
'/var/www/app/resources/fonts/DejaVuSans-B.ttf',
]);
$registry->lock();

lock() 之後,register()addFontDirectory()warmup() 會拋出例外,這會把「映像中路徑錯誤」的疏失轉成硬性啟動失敗,而非正式環境中的一頁豆腐。

請加入一個部署煙霧檢查,以每個必要字面各算繪一頁。下方的標頭檢查只驗證文件產出了輸出——它無法證明字型已剖析、已嵌入,甚至已解析。引擎找不到的字面可能會回退到一個標準基底字型(而在目前非嚴格的行為下,某個規範 profile 可能改為提供一個打包的替代字型),同時仍發出一個有效且非空的 PDF——因此即便發生那種回退,僅憑這項檢查也無法捕捉到該靜默降級。請勿依賴回退在每條路徑上都有保證或都靜默;請如下方所示,直接驗證嵌入的程式:

$doc = $factory->create();
$doc->addPage();
$doc->setFont('DejaVuSans', '', 12);
$doc->cell(0, 10, 'warmup check', newLine: true);
$pdf = $doc->getPdfData();
// `getPdfData()` would normally throw on a real failure; this header check only
// confirms serialization returned PDF bytes, not that any specific font resolved.
if (!str_starts_with($pdf, '%PDF')) {
throw new RuntimeException('Font warmup smoke check produced no PDF output.');
}

若要在某個字面缺失時確實讓部署失敗,請檢查發出的 PDF 是否有嵌入的字型程式。一個能解析的已註冊字面會攜帶它自己帶有嵌入程式的字型字典,因此斷言它的存在便能捕捉到所請求字面從未解析(無論引擎回退到了什麼)而被標頭檢查漏掉的情況。哪個鍵存放程式取決於外框格式:TrueType 外框(.ttf.ttc)使用 /FontFile2,CFF/OpenType 外框(帶 PostScript 外框的 .otf)使用 /FontFile3,而舊式 Type1 (.pfb)使用 /FontFile

如果你只需要一個格式無關的「有某個字型程式被嵌入」訊號,請測試 /FontFile 本身——因為 /FontFile/FontFile2/FontFile3 兩者的子字串,一個裸的子字串檢查已能比對到每種外框類型,而把 /FontFile2//FontFile3 作為額外的 || 分支加上去是多餘的:

if (!str_contains($pdf, '/FontFile')) {
throw new RuntimeException('No embedded font program found — face fell back.');
}

不過,一個裸的 /FontFile 子字串無法分辨外框類型。若要區分它們,請以帶字邊界的精確標記比對,讓 /FontFile 不會同時對 /FontFile2/FontFile3 觸發:

$isTrueType = preg_match('~/FontFile2\b~', $pdf) === 1; // TrueType (.ttf/.ttc)
$isCffOtf = preg_match('~/FontFile3\b~', $pdf) === 1; // CFF/OpenType (.otf)
$isType1 = preg_match('~/FontFile(?![23])\b~', $pdf) === 1; // Type1 (.pfb)
if (!$isTrueType && !$isCffOtf && !$isType1) {
throw new RuntimeException('No embedded font program found — face fell back.');
}

無論哪種方式,請僅把它當作一項粗略的啟發式判斷,而非可靠的部署閘門。對序列化後的 PDF 做原始位元組搜尋,因數個理由而不準確:字型程式可能存在於壓縮的物件串流中(其中 /FontFile* 永遠不會以純位元組出現)、增量更新可能附加或取代物件、未嵌入或 standard-14 字型理所當然完全不攜帶字型程式,而序列化差異(物件順序、空白、名稱編碼)可能移動或隱藏該標記。它最多確認某個字面嵌入了一個程式——絕不確認你想要的特定字面已解析。

對於真正的部署閘門,請勿依賴位元組搜尋。請以適當的 PDF 剖析器或物件檢視器剖析發出的 PDF,並斷言你目標字面的字型物件攜帶一個嵌入的 /FontFile//FontFile2//FontFile3 程式,或者,若你的整合可用,使用產品提供的字型解析斷言。上方那些字邊界感知的正規表示式對快速的本地健全性檢查有用,但讓部署失敗的應是一項結構性檢查。嵌入與字型字典結構記載於 嵌入並子集化 TrueType 字型

  • createStandalone() 有它自己的登錄表。 註冊在另一個 FontRegistry 上的字面對獨立文件不可見。請使用 DocumentFactory (或框架工廠),讓你的登錄表成為作用中的那一個。
  • 樣式檔案必須以檔案形式存在。 引擎不會從常規字面合成粗體或斜體。如果你呼叫 setFont('DejaVuSans', 'B'),目錄搜尋會尋找 DejaVuSans-B.ttfDejaVuSansB.ttfDejaVuSans.ttf (也包含小寫與 .otf 變體)——它從字面上的 B 樣式碼形成候選,因此它永遠不會去找 DejaVuSans-Bold.ttf。像 DejaVuSans-Bold.ttf 這樣拼寫出名稱的檔案,只有在你以 register() 明確註冊它時才會解析,register() 會依檔案自己 name 表中讀到的家族與樣式為它建立索引,與檔名無關;依賴目錄搜尋去找它會落空,之後引擎可能會回退到基底字型(並非有保證或永遠靜默的路徑)——這正是本頁所警示的降級。
  • 串流包裝器與遠端路徑會被拒絕。 登錄表拒絕包含 URI 協定或空位元組的路徑。請只註冊本地檔案;對於執行時擷取的字型,請以 registerFromBinary() 搭配原始位元組使用。
  • 被鎖定的登錄表不可變。 一旦你呼叫 lock(),任何後續的 register()addFontDirectory()warmup() 都會拋出例外。查詢方法仍然可用。請在鎖定前註冊並暖機好一切。
  • CJK 集合很大。$fontIndex 註冊 .ttc 中正確的子字型,並為較大的嵌入子集預留空間。請參閱嵌入與子集化範例中的 CJK 備註。
  • 字型檔是不受信任的二進位輸入。請只打包來自你信任來源的字型,並驗證任何接受自終端使用者之字面的來源。
  • 在暖機後鎖定登錄表,可移除一個執行時的變動面,並讓路徑錯誤在啟動時失敗,而非默默降級輸出。
  • 請勿把使用者輸入內插進已註冊的檔案路徑。請註冊一組固定的已打包字面;不要讓某個請求選擇任意的檔案系統路徑。

本指南不作任何規範性標準主張。所示的每個符號都是經驗證的公開介面: NextPDF\Typography\FontRegistryregister()addFontDirectory()warmup()lock()、目錄建構子引數)、其 NextPDF\Contracts\FontRegistryInterface 合約、 NextPDF\Core\DocumentFactory::create(),以及 NextPDF\Core\Document::setFont() /addFontDirectory()。Laravel 的 fonts_pathpreload_fonts 鍵是 nextpdf/laravel 套件有記載的設定。帶有其 ISO 32000-2 引用的嵌入與子集標籤行為,記載於「另請參閱」下方連結的嵌入與子集化範例。