# PDFKit patterns

Longer recipes that build on the main skill. Code targets Swift 6.3 and iOS 26;
functions that touch views are `@MainActor`.

## Contents

- [Forms](#forms)
- [Building PDFs in code](#building-pdfs-in-code)
- [Watermarks](#watermarks)
- [Merging and splitting](#merging-and-splitting)
- [Printing](#printing)
- [Outlines](#outlines)
- [Custom annotation classes](#custom-annotation-classes)
- [Custom page classes](#custom-page-classes)
- [Overlay lifecycle](#overlay-lifecycle)
- [Rendering a page to an image](#rendering-a-page-to-an-image)
- [Rotation and cropping](#rotation-and-cropping)
- [Burning in annotations](#burning-in-annotations)
- [Permissions](#permissions)
- [Coordinator-based SwiftUI wrapper](#coordinator-based-swiftui-wrapper)

## Forms

Interactive PDF fields are widget annotations. Each one has a `fieldName`, a
`widgetFieldType` and, for text fields, a `widgetStringValue`.

### Reading and filling

```swift
import PDFKit

func textFieldValues(in form: PDFDocument) -> [String: String] {
    var values: [String: String] = [:]
    for index in 0..<form.pageCount {
        guard let page = form.page(at: index) else { continue }
        for field in page.annotations where field.widgetFieldType == .text {
            if let name = field.fieldName {
                values[name] = field.widgetStringValue ?? ""
            }
        }
    }
    return values
}

func allFields(in form: PDFDocument) -> [PDFAnnotation] {
    (0..<form.pageCount).compactMap { form.page(at: $0) }.flatMap(\.annotations)
}

func setText(_ value: String, field name: String, in form: PDFDocument) {
    for field in allFields(in: form) where field.widgetFieldType == .text && field.fieldName == name {
        field.widgetStringValue = value
    }
}

func setCheckbox(_ name: String, checked: Bool, in form: PDFDocument) {
    for field in allFields(in: form)
    where field.widgetFieldType == .button
        && field.widgetControlType == .checkBoxControl
        && field.fieldName == name {
        field.buttonWidgetState = checked ? .onState : .offState
    }
}
```

### Creating fields

```swift
import PDFKit
import UIKit

func registrationForm() -> PDFDocument {
    let sheet = PDFPage()
    sheet.setBounds(CGRect(x: 0, y: 0, width: 612, height: 792), for: .mediaBox)

    let fullName = PDFAnnotation(bounds: CGRect(x: 72, y: 690, width: 300, height: 24), forType: .widget, withProperties: nil)
    fullName.widgetFieldType = .text
    fullName.fieldName = "fullName"
    fullName.widgetStringValue = ""
    fullName.font = .systemFont(ofSize: 13)
    fullName.backgroundColor = UIColor.systemGray6
    sheet.addAnnotation(fullName)

    let newsletter = PDFAnnotation(bounds: CGRect(x: 72, y: 650, width: 18, height: 18), forType: .widget, withProperties: nil)
    newsletter.widgetFieldType = .button
    newsletter.widgetControlType = .checkBoxControl
    newsletter.fieldName = "newsletter"
    newsletter.buttonWidgetState = .offState
    sheet.addAnnotation(newsletter)

    for (offset, size) in ["S", "M", "L"].enumerated() {
        let option = PDFAnnotation(bounds: CGRect(x: 72 + offset * 40, y: 610, width: 18, height: 18), forType: .widget, withProperties: nil)
        option.widgetFieldType = .button
        option.widgetControlType = .radioButtonControl
        option.fieldName = "shirtSize"
        option.buttonWidgetStateString = size
        sheet.addAnnotation(option)
    }

    let country = PDFAnnotation(bounds: CGRect(x: 72, y: 560, width: 200, height: 24), forType: .widget, withProperties: nil)
    country.widgetFieldType = .choice
    country.fieldName = "country"
    country.choices = ["Canada", "Chile", "Japan"]
    country.isListChoice = false
    sheet.addAnnotation(country)

    let form = PDFDocument()
    form.insert(sheet, at: 0)
    return form
}
```

| Field | `widgetFieldType` | Extra settings |
|---|---|---|
| text box | `.text` | `widgetStringValue`, `font`, `backgroundColor` |
| checkbox | `.button` | `widgetControlType = .checkBoxControl`, start at `.offState` |
| radio button | `.button` | `.radioButtonControl`; `fieldName` is the group, `buttonWidgetStateString` the value |
| dropdown | `.choice` | `choices`, `isListChoice = false` |
| list box | `.choice` | `choices`, `isListChoice = true` |

A US Letter page is 612 by 792 points; set it with `setBounds(_:for: .mediaBox)`.

## Building PDFs in code

### From images

```swift
import PDFKit
import UIKit

func album(from photos: [UIImage]) -> PDFDocument {
    let book = PDFDocument()
    for (slot, photo) in photos.enumerated() {
        if let page = PDFPage(image: photo) {
            book.insert(page, at: slot)
        }
    }
    return book
}

func compactPage(from photo: UIImage) -> PDFPage? {
    PDFPage(image: photo, options: [
        .compressionQuality: 0.7,
        .mediaBox: NSValue(cgRect: CGRect(x: 0, y: 0, width: 595, height: 842))
    ])
}
```

`PDFPage(image:)` is failable. `PDFPage(image:options:)` accepts
`PDFPage.ImageInitializationOption` keys such as `.compressionQuality` and
`.mediaBox`.

### With full layout control

```swift
import PDFKit
import UIKit

func receiptPDF(lines: [String]) -> PDFDocument? {
    let paper = CGRect(x: 0, y: 0, width: 420, height: 595)
    let bytes = UIGraphicsPDFRenderer(bounds: paper).pdfData { ctx in
        ctx.beginPage()
        let style: [NSAttributedString.Key: Any] = [.font: UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)]
        for (row, text) in lines.enumerated() {
            text.draw(at: CGPoint(x: 32, y: 40 + CGFloat(row) * 18), withAttributes: style)
        }
        let rule = ctx.cgContext
        rule.move(to: CGPoint(x: 32, y: 30))
        rule.addLine(to: CGPoint(x: paper.width - 32, y: 30))
        rule.strokePath()
    }
    return PDFDocument(data: bytes)
}
```

`UIGraphicsPDFRenderer(bounds:)` plus `pdfData { $0.beginPage() ... }` gives a
normal UIKit drawing context; load the `Data` back with `PDFDocument(data:)`.

## Watermarks

Subclass `PDFPage`, override `draw(with:to:)`, and call `super` first so the
original content is underneath.

```swift
import PDFKit
import UIKit

final class ConfidentialPage: PDFPage {
    override func draw(with box: PDFDisplayBox, to context: CGContext) {
        super.draw(with: box, to: context)
        let area = bounds(for: box)
        UIGraphicsPushContext(context)
        context.saveGState()
        context.translateBy(x: area.midX, y: area.midY)
        context.rotate(by: -.pi / 4)
        let stamp = NSAttributedString(string: "CONFIDENTIAL", attributes: [
            .font: UIFont.systemFont(ofSize: 60, weight: .heavy),
            .foregroundColor: UIColor.systemRed.withAlphaComponent(0.15)
        ])
        let size = stamp.size()
        stamp.draw(at: CGPoint(x: -size.width / 2, y: -size.height / 2))
        context.restoreGState()
        UIGraphicsPopContext()
    }
}

final class LogoPage: PDFPage {
    static let logo = UIImage(systemName: "seal")?.cgImage

    override func draw(with box: PDFDisplayBox, to context: CGContext) {
        super.draw(with: box, to: context)
        guard let mark = Self.logo else { return }
        let area = bounds(for: box)
        context.saveGState()
        context.setAlpha(0.1)
        context.draw(mark, in: CGRect(x: area.midX - 100, y: area.midY - 100, width: 200, height: 200))
        context.restoreGState()
    }
}

final class WatermarkSource: NSObject, PDFDocumentDelegate {
    func classForPage() -> AnyClass {
        ConfidentialPage.self
    }
}
```

The document asks its delegate which page class to instantiate through
`classForPage()`. Set `document.delegate` before the document is handed to a
`PDFView`, and keep the delegate object alive.

## Merging and splitting

```swift
import PDFKit

func combine(_ parts: [PDFDocument]) -> PDFDocument {
    let merged = PDFDocument()
    var next = 0
    for part in parts {
        for index in 0..<part.pageCount {
            if let page = part.page(at: index) {
                merged.insert(page, at: next)
                next += 1
            }
        }
    }
    return merged
}

func excerpt(_ span: ClosedRange<Int>, of source: PDFDocument) -> PDFDocument {
    let piece = PDFDocument()
    for index in span where index >= 0 && index < source.pageCount {
        if let page = source.page(at: index) {
            piece.insert(page, at: piece.pageCount)
        }
    }
    return piece
}

func chunks(of source: PDFDocument, size: Int) -> [PDFDocument] {
    var result: [PDFDocument] = []
    var current = PDFDocument()
    for index in 0..<source.pageCount {
        if let page = source.page(at: index) {
            current.insert(page, at: current.pageCount)
        }
        if current.pageCount == size {
            result.append(current)
            current = PDFDocument()
        }
    }
    if current.pageCount > 0 { result.append(current) }
    return result
}
```

## Printing

```swift
import PDFKit
import UIKit

@MainActor
func printDocument(_ file: PDFDocument, title: String) {
    let job = UIPrintInfo(dictionary: nil)
    job.outputType = .general
    job.jobName = title
    let printer = UIPrintInteractionController.shared
    printer.printInfo = job
    printer.printingItem = file.dataRepresentation()
    printer.present(animated: true)
}
```

To print only some pages, build a smaller document with `excerpt` above and
print that.

## Outlines

`PDFOutline` is the table of contents (the bookmarks sidebar in most readers).

```swift
import PDFKit

func titles(under node: PDFOutline, depth: Int = 0) -> [String] {
    var found: [String] = []
    for index in 0..<node.numberOfChildren {
        guard let child = node.child(at: index) else { continue }
        found.append(String(repeating: "  ", count: depth) + (child.label ?? ""))
        found += titles(under: child, depth: depth + 1)
    }
    return found
}

func addChapters(_ names: [String], startPages: [Int], to file: PDFDocument) {
    let root = PDFOutline()
    for (slot, name) in names.enumerated() {
        guard slot < startPages.count, let page = file.page(at: startPages[slot]) else { continue }
        let chapter = PDFOutline()
        chapter.label = name
        chapter.destination = PDFDestination(page: page, at: .zero)
        root.insertChild(chapter, at: slot)
    }
    file.outlineRoot = root
}

@MainActor
func open(_ entry: PDFOutline, in reader: PDFView) {
    if let target = entry.destination {
        reader.go(to: target)
    } else if let action = entry.action {
        reader.perform(action)
    }
}
```

Start reading from `document.outlineRoot`.

## Custom annotation classes

Annotations draw with `draw(with:in:)`; pages use `draw(with:to:)`. Mind the
different label.

```swift
import PDFKit
import UIKit

final class CircledNote: PDFAnnotation {
    override func draw(with box: PDFDisplayBox, in context: CGContext) {
        super.draw(with: box, in: context)
        UIGraphicsPushContext(context)
        let ring = UIBezierPath(ovalIn: bounds.insetBy(dx: 3, dy: 3))
        ring.lineWidth = 2
        UIColor.systemPurple.setStroke()
        ring.stroke()
        let tag = NSAttributedString(string: contents ?? "", attributes: [.font: UIFont.systemFont(ofSize: 10)])
        let size = tag.size()
        tag.draw(at: CGPoint(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2))
        UIGraphicsPopContext()
    }
}

final class AnnotationFactory: NSObject, PDFDocumentDelegate {
    func `class`(forAnnotationType annotationType: String) -> AnyClass {
        annotationType == "Circle" ? CircledNote.self : PDFAnnotation.self
    }
}
```

A document asks `class(forAnnotationType:)` which class to create for each
annotation it reads; return `PDFAnnotation.self` for anything you do not
customise.

## Custom page classes

Headers, footers and borders work the same way as watermarks. Remember that
y = 0 is the bottom edge.

```swift
import PDFKit
import UIKit

final class NumberedPage: PDFPage {
    override func draw(with box: PDFDisplayBox, to context: CGContext) {
        super.draw(with: box, to: context)
        let area = bounds(for: box)
        let small: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 9)]
        UIGraphicsPushContext(context)
        context.saveGState()
        context.translateBy(x: 0, y: area.height)
        context.scaleBy(x: 1, y: -1)
        ("Quarterly report" as NSString).draw(at: CGPoint(x: 40, y: 18), withAttributes: small)
        ("Page \(pageRef?.pageNumber ?? 0)" as NSString).draw(at: CGPoint(x: 40, y: area.height - 30), withAttributes: small)
        context.restoreGState()
        UIGraphicsPopContext()
    }
}
```

In unflipped PDF space the header sits near `area.maxY - 30` and the footer
near y = 20. The example flips the context so UIKit text draws upright, which
turns those into a small y for the header and `height - 30` for the footer.

## Overlay lifecycle

- Keep the `PDFPageOverlayViewProvider` on something that outlives the
  `PDFView`: a view controller, a SwiftUI coordinator or a model object.
- PDFKit asks for overlays as pages are prepared for display and tells the
  provider when they leave the visible range. Keep editing state outside the
  views so scrolling away and back does not lose it.
- `pdfView(_:willEndDisplayingOverlayView:for:)` is where to capture what the
  user did, for example into a dictionary keyed by `PDFPage`.
- Overlay views are UIKit (or AppKit) views, not PDF content. `write(to:)` and
  `dataRepresentation()` ignore them.
- Before saving, turn overlay content into real PDF annotations, annotations
  with custom appearance streams, or drawn page content. Add
  `.burnInAnnotationsOption` when the result must not be editable.

```swift
import PDFKit
import UIKit

@MainActor
final class SignatureOverlays: NSObject, @preconcurrency PDFPageOverlayViewProvider {
    private var savedStrokes: [PDFPage: [CGPoint]] = [:]

    func pdfView(_ view: PDFView, overlayViewFor page: PDFPage) -> UIView? {
        let pad = StrokePad()
        pad.points = savedStrokes[page] ?? []
        return pad
    }

    func pdfView(_ pdfView: PDFView, willEndDisplayingOverlayView overlayView: UIView, for page: PDFPage) {
        if let pad = overlayView as? StrokePad {
            savedStrokes[page] = pad.points
        }
    }
}

final class StrokePad: UIView {
    var points: [CGPoint] = []
}
```

## Rendering a page to an image

```swift
import PDFKit
import UIKit

func snapshot(of page: PDFPage, scale: CGFloat = 2.0) -> UIImage {
    let box = page.bounds(for: .mediaBox)
    let size = CGSize(width: box.width * scale, height: box.height * scale)
    return UIGraphicsImageRenderer(size: size).image { ctx in
        UIColor.white.setFill()
        ctx.fill(CGRect(origin: .zero, size: size))
        let cg = ctx.cgContext
        cg.translateBy(x: 0, y: size.height)
        cg.scaleBy(x: scale, y: -scale)
        page.draw(with: .mediaBox, to: cg)
    }
}
```

`draw(with:to:)` assumes a bottom-left origin, so the context is flipped first.
`thumbnail(of:for:)` does that flip for you and is the simpler choice when a
preview-sized image is enough.

## Rotation and cropping

```swift
import PDFKit

func straighten(_ page: PDFPage) {
    page.rotation = (page.rotation + 90) % 360
    let media = page.bounds(for: .mediaBox)
    page.setBounds(media.insetBy(dx: 36, dy: 36), for: .cropBox)
}
```

`rotation` accepts only 0, 90, 180 or 270. `setBounds(_:for: .cropBox)` trims
the visible area.

## Burning in annotations

```swift
import PDFKit

func flatten(_ file: PDFDocument, to url: URL) -> Bool {
    file.write(to: url, withOptions: [.burnInAnnotationsOption: true])
}
```

The option is iOS 16+ and the call returns `Bool`. Afterwards the marks are part of the page drawing and
can no longer be selected, edited or removed as annotations.

## Permissions

```swift
import PDFKit

func describeAccess(_ file: PDFDocument) -> [String] {
    var notes: [String] = []
    switch file.permissionsStatus {
    case .none: notes.append("opened without a password")
    case .user: notes.append("opened with the user password")
    case .owner: notes.append("opened with the owner password")
    @unknown default: break
    }
    if !file.allowsCopying { notes.append("copying blocked") }
    if !file.allowsPrinting { notes.append("printing blocked") }
    if !file.allowsCommenting { notes.append("comments blocked") }
    if !file.allowsFormFieldEntry { notes.append("form entry blocked") }
    if !file.allowsDocumentAssembly { notes.append("page assembly blocked") }
    if !file.allowsDocumentChanges { notes.append("edits blocked") }
    if !file.allowsContentAccessibility { notes.append("accessibility extraction blocked") }
    return notes
}

func lockDown(_ file: PDFDocument, to url: URL) -> Bool {
    let allowed = PDFAccessPermissions.allowsLowQualityPrinting.rawValue
        | PDFAccessPermissions.allowsCommenting.rawValue
    return file.write(to: url, withOptions: [
        .ownerPasswordOption: "archive-owner",
        .userPasswordOption: "",
        .accessPermissionsOption: allowed
    ])
}
```

`permissionsStatus` is `.none`, `.user` or `.owner`. To restrict a saved file,
pass `PDFAccessPermissions` flags (for example `.allowsLowQualityPrinting`,
`.allowsContentCopying`, `.allowsCommenting`) as the value of
`.accessPermissionsOption` (iOS 15+), together with the password options.
In Swift `PDFAccessPermissions` imports as a plain enum, not an `OptionSet`,
so combine flags by OR-ing their `rawValue`s. Some flags imply others:
content copying includes accessibility extraction, commenting includes form
entry, and document changes include both.

## Coordinator-based SwiftUI wrapper

```swift
import PDFKit
import SwiftUI
import UIKit

struct TrackedPDFView: UIViewRepresentable {
    let document: PDFDocument
    @Binding var pageIndex: Int
    var onAnnotationTap: (PDFAnnotation) -> Void = { _ in }

    func makeCoordinator() -> Coordinator {
        Coordinator(owner: self)
    }

    func makeUIView(context: Context) -> PDFView {
        let reader = PDFView()
        reader.autoScales = true
        reader.document = document
        reader.delegate = context.coordinator
        context.coordinator.attach(to: reader)
        return reader
    }

    func updateUIView(_ reader: PDFView, context: Context) {
        context.coordinator.owner = self
        if reader.document !== document {
            reader.document = document
        }
        guard pageIndex >= 0, pageIndex < document.pageCount,
              let wanted = document.page(at: pageIndex) else { return }
        if reader.currentPage !== wanted {
            reader.go(to: wanted)
        }
    }

    static func dismantleUIView(_ reader: PDFView, coordinator: Coordinator) {
        NotificationCenter.default.removeObserver(coordinator)
    }

    @MainActor
    final class Coordinator: NSObject, @preconcurrency PDFViewDelegate {
        var owner: TrackedPDFView

        init(owner: TrackedPDFView) {
            self.owner = owner
        }

        deinit {
            NotificationCenter.default.removeObserver(self)
        }

        func attach(to reader: PDFView) {
            let center = NotificationCenter.default
            center.removeObserver(self)
            center.addObserver(self, selector: #selector(pageDidChange(_:)), name: .PDFViewPageChanged, object: reader)
            center.addObserver(self, selector: #selector(annotationWasHit(_:)), name: .PDFViewAnnotationHit, object: reader)
        }

        @objc private func pageDidChange(_ note: Notification) {
            guard let reader = note.object as? PDFView,
                  let page = reader.currentPage,
                  let file = reader.document else { return }
            let index = file.index(for: page)
            if index != owner.pageIndex {
                owner.pageIndex = index
            }
        }

        @objc private func annotationWasHit(_ note: Notification) {
            if let tapped = note.userInfo?["PDFAnnotationHit"] as? PDFAnnotation {
                owner.onAnnotationTap(tapped)
            }
        }

        func pdfViewWillClick(onLink sender: PDFView, with url: URL) {
            UIApplication.shared.open(url)
        }
    }
}
```

- `updateUIView` swaps the document only when `!==` says it changed, and
  navigates only when the requested index is valid and differs from
  `currentPage`.
- The page handler writes to the binding only on a real change, which avoids
  an update loop between SwiftUI and the view.
- `dismantleUIView(_:coordinator:)` and the coordinator's `deinit` both remove
  the notification observers; `attach` clears old ones before adding.
- `.PDFViewAnnotationHit` carries the tapped annotation under the
  `"PDFAnnotationHit"` user-info key.
- `pdfViewWillClick(onLink:with:)` lets the app decide what a link tap does.
