| @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: " · "))</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: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: "\"", with: """)
}
} | Code block | See source code for full implementation. |