简介:面向 iOS 开发者的轻量级 PDF 电子签章集成方案,聚焦在移动端原生加载 PDF 并完成签章交互这一常见但实现门槛较高的需求,适合需要为合同、协议等文档补充签署能力的中高级开发者参考使用。压缩包共 7 个文件,以 4 个 .a 静态库、2 个 .h 头文件与 1 个 .mm 实现文件为主,静态库承载签章核心逻辑,头文件对外暴露调用接口,实现文件则衔接 PDF 渲染与签章流程,整体约 55.69MB,体积控制较为克制。资源已有 644 人学习下载,具备一定实践参考价值。通过封装好的控制器,开发者只需传入文件路径与文件名即可推入页面完成加载,省去从零搭建 PDF 解析与签章链路的成本,便于快速验证签章效果并嵌入既有业务模块,对理解原生 PDF 签章库的接口组织方式亦有帮助。
1. 在 iOS 上给 PDF 盖章:一份能直接跑通的电子签章工程包
上周有个做合同管理的朋友找我,说他们 App 里客户签完字,生成的 PDF 拿到对方系统一验就报“签名无效”,来回折腾两周没定位到问题。我把他发来的工程拆开一看,问题不在签名算法,而在 PDF 增量更新时把原始字节流改坏了——这类坑在 iOS 端做 PDF 电子签章特别常见,因为大多数人只盯着“画个章上去”,忽略了 PDF 的字节级结构。这份工程包解决的正是这件事:在 iOS 原生环境里完成 PDF 的签章绘制、数字签名嵌入和验签闭环,覆盖从 UIKit 绘制到 PDF 对象写入的完整链路。适合已经能写 Swift、但对 PDF 底层结构不熟、又需要把签章做成可交付功能的从业者。下面按“它怎么组织、怎么跑、坑在哪”的顺序拆。
2. 工程结构与签章链路:从 PDF 页面坐标到签名字节流
2.1 工程分层与关键文件职责
拿到包先别急着跑,花十分钟把目录结构过一遍,后面排错能省一半时间。这份工程按职责切成四层,每层只干一件事,改起来不容易互相污染。
| 层级 | 目录/文件 | 职责 | 改动频率 |
|---|---|---|---|
| 界面层 | SignViewController.swift | 手势拖拽签章、缩放、确认落章 | 高 |
| 绘制层 | SealRenderer.swift | 把签章图片/文字渲染成 PDF 页面上的绘制指令 | 中 |
| PDF 操作层 | PDFDocumentEditor.swift | 打开 PDF、定位页面、追加内容流、增量保存 | 低 |
| 签名层 | PDFSigner.swift | 计算摘要、构造签名字典、写入签名字节 | 低 |
绘制层和 PDF 操作层是分开的,这点很关键。很多人把“画章”和“改 PDF”揉在一个类里,结果签章位置一调,PDF 结构也跟着乱。这里SealRenderer只负责产出一段 PDF 内容流操作符(比如q ... cm ... Do Q这种),PDFDocumentEditor负责把这段操作符追加到目标页的内容流末尾,职责边界清晰。
签名层单独抽出来,是因为数字签名和视觉签章是两回事:视觉签章是给人看的,数字签名是给机器验的。工程里两者可以独立开关,只画章不签名也能跑,方便先验证绘制逻辑。
2.2 签章绘制的坐标换算
iOS 上做 PDF 签章,第一个翻车点几乎都是坐标系。UIKit 的原点在左上、y 轴向下,PDF 的原点在左下、y 轴向上,两者差一个页面高度。工程里统一在SealRenderer里做换算,不让调用方操心。
// SealRenderer.swift // 把 UIKit 坐标(左上原点,y 向下)转成 PDF 坐标(左下原点,y 向上) func pdfRect(fromUIKitRect rect: CGRect, pageHeight: CGFloat) -> CGRect { // PDF 的 y = 页面高度 - UIKit 的 y - 元素高度 let pdfY = pageHeight - rect.origin.y - rect.height return CGRect(x: rect.origin.x, y: pdfY, width: rect.width, height: rect.height) } // 生成绘制指令:把签章图片作为 XObject 贴到指定矩形 func makeDrawOperators(sealName: String, rect: CGRect) -> String { // q/Q 保存和恢复图形状态,cm 做坐标变换,Do 绘制 XObject return """ q \(rect.width) 0 0 \(rect.height) \(rect.origin.x) \(rect.origin.y) cm /\(sealName) Do Q """ }pdfRect里那个减法是最容易写错的地方:有人只减rect.origin.y,忘了再减高度,章就整体上移一个章的高度,肉眼看着“差不多”,但和合同上的签字栏永远对不齐。makeDrawOperators里的cm是 6 个参数的变换矩阵,前四个控制缩放和旋转,后两个是平移量,这里只做平移加缩放,旋转留空。q/Q必须成对出现,漏掉Q会导致后续所有绘制指令继承错误状态,表现为“章画出来了但页面其他内容错位”。
2.3 增量更新与内容流追加
PDF 电子签章不能重写整个文件,必须走增量更新(incremental update):在文件末尾追加新的对象和交叉引用表,原始字节一个不动。这是验签能通过的前提,因为数字签名算的是原始字节的摘要,你哪怕只改一个空格,摘要就变了。
// PDFDocumentEditor.swift // 追加内容流到指定页,走增量保存 func appendContentStream(_ operators: String, toPage pageIndex: Int, in data: Data) throws -> Data { guard var pdfString = String(data: data, encoding: .isoLatin1) else { throw PDFEditError.encodingFailed } // 找到目标页对象的内容流引用,追加新流对象 let newStreamObject = """ \(nextObjectNumber) 0 obj << /Length \(operators.utf8.count) >> stream \(operators) endstream endobj """ // 在 %%EOF 之前插入新对象,并更新 xref pdfString = pdfString.replacingOccurrences(of: "%%EOF", with: newStreamObject + "\n%%EOF") guard let result = pdfString.data(using: .isoLatin1) else { throw PDFEditError.encodingFailed } return result }这段代码是简化示意,真实工程里还要处理交叉引用表的偏移量重算——每追加一个对象,xref表里对应条目记录的字节偏移都要更新,否则阅读器找不到新对象。工程包里PDFXrefBuilder专门干这件事,它维护一个“对象号 → 字节偏移”的映射,每次追加后重算。参数上注意/Length必须和stream与endstream之间的实际字节数严格一致,差一个字节,部分阅读器会直接拒绝打开。编码统一用isoLatin1,因为 PDF 的语法层是字节导向的,用 UTF-8 处理中文内容流会在某些阅读器里乱码。
3. 数字签名嵌入:摘要计算与签名字典构造
3.1 为什么先算摘要再签
数字签名的本质是“对一段字节的摘要做非对称加密”,验签方用公钥解密签名、比对摘要。所以顺序必须是:先把除签名字典以外的所有字节算摘要,再把摘要送进签名算法,最后把签名值写进 PDF 的签名占位区。顺序反了,或者算摘要时把签名占位区也算进去,验签必挂。
工程里PDFSigner的做法是:预留一个固定长度的签名字典占位(/Contents字段留空但占位),算摘要时跳过这段占位区,签完再把真实签名值填进去。这个“先占位后填充”的模式是 PDF 签名的标准做法,占位长度要按签名算法和证书链长度预估,留小了签名值放不下,留大了浪费空间但无害。
// PDFSigner.swift // 计算待签名数据的摘要(跳过 /Contents 占位区) func computeDigest(pdfData: Data, contentsRange: Range<Int>) -> Data { var signableData = Data() signableData.append(pdfData[0..<contentsRange.lowerBound]) signableData.append(pdfData[contentsRange.upperBound..<pdfData.count]) // SHA-256 摘要,和签名算法配套 return SHA256.hash(data: signableData) }contentsRange是占位区在文件里的字节区间,由PDFDocumentEditor在构造签名字典时记录。这里用 SHA-256,对应签名算法选 RSA-2048 或 ECDSA P-256,两者在 iOS 上都有系统支持(Security.framework的SecKeyCreateSignature)。选 ECDSA 的话签名值更短,占位区可以留小一点,但要注意 ECDSA 签名是变长的,占位区必须按最大可能长度留,否则填充时越界。
3.2 签名外观与签名字典的字段
PDF 的签名字典(/Sig对象)里几个字段必须写对,写错一个验签工具就报“签名格式非法”:
| 字段 | 含义 | 常见错误 |
|---|---|---|
/Type /Sig | 对象类型 | 漏写导致不被识别为签名 |
/Filter /Adobe.PPKLite | 签名处理器 | 写成自定义值,验签方不认 |
/SubFilter /adbe.pkcs7.detached | 签名格式 | 和实际签名数据结构不匹配 |
/Contents | 签名值占位 | 长度不足或未按十六进制编码 |
/ByteRange | 摘要覆盖的字节区间 | 区间算错,最常见 |
/M | 签名时间 | 格式不是 PDF 日期格式 |
/ByteRange是重灾区。它是个四元组[a b c d],表示“从偏移 a 开始取 b 字节,跳过一段,再从偏移 c 开始取 d 字节”,跳过的正好是/Contents占位区。很多人手算这个区间,差一个字节就验不过。工程里用代码自动算:
// 根据 /Contents 占位区自动生成 ByteRange func makeByteRange(totalLength: Int, contentsStart: Int, contentsLength: Int) -> [Int] { let firstPartLength = contentsStart let secondPartStart = contentsStart + contentsLength let secondPartLength = totalLength - secondPartStart return [0, firstPartLength, secondPartStart, secondPartLength] }四个数的含义:[0, 占位区起始偏移, 占位区结束偏移, 剩余字节数]。注意第二个数是从 0 开始算的长度,不是结束偏移,这里混淆是高频错误。生成后建议用qpdf --check或任意 PDF 验签工具跑一遍,确认区间覆盖了除占位区外的全部字节。
3.3 用系统框架完成签名与验签
iOS 上做非对称签名不需要引第三方库,Security.framework够用。工程里封装了一层,把证书、私钥、摘要串起来:
// 用 SecKey 对摘要做签名 func signDigest(_ digest: Data, privateKey: SecKey, algorithm: SecKeyAlgorithm) throws -> Data { var error: Unmanaged<CFError>? guard let signature = SecKeyCreateSignature( privateKey, algorithm, // 如 .rsaSignatureMessagePKCS1v15SHA256 digest as CFData, &error ) else { throw PDFSignError.signFailed(error?.takeRetainedValue()) } return signature as Data }algorithm必须和摘要算法配套:摘要用 SHA-256,签名算法就得选带SHA256的那个,选错验签方算出来的摘要对不上。私钥建议放在 Keychain 里,用kSecAttrAccessibleWhenUnlockedThisDeviceOnly保护,别硬编码在代码里——这是血泪经验,硬编码私钥的工程一旦被反编译,等于把签名能力送人。验签侧用SecKeyVerifySignature,传入公钥、原始摘要和签名值,返回布尔值。
4. 避坑与排查:五条踩过的真实记录
4.1 现象:签完的 PDF 在部分阅读器打不开
原因:增量更新时交叉引用表偏移量没重算,或者/Length和实际流长度不一致。PDF 阅读器对结构容错程度不同,宽松的能打开,严格的直接报错。
解决:每次追加对象后强制重算xref表,用qpdf --check做结构校验,把校验放进构建流程,别等交付才发现。
4.2 现象:验签工具报“摘要不匹配”,但签名值本身能解出来
原因:/ByteRange区间算错,最常见的是把占位区的边界算偏了一两个字节,或者算摘要时把/ByteRange字段本身也纳入了覆盖范围(它应该被覆盖,但占位区不该)。
解决:打印出ByteRange四元组,用十六进制编辑器对照文件实际字节核对;摘要计算函数里加断言,确认跳过的区间和/Contents占位区完全重合。
4.3 现象:签章位置在 iPhone 上对,在 iPad 上偏
原因:坐标换算用了固定页面高度,没按实际 PDF 页面尺寸算。PDF 页面尺寸是每页独立的(/MediaBox),不同设备上预览缩放比例不同,但 PDF 坐标是绝对的。
解决:换算时从页面对象的/MediaBox读实际宽高,别用屏幕尺寸或固定常量。工程里PDFDocumentEditor提供pageSize(at:)方法统一取。
4.4 现象:中文签章文字渲染成方块或乱码
原因:内容流里直接写 UTF-8 中文,但 PDF 基础字体不支持中文,且内容流编码和字体编码不匹配。
解决:中文签章不要走文字绘制,走图片 XObject——把中文渲染成图片再贴上去,绕开字体嵌入问题。如果必须用文字,得嵌入支持中文的字体子集,工作量大,工程包里默认走图片方案。
4.5 现象:签名值写入后文件体积暴涨
原因:/Contents占位区留得过大,或者签名值按十六进制字符串写入时没压缩。
解决:占位区按“证书链最大长度 + 签名算法最大输出 + 余量”精确预估,别拍脑袋留几 KB;/Contents里的签名值用十六进制编码是规范要求,但占位区本身可以按实际需要留,RSA-2048 加证书链一般 4KB 以内够用。
5. 进阶:把签章做成可验证的闭环
工程跑通只是第一步,真正交付前我习惯做三件事,这里说透。
第一件是自建验签脚本。别只依赖第三方工具,自己写一段代码把“读 PDF → 提取/ByteRange→ 重算摘要 → 用公钥验签”走一遍,能暴露很多工具掩盖的问题。核心逻辑和签名侧对称,摘要算法、区间提取、公钥加载三步,跑通一次,后面所有签章文件都能批量验。
第二件是签章幂等性检查。同一份 PDF 重复签章,增量更新会不断追加对象,文件越来越大,而且多签场景下/ByteRange要覆盖前一次签名,区间计算复杂度上升。工程里加了“已签章检测”:读现有签名字典,存在就提示走多签流程,别盲目再签一次。
第三件是跨端一致性抽检。iOS 签完的文件,拿到桌面端阅读器、在线验签服务、对方业务系统各验一遍。我遇到过 iOS 端自验通过、桌面端报“签名格式非法”的情况,根因是/SubFilter写成了非标准值,iOS 的解析器宽松放过了。抽检清单固定三项:结构校验、摘要校验、外观渲染,三项都过才算交付。
| 检查项 | 工具/方法 | 通过标准 |
|---|---|---|
| 结构完整性 | qpdf --check | 无语法错误、无损坏对象 |
| 摘要一致性 | 自建验签脚本 | 重算摘要与签名值匹配 |
| 外观渲染 | 多端阅读器打开 | 签章位置、清晰度一致 |
| 多签兼容 | 连续签两次 | 第二次签名覆盖第一次,验签均通过 |
从那以后我每次交付签章功能,都强制走一遍“自建验签 + 跨端抽检”,不再相信单一环境的“能打开就行”。这套流程帮我拦下过至少三次会在客户现场爆发的结构问题。希望帮到你。
本文还有配套的精品资源,点击获取