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:
@@ -21,6 +21,7 @@
|
||||
// Olsen via <https://oai.pm>.
|
||||
|
||||
|
||||
import AppKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@@ -67,6 +68,7 @@ enum ConversationExportService {
|
||||
}
|
||||
|
||||
nonisolated private static let css = """
|
||||
:root { color-scheme: light dark; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
|
||||
color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; padding: 0 24px; \
|
||||
line-height: 1.5; }
|
||||
@@ -87,6 +89,22 @@ enum ConversationExportService {
|
||||
.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
|
||||
@@ -94,6 +112,9 @@ enum ConversationExportService {
|
||||
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))
|
||||
// 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)
|
||||
@@ -159,7 +180,7 @@ enum ConversationExportService {
|
||||
|
||||
// MARK: - Markdown → HTML rendering (scoped to what chat messages actually contain —
|
||||
// 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 {
|
||||
var result = text
|
||||
@@ -231,6 +252,24 @@ enum ConversationExportService {
|
||||
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()
|
||||
@@ -311,6 +350,60 @@ enum ConversationExportService {
|
||||
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
|
||||
|
||||
@@ -101,4 +101,35 @@ struct ConversationExportServiceTests {
|
||||
#expect(body.contains("<code>**not bold**</code>"))
|
||||
#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>"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user