From b0f3049d6b54a5428ec763d7322d54d9d65215d4 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 29 Jul 2026 12:37:18 +0200 Subject: [PATCH] Make PDF export actually dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were fighting the previous dark-PDF attempt, both confirmed by direct experimentation: - prefers-color-scheme is ignored entirely by the print pipeline — identical CSS printed light even with the webview's appearance forced to dark. Screen dark-mode media queries just don't apply to NSPrintOperation rendering. - Even with dark colors set unconditionally (no media query), the print pipeline still dropped every background color and printed white — browsers/WebKit strip background-color/background-image by default when printing, to save ink, unless told otherwise via print-color-adjust: exact. PDF now has its own always-dark stylesheet (pdfCss) instead of the prefers-color-scheme-driven one HTML export still uses, plus `* { print-color-adjust: exact }` so the dark backgrounds actually survive the print pass. html(name:messages:) takes a new forceDarkCSS parameter (default false, unchanged for HTML/other callers); pdfData passes true. Dropped the now-pointless webView.appearance forcing, since dark is unconditional now. Verified with the same standalone repro-script + Read-the-PDF method: dark page background, colored message boxes, dark code block, all correctly surviving the real A4 print pipeline. --- oAI/Services/ConversationExportService.swift | 110 +++++++++++++------ 1 file changed, 74 insertions(+), 36 deletions(-) diff --git a/oAI/Services/ConversationExportService.swift b/oAI/Services/ConversationExportService.swift index ea886c1..04c7b6d 100644 --- a/oAI/Services/ConversationExportService.swift +++ b/oAI/Services/ConversationExportService.swift @@ -38,36 +38,6 @@ enum ConversationExportService { // 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 """ -
-
\(roleLabel)
- \(rendered) -
- """ - }.joined(separator: "\n") - - return """ - - - - - - \(htmlEscape(name)) - - - -

\(htmlEscape(name))

- \(body) - - - """ - } - nonisolated private static let css = """ :root { color-scheme: light dark; } html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } @@ -114,6 +84,77 @@ enum ConversationExportService { } """ + // PDF is baked at export time — it can't respond to prefers-color-scheme live like an + // HTML file opened in a browser can, and empirically the print pipeline ignores that media + // query entirely regardless (confirmed: identical CSS printed light even with the webview + // forced into dark appearance). So PDF gets its own always-dark stylesheet, applied + // unconditionally rather than behind a media query. print-color-adjust: exact is required + // too — browsers/WebKit's print pipeline strips background colors by default to save ink + // unless told otherwise; without it every background here silently prints white. + nonisolated private static let pdfCss = """ + * { -webkit-print-color-adjust: exact; print-color-adjust: exact; color-adjust: exact; } + :root { color-scheme: 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: #e8e8e8; background: #1c1c1e; max-width: 820px; margin: 40px auto; \ + padding: 0 24px; line-height: 1.5; } + h1 { font-size: 22px; border-bottom: 1px solid #3a3a3c; padding-bottom: 12px; } + .message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; \ + break-inside: avoid; page-break-inside: avoid; } + .message.user { background: #24303d; border-left-color: #5aa2f0; } + .message.assistant { background: #262626; border-left-color: #9a9a9a; } + .role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \ + color: #a8a8a8; 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 #555; color: #bbb; } + .message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \ + background: #2e2e2e; color: #e0e0e0; padding: 1px 5px; border-radius: 4px; } + .message pre { background: #111; 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 #3a3a3c; margin: 16px 0; } + .message a { color: #6fb1f0; } + .message table { border-collapse: collapse; margin: 10px 0; width: 100%; } + .message th, .message td { border: 1px solid #3a3a3c; padding: 6px 10px; text-align: left; } + .message th { background: #2a2a2c; font-weight: 600; } + """ + + nonisolated static func html(name: String, messages: [Message], forceDarkCSS: Bool = false) -> 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 """ +
+
\(roleLabel)
+ \(rendered) +
+ """ + }.joined(separator: "\n") + + return """ + + + + + + \(htmlEscape(name)) + + + +

\(htmlEscape(name))

+ \(body) + + + """ + } + // MARK: - PDF enum PDFError: LocalizedError { @@ -128,15 +169,12 @@ enum ConversationExportService { /// conversation). That's a fundamentally different, non-standard document shape from any /// normal PDF, which is what actually made ordinarily-sized text read as "huge" — there was /// no page of a familiar size to judge it against. Printing through NSPrintOperation with a - /// real US Letter paper size gives genuine multi-page pagination, matching what any other - /// app's "export/print to PDF" produces. + /// real A4 paper size gives genuine multi-page pagination, matching what any other app's + /// "export/print to PDF" produces. static func pdfData(name: String, messages: [Message]) async throws -> Data { - let htmlString = html(name: name, messages: messages) + let htmlString = html(name: name, messages: messages, forceDarkCSS: true) 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)