PDFKit on macOS: Document Ownership, Search, Selections, and Annotations
Integrate PDFKit with explicit document ownership, truthful text search, page/view coordinate conversion, annotation persistence, and large-file testing.
PDFKit gives AppKit applications a document model, page abstractions, text selection and search, annotations, and a ready-made PDFView. It is a document framework, not a promise that every PDF has well-structured searchable text or that every annotation is a permanent edit to page content. Treat the source document, transient viewer state, and saved annotation data as separate concerns.
For a standard reader, start with PDFView and its associated PDFDocument. It supports display modes, zoom, navigation, selection, and printing without requiring the app to implement page rendering from scratch. A custom viewer should be justified by a specific interaction or rendering requirement; otherwise it takes ownership of coordinate conversion, page reuse, accessibility, scrolling, selection, and annotation behavior that the framework already handles.
Establish one document owner
Create the PDFDocument from a URL or data source and keep it under the document model or controller that owns the file lifecycle. Set the view’s document from that owner, and ensure the view is detached or updated when the document closes or changes. A stale PDFView retaining an old document can continue displaying content after the app has moved to a replacement file.
import AppKit
import PDFKit
@MainActor
func makePDFView(for url: URL) -> PDFView? {
guard let document = PDFDocument(url: url) else { return nil }
let view = PDFView(frame: .zero)
view.autoScales = true
view.displayMode = .singlePageContinuous
view.document = document
return view
}
This creates a viewer, not an NSDocument subclass or a complete import workflow. Validate file access and coordinate security-scoped URL access when a document picker provides a URL that requires it. For editable app-owned files, route reading and writing through the document architecture that owns saving, conflict handling, and version behavior rather than saving directly from an arbitrary view callback.
Check isLocked and handle password unlock as a state transition. A failed unlock should not leave a partially configured document presented as readable. Handle a nil page or document as normal input failure, and do not assume pageCount is positive for every malformed or empty file.
Search depends on the PDF’s text layer
PDFDocument.findString(_:withOptions:) returns selections for searchable text. That does not perform OCR on a scanned page that contains only pixels. A document can look perfectly readable while returning no search results because its text layer is absent, poorly encoded, or has an unexpected reading order. If OCR is part of the product, treat Vision output as a separate derived index with page identity and coordinate mapping; do not claim that PDFKit itself recognized the image.
Search results should be tied to the document revision. A PDFSelection can span one or more pages and can expose a string and page bounds. To navigate, set the current selection and scroll it into view. If the document is replaced or edited, discard old selection objects and recompute them against the new document. Do not persist a selection object as a durable bookmark; persist a stable page reference and a normalized position or text context suitable for re-resolution.
PDF text extraction order is not guaranteed to match visual reading order in complex layouts. Columns, tables, ligatures, and embedded fonts can yield surprising strings. Make search results useful by showing a page context and verifying the destination visually. Avoid using extracted text as an authoritative source for financial, legal, or safety-critical decisions without a separate validation policy.
Page space and view space are different
PDFPage geometry is in page space; the visible PDFView applies scaling, rotation, display-box selection, and scrolling. Use the framework’s conversion methods when mapping a pointer or annotation between view and page coordinates. Never multiply coordinates by scaleFactor alone: that ignores page origin, rotation, crop box, and view placement.
When an annotation is clicked or created, resolve the page under the pointer, convert the point or rectangle to page space, and store the annotation in that page’s coordinate system. If the view uses a different display box than the page’s media box, account for that consistently. Test rotated pages, crop boxes, zoom, continuous scrolling, and pages with nonzero origins.
func pagePoint(for pointInView: CGPoint, in view: PDFView) -> (PDFPage, CGPoint)? {
guard let page = view.page(for: pointInView, nearest: false) else { return nil }
return (page, view.convert(pointInView, to: page))
}
The returned point is meaningful only with its associated page. Do not retain a page-space point and later apply it to a different page or a different document revision.
Annotations are not always flattened content
PDF annotations represent interactive objects such as links, highlights, notes, shapes, and form widgets. They can be added to a PDFPage, changed, and saved as part of the PDF, but an annotation is structurally distinct from the page’s original content stream. Flattening or exporting can change that relationship and should be an explicit product operation with a backup or undo strategy.
When editing annotations, keep stable app-level IDs if the product synchronizes or tracks changes. Define how duplicate imported annotations are handled, which user owns each change, how annotation edits affect document dirty state, and whether unsupported annotation types are preserved. A PDF round trip should not silently discard metadata the product does not understand.
Link annotations require care because a visible URL may be malicious or misleading. Present destinations in a way users can inspect and route opening through the app’s normal URL policy. Do not assume that text printed on a page is the same as an annotation’s target. For form widgets, distinguish the field’s current value from its default and define save/export behavior.
Search, navigation, and viewer state
Track current page, zoom, display mode, visible pages, and selection as viewer state. These may be restored when reopening the same document, but they do not belong inside the PDF’s authored content unless the user chooses to save a destination, bookmark, or annotation. Page indices are zero-based in many APIs, while page labels shown to users can be arbitrary strings; do not show a raw array index as a printed page number without checking label.
Keep navigation state coherent when a search result targets a page that is not yet visible. Set the selection, navigate to its page, and scroll to the selection using PDFKit rather than computing a guessed scroll offset. Observe document and page changes where required, and unregister notification observers when the owning controller is released.
Failure modes and performance
Large documents can consume substantial memory, particularly when many high-resolution pages are simultaneously visible or when the app creates duplicate document instances. Reuse the owned document and let PDFView manage visible rendering where possible. Avoid rasterizing every page at full resolution merely to make a thumbnail strip. Use a thumbnail view or a bounded preview cache and key cached output by document revision, page, display box, and target size.
Treat malformed, encrypted, truncated, and unsupported PDFs as expected inputs. Check errors at open and save boundaries, keep the original file until a replacement is safely written, and report page-level failures without crashing the entire app when possible. If a PDF is supplied by another process, read the actual coordinated or security-scoped URL according to the acquisition mechanism rather than assuming a path remains valid forever.
Acceptance checks
Test searchable text and scanned-image PDFs separately; password-protected and empty files; multi-page selections; rotated pages; annotation save/reopen; link activation; display-mode changes; large zoom; and replacement of a document while search is still running. Confirm every selection belongs to the active document and each annotation reopens at the same page-space location.
Measure first page display, search time, memory at several zoom levels, thumbnail cache size, and save duration on representative files. Keep diagnostics free of extracted private text; log document revision, page count, file size, operation duration, and error class instead. Verify that closing a document releases observers, tasks, cached pages, and view references.
PDFKit covers the difficult mechanics of document display and manipulation, but applications still own persistence policy, stale-result handling, semantic accuracy, and the distinction between visible page pixels and searchable document structure.
Related:
- Core Text on macOS: Typesetting, Frames, Glyph Runs, and Hit Testing
- Quick Look Thumbnailing on macOS: Requests, Extensions, and Cache Boundaries
Sources: