简介面向iOS开发者的PDF电子签章库原生渲染与加载体积控制得较小适用于合同签署、贷款协议、单据确认等需要电子签章的移动场景适合有一定Objective-C/iOS原生开发基础的工程师。资源共7个文件压缩包约55.69MB.a静态库提供核心签章能力.h头文件声明接口.mm源文件展示Controller实现还包含相关依赖库可支撑二次开发与问题排查目录组织清晰便于按模块引用。已有643人学习/下载。使用者可获得一个可直接集成的签章控制器通过传入文件路径与文件名即可快速在导航栈中打开PDF签章页面免去底层PDF渲染与签名逻辑的重复开发同时由于实现源码与头文件均完整公开也便于调整签章样式、扩展业务回调或定位集成问题整体集成成本低、结构清晰适合快速嵌入现有App。1. iOS PDF电子签章到底在解决什么问题先给结论iOS PDF电子签章是一套“移动端把签名和印章可靠地落进PDF文件”的完整方案它不只是画一笔、贴一张图而是要把签章的视觉结果、位置信息、内容防篡改和后续验签路径一起处理掉。我在某供应链审批系统和某移动办公项目里都做过类似功能最深的感受是手写签名和红章看似简单真正放进PDF后坐标系、透明通道、页面裁剪框这三个点任何一个出错做出来的文件要么盖错位置要么在别的阅读器里直接变样。这套技术适合两类人去读一类是做iOS端文档类App、需要把合同签署做成线上流程的开发者另一类是接了企业OA或法务系统的客户端负责人需要评估PDF签章在iOS端的实现边界和工期。读完你能回答三个问题用系统能力做和自研绘制的分界线在哪、落章的数据结构该怎么设计、以及如何向别人证明“这个章不是P上去的”。下文我用一段可复现的最小实现加上踩过的坑来展开所有代码基于Swift和系统框架不依赖具体第三方SDK。2. 选型先行PDFKit页面重绘与签名字段两条路线的边界2.1 两条实现路线重绘叠加与PDF签名字段iOS平台上做PDF电子签章常见做法是两条路线。第一条是“页面重绘叠加”用PDFKit拿到页面对象把签名图和印章图以CoreGraphics绘制的方式画到PDF页面上然后输出新PDF。这条路线实现直接、视觉效果可控适合“所见即所得”的签章场景但缺点也很明显——绘制结果本质上是内容层叠加不参与PDF标准里的签名字段结构。第二条路线是“表单签名字段填充”在PDF里找到或创建签名字段Widget Annotation把签名外观和数字签名数据写入字段。这条路更接近Adobe Acrobat等桌面软件的电子签名行为适合要对接权威验签服务的场景但iOS系统框架对签名字段的高级操作支持有限通常需要自己解析PDF注释字典或者借助服务端能力补全。我做移动端时一般按业务性质分流企业内部审批流、意向确认这类轻量签署用路线一因为要的是快和直观涉及外部监管或法律效力的合同用路线二配合服务端数字证书。下面以一个典型的最小实现为主把路线一讲透。2.2 最小可跑通用PDFKit在页面上叠加签名视图先看一段完成“打开PDF并叠加签章视图”的最小代码。它实现了点击某个PDF页面后把一个UIView的快照绘制到该页面上并导出成新PDF文件import PDFKit import UIKit func applySignatureStamp(to sourceURL: URL, pageIndex: Int, stampView: UIView, outputURL: URL) throws { // 1. 加载原PDF文档 guard let document PDFDocument(url: sourceURL) else { throw NSError(domain: PDFSign, code: -1, userInfo: [NSLocalizedDescriptionKey: PDF加载失败]) } guard let page document.page(at: pageIndex) else { throw NSError(domain: PDFSign, code: -2, userInfo: [NSLocalizedDescriptionKey: 页码不存在]) } // 2. 把stampView渲染成一个带透明通道的UIImage let rendererFormat UIGraphicsImageRendererFormat.default() rendererFormat.opaque false rendererFormat.scale UIScreen.main.scale let renderer UIGraphicsImageRenderer(size: stampView.bounds.size, format: rendererFormat) let stampImage renderer.image { context in stampView.layer.render(in: context.cgContext) } // 3. 基于原页面数据创建可变数据获得绘制上下文 let pageData page.dataRepresentation! let mutableData pageData.mutableCopy() as! NSMutableData UIGraphicsBeginPDFContextToData(mutableData, page.bounds(for: .cropBox), nil) UIGraphicsBeginPDFPageWithInfo(page.bounds(for: .cropBox), nil) guard let pdfContext UIGraphicsGetCurrentContext() else { UIGraphicsEndPDFContext() throw NSError(domain: PDFSign, code: -3, userInfo: [NSLocalizedDescriptionKey: 上下文创建失败]) } // 4. 先绘制原页面内容再叠加签章内容 page.draw(with: .mediaBox, to: pdfContext) let drawRect CGRect(x: 100, y: 100, width: stampImage.size.width / 2, height: stampImage.size.height / 2) stampImage.draw(in: drawRect) // 5. 结束上下文并写文件 UIGraphicsEndPDFContext() try mutableData.write(to: outputURL, options: .atomic) }这段代码的逻辑分为五步加载原PDF并定位页面把签名视图变成透明背景图片用原页面创建一个新的PDF绘制上下文绘制原页面内容后再把签名图片叠上去最后写文件。这里最关键的是UIGraphicsBeginPDFPageWithInfo的页面尺寸必须和page.bounds(for: .cropBox)一致否则输出文件的页面大小会漂移这是新手最容易忽视的参数。page.draw(with: .mediaBox, to: pdfContext)指定的截取区域是mediaBox而绘制目标尺寸用的是cropBox这两个值在标准PDF里通常一致但有些扫描件或生成工具生成的文档两者不一致会导致内容被裁切。遇到这种情况我的习惯是统一用page.bounds(for: .mediaBox)作为绘制目标规避裁剪框异常。2.3 关键参数为什么用透明PNG而不是JPG签章素材的编码格式直接影响最终效果。手写签名和红色印章都必须保留透明背景JPG格式不支持alpha通道放到白底PDF上会带出一个白色矩形块肉眼非常明显。所以签名视图渲染出的图片在写入PDF之前要确认图片格式支持透明上面的代码里通过rendererFormat.opaque false来保证绘制上下文不填充不透明背景这是很多初稿翻车的地方。另外还要注意坐标原点问题。PDFKit页面对象默认坐标原点在左下角UIKit视图坐标原点在左上角。直接把UIKit的frame坐标传给PDF绘制上下文印章会垂直翻转。常见做法是绘制时对y轴做一次翻转y_pdf pageHeight - y_ui - stampHeight。如果不翻转印章会跑到页面的镜像位置看起来就是盖反了。3. 把签章内容做出来手写签名捕获与红章位图合成3.1 手写签名触摸事件、笔画平滑与透明度保存签章体验的第一步是捕获用户手写签名。我用一个自定义UIView通过touchesBegan、touchesMoved事件记录笔画再用UIBezierPath连接点集。这里有两个质量相关的细节一是笔画要加平滑处理直接用原始点连线会在快速书写时出现折角二是整个签名视图需要透明背景方便后续合成到PDF上。下面是我常用的签名捕获核心代码包含笔画记录和简单平滑class SignaturePadView: UIView { private var strokes: [Stroke] [] private var currentPoints: [CGPoint] [] struct Stroke { var points: [CGPoint] var color: UIColor var lineWidth: CGFloat } override func touchesBegan(_ touches: SetUITouch, with event: UIEvent?) { guard let point touches.first?.location(in: self) else { return } currentPoints [point] strokes.append(Stroke(points: currentPoints, color: .black, lineWidth: 2.5)) setNeedsDisplay() } override func touchesMoved(_ touches: SetUITouch, with event: UIEvent?) { guard let point touches.first?.location(in: self) else { return } currentPoints.append(point) strokes[strokes.count - 1].points currentPoints setNeedsDisplay() } override func draw(_ rect: CGRect) { guard let context UIGraphicsGetCurrentContext() else { return } context.setShouldAntialias(true) for stroke in strokes { let path UIBezierPath() guard let first stroke.points.first else { continue } path.move(to: first) // 中间点用二次贝塞尔连接避免折角 for i in 1..stroke.points.count { let mid CGPoint(x: (stroke.points[i-1].x stroke.points[i].x) / 2, y: (stroke.points[i-1].y stroke.points[i].y) / 2) path.addQuadCurve(to: mid, controlPoint: stroke.points[i-1]) } path.addLine(to: stroke.points.last!) stroke.color.setStroke() path.lineWidth stroke.lineWidth path.lineCapStyle .round path.lineJoinStyle .round path.stroke() } } }这段代码的思路很直接每次移动把新点加入当前笔画然后整条笔画用二次贝塞尔曲线重绘。参数上lineWidth是笔画宽度2.5在普通屏幕上接近真实钢笔笔迹lineCapStyle和lineJoinStyle设置为round是为了避免快速起笔和转折时出现尖锐的毛刺。有时候我会再加一层点集抽稀逻辑因为touchesMoved在120Hz设备上事件密度很高不抽稀的话点太多会导致贝塞尔曲线计算量大、绘制变慢。常用算法是距离阈值法只有当前点和上一个保留点的距离超过2个点才记录。这个优化在生成图片素材时也能降低后续渲染成本。3.2 红章合成把印章图片按照指定位置压印到PDF页红色印章通常是一张透明背景的PNG素材包含公司名称环绕、中央五角星或椭圆边框。合成时要做三件事缩放、定位、保持alpha。前面第2章的代码里已经展示了用stampImage.draw(in:)做基本绘制但真实场景里印章往往需要旋转。某些合同模板要求骑缝章或角度章这就需要在绘制上下文中先做矩阵变换。// 在UIGraphicsPDFRenderer或UIGraphicsBeginPDFContext中绘制旋转印章 func drawRotatedStamp(in context: CGContext, image: UIImage, center: CGPoint, angle: CGFloat, targetSize: CGSize) { context.saveGState() // 平移到印章中心再旋转再平移回左上角绘制 context.translateBy(x: center.x, y: center.y) context.rotate(by: angle) let origin CGPoint(x: -targetSize.width / 2, y: -targetSize.height / 2) image.draw(in: CGRect(origin: origin, size: targetSize)) context.restoreGState() }这段代码的关键在于旋转顺序。CoreGraphics的坐标系变换是后调用的先生效先平移后旋转这样图像会围绕自己的中心点旋转而不是围绕页面原点旋转。如果先旋转再平移印章会绕着页面原点画圆弧落点完全错误。合成红章时还有一个容易被忽略的视觉效果参数透明度。真实盖在合同上的印章在灯光下会有一定透底效果但数字印章如果alpha值太高会显得浮太低会显得假。我的经验是印章素材本身保持100%不透明但通过叠加一个透明度为0.85到0.9的绘制层来模拟油墨感。不要直接在素材上改alpha因为落章位置会有白色纸纹背景整体调低透明度会让印章边缘发灰。3.3 参数落地印章大小与页面尺寸的匹配关系印章在PDF上的大小需要相对页面尺寸做归一化而不是用固定像素。原因很简单一个60mm宽的章在A4纸上比例合适在A3或16开纸上会显得偏大或偏小。我做了一套换算逻辑印章宽度占页面宽度的比例作为配置项默认值取0.18即印章宽度约为页面宽度的18%。换算公式为stampWidth pageWidth * 0.18高度按素材宽高比等比计算。企业公章一般是圆形所以宽高相等合同专用章或财务章可能是椭圆这时宽度按比例计算后高度要按素材原始宽高比缩放避免变形。旋转角度建议只允许0度、90度、180度、270度四个档位虽然技术上任意角度都支持但用户操作习惯里这四个方向最常用也最容易保证骑缝章的对齐效果。4. 多页文档与位置管理把盖章位置变成可复用的参数4.1 页面bounds换算为什么同一个坐标在不同页面会偏移多页PDF的签章位置管理比单页复杂一个维度。PDFKit的每个PDFPage有独立的坐标系和bounds不同页面即使纸张尺寸相同页面对象内的坐标转换也可能不一致尤其是那些由扫描软件或打印驱动生成的文档。我的处理办法是签章参数只保存逻辑坐标不保存转换后的物理坐标。逻辑坐标定义为“以页面左下角为原点按页面宽度和高度的比例来定位印章中心”。例如xRatio 0.5, yRatio 0.3表示印章中心在页面水平中央、垂直30%位置。这样同一份参数应用到不同尺寸的页面上位置相对比例保持一致。实际落章时再把比例值乘以对应页面的cropBox宽高转换成绘制坐标。struct StampPosition { var pageIndex: Int var centerXRatio: CGFloat var centerYRatio: CGFloat var widthRatioToPage: CGFloat var angle: CGFloat func absoluteRect(for page: PDFPage) - CGRect { let bounds page.bounds(for: .cropBox) let stampWidth bounds.width * widthRatioToPage let centerX bounds.width * centerXRatio let centerY bounds.height * centerYRatio let origin CGPoint(x: centerX - stampWidth / 2, y: centerY - stampWidth / 2) return CGRect(origin: origin, size: CGSize(width: stampWidth, height: stampWidth)) } }这个结构体的好处是预览时和最终落章时用同一套换算逻辑不会出现“预览位置正确、导出后偏了”的问题。absoluteRect(for:)方法接收一个PDFPage对象并返回该页面上对应的绘制区域所有调用方共用这个方法从机制上避免了两套坐标逻辑不一致的bug。我踩过的一个具体坑是某些PDF的cropBox原点不是(0,0)而是带有偏移量比如(12, 24)。直接用bounds.width * ratio计算坐标时没加上原点偏移结果整体往左下角偏移了一截。正确做法是中心点坐标加上bounds.origin.x和bounds.origin.y上面代码里如果遇到非零原点的情况需要补上这一项。4.2 签章参数的数据结构JSON怎么设计才能兼容后续扩展签章参数最好设计成版本化JSON存到服务器或本地方便工作流里多端回放。我会在JSON里放一个version字段避免后续加字段时旧客户端解析失败。{ version: 2, signatures: [ { type: handwrite, pageIndex: 2, centerXRatio: 0.42, centerYRatio: 0.8, widthRatioToPage: 0.25, angle: 0, imageBase64: /9j/4AAQSkZJRg..., timestamp: 1712000000, signerId: user_9527 }, { type: stamp, pageIndex: 3, centerXRatio: 0.5, centerYRatio: 0.5, widthRatioToPage: 0.18, angle: 0, imageBase64: /9j/4AAQSkZJRg..., sealId: seal_company_a } ] }这里涉及一个性能取舍把签名和印章的图片Base64放进JSON会导致参数体积膨胀。一张800x400的透明PNG签名图约200KB到500KB多页几十个签章时JSON可能到几十MB。我后续改成了图片和参数分离存储JSON只保存参照ID图片单独上传到存储服务客户端组装时再拉取。但单机版离线场景用Base64内嵌最省事至少不丢数据。针对图片数据还有一个更稳妥的做法不存Base64的内容本体改存“签名轨迹点”。把SignaturePadView记录的笔画点序列化成JSON展示时用同样的曲线绘制逻辑恢复。这样参数体积极小而且后续可以按轨迹数据做笔迹校验。缺点是实现复杂度更高需要对笔画重放逻辑做单元测试。4.3 预览对齐保证预览视图和最终PDF渲染结果一致预览对齐是移动端签章最容易出现“预览很美、导出翻车”的环节。原因是预览界面通常用UIView直接绘制而导出时用的是CoreGraphics PDF上下文两者的坐标系和渲染管线不同。我的做法是预览时不单独绘制而是直接生成一份临时PDF在预览页显示临时PDF的页面快照。这样做的好处是所见即所得用户看到的预览图就是从PDF上下文中渲染出来的实际结果而不是UIKit模拟效果。每个签章参数调整后重新执行一次PDF生成页面显示最新的PDF渲染图。虽然每次调整都要做一次PDF导出但对现代设备来说单页PDF的绘制耗时只有几十毫秒完全在可接受范围内。如果对性能要求更高可以用PDFPage的缩略图API异步生成预览图但要注意把参数应用和缩略图生成放到后台队列避免主线程卡顿。我的经验是先保证一致性再优化速度。在这个环节上一致性比性能重要得多。5. iOS签章避坑这五种翻车现场与排查手段5.1 印章导出后变白底矩形块现象盖章到PDF后印章周围出现一个白色矩形把底下的合同文字挡住了。原因绘制印章图片时源图片不是透明背景或者UIGraphicsImageRendererFormat的opaque属性被默认设为true导致渲染上下文把透明区域填充成白色。解决检查两点一是素材必须是带alpha通道的PNG二是渲染格式必须设置为opaque false。有时候从网络下载的图片被服务端转码成JPG也会出现这个问题需要在下载时校验图片格式。5.2 签章位置在iPhone和iPad上不一致现象同一份PDF在同一台iPhone上盖章位置正确换到iPad上导出后位置偏差明显。原因代码里用了UIKit的point作为坐标单位而不是PDF的坐标单位。iPad屏幕逻辑分辨率与iPhone不同直接用UIKit视图的坐标传给PDF上下文等于用屏幕坐标去描述纸张坐标必然错位。解决所有坐标在传进PDF上下文之前统一通过page.bounds(for: .cropBox)做一次比例换算绝对不允许把UIView的frame直接当PDF绘制坐标用。5.3 多页文档签章后内存持续上涨最终崩溃现象对一份100页以上的PDF连续签章时App内存从200MB涨到1GB以上然后崩溃。原因每次签章导出都生成一份完整的新PDFData并且原PDFDocument和导出后的PDFDocument同时常驻内存。旧文档没有释放反复操作导致内存叠加。解决每完成一次导出立即把中间生成的PDFDocument置为nil并把PDFData写入临时文件而非内存Array。我用autoreleasepool包裹整个导出过程确保每轮的临时对象及时释放。5.4 自定义签章图片旋转后清晰度下降边缘发虚现象印章旋转45度后边缘出现锯齿和模糊在Retina屏幕上尤其明显。原因绘制图片时没有考虑UIScreen.main.scale以1x的像素分辨率绘制后被拉伸到3x屏幕显示。解决渲染时指定rendererFormat.scale UIScreen.main.scale并在PDF绘制时关闭位图插值的负面影响用context.interpolationQuality .high提升缩放画质。如果印章素材本身分辨率不足最直接的办法是让设计师输出2倍尺寸的素材代码里再做等比缩小。5.5 导出PDF后其他阅读器打不开或提示文件已损坏现象用PDFKit生成的签章PDF在iOS自带预览器能打开但传到Windows或Android端后无法打开。原因UIGraphicsBeginPDFContextToData生成的PDF缺少必要的文档结构信息或者原PDF本身包含加密重绘时丢失了解密数据。解决不要从page.dataRepresentation作为源数据来绘制改用PDFDocument.dataRepresentation()生成完整的可写副本。遇到加密PDF先调用unlock(withPassword:)成功后再进行签章操作否则直接拒绝处理。这五条避坑经验每一条都是从实际项目里修出来的。尤其是第一和第三条基本属于“不遇到不知道自己错了”的典型问题在开发阶段很难通过代码审查发现必须在真机上用真实的多页合同做集成测试。6. 进阶技巧把防篡改校验和内嵌元数据做进签章文件签章做完只是第一步真正要投入使用时还得让拿着文件的人能验证这个章有没有被改动过。iOS端的局限性在于系统PDFKit没有公开接口直接生成符合完整PDF数字签名规范如PAdES的签名所以我采用的方案是“摘要自校验”先对整个签章前的PDF二进制内容计算SHA-256摘要再把摘要和签章参数一起写入PDF的自定义元数据字段或附件验签时重新计算摘要并比对。具体做法是在签章完成后读取最终PDF文件的二进制数据计算其SHA-256哈希值然后把哈希值写入同一个PDF的文档属性字典。验签端读取该属性重新计算文件哈希如果一致说明文件内容没有任何字节被改动过不一致则说明文件被编辑过。这个方案对抗的是无意的修改或截图式篡改对于专业攻击者来说强度有限——哈希值和内容在同一文件里攻击者可以同时篡改两者——但作为企业内部合规校验已经够用。import CryptoKit import PDFKit func attachDigest(to document: PDFDocument) { // 1. 获取PDF文档的原始二进制数据 guard let data document.dataRepresentation() else { return } // 2. 计算SHA-256摘要 let digest SHA256.hash(data: data) let digestHex digest.map { String(format: %02x, $0) }.joined() // 3. 写入文档属性作为自定义元数据 var attributes document.documentAttributes ?? [:] attributes[SignDigest] digestHex attributes[SignTimestamp] String(Int(Date().timeIntervalSince1970)) document.documentAttributes attributes }这段代码需要在签章内容全部绘制完成后调用确保摘要覆盖的是最终文件内容。注意documentAttributes的写入时机每次重新导出PDF后都要重新计算摘要因为文件字节已经变化。有些开发者先计算原文件摘要再签章最后写入的属性摘要和实际文件对不上验签永远失败这是最常犯的逻辑错误。验签端代码更简单读取PDF文档属性里的SignDigest对当前文件重新计算SHA-256比较两个hex字符串。比较时建议使用恒定时间比较避免时间侧信道攻击——虽然移动端场景威胁模型不大但养成习惯没坏处。CryptoKit的Digest类型自带isValid校验方法可以配合使用。还有一个落地习惯想分享签章参数和摘要信息要同时写入业务数据库。纯靠PDF文件自身携带的元数据做防篡改有个绕不开的漏洞就是元数据本身也是文件的一部分可以被人用其他编辑工具改掉。所以我的做法是PDF文件里的摘要用于防无意修改数据库里记录的摘要用于防刻意篡改两者比对一致才认定文件可信。这个双写方案在公司内部法务系统里实际跑了一年多验证成本很低但效果明显算是我最推荐的一个折中做法。如果你做的版本迭代到后期可以考虑引入服务端验签接口客户端上传摘要服务端存证并返回存证编号文件里只保留编号。这样既能支持法律效力更强的场景又避开了移动端数字证书管理的复杂性。从长期可维护性来看架构上预留这个接口会比将来推倒重来省很多工夫。希望帮到你。本文还有配套的精品资源点击获取