iOS PDF 工具怎么实现?从 PDFKit、标注、签名到扫描生成 PDF
前面的系列,我们已经从文件浏览一路扩展到了压缩、图片、视频和音频处理:
01 iOS 文件浏览器架构
02 GB 级大文件处理
03 Wi-Fi 文件传输
04 SMB / NAS
05 WebDAV
06 FTP
07 ZIP / RAR / 7z
08 图片转换与压缩
09 视频转换与压缩
10 音频格式转换
这一篇开始进入另外一个非常高频的文件类型:
对于普通用户来说,PDF 需求往往非常明确:
收到一份 PDF,怎么在 iPhone 上签字?
或者:
PDF 里怎么高亮、画线、写字?
又或者:
没有扫描仪,能不能直接拍照生成 PDF?
从产品角度看,这些都是一个 PDF 工具应该完成的事情。
但从技术实现上看,PDF 模块实际上包含多个不同能力:
- PDF 浏览
- 页面渲染
- 缩略图
- 文本选择
- Highlight
- Underline
- Strikeout
- Freehand Ink
- Signature
- 页面旋转
- 页面增删
- 图片转 PDF
- 相机扫描
- 文件保存
- 大型 PDF 内存控制
- 增量保存与文件替换
所以:
PDFService 不应该只是一个 PDFView 页面,而应该是一套完整的 Document Processing 模块。
1. PDF 为什么和图片不一样?
很多人第一反应是:
PDF 不就是很多张图片放在一起吗?
不完全是。
一个 PDF 页面里可能包含:
Text
Vector Graphics
Images
Annotations
Forms
Links
Metadata
例如:
Page 1
├── Text
├── Logo
├── Vector Line
├── Embedded Image
└── Annotation
所以 PDF 更像:
一种完整的页面描述文档格式。
这也是为什么:
PDF
可以在不同屏幕尺寸上缩放,而文字依然保持清晰。
2. iOS PDF 开发最核心的框架:PDFKit
在 Apple 平台上,PDF 处理经常会使用:
PDFKit
几个最核心的类型包括:
PDFView
PDFDocument
PDFPage
PDFAnnotation
可以简单理解为:
PDFDocument
│
├── PDFPage
├── PDFPage
└── PDFPage
然后:
PDFView
↓
Display PDFDocument
这是整个 PDF 模块最基础的一层。
3. PDFDocument 可以看作 PDF 文件模型
例如:
import PDFKit
let document = PDFDocument(url: fileURL)
拿到以后:
let count = document?.pageCount
获取页面:
let page = document?.page(at: 0)
整体:
FileItem
↓
PDFService
↓
PDFDocument
↓
PDFPage
因此不要让:
UIViewController
直接承担 PDF 文件逻辑。
4. PDFView 负责什么?
PDFView 更接近:
PDF 阅读和交互 UI。
例如:
let pdfView = PDFView()
pdfView.document = document
可以负责:
- 页面显示
- 缩放
- 滚动
- 页面切换
- 文本选择
所以架构最好是:
PDF Reader UI
↓
PDFView
↓
PDFDocument
而保存、标注、签名等业务逻辑继续放在:
PDFService
里。
5. PDF 模块应该先有统一 PDFInfo
类似前面的:
ImageInfo
VideoInfo
AudioInfo
PDF 也应该先 Inspect。
例如:
struct PDFInfo {
let pageCount: Int
let fileSize: Int64
let title: String?
let author: String?
let isEncrypted: Bool
let allowsCopying: Bool
let allowsPrinting: Bool
}
UI 可以先展示:
27 Pages
4.8 MB
Encrypted: No
以后也方便进行:
PDF Validation
6. PDFAnnotation 是标注功能的核心
PDF 中很多常见编辑行为实际上不是:
修改原正文。
而是:
给页面增加 Annotation。
例如:
Highlight
Underline
Strikeout
Ink
FreeText
Link
Stamp
都可以抽象为:
PDFAnnotation
因此用户看到:
高亮一段文字
底层可能只是:
PDFPage
↓
Add Annotation
而不是重新生成整页 PDF。
7. Highlight 怎么实现?
用户选择文字:
This is important text
然后点击:
Highlight
底层需要得到:
Selection
↓
Bounds
↓
PDFAnnotation
概念上:
let annotation = PDFAnnotation(
bounds: bounds,
forType: .highlight,
withProperties: nil
)
page.addAnnotation(annotation)
于是:
Page Content
+
Highlight Annotation
共同组成最终页面。
8. Underline 和 Strikeout 也是类似思路
例如:
Highlight
Underline
Strikeout
UI 看起来是三种功能。
底层:
PDFAnnotation Type
不同。
因此 PDF 编辑工具不应该写:
highlightText()
underlineText()
strikeoutText()
然后各自独立一整套架构。
更合理:
TextMarkupService
↓
Annotation Type
9. 手写画笔怎么实现?
自由手写:
Pen / Ink
通常可以抽象为:
Touch Points
↓
Bezier Path
↓
PDF Ink Annotation
用户:
手指 / Apple Pencil
在页面上画:
~~~~
底层记录:
Point 1
Point 2
Point 3
...
然后变成:
Path
最终加入 PDF。
10. 不要每移动一个点都立即重写 PDF
如果用户正在连续书写:
Touch Move
Touch Move
Touch Move
如果每次:
Save PDF
性能会非常差。
更合理:
Drawing Session
↓
Collect Points
↓
Render Overlay
↓
Gesture End
↓
Create Annotation
↓
Mark Document Dirty
最后再统一保存。
所以:
UI 实时反馈和 PDF 文件持久化应该分离。
11. Apple Pencil 怎么处理?
如果产品支持 Pencil,可以进一步区分:
Finger
Apple Pencil
并利用:
- Pressure
- Tilt
- Precise Points
改善笔迹。
但 PDFService 本身不应该知道:
UITouch
细节。
可以设计:
DrawingInput
↓
InkStroke
↓
PDF Annotation
这样输入层和文件层继续分离。
12. PDF 签名,本质上是什么?
普通用户看到:
Sign PDF
以为 PDF 有一个特殊“签名按钮”。
但很多普通电子签名功能,本质上可能是:
用户手写签名
↓
Signature Drawing/Image
↓
Placed on PDF Page
↓
Annotation
例如:
John Smith
用户先保存一个签名,然后拖到:
Signature: ___________
位置。
这和:
数字证书签名
不是一回事。
13. 手写签名 ≠ 数字签名
这个区别非常重要。
Handwritten Signature
本质更接近:
Image / Vector Drawing
放到 PDF 页面上。
主要解决:
我要在合同上签字。
Digital Signature
则涉及:
Certificate
Private Key
Cryptographic Signature
Document Integrity
用于验证:
- 谁签的
- 文档有没有被篡改
- 证书是否可信
所以:
PDF 工具如果只是支持手写签字,不应该宣传成数字证书签名系统。
14. Signature 应该独立建模
例如:
struct SavedSignature {
let id: UUID
let createdAt: Date
let vectorData: Data?
let imageData: Data?
}
UI:
Signature Library
│
├── Signature A
└── Signature B
用户选择:
Signature A
然后:
Drag
Resize
Place
最终转成:
PDF Annotation
15. 签名保存在哪里?
这涉及用户隐私。
签名属于比较敏感的数据。
因此不建议:
随便放到公开 Documents
更适合:
Application Support
配合:
File Protection
或者加密存储。
如果产品支持:
Face ID App Lock
签名库也可以被整体应用锁保护。
16. PDF 签名最好支持矢量数据
如果只是:
PNG Signature
放大后可能变模糊。
更好的方式是:
Stroke Points
↓
Vector Path
保存签名。
渲染时:
Vector
↓
Scale
↓
Still Sharp
当然实现成本更高。
第一版使用透明 PNG 也完全可以满足大部分需求。
17. Lasso 是什么?
如果用户画了多个:
Ink Annotation
希望:
框选
移动
删除
就需要:
Selection Tool / Lasso
架构上:
Touch Path
↓
Selection Bounds
↓
Intersect Annotations
↓
Selected Annotation Set
然后可以:
Move
Delete
Resize
所以 PDF 编辑器一旦支持:
Ink
后面很自然就会进入:
Annotation Editing
体系。
18. Undo / Redo 很重要
用户在 PDF 上:
写错
删错
移动错
如果没有 Undo:
体验会非常差。
因此可以设计:
PDFEditCommand
例如:
AddAnnotationCommand
RemoveAnnotationCommand
MoveAnnotationCommand
ResizeAnnotationCommand
然后:
Undo Stack
Redo Stack
这比到处手写:
undoLastThing()
更容易维护。
19. Command Pattern 很适合 PDF 编辑
比如:
protocol PDFEditCommand {
func execute()
func undo()
}
新增高亮:
Add Annotation
执行:
page.addAnnotation(...)
撤销:
page.removeAnnotation(...)
于是:
Highlight
Ink
Signature
Delete
Move
都可以进入统一编辑历史。
20. PDF 保存不能太随意
用户:
Add Highlight
之后需要:
Save
最危险的做法是:
直接破坏式覆盖原文件
更稳妥:
Original PDF
↓
Write Temp
↓
Validate
↓
Replace Original
这和我们前几篇所有文件任务的原则完全一致。
21. 为什么还是要临时文件?
假设:
document.pdf
保存到:
78%
突然:
- App 被终止
- 磁盘满了
- 写文件失败
如果直接写原文件,
就可能:
Original PDF
↓
Corrupted
所以:
.document.pdf.tmp
↓
Write
↓
Complete
↓
Validate
↓
Replace
更安全。
22. Auto Save 怎么设计?
PDF 标注很适合:
Auto Save
但不要:
每画一个点就写磁盘
可以:
Document Dirty
↓
Debounce
↓
Auto Save
例如:
用户停止编辑一段时间
再保存。
同时保留:
Explicit Save
以增强用户确定感。
23. 大型 PDF 最大的问题之一是渲染
例如:
1000 Pages
或者:
每页都是超高清扫描图
如果一次:
Render All Pages
会造成:
Memory Pressure
所以 PDF 阅读器应该始终围绕:
Visible Pages
工作。
即:
Current Page
+
Nearby Pages
优先渲染。
24. PDF Thumbnail 不能一次全生成
假设:
2000 Pages
缩略图栏如果一打开:
Generate 2000 Thumbnails
非常浪费。
应该:
Visible Thumbnail
↓
Generate
↓
Cache
滚动到新的页面:
Generate On Demand
这和前面的:
Image Thumbnail
Video Thumbnail
NAS Thumbnail
完全一样。
25. PDF Thumbnail 也应该有 Cache
例如 Key:
PDF File ID
+
Modified Date
+
Page Index
+
Target Size
然后:
Memory Cache
+
Disk Cache
文件修改后:
Modified Date
变化,
缓存失效。
26. Page Rendering 和 Thumbnail Rendering 应该区分
正文阅读:
Full Page Rendering
要求:
清晰
缩放
缩略图:
Small Preview
要求:
速度
低内存
所以不要:
Render Full Resolution
↓
Resize to Thumbnail
应该:
Render Directly at Target Size
尽量减少无意义资源消耗。
27. PDF Search 是另一个非常有价值的能力
用户打开:
300 Page Manual
想找:
authentication
需要:
Text Search
如果 PDF 包含真正的 Text Layer,
可以通过:
PDFDocument
↓
Find String
找到匹配位置。
UI:
23 Results
然后跳转页面。
28. 扫描 PDF 可能没有文字层
例如:
Paper
↓
Camera
↓
PDF
如果只是图片生成 PDF,
页面里面实际上是:
Image
而不是:
Text
于是:
Search
Copy Text
Select Text
都无法工作。
这就是:
OCR
能力出现的原因。
29. OCR 和 PDF 是两个不同模块
PDFService 负责:
PDF Structure
Pages
Annotations
Save
OCRService 负责:
Image
↓
Text Recognition
不要把所有能力塞进:
PDFManager
更合理:
PDFService
OCRService
ScanService
各自负责不同问题。
30. 扫描生成 PDF 的基本流程是什么?
用户操作:
打开相机
↓
拍摄纸张
↓
自动识别边缘
↓
透视矫正
↓
增强
↓
生成页面
↓
PDF
整个 Pipeline:
Camera Image
↓
Document Detection
↓
Perspective Correction
↓
Image Enhancement
↓
Page Image
↓
PDF Generator
这并不是简单:
Camera Photo
↓
PDF
31. VisionKit 很适合文档扫描场景
在 iOS 里,系统提供了和文档扫描相关的能力。
产品可以利用系统扫描体验完成:
Document Capture
包括:
- 页面边缘检测
- 透视调整
- 多页扫描
然后拿到扫描结果:
Page Images
再进入自己的:
PDFService
生成 PDF。
32. ScanService 应该独立
例如:
protocol ScanService {
func scanDocuments() async throws -> [ScannedPage]
}
其中:
struct ScannedPage {
let image: UIImage
}
然后:
ScanService
↓
ScannedPage[]
↓
PDFService
↓
PDFDocument
这样:
Camera Scanning
和:
PDF Generation
职责清晰。
33. 图片转 PDF 其实也是同一条路径
例如用户已经有:
Photo 1
Photo 2
Photo 3
想:
Create PDF
流程:
Images
↓
PDF Page Generator
↓
PDFDocument
所以:
Camera Scan
只是图片来源不同。
统一后:
Image Source
↓
PDF Generator
可以同时支持:
Camera
Photo Library
Files
Scanner
34. 页面尺寸怎么选?
图片生成 PDF 时要决定:
Page Size
例如:
A4
Letter
Original Image Ratio
如果用户扫描合同,
通常:
A4
比较自然。
如果只是:
照片合集 PDF
则:
Fit Image
可能更合适。
因此 PDF Create Options 可以包含:
Page Size
Margins
Orientation
Image Fit
35. 扫描 PDF 为什么很容易变得巨大?
假设一页扫描图:
4032 × 3024
10 页:
10 high-resolution images
如果全部用高质量 PNG 写进 PDF,
最终可能:
100MB+
所以扫描生成 PDF 时必须考虑:
Image Compression
比如:
Downsample
+
JPEG Compression
36. PDF Scanner 应该考虑目标用途
例如:
Document
合同
文字
表格
可以采用:
高对比度
适中分辨率
Photo
需要:
颜色
更高质量
所以可以提供:
Document
Color
Photo
不同模式。
不必暴露:
JPEG Quality = 0.72
这种参数给普通用户。
37. 黑白扫描可以显著减少体积
很多纯文字文件:
黑字白纸
没必要保存完整彩色信息。
可以:
Color
↓
Grayscale
甚至:
Black & White
大幅降低数据量。
同时提高:
Text Contrast
这也是扫描工具常见的模式。
38. OCR 可以在扫描后异步进行
例如:
Scan
↓
Create PDF
↓
User can immediately view
后台再:
OCR
而不是强制:
等待 OCR 全部完成
才能生成文件。
架构:
Scan
↓
PDF Ready
↓
OCR Operation
这能明显提升用户感知速度。
39. OCR 结果应该如何保存?
可以有几种思路。
方案 A
只用于:
Search Index
不写回 PDF。
方案 B
生成:
Invisible Text Layer
让扫描 PDF 可以:
- 搜索
- 复制文字
方案 C
单独保存:
OCR Text
作为辅助数据。
不同实现复杂度差异很大。
40. PDF 页面管理也是重要功能
除了标注,
用户还经常需要:
Delete Page
Rotate Page
Reorder Page
Insert Page
这些能力最好统一成:
PDFPageOperation
而不是每个按钮自己直接操作 Document。
例如:
Move Page 8 → 2
可以成为:
ReorderPageCommand
同样进入:
Undo / Redo
体系。
41. Merge PDF 怎么实现?
例如:
A.pdf
+
B.pdf
+
C.pdf
合并:
Merged.pdf
逻辑:
New PDFDocument
↓
Insert Pages from A
↓
Insert Pages from B
↓
Insert Pages from C
但仍然需要考虑:
- 页面顺序
- Metadata
- 加密 PDF
- 大文件
- 失败恢复
所以 Merge 本质也是:
FileOperation
42. Split PDF 呢?
例如:
100-page.pdf
用户选择:
Pages 1–10
导出:
part1.pdf
或者:
每页一个文件
这就是:
Split Operation
所以未来 PDFService 可以拥有:
merge()
split()
extractPages()
43. PDF 密码怎么办?
PDF 可能是:
Encrypted PDF
打开时需要密码。
流程:
Open PDF
↓
Encrypted?
↓ Yes
Request Password
↓
Unlock
错误应该明确:
Wrong Password
而不是:
Failed to open PDF
继续延续整个系列的错误业务化原则。
44. PDF 权限也可能有限制
某些 PDF 会设置:
Allow Copy
Allow Print
等权限信息。
所以 PDFInfo 可以记录:
allowsCopying
allowsPrinting
应用在实现:
Copy Text
Print
时应尊重对应文档状态和系统能力。
45. PDF Password Store 也可以统一到安全存储
如果用户明确选择:
Remember Password
可以:
PDF Credential
↓
Keychain
和之前:
SMB
WebDAV
FTP
Archive Password
统一进:
CredentialStore
这样安全层继续复用。
46. 大 PDF 也应该进入 FileOperationManager 吗?
不是所有浏览行为都需要。
例如:
Open PDF
Scroll PDF
属于交互。
但:
Save Edited PDF
Merge
Split
Scan to PDF
Export
OCR
这些都可能是长任务。
所以可以定义:
OperationType.pdfSave
OperationType.pdfMerge
OperationType.pdfSplit
OperationType.pdfScan
OperationType.ocr
统一进入任务系统。
47. PDF 保存进度可能比复制更难
例如:
Rewriting 500-page PDF
底层不一定总能直接给你一个非常精确的:
0...100%
所以进度可以分成:
Preparing
Processing
Writing
Finalizing
而不是假装给用户一个特别精确但实际不可靠的百分比。
有时候:
Stage Progress
比:
Fake 73%
更诚实。
48. PDF 错误应该统一业务化
例如:
enum PDFProcessingError: Error {
case invalidDocument
case encrypted
case wrongPassword
case permissionDenied
case unsupportedOperation
case insufficientStorage
case saveFailed
case cancelled
case unknown(Error)
}
UI 显示:
PDF 密码错误
而不是:
Error Domain=PDFKit...
49. 远程 PDF 怎么办?
例如:
WebDAV
↓
contract.pdf
用户只是阅读,
可以:
Download / Cache
↓
PDFView
用户要:
签名
则更合理:
Remote PDF
↓
Local Working Copy
↓
Edit
↓
Save
↓
Upload Back
因为编辑通常需要可靠的本地工作文件。
50. 为什么远程 PDF 编辑最好先做 Working Copy?
如果直接:
Remote File
↓
Edit In Place
网络中断时非常危险。
更稳妥:
Remote
↓
Download
↓
Local Working Copy
↓
Edit
↓
Save
↓
Upload Temp
↓
Replace Remote
这个流程其实就是:
Transactional Remote Edit
51. 远程更新还要考虑冲突
假设:
10:00
你下载 contract.pdf
你编辑了 10 分钟。
与此同时服务器上的:
contract.pdf
已经被别人修改。
如果你直接上传覆盖:
Remote Changes Lost
所以成熟实现可以比较:
ETag
Modified Date
File ID
判断:
Remote Changed?
如果变化:
Conflict
让用户决定:
Replace
Save Copy
Cancel
这和前面的 Provider 架构完全一致。
52. PDFService 可以怎么抽象?
例如:
protocol PDFService {
func inspect(
_ item: FileItem
) async throws -> PDFInfo
func addAnnotation(
_ annotation: PDFAnnotationModel,
to item: FileItem
) async throws
func merge(
_ items: [FileItem],
destination: FileLocation
) async throws -> FileItem
func split(
_ item: FileItem,
pages: IndexSet,
destination: FileLocation
) async throws -> FileItem
func create(
from images: [FileItem],
options: PDFCreateOptions
) async throws -> FileItem
}
这样:
PDFKit
只是底层实现。
53. PDFAnnotationModel 最好独立于 PDFKit
如果业务层直接持有:
PDFAnnotation
以后很难:
- 做 Undo
- 做持久化
- 做同步
- 做其他 PDF Engine
更好的方式:
struct PDFAnnotationModel {
let id: UUID
let pageIndex: Int
let type: AnnotationType
let bounds: CGRect
}
然后:
PDFAnnotationModel
↓
PDFKit Adapter
↓
PDFAnnotation
保持业务模型和框架解耦。
54. 一个完整 PDF 架构
最终可以整理成:
┌─────────────────────────────┐
│ UI │
│ │
│ PDF Reader │
│ Annotation Toolbar │
│ Signature │
│ Scan │
│ Page Manager │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ PDFService │
│ │
│ Inspect │
│ Annotation │
│ Signature │
│ Merge / Split │
│ Create PDF │
│ Save │
└──────────────┬──────────────┘
│
┌──────┼───────────┐
▼ ▼ ▼
PDFKit ScanService OCRService
│
▼
PDFDocument
旁边继续复用:
FileOperationManager
TemporaryFileManager
ThumbnailService
CredentialStore
ConflictResolver
StorageChecker
55. TS File Explorer 的 PDF 能力真正解决什么?
从开发者角度:
PDFDocument
PDFPage
PDFAnnotation
VisionKit
OCR
是技术实现。
但用户真正的问题可能是:
合同突然发来,我现在就要签字。
或者:
我想把纸质文件直接扫描成 PDF。
或者:
PDF 里这几段文字需要高亮。
所以真正的使用路径是:
contract.pdf
↓
TS File Explorer
↓
Sign
↓
Save
↓
Share
或者:
Paper Document
↓
Scan
↓
PDF
↓
Share
用户真正需要的不是:
一个 PDFKit Demo。
而是:
文件来了以后,直接在手机上把事情做完。
56. 文件处理平台已经进入 Document 层
到现在:
FileProvider
│
├── Local
├── SMB
├── WebDAV
└── FTP
上层:
Services
│
├── ArchiveService
├── ImageService
├── VideoService
├── AudioService
├── PDFService
├── ScanService
├── OCRService
├── ThumbnailService
└── WaveformService
再由:
FileOperationManager
统一处理:
Copy
Transfer
Compress
Convert
Scan
Merge
Split
Save
整个系统已经明显从:
File Browser
变成:
File Processing Platform
57. 开发 PDF 模块前建议先回答这 20 个问题
1. PDF 浏览使用什么架构?
2. PDFDocument 生命周期如何管理?
3. Annotation 如何建模?
4. Highlight / Underline 如何实现?
5. Ink 如何保存?
6. 是否支持 Apple Pencil?
7. Signature 如何保存?
8. 手写签名与数字签名是否明确区分?
9. Undo / Redo 如何设计?
10. PDF 保存是否使用临时文件?
11. 大 PDF 如何控制内存?
12. Thumbnail 如何缓存?
13. 是否支持 Search?
14. Scan 与 PDFService 是否分层?
15. 是否支持 OCR?
16. OCR 结果如何保存?
17. 是否支持 Merge / Split?
18. PDF 密码如何处理?
19. Remote PDF 编辑如何做冲突检测?
20. PDF 长任务如何接入 FileOperationManager?
如果这些问题没有提前设计,
PDF 模块很容易从:
一个 PDFView
逐渐变成:
各种手势
+
各种标注状态
+
保存 Bug
+
页面 Bug
+
远程文件 Bug
最后难以维护。
写在最后
PDF 功能表面上看只是:
Read
Highlight
Sign
Scan
但真正的工程架构其实涉及:
PDF Rendering
+
Annotation
+
Drawing
+
Document Editing
+
Scanning
+
OCR
+
File Persistence
+
Remote File Conflict
最重要的一点仍然是:
不要把 PDF 模块写成 PDFView 上不断叠加按钮,而应该把它设计成独立的 Document Processing Service。
整个系列现在已经来到:
01 File Browser
↓
02 Large File IO
↓
03 Wi-Fi Transfer
↓
04 SMB
↓
05 WebDAV
↓
06 FTP
↓
07 Archive
↓
08 Image
↓
09 Video
↓
10 Audio
↓
11 PDF
下一篇我建议继续进入:
《iOS EPUB / MOBI 阅读器怎么实现?从电子书解析、目录、分页到 TTS 阅读架构》
可以继续拆解:
EPUB
MOBI
HTML
CSS
Table of Contents
Chapter
Pagination
Theme
Font Size
Reading Progress
Bookmark
TTS
这样就把 TS File Explorer 的 Reader 能力也完整接入这套架构。