Files
oai-swift/oAI/Services/ConversationExportService.swift
T
rune a3fd2c0eab Fix extremely oversized text in PDF export
Three defensive fixes for WKWebView.createPDF() rendering text far
larger than the source HTML's actual font sizes specify:

- Added a viewport meta tag and explicit -webkit-text-size-adjust:
  100% — WebKit's shared engine has a text-autosizing heuristic
  (normally associated with mobile Safari, but can trigger in WKWebView
  contexts too, especially for content loaded via loadHTMLString with
  no base URL) that boosts font sizes for perceived readability; this
  disables it explicitly rather than relying on it not firing.
- Explicit body font-size (was previously unset, relying on WebKit's
  default) and webView.pageZoom = 1.0.
- Resize the webview to the actual rendered content height
  (via document.body.scrollHeight) before calling createPDF, instead
  of relying on its undocumented auto-sizing against the arbitrary
  initial frame — a bounds mismatch here is a known cause of
  blown-up/rescaled PDF output.
2026-07-29 09:01:12 +02:00

469 lines
19 KiB
Swift

//
// ConversationExportService.swift
// oAI
//
// 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 oAI.
//
// oAI 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 oAI 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
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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 static func html(name: String, messages: [Message]) -> 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 """
<div class="message \(roleClass)">
<div class="role">\(roleLabel)</div>
\(rendered)
</div>
"""
}.joined(separator: "\n")
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>\(htmlEscape(name))</title>
<style>\(css)</style>
</head>
<body>
<h1>\(htmlEscape(name))</h1>
\(body)
</body>
</html>
"""
}
nonisolated private static let css = """
:root { color-scheme: light dark; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
font-size: 15px; color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; \
padding: 0 24px; line-height: 1.5; }
h1 { font-size: 24px; border-bottom: 1px solid #ddd; padding-bottom: 12px; }
.message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; }
.message.user { background: #f5f7fa; border-left-color: #4a90d9; }
.message.assistant { background: #fafafa; border-left-color: #8a8a8a; }
.role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \
color: #666; margin-bottom: 8px; }
.message p { margin: 8px 0; }
.message h1, .message h2, .message h3, .message h4, .message h5, .message h6 { 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; }
}
"""
// MARK: - PDF
static func pdfData(name: String, messages: [Message]) async throws -> Data {
let htmlString = html(name: name, messages: messages)
let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 816, height: 1056))
webView.pageZoom = 1.0
// PDF content is baked at export time, so it can't respond to prefers-color-scheme
// live like the HTML export does — match whatever appearance the app is in right now.
webView.appearance = NSApp.effectiveAppearance
let delegate = PDFLoadDelegate()
webView.navigationDelegate = delegate
try await delegate.load(htmlString, in: webView)
// Resize to the actual rendered content height so createPDF's capture bounds match
// reality, rather than relying on its undocumented auto-sizing against the arbitrary
// initial frame — a mismatch here is a common cause of blown-up/rescaled PDF output.
if let heightValue = try? await webView.evaluateJavaScript("document.body.scrollHeight"),
let height = (heightValue as? NSNumber)?.doubleValue, height > 0 {
webView.frame = NSRect(x: 0, y: 0, width: webView.frame.width, height: height)
}
return try await withCheckedThrowingContinuation { continuation in
webView.createPDF(configuration: WKPDFConfiguration()) { result in
continuation.resume(with: result)
}
}
}
private final class PDFLoadDelegate: NSObject, WKNavigationDelegate {
private var continuation: CheckedContinuation<Void, Error>?
func load(_ html: String, in webView: WKWebView) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) 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: "&amp;")
result = result.replacingOccurrences(of: "<", with: "&lt;")
result = result.replacingOccurrences(of: ">", with: "&gt;")
result = result.replacingOccurrences(of: "\"", with: "&quot;")
result = result.replacingOccurrences(of: "'", with: "&#39;")
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: "<br>\n")
html += "<p>\(renderInline(joined))</p>\n"
paragraphLines = []
}
func flushList() {
guard let tag = listTag, !listBuffer.isEmpty else { return }
html += "<\(tag)>\n"
for item in listBuffer { html += "<li>\(renderInline(item))</li>\n" }
html += "</\(tag)>\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 += "<pre><code\(classAttr)>\(codeLines.joined(separator: "\n"))</code></pre>\n"
if index < lines.count { index += 1 } // skip closing ```
continue
}
// Headers
if let header = headerMatch(line) {
flushParagraph(); flushList()
html += "<h\(header.level)>\(renderInline(header.text))</h\(header.level)>\n"
index += 1
continue
}
// Horizontal rule
if isHorizontalRule(line) {
flushParagraph(); flushList()
html += "<hr>\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 "&gt;")
if line.hasPrefix("&gt; ") || line == "&gt;" {
flushParagraph(); flushList()
var quoteLines: [String] = []
while index < lines.count {
let quoteLine = lines[index].trimmingCharacters(in: .whitespaces)
if quoteLine.hasPrefix("&gt; ") {
quoteLines.append(String(quoteLine.dropFirst(5)))
} else if quoteLine == "&gt;" {
quoteLines.append("")
} else {
break
}
index += 1
}
html += "<blockquote><p>\(renderInline(quoteLines.joined(separator: "<br>\n")))</p></blockquote>\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..<dotRange.lowerBound]
guard !prefix.isEmpty, prefix.allSatisfy(\.isNumber) else { return nil }
return String(line[dotRange.upperBound...])
}
nonisolated private static func isHorizontalRule(_ line: String) -> 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 = "<table>\n<thead>\n<tr>\n"
for (i, cell) in headerCells.enumerated() {
html += "<th\(alignAttr(i))>\(renderInline(cell))</th>\n"
}
html += "</tr>\n</thead>\n<tbody>\n"
for row in bodyRows {
html += "<tr>\n"
for (i, cell) in row.enumerated() {
html += "<td\(alignAttr(i))>\(renderInline(cell))</td>\n"
}
html += "</tr>\n"
}
html += "</tbody>\n</table>\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("<code>\(match)</code>")
return token
}
text = replacingCaptures(text, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#, groups: 2) { groups in
"<a href=\"\(groups[1])\">\(groups[0])</a>"
}
text = replacingCaptures(text, pattern: #"\*\*([^*]+?)\*\*"#) { "<strong>\($0)</strong>" }
text = replacingCaptures(text, pattern: #"__([^_]+?)__"#) { "<strong>\($0)</strong>" }
text = replacingCaptures(text, pattern: #"\*([^*]+?)\*"#) { "<em>\($0)</em>" }
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)
}
}
}