Files
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

154 lines
6.6 KiB
Swift

import AppKit
import WebKit
import UniformTypeIdentifiers
// MARK: - MarkdownPrinter
//
// Export-to-PDF and Print over the same themed HTML the Read mode renders, using
// the same PDF *creator* — `WKWebView.printOperation` — so both are real vector
// text with native pagination. Export sets `jobDisposition = .save` to write the
// file headlessly; Print shows the system dialog. They remain separate entry
// points so Export can grow its own settings (margins, page size, …) later.
//
// `printOperation` is run via `runModal(for:)` (a nested event loop) rather than
// `op.run()` — the latter blocks the main thread inside the WKWebView load
// callback and deadlocks, since WebKit rendering also needs the main thread.
@MainActor
public enum MarkdownPrinter {
/// Prompts for a PDF destination and writes the document as a paginated
/// vector PDF.
public static func exportPDF(markdown: String,
theme: EditorTheme,
callouts: [String: CalloutStyle],
baseURL: URL? = nil,
options: ReadRenderOptions = .default,
suggestedName: String,
window: NSWindow?) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.pdf]
panel.nameFieldStringValue = suggestedName + ".pdf"
let html = DocumentHTML.full(markdown: markdown, theme: theme,
callouts: callouts, dark: false,
baseURL: baseURL, options: options)
let begin: (URL) -> Void = { url in
let info = makePrintInfo()
info.jobDisposition = .save
info.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = url
PrintJob.start(html: html, parentWindow: window, printInfo: info, showsPanel: false)
}
if let window {
panel.beginSheetModal(for: window) { if $0 == .OK, let url = panel.url { begin(url) } }
} else if panel.runModal() == .OK, let url = panel.url {
begin(url)
}
}
/// Shows the native Print dialog for `markdown`.
public static func print(markdown: String,
theme: EditorTheme,
callouts: [String: CalloutStyle],
baseURL: URL? = nil,
options: ReadRenderOptions = .default,
window: NSWindow?) {
let html = DocumentHTML.full(markdown: markdown, theme: theme,
callouts: callouts, dark: false,
baseURL: baseURL, options: options)
PrintJob.start(html: html, parentWindow: window, printInfo: makePrintInfo(), showsPanel: true)
}
/// US-Letter with 0.75" margins; WKWebView reflows content to the imageable
/// width and paginates.
static func makePrintInfo() -> NSPrintInfo {
let info = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo.shared
let margin: CGFloat = 54
info.topMargin = margin; info.bottomMargin = margin
info.leftMargin = margin; info.rightMargin = margin
info.horizontalPagination = .automatic
info.verticalPagination = .automatic
info.isHorizontallyCentered = false
info.isVerticallyCentered = false
return info
}
}
// MARK: - PrintJob
//
// Loads HTML into a WKWebView placed in a real (off-screen, non-activating)
// window so AppKit can draw it, then runs printOperation via runModal(for:).
// Retains itself until the operation completes.
@MainActor
private final class PrintJob: NSObject, WKNavigationDelegate {
private static var live: Set<PrintJob> = []
private let webView: WKWebView
private let offscreenWindow: NSWindow
private let parentWindow: NSWindow?
private let printInfo: NSPrintInfo
private let showsPanel: Bool
static func start(html: String, parentWindow: NSWindow?,
printInfo: NSPrintInfo, showsPanel: Bool) {
let job = PrintJob(html: html, parentWindow: parentWindow,
printInfo: printInfo, showsPanel: showsPanel)
live.insert(job)
}
private init(html: String, parentWindow: NSWindow?,
printInfo: NSPrintInfo, showsPanel: Bool) {
let config = WKWebViewConfiguration()
config.defaultWebpagePreferences.allowsContentJavaScript = false
webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 1000),
configuration: config)
offscreenWindow = NSWindow(
contentRect: NSRect(x: -20000, y: -20000, width: 800, height: 1000),
styleMask: [.borderless], backing: .buffered, defer: false)
offscreenWindow.isReleasedWhenClosed = false
offscreenWindow.contentView = webView
offscreenWindow.orderBack(nil)
self.parentWindow = parentWindow
self.printInfo = printInfo
self.showsPanel = showsPanel
super.init()
webView.navigationDelegate = self
webView.loadHTMLString(html, baseURL: ReadModeNavigationPolicy.trustedBaseURL)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let op = webView.printOperation(with: printInfo)
op.showsPrintPanel = showsPanel
op.showsProgressPanel = showsPanel
if let parentWindow {
op.runModal(for: parentWindow, delegate: self,
didRun: #selector(printDidRun(_:success:contextInfo:)), contextInfo: nil)
} else {
op.run()
cleanup()
}
}
// QUIRK: for a `.save` (headless export) job, NSPrintOperation runs
// `_continueModalOperationToTheEnd` on a *spawned* thread and invokes this
// didRun callback off the main thread. `cleanup()` closes an NSWindow, which
// must happen on main — so this callback is `nonisolated` (otherwise the
// Swift-6 main-actor check traps when AppKit calls it off-main) and hops back
// to the main actor before touching any AppKit state.
@objc nonisolated func printDidRun(_ op: NSPrintOperation, success: Bool,
contextInfo: UnsafeMutableRawPointer?) {
Task { @MainActor in self.cleanup() }
}
private func cleanup() {
offscreenWindow.close()
PrintJob.live.remove(self)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { cleanup() }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { cleanup() }
}