CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

71

Quality

89%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

paperkit-patterns.mdskills/paperkit/references/

PaperKit Patterns

Beta-sensitive. PaperKit is new in iOS/iPadOS 26, macOS 26, and visionOS 26. Verify all patterns against current Apple documentation before shipping.

Extended patterns, data persistence strategies, rendering, multi-platform considerations, and advanced FeatureSet usage for PaperKit.

Contents

Data Persistence

File-Based Save/Load

PaperMarkup.dataRepresentation() is async. Always call from an async context and handle errors.

import PaperKit

actor MarkupStore {
    private let fileURL: URL

    init(directory: URL, filename: String = "markup.paperkit") {
        self.fileURL = directory.appendingPathComponent(filename)
    }

    func save(_ markup: PaperMarkup) async throws {
        let data = try await markup.dataRepresentation()
        try data.write(to: fileURL, options: .atomic)
    }

    func load() throws -> PaperMarkup {
        let data = try Data(contentsOf: fileURL)
        return try PaperMarkup(dataRepresentation: data)
    }

    func loadOrCreate(bounds: CGRect) -> PaperMarkup {
        do {
            return try load()
        } catch {
            return PaperMarkup(bounds: bounds)
        }
    }
}

Save Alongside Thumbnail

Store a rendered thumbnail next to the data file for use in file browsers or version-mismatch fallback. This is the pattern used by Notes.

func saveWithThumbnail(
    _ markup: PaperMarkup,
    dataURL: URL,
    thumbnailURL: URL,
    thumbnailSize: CGSize
) async throws {
    // Save data
    let data = try await markup.dataRepresentation()
    try data.write(to: dataURL, options: .atomic)

    // Render and save thumbnail
    let renderer = UIGraphicsImageRenderer(size: thumbnailSize)
    let options = RenderingOptions(traitCollection: .current)

    let image = renderer.image { ctx in
        let frame = CGRect(origin: .zero, size: thumbnailSize)
        Task {
            await markup.draw(in: ctx.cgContext, frame: frame, options: options)
        }
    }

    if let pngData = image.pngData() {
        try pngData.write(to: thumbnailURL, options: .atomic)
    }
}

Observation-Based Auto-Save

PaperMarkupViewController conforms to Observable. Use Observation framework tracking or the delegate for auto-save.

Using Observations (from WWDC25 session)

let markups = Observations.untilFinished { [weak paperVC] in
    if let markup = paperVC?.markup {
        return .next(markup)
    }
    return .finish
}

Task { [weak self] in
    for await newMarkup in markups {
        try? await self?.store.save(newMarkup)
    }
}

Using Delegate with Debouncing

Avoid saving on every stroke. Debounce saves to reduce disk I/O:

class MarkupViewController: UIViewController, PaperMarkupViewController.Delegate {
    var paperVC: PaperMarkupViewController!
    private var saveTask: Task<Void, Never>?

    func paperMarkupViewControllerDidChangeMarkup(
        _ controller: PaperMarkupViewController
    ) {
        saveTask?.cancel()
        saveTask = Task {
            try? await Task.sleep(for: .seconds(1))
            guard !Task.isCancelled else { return }
            guard let markup = controller.markup else { return }
            try? await store.save(markup)
        }
    }
}

Forwards Compatibility and Thumbnails

When loading markup data created by a newer version of the app or OS, the data may contain features unsupported by the current FeatureSet. Handle this gracefully.

Check on Load

func loadAndValidate(
    from url: URL,
    supportedFeatures: FeatureSet
) throws -> LoadResult {
    let data = try Data(contentsOf: url)
    let markup = try PaperMarkup(dataRepresentation: data)

    if markup.featureSet.isSubset(of: supportedFeatures) {
        return .editable(markup)
    } else {
        return .readOnly(markup)
    }
}

enum LoadResult {
    case editable(PaperMarkup)
    case readOnly(PaperMarkup)
}

Show Thumbnail for Incompatible Content

If the loaded markup uses features the current app version does not support, show a pre-rendered thumbnail instead of a broken editor. This matches the Notes app behavior.

func handleVersionMismatch(
    markup: PaperMarkup,
    in view: UIImageView,
    size: CGSize
) async {
    let options = RenderingOptions(traitCollection: .current)
    let renderer = UIGraphicsImageRenderer(size: size)

    let thumbnail = renderer.image { ctx in
        let frame = CGRect(origin: .zero, size: size)
        Task {
            await markup.draw(in: ctx.cgContext, frame: frame, options: options)
        }
    }

    view.image = thumbnail
}

Strip Unsupported Content

Alternatively, remove unsupported elements and allow editing of the rest:

var markup = try PaperMarkup(dataRepresentation: data)
markup.removeContentUnsupported(by: appFeatureSet)
paperVC.markup = markup

This mutates the model in place, dropping any elements not representable by the given FeatureSet.

Rendering Markup to Images

Basic Rendering

func render(
    markup: PaperMarkup,
    size: CGSize,
    darkMode: Bool = false
) async -> CGImage? {
    let colorSpace = CGColorSpaceCreateDeviceRGB()
    guard let context = CGContext(
        data: nil,
        width: Int(size.width),
        height: Int(size.height),
        bitsPerComponent: 8,
        bytesPerRow: 0,
        space: colorSpace,
        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
    ) else { return nil }

    let options = RenderingOptions(
        darkUserInterfaceStyle: darkMode,
        layoutRightToLeft: false
    )

    await markup.draw(
        in: context,
        frame: CGRect(origin: .zero, size: size),
        options: options
    )

    return context.makeImage()
}

Rendering with Trait Collection

Use the current environment's traits for correct appearance:

let options = RenderingOptions(traitCollection: .current)

Rendering Tight to Content

Use contentsRenderFrame to render only the area that has content:

let contentFrame = markup.contentsRenderFrame
let size = contentFrame.size

// Render just the content area
await markup.draw(
    in: context,
    frame: CGRect(origin: .zero, size: size),
    options: options
)

Multi-Platform Setup

Platform-Conditional Insertion UI

#if os(iOS) || os(visionOS)
import PaperKit

func setupInsertionUI(
    features: FeatureSet,
    markupVC: PaperMarkupViewController
) -> UIViewController {
    let editVC = MarkupEditViewController(
        supportedFeatureSet: features,
        additionalActions: []
    )
    editVC.delegate = markupVC
    return editVC
}
#endif

#if os(macOS)
import PaperKit

func setupInsertionUI(
    features: FeatureSet,
    markupVC: PaperMarkupViewController
) -> NSViewController {
    let toolbar = MarkupToolbarViewController(supportedFeatureSet: features)
    toolbar.delegate = markupVC
    return toolbar
}
#endif

macOS Toolbar Properties

MarkupToolbarViewController exposes additional state:

PropertyTypeDescription
selectedDrawingToolany PKToolActive drawing tool
selectedDrawingToolItemPKToolPickerItemActive tool picker item
selectedIndirectPointerTouchModeTouchModeCurrent pointer mode
indirectPointerTouchModes[TouchMode]Available pointer modes

Full iOS Setup with Tool Picker and Insertion Menu

Complete setup matching the WWDC25 session pattern:

import PaperKit
import PencilKit
import UIKit

class RecipeMarkupViewController: UIViewController, PaperMarkupViewController.Delegate {
    var paperVC: PaperMarkupViewController!
    var toolPicker: PKToolPicker!
    let store: MarkupStore

    init(store: MarkupStore) {
        self.store = store
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()

        let markup = store.loadOrCreate(bounds: view.bounds)
        let features = FeatureSet.latest

        // Markup controller
        paperVC = PaperMarkupViewController(
            markup: markup,
            supportedFeatureSet: features
        )
        paperVC.delegate = self

        addChild(paperVC)
        paperVC.view.frame = view.bounds
        paperVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(paperVC.view)
        paperVC.didMove(toParent: self)

        // Tool picker
        toolPicker = PKToolPicker()
        toolPicker.addObserver(paperVC)
        paperVC.pencilKitResponderState.activeToolPicker = toolPicker
        paperVC.pencilKitResponderState.toolPickerVisibility = .visible

        // Insertion menu button in tool picker accessory
        let insertButton = UIBarButtonItem(
            systemItem: .add,
            primaryAction: UIAction { [weak self] _ in
                self?.presentInsertionMenu()
            }
        )
        toolPicker.accessoryItem = insertButton
    }

    func presentInsertionMenu() {
        let editVC = MarkupEditViewController(
            supportedFeatureSet: paperVC.supportedFeatureSet,
            additionalActions: []
        )
        editVC.delegate = paperVC
        editVC.modalPresentationStyle = .popover
        if let popover = editVC.popoverPresentationController {
            popover.barButtonItem = toolPicker.accessoryItem
        }
        present(editVC, animated: true)
    }

    // MARK: - Delegate

    func paperMarkupViewControllerDidChangeMarkup(
        _ controller: PaperMarkupViewController
    ) {
        guard let markup = controller.markup else { return }
        Task { try? await store.save(markup) }
    }
}

Full macOS Setup with Toolbar

import PaperKit
import PencilKit
import AppKit

class MacMarkupViewController: NSViewController {
    var paperVC: PaperMarkupViewController!
    let features = FeatureSet.latest

    override func viewDidLoad() {
        super.viewDidLoad()

        let markup = PaperMarkup(bounds: view.bounds)
        paperVC = PaperMarkupViewController(
            markup: markup,
            supportedFeatureSet: features
        )

        addChild(paperVC)
        paperVC.view.frame = view.bounds
        paperVC.view.autoresizingMask = [.width, .height]
        view.addSubview(paperVC.view)

        // macOS toolbar for insertion UI
        let toolbar = MarkupToolbarViewController(supportedFeatureSet: features)
        toolbar.delegate = paperVC

        addChild(toolbar)
        // Position toolbar at top of view
        let toolbarHeight: CGFloat = 44
        toolbar.view.frame = CGRect(
            x: 0, y: view.bounds.height - toolbarHeight,
            width: view.bounds.width, height: toolbarHeight
        )
        toolbar.view.autoresizingMask = [.width, .minYMargin]
        view.addSubview(toolbar.view)
    }
}

Custom FeatureSet Patterns

Annotation-Only Mode

For apps that need markup annotations without freeform drawing:

var features = FeatureSet.latest
features.remove(.drawing)
// User can insert shapes, text, images but cannot draw freehand

Shapes-Only Mode

var features = FeatureSet.empty
features.insert(.shapeStrokes)
features.insert(.shapeFills)
features.shapes = [.rectangle, .ellipse, .arrowShape]

Document Review Mode

var features = FeatureSet.latest
features.remove(.stickers)
features.remove(.images)
features.shapes = [.rectangle, .ellipse, .line, .arrowShape]
features.lineMarkerPositions = .single  // Single-ended arrows only

HDR Creative Mode

var features = FeatureSet.latest
features.colorMaximumLinearExposure = view.window?.windowScene?.screen.potentialEDRHeadroom ?? 1.0
// Also set on tool picker:
toolPicker.maximumLinearExposure = features.colorMaximumLinearExposure

Programmatic Markup Construction

Build markup content in code without user interaction — useful for generating templates, reports, or test content.

Building a Template

func createAnnotatedTemplate(size: CGSize) -> PaperMarkup {
    var markup = PaperMarkup(bounds: CGRect(origin: .zero, size: size))

    // Title text box
    markup.insertNewTextbox(
        attributedText: AttributedString("Document Title"),
        frame: CGRect(x: 20, y: 20, width: size.width - 40, height: 40),
        rotation: 0
    )

    // Decorative line separator
    let lineConfig = ShapeConfiguration(
        type: .line,
        fillColor: nil,
        strokeColor: UIColor.separator.cgColor,
        lineWidth: 1
    )
    markup.insertNewLine(
        configuration: lineConfig,
        from: CGPoint(x: 20, y: 70),
        to: CGPoint(x: size.width - 20, y: 70),
        startMarker: false,
        endMarker: false
    )

    // Annotation callout
    let calloutConfig = ShapeConfiguration(
        type: .chatBubble,
        fillColor: UIColor.systemYellow.withAlphaComponent(0.2).cgColor,
        strokeColor: UIColor.systemYellow.cgColor,
        lineWidth: 1.5
    )
    markup.insertNewShape(
        configuration: calloutConfig,
        frame: CGRect(x: 20, y: 90, width: 280, height: 80),
        rotation: 0
    )

    return markup
}

Merging Multiple Markup Documents

var combined = PaperMarkup(bounds: totalBounds)
combined.append(contentsOf: page1Markup)
combined.append(contentsOf: page2Markup)

Content Transformation

Apply affine transforms to all content in a markup model:

// Scale content to 50%
let scale = CGAffineTransform(scaleX: 0.5, y: 0.5)
markup.transformContent(scale)

// Translate content
let translate = CGAffineTransform(translationX: 100, y: 50)
markup.transformContent(translate)

// Combined transform
let transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
    .translatedBy(x: 100, y: 50)
markup.transformContent(transform)

PencilKit Migration

For apps already using PencilKit that want to adopt PaperKit:

Append Existing PKDrawing

import PencilKit

func migrateDrawing(_ drawing: PKDrawing, to markup: inout PaperMarkup) {
    markup.append(contentsOf: drawing)
}

Side-by-Side Drawing and Markup

PaperKit handles both layers internally. The drawingTool property accepts any PKTool:

paperVC.drawingTool = PKInkingTool(.pen, color: .black, width: 3)
paperVC.drawingTool = PKEraserTool(.bitmap)
paperVC.drawingTool = PKLassoTool()

Error Handling

MarkupError covers deserialization failures:

CaseMeaning
.incorrectFormatData is not PaperKit format
.malformedDataData is corrupted
.incompatibleFormatTooNewData requires a newer PaperKit version
do {
    let markup = try PaperMarkup(dataRepresentation: data)
    paperVC.markup = markup
} catch MarkupError.incompatibleFormatTooNew {
    // Show thumbnail fallback or upgrade prompt
    showUpgradePrompt()
} catch MarkupError.malformedData {
    // Data corrupted — offer to start fresh
    showCorruptionAlert()
} catch MarkupError.incorrectFormat {
    // Not PaperKit data
    showFormatError()
} catch {
    showGenericError(error)
}

Undo Support

PaperMarkupViewController exposes an undoManager property. The controller registers undo actions automatically for user interactions. Connect it to the responder chain for standard undo/redo behavior.

// The undoManager is available after viewDidLoad
override var undoManager: UndoManager? {
    paperVC.undoManager
}

Document-Based App Integration

PaperKit fits naturally into UIDocument subclasses:

class MarkupDocument: UIDocument {
    var markup: PaperMarkup?

    override func contents(forType typeName: String) throws -> Any {
        guard let markup else { throw CocoaError(.fileWriteUnknown) }
        // Use a synchronous wrapper or pre-computed data
        // Note: dataRepresentation() is async — pre-compute before save
        return precomputedData ?? Data()
    }

    override func load(fromContents contents: Any, ofType typeName: String?) throws {
        guard let data = contents as? Data else {
            throw CocoaError(.fileReadCorruptFile)
        }
        markup = try PaperMarkup(dataRepresentation: data)
    }
}

Since dataRepresentation() is async, pre-compute the data representation before the document system calls contents(forType:). Trigger serialization in the delegate callback when markup changes.

Searchable Content

PaperMarkup.indexableContent returns extractable text from text boxes, useful for Spotlight indexing:

if let searchText = markup.indexableContent {
    // Index with Core Spotlight
    let attributes = CSSearchableItemAttributeSet(contentType: .data)
    attributes.textContent = searchText
}

skills

CHANGELOG.md

README.md

tile.json