在生产环境中配置字体
你的 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 桥,而非本页所讲的原生引擎。对于原生生成,请捆绑文件并注册它们。
步骤 1 —— 捆绑真实的字体文件
标题为“步骤 1 —— 捆绑真实的字体文件”的章节把字体文件放进你的应用目录树,让它们被版本化并随每次构建一同发布。一个惯用位置是
resources/fonts/ 目录。
your-app/├── resources/│ └── fonts/│ ├── DejaVuSans.ttf│ ├── DejaVuSans-B.ttf│ └── NotoSansCJK-Regular.ttc└── src/给文件命名,让引擎的目录搜索能按字族和样式找到它们。当你注册一个目录
(而非具体文件)并随后调用 setFont('DejaVuSans', 'B', 12) 时,引擎会在每个已配置目录里查找诸如
DejaVuSans-B.ttf、DejaVuSansB.ttf 或 DejaVuSans.ttf
之类的文件。目录搜索会从你传给 setFont 的同一个单字母样式代码
(B 表示粗体,I 表示斜体,BI
表示粗斜体)构建那些候选名,而不是拼写出来的单词 —— 因此可靠的形式是
Family-<StyleCode>.ttf(例如 DejaVuSans-B.ttf 或 DejaVuSans-BI.ttf),
而非 Family-Bold.ttf。一个名为 DejaVuSans-Bold.ttf
的文件永远不会被目录搜索找到;要使用这样的文件,请用 register() 显式注册它 ——
它会解析字体,并按从文件自身 name
表读到的字族和样式建立索引,于是拼写出来的文件名便不再重要(见步骤 2)。
步骤 2 —— 把字体注册到引擎
标题为“步骤 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
容器、一个无服务器镜像,或一台经过加固的主机),字体文件在生成时被读取且从不写入,因此只读挂载没问题。引擎可能想要的唯一写入是它解析后的字体缓存:要么给那个目录一个小的可写卷,要么在启动时预热并锁定注册表(下一节),让运行时不再尝试任何写入或注册。
步骤 4 —— 预热并验证
标题为“步骤 4 —— 预热并验证”的章节在一个长期运行的 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.ttf、DejaVuSansB.ttf或DejaVuSans.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\FontRegistry(register()、
addFontDirectory()、warmup()、lock()、目录构造参数),
它的 NextPDF\Contracts\FontRegistryInterface 契约,
NextPDF\Core\DocumentFactory::create(),以及 NextPDF\Core\Document::setFont()
/ addFontDirectory()。Laravel 的 fonts_path 和 preload_fonts
键是 nextpdf/laravel 包的有文档配置。嵌入与子集标签行为及其 ISO 32000-2
引用,记录在 See also 下链接的嵌入与子集化示例上。
- 嵌入并子集化 TrueType 字体:注册单个字面以及保存时自动子集化的 API 级示例。
- 把 HTML 渲染为 PDF 页:原生 HTML 路径,它通过同一个注册表解析字体。
- 从控制器返回生成的 PDF:把一个工厂构建的文档接入框架响应。
- Laravel 生产用法:框架字体配置和 worker 启动预热。