Compare commits

...
7 changed files with 900 additions and 3 deletions

No files matched your search

+238 -2
View File
@@ -1,4 +1,5 @@
import SwiftUI
import WebKit
class LiveContent: ObservableObject {
@Published var rawMarkdown: String = ""
@@ -46,6 +47,13 @@ struct ContentView: View {
@State private var hasInitializedWindowViewState = false
@State private var zoomScale = 1.0
@State private var activeThemeName: String?
@AppStorage("printTheme") private var printTheme = "Clean Printing"
@AppStorage("printBackgrounds") private var printBackgrounds = false
@AppStorage("printImages") private var printImages = true
@AppStorage("printZoom") private var printZoom = "Standard (100%)"
@AppStorage("printContentWidth") private var printContentWidth = "Full Page"
@AppStorage("printBorders") private var printBorders = false
@AppStorage("printPagePadding") private var printPagePadding = "0px"
private var currentMarkdown: String {
liveContent.rawMarkdown.isEmpty ? document.rawMarkdown : liveContent.rawMarkdown
@@ -144,6 +152,7 @@ struct ContentView: View {
setRawVisible: setRawSourceVisible,
setZoomScale: { zoomScale = $0 },
setActiveThemeName: { activeThemeName = $0 },
printDocument: { printActiveDocument() },
showMissingEditor: { editorName in
missingEditorName = editorName
showMissingEditorAlert = true
@@ -195,7 +204,8 @@ struct ContentView: View {
zoomOut: { zoomScale = max(zoomScale / 1.1, 0.3) },
zoomReset: { zoomScale = 1.0 },
nextTheme: { cycleTheme(direction: 1) },
previousTheme: { cycleTheme(direction: -1) }
previousTheme: { cycleTheme(direction: -1) },
printDocument: { printActiveDocument() }
)
}
@@ -246,6 +256,81 @@ struct ContentView: View {
hasActivatedRawSource = true
}
/// Triggers the native macOS print sheet modal for the active Markdown document.
/// It temporarily swaps in the print-specific theme CSS, applies layout class overrides,
/// presents the print panel, and restores the original theme when the print flow concludes.
@MainActor
private func printActiveDocument() {
guard let window = findBar.window else { return }
guard let webView = window.contentView?.firstSubview(ofType: WKWebView.self) else { return }
let themeToPrint: Theme?
if printTheme == "Active Theme" {
themeToPrint = activeTheme
} else if printTheme == "Clean Printing" {
themeToPrint = Theme.cleanPrinting
} else if printTheme == "Plain HTML" {
themeToPrint = nil
} else {
themeToPrint = themeManager.themes.first { $0.name == printTheme } ?? Theme.cleanPrinting
}
// Determine zoom scale based on print settings
let zoom = printZoom == "Match Screen" ? zoomScale : 1.0
let printCSS = themeToPrint != nil ? themeManager.generateCSS(for: themeToPrint!, zoomScale: zoom) : ""
let escapedPrintCSS = printCSS.escapedForJavaScriptLiteral()
let prepareJS = PrintScriptGenerator.prepareScript(
escapedCSS: escapedPrintCSS,
stripBackgrounds: !printBackgrounds,
stripImages: !printImages,
stripWidth: printContentWidth == "Full Page",
printBorders: printBorders,
pagePadding: printPagePadding
)
let restoreCSS = themeManager.generateCSS(for: activeTheme, zoomScale: zoomScale)
let printBackgroundsVal = printBackgrounds
let printImagesVal = printImages
let printContentWidthVal = printContentWidth
let printBordersVal = printBorders
let printPagePaddingVal = printPagePadding
webView.evaluateJavaScript(prepareJS) { [weak webView] _, _ in
guard let webView = webView else { return }
guard let printInfo = NSPrintInfo.shared.copy() as? NSPrintInfo else { return }
printInfo.horizontalPagination = .fit
printInfo.verticalPagination = .automatic
printInfo.leftMargin = 36
printInfo.rightMargin = 36
printInfo.topMargin = 36
printInfo.bottomMargin = 36
let printOp = webView.printOperation(with: printInfo)
printOp.showsPrintPanel = true
printOp.showsProgressPanel = true
let coordinator = PrintCoordinator(
webView: webView,
restoreCSS: restoreCSS,
stripBackgrounds: !printBackgroundsVal,
stripImages: !printImagesVal,
stripWidth: printContentWidthVal == "Full Page",
printBorders: printBordersVal,
pagePadding: printPagePaddingVal
)
let coordinatorPointer = Unmanaged.passRetained(coordinator).toOpaque()
printOp.runModal(
for: window,
delegate: coordinator,
didRun: #selector(PrintCoordinator.printOperationDidRun(_:success:contextInfo:)),
contextInfo: coordinatorPointer
)
}
}
}
enum DocumentViewLayout {
@@ -279,6 +364,7 @@ struct DocumentViewCommands {
let zoomReset: () -> Void
let nextTheme: () -> Void
let previousTheme: () -> Void
let printDocument: () -> Void
}
private struct DocumentViewCommandsKey: FocusedValueKey {
@@ -307,6 +393,7 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
private var setRawVisible: ((Bool) -> Void)?
private var setZoomScale: ((Double) -> Void)?
private var setActiveThemeName: ((String) -> Void)?
private var printDocument: (() -> Void)?
private var showMissingEditor: ((String) -> Void)?
func configure(
@@ -324,6 +411,7 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
setRawVisible: @escaping (Bool) -> Void,
setZoomScale: @escaping (Double) -> Void,
setActiveThemeName: @escaping (String) -> Void,
printDocument: @escaping () -> Void,
showMissingEditor: @escaping (String) -> Void
) {
self.window = window
@@ -340,6 +428,7 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
self.setRawVisible = setRawVisible
self.setZoomScale = setZoomScale
self.setActiveThemeName = setActiveThemeName
self.printDocument = printDocument
self.showMissingEditor = showMissingEditor
Self.removeLegacySavedToolbarConfigurations()
@@ -365,7 +454,7 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .space]
return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .printDocument, .space]
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
@@ -401,6 +490,8 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
return makeThemeItem()
case .externalEditor:
return makeExternalEditorItem()
case .printDocument:
return makePrintItem()
default:
return nil
}
@@ -503,6 +594,18 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
return item
}
private func makePrintItem() -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: .printDocument)
item.label = "Print"
item.paletteLabel = "Print"
item.toolTip = "Print"
item.image = NSImage(systemSymbolName: "printer", accessibilityDescription: "Print")
item.target = self
item.action = #selector(printDocumentFromToolbar)
item.menuFormRepresentation = toolbarMenuItem(title: "Print", action: #selector(printDocumentFromToolbar))
return item
}
private var externalEditorLabel: String {
let validEditors = editorManager?.editors.filter(\.exists) ?? []
if validEditors.count == 1 {
@@ -725,6 +828,10 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
setZoomScale?(max(zoomScale / 1.1, 0.3))
}
@objc private func printDocumentFromToolbar() {
printDocument?()
}
@objc private func actualSize() {
setZoomScale?(1.0)
}
@@ -796,6 +903,7 @@ private extension NSToolbarItem.Identifier {
static let zoom = NSToolbarItem.Identifier("zoom")
static let theme = NSToolbarItem.Identifier("theme")
static let externalEditor = NSToolbarItem.Identifier("external-editor")
static let printDocument = NSToolbarItem.Identifier("print-document")
}
private extension NSToolbar.Identifier {
@@ -990,3 +1098,131 @@ extension Color {
self.init(red: r, green: g, blue: b)
}
}
extension NSView {
func firstSubview<T: NSView>(ofType type: T.Type) -> T? {
if let match = self as? T {
return match
}
for subview in subviews {
if let match = subview.firstSubview(ofType: type) {
return match
}
}
return nil
}
}
/// A helper class to coordinate the print sheet operations in macOS.
/// Since SwiftUI's `ContentView` is a struct, it cannot receive `@objc` target-action messages.
/// This NSObject subclass serves as the delegate for `NSPrintOperation`, ensuring that when the
/// modal print sheet closes, the web view is cleanly restored to its screen theme/styles and
/// its manually-retained unmanaged reference pointer is released.
@MainActor
class PrintCoordinator: NSObject {
weak var webView: WKWebView?
let restoreCSS: String
let stripBackgrounds: Bool
let stripImages: Bool
let stripWidth: Bool
let printBorders: Bool
let pagePadding: String
init(webView: WKWebView, restoreCSS: String, stripBackgrounds: Bool, stripImages: Bool, stripWidth: Bool, printBorders: Bool, pagePadding: String) {
self.webView = webView
self.restoreCSS = restoreCSS
self.stripBackgrounds = stripBackgrounds
self.stripImages = stripImages
self.stripWidth = stripWidth
self.printBorders = printBorders
self.pagePadding = pagePadding
super.init()
}
/// Delegate callback executed by the macOS printing system after the print sheet runs.
/// It restores visual screen settings and releases the retained coordinate pointer.
@objc func printOperationDidRun(
_ printOperation: NSPrintOperation,
success: Bool,
contextInfo: UnsafeMutableRawPointer?
) {
restore()
if let contextInfo = contextInfo {
// Balance the manual passRetained call done at instantiation to prevent memory leaks.
Unmanaged<PrintCoordinator>.fromOpaque(contextInfo).release()
}
}
/// Restores the web view's CSS variables and removes active print classes.
func restore() {
guard let webView = webView else { return }
let escapedCSS = restoreCSS.escapedForJavaScriptLiteral()
let restoreJS = PrintScriptGenerator.restoreScript(
escapedCSS: escapedCSS,
stripBackgrounds: stripBackgrounds,
stripImages: stripImages,
stripWidth: stripWidth,
printBorders: printBorders,
pagePadding: pagePadding
)
webView.evaluateJavaScript(restoreJS, completionHandler: nil)
}
}
/// Generates JavaScript commands for WebKit style updates during print operations.
struct PrintScriptGenerator {
static func prepareScript(
escapedCSS: String,
stripBackgrounds: Bool,
stripImages: Bool,
stripWidth: Bool,
printBorders: Bool,
pagePadding: String
) -> String {
let stripBackgroundsJS = stripBackgrounds ? "document.documentElement.classList.add('print-no-backgrounds');" : ""
let stripImagesJS = stripImages ? "document.documentElement.classList.add('print-no-images');" : ""
let fullWidthJS = stripWidth ? "document.documentElement.classList.add('print-full-width');" : ""
let bordersJS = printBorders ? "document.documentElement.classList.add('print-borders');" : ""
let paddingJS = "document.documentElement.style.setProperty('--print-padding', '\(pagePadding)');"
return """
document.getElementById('theme-css').textContent = '\(escapedCSS)';
\(stripBackgroundsJS)
\(stripImagesJS)
\(fullWidthJS)
\(bordersJS)
\(paddingJS)
"""
}
static func restoreScript(
escapedCSS: String,
stripBackgrounds: Bool,
stripImages: Bool,
stripWidth: Bool,
printBorders: Bool,
pagePadding: String
) -> String {
let removeBgJS = stripBackgrounds ? "document.documentElement.classList.remove('print-no-backgrounds');" : ""
let removeImgJS = stripImages ? "document.documentElement.classList.remove('print-no-images');" : ""
let removeWidthJS = stripWidth ? "document.documentElement.classList.remove('print-full-width');" : ""
let removeBordersJS = printBorders ? "document.documentElement.classList.remove('print-borders');" : ""
let removePaddingJS = "document.documentElement.style.removeProperty('--print-padding');"
return """
document.getElementById('theme-css').textContent = '\(escapedCSS)';
\(removeBgJS)
\(removeImgJS)
\(removeWidthJS)
\(removeBordersJS)
\(removePaddingJS)
"""
}
}
fileprivate extension String {
func escapedForJavaScriptLiteral() -> String {
self.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: "\n", with: "\\n")
}
}
+8
View File
@@ -55,6 +55,14 @@ struct MarkdownViewrApp: App {
.keyboardShortcut(",", modifiers: .command)
}
CommandGroup(replacing: .printItem) {
Button("Print...") {
documentViewCommands?.printDocument()
}
.keyboardShortcut("p", modifiers: .command)
.disabled(documentViewCommands == nil)
}
CommandGroup(replacing: .textEditing) {
Button("Find...") {
NotificationCenter.default.post(name: .findToggle, object: nil)
+131
View File
@@ -408,6 +408,137 @@ mark {
font-weight: 600;
white-space: nowrap;
}
@media print {
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
html, body {
height: auto !important;
overflow: visible !important;
display: block !important;
background-color: var(--bg) !important;
color: var(--text) !important;
}
#content {
flex: none !important;
height: auto !important;
overflow: visible !important;
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
display: block !important;
}
#content-inner {
padding: var(--print-padding, 0px) !important;
margin: 0 !important;
}
/* Force collapsed editor sections to print expanded */
#content-inner > * {
display: revert !important;
}
html.print-full-width #content-inner {
max-width: none !important;
}
#toc, #toc-resizer, #raw-resizer, #raw-source, .collapse-arrow, .resizer {
display: none !important;
}
html.print-no-backgrounds, html.print-no-backgrounds body {
background-color: #ffffff !important;
color: #000000 !important;
}
html.print-no-backgrounds * {
-webkit-print-color-adjust: economy !important;
print-color-adjust: economy !important;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
/* Avoid light text rendering on white backgrounds */
color: #000000 !important;
}
/* Maintain link readability in ink-saving mode */
html.print-no-backgrounds a {
color: #0969da !important;
text-decoration: underline !important;
}
/* Ensure rules and borders are visible in ink-saving mode */
html.print-no-backgrounds hr {
border-top: 1px solid #dddddd !important;
}
html.print-no-backgrounds th,
html.print-no-backgrounds td {
border: 1px solid #dddddd !important;
}
html.print-no-backgrounds blockquote {
border-left: 3px solid #cccccc !important;
}
html.print-no-backgrounds h1 {
border-bottom: 1px solid #dddddd !important;
}
html.print-no-backgrounds h2 {
border-bottom: 1px solid #eeeeee !important;
}
/* Add borders to content blocks that have custom background colors when print-borders is active */
html.print-borders pre,
html.print-borders .frontmatter {
border: 1px solid color-mix(in srgb, var(--text) 25%, transparent) !important;
}
html.print-borders blockquote {
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent) !important;
border-left: 4px solid var(--blockquote-border) !important;
}
html.print-borders code {
border: 1px solid color-mix(in srgb, var(--text) 30%, transparent) !important;
}
/* Specific override to strip images when printImages is false */
html.print-no-images #content-inner img,
html.print-no-images #content-inner > img {
display: none !important;
}
/* Print Layout/Rendering Optimizations */
h1, h2, h3, h4, h5, h6 {
break-after: avoid !important;
page-break-after: avoid !important;
}
/* Keep lists and table rows unbroken on page boundaries */
pre, blockquote, tr, li {
break-inside: avoid !important;
page-break-inside: avoid !important;
}
img {
max-width: 100% !important;
break-inside: avoid !important;
page-break-inside: avoid !important;
}
thead {
display: table-header-group !important;
}
th, td {
word-wrap: break-word !important;
white-space: normal !important;
}
}
</style>
<style id="theme-css">
{{THEME_CSS}}
+139
View File
@@ -23,6 +23,11 @@ struct SettingsView: View {
.tabItem {
Label("Markdown", systemImage: "doc.plaintext")
}
PrintingSettingsView()
.tabItem {
Label("Printing", systemImage: "printer")
}
}
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
}
@@ -481,6 +486,140 @@ struct MarkdownSettingsView: View {
}
}
struct PrintingSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
@AppStorage("printTheme") private var printTheme = "Clean Printing"
@AppStorage("printBackgrounds") private var printBackgrounds = false
@AppStorage("printImages") private var printImages = true
@AppStorage("printZoom") private var printZoom = "Standard (100%)"
@AppStorage("printContentWidth") private var printContentWidth = "Full Page"
@AppStorage("printBorders") private var printBorders = false
@AppStorage("printPagePadding") private var printPagePadding = "0px"
var body: some View {
SettingsScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Printing")
.font(.headline)
.padding(.bottom, 16)
GroupBox(label: Text("Print Theme")) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Theme")
Spacer()
Picker("Print Theme", selection: $printTheme) {
Text("Active Screen Theme").tag("Active Theme")
Text("Plain HTML (Unstyled)").tag("Plain HTML")
Text("Clean Printing").tag("Clean Printing")
Divider()
ForEach(themeManager.themes, id: \.name) { theme in
Text(theme.name).tag(theme.name)
}
}
.frame(width: 200)
.labelsHidden()
}
Text("Select the theme to use when printing or exporting to PDF.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
GroupBox(label: Text("Page Layout")) {
VStack(alignment: .leading, spacing: 12) {
Toggle("Include Background Colors", isOn: $printBackgrounds)
.toggleStyle(.checkbox)
Text("When enabled, code block backgrounds, blockquotes, and highlights will print with their theme colors. Disable to save ink.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
Toggle("Include Images", isOn: $printImages)
.toggleStyle(.checkbox)
Text("When enabled, inline and document images will print. Disable to save color ink and space.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
Toggle("Include Content Borders", isOn: $printBorders)
.toggleStyle(.checkbox)
Text("When enabled, adds borders to styled blocks like code blocks, blockquotes, and tables to guarantee contrast with page background.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
HStack {
Text("Page Padding")
Spacer()
Picker("Page Padding", selection: $printPagePadding) {
Text("None (0px)").tag("0px")
Text("Small (24px)").tag("24px")
Text("Medium (48px)").tag("48px")
Text("Large (72px)").tag("72px")
}
.frame(width: 200)
.labelsHidden()
}
Text("Add margins inside the background color container to keep text away from the page edge.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
.padding(.top, 12)
GroupBox(label: Text("Zoom & Scaling")) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Font Zoom")
Spacer()
Picker("Font Zoom", selection: $printZoom) {
Text("Standard (100%)").tag("Standard (100%)")
Text("Match Screen").tag("Match Screen")
}
.frame(width: 200)
.labelsHidden()
}
Text("Select whether the printed document inherits the screen zoom scaling.")
.font(.caption)
.foregroundStyle(.secondary)
Divider().padding(.vertical, 4)
HStack {
Text("Content Width")
Spacer()
Picker("Content Width", selection: $printContentWidth) {
Text("Full Page Width").tag("Full Page")
Text("Match Screen Width").tag("Match Screen")
}
.frame(width: 200)
.labelsHidden()
}
Text("Select whether page layout uses full printable bounds or conforms to screen width guidelines.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(4)
}
.padding(.top, 12)
}
}
}
}
struct ThemeEditorItem: Identifiable {
let id = UUID()
let theme: Theme?
+28
View File
@@ -172,3 +172,31 @@ struct ThemeSizes: Codable, Hashable {
self.lineHeight = lineHeight
}
}
extension Theme {
static let cleanPrinting = Theme(
name: "Clean Printing",
colors: ThemeColors(
background: "#ffffff",
text: "#24292e",
heading1: "#1b1f23",
heading2: "#1b1f23",
heading3: "#1b1f23",
heading4: "#1b1f23",
heading5: "#1b1f23",
heading6: "#1b1f23",
link: "#0366d6",
codeBackground: "#f6f8fa",
codeText: "#24292e",
blockquoteBorder: "#dfe2e5",
blockquoteBackground: "#fafbfc",
highlightBackground: "#fff5b1",
highlightText: "#24292e"
),
fonts: ThemeFonts(body: "System", heading: "System", code: "SF Mono"),
sizes: ThemeSizes(),
customCSS: "",
isBuiltIn: true
)
}
+355
View File
@@ -0,0 +1,355 @@
import XCTest
import WebKit
@testable import markdownViewr
final class PrintingTests: XCTestCase {
// 1. Unit Test for firstSubview(ofType:)
func testNSViewFirstSubviewOfTypeRecurse() {
let parent = NSView()
let mid = NSView()
let target = WKWebView()
mid.addSubview(target)
parent.addSubview(mid)
// Assert we can find the WKWebView recursively
let foundWebView = parent.firstSubview(ofType: WKWebView.self)
XCTAssertNotNil(foundWebView)
XCTAssertEqual(foundWebView, target)
// Assert we find nil for a type that doesn't exist
class CustomTestView: NSView {}
XCTAssertNil(parent.firstSubview(ofType: CustomTestView.self))
}
// 2. Static Assertions on source files
func testAppConfiguresReplacementPrintCommand() throws {
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
// Verify we replaced the default print command group
XCTAssertTrue(appSource.contains("CommandGroup(replacing: .printItem)"))
XCTAssertTrue(appSource.contains("documentViewCommands?.printDocument()"))
}
func testContentViewBindsPrintDocumentActionAndCoordinator() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
// Verify printDocument is in DocumentViewCommands struct
XCTAssertTrue(contentSource.contains("let printDocument: () -> Void"))
// Verify printDocument is instantiated with printActiveDocument
XCTAssertTrue(contentSource.contains("printDocument: { printActiveDocument() }"))
// Verify printActiveDocument exists
XCTAssertTrue(contentSource.contains("func printActiveDocument()"))
// Verify recursive traversal is used to find WKWebView
XCTAssertTrue(contentSource.contains("window.contentView?.firstSubview(ofType: WKWebView.self)"))
// Verify print settings storage variables exist
XCTAssertTrue(contentSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
XCTAssertTrue(contentSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
// Verify print coordinator instantiation passing all parameters
XCTAssertTrue(contentSource.contains("let coordinator = PrintCoordinator("))
XCTAssertTrue(contentSource.contains("stripBackgrounds: !printBackgroundsVal"))
XCTAssertTrue(contentSource.contains("stripImages: !printImagesVal"))
XCTAssertTrue(contentSource.contains("stripWidth: printContentWidthVal == \"Full Page\""))
// Verify we run the print operation modally with coordinator pointer
XCTAssertTrue(contentSource.contains("printOp.runModal("))
XCTAssertTrue(contentSource.contains("didRun: #selector(PrintCoordinator.printOperationDidRun(_:success:contextInfo:))"))
XCTAssertTrue(contentSource.contains("contextInfo: coordinatorPointer"))
// Verify PrintCoordinator class exists, conforms to @MainActor and supports stripWidth
XCTAssertTrue(contentSource.contains("@MainActor\nclass PrintCoordinator"))
XCTAssertTrue(contentSource.contains("let stripImages: Bool"))
XCTAssertTrue(contentSource.contains("let stripWidth: Bool"))
XCTAssertTrue(contentSource.contains("func restore()"))
}
func testPrintOperationUsesCopiedPrintInfo() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains("NSPrintInfo.shared.copy() as? NSPrintInfo"))
XCTAssertFalse(contentSource.contains("let printInfo = NSPrintInfo.shared\n"))
}
func testPrintToolbarItemIsAvailableForCustomization() throws {
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
XCTAssertTrue(contentSource.contains("printDocument: @escaping () -> Void"))
XCTAssertTrue(contentSource.contains("self.printDocument = printDocument"))
XCTAssertTrue(contentSource.contains(".printDocument, .space"))
XCTAssertFalse(contentSource.contains("return [.toc, .tocDepth, .printDocument"))
XCTAssertTrue(contentSource.contains("case .printDocument:\n return makePrintItem()"))
XCTAssertTrue(contentSource.contains(#"item.label = "Print""#))
XCTAssertTrue(contentSource.contains(#"item.paletteLabel = "Print""#))
XCTAssertTrue(contentSource.contains(#"NSImage(systemSymbolName: "printer""#))
XCTAssertTrue(contentSource.contains("item.action = #selector(printDocumentFromToolbar)"))
XCTAssertTrue(contentSource.contains("@objc private func printDocumentFromToolbar()"))
XCTAssertTrue(contentSource.contains("printDocument?()"))
XCTAssertTrue(contentSource.contains(#"static let printDocument = NSToolbarItem.Identifier("print-document")"#))
}
func testXcodeGenConfigurationIncludesTestSources() throws {
let projectSource = try loadSourceFile("project.yml")
XCTAssertTrue(projectSource.contains("markdownViewrTests:"))
XCTAssertTrue(projectSource.contains("- markdownViewrTests"))
}
func testSettingsViewIncludesPrintingTab() throws {
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
// Verify PrintingSettingsView is declared in TabView
XCTAssertTrue(settingsSource.contains("PrintingSettingsView()"))
XCTAssertTrue(settingsSource.contains(#"Label("Printing", systemImage: "printer")"#))
// Verify PrintingSettingsView struct layout
XCTAssertTrue(settingsSource.contains("struct PrintingSettingsView: View"))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
// Verify spacing and alignment matching HIG guidelines
XCTAssertTrue(settingsSource.contains("VStack(alignment: .leading, spacing: 0)"))
XCTAssertTrue(settingsSource.contains(".padding(.bottom, 16)"))
XCTAssertTrue(settingsSource.contains(".padding(.top, 12)"))
// Verify Settings Layout Components (splits and columns)
XCTAssertTrue(settingsSource.contains("Picker("))
XCTAssertTrue(settingsSource.contains(#"Text("Plain HTML (Unstyled)")"#))
XCTAssertTrue(settingsSource.contains(#"Text("Clean Printing")"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Print Theme"))"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Page Layout"))"#))
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Zoom & Scaling"))"#))
XCTAssertTrue(settingsSource.contains("Toggle("))
}
// 3. Template @media print assertion
func testTemplateIncludesPrintMediaQueries() throws {
let template = try loadTemplate()
// Verify print media query exists
XCTAssertTrue(template.contains("@media print"))
let printBlock = try printMediaQueryBlock(in: template)
// Verify print color adjust properties are present to preserve theme colors
XCTAssertTrue(printBlock.contains("-webkit-print-color-adjust: exact !important"))
XCTAssertTrue(printBlock.contains("print-color-adjust: exact !important"))
// Verify print background toggling override style exists
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds *"))
XCTAssertTrue(printBlock.contains("print-color-adjust: economy !important"))
XCTAssertTrue(printBlock.contains("background: transparent !important"))
XCTAssertTrue(printBlock.contains("color: #000000 !important"))
// Verify ink-saving layout overrides for links, rules, headers, and blockquotes
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds a"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds hr"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds blockquote"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds h1"))
// Verify print image toggling override style targets specific content images
XCTAssertTrue(printBlock.contains("html.print-no-images #content-inner img"))
// Verify collapsed editor expansion rules
XCTAssertTrue(printBlock.contains("#content-inner > *"))
XCTAssertTrue(printBlock.contains("display: revert !important"))
// Verify print full width toggling override style exists
XCTAssertTrue(printBlock.contains("html.print-full-width #content-inner"))
XCTAssertTrue(printBlock.contains("max-width: none !important"))
// Verify layout overrides
XCTAssertTrue(printBlock.contains("html, body"))
XCTAssertTrue(printBlock.contains("height: auto !important"))
XCTAssertTrue(printBlock.contains("overflow: visible !important"))
XCTAssertTrue(printBlock.contains("#content"))
// Verify elements hidden
XCTAssertTrue(printBlock.contains("#toc"))
XCTAssertTrue(printBlock.contains("#raw-source"))
XCTAssertTrue(printBlock.contains(".collapse-arrow"))
XCTAssertTrue(printBlock.contains("display: none !important"))
}
func testTemplatePrintStylesForEdgeCases() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
// 1. Heading Orphaning: Ensure headings (h1-h6) have CSS break-after avoid rules
XCTAssertTrue(printBlock.contains("h1, h2, h3, h4, h5, h6 {"))
XCTAssertTrue(printBlock.contains("break-after: avoid"))
XCTAssertTrue(printBlock.contains("page-break-after: avoid"))
// 2. Element Page Splits: Ensure small code blocks (pre/code), blockquotes, table rows, and list items have break-inside/page-break-inside avoid rules
XCTAssertTrue(printBlock.contains("pre, blockquote, tr, li {"))
XCTAssertTrue(printBlock.contains("break-inside: avoid"))
XCTAssertTrue(printBlock.contains("page-break-inside: avoid"))
// 3. Image Overflow: Ensure images do not exceed page boundaries and break-inside: avoid
XCTAssertTrue(printBlock.contains("img {"))
XCTAssertTrue(printBlock.contains("max-width: 100%"))
// 4. Table Formatting: Ensure table headers repeat on page boundaries and headers/cells wrap text nicely
XCTAssertTrue(printBlock.contains("thead {"))
XCTAssertTrue(printBlock.contains("display: table-header-group"))
XCTAssertTrue(printBlock.contains("th, td {"))
XCTAssertTrue(printBlock.contains("word-wrap: break-word"))
XCTAssertTrue(printBlock.contains("white-space: normal"))
}
// 4. Functional test for PrintCoordinator script building and memory cycles
@MainActor
func testPrintCoordinatorMemoryManagementAndRestore() {
class MockWebView: WKWebView {
var lastEvaluatedJavaScript: String?
override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil) {
self.lastEvaluatedJavaScript = javaScriptString
completionHandler?(nil, nil)
}
}
let webView = MockWebView()
var coordinator: PrintCoordinator? = PrintCoordinator(
webView: webView,
restoreCSS: "body { background: 'white'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
weak var weakCoordinator = coordinator
XCTAssertNotNil(weakCoordinator)
// Pass retained pointer simulating print runModal contextInfo
let pointer = Unmanaged.passRetained(coordinator!).toOpaque()
// Release our strong local reference (ref count is now held only by the unmanaged pointer)
coordinator = nil
XCTAssertNotNil(weakCoordinator)
// Simulate AppKit delegate execution finishing
let dummyOperation = NSPrintOperation()
weakCoordinator?.printOperationDidRun(dummyOperation, success: true, contextInfo: pointer)
// Assert the unmanaged pointer is released and coordinator is deallocated
XCTAssertNil(weakCoordinator)
// Assert Javascript script execution occurred and escaped CSS correctly
XCTAssertNotNil(webView.lastEvaluatedJavaScript)
let js = webView.lastEvaluatedJavaScript!
XCTAssertTrue(js.contains("document.getElementById('theme-css').textContent = 'body { background: \\'white\\'; }';"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-images');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-full-width');"))
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-borders');"))
XCTAssertTrue(js.contains("document.documentElement.style.removeProperty('--print-padding');"))
}
func testCleanPrintingThemeIsBuiltInAndIndependent() {
let theme = Theme.cleanPrinting
XCTAssertEqual(theme.name, "Clean Printing")
XCTAssertTrue(theme.isBuiltIn)
XCTAssertEqual(theme.colors.background, "#ffffff")
XCTAssertEqual(theme.colors.text, "#24292e")
}
// 5. Test that template print query stylesheet contains the print-borders rules
func testTemplatePrintStylesForBorders() throws {
let template = try loadTemplate()
let printBlock = try printMediaQueryBlock(in: template)
XCTAssertTrue(printBlock.contains("html.print-borders pre"))
XCTAssertTrue(printBlock.contains("html.print-borders .frontmatter"))
XCTAssertTrue(printBlock.contains("html.print-borders blockquote"))
XCTAssertTrue(printBlock.contains("html.print-borders code"))
}
// 6. Test that PrintScriptGenerator outputs correct prepare and restore scripts
func testPrintScriptGeneratorScriptOutputs() {
let prepareScript = PrintScriptGenerator.prepareScript(
escapedCSS: "body { background: 'white'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'white\'; }';"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-backgrounds');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-images');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-full-width');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-borders');"))
XCTAssertTrue(prepareScript.contains("document.documentElement.style.setProperty('--print-padding', '48px');"))
let restoreScript = PrintScriptGenerator.restoreScript(
escapedCSS: "body { background: 'dark'; }",
stripBackgrounds: true,
stripImages: true,
stripWidth: true,
printBorders: true,
pagePadding: "48px"
)
XCTAssertTrue(restoreScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'dark\'; }';"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-images');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-full-width');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-borders');"))
XCTAssertTrue(restoreScript.contains("document.documentElement.style.removeProperty('--print-padding');"))
}
// Helper function to read source files
private func loadSourceFile(_ relativePath: String) throws -> String {
let testFileURL = URL(fileURLWithPath: #filePath)
let repoRoot = testFileURL
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceURL = repoRoot.appendingPathComponent(relativePath)
return try String(contentsOf: sourceURL, encoding: .utf8)
}
// Helper function to read HTML template
private func loadTemplate() throws -> String {
let testFileURL = URL(fileURLWithPath: #filePath)
let repoRoot = testFileURL
.deletingLastPathComponent()
.deletingLastPathComponent()
let templateURL = repoRoot.appendingPathComponent("markdownViewr/Resources/template.html")
return try String(contentsOf: templateURL, encoding: .utf8)
}
// Helper function to extract @media print block
private func printMediaQueryBlock(in template: String) throws -> String {
let start = try XCTUnwrap(template.range(of: "@media print {"))
let remainder = template[start.upperBound...]
var bracketCount = 1
var index = remainder.startIndex
while bracketCount > 0 && index < remainder.endIndex {
let char = remainder[index]
if char == "{" {
bracketCount += 1
} else if char == "}" {
bracketCount -= 1
}
if bracketCount > 0 {
index = remainder.index(after: index)
}
}
return String(remainder[..<index])
}
}
@@ -411,7 +411,7 @@ final class TemplateScrollbarTests: XCTestCase {
XCTAssertTrue(contentSource.contains(".space"))
XCTAssertFalse(contentSource.contains(".flexibleSpace"))
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .space]"))
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .printDocument, .space]"))
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .space, .markdownSource, .zoom, .theme, .externalEditor]"))
XCTAssertFalse(contentSource.contains("case .fixedSpace:"))
XCTAssertFalse(contentSource.contains("makeFixedSpaceItem"))