2.5.0 #10
@@ -38,36 +38,6 @@ enum ConversationExportService {
|
|||||||
|
|
||||||
// MARK: - HTML
|
// 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 = """
|
nonisolated private static let css = """
|
||||||
:root { color-scheme: light dark; }
|
:root { color-scheme: light dark; }
|
||||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
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
|
// MARK: - PDF
|
||||||
|
|
||||||
enum PDFError: LocalizedError {
|
enum PDFError: LocalizedError {
|
||||||
@@ -128,15 +169,12 @@ enum ConversationExportService {
|
|||||||
/// conversation). That's a fundamentally different, non-standard document shape from any
|
/// 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
|
/// 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
|
/// 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
|
/// real A4 paper size gives genuine multi-page pagination, matching what any other app's
|
||||||
/// app's "export/print to PDF" produces.
|
/// "export/print to PDF" produces.
|
||||||
static func pdfData(name: String, messages: [Message]) async throws -> Data {
|
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))
|
let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 850, height: 1100))
|
||||||
webView.pageZoom = 1.0
|
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()
|
let delegate = PDFLoadDelegate()
|
||||||
webView.navigationDelegate = delegate
|
webView.navigationDelegate = delegate
|
||||||
try await delegate.load(htmlString, in: webView)
|
try await delegate.load(htmlString, in: webView)
|
||||||
|
|||||||
Reference in New Issue
Block a user