Add dark mode and GFM table support to HTML/PDF export

Dark: added a prefers-color-scheme: dark CSS block, so HTML export
adapts to the browser/OS theme it's later viewed in. PDF is baked at
export time so it can't respond live — instead the offscreen WKWebView
used for PDF rendering has its appearance explicitly set to
NSApp.effectiveAppearance, matching whatever mode the app is in right
now at export time.

Tables: the renderer had no table support at all (previously
documented as an intentional scope cut), so GFM pipe tables were
falling through to the plain-paragraph path and showing up as literal
"| --- | --- |" text. Added detection (a row line immediately followed
by a valid dashes/colons separator row) plus alignment parsing from
the separator's colons.
This commit is contained in:
2026-07-29 08:08:16 +02:00
parent 634b83f284
commit 53736d4c42
2 changed files with 125 additions and 1 deletions
+94 -1
View File
@@ -21,6 +21,7 @@
// Olsen via <https://oai.pm>. // Olsen via <https://oai.pm>.
import AppKit
import Foundation import Foundation
import WebKit import WebKit
@@ -67,6 +68,7 @@ enum ConversationExportService {
} }
nonisolated private static let css = """ nonisolated private static let css = """
:root { color-scheme: light dark; }
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \ body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; padding: 0 24px; \ color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; padding: 0 24px; \
line-height: 1.5; } line-height: 1.5; }
@@ -87,6 +89,22 @@ enum ConversationExportService {
.message pre code { background: none; padding: 0; color: inherit; } .message pre code { background: none; padding: 0; color: inherit; }
.message hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; } .message hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }
.message a { color: #4a90d9; } .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 // MARK: - PDF
@@ -94,6 +112,9 @@ enum ConversationExportService {
static func pdfData(name: String, messages: [Message]) async throws -> Data { static func pdfData(name: String, messages: [Message]) async throws -> Data {
let htmlString = html(name: name, messages: messages) let htmlString = html(name: name, messages: messages)
let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 816, height: 1056)) let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 816, height: 1056))
// 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() let delegate = PDFLoadDelegate()
webView.navigationDelegate = delegate webView.navigationDelegate = delegate
try await delegate.load(htmlString, in: webView) try await delegate.load(htmlString, in: webView)
@@ -159,7 +180,7 @@ enum ConversationExportService {
// MARK: - Markdown HTML rendering (scoped to what chat messages actually contain // MARK: - Markdown HTML rendering (scoped to what chat messages actually contain
// headers, bold/italic, inline code, fenced code blocks, lists, blockquotes, links, // headers, bold/italic, inline code, fenced code blocks, lists, blockquotes, links,
// horizontal rules, paragraphs. Not full CommonMark/GFM no tables.) // horizontal rules, tables, paragraphs. Not full CommonMark/GFM.)
nonisolated private static func htmlEscape(_ text: String) -> String { nonisolated private static func htmlEscape(_ text: String) -> String {
var result = text var result = text
@@ -231,6 +252,24 @@ enum ConversationExportService {
continue 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;") // Blockquote (escaped ">" is "&gt;")
if line.hasPrefix("&gt; ") || line == "&gt;" { if line.hasPrefix("&gt; ") || line == "&gt;" {
flushParagraph(); flushList() flushParagraph(); flushList()
@@ -311,6 +350,60 @@ enum ConversationExportService {
return line.allSatisfy { $0 == "-" } || line.allSatisfy { $0 == "*" } || line.allSatisfy { $0 == "_" } 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. /// Applies inline markdown (code, links, bold, italic) to an already-HTML-escaped line.
nonisolated private static func renderInline(_ escapedText: String) -> String { nonisolated private static func renderInline(_ escapedText: String) -> String {
var text = escapedText var text = escapedText
@@ -101,4 +101,35 @@ struct ConversationExportServiceTests {
#expect(body.contains("<code>**not bold**</code>")) #expect(body.contains("<code>**not bold**</code>"))
#expect(!body.contains("<strong>")) #expect(!body.contains("<strong>"))
} }
// MARK: - Tables
@Test("Renders a GFM pipe table as a proper HTML table, not raw pipe text")
func rendersPipeTable() {
let markdown = "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |"
let body = ConversationExportService.renderMarkdownBody(markdown)
#expect(body.contains("<table>"))
#expect(body.contains("<th>Name</th>"))
#expect(body.contains("<th>Age</th>"))
#expect(body.contains("<td>Alice</td>"))
#expect(body.contains("<td>30</td>"))
#expect(body.contains("<td>Bob</td>"))
#expect(!body.contains("|---|"))
#expect(!body.contains("| --- |"))
}
@Test("Renders table column alignment from the separator row's colons")
func rendersTableAlignment() {
let markdown = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |"
let body = ConversationExportService.renderMarkdownBody(markdown)
#expect(body.contains("text-align:left"))
#expect(body.contains("text-align:center"))
#expect(body.contains("text-align:right"))
}
@Test("A line containing a pipe with no valid separator row after it is not treated as a table")
func doesNotTreatOrdinaryPipeTextAsTable() {
let body = ConversationExportService.renderMarkdownBody("run `cmd1 | cmd2` to pipe output")
#expect(!body.contains("<table>"))
}
} }