简介:面向iOS开发者的PDF电子签章集成方案,采用原生加载方式,占用体积较小,可通过TrustSignPDFDSController控制器快速载入PDF并完成相关签章操作。资源包共7个文件,涵盖静态库.a、头文件.h及Objective-C++实现文件.mm,其中静态库为核心编译产物,头文件用于接口声明,.mm文件包含控制器实现,结构清晰便于二次开发。压缩包整体大小约55.69MB,已有643人学习下载。使用者按照描述中的初始化方法创建控制器并压入导航栈即可使用,省去复杂定制流程,适合需要在App内嵌入PDF电子签章能力的中高级iOS工程师快速落地。
1. iOS 上做 PDF 电子签章:先把"贴图"和"真签名"分开
很多人一听到"PDF 电子签章"就以为是在 PDF 上盖个红章图片,导出保存就完事了。这个认知在 iOS 开发里会踩出一串坑:坐标翻转、页面旋转、文本被盖住、证书签名验签失败,每一件都能让人怀疑人生。真正的 iOS PDF 电子签章至少包含两件事:一是把签名或印章以视觉形式落到 PDF 指定页面,二是用数字证书对 PDF 内容做防篡改签名,让收件方确认这份文件没被改过。这篇文章就按这条主线拆,先讲清楚 iOS 上 PDFKit 与 Core Graphics 的选型和坐标系换算,再给出可复用的 Swift 绘制代码和证书签名流程,最后把我踩过的问题按"现象→原因→解决"整理出来。适合正在做 PDF 编辑器、合同签批、OA 移动端签章功能的开发者和技术负责人,想快速判断原生方案边界的人也能从这得到答案。
2. 选型与坐标系:PDFKit、Quartz 2D 与 PDF 原生坐标的三方博弈
2.1 原生能力盘点:PDFKit 渲染、Quartz 绘制、第三方库的边界
iOS 上处理 PDF 有三条路:PDFKit、Core Graphics 的 PDF 接口、以及第三方库。PDFKit 是 iOS 11 之后的高层框架,负责 PDF 文档的加载、页面渲染和内容提取;Core Graphics 里的CGPDFDocument和CGContext负责最底层的绘制操作;第三方库(比如 PSPDFKit、PDFium)适合需要复杂批注、表单填写和 PAdES 规范数字签名的场景。
做电子签章时我的选型经验是:视觉签章靠 PDFKit 定位页面,用 Core Graphics 绘制签名图像;数字签名走 Security 框架的 CMS 接口对 PDF 字节做摘要签名。PDFKit 本身没有提供创建签名域的原生 API,所以纯原生方案做不了 PDF 规范里的完整数字签名域,但可以做"视觉签名 + 字节级 CMS 签名"的组合,这个组合能覆盖大多数业务场景。
2.2 UIKit 左上原点与 PDF 左下原点:坐标换算不能靠猜
iOS 的 UIKit 坐标系原点在左上角,y 轴向下;PDF 的 CG 坐标系原点在左下角,y 轴向上。这个差异导致同一个 CGRect 在 UIKit 视图上显示正确,但画进 PDF 上下文后就跑到了页面底部甚至页面之外。
我一般这样换算:拿到 PDF 页面后,先读取页面的bounds和rotation,把 UIKit 坐标系的点转换为 PDF 坐标系的点。核心代码是:
import PDFKit /// 将 UIKit 坐标系中的点转换为 PDF 页面坐标系中的点 /// - Parameters: /// - point: UIKit 视图坐标系中的点 /// - pageBounds: PDF 页面的 bounds /// - pageRotation: 页面旋转角度 func convertToPDFCoordinate(point: CGPoint, pageBounds: CGRect, pageRotation: Int) -> CGPoint { var converted = point // 原点从左上角翻转到左下角 converted.y = pageBounds.height - point.y // 当 PDF 页面有旋转角度时,需要绕中心点旋转 if pageRotation != 0 { let center = CGPoint(x: pageBounds.midX, y: pageBounds.midY) let radians = Double(pageRotation) * .pi / 180.0 let dx = converted.x - center.x let dy = converted.y - center.y converted.x = center.x + dx * CGFloat(cos(radians)) - dy * CGFloat(sin(radians)) converted.y = center.y + dx * CGFloat(sin(radians)) + dy * CGFloat(cos(radians)) } return converted }代码的思路分两步:第一步把 UIKit 的 y 轴翻转成 PDF 的 y 轴方向,第二步处理页面旋转。pageRotation来自 PDF 元数据里的/Rotate字段,扫描件和从某些打印机生成的 PDF 经常带旋转角,不处理的话签章位置会偏移。注意这里的旋转是绕 PDF 页面中心点转的,不是绕原点转,这里容易写错。
2.3 页面尺寸与缩放:从 point 到像素不要想当然
PDF 页面的单位是 point,1 point 对应 1/72 英寸。而 iOS 屏幕的显示单位是像素,iPhone 的 scale factor 有 2x 和 3x。绘制签名图时,如果直接把屏幕上的签名点数组传给 PDF 上下文,签出来会比实际偏大或偏小。
我的处理方式是:在采集手写签名时记录归一化坐标,也就是把坐标换算成相对签名区域宽高的比例值,绘制时再按 PDF 页面的目标宽度换算回 point。
/// 归一化坐标转 PDF point func normalizedPointToPDFPoint(normalized: CGPoint, pageBounds: CGRect, signRect: CGRect) -> CGPoint { let x = signRect.origin.x + normalized.x * signRect.width let y = signRect.origin.y + normalized.y * signRect.height return CGPoint(x: x, y: y) }normalized的 x、y 取值范围是 0.0 到 1.0,表示在签名区域内横向和纵向的比例位置。这样做的好处是签名既不受屏幕分辨率影响,也不受 PDF 页面尺寸影响,同一份签名数据可以适配 A4、A5 和自定义尺寸的页面。还有一个细节:签名区域的origin需要用上一节的坐标转换,把 UIKit 坐标转成 PDF 坐标,否则归一化换算再准也是白搭。
3. 签章绘制落地:把签名画到 PDF 指定页并正确导出的完整流程
3.1 从 PDF 页面创建可绘制上下文:CGContext 的常用创建方式
绘制签章的第一步是拿到一个可以绘制的 PDF 上下文。Apple 提供的方案是CGContext,有两种常见创建方式:一种是基于位图的CGBitmapContextCreate,适合先画在内存里再处理;另一种是基于 PDF 页面的CGPDFContextCreate,可以直接把绘制结果写进 PDF。后者更省内存,但要求严格按照 PDF 页面的数据格式初始化。
我一般用CGPDFContextCreate配合CGPDFPage来生成新 PDF:
import CoreGraphics import PDFKit func drawSignOnPDFPage( sourcePage: CGPDFPage, signImage: UIImage, at rect: CGRect, outputURL: URL ) { // 1. 创建 PDF 上下文,输出到指定文件 guard let context = CGContext(outputURL as CFURL, mediaBox: sourcePage.getMediaBox(), nil) else { return } // 2. 开始绘制 PDF 页面内容 context.beginPDFPage(nil) context.drawPDFPage(sourcePage) // 3. 在页面上叠加签名图像 context.saveGState() // 翻转坐标系,因为 UIKit 图像绘制到 PDF 时需要 y 轴翻转 context.translateBy(x: 0, y: rect.origin.y + rect.height) context.scaleBy(x: 1, y: -1) if let cgImage = signImage.cgImage { context.draw(cgImage, in: CGRect(x: rect.origin.x, y: 0, width: rect.width, height: rect.height)) } context.restoreGState() // 4. 结束当前页 context.endPDFPage() context.closePDF() }逻辑说明:第一步的mediaBox取自原页面,保证输出 PDF 的页面尺寸和原文件一致;第二步把原页面内容完整绘制到上下文,这是保留原 PDF 文本和矢量内容的基础;第三步的translateBy加scaleBy是一个经典的坐标系翻转操作,rect.origin.y + rect.height是签名区域顶部在 PDF 坐标系的 y 值,翻转后 UIKit 风格的绘图才能落对位置。
参数说明:rect是指定签名区域在 PDF 坐标系中的位置和大小,不能直接拿 UIKit 的 rect 用;signImage是裁剪好的签名图片,建议用 PNG 格式以保留透明通道,透明底的红章、黑签名都不会出现白底块。
3.2 手写签名采集:touches 采样加贝塞尔平滑
签名采集是电子签章体验的关键环节,直接用touchesMoved里的原始点连线会有明显折线。我一般把采样点存成数组,然后用二次贝塞尔曲线做平滑。核心代码如下:
class SignCanvas: UIView { /// 存储笔画路径 var points: [CGPoint] = [] /// 平滑后的路径 var smoothedPath: UIBezierPath? override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first else { return } points.append(touch.location(in: self)) setNeedsDisplay() } override func draw(_ rect: CGRect) { guard points.count > 2 else { return } let path = UIBezierPath() path.move(to: points[0]) // 用贝塞尔曲线连接中间点,避免折线 for i in 1..<points.count - 1 { let mid = CGPoint(x: (points[i].x + points[i + 1].x) / 2, y: (points[i].y + points[i + 1].y) / 2) path.addQuadCurve(to: mid, controlPoint: points[i]) } path.lineWidth = 2.0 path.lineCapStyle = .round path.lineJoinStyle = .round path.stroke() smoothedPath = path } }逻辑说明:每两个采样点之间取中点,以原采样点为控制点画二次贝塞尔曲线,这样折线就被平滑成连续弧线。lineCapStyle和lineJoinStyle设为.round是为了让笔迹首尾不出现毛刺,这个细节对手写签名观感影响很大。
参数说明:lineWidth我一般给 2.0 到 3.0,太细在 PDF 放大后不明显,太粗会糊成一团。把points转成归一化坐标时记得除以 view 的宽高,这样签名才能在不同大小的 PDF 页面里按比例落位。
3.3 多页签章批量循环:逐页绘制的正确姿势
合同签章经常要一签签好几页,循环绘制时最容易犯的错误是复用同一个CGPDFPage重复创建上下文。正确的做法是每个页面创建独立的上下文,并且每页都设置独立的输出位置。多页签章的循环结构如下:
func batchSignPDF( document: PDFDocument, signImage: UIImage, pageIndexes: [Int], outputURL: URL ) { for pageIndex in pageIndexes { guard let page = document.page(at: pageIndex) else { continue } guard let pdfPage = page.pageRef else { continue } // 每个页面创建独立输出文件 let pageOutput = outputURL.appendingPathComponent("page_\(pageIndex).pdf") let mediaBox = pdfPage.getMediaBox() guard let context = CGContext(pageOutput as CFURL, mediaBox: mediaBox, nil) else { continue } context.beginPDFPage(nil) context.drawPDFPage(pdfPage) // 计算签名区域,例如固定放在页面底部中间 let signRect = CGRect(x: mediaBox.width * 0.3, y: mediaBox.height * 0.15, width: mediaBox.width * 0.4, height: mediaBox.height * 0.08) drawSignImage(context: context, image: signImage, rect: signRect) context.endPDFPage() context.closePDF() } }逻辑说明:每次循环都重新getMediaBox(),因为每一页的尺寸可能不同,特别是混合了横竖页面的文档。签名区域按页面宽高的比例计算,确保 A4 和 A5 混合文档里签名位置视觉一致。
参数说明:pageIndexes是要签章的页码数组,来源于用户在 UI 里勾选的页面;mediaBox.width * 0.4是签名宽度占页面宽度的比例,实际项目中我会把这个做成可配置项,不同客户对签名占位比例有不同要求。
4. 数字证书签名:用 Security 框架给 PDF 做防篡改签名
4.1 PKCS#12 证书导入:SecPKCS12Import 的参数与内存管理
视觉签章只能证明"这里有个人签了名",但证明不了文件内容没被改过,所以需要数字证书签名。iOS 上导入证书最常见的方式是 PKCS#12(也就是 .p12 文件),导入接口是SecPKCS12Import。这里有个关键坑:SecPKCS12Import的参数是字典,密码和Options要传对,否则返回errSecAuthFailed。
import Security func importP12Certificate(p12Data: Data, password: String) -> (identity: SecIdentity?, error: Error?) { // 1. 构造导入参数 let options: [String: Any] = [ kSecImportExportPassphrase as String: password ] // 2. 执行导入 var items: CFArray? let status = SecPKCS12Import(p12Data as CFData, options as CFDictionary, &items) guard status == errSecSuccess, let items = items, let firstItem = (items as? [[CFString: Any]])?.first, let identity = firstItem[kSecImportItemIdentity as CFString] as? SecIdentity else { return (nil, NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil)) } return (identity, nil) }逻辑说明:kSecImportExportPassphrase是 p12 文件的解密密码,这一步证书私钥会被导入到应用的 Keychain 访问控制中,后续签名可以直接拿SecIdentity用它。SecPKCS12Import返回的 items 是 CFArray,里面每个元素是一个字典,kSecImportItemIdentity对应的就是 SecIdentity。
参数说明:password 是 p12 文件的保护密码,不是证书里面的公钥密码。如果 p12 是开发环境临时生成的,密码为空字符串时需要传空字符串而不是 nil。内存管理方面,items是 CFArray,ARC 在 Swift 里会自动处理 CF 对象的释放,不需要手动CFRelease。
4.2 对 PDF 字节做 CMS 签名:创建、签名、导出的完整链路
拿到SecIdentity后,下一步是对 PDF 文件字节做 CMS 签名。iOS 的 Security 框架提供了SecCMSContentInfoCreateFromData、SecCMSContentInfoAddSigner、SecCMSContentInfoGetContent这几个接口来实现基本的 CMS 签名。核心代码如下:
func signPDFDataWithCMS(data: Data, identity: SecIdentity) -> Data? { // 1. 创建 CMS 内容对象 var contentInfo: SecCMSContentInfo? let createStatus = SecCMSContentInfoCreateFromData( data as CFData, kSecCMSContentTypeSignedData, &contentInfo ) guard createStatus == errSecSuccess, let contentInfo = contentInfo else { return nil } // 2. 添加签名者 let signerStatus = SecCMSContentInfoAddSigner( contentInfo, identity, kSecCMSDigestAlgorithmSHA256, nil ) guard signerStatus == errSecSuccess else { return nil } // 3. 导出签名后的数据 var signedData: CFData? let getStatus = SecCMSContentInfoGetContent(contentInfo, &signedData) guard getStatus == errSecSuccess, let signedData = signedData else { return nil } return signedData as Data }逻辑说明:SecCMSContentInfoCreateFromData把原始 PDF 字节包装成 CMS SignedData 结构,第二个参数指定内容类型为kSecCMSContentTypeSignedData。SecCMSContentInfoAddSigner将 current identity 作为签名者加入,摘要算法选择 SHA256 而不是 SHA1,SHA1 在大多数安全合规场景下已经不被接受了。最后SecCMSContentInfoGetContent把签名后的内容导出为CFData。
参数说明:kSecCMSDigestAlgorithmSHA256是摘要算法枚举值,这决定了签名内容有多长的摘要指纹。签名后的 data 是 CMS 的 DER 编码数据,不是原 PDF,所以实际项目中要把它存成.p7s文件或者嵌入到 PDF 的 metadata / 特定字段中,并随 PDF 一起分发。验证方可以提取这份 CMS 数据做SecCMSContentInfoGetContent,比对解出的内容与 PDF 原始字节是否一致。
4.3 视觉签章与密码学签章的分工:什么时候两者都要
明确了 CMS 签名的能力边界后,你会发现它和视觉签章是互补关系:视觉签章解决"谁签的、签在哪"的可见性问题,CMS 签名解决"内容有没有被篡改"的证明问题。实际项目里两者都要做的场景是合同审批和电子签章类 App——先绘制签名图片到 PDF 页面,再把整份 PDF 的字节做 CMS 签名,最后把签名文件作为附件或单独字段传给服务端。
如果业务只要求"看得见戳"而不涉及法律效力验证,视觉签章就够了;反之如果服务端已经有验签链路,只做 CMS 签名、不做页面绘制也能满足要求。我倾向在生产项目里把这两条链分开封装成两个 service 类,一个负责SignRenderer,一个负责CMSigner,这样后续接第三方合规签名服务时只需要替换CMSigner内部实现,不影响绘制层。
5. 避坑排查:坐标系、文本层级、内存与签章时效的五个实战教训
5.1 印章图片旋转 90 度:翻转不是旋转,两者混用必翻车
现象:从相册选一张红色公章图,画到 PDF 后印章旋转了 90 度,位置还跑到页面右侧。
原因:UIKit 图像坐标系和 PDF 坐标系在scaleBy(y: -1)之后,图像绘制方向发生了变化。具体原因是绘制代码里把translateBy和scaleBy的顺序搞反了。CGContext的变换是矩阵乘法,先平移再缩放和先缩放再平移的结果完全不同。
解决:先translateBy(x: 0, y: rect.origin.y + rect.height),再scaleBy(x: 1, y: -1),顺序不能换。我把这个顺序注释直接写在代码里,后来再没在这个坑里反复横跳。
5.2 签章后文字变灰或白底遮挡:绘制模式用错了
现象:签章落在文字区域,结果盖章位置的文字看不清,像是被一块白底遮住了。
原因:签名 PNG 图片本身带白色背景,或者绘制时使用了错误的混合模式,把不透明像素直接覆盖在了 PDF 文字上。PDF 页面内容在底层,签名图在上层,不透明白底自然盖住了文字。
解决:签名图用透明背景 PNG,绘制前去掉白底;或者改用context.setBlendMode(.multiply),让签名像素与底层文字按乘法混合,红章盖在文字上依然是透明的。实际项目中我两种都保留,默认用透明 PNG,遇到客户提供的白底 JPG 时再走 multiply 降级方案。
5.3 大 PDF 内存暴涨:逐页绘制时没有释放 page 引用
现象:签一个 200 页以上的大 PDF,内存飙到 1GB,App 在 iPad 上直接闪退。
原因:循环里每次document.page(at:)取出的 PDFPage 对象被强持有了,CGPDFPage的 retain 计数一直没释放。PDFKit 的PDFDocument会缓存页面内容,页数多时内存占用急剧上升。
解决:按页处理完就置空引用,并且把大文档分成 10 页一组的批次处理,每批次结束调用autoreleasepool强制清空临时对象。核心技巧是不要全程持有PDFDocument,而是每 10 页重新加载一次文件流,用空间换时间。
5.4 带旋转角度的页面签章偏移:只处理 bounds 没处理 rotation
现象:从某扫描仪生成的 PDF,签章位置整体向右偏移了约 90 度对应的距离。
原因:那些 PDF 页面的/Rotate字段为 90,意味着页面渲染时要先旋转 90 度再显示。签章代码只用了mediaBox计算位置,没有读page.rotation,也没有做旋转补偿,导致签章位置按未旋转的坐标系计算,落点自然偏移。
解决:绘制前读取page.rotation,按照旋转角度对签名区域做反变换。具体方法是把签名 rect 的中心点绕页面中心反向旋转对应角度,再把 rect 的宽高交换。这部分代码建议封装成独立函数,因为几乎每个真实扫描件库都有可能混入带旋转的页面。
5.5 CMS 签名内容对不上:签名后再保存 PDF 导致验签失败
现象:CMS 签名验签时,解出的数据和收到的 PDF 字节不一致,验签方报"签名无效"。
原因:CMS 签名是对"签名时刻的 PDF 文件字节"做摘要,而我把签名的字节取了之后,又对 PDF 做了二次保存(比如调整 metadata、重新压缩),文件字节变了,摘要自然对不上。
解决:签名动作放在 PDF 所有修改完成之后执行,取字节时用Data(contentsOf: url)一次性读入,签名计算完成后立即将 PDF 文件冻结,任何后续操作都不要再写这个文件。我一般会给签名后的 PDF 加一个文件锁标记,业务层强制约束"签名后只读"。这条对我来说是血泪经验,上线后翻车过两次才老实。
6. 验证与进阶:用 Preview、PDF.js 和 OpenSSL 三连确认签章没问题
签章做完不能只看"PDF 能打开"就完事,我习惯用三套工具验证:预览阶段用 macOS 的 Preview 看视觉层,Web 端用 PDF.js 验证跨平台兼容性,服务端用 OpenSSL 验 CMS 签名数据结构。三管齐下基本能把坐标系、编码和签名三类问题都暴露出来。
Preview 验证比较简单:打开生成的 PDF,检查签名位置是否在预期区域、印章有没有白底、文字是否可选中,把每一页翻一遍。PDF.js 层面我会写一个几十行的测试页面:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>签章 PDF 验证</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"></script> </head> <body> <canvas id="viewer"></canvas> <script> const loadingTask = pdfjsLib.getDocument('signed.pdf'); loadingTask.promise.then(pdf => { return pdf.getPage(1); }).then(page => { const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.getElementById('viewer'); canvas.width = viewport.width; canvas.height = viewport.height; return page.render({ canvasContext: canvas.getContext('2d'), viewport }); }); </script> </body> </html>这段代码先把signed.pdf交给 PDF.js 解析,再渲染第一页到 canvas。如果 PDF.js 渲染出来的签章位置、大小和 Preview 一致,说明这份 PDF 的交叉兼容性没问题;如果位置偏移,问题几乎都出在我前面说的 rotation 没补偿。
OpenSSL 验签 CMS 数据用命令行一行就能搞定:
openssl smime -verify -in sign.p7s -inform DER -content original.pdf -noverify输出Verification successful说明 CMS 内容没有被篡改过。这里-noverify的意思是跳过证书链验证,只验证内容完整性,因为很多测试证书本身就是自签名的。生产环境建议去掉-noverify,把 CA 证书链一起传上去验完整信任链。
这套流程走顺之后,我养成了一个习惯:每次签章功能改完代码,先拿一份 50 页以上、含旋转页面的真实扫描 PDF 测试,Preview、PDF.js、OpenSSL 三步全过才算联调完成。这个习惯帮我挡掉了至少三次坐标系回归事故。现在我把这套验证脚本集成到了工程的 CI 流程里,签章产物不通过验证就进不了测试包,希望这份经验能帮你的项目少走几步弯路。
本文还有配套的精品资源,点击获取