Files
oai-swift/oAI/Services/ConversationExportService.swift
T
rune 2219aceda6 Fix oversized nested headers in exported HTML/PDF
The remaining "text still looks large" complaint after the page-
geometry fix was real: .message h1-h6 only ever set margin, never
font-size, so any markdown header inside an AI response (very common
in formatted answers — "## Reality Check on Capacity" etc) fell back
to the browser's default heading sizes (h2 ≈ 22px+ against a 15px
body). Gave headers an explicit, compact scale (19/17/15.5/14px) and
trimmed the base body size from 15px to 14px and the outer title from
24px to 22px for a tighter, more document-like feel overall.

Verified by reproducing the exact content from the reported screenshot
(the M1 AI Setup conversation with its "Reality Check on Capacity"
h2 and bolded list items) through the same standalone WKWebView/
createPDF harness used for the previous fix, and visually confirming
the heading now renders proportionately instead of oversized.
2026-07-29 11:51:53 +02:00

473 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%; }
::-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; }
.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; }
}
"""
// MARK: - PDF
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
// 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)
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)
}
}
}