ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Delphi Android PDF生成实战:从Canvas映射到PDFGenerator实现

Delphi Android PDF生成实战:从Canvas映射到PDFGenerator实现 简介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”把整个交付卡住。打印预览不等于 PDFRave 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 系统 IntentACTION_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.TextOutBT /F1 12 Tf 50 50 Td (Hello) Tj ET开始文本块设字体移到坐标画字符串Canvas.Rectanglere f画矩形并填充Canvas.MoveTo/LineTom l S构造路径并描边Canvas.Brush.Color1 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. 对象 1Catalog Offsets[1] : FS.Position; S : UTF8String(1 0 obj#13#10 /Type /Catalog /Pages 2 0 R #13#10endobj#13#10); FS.WriteBuffer(S[1], Length(S)); // 对象 2Pages只有一个页面 Offsets[2] : FS.Position; S : UTF8String(2 0 obj#13#10 /Type /Pages /Kids [3 0 R] /Count 1 #13#10endobj#13#10); FS.WriteBuffer(S[1], Length(S)); // 对象 3PageA4 尺寸 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#10endobj#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#10stream#13#10) Content UTF8String(endstream#13#10endobj#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#10endobj#13#10); FS.WriteBuffer(S[1], Length(S)); // 3. xref 交叉引用表 XRefPos : FS.Position; S : UTF8String(xref#13#100 6#13#100000000000 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#10startxref#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 代码逻辑和参数梳理上面的代码里有几个参数值得单独列出来集成到正式项目时可以按需修改。参数当前值作用调整场景MediaBox0 0 595 842A4 尺寸单位是点改成宽高比即自定义纸张/F1 12 Tf12文本字号字号与页面尺寸成比例缩放72 720 Td72, 720文本起点坐标左下原点改变内容在页面中的位置/Count 11Pages 对象的子页数多页时同步修改0 66该对象流包含的 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 设为 50PageBottom 设为 792行高取字体高度的 1.5 倍。这样每一页的理论行数是(792 - 50) / RowHeight。实际操作时行高不能只用 Font.Size 来算TTF 的 Ascent 和 Descent 通常会占掉额外空间所以要在写完第一页后用真实文本的高度做一次校准。参数取值含义PageTop50页面上边距从页面顶部算PageBottom792页面下边距从页面底部算RowHeight18 或 FontHeight * 1.5每行内容占用的纵向空间PageCount动态累计写入 Pages 的 Kids 数组分页时还要记得把 Pages 对象的/Count更新为实际页数Kids 数组同步追加新页面对象 ID。漏掉任何一处PDF 阅读器都只显示第一页或直接报结构错误。4.3 FileProvider 与 content:// 的坑Delphi Android 应用写完 PDF 后通常要调系统阅读器打开。如果你直接传file:///storage/emulated/0/xxx.pdfAndroid 7 以上会直接抛 FileUriExposedException。正确的做法是配置 FileProvider对外只暴露 content:// URI。AndroidManifest.template.xml 里需要加入 provider 声明。provider android:nameandroidx.core.content.FileProvider android:authorities${applicationId}.fileprovider android:exportedfalse android:grantUriPermissionstrue meta-data android:nameandroid.support.FILE_PROVIDER_PATHS android:resourcexml/file_paths / /provider对应的 res/xml/file_paths.xml 要告诉系统哪些目录可以授权。paths external-files-path namereports pathreports/ / /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 是否可读不用再处理存储权限。本文还有配套的精品资源点击获取
返回列表