From 53736d4c42fffb8729bdb52d5243c25d5713954b Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 29 Jul 2026 08:08:16 +0200 Subject: [PATCH] Add dark mode and GFM table support to HTML/PDF export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- oAI/Services/ConversationExportService.swift | 95 ++++++++++++++++++- oAITests/ConversationExportServiceTests.swift | 31 ++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/oAI/Services/ConversationExportService.swift b/oAI/Services/ConversationExportService.swift index cbe84a3..5374070 100644 --- a/oAI/Services/ConversationExportService.swift +++ b/oAI/Services/ConversationExportService.swift @@ -21,6 +21,7 @@ // Olsen via . +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 = "\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 diff --git a/oAITests/ConversationExportServiceTests.swift b/oAITests/ConversationExportServiceTests.swift index 1f815e2..4a9bb5a 100644 --- a/oAITests/ConversationExportServiceTests.swift +++ b/oAITests/ConversationExportServiceTests.swift @@ -101,4 +101,35 @@ struct ConversationExportServiceTests { #expect(body.contains("**not bold**")) #expect(!body.contains("")) } + + // 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("")) + #expect(body.contains("")) + #expect(body.contains("")) + #expect(body.contains("")) + #expect(body.contains("")) + #expect(body.contains("")) + #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("
NameAgeAlice30Bob
")) + } }