跳转到内容

HTML 单遍流式约束(ADR-001)

NextPDF 以单遍向前扫描的方式渲染 HTML,并且不在内存中保留任何元素树。ADR-001 记录了这一决策,以及它对每项 CSS 功能施加的约束。

Terminal window
composer require nextpdf/core:^3

HTML 子系统是一个单遍流式的 HTML+CSS 转 PDF renderer(渲染器)。ADR-001(标题为「Stream-based Rendering Pipeline Retention」,于 2026-04-06 通过)确立了这一架构模型。本页说明这个模型是什么、不做什么,以及它对贡献者提出了哪些约束。

在流式模型中,tokenizer(HtmlTokenizer)只读取输入一次,并生成一份扁平的 token 列表。HtmlParser::processTokens() 从左到右遍历这份列表。每当遇到一个元素,它就把 PDF content-stream 运算符写入字符串缓冲区。引擎不会在多次调用之间构建任何持久的元素图。必须跨越一次 handler 调用而继续存在的状态,通过快照值对象(HtmlBlockCursor)传递,而不是通过共享节点传递。样式继承使用推入与弹出的栈,栈中保存扁平的 HtmlStyleState 实例,而不是一棵带父指针的树。

这不是保留式文档模型。引擎不会持有文档树,不会对已经写出的内容重新排版,也不允许输入在解析开始后再被修改。边界很明确:NextPDF 全程采用流式处理。保留式 renderer 会先在内存中构建整份文档;NextPDF 不会这样做。

有两项操作需要有限前瞻,它们是明确且有界的例外。表格列宽计算会在放置单元格之前先扫描每一行。它把这些行暂存在 TableParser 内部的短暂表格缓冲区中,这是 ADR-001 明确承认的例外。:has() 关系选择器,以及 :last-child:last-of-type 选择器,会对扁平 token 列表执行有界预扫描,而不是进行树遍历。ADR-001 记录了这两项例外,并为它们设定了边界。

这个模型对 worker 安全。HtmlParser 按请求构造,绝不作为 singleton(单例)使用。HtmlParser::parse() 会在每次调用开始时重置每个字段。渲染路径中不存在任何静态可变状态,因此 RoadRunner、Swoole 与 Laravel Octane 可以复用进程,文档之间不会发生状态泄漏。

以下符号实现了下方的约束。请对照 src/Html/ 逐项验证。

符号位置角色
HtmlParser::parse(string $html): HtmlRenderResultsrc/Html/HtmlParser.php入口点。重置所有状态,然后执行单遍扫描。
HtmlParser::MAX_ELEMENT_COUNT50_000src/Html/HtmlParser.php已处理元素数量的硬性上限。
HtmlParser::MAX_NESTING_DEPTH100src/Html/HtmlParser.php嵌套深度的硬性上限。
HtmlBlockCursorsrc/Html/HtmlBlockCursor.phpCursor 快照。唯一的共享状态机制。
HtmlStyleStatesrc/Html/HtmlStyleState.php推入栈的样式帧。没有父指针。
TableParser::reset()src/Html/TableParser.php在不同表格之间必须重置这个短暂表格缓冲区。

流式模型对调用方透明。一次调用即可渲染任何受支持的文档。

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\Document;
$doc = Document::createStandalone();
$doc->setTitle('Streaming render');
$doc->addPage();
$doc->writeHtml('<h1>One forward pass</h1><p>No retained tree.</p>');
$doc->save(__DIR__ . '/output/streaming.pdf');

在固定内存预算下渲染一份大型文档。元素上限是安全边界;请在调用前先估算输入大小。

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\Document;
use NextPDF\Exception\HtmlParsingException;
/**
* Render trusted HTML, surfacing the streaming-model limits as typed errors.
*
* @param non-empty-string $html
*/
function renderReport(string $html, string $out): void
{
$doc = Document::createStandalone();
$doc->addPage();
try {
$doc->writeHtml($html);
} catch (HtmlParsingException $e) {
// Thrown on the 10 MB input cap, the 50,000-element cap,
// or the 100-level nesting cap. These are model boundaries,
// not transient faults — do not retry.
throw $e;
}
$doc->save($out);
}
  • 元素上限是硬性中止点。 引擎会抛出 HtmlParsingException,触发条件是 MAX_ELEMENT_COUNT = 50_000。请将非常大的报表拆成多次 writeHtml() 调用,或拆分为多份文档。
  • 嵌套上限是硬性中止点。 深度超过 MAX_NESTING_DEPTH = 100 时会抛出异常。常见原因是包裹元素嵌套过深。
  • 输入大小上限。 HtmlParser::parse() 会在 tokenize 之前拒绝超过 10 MB 的输入。
  • :has() 受 gate 管控。 只有在启用 css.has 这项实验性功能时,:has() 预扫描才会执行。若未启用,:has() 选择器不会匹配。
  • 表格缓冲是唯一的短暂树状结构。 单个非常宽或非常高的表格,会将其行保留在内存中,直到 render() 为止。TableParser 会按表格限制这个缓冲区的大小,并在表格之间重置;它不是文档级树。
  • 不重新排版。 已经写出的内容绝不会被移动。后出现的样式无法回溯改变更早的输出。

流式模型在每个嵌套层级最多只持有一个 HtmlStyleState,受 MAX_NESTING_DEPTH = 100 约束,再加上当前作用中的 cursor 字段。样式状态与 cursor 的内存用量是 O(depth),而不是 O(element count)。ADR-001 记录的设计意图是:在相同输入下,这个用量会明显低于保留式对象图。这个受控的 50,000 元素 peak-RSS 基准测试,是 ADR-001 指定的实证验证目标。它通过 HTML render-pipeline 性能基准测试及其 5% 回归 gate 进行跟踪(已合并的工作,PR #564)。请将每页的 performance_budgetwall_ms: 1500peak_mb: 64)视为运行时上限。

本页列出的上限同时也是防拒绝服务(denial-of-service)的控制措施。DefaultHtmlSecurityPolicy 会在 parser 之外独立强制执行 10 MB 的输入上限与 100 层的嵌套上限,因此恶意文档无法通过深度或大小耗尽内存。流式模型本身在结构上限制了内存使用:没有任何可供攻击者膨胀的元素图。完整的策略范围请参阅 HTML 模块安全模型分层契约

本页未引用任何外部标准。这些约束源自 ADR-001,以及 API 接口一节列出的、用于落实约束的源代码符号。行为层面的 CSS 规范对应关系记录在 css-resolver 一节,而不是本页。

企业级能力。 流式架构在 Core 与 Premium 中完全相同。Premium 扩大了 CSS 覆盖范围;它并未改变单遍模型,也不会放宽这些上限。请参阅 CSS 支持对照表