跳转到内容

字体排印:字体注册表、子集化、CMap、编码、BiDi

字体排印模块会将字体文件和 Unicode 字符串转换为 PDF 内容流所需的 bytes。它负责字体 resolve(解析)、进程生命周期内的字体注册表、字形子集化、ToUnicode CMap、cmap 感知的编码策略,以及 Unicode 双向文本引擎。

Terminal window
composer require nextpdf/core:^3

FontRegistry 是进程生命周期内的字体存储区,并实现 FontRegistryInterface。它只会解析 TrueType、OpenType、TTC 或 Type 1(PFB 与 AFM)文件一次,并返回不可变的 FontInfo。这个注册表面向长时间运行的 worker 设计:启动时预热一组字体,然后调用 lock()。随后注册表会拒绝所有变更,同时查询仍可持续服务流量。它只持有纯 PHP 数据 —— 解析后的元数据与原始字体 bytes —— 因此一个 worker 池可以共享同一个实例。registerFromBinary() 接受原始字体 bytes,HTML 的 @font-face 桥接层会用它处理从远程来源或 data URI 取得的字体。

引擎会嵌入并子集化它使用的每一种字体。嵌入的字体程序会随 PDF 一并携带,因此文档在任何查看器中都会呈现相同外观,与已安装的系统字体无关 —— ISO 32000-2 §9。子集只携带文档实际引用到的字形,这对 CJK 或 Unicode 密集内容至关重要 —— ISO 32000-2 §9。FontSubsetter 会解析原始表目录、提取 cmap、通过传递闭包解析复合字形的依赖关系,并重建 headhheamaxpcmaplocaglyfhmtx 这些表。它会保留原始字形标识符的编号,并将未使用的槽位补零,因此 CIDToGIDMap/Identity 时仍然有效。子集化节省的空间少于百分之十时,它会原样返回原始字体,避免做不划算的工作。CffSubsetter 会对携带 Compact Font Format 轮廓表的 OpenType 字体执行等效操作。

文本输出要经过三阶段转换:先是 Unicode 码位,接着是内容流中的字符码,最后是字体内部的字形标识符。本模块将这段过程建模为一组明确的协作对象。FontInfo::encodeText() 是门面;FontEncodingStrategyResolver 按字体分派。具有 Unicode cmap 的嵌入式 TrueType 或 OpenType 字体会路由到 TrueTypeCmapStrategy,由它输出双字节的 Identity-H 十六进制流。这正是带有 Identity-H CMap 与 CIDFontType2 后代的 Type 0 字体所需的形式(ISO 32000-2 §9.7.4;对应的 RAG 区块摘要因授权上限而被截断返回,记录于 _downgraded-claims-o3.md)。其他每一种字体 —— Base 14 标准字体、Type 1 PFB 与 AFM —— 都会路由到 Base14EncodingStrategy,由它输出单字节的 WinAnsi 字面字符串。该流涵盖完整的 WinAnsiEncoding(Windows code page 1252)字集——带重音符号的拉丁字符、欧元符号,以及常见的排版标点。落在该字集之外的码位会从单字节流中被丢弃;当注册了一个能覆盖它们的字体时,便交由逐簇字体后备处理(ISO 32000-2 Annex D.2)。解析器在整个 FontInfo 值空间上都是全函数,不存在可为 null 的路径。ToUnicodeCMapBuilder 会建立 /ToUnicode 资源,让阅读器能够从 Identity-H 字体还原原始 Unicode。它会套用贪婪的 bfrange 合并,并设置每个区块 100 条的上限。

BidiEngine 是 Unicode 双向算法(UAX #9,Unicode 16)的边界服务。在隔离支持关闭时,它会委托给旧版解析器,因此现有调用方不受影响。开启后,它会执行隔离感知流水线:最大深度为 125 的明确隔离栈、弱类型处理阶段、包含成对括号解析的中性类型处理阶段,以及隐含层级与行重排处理阶段。候选字体的 CJK 字符覆盖率是另一项独立诊断:CjkFontValidator 会按文本所属文字系统抽样所需的 Unicode 区块,并报告覆盖率百分比。

类型种类主要成员稳定性起始版本
FontRegistryfinal classregister(), registerType1(), registerFromBinary(), registerFromDirectory(), get(), has(), all(), warmup(), lock(), isLocked(), memoryUsage()稳定1.7.0
FontInfofinal readonly class$family, $type, $widths, $unicodeMap, $cmapForward, getKey(), encodeText()稳定1.0.0
FontSubsetterfinal classsubset(string, array<int>, int): string稳定1.0.0
CffSubsetterfinal classOpenType/CFF 轮廓子集化稳定1.0.0
FontEncodingStrategyResolverfinal classresolve(FontInfo): FontEncodingStrategy稳定2.7.0
ToUnicodeCMapBuilderfinal classbuildFromRun(), buildFromMap(), encodeUnicodeUtf16Be()稳定2.7.0
BidiEnginefinal classUAX #9 隔离感知解析稳定3.1.0
CjkFontValidatorfinal classvalidateCoverage(), detectScript(), isCjkCodepoint()稳定1.0.0

FontInfo 是不可变的:它的构造函数签名与公开属性都已冻结。编码策略是 (FontInfo, UTF-8 text) 的纯函数 —— 每次调用时,相同输入都会得到相同的 EncodedGlyphRun

examples/35-cjk-cmap-demo.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Typography\Encoding\EncodingMode;
use NextPDF\Typography\FontRegistry;
$registry = new FontRegistry();
$cjkFont = $registry->register('/path/to/NotoSansTC-Regular.ttf', alias: 'NotoSansTC');
$encoded = $cjkFont->encodeText('PDF 2.0 引擎 — 使用 CMap 编码');
// An embedded CJK TrueType face resolves to the two-byte Identity-H path.
assert($encoded->mode === EncodingMode::TwoByteCid);

register() 会解析字体一次,并返回不可变的 FontInfoencodeText() 会经由解析器路由,并返回一个 EncodedGlyphRun,其中包含 byte 流、PDF 字符串操作数、各字符的前进宽度,以及 /ToUnicode CMap 会用到的 GID 到 Unicode 映射。

examples/typography/registry-warmup.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use NextPDF\Exception\NextPdfException;
use NextPDF\Typography\FontRegistry;
use Psr\Log\LoggerInterface;
final readonly class FontBootstrap
{
public function __construct(
private FontRegistry $registry,
private LoggerInterface $logger,
) {}
/**
* Warm a font set at worker boot, then lock the registry for the
* lifetime of the process.
*
* @param list<string> $fontFiles Absolute paths to font files.
*/
public function boot(array $fontFiles): void
{
try {
$this->registry->warmup($fontFiles);
$this->registry->lock();
} catch (NextPdfException $e) {
$this->logger->error('Font warmup failed', ['error' => $e->getMessage()]);
throw $e;
}
$report = $this->registry->memoryUsage();
$this->logger->info('Font cache primed', [
'fonts' => $report->entryCount,
'bytes' => $report->currentBytes,
]);
}
}

warmup() 接着 lock() 就是 worker 的启动流程。在 lock() 之后,任何变更都会抛出异常;查询则继续服务流量。memoryUsage() 会返回一个 MemoryReport,让 worker 能按预算追踪字体缓存。

  • 已锁定的注册表会拒绝 register()registerFromBinary()addFontDirectory()warmup()。请在启动时预热并锁定;绝不要在请求处理过程中注册字体。
  • FontSubsetter::subset() 会在节省的空间低于百分之十,或缺少某个必要表时,原样返回原始 bytes。返回的字体与输入相同,是文档中载明的无收益路径,并非失败。
  • 子集化器会保留原始字形标识符的编号,并将未使用的槽位补零。这让 CIDToGIDMap /Identity 保持有效;不要假设字形标识符会被重新编号为连续区间。
  • registerFromBinary() 会将这些 bytes 写入临时文件以进行解析,并会删除扩展名文件与 tempnam() 基底文件;这个删除动作在 finally 区块中执行。不受信任的字体数据是一个解析攻击面 —— 在它抵达解析器之前先加以把关(见「安全性注意事项」)。
  • BidiEngine 会在隔离支持关闭时原样委托给旧版解析器。此时,隔离格式字符会以边界中性的方式被略过。若要取得完整的 UAX #9 行为,请通过符合性策略开启隔离支持。
  • CjkFontValidator 会按间隔抽样码位,而不是逐一测试每个码位,因此它的覆盖率数字是统计上足够的估计值,而非详尽计数。

字体解析的成本主要发生在首次使用时;注册表会将其摊销为每个进程只执行一次。预热之后,get()has() 都是 O(1) 的映射查询。子集化成本随文档实际使用的字形数量变化,而不是随字体完整字符表变化。这正是子集化能让 CJK 内容同时获得体积与速度收益的原因:子集化器会通过二分搜索、预先分配的缓冲区与大量字符串运算,处理拥有 20,000 个以上字形的字体。复合字形解析是有界的 —— 它会在 100 次闭包迭代时设限,以抵御循环组件引用。cmap Format 12 解析器会限制群组与项目数量,从而约束恶意字体造成的内存用量。1500 毫秒挂钟时间与 64 MB 峰值的 performance_budget,足以覆盖一次典型的字体预热加文档渲染。

有两个接口具有安全影响。第一个是字体输入。register()registerFromBinary() 会解析任意 bytes。registerFromBinary() 会创建一个临时文件。路径中的流包装器与 null bytes 会在边界处被拒绝。不受信任的字体数据必须先经过一道外部资源策略,在数据抵达解析器之前限制文件大小与字形数量。子集化器的二进制读取器会对每个偏移量做边界检查。cmap 解析器会限制群组、项目与表的数量(Format 12 中为 numGroups > 31000 与项目上限 200,000),使刻意构造的字体无法触发无界分配。第二个接口是文本还原:ToUnicodeCMapBuilder 会验证每个字符码都落在 16 位码空间内,且每个 Unicode 值都是有效的标量 —— surrogate(代理项)的半值会被拒绝 —— 因此格式错误的映射无法生成损坏的提取资源。请将任何外部提供的字体或文本都视为不受信任。

宣称标准条款佐证
文档使用的每个字体都会被嵌入,因此文档无须依赖系统字体即可呈现。ISO 32000-2§9
嵌入的字体会被子集化为文档引用到的字形。ISO 32000-2§9
嵌入的 CJK TrueType 字体会以 Type 0 字体输出,搭配 Identity-H CMap 与 CIDFontType2 后代。ISO 32000-2§9.7.4RAG 摘要因授权上限而被截断,此处保留可供对照的前缀值为 7a5258772f508e3b,详见 _downgraded-claims-o3.md

前两条条款是改写内容,并以摘要固定。第三条条款的完整 RAG 摘要未返回(授权上限截断);它由 ADR-013 与 cmap 编码器开发者总览佐证,并记录为降级。NextPDF 不会复写规范性文本。CJK 内容的 PDF/A-4 与 PDF/UA-2 符合性取决于写入端的子集化与 /ToUnicode 接入,并在该处追踪。

商业 OpenType 功能套件与进阶字体后备链构建在 Core 的注册表与编码衔接层之上。Core 字体排印模块无须授权即可嵌入、子集化并编码每一种字体;付费套件则额外提供精选的后备解析。这里刻意不放转化链接 —— 这是文档,不是销售渠道。