几乎每个团队第一次用 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 通过 RegisterType3Font 和 AddType3Glyph 支持 Type 3 字体,每个 glyph 都是你定义的小内容流。这是小众工具,但比把符号作为数百张微小图片交付更好。
图像:中间参数是宽高,不是另一个角
HotPDF 把图像注册与放置分开。AddImage 一次接收 TBitmap 或 TJPEGImage,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 编码,即传入 icJpeg 给 AddImage,并把 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 内容流相同:设置绘制状态,构建路径,然后调用 Stroke 或 Fill。从未绘制的路径会直接消失,这通常就是规则线“不显示”的原因。SetRGBFillColor 接收单个 TColor,所以 VCL 常量如 clNavy、clBlack 可直接使用,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 前执行。
嵌入很多照片时如何保持文件大小合理?
对摄影内容传入 icJpeg 给 AddImage,并把 JpegQuality 设在接近 85;无损 icFlate 留给平面色 logo 和线稿。每个不同图像只用 AddImage 注册一次,然后复用索引。
产品参考
本文中的每个调用都随面向 Delphi 和 C++Builder 的 HotPDF Component 提供;它在表单、加密和签名功能之外,也记录了完整文本、字体、图像和绘图 API。