跳转到内容
getnextpdf.com

在生产环境中配置字体

你的 PDF 在笔记本上渲染正确,一发布到容器,出来的却是一排空方框 —— 即“豆腐块”字形 —— 或者缺了重音符号和非拉丁字符。原因几乎总是同一个:你选用的字体在已部署的镜像里并不存在。

原生的、进程内的 NextPDF 引擎从字体注册表能读取的字体文件解析字体。它不会自动发现操作系统或 fontconfig 字体 —— 操作系统安装的字体文件只有在你显式注册那些文件,或把它们所在目录加入 FontRegistry 搜索路径时才有用。一个从精简基础镜像构建的容器没有 apt/apk 安装的字体,即便有,原生引擎也会忽略它们,除非你把注册表指向它们的文件。修复方法是把真实的字体文件捆绑进你的应用或镜像,并把它们注册到引擎。注册表读取 TrueType(.ttf)、OpenType(.otf)和 TrueType Collection(.ttc)文件;遗留的 Type1(.pfb)也被接受,但新项目很少需要。

开始之前,确认这些部分都已就位:

  • NextPDF core 已安装。
  • 你有打算使用的真实字体文件,并且你有权嵌入它们。嵌入权由你负责 —— 参见 嵌入并子集化 TrueType 字体
  • 你的构建能把那些文件复制进已部署的产物。

这是一篇运维 how-to。代码很少;工作在于构建和文件系统布局。关于注册和子集化单个字面的 API 级机制,请阅读上面链接的嵌入与子集化示例。本页讲的是把文件弄到机器上并把引擎指向它们。

为什么原生引擎不会自动找到操作系统字体

标题为“为什么原生引擎不会自动找到操作系统字体”的章节

存在两条截然不同的渲染路径,它们之间的字体故事并不相同。

  • 原生进程内引擎(默认,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 指向你捆绑的目录,你注册的字面便会自动解析。

步骤 3 —— 在 Docker 镜像中配置字体

标题为“步骤 3 —— 在 Docker 镜像中配置字体”的章节

在容器里,字体文件必须是镜像层的一部分,在构建时复制进来。因为当你把字体捆绑在 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 scheme 或空字节的路径。只注册本地文件;对于运行时抓取的字体,请用 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 引用,记录在 See also 下链接的嵌入与子集化示例上。