import Foundation import SwiftUI import UniformTypeIdentifiers #if os(macOS) import AppKit #endif extension ContentView { enum MarkdownPDFExportMode: String { case paginatedFit = "paginated-fit" case onePageFit = "one-page-fit" } enum MarkdownPreviewBackgroundStyle: String, CaseIterable, Identifiable { case automatic case template case translucent case neutral var id: String { rawValue } var title: String { switch self { case .automatic: return "Automatic" case .template: return "Template" case .translucent: return "Translucent" case .neutral: return "Neutral" } } } var markdownPDFExportMode: MarkdownPDFExportMode { MarkdownPDFExportMode(rawValue: markdownPDFExportModeRaw) ?? .paginatedFit } var markdownPDFRendererMode: MarkdownPreviewPDFRenderer.ExportMode { switch markdownPDFExportMode { case .onePageFit: return .onePageFit case .paginatedFit: return .paginatedFit } } var markdownPreviewTemplate: String { switch markdownPreviewTemplateRaw { case "docs", "article", "compact", "github-docs", "academic-paper", "terminal-notes", "magazine", "minimal-reader", "presentation", "night-contrast", "warm-sepia", "dense-compact", "developer-spec": return markdownPreviewTemplateRaw default: return "default" } } var markdownPreviewBackgroundStyle: MarkdownPreviewBackgroundStyle { MarkdownPreviewBackgroundStyle(rawValue: markdownPreviewBackgroundStyleRaw) ?? .automatic } var markdownPreviewPreferDarkMode: Bool { if let forcedScheme = ReleaseRuntimePolicy.preferredColorScheme(for: appearance) { return forcedScheme == .dark } return colorScheme == .dark } @MainActor func exportMarkdownPreviewPDF() { Task { @MainActor in do { let exportSource = await markdownExportSourceText() let html = markdownPreviewExportHTML(from: exportSource, mode: markdownPDFExportMode) guard markdownExportHasContrastContract(html) else { throw NSError( domain: "MarkdownPreviewExport", code: 1, userInfo: [NSLocalizedDescriptionKey: "PDF export contrast guard failed."] ) } let pdfData = try await MarkdownPreviewPDFRenderer.render( html: html, mode: markdownPDFRendererMode ) let filename = suggestedMarkdownPDFFilename() #if os(macOS) try saveMarkdownPreviewPDFOnMac(pdfData, suggestedFilename: filename) showMarkdownPreviewActionStatus( String( format: NSLocalizedString("Markdown Preview Exported PDF: %@", comment: ""), filename ) ) #else markdownPDFExportDocument = PDFExportDocument(data: pdfData) markdownPDFExportFilename = filename showMarkdownPDFExporter = true showMarkdownPreviewActionStatus( String( format: NSLocalizedString("Markdown Preview Ready PDF: %@", comment: ""), filename ) ) #endif } catch { markdownPDFExportErrorMessage = error.localizedDescription } } } @MainActor func markdownExportSourceText() async -> String { guard let fileURL = viewModel.selectedTab?.fileURL else { return currentContent } let fallback = currentContent return await Task.detached(priority: .userInitiated) { let didAccess = fileURL.startAccessingSecurityScopedResource() defer { if didAccess { fileURL.stopAccessingSecurityScopedResource() } } guard let data = try? Data(contentsOf: fileURL, options: [.mappedIfSafe]) else { return fallback } if let utf8 = String(data: data, encoding: .utf8) { return utf8 } if let utf16LE = String(data: data, encoding: .utf16LittleEndian) { return utf16LE } if let utf16BE = String(data: data, encoding: .utf16BigEndian) { return utf16BE } if let utf32LE = String(data: data, encoding: .utf32LittleEndian) { return utf32LE } if let utf32BE = String(data: data, encoding: .utf32BigEndian) { return utf32BE } return String(decoding: data, as: UTF8.self) }.value } func suggestedMarkdownPDFFilename() -> String { let tabName = viewModel.selectedTab?.name ?? "Markdown-Preview" let rawName = URL(fileURLWithPath: tabName).deletingPathExtension().lastPathComponent let safeBase = rawName.isEmpty ? "Markdown-Preview" : rawName return "\(safeBase)-Preview.pdf" } func suggestedMarkdownPreviewBaseName() -> String { let tabName = viewModel.selectedTab?.name ?? "Markdown-Preview" let rawName = URL(fileURLWithPath: tabName).deletingPathExtension().lastPathComponent return rawName.isEmpty ? "Markdown-Preview" : rawName } var markdownPreviewShareHTML: String { markdownPreviewExportHTML(from: currentContent, mode: markdownPDFExportMode) } @MainActor func showMarkdownPreviewActionStatus(_ message: String, duration: TimeInterval = 2.0) { let token = UUID() markdownPreviewActionStatusToken = token markdownPreviewActionStatusMessage = message DispatchQueue.main.asyncAfter(deadline: .now() + duration) { Task { @MainActor in guard markdownPreviewActionStatusToken == token else { return } markdownPreviewActionStatusMessage = "" } } } @MainActor func copyMarkdownPreviewHTML() { #if os(macOS) NSPasteboard.general.clearContents() NSPasteboard.general.setString(markdownPreviewShareHTML, forType: .string) #elseif os(iOS) UIPasteboard.general.setValue(markdownPreviewShareHTML, forPasteboardType: UTType.html.identifier) UIPasteboard.general.string = markdownPreviewShareHTML #endif showMarkdownPreviewActionStatus(NSLocalizedString("Markdown Preview Copied HTML", comment: "")) } @MainActor func copyMarkdownPreviewMarkdown() { #if os(macOS) NSPasteboard.general.clearContents() NSPasteboard.general.setString(currentContent, forType: .string) #elseif os(iOS) UIPasteboard.general.string = currentContent #endif showMarkdownPreviewActionStatus(NSLocalizedString("Markdown Preview Copied Markdown", comment: "")) } #if os(macOS) @MainActor func saveMarkdownPreviewPDFOnMac(_ data: Data, suggestedFilename: String) throws { let panel = NSSavePanel() panel.title = "Export Markdown Preview as PDF" panel.nameFieldStringValue = suggestedFilename panel.canCreateDirectories = true panel.allowedContentTypes = [.pdf] guard panel.runModal() == .OK else { return } guard let destinationURL = panel.url else { return } try data.write(to: destinationURL, options: .atomic) } #endif var markdownPreviewRenderByteLimit: Int { 180_000 } var markdownPreviewFallbackCharacterLimit: Int { 120_000 } func markdownPreviewHTML(from markdownText: String, preferDarkMode: Bool) -> String { let bodyHTML = markdownPreviewBodyHTML(from: markdownText, useRenderLimits: true) return """
\(escapedHTML(markdownText))" } return renderedMarkdownBodyHTML(from: markdownText) ?? "
\(escapedHTML(markdownText))" } func largeMarkdownFallbackHTML(from markdownText: String, byteCount: Int) -> String { let previewText = String(markdownText.prefix(markdownPreviewFallbackCharacterLimit)) let truncated = previewText.count < markdownText.count let statusSuffix = truncated ? " (truncated preview)" : "" return """
Large Markdown file
\(escapedHTML(previewText))""" } func renderedMarkdownBodyHTML(from markdownText: String) -> String? { let html = simpleMarkdownToHTML(markdownText).trimmingCharacters(in: .whitespacesAndNewlines) return html.isEmpty ? nil : html } func simpleMarkdownToHTML(_ markdown: String) -> String { let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n") var result: [String] = [] var paragraphLines: [String] = [] var insideCodeFence = false var codeFenceLanguage: String? var insideUnorderedList = false var insideOrderedList = false var insideBlockquote = false func flushParagraph() { guard !paragraphLines.isEmpty else { return } let paragraph = paragraphLines.map { inlineMarkdownToHTML($0) }.joined(separator: "
\(paragraph)
") paragraphLines.removeAll(keepingCapacity: true) } func closeLists() { if insideUnorderedList { result.append("") insideUnorderedList = false } if insideOrderedList { result.append("") insideOrderedList = false } } func closeBlockquote() { if insideBlockquote { flushParagraph() closeLists() result.append("") insideBlockquote = false } } func closeParagraphAndInlineContainers() { flushParagraph() closeLists() } for rawLine in lines { let trimmed = rawLine.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("```") { if insideCodeFence { result.append("") insideCodeFence = false codeFenceLanguage = nil } else { closeBlockquote() closeParagraphAndInlineContainers() insideCodeFence = true let lang = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) codeFenceLanguage = lang.isEmpty ? nil : lang if let codeFenceLanguage { result.append("")
} else {
result.append("")
}
}
continue
}
if insideCodeFence {
result.append("\(escapedHTML(rawLine))\n")
continue
}
if trimmed.isEmpty {
closeParagraphAndInlineContainers()
closeBlockquote()
continue
}
if let heading = markdownHeading(from: trimmed) {
closeBlockquote()
closeParagraphAndInlineContainers()
result.append("\(inlineMarkdownToHTML(heading.text)) ")
continue
}
if isMarkdownHorizontalRule(trimmed) {
closeBlockquote()
closeParagraphAndInlineContainers()
result.append("
")
continue
}
var workingLine = trimmed
let isBlockquoteLine = workingLine.hasPrefix(">")
if isBlockquoteLine {
if !insideBlockquote {
closeParagraphAndInlineContainers()
result.append("")
insideBlockquote = true
}
workingLine = workingLine.dropFirst().trimmingCharacters(in: .whitespaces)
} else {
closeBlockquote()
}
if let unordered = markdownUnorderedListItem(from: workingLine) {
flushParagraph()
if insideOrderedList {
result.append("")
insideOrderedList = false
}
if !insideUnorderedList {
result.append("")
insideUnorderedList = true
}
result.append("- \(inlineMarkdownToHTML(unordered))
")
continue
}
if let ordered = markdownOrderedListItem(from: workingLine) {
flushParagraph()
if insideUnorderedList {
result.append("
")
insideUnorderedList = false
}
if !insideOrderedList {
result.append("")
insideOrderedList = true
}
result.append("- \(inlineMarkdownToHTML(ordered))
")
continue
}
closeLists()
paragraphLines.append(workingLine)
}
closeBlockquote()
closeParagraphAndInlineContainers()
if insideCodeFence {
result.append("
")
}
return result.joined(separator: "\n")
}
func markdownHeading(from line: String) -> (level: Int, text: String)? {
let pattern = "^(#{1,6})\\s+(.+)$"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(line.startIndex..., in: line)
guard let match = regex.firstMatch(in: line, options: [], range: range),
let hashesRange = Range(match.range(at: 1), in: line),
let textRange = Range(match.range(at: 2), in: line) else {
return nil
}
return (line[hashesRange].count, String(line[textRange]))
}
func isMarkdownHorizontalRule(_ line: String) -> Bool {
let compact = line.replacingOccurrences(of: " ", with: "")
return compact == "***" || compact == "---" || compact == "___"
}
func markdownUnorderedListItem(from line: String) -> String? {
let pattern = "^[-*+]\\s+(.+)$"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(line.startIndex..., in: line)
guard let match = regex.firstMatch(in: line, options: [], range: range),
let textRange = Range(match.range(at: 1), in: line) else {
return nil
}
return String(line[textRange])
}
func markdownOrderedListItem(from line: String) -> String? {
let pattern = "^\\d+[\\.)]\\s+(.+)$"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(line.startIndex..., in: line)
guard let match = regex.firstMatch(in: line, options: [], range: range),
let textRange = Range(match.range(at: 1), in: line) else {
return nil
}
return String(line[textRange])
}
func inlineMarkdownToHTML(_ text: String) -> String {
var html = escapedHTML(text)
var codeSpans: [String] = []
let codeSpanTokenPrefix = "%%CODESPAN"
let codeSpanTokenSuffix = "%%"
html = replacingRegex(in: html, pattern: "`([^`]+)`") { match in
let content = String(match.dropFirst().dropLast())
let token = "\(codeSpanTokenPrefix)\(codeSpans.count)\(codeSpanTokenSuffix)"
codeSpans.append("\(content)")
return token
}
html = replacingRegex(in: html, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)") { match in
let parts = captureGroups(in: match, pattern: "!\\[([^\\]]*)\\]\\(([^\\)\\s]+)\\)")
guard parts.count == 2 else { return match }
return "
"
}
html = replacingRegex(in: html, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)") { match in
let parts = captureGroups(in: match, pattern: "\\[([^\\]]+)\\]\\(([^\\)\\s]+)\\)")
guard parts.count == 2 else { return match }
return "\(parts[0])"
}
html = replacingRegex(in: html, pattern: "\\*\\*([^*]+)\\*\\*") { "\(String($0.dropFirst(2).dropLast(2)))" }
html = replacingRegex(in: html, pattern: "__([^_]+)__") { "\(String($0.dropFirst(2).dropLast(2)))" }
html = replacingRegex(in: html, pattern: "\\*([^*]+)\\*") { "\(String($0.dropFirst().dropLast()))" }
html = replacingRegex(in: html, pattern: "_([^_]+)_") { "\(String($0.dropFirst().dropLast()))" }
for (index, codeHTML) in codeSpans.enumerated() {
html = html.replacingOccurrences(
of: "\(codeSpanTokenPrefix)\(index)\(codeSpanTokenSuffix)",
with: codeHTML
)
}
return html
}
func replacingRegex(in text: String, pattern: String, transform: (String) -> String) -> String {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
let matches = regex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text))
guard !matches.isEmpty else { return text }
var output = text
for match in matches.reversed() {
guard let range = Range(match.range, in: output) else { continue }
let segment = String(output[range])
output.replaceSubrange(range, with: transform(segment))
}
return output
}
func captureGroups(in text: String, pattern: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: text, options: [], range: NSRange(text.startIndex..., in: text)) else {
return []
}
var groups: [String] = []
for idx in 1.. String {
let basePadding: String
let fontSize: String
let lineHeight: String
let maxWidth: String
var bodyBackground: String
var contentBackground: String
var contentBorder: String
let textColor: String
let mutedTextColor: String
let linkColor: String
let codeBackground: String
let codeBorder: String
let quoteBackground: String
let quoteBorder: String
let tableHeaderBackground: String
let horizontalRuleColor: String
var shadowColor: String
let bodyFontFamily: String
var contentBackdropFilter = "none"
switch template {
case "docs":
basePadding = "22px 30px"
fontSize = "15px"
lineHeight = "1.7"
maxWidth = "900px"
bodyBackground = preferDarkMode ? "#0f172a" : "#f8fafc"
contentBackground = preferDarkMode ? "#111827" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #1f2937" : "1px solid #e5e7eb"
textColor = preferDarkMode ? "#e5e7eb" : "#111827"
mutedTextColor = preferDarkMode ? "#94a3b8" : "#6b7280"
linkColor = preferDarkMode ? "#93c5fd" : "#2563eb"
codeBackground = preferDarkMode ? "#0b1220" : "#f3f4f6"
codeBorder = preferDarkMode ? "#334155" : "#d1d5db"
quoteBackground = preferDarkMode ? "#0b1220" : "#f8fafc"
quoteBorder = preferDarkMode ? "#3b82f6" : "#2563eb"
tableHeaderBackground = preferDarkMode ? "#172033" : "#f3f4f6"
horizontalRuleColor = preferDarkMode ? "#334155" : "#d1d5db"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.25)" : "rgba(15, 23, 42, 0.06)"
bodyFontFamily = "-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Helvetica Neue\", sans-serif"
case "article":
basePadding = "32px 48px"
fontSize = "17px"
lineHeight = "1.8"
maxWidth = "760px"
bodyBackground = preferDarkMode ? "#111827" : "#f9fafb"
contentBackground = preferDarkMode ? "#0f172a" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #1f2937" : "1px solid #e5e7eb"
textColor = preferDarkMode ? "#f3f4f6" : "#111827"
mutedTextColor = preferDarkMode ? "#9ca3af" : "#6b7280"
linkColor = preferDarkMode ? "#c4b5fd" : "#7c3aed"
codeBackground = preferDarkMode ? "#111827" : "#f3f4f6"
codeBorder = preferDarkMode ? "#374151" : "#d1d5db"
quoteBackground = preferDarkMode ? "#111827" : "#faf5ff"
quoteBorder = preferDarkMode ? "#8b5cf6" : "#7c3aed"
tableHeaderBackground = preferDarkMode ? "#172033" : "#f5f3ff"
horizontalRuleColor = preferDarkMode ? "#374151" : "#d1d5db"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.28)" : "rgba(15, 23, 42, 0.07)"
bodyFontFamily = "Charter, \"Iowan Old Style\", \"Palatino Linotype\", serif"
case "compact", "dense-compact":
basePadding = "14px 16px"
fontSize = "13px"
lineHeight = "1.5"
maxWidth = "none"
bodyBackground = preferDarkMode ? "#0f172a" : "#f8fafc"
contentBackground = preferDarkMode ? "#111827" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #1f2937" : "1px solid #e5e7eb"
textColor = preferDarkMode ? "#e5e7eb" : "#111827"
mutedTextColor = preferDarkMode ? "#94a3b8" : "#6b7280"
linkColor = preferDarkMode ? "#7dd3fc" : "#0284c7"
codeBackground = preferDarkMode ? "#0b1220" : "#eef2ff"
codeBorder = preferDarkMode ? "#334155" : "#c7d2fe"
quoteBackground = preferDarkMode ? "#111827" : "#f8fafc"
quoteBorder = preferDarkMode ? "#38bdf8" : "#0284c7"
tableHeaderBackground = preferDarkMode ? "#172033" : "#f3f4f6"
horizontalRuleColor = preferDarkMode ? "#334155" : "#d1d5db"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.22)" : "rgba(15, 23, 42, 0.05)"
bodyFontFamily = "-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Helvetica Neue\", sans-serif"
case "github-docs":
basePadding = "24px 28px"
fontSize = "14px"
lineHeight = "1.65"
maxWidth = "920px"
bodyBackground = preferDarkMode ? "#0d1117" : "#f6f8fa"
contentBackground = preferDarkMode ? "#161b22" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #30363d" : "1px solid #d0d7de"
textColor = preferDarkMode ? "#c9d1d9" : "#1f2328"
mutedTextColor = preferDarkMode ? "#8b949e" : "#57606a"
linkColor = preferDarkMode ? "#58a6ff" : "#0969da"
codeBackground = preferDarkMode ? "#0d1117" : "#f6f8fa"
codeBorder = preferDarkMode ? "#30363d" : "#d0d7de"
quoteBackground = preferDarkMode ? "#0d1117" : "#f6f8fa"
quoteBorder = preferDarkMode ? "#30363d" : "#d0d7de"
tableHeaderBackground = preferDarkMode ? "#21262d" : "#f6f8fa"
horizontalRuleColor = preferDarkMode ? "#30363d" : "#d8dee4"
shadowColor = preferDarkMode ? "rgba(1, 4, 9, 0.28)" : "rgba(31, 35, 40, 0.04)"
bodyFontFamily = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif"
case "academic-paper":
basePadding = "40px 54px"
fontSize = "16px"
lineHeight = "1.85"
maxWidth = "780px"
bodyBackground = preferDarkMode ? "#161311" : "#f6f1e8"
contentBackground = preferDarkMode ? "#1f1a17" : "#fffdf8"
contentBorder = preferDarkMode ? "1px solid #3a312b" : "1px solid #e7dccb"
textColor = preferDarkMode ? "#f5efe4" : "#2f241c"
mutedTextColor = preferDarkMode ? "#c2b4a3" : "#7a6755"
linkColor = preferDarkMode ? "#fbbf24" : "#9a6700"
codeBackground = preferDarkMode ? "#191512" : "#f3eadc"
codeBorder = preferDarkMode ? "#4b3f36" : "#dbcab0"
quoteBackground = preferDarkMode ? "#191512" : "#f8efe0"
quoteBorder = preferDarkMode ? "#c08457" : "#b7791f"
tableHeaderBackground = preferDarkMode ? "#2b241f" : "#efe3d3"
horizontalRuleColor = preferDarkMode ? "#4b3f36" : "#d6c3aa"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.24)" : "rgba(120, 103, 85, 0.08)"
bodyFontFamily = "\"New York\", Georgia, \"Times New Roman\", serif"
case "terminal-notes":
basePadding = "18px 20px"
fontSize = "14px"
lineHeight = "1.55"
maxWidth = "940px"
bodyBackground = preferDarkMode ? "#08110c" : "#f3fbf5"
contentBackground = preferDarkMode ? "#0b1510" : "#fbfffc"
contentBorder = preferDarkMode ? "1px solid #173022" : "1px solid #cfe7d5"
textColor = preferDarkMode ? "#c8facc" : "#16301f"
mutedTextColor = preferDarkMode ? "#7bcf90" : "#4b6b55"
linkColor = preferDarkMode ? "#5eead4" : "#0f766e"
codeBackground = preferDarkMode ? "#07100c" : "#e8f6eb"
codeBorder = preferDarkMode ? "#214433" : "#b9d8c0"
quoteBackground = preferDarkMode ? "#09130e" : "#eef8f0"
quoteBorder = preferDarkMode ? "#22c55e" : "#15803d"
tableHeaderBackground = preferDarkMode ? "#102119" : "#e5f3e8"
horizontalRuleColor = preferDarkMode ? "#214433" : "#b9d8c0"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.18)" : "rgba(22, 48, 31, 0.05)"
bodyFontFamily = "\"SF Mono\", Menlo, Monaco, monospace"
case "magazine":
basePadding = "34px 42px"
fontSize = "17px"
lineHeight = "1.75"
maxWidth = "900px"
bodyBackground = preferDarkMode ? "#111827" : "#fff7ed"
contentBackground = preferDarkMode ? "#1f2937" : "#fffdf8"
contentBorder = preferDarkMode ? "1px solid #374151" : "1px solid #fed7aa"
textColor = preferDarkMode ? "#f9fafb" : "#231815"
mutedTextColor = preferDarkMode ? "#d1d5db" : "#7c5e54"
linkColor = preferDarkMode ? "#fda4af" : "#c2410c"
codeBackground = preferDarkMode ? "#111827" : "#fff1e6"
codeBorder = preferDarkMode ? "#4b5563" : "#fdba74"
quoteBackground = preferDarkMode ? "#172033" : "#ffedd5"
quoteBorder = preferDarkMode ? "#fb7185" : "#ea580c"
tableHeaderBackground = preferDarkMode ? "#243044" : "#ffedd5"
horizontalRuleColor = preferDarkMode ? "#4b5563" : "#fdba74"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.26)" : "rgba(194, 65, 12, 0.07)"
bodyFontFamily = "\"Avenir Next\", \"Helvetica Neue\", sans-serif"
case "minimal-reader":
basePadding = "26px 28px"
fontSize = "15px"
lineHeight = "1.72"
maxWidth = "720px"
bodyBackground = preferDarkMode ? "#0f172a" : "#ffffff"
contentBackground = preferDarkMode ? "#111827" : "#ffffff"
contentBorder = "none"
textColor = preferDarkMode ? "#e5e7eb" : "#111827"
mutedTextColor = preferDarkMode ? "#9ca3af" : "#6b7280"
linkColor = preferDarkMode ? "#a5b4fc" : "#4338ca"
codeBackground = preferDarkMode ? "#111827" : "#f3f4f6"
codeBorder = preferDarkMode ? "#374151" : "#e5e7eb"
quoteBackground = preferDarkMode ? "#111827" : "#f9fafb"
quoteBorder = preferDarkMode ? "#6366f1" : "#4338ca"
tableHeaderBackground = preferDarkMode ? "#1f2937" : "#f9fafb"
horizontalRuleColor = preferDarkMode ? "#374151" : "#e5e7eb"
shadowColor = "transparent"
bodyFontFamily = "-apple-system, BlinkMacSystemFont, \"SF Pro Text\", sans-serif"
case "presentation":
basePadding = "40px 48px"
fontSize = "18px"
lineHeight = "1.7"
maxWidth = "1040px"
bodyBackground = preferDarkMode ? "#0b1020" : "#eef4ff"
contentBackground = preferDarkMode ? "#0f172a" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #1e293b" : "1px solid #c7d2fe"
textColor = preferDarkMode ? "#f8fafc" : "#0f172a"
mutedTextColor = preferDarkMode ? "#cbd5e1" : "#64748b"
linkColor = preferDarkMode ? "#93c5fd" : "#1d4ed8"
codeBackground = preferDarkMode ? "#111827" : "#eef2ff"
codeBorder = preferDarkMode ? "#334155" : "#c7d2fe"
quoteBackground = preferDarkMode ? "#111827" : "#eff6ff"
quoteBorder = preferDarkMode ? "#60a5fa" : "#2563eb"
tableHeaderBackground = preferDarkMode ? "#172033" : "#dbeafe"
horizontalRuleColor = preferDarkMode ? "#334155" : "#bfdbfe"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.32)" : "rgba(37, 99, 235, 0.08)"
bodyFontFamily = "\"SF Pro Display\", -apple-system, BlinkMacSystemFont, sans-serif"
case "night-contrast":
basePadding = "22px 26px"
fontSize = "15px"
lineHeight = "1.68"
maxWidth = "920px"
bodyBackground = preferDarkMode ? "linear-gradient(180deg, #020617 0%, #050816 100%)" : "#eff6ff"
contentBackground = preferDarkMode ? "#020617" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #1e293b" : "1px solid #bfdbfe"
textColor = preferDarkMode ? "#f8fafc" : "#0f172a"
mutedTextColor = preferDarkMode ? "#cbd5e1" : "#64748b"
linkColor = preferDarkMode ? "#7dd3fc" : "#0369a1"
codeBackground = preferDarkMode ? "#0f172a" : "#eff6ff"
codeBorder = preferDarkMode ? "#334155" : "#bfdbfe"
quoteBackground = preferDarkMode ? "#0b1220" : "#e0f2fe"
quoteBorder = preferDarkMode ? "#38bdf8" : "#0284c7"
tableHeaderBackground = preferDarkMode ? "#172033" : "#dbeafe"
horizontalRuleColor = preferDarkMode ? "#334155" : "#bfdbfe"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.34)" : "rgba(3, 105, 161, 0.07)"
bodyFontFamily = "-apple-system, BlinkMacSystemFont, \"SF Pro Text\", sans-serif"
case "warm-sepia":
basePadding = "24px 28px"
fontSize = "16px"
lineHeight = "1.74"
maxWidth = "820px"
bodyBackground = preferDarkMode ? "#221b16" : "#f8f1e3"
contentBackground = preferDarkMode ? "#2c241d" : "#fffaf0"
contentBorder = preferDarkMode ? "1px solid #4a3c30" : "1px solid #e6d4b8"
textColor = preferDarkMode ? "#f4e7d3" : "#3f2d1f"
mutedTextColor = preferDarkMode ? "#d8c1a1" : "#7c6247"
linkColor = preferDarkMode ? "#fbbf24" : "#b45309"
codeBackground = preferDarkMode ? "#201913" : "#f3e7d4"
codeBorder = preferDarkMode ? "#5a493b" : "#dec5a0"
quoteBackground = preferDarkMode ? "#241d17" : "#f5ead9"
quoteBorder = preferDarkMode ? "#f59e0b" : "#b45309"
tableHeaderBackground = preferDarkMode ? "#332920" : "#efe0c6"
horizontalRuleColor = preferDarkMode ? "#5a493b" : "#dec5a0"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.22)" : "rgba(126, 98, 71, 0.08)"
bodyFontFamily = "Charter, Georgia, serif"
case "developer-spec":
basePadding = "22px 24px"
fontSize = "14px"
lineHeight = "1.62"
maxWidth = "980px"
bodyBackground = preferDarkMode ? "#0f172a" : "#f5f7fb"
contentBackground = preferDarkMode ? "#111827" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #334155" : "1px solid #dbe1ea"
textColor = preferDarkMode ? "#e2e8f0" : "#0f172a"
mutedTextColor = preferDarkMode ? "#94a3b8" : "#64748b"
linkColor = preferDarkMode ? "#60a5fa" : "#2563eb"
codeBackground = preferDarkMode ? "#0b1220" : "#eff3f8"
codeBorder = preferDarkMode ? "#334155" : "#cbd5e1"
quoteBackground = preferDarkMode ? "#101826" : "#f8fafc"
quoteBorder = preferDarkMode ? "#38bdf8" : "#2563eb"
tableHeaderBackground = preferDarkMode ? "#172033" : "#eef2f7"
horizontalRuleColor = preferDarkMode ? "#334155" : "#cbd5e1"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.24)" : "rgba(15, 23, 42, 0.05)"
bodyFontFamily = "\"SF Mono\", Menlo, Monaco, monospace"
default:
basePadding = "18px 22px"
fontSize = "14px"
lineHeight = "1.6"
maxWidth = "860px"
bodyBackground = preferDarkMode ? "#0f172a" : "#f8fafc"
contentBackground = preferDarkMode ? "#111827" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #1f2937" : "1px solid #e5e7eb"
textColor = preferDarkMode ? "#e5e7eb" : "#111827"
mutedTextColor = preferDarkMode ? "#94a3b8" : "#6b7280"
linkColor = preferDarkMode ? "#7FB0FF" : "#2F7CF6"
codeBackground = preferDarkMode ? "#0b1220" : "#f3f4f6"
codeBorder = preferDarkMode ? "#334155" : "#d1d5db"
quoteBackground = preferDarkMode ? "#111827" : "#f8fafc"
quoteBorder = preferDarkMode ? "#3b82f6" : "#2563eb"
tableHeaderBackground = preferDarkMode ? "#172033" : "#f3f4f6"
horizontalRuleColor = preferDarkMode ? "#334155" : "#d1d5db"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.25)" : "rgba(15, 23, 42, 0.06)"
bodyFontFamily = "-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Helvetica Neue\", sans-serif"
}
let resolvedBackgroundStyle: MarkdownPreviewBackgroundStyle = {
switch backgroundStyle {
case .automatic:
return translucentBackgroundEnabled ? .translucent : .template
case .template, .translucent, .neutral:
return backgroundStyle
}
}()
switch resolvedBackgroundStyle {
case .template:
break
case .translucent:
bodyBackground = "transparent"
contentBackground = preferDarkMode ? "rgba(15, 23, 42, 0.34)" : "rgba(255, 255, 255, 0.38)"
contentBorder = preferDarkMode ? "1px solid rgba(148, 163, 184, 0.18)" : "1px solid rgba(148, 163, 184, 0.26)"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.16)" : "rgba(15, 23, 42, 0.05)"
contentBackdropFilter = "saturate(1.15) blur(18px)"
case .neutral:
bodyBackground = preferDarkMode ? "#10131a" : "#f3f5f8"
contentBackground = preferDarkMode ? "#161b24" : "#ffffff"
contentBorder = preferDarkMode ? "1px solid #242b38" : "1px solid #dde3ea"
shadowColor = preferDarkMode ? "rgba(0, 0, 0, 0.22)" : "rgba(15, 23, 42, 0.06)"
case .automatic:
break
}
return """
:root {
color-scheme: light dark;
--md-text-color: \(textColor);
--md-link-color: \(linkColor);
--md-muted-color: \(mutedTextColor);
--md-body-background: \(bodyBackground);
--md-content-background: \(contentBackground);
--md-content-border: \(contentBorder);
--md-code-background: \(codeBackground);
--md-code-border: \(codeBorder);
--md-quote-background: \(quoteBackground);
--md-quote-border: \(quoteBorder);
--md-table-header-background: \(tableHeaderBackground);
--md-hr-color: \(horizontalRuleColor);
--md-shadow-color: \(shadowColor);
--md-content-backdrop-filter: \(contentBackdropFilter);
}
html, body {
margin: 0;
padding: 0;
background: var(--md-body-background);
color: var(--md-text-color);
font-family: \(bodyFontFamily);
font-size: \(fontSize);
line-height: \(lineHeight);
}
.content {
max-width: \(maxWidth);
padding: \(basePadding);
margin: 0 auto;
background: var(--md-content-background);
border: var(--md-content-border);
border-radius: 14px;
box-shadow: 0 10px 30px var(--md-shadow-color);
-webkit-backdrop-filter: var(--md-content-backdrop-filter);
backdrop-filter: var(--md-content-backdrop-filter);
}
.preview-warning {
margin: 0.5em 0 0.8em;
padding: 0.75em 0.9em;
border-radius: 9px;
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
background: color-mix(in srgb, #f59e0b 12%, transparent);
}
.preview-warning p {
margin: 0;
}
.preview-warning-meta {
margin-top: 0.4em !important;
font-size: 0.92em;
opacity: 0.9;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.25;
margin: 1.1em 0 0.55em;
font-weight: 700;
}
h1 { font-size: 1.85em; border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); padding-bottom: 0.25em; }
h2 { font-size: 1.45em; border-bottom: 1px solid color-mix(in srgb, currentColor 13%, transparent); padding-bottom: 0.2em; }
h3 { font-size: 1.2em; }
p, li, td, th { color: var(--md-text-color); }
.preview-warning-meta, figcaption { color: var(--md-muted-color); }
p, ul, ol, blockquote, table, pre { margin: 0.65em 0; }
ul, ol { padding-left: 1.3em; }
li { margin: 0.2em 0; }
blockquote {
margin-left: 0;
padding: 0.45em 0.9em;
border-left: 3px solid var(--md-quote-border);
background: var(--md-quote-background);
border-radius: 6px;
}
code {
font-family: "SF Mono", "Menlo", "Monaco", monospace;
font-size: 0.9em;
padding: 0.12em 0.35em;
border-radius: 5px;
background: var(--md-code-background);
border: 1px solid var(--md-code-border);
}
pre {
overflow-x: auto;
padding: 0.8em 0.95em;
border-radius: 9px;
background: var(--md-code-background);
border: 1px solid var(--md-code-border);
line-height: 1.35;
white-space: pre;
}
pre code {
display: block;
padding: 0;
background: transparent;
border-radius: 0;
font-size: 0.88em;
line-height: 1.35;
white-space: pre;
}
table {
border-collapse: collapse;
width: 100%;
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
border-radius: 8px;
overflow: hidden;
}
th, td {
text-align: left;
padding: 0.45em 0.55em;
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
}
th {
background: var(--md-table-header-background);
font-weight: 600;
}
a {
color: var(--md-link-color);
text-decoration: none;
border-bottom: 1px solid color-mix(in srgb, var(--md-link-color) 45%, transparent);
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
hr {
border: 0;
border-top: 1px solid var(--md-hr-color);
margin: 1.1em 0;
}
body.pdf-export {
background: #ffffff !important;
color: #111827 !important;
}
body.pdf-export .content {
background: #ffffff !important;
border: none !important;
box-shadow: none !important;
}
body.pdf-export.pdf-one-page .content {
max-width: none !important;
width: auto !important;
}
body.pdf-export, body.pdf-export * {
opacity: 1 !important;
text-shadow: none !important;
-webkit-text-fill-color: #111827 !important;
}
body.pdf-export a {
color: #1d4ed8 !important;
border-bottom-color: color-mix(in srgb, #1d4ed8 45%, transparent) !important;
-webkit-text-fill-color: #1d4ed8 !important;
}
body.pdf-export code,
body.pdf-export pre,
body.pdf-export pre code {
color: #111827 !important;
background: #f3f4f6 !important;
border-color: #d1d5db !important;
-webkit-text-fill-color: #111827 !important;
}
@media print {
:root {
color-scheme: light;
--md-text-color: #111827;
--md-link-color: #1d4ed8;
}
@page {
size: A4;
margin: 0;
}
html, body {
height: auto !important;
overflow: visible !important;
background: #ffffff !important;
color: var(--md-text-color) !important;
}
body * {
color: inherit !important;
text-shadow: none !important;
}
a {
color: var(--md-link-color) !important;
}
code, pre {
color: #111827 !important;
}
.content {
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
border: none !important;
box-shadow: none !important;
border-radius: 0 !important;
background: transparent !important;
}
h1, h2, h3 {
break-after: avoid-page;
}
blockquote, figure {
break-inside: avoid;
}
}
"""
}
func escapedHTML(_ text: String) -> String {
text
.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: "\"", with: """)
.replacingOccurrences(of: "'", with: "'")
}
}