WWDC Quick Look 💓 By SwiftGGTeam
What's new in PDFKit

What's new in PDFKit

观看原视频

Highlight

iOS 16 和 macOS Ventura 的 PDFKit 支持扫描 PDF 的按需 Live Text、从 CGImage 创建 PDF 页面、为每页安装交互式 overlay view,并能把 PencilKit 绘图保存为 PDF annotation。


核心内容

很多文档 App 都会走到同一个问题:PDF 负责显示和翻页,但用户想做的事越来越像编辑器。

扫描件里的文字要能选中。表格要能填。照片要能变成 PDF 页面。用户用 Apple Pencil 在页面上画出的标注,关闭文件后还要保留下来。

过去,额外绘制 PDF 内容通常要子类化 PDFPage 并重写绘制方法,或者直接创建自定义 PDFAnnotation。这类做法更接近一次性绘制。它不适合放一个可缩放、可响应手势、可实时编辑的视图到页面上。

04:18)iOS 16 和 macOS Ventura 给 PDFKit 加了 overlay view。PDFKit 在页面进入视野前向 App 请求视图,负责把它约束到页面大小,并处理页面旋转。App 只需要返回一个 UIViewNSView

这让 PencilKit 的使用方式变得直接:每个 PDF 页面上放一个 PKCanvasView。用户画线时,画的是活的视图。页面滚出屏幕时,把 PKDrawing 存回自定义 PDFPage。保存文件时,再把这些绘图转成 annotation。


详细内容

PDFKit 的四个核心对象

00:54)这场 session 先回顾了 PDFKit 的对象模型。PDFView 放在界面里,负责显示、导航、缩放和复制文本。PDFDocument 表示整个 PDF 文件。PDFPage 表示每一页。PDFAnnotation 是页面上的可交互附加内容。

// 概念示例,非完整 API 签名
let pdfView = PDFView()
let document = PDFDocument(url: fileURL)
pdfView.document = document
let page = document?.page(at: 0)
let annotations = page?.annotations

关键点:

  • PDFView 是放进布局的视图对象,SwiftUI 中可以通过 UIViewRepresentable 接入。
  • PDFDocument 是根对象,一份文档包含多个页面。
  • page(at:) 取出单页,页面负责渲染内容并保存该页资源。
  • annotations 是页面上的可编辑附加对象,后面的保存流程会用它承载 PencilKit 绘图。

Live Text 和表单识别

02:08)PDFKit 现在支持 Live Text。扫描 PDF 即使只是位图、没有文本层,用户也可以选择和搜索其中的文字。

PDF 可以有很多页。PDFKit 不会在打开文档时对所有页面跑 OCR。它会在用户与某一页交互时按需处理该页。OCR 在原文档上就地完成,不需要复制文件。保存时也可以选择为整个文档保存识别出的文本。

02:58)表单处理也有更新。PDFKit 会自动识别文档里的表单字段,即使原始 PDF 没有内置文本字段。用户可以用 Tab 在字段之间跳转并输入文字。

// 使用 PDFView 打开文档后,Live Text 与表单识别由 PDFKit 提供。
// 这里没有额外启用开关;示例只展示 PDFKit 的接入位置。
let pdfView = PDFView()
pdfView.document = PDFDocument(url: scannedPDFURL)

关键点:

  • PDFView 是用户与 PDF 页面交互的位置,Live Text 的选择和搜索发生在这里。
  • PDFDocument(url:) 载入的文件可以是扫描 PDF;session 明确说明示例文档没有文本层。
  • Live Text 的 OCR 按页、按需执行,避免打开大文档时一次性处理全部页面。
  • 表单字段识别同样由 PDFKit 处理,用户获得接近网页表单的键盘跳转体验。

从图片创建 PDF 页面

03:14)iOS 16 和 macOS Ventura 提供了从图片创建 PDF 页面的新 API。输入图片使用 CGImageRef,PDFKit 会用高质量 JPEG 编码压缩图片。

这条路径适合扫描、拍照归档、把图片整理成 PDF 的 App。因为 CGImageRef 是 CoreGraphics 原生类型,App 不需要先把图片转成中间格式。

// 概念示例,非 API 签名:演示 session 提到的输入和选项。
let sourceImage: CGImage = scannedImage
let pageOptions = [
    "MediaBox": pageSize,
    "Rotation": pageRotation,
    "UpscaleIfSmaller": true,
]
let pdfPage = PDFPage(image: sourceImage, options: pageOptions)

关键点:

  • sourceImage 使用 CGImage,对应 transcript 中的 CGImageRef 输入。
  • MediaBox 决定页面大小,可以贴合图片,也可以选择 Letter 这类纸张尺寸。
  • Rotation 用来指定纵向或横向。
  • UpscaleIfSmaller 表示小图也放大填充页面;大图仍会缩小以适配页面。
  • 这段是概念示例,不是官方 snippet;session 没有给出完整 Swift 签名。

用 PDFPageOverlayViewProvider 安装交互式视图

05:47)overlay view 的入口是 PDFPageOverlayViewProvider。最重要的方法是 overlayViewFor page。PDFKit 需要显示某页时,会调用这个方法请求视图。

class Coordinator: NSObject, PDFPageOverlayViewProvider {

    var pageToViewMapping = [PDFPage: UIView]()
   
    func pdfView(_ view: PDFView, overlayViewFor page: PDFPage) -> UIView? {
        var resultView: PKCanvasView? = nil
        
        if let overlayView = pageToViewMapping[page] {
            resultView = overlayView
        } else {
            let canvasView = PKCanvasView(frame: .zero)
            canvasView.drawingPolicy = .anyInput
            canvasView.tool = PKInkingTool(.pen, color: .systemYellow, width: 20)
            canvasView.backgroundColor = UIColor.clear
            pageToViewMapping[page] = canvasView
            resultView = canvasView
        }
        
        // If we have stored a drawing on the page, set it on the canvas
        let page = page as! MyPDFPage
        if let drawing = page.drawing {
            resultView?.drawing = drawing;
        }
        return resultView
    }

    func pdfView(_ pdfView: PDFView, willEndDisplayingOverlayView
        overlayView: UIView, for page: PDFPage) {
        let overlayView = overlayView as! PKCanvasView
        let page = page as! MyPDFPage
        page.drawing = overlayView.drawing
        pageToViewMapping.removeValue(forKey: page)
    }
}

关键点:

  • Coordinator 实现 PDFPageOverlayViewProvider,它是 PDFKit 请求 overlay view 的对象。
  • pageToViewMappingPDFPage 映射到已经创建的 UIView,避免同一页重复创建 canvas。
  • overlayViewFor page 先查缓存,没有缓存时创建新的 PKCanvasView
  • drawingPolicy = .anyInput 允许 Pencil 或手指输入。
  • PKInkingTool(.pen, color: .systemYellow, width: 20) 设置示例中的黄色画笔。
  • backgroundColor = UIColor.clear 让 PDF 页面内容透出来。
  • MyPDFPage 是示例里的 PDFPage 子类,只增加了一个 drawing 属性。
  • willEndDisplayingOverlayView 在页面滚出视野时调用,把当前 PKCanvasView.drawing 存回页面,并移除缓存视图。

把 PencilKit 绘图保存成 PDF annotation

09:28)overlay view 只解决屏幕上的交互。保存文件时,还要把绘图写回 PDF。

PDF annotation 有两个对这件事有用的特性。第一,它可以有 appearance stream,也就是 PDF 绘制命令流,用来保证在 Adobe Reader、Chrome 等阅读器里显示一致。第二,它以字典形式存储,可以放私有 key/value 数据,供下次打开时恢复编辑状态。

// Implement a subclass with a drawing override

class MyPDFAnnotation: PDFAnnotation {
    
    override func draw(with box: PDFDisplayBox, in context: CGContext) {
        UIGraphicsPushContext(context)
        context.saveGState()
        
        let page = self.page as! MyPDFPage
        if let drawing = page.drawing {
            let image = drawing.image(from: drawing.bounds, scale: 1)
            image.draw(in: drawing.bounds)
        }
        
        context.restoreGState()
        UIGraphicsPopContext()
    }
}

关键点:

  • MyPDFAnnotation 继承 PDFAnnotation,目的是重写绘制行为。
  • draw(with:in:) 会在 PDFKit 保存 annotation 的 appearance stream 时被调用。
  • UIGraphicsPushContext(context) 把 Core Graphics context 接到 UIKit 绘制环境。
  • context.saveGState()restoreGState() 包住自定义绘制,避免污染外层绘制状态。
  • self.page as! MyPDFPage 取回带 drawing 属性的自定义页面。
  • drawing.image(from:scale:)PKDrawing 渲染成图片。
  • image.draw(in:) 把图片画进 annotation 的 bounds。

保存 annotation,并选择是否 burn in

10:35)示例在 UIDocumentcontents(forType:) 中遍历所有页面。每个有绘图的页面都会获得一个自定义 annotation。绘图本身用 NSKeyedArchiver 编码后,写入 annotation 的私有 key。

override func contents(forType typeName: String) throws -> Any {
        
    if let pdfDocument = pdfDocument {
      
        // Go through all pages in the document
        for i in 0...pdfDocument.pageCount-1 {
            if let page = pdfDocument.page(at: i) {                       
                if let drawing = (page as! MyPDFPage).drawing {
                        
                    // Create an annotation of our custom subclass
                    let newAnnotation = MyPDFAnnotation(bounds: drawing.bounds,
                                                        forType: .stamp, withProperties: nil)
                        
                    // Add our custom data
                    let codedData = try! NSKeyedArchiver.archivedData(withRootObject: drawing,
                                                                      requiringSecureCoding: true)
                    newAnnotation.setValue(codedData,
                                           forAnnotationKey: PDFAnnotationKey(rawValue: "drawingData"))
                        
                    // Add our annotation to the page
                    page.addAnnotation(newAnnotation)
                }
            }
        }

        // -- Option #1: Save the document to a data representation
        if let resultData = pdfDocument.dataRepresentation() {
            return resultData
        }
      
        // -- Option #2: Save the document to a data representation and "burn in" annotations
        let options = [PDFDocumentWriteOption.burnInAnnotationsOption: true]
        if let resultData = pdfDocument.dataRepresentation(options: options) {
            return resultData
        }
    }
                
    // Fall through to returning empty data
    return Data()
}

关键点:

  • contents(forType:)UIDocument 写出内容的位置。
  • 0...pdfDocument.pageCount-1 遍历所有 PDF 页面。
  • (page as! MyPDFPage).drawing 只处理已经有 PencilKit 绘图的页面。
  • MyPDFAnnotation(bounds:forType:withProperties:) 创建自定义 annotation,示例类型是 .stamp
  • NSKeyedArchiver.archivedDataPKDrawing 编码成数据,供下次打开后恢复 round-trip editing。
  • setValue(_:forAnnotationKey:) 用私有 key drawingData 保存绘图数据。
  • page.addAnnotation(newAnnotation) 把 annotation 挂到页面上。
  • dataRepresentation() 返回可写出的 PDF 数据;annotation 仍可被接收方移动、缩放或删除。
  • burnInAnnotationsOption: true 会把 annotation 烧录到页面内容里,适合不希望接收方继续编辑标注的文件。

控制 PDF 写出体积和网络加载

11:50)iOS 16 和 macOS Ventura 还开放了更多 PDF 写出选项。

默认情况下,CoreGraphics 会尽量保存图片质量,使用全分辨率和无损压缩。这适合大幅打印,但对屏幕阅读可能导致文件过大。saveAllImagesAsJPEG 会把图片用 JPEG 编码保存。optimizeImagesForScreen 会把图片降采样到 HiDPI 屏幕分辨率上限。两者可以一起使用。

12:41createLinearizedPDF 用来创建适合网络加载的 linearized PDF。它把显示第一页所需的数据放到文件开头,浏览器可以先显示第一页,再继续加载剩余内容。

// 概念示例,非完整 API 签名:session 说明这些选项传给 dataRepresentation 或 writeToURL。
let options = [
    "saveAllImagesAsJPEG": true,
    "optimizeImagesForScreen": true,
    "createLinearizedPDF": true,
]
let data = pdfDocument.dataRepresentation(options: options)

关键点:

  • saveAllImagesAsJPEG 适合降低包含大量图片的 PDF 文件体积。
  • optimizeImagesForScreen 面向屏幕阅读,会降低过高分辨率图片的成本。
  • createLinearizedPDF 改善网络分发场景下的首屏显示速度。
  • 这些写出选项可以传给 PDFDocumentdataRepresentationwriteToURL
  • 这段是概念示例;session 只给出了 burnInAnnotationsOption 的完整 Swift 写法。

核心启发

  1. 做一个扫描 PDF 的文字检索器

    • 做什么:打开扫描件后,让用户直接搜索其中的文字。
    • 为什么值得做:PDFKit 对无文本层扫描 PDF 提供按需 Live Text,不需要 App 自己在打开时 OCR 全文档。
    • 怎么开始:用 PDFView 加载 PDFDocument,把交互入口放在页面视图上,保存时提供保存识别文本的路径。
  2. 做一个图片转 PDF 的归档工具

    • 做什么:把拍照或导入的图片整理成固定纸张尺寸的 PDF。
    • 为什么值得做:新 API 直接吃 CGImageRef,PDFKit 负责 JPEG 压缩,并提供页面尺寸、方向、小图放大等选项。
    • 怎么开始:把扫描图片保留为 CGImage,为每页设置 MediaBox、Rotation 和 UpscaleIfSmaller,再组合成 PDFDocument
  3. 做一个带 Apple Pencil 标注的 PDF 阅读器

    • 做什么:用户翻阅 PDF 时可以直接在页面上手写批注。
    • 为什么值得做PDFPageOverlayViewProvider 允许每页叠加 PKCanvasView,PDFKit 负责按需创建、布局和旋转。
    • 怎么开始:实现 overlayViewFor page 返回 PKCanvasView,在 willEndDisplayingOverlayView 中把 PKDrawing 存回自定义 PDFPage
  4. 做一个可继续编辑的 PDF 批注保存流程

    • 做什么:保存手写标注后,下次打开还能继续编辑。
    • 为什么值得做:PDF annotation 能保存 appearance stream,也能用私有 key/value 存原始 PKDrawing 数据。
    • 怎么开始:子类化 PDFAnnotation 重写 draw(with:in:),保存时把 PKDrawing 编码到 drawingData,打开时再读回 overlay view。
  5. 做一个适合网页分发的 PDF 导出选项

    • 做什么:给用户提供“小文件”“适合屏幕”“适合网页预览”等导出开关。
    • 为什么值得做:PDFKit 的 JPEG、屏幕优化和 linearized PDF 写出选项分别对应体积、屏幕阅读和首屏加载。
    • 怎么开始:在导出面板里把这些选项映射到 PDFDocumentdataRepresentationwriteToURL 写出参数。

关联 Session

评论

GitHub Issues · utterances