技术文章

使用 HotPDF 创建 Delphi PDF 报表:TextOut、字体和图像

几乎每个团队第一次用 PDF 库渲染发票时,都会以同样方式出错:标题文本贴在页面底边,后续每一行都向上爬。没有任何东西坏掉。按照 ISO 32000-1 §8.3,PDF user space 的原点在左下角,Y 向上增长,和 VCL 开发者多年使用的 GDI canvas 正好相反。HotPDF 是 losLab 面向 Delphi 和 C++Builder 的 PDF 生成库,它直接暴露这种坐标模型,所以现在花五分钟内化它,可以避免之后重写布局。本文走查报表生成器实际需要的输出基元:定位文本、能经受部署的字体、图像放置和矢量绘制。

文本放置和左下角原点

页面对象的核心调用是 TextOut(X, Y, Angle, Text)。X 和 Y 以点为单位从左下角定位文本,Angle 以度数旋转文本,这就是不借助额外机制绘制对角 DRAFT 和 COPY 印章的方法。让 VCL 训练出的直觉继续工作的惯用法,是把 Y 计算为页面高度减去距顶部距离:

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice-0001.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [fsBold], 16);
    Pdf.CurrentPage.TextOut(50, 792 - 50, 0, 'INVOICE');       // 50pt from top of Letter
    Pdf.CurrentPage.SetFont('Arial', [], 10);
    Pdf.CurrentPage.TextOut(50, 792 - 70, 0, 'Date: 2026-06-11');
    Pdf.CurrentPage.TextOut(300, 400, 45, 'COPY');              // rotated stamp
    Pdf.AddPage;                                                // CurrentPage now points here
    Pdf.CurrentPage.SetFont('Arial', [], 10);                   // font state does not carry over
    Pdf.CurrentPage.TextOut(50, 742, 0, 'Page 2 detail rows');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

清单中的两个有状态行为导致大多数第二页 bug。AddPage 会把 CurrentPage 指向新页面,因此你缓存的上一页对象引用对绘制用途已经过期。字体选择也是按页生效的:应重新调用 SetFont,并在每次 AddPage 之后执行,否则新页面上的第一个 TextOut 不会使用你以为的字体。报表循环应把“新页面”和“重新建立文本状态”作为一个单元处理。

字体要存在于服务器,而不只是你的桌面

字体失败就是部署失败。开发机安装了企业字体;生产主机上的 Windows 服务账号没有,输出就会静默替换。防御性模式是从安装程序控制的文件加载字体,而不是信任 OS 字体目录,HotPDF 的 Unicode 注册调用正是这样做的:

Pdf.RegisterUnicodeTTF('C:\ProgramData\MyApp\Fonts\NotoSans.ttf');
Pdf.CurrentPage.SetFont('NotoSans', [], 12);
Pdf.CurrentPage.TextOut(50, 700, 0, WideString('Łódź — Ünïcode test ✓'));

注意,TextOut 直接接收 WideString,所以包含本地代码页之外任何内容的客户数据,也就是实践中所有客户数据,只要所选字体覆盖 glyph,就会和 ASCII 报表装饰通过同一个调用输出。嵌入式 Unicode 字体还要求文档为 PDF 1.5 或更高版本,因此如果其他需求把你固定在较旧版本,要记住这个版本下限。对于需要成形而不仅是 glyph 查找的脚本,尤其是阿拉伯语和希伯来语,专用从右到左管线见关于 HotPDF 复杂文字成形的文章

在少见的“没有字体文件可以表示所需内容”的场景下,例如 MICR 类标记、专有符号,HotPDF 通过 RegisterType3FontAddType3Glyph 支持 Type 3 字体,每个 glyph 都是你定义的小内容流。这是小众工具,但比把符号作为数百张微小图片交付更好。

图像:中间参数是宽高,不是另一个角

HotPDF 把图像注册与放置分开。AddImage 一次接收 TBitmapTJPEGImage,PNG 图稿先解码为 bitmap,并返回索引;ShowImage 可以按需多次放置该索引。需要读两遍的是签名:

var
  Png: TPngImage;
  Logo: TBitmap;
  LogoIdx: Integer;
begin
  Png := TPngImage.Create;
  Logo := TBitmap.Create;
  try
    Png.LoadFromFile('brand-logo.png');
    Logo.Assign(Png);                       // decode PNG to a bitmap
    LogoIdx := Pdf.AddImage(Logo, icFlate); // lossless for flat-color art
  finally
    Logo.Free;
    Png.Free;
  end;
  // (Index, X, Y, Width, Height, Angle) — not (X1, Y1, X2, Y2)
  Pdf.CurrentPage.ShowImage(LogoIdx, 50, 700, 120, 40, 0);
end;

位置之后的两个数是宽度和高度,而不是对角点,最后一个参数是旋转角度。按 X1/Y1/X2/Y2 假设写出的代码,会把 logo 拉伸到页面大部分区域,这是输出中显而易见、源码中却令人困惑的 bug。相关地,KeepImageAspectRatio 默认是 True,所以不匹配的盒子会 letterbox,而不是扭曲;只有真正想拉伸时才应设为 False

注册与放置分离也影响性能和文件大小:AddImage 只嵌入一次位图数据,之后每个使用相同索引的 ShowImage 都复用这个嵌入对象。一个 500 页对账单作业,如果每页都为同一 logo 调用 AddImage,会嵌入 500 次 logo;同一作业如果注册一次并复用索引,就只嵌入一次。用按资源路径索引的小字典缓存索引,这个问题就不会出现。

文件大小也在这里决定。摄影内容应走 JPEG 编码,即传入 icJpegAddImage,并把 JpegQuality 设在约 85,因为该属性默认 100。对于扫描附件和照片,这在视觉上干净,尺寸却只是无损的一小部分。PNG 应留给 logo 和图表等平面色图稿,在那里 JPEG 振铃伪影可见,而 Flate 压缩本来就高效。每页嵌入一张照片且设置错误的对账单批次会发出 GB 级文件;同一批次使用 JPEG 85,体积可以降到十分之一,而且没人会用肉眼抱怨。

用路径基元绘制规则线、边框和底纹

表格线和合计框完全不需要图像;矢量基元在任意缩放下更清晰,文件大小成本几乎为零。模型是先构建路径,再执行绘制操作:

// Horizontal rule under the table header
Pdf.CurrentPage.SetLineWidth(0.75);
Pdf.CurrentPage.MoveTo(50, 660);
Pdf.CurrentPage.LineTo(545, 660);
Pdf.CurrentPage.Stroke;

// Shaded totals box: X, Y, width, height
Pdf.CurrentPage.SetRGBFillColor(RGB(235, 235, 235));
Pdf.CurrentPage.Rectangle(395, 120, 150, 40);
Pdf.CurrentPage.Fill;

顺序纪律和原始 PDF 内容流相同:设置绘制状态,构建路径,然后调用 StrokeFill。从未绘制的路径会直接消失,这通常就是规则线“不显示”的原因。SetRGBFillColor 接收单个 TColor,所以 VCL 常量如 clNavyclBlack 可直接使用,Rectangle 遵循和图像放置相同的宽高约定。Hairline 需要一个提醒:低于约半点的线宽在屏幕上看起来优雅,却可能在 600 dpi 办公打印机上完全消失,因此 0.75pt 是必须经受纸张输出的表格线的合理下限。

用真实数据处理分页,而不是样本数据

数值列会暴露另一个应尽早建立的习惯:通过列右边界减去每个值的渲染宽度来计算 X 位置,让金额右对齐,而不是用空格填充字符串。空格填充只在等宽字体中对齐,而财务报表几乎不会使用等宽字体。应先通过 Delphi 的 locale-aware 例程,例如 FormatFloat,格式化数值再测量,这样客户 locale 期望的千位分隔符,才是你测量宽度的那个字符。

演示数据集有十条短行;生产数据里会有公司名称 140 个字符的客户,也会有 4,000 个行项目的对账单。健壮的报表循环跟踪一个向下移动的 Y 游标,减去每行高度,并在游标将越过底部边距时换页,记住在这个坐标系中“向下”意味着 Y 减小。把分页处理集中在一个位置,在其中重新发出 SetFont 并重绘运行页眉,off-by-one-page bug 就会消失。如果报表还必须满足归档或无障碍要求,嵌入字体、tagged output、颜色空间等这里做出的生成选择,正是标准会约束的内容;模板固化前应先阅读 HotPDF PDF/A、PDF/X 和 PDF/UA 指南

FAQ

为什么我的文本渲染在页面底部?

PDF 的原点在左下角,Y 向上增长。用 PageHeight - Offset 转换相对顶部的位置,或者从一开始就围绕左下角原点设计布局代码。

为什么第一页字体正确,第二页字体错误?

AddPage 也会把 CurrentPage 切换到新页面。应调用 SetFont,并在每次 AddPage 后、第一次 TextOut 前执行。

嵌入很多照片时如何保持文件大小合理?

对摄影内容传入 icJpegAddImage,并把 JpegQuality 设在接近 85;无损 icFlate 留给平面色 logo 和线稿。每个不同图像只用 AddImage 注册一次,然后复用索引。

产品参考

本文中的每个调用都随面向 Delphi 和 C++Builder 的 HotPDF Component 提供;它在表单、加密和签名功能之外,也记录了完整文本、字体、图像和绘图 API。