生成一份报表,归根到底就是把三样东西摆到页面上,并让它们对各自的位置达成一致:落在已知坐标上的文本、在服务器上和你桌面上呈现一致的字体,以及尺寸合适的图像。报表库做的其他一切,都是围着这三样安排的。HotPDF 是 losLab 面向 Delphi 与 C++Builder 的 PDF 生成库,它把这三样都做成页面对象上的直接调用,真正的摩擦只有底下那套坐标系 —— 它的方向和你熟悉的 VCL 画布正好相反。先把这个朝向理顺,剩下的排版工作就不再跟你较劲
文本定位与左下角原点
几乎每个人的第一份报表都是倒着出来的。标题落在靠近底边的地方,它下面的每一行反而往顶上爬。这不是哪里坏了。PDF 用户空间在 ISO 32000-1 §8.3 中定义,原点在左下角、Y 向上增长,正好是 GDI 画布的镜像 —— 后者从左上角开始、Y 向下增长。花五分钟跟它握手言和,能省下一整套本来要在数字对不上时重写的排版
页面对象的核心调用是 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'); // 距 Letter 页顶 50pt
Pdf.CurrentPage.SetFont('Arial', [], 10);
Pdf.CurrentPage.TextOut(50, 792 - 70, 0, 'Date: 2026-06-11');
Pdf.CurrentPage.TextOut(300, 400, 45, 'COPY'); // 旋转的水印
Pdf.AddPage; // CurrentPage 现在指向这一页
Pdf.CurrentPage.SetFont('Arial', [], 10); // 字体状态不会延续过来
Pdf.CurrentPage.TextOut(50, 742, 0, 'Page 2 detail rows');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
这段代码里的两处有状态行为,要为大多数只在第二页才现身的 bug 负责。AddPage 会把 CurrentPage 重新指向它刚创建的那一页,因此你早先缓存的页面引用不会再画到你以为的地方。字体选择同样是按页而非按文档保持的。如果你在 AddPage 之后跳过 SetFont,新页上的第一次 TextOut 会退回该页起始时的默认字体,而不是你三页之前设好的粗体标题字体。安全的习惯是把“开新页”和“重建文本状态”当成报表循环中不可分割的一步
要让字体存在于服务器上,而不只是你的桌面上
大多数字体问题其实是伪装过的部署问题。你的开发机装了公司字体,于是报表在你屏幕上看着没问题就发了出去。生产主机用一个从未装过该字体的服务账号跑这个任务,渲染器悄悄替换成它能找到的东西,而最先听到消息的,是客户来问信头为什么变了。出路是别再信任操作系统的字体目录,改从安装程序放到磁盘上的文件加载字体。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,这一点比乍看更重要。一个带重音的客户姓名、一条德国街道、一座波兰城市:这些不是边缘情况,而是客户表里再正常不过的内容,只要注册的字体确实包含这些字形,它们走的就是和你硬编码的 ASCII 标签同一个调用。嵌入字体还捎带一条版本约束:文档必须是 PDF 1.5 或更高版本,所以如果有别的需求把你钉在更老的版本上,坏掉的正是这一环。阿拉伯语、希伯来语这类从右往左的文字需要真正的塑形而非直接查字形,那有自己的一套流水线;见我们关于用 HotPDF 做复杂文字塑形的文章
当没有任何已安装字体能表达你需要的东西时 —— 想想支票上的 MICR 字符或者一套专有符号集 —— Type 3 字体来填补这个空缺。你通过 RegisterType3Font 和 AddType3Glyph 把每个字形定义成一小段内容流。这是 API 里一个专门的角落,你极少会用到它,但它比把几百个小符号位图撒满一页要干净得多
图像:中间那两个参数是宽和高,不是对角
图像处理分成两步,把它们分开正是全部要点。AddImage 接受一个 TBitmap 或 TJPEGImage,把它嵌入一次,然后回传一个索引。PNG 图稿必须先解码成位图才能送进去。之后 ShowImage 就能把那个索引画到你想要的任何位置、想画几次画几次。ShowImage 的参数顺序是唯一值得放慢速度读一遍的地方:
var
Png: TPngImage;
Logo: TBitmap;
LogoIdx: Integer;
begin
Png := TPngImage.Create;
Logo := TBitmap.Create;
try
Png.LoadFromFile('brand-logo.png');
Logo.Assign(Png); // 把 PNG 解码成位图
LogoIdx := Pdf.AddImage(Logo, icFlate); // 平涂色稿用无损压缩
finally
Logo.Free;
Png.Free;
end;
// (Index, X, Y, Width, Height, Angle):不是 (X1, Y1, X2, Y2)
Pdf.CurrentPage.ShowImage(LogoIdx, 50, 700, 120, 40, 0);
end;
位置之后的两个数字是宽和高。它们不是对角的坐标,而末尾那个参数是以度为单位的旋转角。若把签名读成 X1/Y1/X2/Y2 的方框,一个放在 (50, 700) 的 120 乘 40 徽标就会从那里一直拉伸到 (120, 40),摊满大半页。输出会把这个错误摆得明明白白,而源代码看上去完全合情合理,这正是它能吃掉一下午的原因。KeepImageAspectRatio 默认为 True,因此比例不对的方框会给图像加黑边而不是把它拉变形;只有当你真想拉伸时,才把它翻成 False
注册与摆放分离,在长批量任务上会回本。因为 AddImage 只嵌入一次像素,而带该索引的每一次 ShowImage 都指回同一个嵌入对象,所以你在哪里调用 AddImage 决定了文件大小。把它放进 500 页对账单的页循环里,同一个徽标就会被嵌入 500 次。在循环之前调一次、留住索引,徽标就只存一份。一个以资源路径为键的小字典,就足以保证每张不同的图像只被注册一次
编解码器的选择是另一根尺寸杠杆。照片类内容,比如扫描附件之类,该走 JPEG:给 AddImage 传 icJpeg,并把 JpegQuality 降到 85 左右,因为该属性起始值是 100,而 85 的差别在印出来的纸面上看不出来。徽标、图表、线条图这类平涂色稿该走 icFlate,那里无损压缩本身就已经很紧凑,而 JPEG 会在硬边缘周围抹出可见的振铃。一次给每页都塞一张全质量照片的对账单批量任务,能膨胀到数 GB;同样的内容用 JPEG 85 大约落在十分之一,而没有哪个读者分辨得出来
用路径图元画分隔线、方框与底色
表头下方那条横线、合计数字背后那个灰框,都不需要用图像。把它们画成矢量,在任何缩放级别都保持锐利,打印清晰,而且几乎不给文件增重。HotPDF 遵循的是原始 PDF 内容流用的同一套模型:先构建路径,再调用一个把它绘制出来的操作符
// 表头下方的水平分隔线
Pdf.CurrentPage.SetLineWidth(0.75);
Pdf.CurrentPage.MoveTo(50, 660);
Pdf.CurrentPage.LineTo(545, 660);
Pdf.CurrentPage.Stroke;
// 带底色的合计框:X、Y、宽、高
Pdf.CurrentPage.SetRGBFillColor(RGB(235, 235, 235));
Pdf.CurrentPage.Rectangle(395, 120, 150, 40);
Pdf.CurrentPage.Fill;
顺序不是可选的:先设置绘制状态,再构造路径,然后调用 Stroke 或 Fill。一条构建了却从未绘制的路径对页面毫无贡献,而当一条分隔线“没显示出来”时,答案几乎总是这个。SetRGBFillColor 接受单个 TColor,所以 clNavy、clBlack 这些熟悉的 VCL 常量可以直接塞进去,而 Rectangle 用的是和图像摆放相同的宽高参数,不是两个对角。关于细线有一条提醒:任何低于约半点的线宽在显示器上可能很优雅,到了 600 dpi 的办公打印机上却会消失,所以对任何必须扛住打印的分隔线,0.75pt 是个合理的下限
拿真实数据而不是样本数据来做分页
在版式定型之前有一个细节要做对:数值列应当按右边缘对齐,而做法是量出每个值渲染后的宽度,再从列边界往回定位,而不是给字符串补前导空格。空格补齐只在等宽字体里能对齐,而没人会用等宽字体排财务报表。先让数值走一遍 Delphi 的区域感知例程比如 FormatFloat,这样你量宽度的那个千位分隔符,才是客户区域设置真正会显示的那一个
分页的危险在于,你是照着演示数据集写的:十行短记录正好装满一页,循环永远不必换页。生产环境递给你的是一个公司名长达 140 个字符的客户,和一份有 4000 条明细的对账单,此时循环必须每次都正确换页。经得起考验的模式是一个 Y 游标:每减去一行的高度它就向下移动,并在游标即将越过下边距的那一刻检查并开新页。这里的“向下”意味着 Y 变小,这是左下角原点唯一始终反直觉的地方。把这一切都放进同一个例程里,让它同时在新页上重新执行 SetFont 并重画连续表头,跳页差一页的 bug 就永远站不住脚。当同一批报表还要满足归档或无障碍规则时,你在这里做的选择 —— 嵌入哪些字体、输出是否带标记、用哪些色彩空间 —— 正是那些标准要管的;在模板定型之前值得先读一读 HotPDF 的 PDF/A、PDF/X 与 PDF/UA 指南
本文展示的每一个调用 —— 文本定位、字体注册、图像嵌入和路径绘制 —— 都随面向 Delphi 与 C++Builder 的 HotPDF Delphi Component 一同发布,其参考文档记录了完整的输出 API,以及与之相邻的表单、加密和签名功能