Swift has two built-in paths for PDF rendering: PDFKit (high-level, available on macOS 10.4+ and iOS 11+) and the lower-level Quartz Core Graphics framework. PDFKit is cleaner for simple use cases. Quartz gives you explicit DPI and color space control. Both are macOS/iOS only — for Linux or CI environments, the ChangeThisFile API fills the gap.

Method 1: PDFKit (cleanest API, macOS 10.4+ / iOS 11+)

PDFKit's PDFPage.thumbnail(of:for:) renders a page to an NSImage/UIImage. Fast and concise for most use cases.

import PDFKit
import AppKit  // macOS; use UIKit on iOS
import Foundation

func pdfToJpg(
    inputURL: URL,
    outputDir: URL,
    scaleFactor: CGFloat = 2.0,  // 2x = ~150 DPI from 72pt native
    compressionQuality: CGFloat = 0.88
) throws -> [URL] {
    guard let document = PDFDocument(url: inputURL) else {
        throw ConversionError.unreadable
    }

    try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
    var outputPaths: [URL] = []

    for pageIndex in 0 ..< document.pageCount {
        guard let page = document.page(at: pageIndex) else { continue }

        let mediaBox  = page.bounds(for: .mediaBox)
        let thumbSize = CGSize(
            width:  mediaBox.width  * scaleFactor,
            height: mediaBox.height * scaleFactor
        )

        let nsImage = page.thumbnail(of: thumbSize, for: .mediaBox)

        guard let tiffData = nsImage.tiffRepresentation,
              let bitmap   = NSBitmapImageRep(data: tiffData),
              let jpegData = bitmap.representation(
                  using: .jpeg,
                  properties: [.compressionFactor: compressionQuality]
              )
        else {
            throw ConversionError.renderFailed(pageIndex)
        }

        let outURL = outputDir.appendingPathComponent("page-\(String(format: "%03d", pageIndex + 1)).jpg")
        try jpegData.write(to: outURL)
        outputPaths.append(outURL)
    }
    return outputPaths
}

enum ConversionError: Error {
    case unreadable
    case renderFailed(Int)
}

// Usage
let pages = try pdfToJpg(
    inputURL:  URL(fileURLWithPath: "/tmp/document.pdf"),
    outputDir: URL(fileURLWithPath: "/tmp/pages"),
    scaleFactor: 2.0
)
print("Wrote \(pages.count) pages")

Scale factor guide (PDFs are 72 points/inch native):

  • 1.0 — 72 DPI. Screen thumbnails only.
  • 2.0 — 144 DPI. Good default for web viewing.
  • 2.78 — 200 DPI. Sharp enough for most print previews.
  • 4.17 — 300 DPI. Print quality.

Method 2: Quartz CGPDFDocument (explicit DPI, full control)

The lower-level Quartz path gives explicit DPI control by setting the CGContext transform. More code, but precise output sizing.

import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
import Foundation

func pdfToJpgQuartz(
    inputURL: URL,
    outputDir: URL,
    dpi: CGFloat = 150,
    compressionQuality: CGFloat = 0.88
) throws -> [URL] {
    guard let provider = CGDataProvider(url: inputURL as CFURL),
          let document = CGPDFDocument(provider)
    else {
        throw ConversionError.unreadable
    }

    try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
    var outputPaths: [URL] = []
    let scale = dpi / 72.0

    for pageNumber in 1 ... document.numberOfPages {
        guard let page = document.page(at: pageNumber) else { continue }

        let mediaBox = page.getBoxRect(.mediaBox)
        let width    = Int(ceil(mediaBox.width  * scale))
        let height   = Int(ceil(mediaBox.height * scale))

        let colorSpace = CGColorSpaceCreateDeviceRGB()
        guard let context = CGContext(
            data: nil,
            width: width,
            height: height,
            bitsPerComponent: 8,
            bytesPerRow: 0,
            space: colorSpace,
            bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
        ) else {
            throw ConversionError.contextFailed
        }

        // White background
        context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
        context.fill(CGRect(x: 0, y: 0, width: width, height: height))

        context.scaleBy(x: scale, y: scale)
        context.drawPDFPage(page)

        guard let cgImage = context.makeImage() else {
            throw ConversionError.renderFailed(pageNumber)
        }

        let outURL = outputDir.appendingPathComponent("page-\(String(format: "%03d", pageNumber)).jpg")
        guard let dest = CGImageDestinationCreateWithURL(
            outURL as CFURL,
            UTType.jpeg.identifier as CFString,
            1, nil
        ) else { continue }

        CGImageDestinationAddImage(dest, cgImage, [
            kCGImageDestinationLossyCompressionQuality: compressionQuality
        ] as CFDictionary)
        CGImageDestinationFinalize(dest)
        outputPaths.append(outURL)
    }
    return outputPaths
}

enum ConversionError: Error {
    case unreadable
    case contextFailed
    case renderFailed(Int)
}

Setting the white background fill before drawPDFPage is important — PDF pages can have transparent backgrounds. Without it, transparent areas render black in JPEG (which has no alpha channel).

Method 3: ChangeThisFile API via URLSession (Linux + CI-friendly)

PDFKit and Quartz are Apple frameworks — unavailable on Linux Swift or non-macOS CI. POST to the API for platform-independent rendering. Free tier: 1,000 conversions/month.

# curl reference
curl -X POST https://changethisfile.com/v1/convert \
  -H "Authorization: Bearer ctf_sk_your_key_here" \
  -F "file=@document.pdf" \
  -F "target=jpg" \
  --output result.jpg
import Foundation

func pdfToJpgAPI(
    inputURL: URL,
    outputDir: URL,
    apiKey: String
) async throws -> [URL] {
    let endpoint = URL(string: "https://changethisfile.com/v1/convert")!
    let boundary = UUID().uuidString
    let fileData = try Data(contentsOf: inputURL)

    var request = URLRequest(url: endpoint)
    request.httpMethod = "POST"
    request.timeoutInterval = 120
    request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
    request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

    var body = Data()
    body.append("--\(boundary)\r\n".data(using: .utf8)!)
    body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(inputURL.lastPathComponent)\"\r\n".data(using: .utf8)!)
    body.append("Content-Type: application/pdf\r\n\r\n".data(using: .utf8)!)
    body.append(fileData)
    body.append("\r\n".data(using: .utf8)!)
    body.append("--\(boundary)\r\n".data(using: .utf8)!)
    body.append("Content-Disposition: form-data; name=\"target\"\r\n\r\n".data(using: .utf8)!)
    body.append("jpg\r\n".data(using: .utf8)!)
    body.append("--\(boundary)--\r\n".data(using: .utf8)!)
    request.httpBody = body

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }

    try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)

    // Multi-page PDFs return a ZIP
    let contentType = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Type") ?? ""
    if contentType.contains("zip") {
        let zipURL = outputDir.appendingPathComponent("pages.zip")
        try data.write(to: zipURL)
        // Unzip using Process on macOS or a Swift zip library
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
        process.arguments = ["-o", zipURL.path, "-d", outputDir.path]
        try process.run()
        process.waitUntilExit()
        try FileManager.default.removeItem(at: zipURL)
        return try FileManager.default.contentsOfDirectory(at: outputDir, includingPropertiesForKeys: nil)
            .filter { $0.pathExtension == "jpg" }
            .sorted { $0.lastPathComponent < $1.lastPathComponent }
    } else {
        let outURL = outputDir.appendingPathComponent("page-001.jpg")
        try data.write(to: outURL)
        return [outURL]
    }
}

When to use each

ApproachBest forTradeoff
PDFKitmacOS/iOS apps, quick page thumbnailsmacOS/iOS only; no explicit DPI setting
Quartz CGPDFDocumentExplicit DPI, color space control, batch pipelinesMore verbose; macOS/iOS only
ChangeThisFile API (URLSession)Linux Swift, CI pipelines, no Apple framework depNetwork latency, 25MB free-tier upload limit

Production tips

  • Always fill a white background before rendering. PDF pages can be transparent. JPEG has no alpha channel. Without a white fill, transparent areas become black — a common and confusing bug.
  • Use scale factor 2.0–3.0 as your default. PDF points are 72 per inch. Multiplying by 2 gives 144 DPI, which is crisp on Retina displays and most web contexts. 3.0 (216 DPI) is good for thumbnail generation.
  • CGContext is not thread-safe. Create a separate context per page. For concurrent page rendering, use DispatchQueue.concurrentPerform with one context per iteration.
  • Rotate pages according to PDFPage.rotation. Some scanned PDFs store pages rotated with a rotation metadata flag. Apply context.rotate(by: page.rotation * .pi / 180) before drawPDFPage to handle rotated pages correctly.
  • Check page count before iterating. Encrypted PDFs return numberOfPages = 0 if the password isn't supplied. Check document.isLocked before iterating to surface a clear error.

For macOS/iOS apps, PDFKit is the fastest path to working code. For precise DPI control in batch pipelines, Quartz. For Linux Swift or CI without Apple frameworks, the API. Free tier covers 1,000 conversions/month.