技术文章

HotPDF Delphi 的 HTML 转 PDF 里的 CSS 多栏布局

当 THPDFHTMLImporter.RenderHTML5 在 Delphi 里把 HTML 转成 PDF 时,HotPDF Component 会排布 CSS 多栏文本。分页 HTML 引擎对照内容盒解析 column-count 和 column-width,用二分搜索平衡每个多栏容器的最后一个片段,早先的页面逐栏填到页底,并支持 column-fill、column-span: all 和 column-rule

想用它的理由无聊但真实:双栏产品简报、三窄栏的条款附录,或者一份要长得像营销团队已经用 HTML 排好的印刷简报的发布说明 PDF。没有栏支持,导入器把每个段落堆进一条宽行宽里,行长翻倍,文档读起来像日志文件。有了它,驱动浏览器预览的同一份样式表驱动 PDF,难点变成分页——浏览器在屏幕上基本不用操心的事

HotPDF 怎么决定用几栏?

HotPDF 按 CSS Multi-column Layout Module Level 1 §3.4 的伪算法同一套规则解析栏数,套在去掉 padding 和边框后的内容盒宽度 W 上。声明了 column-count 就直接采用。只给 column-width 时,引擎算 N = Trunc((W + gap) / (column-width + gap)),下限一栏。两者都给时取较小者,所以声明的栏数能给推导栏数封顶,但永远不会把它抬高

一个具体例子:483pt 的内容盒(A4 加 56pt 侧边距,取整)配 column-width: 140pt; column-gap: 12pt,得 Trunc(495 / 152) = 3 栏,每栏 (483 - 2 * 12) / 3 = 153pt 宽。同一条规则再加 column-count: 2 就降到两栏,因为较小者获胜。gap 关键字 normal 解析为容器字号的 1em,而且这个解析发生在布局时而不是解析时,所以同一规则里写在 column-gap 之后的 font-size 仍然产生正确的 gap

HotPDF 在 Delphi 里 HTML 转 PDF 的 CSS 多栏栏数解析:column-count 按声明采用,column-width 推导 N = Trunc((W + gap) / (column-width + gap)),两者都设时取较小者,低于 12pt 的栏一律塌回单栏
引擎把 CSS 伪算法套在内容盒上,用声明栏数给推导栏数封顶,并加 12pt 下限保护,让不可信模板造不出成千上万个微型栏

有一条保护不在规范里,是刻意加的。栏宽算出之后,任何低于 12pt 的值都把容器塌回单栏。一条写着 column-count: 9999 的样式表——无论是手滑还是来自不可信模板——否则会产出成千上万个负宽或微宽的栏,断行器试图每行塞一个字形。回退到单栏保住可读性,也保住布局成本的上限。零或负的栏数根本走不到那一步:验证器对 column-count 只接受 auto 或正整数,column-width 和 column-gap 分别接受 auto 或 normal,或以 pt 或 em 计的长度

在 Delphi 里把多栏 HTML 区块渲染成 PDF

多栏输出不需要在导入器上开任何开关。THPDFHTMLImporter 用它要画进去的 THotPDF 文档创建,BeginDoc 必须已经跑过,栏属性经正常级联到来——写在标记里,或写在作为 RenderHTML5 第二个参数传入的作者样式表里

uses
  SysUtils, HPDFDoc, HPDFHTMLImport;

procedure BuildBulletin(const FileName: string);
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;
    Importer.Margin := 56;
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;

    Css := 'section.news { column-width: 140pt; column-gap: 12pt; ' +
           '  column-rule: 0.5pt solid #999999 } ' +
           'section.news h2 { column-span: all }';

    Html := '<section class="news">' +
            '<p>Release 4.2 ships the new export pipeline...</p>' +
            '<p>Licensing changes take effect on 1 March...</p>' +
            '<h2>Known issues</h2>' +
            '<p>Import of legacy archives &amp; templates...</p>' +
            '</section>';

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('Nothing was emitted');
    Pdf.EndDoc;
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

注意最后一段的双重转义:Pascal 字符串里的 &amp; 是 and 符号的 HTML 实体,导入器会把它解码回单个字符。级联的其余部分、预算和资源回调的行为与 HotPDF HTML5 分页媒体导入总览描述的完全一致;栏只是同一 profile 里的又一种布局模式

用二分搜索平衡各栏

HotPDF 平衡栏的办法是搜索一个最小的栏高,使贪心填充仍能放下所有剩余行。贪心过程简单:把行装进第一栏直到下一行会超出候选高度,然后换第二栏,依此类推。搜索以最高单行为下界、所有行高之和为上界,每轮把区间减半,界距进入 0.05pt 之内即停

HotPDF 在 Delphi 里的多栏平衡:在最高单行与全部行高之和之间二分,找出贪心填充满下每一行的最小栏高,界距在 0.05pt 内收敛
每行都放得下这个谓词对栏高单调,所以区间减半收敛到最短的平衡带,最后一次贪心填充满下每一行、不留孤行

二分只有在谓词单调时才成立,而这里恰好单调:更高的栏绝不可能装下更少的行,所以"每行都放得下"随高度增长恰好从假翻到真一次。这个性质保证了收敛,也保证在找到的高度上做最后一次贪心填充会消费每一行,而不是留下一行孤行、触发一页多余的分页。它还解释了结果的一个细节:平衡带是行盒允许的那么短,不一定是等高栏——行不可分割,最后一栏可能比其他栏早结束一两行

栏跨过分页时会怎样?

当前页放不下的栏按顺序填到页底,只有最后一页的片段做平衡。这是 CSS Multi-column Layout Level 1 §7.1 对分片上下文里 column-fill: balance 的规定,也正是印刷简报的样子:除最后一页外每页都是满栏,最后一页把剩余文本均匀分开,而不是一栏顶天一栏空白地并排

每个页面片段独立过同一套分片器。剩余行装得进可用高度的 N 栏,片段就平衡;否则每栏填到页底,循环开新页。引擎为 break-inside: avoid 决策和范围测量用的整个容器的自然高度,是无限容量下的平衡带高度,所以一个在空白页上能作为平衡块整体放下的容器会被完整挪过去而不是切片。单行比整页还高时,引擎放松约束,仍然把该行放到页顶,并报告一条主题为 multi-column 的 hdkPaginationConstraint 诊断

HotPDF 在 Delphi 里 HTML 转 PDF 的多栏分页:最后一页之前的片段把每栏填到页底,最后一个片段跨栏平衡,column-span all 把容器在通栏元素前后拆成各自平衡的带
多栏容器只有最后一个片段做平衡;更早的页面按序填满,每个片段独立重复平衡、栏线与分页

column-fill: auto 与定高盒子

column-fill: auto 配确定的 height 时,HotPDF 按顺序填栏,每栏高度取剩余页面空间与容器高度减去其 padding 和边框中的较小者。这个最小值比看上去要紧。只用剩余页面空间的话,只要页面剩余空间比盒子高,第一栏就会吞下每一行。没有确定高度时,column-fill: auto 和浏览器一样回退到平衡,因为不受约束的多栏容器没有可填的栏高;第一版实现漏了这个回退,27 项回归检查之一逮住了它

column-span: all 与嵌套段落

HotPDF 支持多栏容器直接子元素上的 column-span: all,遵循 CSS Multi-column Layout Level 1 §6.1。spanning 子元素把容器拆成几段:它之前的子元素构成一条栏带,spanning 元素横贯全宽渲染,它之后的子元素另起新带。每条带各自平衡、填充、画线、分页,所以三栏区块中间的一个标题产出两条平衡带夹一个通栏标题——这正是人们在小样里画的布局

走到这一步之前得先修一个更隐蔽的问题。像 <div style="column-count:2"> 带若干 <p> 子元素的容器,过去把每个段落渲染进单条堆叠的栏,因为每个段落都作为自己的文本块排版,父元素上的栏声明从未生效。HotPDF 现在把嵌套块级子元素的文本收进容器的一个流。收集时必须在每个块边界插入硬换行;漏掉它会把上一段最后一个词和下一段第一个词拼在同一行——测试文本恰好以标点结尾时,这个 bug 很容易溜过去

合并流有刻意的适用范围。只有整棵子树都是纯文本流时才用。容器内部任何位置含表单控件、图像、表格、列表或 <hr> 的,保持普通块路线、以单栏渲染,因为合并文本收集器表达不了它们的几何。表格尤其有自己的分页规则——HotPDF 的 rowspan 处理与重复表头一文有讲——把它们混进栏带会破坏那些保证

栏线画在哪,以及怎么测它

HotPDF 把 column-rule 画在每条 gap 的中线——栏右缘加半个 gap——从带顶到带底,每个页面片段都画,如 CSS Multi-column Layout Level 1 §4 所述。宽度默认 1pt。颜色未声明或为 currentColor 时默认元素文字色。不带样式关键字的 column-rule 简写按 solid 处理,与 border 简写通常的读法一致;none 和 hidden 抑制栏线

栏几何不解析 PDF 也能观察。THPDFHTMLImporter 上的 OnLineLayout 事件每画一行触发一次,带 THPDFHTMLLineLayout 记录;引擎逐栏分别绘制,所以每行的 X 字段就是它所在栏的左缘。相邻栏恰好相差栏宽加 gap,回归测试里区分 12pt gap 和 20pt gap 很容易

type
  TColumnProbe = class
  public
    Lines: TStringList;
    constructor Create;
    destructor Destroy; override;
    procedure LineDrawn(Sender: TObject; const Line: THPDFHTMLLineLayout);
  end;

constructor TColumnProbe.Create;
begin
  inherited Create;
  Lines := TStringList.Create;
end;

destructor TColumnProbe.Destroy;
begin
  Lines.Free;
  inherited;
end;

procedure TColumnProbe.LineDrawn(Sender: TObject;
  const Line: THPDFHTMLLineLayout);
begin
  // PageIndex 从零起计;X 是该行所在栏的左缘
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// 接线,在 RenderHTML5 之前:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

对着这个事件写测试的两条实用备注。PageIndex 从零起计,这是手写断言第一个容易错的点。X 含对齐偏移,要比栏位置就换左对齐夹具测居中或右对齐文本。栏线本身不出现在事件里;要断言它的位置,去未压缩内容流里扫竖直的 m 和 l 操作符

值得知道的限制与陷阱

有几个行为是近似,布局评审之前知道比之后知道好。合并栏流里的段落间距按每个块边界一行容器行高近似,所以栏带内每段的 margin 不会精确复现。column-span: all 拆容器时,每段重复容器的 padding 和背景盒——这让各段视觉上连续,但严格 CSS 意义上不是一个盒。除 none 和 hidden 之外的每个 column-rule-style 值都通过验证,但栏线一律按实线描

不支持的值不会无声失败。把 StrictCSS 设为 True,每条诊断都变成 EHPDFHTMLUnsupportedCSS 异常;OnDiagnostic 则让批处理任务记录后继续。注意严格模式同样适用于分页诊断,所以行高超过一页的文档在 StrictCSS 下会抛异常而不是悄悄放松约束

procedure TReportJob.HtmlDiagnostic(Sender: TObject;
  const Diagnostic: THPDFHTMLDiagnostic);
begin
  case Diagnostic.Kind of
    hdkUnsupportedProperty, hdkUnsupportedValue:
      FLog.Add('CSS ' + Diagnostic.Subject + ': ' + Diagnostic.MessageText);
    hdkPaginationConstraint:
      FLog.Add('Layout relaxed (' + Diagnostic.Subject + '): ' +
        Diagnostic.MessageText);
  else
    FLog.Add(Diagnostic.MessageText);
  end;
end;

// 模板制作:引擎会忽略的东西一律快速失败
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

模板还在写的时候,严格模式是对的设置——column-span: both 这类笔误被立刻拒绝,而不是无声地让标题流进栏里。生产环境里经 OnDiagnostic 记日志通常是更好的交易,客户 HTML 里一条奇怪的声明不该赔上整个文档。相同诊断(同 kind、subject 和 value)在 MaxDiagnostics 列表容量内只报一次,默认 256 条,所以级联对成百元素重复造访的声明不会把日志刷爆

FAQ

HotPDF 支持 columns 简写吗?

支持。columns 简写按任意顺序收至多一个正整数栏数和一个栏长度,auto 重置两个长属性。解析值遵循与单独属性相同的栏数与栏宽规则

多栏容器能装图像或表格吗?

能装,但 HotPDF 随后会把该容器按单栏渲染。只有每个后代都是纯文本流时才应用栏流,因为图像、表格、列表和表单控件需要自己的盒几何与分页规则

为什么最后一页是平衡栏而更早的页面是满栏?

这正是分页媒体里 column-fill: balance 的 CSS 规则:只有最后一个片段做平衡。更早的页面把每栏填到页底,文档中段不浪费空间

多栏布局、HTML5 分页媒体 profile 和 HTML 转 PDF 管线的其余部分都是 HotPDF Component for Delphi and C++Builder 的一部分,它还覆盖直接 PDF 绘图、表单、加密与文本提取