diff --git a/oAI/Services/ConversationExportService.swift b/oAI/Services/ConversationExportService.swift index 9213984..cdd384d 100644 --- a/oAI/Services/ConversationExportService.swift +++ b/oAI/Services/ConversationExportService.swift @@ -76,7 +76,8 @@ enum ConversationExportService { font-size: 14px; color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; \ padding: 0 24px; line-height: 1.5; } h1 { font-size: 22px; border-bottom: 1px solid #ddd; padding-bottom: 12px; } - .message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; } + .message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; \ + break-inside: avoid; page-break-inside: avoid; } .message.user { background: #f5f7fa; border-left-color: #4a90d9; } .message.assistant { background: #fafafa; border-left-color: #8a8a8a; } .role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \ @@ -115,16 +116,22 @@ enum ConversationExportService { // MARK: - PDF + enum PDFError: LocalizedError { + case generationFailed + var errorDescription: String? { "Failed to generate PDF" } + } + + /// Uses WKWebView's real print pipeline (NSPrintOperation), not createPDF(). Confirmed + /// empirically by comparing against a normal reference PDF: createPDF() never paginates — + /// it captures the webview's frame verbatim, or auto-grows to ONE continuous page matching + /// full content height for overflow (a single page many thousands of points tall for a long + /// conversation). That's a fundamentally different, non-standard document shape from any + /// normal PDF, which is what actually made ordinarily-sized text read as "huge" — there was + /// no page of a familiar size to judge it against. Printing through NSPrintOperation with a + /// real US Letter paper size gives genuine multi-page pagination, matching what any other + /// app's "export/print to PDF" produces. static func pdfData(name: String, messages: [Message]) async throws -> Data { let htmlString = html(name: name, messages: messages) - // Confirmed empirically (not guessed): with WKPDFConfiguration.rect left at its - // default, createPDF captures exactly the webview's frame size verbatim for content - // that fits within it, and auto-grows to a single tall page matching the full - // scrollable content when it overflows — it does not paginate. Manually resizing the - // frame to match content height beforehand (a previous attempt at this) actively - // breaks that: the captured page ends up NOT matching the resized frame at all, - // producing a small, oddly-proportioned page that made ordinary-sized text look - // enormous relative to it. So: set a sane starting frame and leave it alone. let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 850, height: 1100)) webView.pageZoom = 1.0 // PDF content is baked at export time, so it can't respond to prefers-color-scheme @@ -134,11 +141,34 @@ enum ConversationExportService { webView.navigationDelegate = delegate try await delegate.load(htmlString, in: webView) - return try await withCheckedThrowingContinuation { continuation in - webView.createPDF(configuration: WKPDFConfiguration()) { result in - continuation.resume(with: result) + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".pdf") + defer { try? FileManager.default.removeItem(at: tempURL) } + + let printInfo = NSPrintInfo() + printInfo.paperSize = NSSize(width: 612, height: 792) // US Letter + printInfo.topMargin = 36 + printInfo.bottomMargin = 36 + printInfo.leftMargin = 36 + printInfo.rightMargin = 36 + printInfo.horizontalPagination = .fit + printInfo.jobDisposition = .save + printInfo.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = tempURL + + let printOp = webView.printOperation(with: printInfo) + printOp.showsPrintPanel = false + printOp.showsProgressPanel = false + + // NSPrintOperation.run() blocks synchronously, so it's dispatched off the main actor + // to avoid freezing the UI while a long conversation paginates. + let success = await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: printOp.run()) } } + guard success, let data = try? Data(contentsOf: tempURL) else { + throw PDFError.generationFailed + } + return data } private final class PDFLoadDelegate: NSObject, WKNavigationDelegate {