// // ReportPDFGenerator.swift // Reflect // // Generates clinical PDF reports from MoodReport data using HTML + WKWebView. // import Foundation 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 += """

Reflect Mood Report

\(report.overview.dateRange)

Generated \(generatedDate)

""" // Overview Stats html += generateOverviewSection(report.overview) // Quick Summary (if available) if let quickSummary = report.quickSummary { html += """

Summary

\(escapeHTML(quickSummary))
""" } // Weekly sections (Detailed report only) if report.reportType == .detailed { for week in report.weeks { html += generateWeekSection(week, useFahrenheit: useFahrenheit) } } // Monthly Summaries if !report.monthlySummaries.isEmpty { html += """

Monthly Summaries

""" for month in report.monthlySummaries { html += """

\(escapeHTML(month.title))

\(month.entryCount) entries · Average mood: \(String(format: "%.1f", month.averageMood))/5

\(month.aiSummary.map { "
\(escapeHTML($0))
" } ?? "")
""" } html += "
" } // Yearly Summaries if !report.yearlySummaries.isEmpty { html += """

Yearly Summaries

""" for year in report.yearlySummaries { html += """

\(year.year)

\(year.entryCount) entries · Average mood: \(String(format: "%.1f", year.averageMood))/5

\(year.aiSummary.map { "
\(escapeHTML($0))
" } ?? "")
""" } html += "
" } // Footer html += """ """ return html } // MARK: - Section Generators private func generateOverviewSection(_ overview: ReportOverviewStats) -> String { let distributionHTML = [Mood.great, .good, .average, .bad, .horrible] .compactMap { mood -> String? in guard let count = overview.moodDistribution[mood], count > 0 else { return nil } let pct = overview.totalEntries > 0 ? Int(Double(count) / Double(overview.totalEntries) * 100) : 0 return """
\(mood.widgetDisplayName) \(count) (\(pct)%)
""" } .joined() return """

Overview

\(overview.totalEntries)
Total Entries
\(String(format: "%.1f", overview.averageMood))
Avg Mood (1-5)
\(overview.trend)
Trend

Mood Distribution

\(distributionHTML)
""" } private func generateWeekSection(_ week: ReportWeek, useFahrenheit: Bool) -> String { let weekDateFormatter = DateFormatter() weekDateFormatter.dateFormat = "MMM d" let startStr = weekDateFormatter.string(from: week.startDate) let endStr = weekDateFormatter.string(from: week.endDate) let entryDateFormatter = DateFormatter() entryDateFormatter.dateFormat = "EEE, MMM d" var rows = "" for entry in week.entries.sorted(by: { $0.date < $1.date }) { let dateStr = entryDateFormatter.string(from: entry.date) let moodStr = entry.mood.widgetDisplayName let moodColor = moodHexColor(entry.mood) let notesStr = entry.notes ?? "-" let weatherStr = formatWeather(entry.weather, useFahrenheit: useFahrenheit) rows += """ \(escapeHTML(dateStr)) \(escapeHTML(moodStr)) \(escapeHTML(notesStr)) \(escapeHTML(weatherStr)) """ if let reflection = entry.reflection, reflection.answeredCount > 0 { rows += """
\(formatReflectionHTML(reflection))
""" } } return """

Week \(week.weekNumber): \(escapeHTML(startStr)) - \(escapeHTML(endStr))

\(rows)
Date Mood Notes Weather
\(week.aiSummary.map { "
\(escapeHTML($0))
" } ?? "")
""" } // MARK: - Helpers private func formatWeather(_ weather: WeatherData?, useFahrenheit: Bool) -> String { guard let w = weather else { return "-" } let temp: String if useFahrenheit { let f = w.temperature * 9 / 5 + 32 temp = "\(Int(round(f)))°F" } else { temp = "\(Int(round(w.temperature)))°C" } return "\(w.condition), \(temp)" } private func moodHexColor(_ mood: Mood) -> String { switch mood { case .great: return "#4CAF50" case .good: return "#8BC34A" case .average: return "#FFC107" case .bad: return "#FF9800" case .horrible: return "#F44336" default: return "#9E9E9E" } } private func formatReflectionHTML(_ reflection: GuidedReflection) -> String { reflection.responses .filter { !$0.answer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } .map { "

\(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 render 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!) { let config = WKPDFConfiguration() config.rect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter webView.createPDF(configuration: config) { [weak self] result in DispatchQueue.main.async { switch result { case .success(let data): self?.completion(.success(data)) case .failure(let error): self?.completion(.failure(ReportPDFGenerator.PDFError.pdfGenerationFailed(underlying: error))) } } } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { completion(.failure(ReportPDFGenerator.PDFError.htmlRenderFailed)) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { completion(.failure(ReportPDFGenerator.PDFError.htmlRenderFailed)) } }