简介:这是一套面向iOS开发者的原生PDF电子签章轻量库,适用于需要在移动端快速展示PDF并完成电子签名、盖章的金融、法务、政务及合同管理类App。资源以zip压缩包提供,共7个文件,包含4个.a静态库、2个.h头文件和1个.mm实现文件,整体仅55.69MB,体量小巧,便于导入工程。核心控制器TrustSignPDFDSController提供简洁的初始化入口,传入文件路径与文件名后即可push展示,开发者无需关心底层渲染与签章算法;配套的头文件和静态库让调用非常直接,适合有一定iOS开发经验、希望快速集成PDF签署能力的中级工程师。目前已有643人学习下载,说明集成方案具备一定参考价值。包内目录划分明确,包含核心实现、公共依赖库及相关头文件,可在离线环境直接编译接入,省去自行封装底层复杂的PDF交互逻辑的时间。
1. iOS PDF电子签章:难点从来不是“画个章”,而是坐标、色彩与防篡改
在合同审批、工程交付、医疗单据这类场景里,iOS PDF电子签章指的是这样一条链路:App 拿到后端下发的 PDF 文件,用户在指定位置手写签名、加盖公章/骑缝章,再回传一份不可抵赖的 PDF。真正做过的人会告诉你,卡住你的往往不是“把章画上去”这一步,而是三件看起来不起眼的事——UIKit 的 y 轴向下、PDF 的原点却藏在左下角;红章在不同屏幕和打印机上偏成暗红;以及签完章之后文件仍然能被随意改内容。这三件事任何一个没处理好,交付验收时都会翻车。
这篇文章按一条完整的落地路径来讲:先做选型对比,再给出可抄作业的绘制代码和参数,接着列出我踩过的四个具体坑位,然后补上签章后的锁定方案,最后是一份能直接拿去用的验收清单。适合 iOS 开发、Hybrid 应用工程师和做文档中台的后端同学对照着自己的工程看,新手能跟步骤走,熟手可以直接跳到自己关心的参数和边界部分。
2. 选型对比:用PDFKit注解还是CGContext重绘来承载签章
2.1 两种路线的能力边界与生态现状
iOS 上给 PDF 加签章,常见做法只有两条路:一是 PDFKit 的 PDFAnnotation / PDFPage,二是用 Quartz 2D(CGContext)把原 PDF 重绘到一个新的上下文中,在绘制签名和印章后输出新 PDF。
PDFKit 注解路线的优势是 API 简单,几行代码就能往指定页加一个矩形、签名图片或文本气泡;iOS 11 之后的系统内置,不需要额外引入第三方库。但它的短板也很明显:对自定义印章(旋转、透明度、防伪底纹)支持不够直接,PDFAnnotation 的样式在 iOS 16 前后的渲染差异较大,而且生成文件后如果还要做二次加密、数字签名,PDFKit 的控制力偏弱。另一个容易被忽略的边界是,PDFKit 对“修复畸形 PDF”的能力很一般——后端下发文件里有少量非法对象时,PDFKit 会直接拒绝渲染,而 CGContext 的 drawPDFPage 对这类文件反而更宽容。
我一般会优先选择 CGContext 重绘路线来承载电子签章。原因有三个:第一,签名和印章本质上是“在指定 PDF 坐标系里叠加绘图操作”,CGContext 恰好就是干这个的,后续要追加水印、页码、骑缝章都共用同一套管线;第二,重绘输出的 PDF 仍然是矢量的,签章不会因为放大而模糊;第三,重绘结束后可以直接带上密码和权限标志写入文件,一次性完成“签名 + 上锁”。代价是你需要自己处理页面尺寸、裁剪框和坐标换算,这正是本文要展开的部分。
2.2 用UIGraphicsPDFRenderer把现有PDF转成可变PDF:最小工程
在 iOS 上重绘现有 PDF 的最简做法是用 UIGraphicsPDFRenderer。它面向的是“我要生成一个全新的 PDF”的场景,但配合 CGPDFDocument 连起来也能用于“读入旧 PDF、原样重画每一页、再叠加新内容”。下面是一个最小工程示例。
import UIKit func redrawPDF(sourceURL: URL, to destinationURL: URL) throws { guard let document = CGPDFDocument(sourceURL as CFURL) else { throw NSError(domain: "PDFSign", code: -1, userInfo: [NSLocalizedDescriptionKey: "无法打开源 PDF"]) } let pageCount = document.numberOfPages var mediaBox = CGRect.zero let renderer = UIGraphicsPDFRenderer(bounds: .zero) // 占位,实际按页设置 try renderer.writePDF(to: destinationURL) { context in for pageIndex in 1...pageCount { guard let page = document.page(at: pageIndex) else { continue } // 读取原始页面尺寸,确保输出页大小与源文件一致 mediaBox = page.getBoxRect(.mediaBox) UIGraphicsBeginPDFPageWithInfo(mediaBox, nil) // 先原样绘制原页面,此操作保持矢量,不会把整页栅格化 context.cgContext.saveGState() context.cgContext.translateBy(x: 0, y: mediaBox.height) context.cgContext.scaleBy(x: 1, y: -1) context.cgContext.drawPDFPage(page) context.cgContext.restoreGState() } } }逻辑说明:
page.getBoxRect(.mediaBox)拿到源页面的完整页面尺寸,输出页必须沿用这个尺寸,否则后续签章坐标会整体错位。UIGraphicsBeginPDFPageWithInfo(mediaBox, nil)不是在 renderer 的闭包里直接调用,而是借助 renderer 内部已经开启的 PDF 上下文,在每一页绘制前用该函数声明页面大小。实际上在writePDF闭包内不需要再手动调用这个函数,UIGraphicsPDFRenderer会自动开启每一页;上面代码里这一步是为了保持讲解清晰,真实工程中可以直接用context.beginPage(withBounds:pageInfo:)。translateBy + scaleBy做 y 轴翻转:PDF 的原点通常在左下角,UIKit 的 CGContext 直接绘制时原点在左上角,翻转后才能保证原 PDF 内容方向正确。
参数说明:
- 如果源 PDF 页面有
cropBox,优先用getBoxRect(.cropBox)而不是.mediaBox,否则扫描件类 PDF 会出现大留白甚至内容被裁掉。 - 绘制签章前先把整页画完,再叠加签章图层,避免签章被原 PDF 内容覆盖。
这一步跑通后,你就已经拥有了一个“读写 PDF 的管线”。接下来要解决的核心问题只有一个:如何把用户手指点到的位置,精确映射到这一页的 PDF 坐标上。
3. 把签名和公章画上PDF:坐标系换算与绘制参数
3.1 从触摸点到PDF页面坐标:UIKit与PDF坐标系的换算
iOS 拿到用户手势位置时,得到的是视图(UIView)坐标系的点,y 轴向下;PDF 页面绘制坐标系的原点在左下角,y 轴向上;而我们要写入签名框时,业务上通常按“从页面左上角往右、往下”的矩形来定义,例如“签名区在页面宽度 50% 处、高度 70% 处”。这里的换算包含两步:先由页面左上角往下的归一化坐标转成 PDF 坐标系下的点,再由 PDF 坐标点反推 UIKit 视图坐标用于显示。
归一化坐标是最稳妥的中间表达方式,因为它与屏幕尺寸、PDF 页面尺寸无关。比如后端下发的签章指令里写“x=0.45, y=0.7”,含义是“距页面左上角向右 45%、向下 70%”。换算公式如下:
func pointInPDF(fromNormalizedX x: CGFloat, y: CGFloat, pageRect: CGRect) -> CGPoint { // PDF 坐标系:原点在左下角,x 向右,y 向上 let pdfX = pageRect.minX + x * pageRect.width // 从左上角往下的位置,换算到左下角往上的位置 let pdfY = pageRect.maxY - y * pageRect.height return CGPoint(x: pdfX, y: pdfY) }逻辑说明:pageRect是这一页的 mediaBox/cropBox。pdfY = maxY - y * height把“距顶部百分比”翻转为“距底部百分比”,这一步最容易错。很多工程师在这里直接用 UIKit 的 y 值去写 PDF,结果签章在真机上看起来正常,用 PC 端 PDF 阅读器打开后跑到页面外或者上下颠倒。
参数说明:
pageRect不要用屏幕 bounds,也不要用某个 UIView 的 bounds,必须来自CGPDFPage.getBoxRect。- 如果页面被旋转过(iOS 的
page.rotationAngle不为 0),需要先对pageRect做旋转归一化处理。常见做法是当rotationAngle == 90 || rotationAngle == 270时交换宽高,再套用上面的公式。 - 建议所有签章位置用归一化坐标存储到业务字段(例如 JSON 里的
"sign": {"x": 0.45, "y": 0.7, "w": 0.3, "h": 0.08}),而不是把点像素存死,这样同一份签章指令在 iPad、iPhone 和 Android 端渲染结果一致。
3.2 手写签名采集与印章绘制:贝塞尔曲线和透明PNG
手写签名在 iOS 端最常见做法是在一个白底 view 上用UIBezierPath记录触摸轨迹,再用draw(_:)或CAShapeLayer实时渲染。采集完成后,你需要把这个签名路径转成 UIImage,再把 UIImage 画到 PDF 上下文中。印章则更简单:用设计好的透明背景 PNG(通常包一层白底遮罩删掉后导出),画到目标区域。下面这段代码把“签名 + 印章”一次性写入 PDF:
func drawSignatureAndStamp(on context: CGContext, pageRect: CGRect, signatureImage: UIImage, stampImage: UIImage, signRect: CGRect, stampRect: CGRect) { context.saveGState() // 绘制签名:保持原始宽高比,居中放进目标区域 let sigScaled = aspectFitRect(for: signatureImage.size, in: signRect) if let sigCG = signatureImage.cgImage { context.interpolationQuality = .high context.draw(sigCG, in: sigScaled) } // 绘制印章:支持旋转角度,业务上通常干刻/红色公章按 0 度或 30 度倾斜 context.translateBy(x: stampRect.midX, y: stampRect.midY) context.rotate(by: stampRotateAngle) context.translateBy(x: -stampRect.midX, y: -stampRect.midY) if let stampCG = stampImage.cgImage { context.draw(stampCG, in: stampRect) } context.restoreGState() } func aspectFitRect(for size: CGSize, in target: CGRect) -> CGRect { let scale = min(target.width / size.width, target.height / size.height) let w = size.width * scale let h = size.height * scale return CGRect(x: target.midX - w / 2, y: target.midY - h / 2, width: w, height: h) }逻辑说明:
aspectFit是为了避免签名字体被拉伸变形,尤其当签名框是横向长方形、而手写轨迹是竖向的时候。interpolationQuality = .high用于签名这种边缘柔和的光栅图,缩小时发虚问题会明显减轻。- 印章旋转放在绘制坐标系层面完成,而不是预先旋转图片再保存,这样同一个章资源可以灵活支持业务要求的多个角度。
参数建议(工程默认值):
- 签名框宽度取页面宽度的
0.3~0.4,高度取0.08~0.12;太小识别困难,太大会盖住正文。 - 公章直径取页面宽度的
0.15~0.2,过大显得突兀,过小打印出来看不清字。 - 印章图片建议使用
sRGB色彩空间导出 PNG,不要用 Display P3,否则后面会遇到经典的红章偏色问题,具体在第 4 章展开。 - 签名导出 UIImage 时按
scale = UIScreen.main.scale渲染,保证 Retina 屏上路径边缘平滑。
3.3 嵌入H5/uniapp场景时的签名回传
现在不少项目是 Hybrid 架构:外层是 uniapp / H5,签章能力要么放在原生端,要么放在 Web 页里用 canvas 做。uniapp 开发者最常见的处境是:iOS 端原生签章能力要自己封装成原生插件,uniapp 通过uni.requireNativePlugin调用;而 H5 纯 canvas 方案则要处理字体、坐标和导出白图的问题。
如果签章能力已经由原生端封装成插件,建议在插件里输出“签名完成后的本地 PDF 路径 + 页面尺寸 + 签章坐标”三要素,H5 只负责展示和触发,不做任何绘图运算。这样既绕开了 uniapp 层面 canvas 导出的兼容性问题,又能复用原生端的 PDF 重绘管线。如果必须走 H5 canvas 签名,那么 canvas 的坐标系要和第 3.1 节里 PDF 的坐标做同样换算,不能直接把 canvas 的 offsetX/offsetY 当作 PDF 坐标写入,这是最容易被忽略的坑。
4. 签章落地的四个翻车坑位:颜色、模糊、错位、白图
4.1 红章偏成暗红色,真机和预览器显示不一致
现象:公章 PNG 在素材管理里看着是正红,绘制到 PDF 后,在 Mac 的“预览”里打开是偏暗的枣红色,手机上又比预览器鲜亮一些,打印出来更是一言难尽。
原因:最典型的是 PNG 资源色彩空间被标记为 Display P3 或未标记,CGContext 绘制时默认按设备色彩空间映射;加上 PDF 查看器各自的颜色管理策略不同,就会出现同一份文件在不同软件里色差明显。另一类来源是设计师导出 PNG 时勾选了“嵌入颜色配置文件”,而 PDF 写入时没有保留该 profile。
解决:统一在代码里做色彩空间约束。常见做法是在绘制印章前,将UIImage通过CIImage.imageByApplyingCIContext重绘到sRGB色彩空间;也可以直接要求设计师导出 PNG 时勾选 sRGB、关闭 Display P3,并在命名里标记_sRGB。最稳妥的兜底方案是运行时检测:
func srgbImage(from image: UIImage) -> UIImage? { guard let cg = image.cgImage else { return nil } let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)! guard let newCG = cg.copy(colorSpace: colorSpace) else { // 如果无法直接转换,走一次离屏渲染 UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale) image.draw(in: CGRect(origin: .zero, size: image.size)) let result = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return result } return UIImage(cgImage: newCG) }另外,红章偏暗的另一个放大器是绘制时上下文没关闭插值,CGContext 默认的 interpolation 对红色边缘的渐变会产生灰边,视觉上也会让章显得“脏”。绘制过章后建议显式context.setShouldAntialias(true),并保持高插值。
4.2 手写签名在 PDF 里发虚,放大后出现锯齿
现象:签名在签名视图里很清晰,写入 PDF 后,用 PDF 阅读器放大到 200% 时边缘发虚,像低分辨率位图;同一份文件在手机上预览又还可以。
原因:签名被渲染成位图 UIImage,绘制到 PDF 时如果图片的像素密度不够,等于间接把“位图”嵌进 PDF,放大自然会糊。另一个常见操作是把整页 PDF 先截屏成 UIImage,再贴回新 PDF,这是最严重的翻车——整页成位图,文件也膨胀数倍。
解决:签名路径尽量用矢量路径绘制进 PDF 上下文,而不是转成 UIImage 再画。如果受限于采集方式只能拿到 UIImage,至少要按目标绘制区域的三倍尺寸导出签名图,UIGraphicsImageRendererFormat里显式设置scale = 3,并在绘制时用context.interpolationQuality = .high。对于“把整页做位图再贴回”的做法,要坚决杜绝——直接按第 2 章的管线原样 drawPDFPage 保持矢量页,再叠矢量/位图签名。
4.3 真机上位置正确,PC 上签章跑到页外或旋转 90 度
现象:iOS 模拟器和真机预览都正常,用户反馈 PC 端打开后签名横着、印章跑出页面边界;甚至同一份 PDF 在 macOS 预览器和 Adobe Acrobat 里表现不一致。
原因:写入签章时用了 UIKit 视图坐标而没有换算成 PDF 坐标,尤其没处理rotationAngle。部分 PDF 由扫描仪/打印机生成时自带旋转标志(比如 rotationAngle = 90),页面实际渲染时宽高交换;代码里如果只取了 mediaBox 原始宽高,签章坐标就会按旋转前的坐标系放置,表现出横竖错乱。
解决:每次签章前先判断page.rotationAngle。当角度为 90 或 270 时,交换 pageRect 的宽高再执行 3.1 节的归一化换算。同时建议在工程里留一个“坐标系自检页”:在每一页的 (0.5, 0.5) 处画一个红十字,输出后肉眼检查是否落在页面正中,颜色、方向是否正确,再做正式签章。
4.4 微信小程序/H5 里 canvas 签名导出白图
现象:uniapp 开发的 H5 页面在 iOS Safari 里,canvas 签名正常,但点击“导出/生成 PDF”时拿到的是白图;或者首次签名正常,第二次签完导出又是白图;在部分 iOS 版本上把 canvas 转 dataURL 后内容为空。
原因:iOS Safari 对 canvas 的离屏渲染有自身机制,尤其在 uniapp 中签章画布被 WebView 合成、popup、动画等场景干扰时,canvas 纹理在导出瞬间被回收或未提交。这与“uniapp canvas 队列”问题同源:多个 canvas 操作排入队列,导出时机发生在纹理上屏前就开始读取像素。
解决:导出前强制执行一次“让 canvas 上屏”的操作,例如先切换到别的页面再回来,或在导出事件里加requestAnimationFrame双重延迟;更稳妥的是把 canvas 内容在每次签名结束时就同步到一个离屏 canvas 并保存为 dataURL,导出时直接用这份快照,而不是依赖当前屏幕上的 canvas 纹理。对于关键交付场景,优先把签名和盖章交给原生端插件,H5 只做展示和触发,彻底绕开 canvas 兼容性。
5. 签完怎么锁:PDF权限密码、锁写与后端摘要校验
5.1 用 Quartz 设置 PDF 访问权限与打开密码
签章完成后不能让用户随意打开编辑或替换签章页,iOS 原生能力里能做到的是通过 Quartz 的kCGPDFContext系列 key 设置 PDF 打开密码和权限。在 UIGraphicsPDFRenderer 环境里,可以在writePDF前通过 PDF 上下文选项传入:
let pdfInfo: [CFString: Any] = [ kCGPDFContextOwnerPassword: "signer-owner-key", kCGPDFContextUserPassword: "signer-user-key", kCGPDFContextAllowsPrinting: true, kCGPDFContextAllowsCopying: false, kCGPDFContextAllowsHighQualityPrinting: true ] let format = UIGraphicsPDFRendererFormat() format.documentInfo = pdfInfo as [String: Any] let renderer = UIGraphicsPDFRenderer(bounds: .zero, format: format)逻辑说明:
kCGPDFContextOwnerPassword是所有者密码,控制文档权限;kCGPDFContextUserPassword是打开文档需要的用户密码。业务上通常 owner 密码由后端保管,用户侧不设打开密码,只禁止复制和修改。AllowsCopying = false可以阻止大多数阅读器直接复制文字内容,但要注意它并不能阻止截屏。- 这个方案对“已经生成的 PDF 文件做二次加密”不适用,它是在生成 PDF 时顺带写入安全属性。
5.2 防篡改只靠App端不够:哈希摘要与后端数字签名的分工
App 端设置 PDF 权限密码只能算“锁写”,不能算“防篡改”。原因很简单:PDF 权限密码只是阅读器层面的软约束,专业工具可以直接绕过并重写文档。真正让签章具备不可抵赖性的是两类手段:一是外部的 CA 数字签名(对 PDF 内容摘要做非对称签名),二是把摘要哈希上链/存证。
在 iOS 端可落地的做法是:签章完成后,对 PDF 的二进制数据做一次 SHA-256,把这个摘要连同签章坐标、用户 ID、时间戳一起作为 JSON 上报后端;后端用自己的私钥对摘要做签名,并把签名结果存储或上链。验收时做完整性校验:重新计算文件哈希,若不一致,说明文件在传输或存储过程中被动过。
func sha256Digest(of url: URL) -> String? { guard let fileData = try? Data(contentsOf: url) else { return nil } var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) fileData.withUnsafeBytes { buffer in _ = CC_SHA256(buffer.baseAddress, CC_LONG(fileData.count), &digest) } return digest.map { String(format: "%02x", $0) }.joined() }参数与边界:
- 摘要计算必须在 PDF 文件最终写入后执行,不能在内存中的 Data 对象尚未 flush 时计算。
- 后端做数字签名时,摘要字符串要连同签章坐标一起参与签名,这样能防止“文件合法但签章位置被替换”的极端篡改。
- 时间戳建议由后端统一签发,App 本地时间不可信,容易被改造成伪造链路的一环。
6. 验收清单:用这套办法三天稳住签章交付
6.1 四类PDF样本必测
签章问题的隐蔽性在于:你测的源文件越“干净”,越容易漏掉边界情况。我一般会让测试同学准备四类样本:一是纯文字 PDF(无图片、无字体嵌入),二是扫描件(整页是位图),三是包含表单域和多级嵌套页面的 PDF,四是带有旋转标志的 PDF。其中第四类最容易暴露第 4.3 节提到的坐标错位问题。
对每个样本,在 iOS 模拟器、iOS 真机和 macOS 预览器三端打开对比签章位置与颜色。模拟器上验证通过只是第一步,因为模拟器走的是 macOS 的图形栈,色彩管理和真机差距明显;真机验证才是颜色和清晰度的唯一标准。建议在ios开发者模式开启的状态下,用 Xcode 的 View Debugging 检查 PDF 视图层级,确认签章图层没有被系统裁剪。
6.2 用charles抓包核对回传完整性
签章后的 PDF 要走网络回传,回传链路上的意外修改比绘制错误更可怕。用 Charles 代理 iOS 请求,重点关注两件事:一是回传的 PDF 大小是否合理(重绘后一般会比源文件大 100KB 以内,大幅膨胀要怀疑把整页转成了位图);二是抓包拿到的响应体能不能直接被预览器打开。如果回传接口是 base64 嵌套在 JSON 里,还要确认编码后没有换行符被剥离,这类问题在部分后端网关里会静默出现,客户端很难察觉。
6.3 多版本iOS真机与开发者模式验证
最后一条习惯:不要只在当前开发机上验证。iOS 16 与 iOS 18 的 PDFKit 行为有差异,尤其是旋转页面处理和渲染插值策略。有条件就在 iOS 16、17、18 各留一台真机,至少覆盖跑签章绘制和回传两条用例。如果没有多台设备,优先保住最低支持版本,因为大部分颜色和坐标问题在低版本系统上更容易暴露。
我现在的习惯是出包前一天把同一份带签章的 PDF 在预览器和真机上对比一次,命中过一次红章偏色和一次坐标翻转后,再也不敢只在模拟器上看一眼就提交。把上面这套自检走一遍,比你连加三个小时班修线上问题要划算得多。希望帮到你。
本文还有配套的精品资源,点击获取