简介:Delphi-Android-GeraPDF-master是一份面向 Delphi 开发者的 Android 端 PDF 生成库,解决了在 FireMonkey 框架下动态生成 PDF 文档的痛点,适合有一定 Delphi 基础、需要在移动端快速实现 PDF 导出的开发人员。压缩包仅 7 个文件,体积 45KB,包含 XML 配置模板、FMX 界面单元、PAS 核心逻辑、DPR 工程与 DPROJ 工程配置等,结构精简,便于在 IDE 中打开查看工程全貌,快速追踪代码脉络。当前已有 115 人学习下载,适合需要在 Delphi Android 应用中实现电子发票导出、统计报告生成、合同模板填充等场景的开发者,也可以作为入门参考。库中 PDFGenerator 组件与 Androidpdf 图形渲染模块分工明确,前者提供 PDF 构建 API,后者负责页面布局、多字体文本、基本图形、图像插入和表格绘制,还支持链接、书签等交互元素。配合 README 与演示工程,可帮助理解从页面创建到内容输出的完整流程,大幅减少自行对接 PDF 底层格式的封装成本,使开发者能更专注业务功能本身。
1. 先搞清楚 Delphi Android 里的 PDF 生成该走哪条路
做 Delphi Android 报表的人几乎都会遇到同一个卡点:后端数据拿到了,列表画出来了,客户一句“导出 PDF”把整个交付卡住。打印预览不等于 PDF,Rave Reports 在移动端早就不顺手,临时去 Android Studio 里写 Java 再回 Delphi 做 JNI 又要把两个编译链串起来。这时候,像 Delphi-Android-GeraPDF 这种命名里同时带着 PDFGenerator 和 androidpdf 的源码工程,就成了高频搜索词。它真正要解决的并不是做一个 PDF 图标,而是把 Delphi 的 Canvas 绘制指令翻译成 PDF 内容流,并处理字体、分页和 Android 文件路径。我会按最常见的跨平台工程结构,从核心模块、最小可运行工程、踩坑点到集成前的验证方法,把这条路拆开讲清楚。
2. 从 PDFGenerator 源码看 Delphi Android 生成 PDF 的核心模块
拿到这类源码,不要先找生成按钮,先看 uses 关键字。如果主单元里出现 PdfWriter.pas、SynPDF、TPdfDocument 之类的引用,通常是纯 Delphi 实现;如果出现 Androidapi.JNI.GraphicsContentViewText、TJIntent 或 TJFileProvider,则是通过 JNI 调 Android 原生能力。这个判断直接决定排错路径:前者去查内容流和字体对象,后者去查 JAR 是否匹配当前 Android 版本。
2.1 纯 Delphi 生成 PDF 与 Java 封装两条路线的取舍
这里没有绝对正确答案,只有成本和维护面的取舍。我把常见路线按工程结构拆开。
| 路线 | 典型形态 | 适合场景 | 主要代价 |
|---|---|---|---|
| 纯 Delphi | 自写 PdfWriter.pas 或集成 SynPDF | 需要同一套代码跨 Windows/Android 复用 | 字体嵌入、压缩、表格排版要自己维护 |
| Java/JAR 封装 | androidpdf.jar + JNI Bridge | 需要 iText、PDFBox 等成熟生态 | 每次 Android SDK 升级都要检查 JAR 签名 |
| Android 系统 Intent | ACTION_CREATE_DOCUMENT | 用户手动选择目录,自动化要求低 | 无法静默覆盖文件,也无法批量输出 |
纯 Delphi 的方案看起来代码多,但调试链路最短。你可以在 Windows 下直接跑同一个 Writer 类,输出文件后再放到 Android 里验证,不需要等 JAR 编译和 JNI 签名。标题里出现 GeraPDF、PDFGenerator 这类命名时,通常也偏向前者,因为它强调的是“生成器”而不是“打印机桥接层”。
2.2 PDF 文件底层格式与 TCanvas 绘制的映射
PDF 不是一个连续绘制的文档,而是一组带偏移量的对象。阅读器拿到文件后先读最后位置的 startxref,找到 xref 交叉引用表,再由 xref 定位 Catalog、Pages、Page 和内容流。Delphi 的 TCanvas 绘图指令,最终都要翻译成内容流里的 PDF 操作符。
| Delphi 绘制动作 | PDF 内容流操作符 | 说明 |
|---|---|---|
| Canvas.TextOut | BT /F1 12 Tf 50 50 Td (Hello) Tj ET | 开始文本块,设字体,移到坐标,画字符串 |
| Canvas.Rectangle | re f | 画矩形并填充 |
| Canvas.MoveTo/LineTo | m l S | 构造路径并描边 |
| Canvas.Brush.Color | 1 0 0 rg | 当前填充色改成 RGB 红 |
坐标转换是最容易漏的一步。Delphi Canvas 原点在左上角,PDF 原点在左下角。直接抄坐标会得到上下颠倒的内容。常见做法是写入前做一次Y := PageHeight - Y - FontHeight,并且要提前量出 TCanvas 的字体高度,否则第二行文本会顶到第一行。
2.3 源码里常见的 TPdfWriter:一页文本怎么落盘
一个 PDFGenerator 源码工程,核心类通常叫 TPdfWriter 或 TPdfDocumentWriter。我一般会先看它有没有独立的 AddText、AddLine、NewPage 方法,而不是把整个页面的绘制逻辑写在按钮单击事件里。下面这段是一个 Writer 类对外暴露的最小骨架。
type TPdfWriter = class private FStream: TMemoryStream; FOffsets: array [0 .. 9] of Int64; FPageCount: Integer; procedure WriteRaw(const AData: UTF8String); public procedure StartDoc; procedure NewPage(AWidth, AHeight: Integer); procedure AddText(const AText: string; AX, AY, AFontSize: Single); procedure FinishPage; procedure SaveToFile(const AFileName: string); end;AddText 的实现逻辑通常是这样:
procedure TPdfWriter.AddText(const AText: string; AX, AY, AFontSize: Single); var Op: UTF8String; begin Op := UTF8String(Format('BT /F1 %.1f Tf %.1f %.1f Td (%s) Tj ET', [AFontSize, AX, AY, EscapePdfText(AText)])); WriteRaw(Op); end;这段代码把一次文本绘制变成内容流指令。BT 表示开始文本块,Tf 设置当前字体字号,Td 把基线移动到指定坐标,Tj 输出字符串,ET 结束文本块。这里的 AX 和 AY 已经是 PDF 坐标,所以外层调用方需要负责把 Delphi Canvas 的左上原点换算成左下原点。EscapePdfText 必须处理字符串里的括号和反斜杠,否则文本里出现(、)会把 PDF 解析器直接带偏。
3. 在 Delphi Android 上跑通最小 PDF 生成工程
源码骨架有了,下一步是生成一个真正能被 PDF 阅读器打开的最小文件。不要一上来就上报表组件,先用手写对象的方式把协议打通。这样后面接字体和表格时,你知道哪里该插入什么对象。
3.1 最小工程结构与必要权限
一个能跑的工程至少需要三个文件:MainForm.pas、PdfWriterU.pas、AndroidManifest.template.xml。MainForm 只负责传入文本和输出路径,PdfWriterU 负责生成 PDF 字节,AndroidManifest 决定文件写到哪个目录时不会被系统拦截。
Android 8 及以下需要 WRITE_EXTERNAL_STORAGE 权限,Android 9 以上如果目标目录是应用专属目录,通常不需要额外权限。更省事的方式是把 PDF 写到TPath.GetDocumentsPath下,再通过 FileProvider 暴露给其他应用读取。这个做法在 Android 11 到 13 上都能稳定工作。
3.2 生成 PDF 到公共目录的完整代码
下面这段代码不依赖任何第三方库,只使用 Delphi 自带的 TFileStream,就能生成一个单页 A4 文本 PDF。它手工维护 xref 偏移量,用来生成最小的有效文件。
uses System.SysUtils, System.Classes; procedure TMainForm.GeneratePdf(const AFileName, AText: string); const PDF_HEAD: UTF8String = '%PDF-1.4'#13#10; var FS: TFileStream; Offsets: array [0 .. 5] of Int64; I: Integer; S, Content: UTF8String; XRefPos: Int64; begin FS := TFileStream.Create(AFileName, fmCreate); try // 1. 文件头 FS.WriteBuffer(PDF_HEAD[1], Length(PDF_HEAD)); // 2. 对象 1:Catalog Offsets[1] := FS.Position; S := UTF8String('1 0 obj'#13#10'<< /Type /Catalog /Pages 2 0 R >>'#13#10'endobj'#13#10); FS.WriteBuffer(S[1], Length(S)); // 对象 2:Pages,只有一个页面 Offsets[2] := FS.Position; S := UTF8String('2 0 obj'#13#10'<< /Type /Pages /Kids [3 0 R] /Count 1 >>'#13#10'endobj'#13#10); FS.WriteBuffer(S[1], Length(S)); // 对象 3:Page,A4 尺寸 Offsets[3] := FS.Position; S := UTF8String('3 0 obj'#13#10'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] ' + '/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>'#13#10'endobj'#13#10); FS.WriteBuffer(S[1], Length(S)); // 对象 4:内容流,这是 PDF 画布指令 Offsets[4] := FS.Position; Content := UTF8String('BT /F1 12 Tf 72 720 Td (' + EscapePdfText(AText) + ') Tj ET'#13#10); S := UTF8String('4 0 obj'#13#10'<< /Length ' + IntToStr(Length(Content)) + ' >>'#13#10'stream'#13#10) + Content + UTF8String('endstream'#13#10'endobj'#13#10); FS.WriteBuffer(S[1], Length(S)); // 对象 5:标准字体,Helvetica Offsets[5] := FS.Position; S := UTF8String('5 0 obj'#13#10'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'#13#10'endobj'#13#10); FS.WriteBuffer(S[1], Length(S)); // 3. xref 交叉引用表 XRefPos := FS.Position; S := UTF8String('xref'#13#10'0 6'#13#10'0000000000 65535 f '#13#10); FS.WriteBuffer(S[1], Length(S)); for I := 1 to 5 do begin S := UTF8String(Format('%.10d 00000 n'#13#10, [Offsets[I]])); FS.WriteBuffer(S[1], Length(S)); end; // 4. trailer 指向 Root 对象 S := UTF8String('trailer'#13#10'<< /Size 6 /Root 1 0 R >>'#13#10'startxref'#13#10 + IntToStr(XRefPos) + #13#10'%%EOF'#13#10); FS.WriteBuffer(S[1], Length(S)); finally FS.Free; end; end;逻辑上,文件头只占 10 个字节,后面的每个对象在写入前都用FS.Position记录偏移量。这个偏移量必须是文件开头到对象开始位置的字节数,而不是对象里的某个字段。对象写完后再统一生成 xref,这样阅读器才能在解析到 trailer 后跳回每个对象。/Length必须是内容流的真实字节数,多一个空格或少一个换行都会导致流解析失败。
辅助函数 EscapePdfText 要做三件事:把\转成\\,把(转成\(,把)转成\)。这个函数看起来不起眼,却是大部分 Delphi 手写 PDF 崩溃的源头。如果直接拼接用户文本,输入一个问号可能出现非法字符串,PDF 阅读器会直接报文件损坏。
3.3 代码逻辑和参数梳理
上面的代码里有几个参数值得单独列出来,集成到正式项目时可以按需修改。
| 参数 | 当前值 | 作用 | 调整场景 |
|---|---|---|---|
| MediaBox | 0 0 595 842 | A4 尺寸,单位是点 | 改成宽高比即自定义纸张 |
/F1 12 Tf | 12 | 文本字号 | 字号与页面尺寸成比例缩放 |
72 720 Td | 72, 720 | 文本起点坐标,左下原点 | 改变内容在页面中的位置 |
/Count 1 | 1 | Pages 对象的子页数 | 多页时同步修改 |
0 6 | 6 | 该对象流包含的 xref 条目数 | 新增对象后必须更新 |
这个最小工程里最容易被误改的就是 xref。很多人会在写完 xref 后又在文件末尾追加一个字节,导致最后对象偏移全部错位。我建议生成完文件后,把%%EOF前的最后一个字符固定为换行符,不要再做任何二次拼接。
4. 中文乱码、分页和 Android URI 的坑
最小工程能打开 PDF 后,接着就会碰到三个真实业务场景的坎:中文内容变成方块、数据量超过一页、生成的文件在 Android 其他 App 里打开时被提示找不到路径。这三个问题都不是偶然,背后分别是字体、分页策略和 URI 权限。
4.1 中文乱码的根源不是编码而是字体
很多 Delphi 工程一遇到中文乱码,第一反应是转 UTF-8。但 PDF 的 Type1 标准字体不支持中文字符集,哪怕内容流里全部 UTF-8,阅读器没有对应字体映射,仍然显示乱码。真正的解法是嵌入 TTF 字体,或者使用 Type0 / CIDFont 结构。
常见做法是注册一个支持中日韩文字的字体描述符,再把 TTF 原始字节作为 FontFile2 流对象写进 PDF。下面这段是嵌入字体时资源字典的关键部分。
// 使用 Type0 字体时,资源字典这样声明 S := '<< /Type /Font /Subtype /Type0 /BaseFont /SimSun ' + '/Encoding /Identity-H /DescendantFonts [6 0 R] >>'; // 6 0 对象指向 CIDFont S := '<< /Type /Font /Subtype /CIDFontType2 /BaseFont /SimSun ' + '/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> ' + '/FontDescriptor 7 0 R /CIDToGIDMap /Identity >>';这一段的细节是/Registry (Adobe)和/Ordering (Identity)必须按照 CID 字体的约定写,顺序错了阅读器会认为字体编码不合法。TTF 字节本身要作为单独的流对象嵌入,并且 FontDescriptor 里的/FontFile2必须指向这个流对象。如果你用的是开源库,通常只需要调用一个 RegisterFont 方法传入路径,但看懂它背后有哪些对象,才能在报错时知道是字体没嵌入还是引用 ID 没对上。
4.2 多页内容的分页处理
分页的核心逻辑是:当纵向坐标小于页边距时,结束当前页并创建新页面。在 PDFGenerator 源码里,这个判断通常写在 AddText 或 AddLine 的开头。
if CurrentY < PageBottom then begin FinishPage; NewPage(595, 842); CurrentY := PageTop; end;分页参数可以这样设置:PageTop 设为 50,PageBottom 设为 792,行高取字体高度的 1.5 倍。这样每一页的理论行数是(792 - 50) / RowHeight。实际操作时,行高不能只用 Font.Size 来算,TTF 的 Ascent 和 Descent 通常会占掉额外空间,所以要在写完第一页后用真实文本的高度做一次校准。
| 参数 | 取值 | 含义 |
|---|---|---|
| PageTop | 50 | 页面上边距,从页面顶部算 |
| PageBottom | 792 | 页面下边距,从页面底部算 |
| RowHeight | 18 或 FontHeight * 1.5 | 每行内容占用的纵向空间 |
| PageCount | 动态累计 | 写入 Pages 的 Kids 数组 |
分页时还要记得把 Pages 对象的/Count更新为实际页数,Kids 数组同步追加新页面对象 ID。漏掉任何一处,PDF 阅读器都只显示第一页或直接报结构错误。
4.3 FileProvider 与 content:// 的坑
Delphi Android 应用写完 PDF 后,通常要调系统阅读器打开。如果你直接传file:///storage/emulated/0/xxx.pdf,Android 7 以上会直接抛 FileUriExposedException。正确的做法是配置 FileProvider,对外只暴露 content:// URI。
AndroidManifest.template.xml 里需要加入 provider 声明。
<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>对应的 res/xml/file_paths.xml 要告诉系统哪些目录可以授权。
<paths> <external-files-path name="reports" path="reports/" /> </paths>然后把 PDF 写到TPath.GetDocumentsPath + '/reports/'下,再在 Delphi 端用content://包名.fileprovider/reports/xxx.pdf拼接 URI。这里最容易踩的坑是 provider 的 authorities 必须和 Delphi 工程里的包名一致。如果写死了一个和包名无关的字符串,运行时就会在 Android 系统的 query 阶段被杀掉。
5. 把 PDFGenerator 集成进大型 App 前先做的验证
进入正式业务前,用 3 组数据验证比直接接报表更省时间。我通常会准备空字符串、纯英文、中文各一条,分别看 PDF 是否能打开、内容是否可搜索、字体是否回退成方框。
5.1 用 3 组数据快速验证 PDF 输出
以下表格适合放在自动化测试的注释里,也可以直接作为验收清单。
| 输入数据 | 预期结果 | 典型失败表现 |
|---|---|---|
| 空字符串 | 文件能打开,页面为空白 | 生成器崩溃,因为内容流为空 |
Hello PDFGenerator | 文本可选中并可复制 | 文本乱码,通常是坐标越界或流长度错误 |
中文报表导出 | 页面显示正常汉字 | 显示方块,说明没有嵌入 TTF 字体 |
每次生成后可以先用文件头做一轮快速判断。
function IsValidPdf(const AFileName: string): Boolean; var FS: TFileStream; Head: array [0 .. 4] of AnsiChar; begin Result := False; FS := TFileStream.Create(AFileName, fmOpenRead); try if FS.Read(Head, SizeOf(Head)) = 5 then Result := (Head[0] = '%') and (Head[1] = 'P') and (Head[2] = 'D') and (Head[3] = 'F'); finally FS.Free; end; end;这个函数只拦截最基础的文件不完整问题,不能代替阅读器打开验证。真正到业务层面,还要检查 xref 偏移是否落在每个对象头部,以及文件尾部是否以%%EOF结束。
5.2 内存与 Android 13 的落盘策略
生成大批量报表时,不要把所有页面都攒进一个 TMemoryStream 再写盘。常见做法是每写完一批对象就调用 FlushToDisk,并主动释放已经写过的内容流。对 PDF 文件来说,对象顺序不要求连续,所以分段写入完全可行。我一般会在单页文本超过 500 条时,把内容流拆成多个/Contents N 0 R子对象,避免单个流对象过大。
内容流越大,/Length的计算误差就越容易放大。如果需要压缩内容流,要记得在 stream 后加/Filter /FlateDecode,并且用 zlib 的压缩结果作为对象体。不压缩时,/Length还可以人工核对;一旦压缩,长度必须由压缩流自己决定,不能再依赖 Delphi 字符串的 Length。
在 Android 12/13 上,我会把报告写到TPath.GetHomePath + '/files/reports',然后通过 FileProvider 对外只暴露 content:// URI。这样验证脚本只关心 URI 是否可读,不用再处理存储权限。
本文还有配套的精品资源,点击获取