2.5.0 #10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user