Files
konverter/Sources/Konverter/Convert/PDFConverter.swift
T
TZGyn 2d031e58a4 Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs.
Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation,
the bundled CLI tools, and optional ffmpeg/LibreOffice.

Includes a --self-test suite (41 checks) and a --convert CLI mode so the
conversion matrix can be verified without the UI.
2026-10-20 00:36:40 +08:00

382 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import CoreGraphics
import CoreText
import Foundation
import PDFKit
/// Everything PDF: rasterising pages, building PDFs from images, text
/// extraction, splitting, merging, rotating and password handling.
enum PDFConverter {
static let defaultDPI: CGFloat = 300
// MARK: - Images → PDF
static func imageToPDF(_ source: URL, at destination: URL, cancellation: Cancellation? = nil) throws {
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
try imagesToPDF([(image, nil)], at: destination, cancellation: cancellation)
}
/// Each image becomes one page, sized so the image keeps its aspect ratio
/// at 72 dpi (a 1440×900 screenshot becomes a 1440×900 pt page).
static func imagesToPDF(
_ pages: [(image: CGImage, name: String?)],
at destination: URL,
pageSize: CGSize? = nil,
cancellation: Cancellation? = nil
) throws {
guard !pages.isEmpty else { throw ConversionError.failed("Nothing to put in the PDF.") }
var mediaBox = CGRect(origin: .zero, size: pages[0].name == nil
? CGSize(width: pages[0].image.width, height: pages[0].image.height)
: CGSize(width: 595, height: 842))
if let pageSize { mediaBox = CGRect(origin: .zero, size: pageSize) }
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
}
for page in pages {
try cancellation?.check()
let image = page.image
var box = mediaBox
if page.name == nil || pageSize == nil {
box = CGRect(x: 0, y: 0, width: image.width, height: image.height)
}
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(box)] as CFDictionary)
let scale = min(box.width / CGFloat(image.width), box.height / CGFloat(image.height))
let size = CGSize(width: CGFloat(image.width) * scale, height: CGFloat(image.height) * scale)
let rect = CGRect(
x: (box.width - size.width) / 2,
y: (box.height - size.height) / 2,
width: size.width,
height: size.height
)
context.draw(image, in: rect)
context.endPDFPage()
}
context.closePDF()
}
private static func CGRectToData(_ rect: CGRect) -> CFData {
var rect = rect
return CFDataCreate(nil, withUnsafeBytes(of: &rect) { $0.bindMemory(to: UInt8.self).baseAddress }, MemoryLayout<CGRect>.size)
}
// MARK: - PDF → images
/// Renders each page to `format` at `dpi`. Returns the files written.
@discardableResult
static func renderPages(
_ source: URL,
to format: Format,
into directory: URL,
dpi: CGFloat = 300,
pageNumbers: [Int]? = nil,
quality: Double = 0.85,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws -> [URL] {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let base = source.deletingPathExtension().lastPathComponent
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let indices = pageNumbers ?? Array(0..<document.pageCount)
var written: [URL] = []
let scale = dpi / 72
let padding = String(indices.count).count
for (offset, pageIndex) in indices.enumerated() {
try cancellation?.check()
guard let page = document.page(at: pageIndex) else { continue }
let bounds = page.bounds(for: .mediaBox)
let pixelWidth = Int((bounds.width * scale).rounded())
let pixelHeight = Int((bounds.height * scale).rounded())
guard pixelWidth > 0, pixelHeight > 0 else { continue }
guard let context = CGContext(
data: nil,
width: pixelWidth,
height: pixelHeight,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
) else { continue }
context.setFillColor(CGColor(gray: 1, alpha: 1))
context.fill(CGRect(x: 0, y: 0, width: pixelWidth, height: pixelHeight))
context.scaleBy(x: scale, y: scale)
context.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
page.draw(with: .mediaBox, to: context)
guard let image = context.makeImage() else { continue }
let number = String(pageIndex + 1).leftPadded(to: padding)
let target = OutputNaming.availableURL(
for: directory.appendingPathComponent("\(base)-\(number)").appendingPathExtension(format.ext)
)
try Raster.write(image, to: target, format: format, quality: quality, metadata: nil)
written.append(target)
progress?(Double(offset + 1) / Double(indices.count))
}
return written
}
// MARK: - Text
static func extractText(_ source: URL, pageBreak: String = "\n\n") -> String {
guard let document = PDFDocument(url: source) else { return "" }
return (0..<document.pageCount)
.compactMap { document.page(at: $0)?.string }
.joined(separator: pageBreak)
}
// MARK: - Document surgery
static func merge(_ sources: [URL], at destination: URL, cancellation: Cancellation? = nil) throws {
let merged = PDFDocument()
var index = 0
for source in sources {
try cancellation?.check()
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
for pageIndex in 0..<document.pageCount {
try cancellation?.check()
guard let page = document.page(at: pageIndex)?.copy() as? PDFPage else { continue }
merged.insert(page, at: index)
index += 1
}
}
guard merged.pageCount > 0 else { throw ConversionError.failed("No pages to merge.") }
guard merged.write(to: destination) else {
throw ConversionError.failed("Could not write \(destination.lastPathComponent).")
}
}
/// Splits into chunks of `pagesPerFile`, writing into a sibling folder.
static func split(
_ source: URL,
pagesPerFile: Int,
into directory: URL,
cancellation: Cancellation? = nil
) throws -> [URL] {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let base = source.deletingPathExtension().lastPathComponent
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var written: [URL] = []
var start = 0
var part = 1
while start < document.pageCount {
try cancellation?.check()
let chunk = PDFDocument()
var cursor = 0
for pageIndex in start..<min(start + pagesPerFile, document.pageCount) {
guard let page = document.page(at: pageIndex)?.copy() as? PDFPage else { continue }
chunk.insert(page, at: cursor)
cursor += 1
}
let target = OutputNaming.availableURL(
for: directory.appendingPathComponent("\(base)-\(String(format: "%02d", part)).pdf")
)
guard chunk.write(to: target) else {
throw ConversionError.failed("Could not write \(target.lastPathComponent).")
}
written.append(target)
start += pagesPerFile
part += 1
}
return written
}
static func rotate(_ source: URL, degrees: Int, at destination: URL) throws {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
for index in 0..<document.pageCount {
guard let page = document.page(at: index) else { continue }
page.rotation = (page.rotation + degrees) % 360
}
guard document.write(to: destination) else {
throw ConversionError.failed("Could not write \(destination.lastPathComponent).")
}
}
static func encrypt(_ source: URL, at destination: URL, password: String, ownerPassword: String?) throws {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
let options: [PDFDocumentWriteOption: Any] = [
.userPasswordOption: password,
.ownerPasswordOption: ownerPassword?.isEmpty == false ? ownerPassword! : password
]
guard document.write(to: destination, withOptions: options) else {
throw ConversionError.failed("Could not write the encrypted PDF.")
}
}
/// Rewrites through an image pipeline, which is what "compress a PDF"
/// means in practice: re-encode each page as a JPEG at lower quality.
static func compress(
_ source: URL,
at destination: URL,
quality: Double,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
guard let document = PDFDocument(url: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
guard let firstPage = document.page(at: 0) else { return }
var mediaBox = firstPage.bounds(for: .mediaBox)
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
throw ConversionError.failed("Could not create the compressed PDF.")
}
for index in 0..<document.pageCount {
try cancellation?.check()
guard let page = document.page(at: index) else { continue }
let bounds = page.bounds(for: .mediaBox)
let scale: CGFloat = 150 / 72
let width = Int(bounds.width * scale)
let height = Int(bounds.height * scale)
var box = bounds
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(box)] as CFDictionary)
if width > 0, height > 0,
let bitmap = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
) {
bitmap.setFillColor(CGColor(gray: 1, alpha: 1))
bitmap.fill(CGRect(x: 0, y: 0, width: width, height: height))
bitmap.scaleBy(x: scale, y: scale)
bitmap.translateBy(x: -bounds.origin.x, y: -bounds.origin.y)
page.draw(with: .mediaBox, to: bitmap)
if let image = bitmap.makeImage(),
let jpeg = JPEGEncoder.encode(image, quality: quality) {
box = bounds
context.draw(jpeg, in: bounds)
}
}
context.endPDFPage()
progress?(Double(index + 1) / Double(document.pageCount))
}
context.closePDF()
}
/// Lays images out in a grid on a single page (Tangerine's "collage").
static func collage(
_ entries: [(image: CGImage, name: String)],
at destination: URL,
columns: Int,
page: String,
margin: CGFloat,
captions: Bool,
cancellation: Cancellation? = nil
) throws {
guard !entries.isEmpty else { throw ConversionError.failed("Nothing to lay out.") }
let pageSize: CGSize
switch page {
case "Letter": pageSize = CGSize(width: 612, height: 792)
case "Square": pageSize = CGSize(width: 800, height: 800)
default: pageSize = CGSize(width: 595, height: 842)
}
var mediaBox = CGRect(origin: .zero, size: pageSize)
guard let context = CGContext(destination as CFURL, mediaBox: &mediaBox, nil) else {
throw ConversionError.failed("Could not create \(destination.lastPathComponent).")
}
context.beginPDFPage([kCGPDFContextMediaBox: CGRectToData(mediaBox)] as CFDictionary)
let columnCount = max(1, columns)
let rowCount = Int(ceil(Double(entries.count) / Double(columnCount)))
let usableWidth = pageSize.width - margin * 2
let usableHeight = pageSize.height - margin * 2
let cellWidth = usableWidth / CGFloat(columnCount)
let cellHeight = usableHeight / CGFloat(max(1, rowCount))
let captionHeight: CGFloat = captions ? 14 : 0
for (index, entry) in entries.enumerated() {
try cancellation?.check()
let column = index % columnCount
let row = index / columnCount
let cell = CGRect(
x: margin + CGFloat(column) * cellWidth,
y: pageSize.height - margin - CGFloat(row + 1) * cellHeight,
width: cellWidth,
height: cellHeight
).insetBy(dx: 6, dy: 6)
let imageArea = cell.insetBy(dx: 0, dy: captionHeight)
let scale = min(imageArea.width / CGFloat(entry.image.width), imageArea.height / CGFloat(entry.image.height))
let size = CGSize(width: CGFloat(entry.image.width) * scale, height: CGFloat(entry.image.height) * scale)
context.draw(entry.image, in: CGRect(
x: imageArea.midX - size.width / 2,
y: imageArea.midY - size.height / 2,
width: size.width,
height: size.height
))
if captions, let escaped = entry.name as CFString? {
let font = CTFontCreateWithName("Helvetica" as CFString, 9, nil)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: CGColor(gray: 0.35, alpha: 1)
]
let line = CTLineCreateWithAttributedString(
NSAttributedString(string: escaped as String, attributes: attributes)
)
context.textPosition = CGPoint(x: cell.minX, y: cell.minY)
CTLineDraw(line, context)
}
}
context.endPDFPage()
context.closePDF()
}
/// Rasterises a PDF page range to a single contact sheet, used by the
/// "Info" summary.
static func pageSize(_ source: URL) -> CGSize? {
PDFDocument(url: source)?.page(at: 0)?.bounds(for: .mediaBox).size
}
static func pageCount(_ source: URL) -> Int {
PDFDocument(url: source)?.pageCount ?? 0
}
}
/// Small helper so the PDF compressor can produce JPEG bytes in memory.
enum JPEGEncoder {
static func encode(_ image: CGImage, quality: Double) -> CGImage? {
let flattened = Raster.flattened(image)
guard let data = CFDataCreateMutable(nil, 0),
let destination = CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil) else {
return nil
}
CGImageDestinationAddImage(
destination,
flattened,
[kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary
)
guard CGImageDestinationFinalize(destination) else { return nil }
guard let source = CGImageSourceCreateWithData(data, nil) else { return nil }
return CGImageSourceCreateImageAtIndex(source, 0, nil)
}
}
extension String {
func leftPadded(to width: Int, with character: Character = "0") -> String {
count >= width ? self : String(repeating: String(character), count: width - count) + self
}
}