// // ReportPDFGenerator.swift // Reflect // // Generates clinical PDF reports from MoodReport data using HTML + WKWebView. // import Foundation import UIKit import WebKit @MainActor final class ReportPDFGenerator { enum PDFError: Error, LocalizedError { case htmlRenderFailed case pdfGenerationFailed(underlying: Error) case fileWriteFailed var errorDescription: String? { switch self { case .htmlRenderFailed: return "Failed to render report HTML" case .pdfGenerationFailed(let error): return "PDF generation failed: \(error.localizedDescription)" case .fileWriteFailed: return "Failed to save PDF file" } } } // MARK: - Public API func generatePDF(from report: MoodReport) async throws -> URL { let html = generateHTML(from: report) let pdfData = try await renderHTMLToPDF(html: html) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let startStr = dateFormatter.string(from: report.weeks.first?.startDate ?? Date()) let endStr = dateFormatter.string(from: report.weeks.last?.endDate ?? Date()) let fileName = "Reflect-AI-Report-\(startStr)-to-\(endStr).pdf" let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) do { try pdfData.write(to: fileURL) return fileURL } catch { throw PDFError.fileWriteFailed } } // MARK: - HTML Generation func generateHTML(from report: MoodReport) -> String { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .long let generatedDate = dateFormatter.string(from: report.generatedAt) let useFahrenheit = Locale.current.measurementSystem == .us var html = """
""" // Header html += """\(report.overview.dateRange)
Generated \(generatedDate)
\(month.entryCount) entries · Average mood: \(String(format: "%.1f", month.averageMood))/5
\(month.aiSummary.map { "\(year.entryCount) entries · Average mood: \(String(format: "%.1f", year.averageMood))/5
\(year.aiSummary.map { "| Date | Mood | Notes | Weather |
|---|
\(escapeHTML($0.question))
\(escapeHTML($0.answer))
" } .joined() } private func escapeHTML(_ string: String) -> String { string .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) } // MARK: - PDF Rendering private func renderHTMLToPDF(html: String) async throws -> Data { let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 612, height: 792)) webView.isOpaque = false // Load HTML and wait for it to finish rendering return try await withCheckedThrowingContinuation { continuation in let delegate = PDFNavigationDelegate { result in switch result { case .success(let data): continuation.resume(returning: data) case .failure(let error): continuation.resume(throwing: error) } } // Prevent delegate from being deallocated objc_setAssociatedObject(webView, "delegate", delegate, .OBJC_ASSOCIATION_RETAIN) webView.navigationDelegate = delegate webView.loadHTMLString(html, baseURL: nil) } } // MARK: - CSS private var cssStyles: String { """ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Georgia, 'Times New Roman', serif; font-size: 11pt; line-height: 1.5; color: #333; padding: 40px; } h1, h2, h3 { font-family: -apple-system, Helvetica, Arial, sans-serif; color: #1a1a1a; } h1 { font-size: 22pt; margin-bottom: 4px; } h2 { font-size: 16pt; margin-bottom: 12px; border-bottom: 1px solid #ddd; padding-bottom: 6px; } h3 { font-size: 13pt; margin-bottom: 8px; } .header { text-align: center; margin-bottom: 30px; } .subtitle { font-size: 13pt; color: #555; margin-bottom: 2px; } .generated { font-size: 10pt; color: #888; } .section { margin-bottom: 24px; } .stats-grid { display: flex; gap: 20px; margin-bottom: 16px; } .stat-item { flex: 1; text-align: center; padding: 12px; background: #f8f8f8; border-radius: 8px; } .stat-value { font-family: -apple-system, Helvetica, Arial, sans-serif; font-size: 20pt; font-weight: 700; color: #1a1a1a; } .stat-label { font-size: 9pt; color: #888; text-transform: uppercase; letter-spacing: 0.5px; } .distribution { margin-top: 12px; } .mood-bar-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; } .mood-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } .mood-dot-inline { width: 8px; height: 8px; border-radius: 50%; display: inline-block; vertical-align: middle; } .mood-label { width: 80px; font-size: 10pt; } .mood-count { font-size: 10pt; color: #666; } table { width: 100%; border-collapse: collapse; margin: 8px 0 12px 0; font-size: 10pt; } thead { background: #f0f0f0; } th { font-family: -apple-system, Helvetica, Arial, sans-serif; font-weight: 600; text-align: left; padding: 6px 8px; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.3px; color: #555; } td { padding: 6px 8px; border-bottom: 1px solid #eee; } tr:nth-child(even) { background: #fafafa; } .notes-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; } .ai-summary { background: #f4f0ff; border-left: 3px solid #7c5cbf; padding: 10px 14px; margin: 8px 0; font-style: italic; font-size: 10.5pt; line-height: 1.6; } .summary-block { margin-bottom: 16px; page-break-inside: avoid; } .stats { font-size: 10pt; color: #666; margin-bottom: 6px; } .reflection-row td { border-bottom: none; padding-top: 0; } .reflection-block { background: #f9f7ff; padding: 8px 12px; margin: 4px 0 8px 0; border-radius: 4px; } .reflection-q { font-style: italic; font-size: 9pt; color: #666; margin-bottom: 2px; } .reflection-a { font-size: 10pt; color: #333; margin-bottom: 6px; } .page-break { page-break-before: always; } .no-page-break { page-break-inside: avoid; } .footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid #ddd; text-align: center; font-size: 9pt; color: #999; } """ } } // MARK: - WKNavigationDelegate for PDF private class PDFNavigationDelegate: NSObject, WKNavigationDelegate { let completion: (Result) -> Void init(completion: @escaping (Result) -> Void) { self.completion = completion } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // Use UIPrintPageRenderer for proper multi-page pagination. // createPDF(configuration:) only captures a single rect — it does NOT paginate. let renderer = UIPrintPageRenderer() renderer.addPrintFormatter(webView.viewPrintFormatter(), startingAtPageAt: 0) // US Letter size (72 points per inch: 8.5 x 11 inches) let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) renderer.setValue(pageRect, forKey: "paperRect") renderer.setValue(pageRect, forKey: "printableRect") let pdfData = NSMutableData() UIGraphicsBeginPDFContextToData(pdfData, pageRect, nil) renderer.prepare(forDrawingPages: NSMakeRange(0, renderer.numberOfPages)) let bounds = UIGraphicsGetPDFContextBounds() for i in 0..