PDFKit是苹果为macOS、iOS等平台提供的原生PDF框架,封装了PDF渲染、文本提取、标注、大纲解析等大部分常用能力。相比跨平台方案,它无需引入第三方依赖,与AppKit集成度高,非常适合在macOS上构建原生的PDF阅读与批注工具。本文将以一个具备高亮、下划线笔记和目录跳转功能的PDF阅读器为例,逐步拆解PDFKit的关键API和实现思路,所有代码均基于Swift编写,可直接嵌入到Xcode项目中运行。

一、搭建PDFView基础环境并加载文档
PDFKit的核心视图类是PDFView,它继承自NSView,负责PDF页面的渲染、缩放、滚动等交互。在Interface Builder中拖入一个PDFView并绑定IBOutlet即可使用,也可以纯代码创建。加载文档推荐使用PDFDocument(url:)初始化,它能自动处理文件读取和文档结构解析。
import PDFKit
class ReaderViewController: NSViewController {
@IBOutlet weak var pdfView: PDFView!
override func viewDidLoad() {
super.viewDidLoad()
guard let url = Bundle.main.url(forResource: "sample", withExtension: "pdf"),
let document = PDFDocument(url: url) else { return }
pdfView.document = document
pdfView.autoScales = true
pdfView.displayMode = .singlePageContinuous
pdfView.displayDirection = .vertical
}
}
几个属性值得注意:autoScales会根据窗口大小自动调整缩放比例,避免初始加载时页面过大或过小;displayMode设置为singlePageContinuous可以获得类似主流阅读器的连续滚动体验。如果需要监听页面切换事件,可以注册PDFViewPageChangedNotification通知,在回调中更新页码显示。
另外建议开启pdfView.displaysPageBreaks = true,让页面之间有明确分隔。对于加密文档,PDFDocument初始化会返回nil或触发解密选项,需要通过unlock(withPassword:)方法处理,这一步在处理用户本地受保护文件时尤其重要,不要简单地在解密失败时静默返回。
二、实现高亮与下划线笔记
1. 获取用户选中的文本区域
高亮和下划线的实现前提是拿到用户当前选中的文本范围。PDFView内置了文本选择能力,用户用鼠标拖动即可选中文字,选中结果保存在pdfView.currentSelection属性中,它是一个PDFSelection对象,记录了选区覆盖的页面以及每页上的矩形区域。
需要注意的是,跨页选区会产生多个selection。可以通过selectionsByLine()方法把选区按行拆分,每一行的矩形再分别生成标注,这样绘制出的高亮才是贴合文字行高的,而不是一整个大色块。
2. 创建标注对象
PDFKit中标注通过PDFAnnotation表达。高亮使用.highlight类型,下划线使用.underline类型。下面是完整的标注创建代码:
func addAnnotation(to pdfView: PDFView, type: PDFAnnotationSubtype, color: NSColor) {
guard let selection = pdfView.currentSelection,
let page = selection.pages.first else { return }
// 按行拆分选区,逐行生成标注
for lineSelection in selection.selectionsByLine() {
guard let linePage = lineSelection.pages.first else { continue }
var bounds = lineSelection.bounds(for: linePage)
if type == .underline {
// 下划线只贴着文字底部,把矩形压扁成一条线的高度
bounds.origin.y -= 2
bounds.size.height = 2
}
let annotation = PDFAnnotation(bounds: bounds,
forType: type,
withProperties: nil)
annotation.color = color.withAlphaComponent(0.45)
annotation.interiorColor = color
linePage.addAnnotation(annotation)
}
pdfView.clearSelection()
}
高亮标注建议把color的透明度降低到0.4左右,否则原始文字会被色块盖住看不清。下划线标注则需要手动调整bounds,PDFKit不会自动为underline类型压扁矩形,直接使用选区矩形会得到一个实心色块。调用page.addAnnotation后PDFView会自动重绘,无需手动刷新。
3. 笔记数据的持久化与导出
标注对象添加到页面后就属于PDFDocument的一部分,通过document.write(to:)可以把包含标注的PDF写回磁盘,这是最简单的持久化方式,优点是标注可以跟随文件在任何PDF阅读器中查看。
如果产品需要「笔记与原文分离」(例如同步到云端、多端同步、笔记导出为纯文本),就需要自定义数据结构,遍历所有页面的annotations,把页码、bounds、类型、颜色和关联文本序列化为JSON存储:
struct NoteRecord: Codable {
let pageIndex: Int
let type: String
let colorHex: String
let bounds: [CGFloat] // x, y, width, height
let text: String
}
func exportNotes(from document: PDFDocument) throws -> Data {
var records: [NoteRecord] = []
for pageIndex in 0..<document.pageCount {
guard let page = document.page(at: pageIndex),
let annotations = page.annotations else { continue }
for annotation in annotations where annotation.type == "Highlight" || annotation.type == "Underline" {
let record = NoteRecord(
pageIndex: pageIndex,
type: annotation.type ?? "Highlight",
colorHex: annotation.color.hexString,
bounds: [annotation.bounds.origin.x, annotation.bounds.origin.y,
annotation.bounds.size.width, annotation.bounds.size.height],
text: page.string(for: annotation) ?? ""
)
records.append(record)
}
}
return try JSONEncoder().encode(records)
}
两种方案可以并存:本地写入PDF保证通用性,JSON导出保证笔记系统的灵活性。恢复笔记时用JSON中的bounds重建PDFAnnotation即可,坐标体系一致,不会出现偏移。
三、解析PDF大纲并实现目录跳转
1. 理解PDFOutline的树形结构
PDF的目录在PDFKit中用PDFOutline表示,它是一个天生的树形结构:根节点通过document.outlineRoot获取,每个节点的numberOfChildren和child(at:)可以逐层展开,label是章节标题,destination则指向目标页面和具体位置。
2. 用NSOutlineView构建侧边栏目录树
macOS上展示树形目录最合适的控件是NSOutlineView。把它放在PDFView左侧,通过NSOutlineViewDataSource协议把PDFOutline的层级映射过去:
class OutlineDataSource: NSObject, NSOutlineViewDataSource {
let root: PDFOutline
init(root: PDFOutline) {
self.root = root
super.init()
}
func outlineView(_ outlineView: NSOutlineView,
numberOfChildrenOfItem item: Any?) -> Int {
guard let outline = item as? PDFOutline else {
return root.numberOfChildren
}
return outline.numberOfChildren
}
func outlineView(_ outlineView: NSOutlineView,
child index: Int,
ofItem item: Any?) -> Any {
guard let outline = item as? PDFOutline else {
return root.child(at: index)
}
return outline.child(at: index)
}
func outlineView(_ outlineView: NSOutlineView,
isItemExpandable item: Any) -> Bool {
guard let outline = item as? PDFOutline else { return false }
return outline.numberOfChildren > 0
}
}
在delegate中实现点击响应,调用pdfView.go(to: destination)即可完成跳转,PDFView会自动滚动到目标位置并处理缩放:
func outlineViewSelectionDidChange(_ notification: Notification) {
guard let outlineView = notification.object as? NSOutlineView,
let outline = outlineView.item(atRow: outlineView.selectedRow) as? PDFOutline,
let destination = outline.destination else { return }
pdfView.go(to: destination)
}
还有两个细节值得处理。第一,部分PDF没有内嵌大纲,outlineRoot返回nil或空节点,此时可以退化为「页面缩略图列表」作为目录替代方案;第二,深度较大的大纲直接全部展开会导致NSOutlineView卡顿,建议只展开前两级,其余按需展开。
四、常见坑点与进阶优化
中文选区偏移问题。某些扫描版或字体嵌入不规范的PDF,用鼠标选中中文后selection.bounds(for:)返回的矩形会与视觉文字错位,导致高亮画到了错误位置。这类文档的文本层本身就有问题,PDFKit只是如实反映了底层数据。可以在生成标注前先用page.string(for: selection)验证提取文本与用户选中文本是否一致,不一致时提示用户该文档不支持精确标注。
标注命中测试。实现「点击已有高亮弹出笔记编辑框」时,通过pdfView.annotation(at:)传入鼠标位置获取标注,注意这个方法接受的点是PDFView坐标系下的点,需要先用pdfView.convert(point, to: page)转换到页面坐标系,PDF页面坐标系原点在左下角,与AppKit左上角原点不同,这是新手最容易混淆的地方。
性能优化。大文件(几百页以上)首次加载时,可以在后台线程先构建PDFDocument,再回到主线程赋值给pdfView,避免阻塞UI。PDFDocument的构建和解析是线程安全的,但所有涉及PDFView渲染的操作必须回到主线程执行:
DispatchQueue.global(qos: .userInitiated).async {
let document = PDFDocument(url: fileURL)
DispatchQueue.main.async {
self.pdfView.document = document
self.setupOutline()
}
}
掌握以上几个模块后,一个具备基础笔记能力和目录导航的PDF阅读器就成型了。后续还可以在此基础上扩展便签、图形批注、全文搜索高亮等功能,PDFKit的PDFAnnotation子类型和document.findString接口都为这些扩展提供了现成的支持。