← Back to index

SpotsPDFExporter

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import Foundation import UIKit import WebKitFramework importsImports Foundation, UIKit, WebKit.
▶ SECTION MODEL
/// One logical group of entries for the PDF. /// - `header`: section label (cuisine name, borough) — nil = no section headers /// - `subheader`: sub-section label (neighborhood) — nil = single-level grouping struct PDFSection { let header: String? let subheader: String? let entries: [SpotEntry] }Documentation commentDescribes the following declaration.
▶ EXPORTER
@MainActor final class SpotsPDFExporter: NSObject, WKNavigationDelegate { // Continuation resumed when WKWebView finishes rendering + PDF is ready. private var continuation: CheckedContinuation<URL?, Never>? private var webView: WKWebView? private var renderWindow: UIWindow? // dedicated off-screen render window // MARK: - Pre-warm /// Spins up the WKWebView web content process in the background so the first /// real PDF export is instant. Call once shortly after app launch. private static var prewarmView: WKWebView? static func prewarm() { let wv = WKWebView(frame: .zero) prewarmView = wv wv.loadHTMLString("<html></html>", baseURL: nil) // Release after the process is running — it stays warm system-side. DispatchQueue.main.asyncAfter(deadline: .now() + 3) { prewarmView = nil } } // MARK: - Public entry point /// Generates a PDF for the given sections and returns a temporary file URL, or nil on failure. /// - Parameters: /// - sections: Pre-sorted, pre-grouped entry sections. /// - mode: Food or Show mode (affects which fields are shown). /// - sortLabel: Human-readable label appended to the header, e.g. "Cuisine". Pass "" for none. static func export(sections: [PDFSection], mode: EntryMode, sortLabel: String) async -> URL? { let exporter = SpotsPDFExporter() return await exporter.run(sections: sections, mode: mode, sortLabel: sortLabel) } // MARK: - Internal pipeline private func run(sections: [PDFSection], mode: EntryMode, sortLabel: String) async -> URL? { // 1. Collect all entries (for thumbnail loading). let allEntries = sections.flatMap { $0.entries } // 2. Load + compress all thumbnails in parallel (one task per entry). let imageMap: [UUID: String] = await withTaskGroup(of: (UUID, String)?.self) { group in for entry in allEntries { let entryID = entry.id let filename = entry.imageFilename let inMemory = entry.imageData group.addTask(priority: .userInitiated) { let raw: Data? if let fn = filename, !fn.isEmpty { raw = EntryStore.loadImageData(filename: fn) } else { raw = inMemory } guard let imageData = raw, let img = UIImage(data: imageData), let thumb = img.jpegData(compressionQuality: 0.55) else { return nil } return (entryID, "data:image/jpeg;base64,\(thumb.base64EncodedString())") } } var result: [UUID: String] = [:] for await pair in group { if let (id, dataURI) = pair { result[id] = dataURI } } return result } // 3. Build HTML. let html = buildHTML(sections: sections, mode: mode, sortLabel: sortLabel, imageMap: imageMap) // 4. Create an off-screen WKWebView, load HTML, then createPDF in the delegate. let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 680, height: 880)) wv.navigationDelegate = self self.webView = wv // WKWebView needs to be in a live window to perform layout correctly. // Use a dedicated off-screen UIWindow behind the main window so the // web view is never visible to the user (avoids the white-flash artifact // that occurs when adding a subview to the key window). if let windowScene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }) { let win = UIWindow(windowScene: windowScene) win.frame = CGRect(x: 0, y: 0, width: 680, height: 880) win.windowLevel = .normal - 1 // behind every visible window win.backgroundColor = .clear let vc = UIViewController() vc.view.backgroundColor = .clear win.rootViewController = vc win.isHidden = false wv.frame = win.bounds vc.view.addSubview(wv) self.renderWindow = win } return await withCheckedContinuation { cont in self.continuation = cont wv.loadHTMLString(html, baseURL: nil) } } // MARK: - WKNavigationDelegate func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { webView.createPDF { [weak self] result in webView.removeFromSuperview() self?.webView = nil switch result { case .success(let data): let tmp = FileManager.default.temporaryDirectory .appendingPathComponent("Spots_\(Int(Date().timeIntervalSince1970)).pdf") try? data.write(to: tmp) self?.continuation?.resume(returning: tmp) case .failure: self?.continuation?.resume(returning: nil) } self?.continuation = nil } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { webView.removeFromSuperview() self.webView = nil continuation?.resume(returning: nil) continuation = nil } // MARK: - HTML builder private func buildHTML(sections: [PDFSection], mode: EntryMode, sortLabel: String, imageMap: [UUID: String]) -> String { let dateStr = DateFormatter.localizedString(from: Date(), dateStyle: .long, timeStyle: .none) let modeTitle = mode == .food ? "Food" : "Shows" let titleLine = sortLabel.isEmpty ? "Spots — \(modeTitle)" : "Spots — \(modeTitle) · \(sortLabel)" let count = sections.reduce(0) { $0 + $1.entries.count } // Build body: section headers, sub-headers, cards. var bodyParts: [String] = [] var lastHeader: String? = nil for section in sections { // Main section header (cuisine) or borough header (neighborhood sort). // Sections with a subheader are borough+neighborhood pairs; use the // narrower borough-header style for those. if let header = section.header, header != lastHeader { let isBorough = section.subheader != nil let cls = isBorough ? "borough-header" : "section-header" let topMargin = lastHeader == nil ? "margin-top: 0;" : "" bodyParts.append( "<div class=\"\(cls)\" style=\"\(topMargin)\">\(esc(header))</div>" ) lastHeader = header } // Sub-header (neighborhood name). if let sub = section.subheader { bodyParts.append("<div class=\"sub-header\">\(esc(sub))</div>") } // Cards. for entry in section.entries { bodyParts.append(buildCard(entry, mode: mode, imageMap: imageMap)) } } let body = bodyParts.joined(separator: "\n") return """ <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <style> @page { size: letter; margin: 0.45in; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 10pt; color: #1c1c1e; background: #fff; -webkit-print-color-adjust: exact; } /* ── Page header ──────────────────────────────────────────────── */ .header { background: #7B7EC8; color: #fff; padding: 13px 16px 11px; border-radius: 8px; display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; } .header-left { } .header-title { font-size: 19pt; font-weight: 700; letter-spacing: -0.4px; } .header-sub { font-size: 9pt; opacity: 0.82; margin-top: 3px; } .header-date { font-size: 9pt; opacity: 0.82; text-align: right; } /* ── Section headers ──────────────────────────────────────────── */ /* Cuisine / top-level grouping header */ .section-header { font-size: 13pt; font-weight: 700; color: #C0392B; margin-top: 16px; margin-bottom: 7px; break-after: avoid; } /* Borough header (neighborhood sort) — same size as neighborhood, all-caps */ .borough-header { font-size: 10.5pt; font-weight: 700; color: #C0392B; text-transform: uppercase; letter-spacing: 0.4px; margin-top: 14px; margin-bottom: 3px; break-after: avoid; } /* Neighborhood sub-header */ .sub-header { font-size: 10.5pt; font-weight: 600; color: #C0392B; margin-top: 8px; margin-bottom: 5px; break-after: avoid; } /* ── Card ─────────────────────────────────────────────────────── */ .card { border: 1px solid #d1d1d6; border-left: 4px solid #7B7EC8; border-radius: 7px; padding: 9px 11px 9px 11px; margin-bottom: 9px; break-inside: avoid; display: flex; gap: 9px; background: #fafafa; } .card-body { flex: 1; min-width: 0; } .card-photo { width: 72px; height: 72px; border-radius: 7px; object-fit: cover; flex-shrink: 0; align-self: flex-start; } /* ── Card typography ──────────────────────────────────────────── */ .name { font-size: 13pt; font-weight: 700; color: #0a0a0a; margin-bottom: 2px; display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; } .badge { font-size: 7.5pt; font-weight: 600; padding: 1px 7px; border-radius: 9px; white-space: nowrap; } .badge-try { background: #7B7EC8; color: #fff; } .badge-been { background: #8A3324; color: #fff; } .meta { font-size: 9pt; color: #6e6e73; margin-bottom: 4px; } .field { font-size: 9pt; color: #2c2c2e; margin-bottom: 2px; } .field-label { font-weight: 600; color: #48484a; } .divider { height: 1px; background: #e5e5ea; margin: 5px 0; } .notes { font-size: 9pt; color: #3a3a3c; font-style: italic; white-space: pre-wrap; } /* ── Stars & dots ─────────────────────────────────────────────── */ .stars { color: #f4a800; font-size: 11pt; letter-spacing: 1px; margin: 0 0 2px; } .dots { color: #7B7EC8; font-size: 10pt; letter-spacing: 1px; margin: 0 0 2px; } </style> </head> <body> <div class="header"> <div class="header-left"> <div class="header-title">\(esc(titleLine))</div> <div class="header-sub">\(count) \(count == 1 ? "entry" : "entries")</div> </div> <div class="header-date">\(esc(dateStr))</div> </div> \(body) </body> </html> """ } private func buildCard(_ entry: SpotEntry, mode: EntryMode, imageMap: [UUID: String]) -> String { var lines: [String] = [] // ── Rating glyphs — above the title ─────────────────────────── if entry.beenThere && entry.starRating > 0 { let filled = String(repeating: "★", count: entry.starRating) let empty = String(repeating: "☆", count: max(0, 5 - entry.starRating)) lines.append("<div class=\"stars\">\(filled)\(empty)</div>") } else if !entry.beenThere && entry.rating > 0 { let filled = String(repeating: "●", count: entry.rating) let empty = String(repeating: "○", count: max(0, 5 - entry.rating)) lines.append("<div class=\"dots\">\(filled)\(empty)</div>") } // ── Name + badge ─────────────────────────────────────────────── let badgeClass = entry.beenThere ? "badge badge-been" : "badge badge-try" let badgeText = entry.beenThere ? "Been" : "To Try" lines.append(""" <div class="name">\(esc(entry.playName))<span class="\(badgeClass)">\(badgeText)</span></div> """) // ── Meta (cuisine/type · neighborhood) ──────────────────────── var metaParts: [String] = [] if !entry.cuisine.isEmpty { metaParts.append(esc(entry.cuisine)) } if !entry.neighborhood.isEmpty { metaParts.append(esc(entry.neighborhood)) } if !metaParts.isEmpty { lines.append("<div class=\"meta\">\(metaParts.joined(separator: " &middot; "))</div>") } // ── Date ────────────────────────────────────────────────────── if entry.hasCustomDate { let df = DateFormatter() df.dateStyle = .long df.timeStyle = (mode == .show && !entry.consider) ? .short : .none lines.append(field("Date", value: df.string(from: entry.dateTime))) } // ── Standard fields ─────────────────────────────────────────── if !entry.address.isEmpty { lines.append(field("Address", value: entry.address)) } if !entry.phone.isEmpty { lines.append(field("Phone", value: entry.phone)) } if !entry.website.isEmpty { lines.append(field("Web", value: entry.website)) } if !entry.hours.isEmpty { lines.append(field("Hours", value: entry.hours)) } // ── Notes — always last ──────────────────────────────────────── if !entry.notes.isEmpty { lines.append("<div class=\"divider\"></div>") lines.append("<div class=\"notes\">\(esc(entry.notes))</div>") } // ── Photo thumbnail ─────────────────────────────────────────── let photoHTML: String if let dataURL = imageMap[entry.id] { photoHTML = "<img class=\"card-photo\" src=\"\(dataURL)\" />" } else { photoHTML = "" } return """ <div class="card"> <div class="card-body"> \(lines.joined(separator: "\n ")) </div> \(photoHTML) </div> """ } // MARK: - Helpers private func field(_ label: String, value: String) -> String { "<div class=\"field\"><span class=\"field-label\">\(label):</span> \(esc(value))</div>" } private func esc(_ s: String) -> String { s.replacingOccurrences(of: "&", with: "&amp;") .replacingOccurrences(of: "<", with: "&lt;") .replacingOccurrences(of: ">", with: "&gt;") .replacingOccurrences(of: "\"", with: "&quot;") } }Code blockSee source code for full implementation.