// // ConversationExportService.swift // Confab // // Shared conversation export: Markdown, HTML, and PDF // // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen // // This file is part of Confab. // // Confab is licensed under the PolyForm Noncommercial License 1.0.0. // You may use, study, modify, and share it for any noncommercial // purpose. Commercial use — including selling Confab or any part of // it, standalone or bundled into another product or service — // requires a separate commercial license from the copyright holder. // // See the LICENSE file or // for // the full license text. For commercial licensing, contact Rune // Olsen via . import AppKit import Foundation import WebKit enum ConversationExportService { // MARK: - Markdown nonisolated static func markdown(messages: [Message]) -> String { messages.map { msg in let header = msg.role == .user ? "**User**" : "**Assistant**" return "\(header)\n\n\(msg.content)" }.joined(separator: "\n\n---\n\n") } // MARK: - HTML nonisolated private static let css = """ :root { color-scheme: light dark; } html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } ::-webkit-scrollbar { display: none; } body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \ 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; \ 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; \ color: #666; margin-bottom: 8px; } .message p { margin: 8px 0; } .message h1 { font-size: 19px; margin: 12px 0 6px; } .message h2 { font-size: 17px; margin: 12px 0 6px; } .message h3 { font-size: 15.5px; margin: 12px 0 6px; } .message h4, .message h5, .message h6 { font-size: 14px; margin: 12px 0 6px; } .message ul, .message ol { margin: 8px 0; padding-left: 24px; } .message blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ccc; color: #555; } .message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \ background: #eef0f2; padding: 1px 5px; border-radius: 4px; } .message pre { background: #1e1e1e; color: #e8e8e8; padding: 12px 14px; border-radius: 6px; \ overflow-x: auto; } .message pre code { background: none; padding: 0; color: inherit; } .message hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; } .message a { color: #4a90d9; } .message table { border-collapse: collapse; margin: 10px 0; width: 100%; } .message th, .message td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; } .message th { background: #f0f2f4; font-weight: 600; } @media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #1c1c1e; } h1 { border-bottom-color: #3a3a3c; } .message.user { background: #24303d; border-left-color: #5aa2f0; } .message.assistant { background: #262626; border-left-color: #9a9a9a; } .role { color: #a8a8a8; } .message blockquote { border-left-color: #555; color: #bbb; } .message code { background: #2e2e2e; color: #e0e0e0; } .message hr { border-top-color: #3a3a3c; } .message a { color: #6fb1f0; } .message th, .message td { border-color: #3a3a3c; } .message th { background: #2a2a2c; } } """ // PDF is baked at export time — it can't respond to prefers-color-scheme live like an // HTML file opened in a browser can, and empirically the print pipeline ignores that media // query entirely regardless (confirmed: identical CSS printed light even with the webview // forced into dark appearance). So PDF gets its own always-dark stylesheet, applied // unconditionally rather than behind a media query. print-color-adjust: exact is required // too — browsers/WebKit's print pipeline strips background colors by default to save ink // unless told otherwise; without it every background here silently prints white. nonisolated private static let pdfCss = """ * { -webkit-print-color-adjust: exact; print-color-adjust: exact; color-adjust: exact; } :root { color-scheme: dark; } html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } ::-webkit-scrollbar { display: none; } body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \ font-size: 14px; color: #e8e8e8; background: #1c1c1e; max-width: 820px; margin: 40px auto; \ padding: 0 24px; line-height: 1.5; } h1 { font-size: 22px; border-bottom: 1px solid #3a3a3c; padding-bottom: 12px; } .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: #24303d; border-left-color: #5aa2f0; } .message.assistant { background: #262626; border-left-color: #9a9a9a; } .role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \ color: #a8a8a8; margin-bottom: 8px; } .message p { margin: 8px 0; } .message h1 { font-size: 19px; margin: 12px 0 6px; } .message h2 { font-size: 17px; margin: 12px 0 6px; } .message h3 { font-size: 15.5px; margin: 12px 0 6px; } .message h4, .message h5, .message h6 { font-size: 14px; margin: 12px 0 6px; } .message ul, .message ol { margin: 8px 0; padding-left: 24px; } .message blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid #555; color: #bbb; } .message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \ background: #2e2e2e; color: #e0e0e0; padding: 1px 5px; border-radius: 4px; } .message pre { background: #111; color: #e8e8e8; padding: 12px 14px; border-radius: 6px; \ overflow-x: auto; } .message pre code { background: none; padding: 0; color: inherit; } .message hr { border: none; border-top: 1px solid #3a3a3c; margin: 16px 0; } .message a { color: #6fb1f0; } .message table { border-collapse: collapse; margin: 10px 0; width: 100%; } .message th, .message td { border: 1px solid #3a3a3c; padding: 6px 10px; text-align: left; } .message th { background: #2a2a2c; font-weight: 600; } """ nonisolated static func html(name: String, messages: [Message], forceDarkCSS: Bool = false) -> String { let body = messages.map { msg -> String in let roleLabel = msg.role == .user ? "User" : "Assistant" let roleClass = msg.role == .user ? "user" : "assistant" let rendered = renderMarkdownBody(htmlEscape(msg.content)) return """
\(roleLabel)
\(rendered)
""" }.joined(separator: "\n") return """ \(htmlEscape(name))

\(htmlEscape(name))

\(body) """ } // 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 A4 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, forceDarkCSS: true) let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 850, height: 1100)) webView.pageZoom = 1.0 let delegate = PDFLoadDelegate() webView.navigationDelegate = delegate try await delegate.load(htmlString, in: webView) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".pdf") defer { try? FileManager.default.removeItem(at: tempURL) } let printInfo = NSPrintInfo() printInfo.paperSize = NSSize(width: 595, height: 842) // A4 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 { private var continuation: CheckedContinuation? func load(_ html: String, in webView: WKWebView) async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in self.continuation = cont webView.loadHTMLString(html, baseURL: nil) } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { continuation?.resume() continuation = nil } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { continuation?.resume(throwing: error) continuation = nil } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { continuation?.resume(throwing: error) continuation = nil } } // MARK: - File writing nonisolated static func writeToDownloads(_ content: String, filename: String) -> URL? { let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory let fileURL = downloads.appendingPathComponent(filename) do { try content.write(to: fileURL, atomically: true, encoding: .utf8) return fileURL } catch { return nil } } nonisolated static func writeToDownloads(_ data: Data, filename: String) -> URL? { let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory let fileURL = downloads.appendingPathComponent(filename) do { try data.write(to: fileURL, options: .atomic) return fileURL } catch { return nil } } // MARK: - Markdown → HTML rendering (scoped to what chat messages actually contain — // headers, bold/italic, inline code, fenced code blocks, lists, blockquotes, links, // horizontal rules, tables, paragraphs. Not full CommonMark/GFM.) nonisolated private static func htmlEscape(_ text: String) -> String { var result = text result = result.replacingOccurrences(of: "&", with: "&") result = result.replacingOccurrences(of: "<", with: "<") result = result.replacingOccurrences(of: ">", with: ">") result = result.replacingOccurrences(of: "\"", with: """) result = result.replacingOccurrences(of: "'", with: "'") return result } /// Renders already-HTML-escaped markdown text into an HTML body fragment. nonisolated static func renderMarkdownBody(_ escapedContent: String) -> String { let lines = escapedContent.components(separatedBy: "\n") var html = "" var index = 0 var paragraphLines: [String] = [] var listBuffer: [String] = [] var listTag: String? func flushParagraph() { guard !paragraphLines.isEmpty else { return } let joined = paragraphLines.joined(separator: "
\n") html += "

\(renderInline(joined))

\n" paragraphLines = [] } func flushList() { guard let tag = listTag, !listBuffer.isEmpty else { return } html += "<\(tag)>\n" for item in listBuffer { html += "
  • \(renderInline(item))
  • \n" } html += "\n" listBuffer = [] listTag = nil } while index < lines.count { let line = lines[index].trimmingCharacters(in: .whitespaces) // Fenced code block if line.hasPrefix("```") { flushParagraph(); flushList() let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) var codeLines: [String] = [] index += 1 while index < lines.count, !lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") { codeLines.append(lines[index]) index += 1 } let classAttr = lang.isEmpty ? "" : " class=\"language-\(lang)\"" html += "
    \(codeLines.joined(separator: "\n"))
    \n" if index < lines.count { index += 1 } // skip closing ``` continue } // Headers if let header = headerMatch(line) { flushParagraph(); flushList() html += "\(renderInline(header.text))\n" index += 1 continue } // Horizontal rule if isHorizontalRule(line) { flushParagraph(); flushList() html += "
    \n" index += 1 continue } // GFM-style pipe table: a row line immediately followed by a valid separator row if line.contains("|"), index + 1 < lines.count, isTableSeparatorRow(lines[index + 1].trimmingCharacters(in: .whitespaces)) { flushParagraph(); flushList() let headerCells = splitTableRow(line) let alignments = tableAlignments(from: lines[index + 1].trimmingCharacters(in: .whitespaces)) index += 2 var bodyRows: [[String]] = [] while index < lines.count { let rowLine = lines[index].trimmingCharacters(in: .whitespaces) guard rowLine.contains("|"), !rowLine.isEmpty else { break } bodyRows.append(splitTableRow(rowLine)) index += 1 } html += renderTable(headerCells: headerCells, alignments: alignments, bodyRows: bodyRows) continue } // Blockquote (escaped ">" is ">") if line.hasPrefix("> ") || line == ">" { flushParagraph(); flushList() var quoteLines: [String] = [] while index < lines.count { let quoteLine = lines[index].trimmingCharacters(in: .whitespaces) if quoteLine.hasPrefix("> ") { quoteLines.append(String(quoteLine.dropFirst(5))) } else if quoteLine == ">" { quoteLines.append("") } else { break } index += 1 } html += "

    \(renderInline(quoteLines.joined(separator: "
    \n")))

    \n" continue } // Unordered list if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") { flushParagraph() if listTag == "ol" { flushList() } listTag = "ul" listBuffer.append(String(line.dropFirst(2))) index += 1 continue } // Ordered list if let text = orderedListMatch(line) { flushParagraph() if listTag == "ul" { flushList() } listTag = "ol" listBuffer.append(text) index += 1 continue } // Blank line — paragraph/list separator if line.isEmpty { flushParagraph(); flushList() index += 1 continue } // Plain paragraph text flushList() paragraphLines.append(line) index += 1 } flushParagraph() flushList() return html } nonisolated private static func headerMatch(_ line: String) -> (level: Int, text: String)? { var level = 0 var idx = line.startIndex while idx < line.endIndex, line[idx] == "#", level < 6 { level += 1 idx = line.index(after: idx) } guard level > 0, idx < line.endIndex, line[idx] == " " else { return nil } return (level, String(line[line.index(after: idx)...])) } nonisolated private static func orderedListMatch(_ line: String) -> String? { guard let dotRange = line.range(of: ". ") else { return nil } let prefix = line[line.startIndex.. Bool { guard line.count >= 3 else { return false } return line.allSatisfy { $0 == "-" } || line.allSatisfy { $0 == "*" } || line.allSatisfy { $0 == "_" } } /// A GFM table separator row looks like `| --- | :--: | ---: |` — pipe-delimited cells /// made up of dashes with optional leading/trailing colons for alignment. nonisolated private static func isTableSeparatorRow(_ line: String) -> Bool { guard line.contains("-") else { return false } let cells = splitTableRow(line) guard !cells.isEmpty else { return false } return cells.allSatisfy { cell in var core = cell.trimmingCharacters(in: .whitespaces) guard !core.isEmpty else { return false } if core.hasPrefix(":") { core.removeFirst() } if core.hasSuffix(":") { core.removeLast() } return !core.isEmpty && core.allSatisfy { $0 == "-" } } } nonisolated private static func splitTableRow(_ line: String) -> [String] { var trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("|") { trimmed.removeFirst() } if trimmed.hasSuffix("|") { trimmed.removeLast() } return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) } } nonisolated private static func tableAlignments(from separatorLine: String) -> [String] { splitTableRow(separatorLine).map { cell in let left = cell.hasPrefix(":") let right = cell.hasSuffix(":") if left && right { return "center" } if right { return "right" } if left { return "left" } return "" } } nonisolated private static func renderTable(headerCells: [String], alignments: [String], bodyRows: [[String]]) -> String { func alignAttr(_ i: Int) -> String { guard i < alignments.count, !alignments[i].isEmpty else { return "" } return " style=\"text-align:\(alignments[i])\"" } var html = "\n\n\n" for (i, cell) in headerCells.enumerated() { html += "\(renderInline(cell))\n" } html += "\n\n\n" for row in bodyRows { html += "\n" for (i, cell) in row.enumerated() { html += "\(renderInline(cell))\n" } html += "\n" } html += "\n
    \n" return html } /// Applies inline markdown (code, links, bold, italic) to an already-HTML-escaped line. nonisolated private static func renderInline(_ escapedText: String) -> String { var text = escapedText var codeSpans: [String] = [] text = replacingCaptures(text, pattern: #"`([^`]+?)`"#) { match in let token = "\u{E000}\(codeSpans.count)\u{E000}" codeSpans.append("\(match)") return token } text = replacingCaptures(text, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#, groups: 2) { groups in "\(groups[0])" } text = replacingCaptures(text, pattern: #"\*\*([^*]+?)\*\*"#) { "\($0)" } text = replacingCaptures(text, pattern: #"__([^_]+?)__"#) { "\($0)" } text = replacingCaptures(text, pattern: #"\*([^*]+?)\*"#) { "\($0)" } for (i, span) in codeSpans.enumerated() { text = text.replacingOccurrences(of: "\u{E000}\(i)\u{E000}", with: span) } return text } nonisolated private static func replacingCaptures( _ text: String, pattern: String, transform: @escaping (String) -> String ) -> String { guard let regex = try? Regex(pattern) else { return text } return text.replacing(regex) { match in transform(match.output.count > 1 ? String(match.output[1].substring ?? "") : "") } } nonisolated private static func replacingCaptures( _ text: String, pattern: String, groups: Int, transform: @escaping ([String]) -> String ) -> String { guard let regex = try? Regex(pattern) else { return text } return text.replacing(regex) { match in let captured = (1...groups).map { i in match.output.count > i ? String(match.output[i].substring ?? "") : "" } return transform(captured) } } }