Make PDF export actually dark

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.
This commit is contained in:
2026-07-29 12:37:18 +02:00
parent 879739b32c
commit b0f3049d6b
+74 -36
View File
@@ -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 """
<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%; }
@@ -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 """
<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>\(forceDarkCSS ? pdfCss : css)</style>
</head>
<body>
<h1>\(htmlEscape(name))</h1>
\(body)
</body>
</html>
"""
}
// 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)