Printing on the Mac from a SwiftUI application seems to be a bit of a black art and the secrets of how to do it seem to be closely guarded by those 'in the know'. You can spend a significant amount of your life searching the web for information and getting little more than snippets.

In this note, I want to present a finished version of a printing system for printing HTML with optional headers, footers and page numbering. I hope that this will get someone started and in a position to expand it out to their specific needs. I'm choosing to print HTML because that's a flexible way to layout content. For the purposes of the sample program, though, I'm going to work with Markdown for editing the document and will convert it to HTML for printing.

The sample app will have an external dependence for converting the Markdown to HTML. This conversion can be done without the external dependency, but it's a lot of work. Way beyond the scope of this humble note.

Sample application

The code for this article is online in GitHub. It contains several files and it seems appropriate to give an overview of what they are and what they do up-front. It'll make things easier if you can see the code in context (Link to GitHub at the end of this article).

  • ContentView The ContentView is a simple text editor window with a single Print button.
  • ContentViewModel contains the logic for printing the HTML I tried to keep the view as clean as possible and put the code into the view model. It also illustrates the process of printing without being dependent on a view.
  • HTMLPrintView is the heart of the printing process. It sub-classes WKWebView to deal with printing.
  • Bundle+Extensions is a convenience extension to the bundle.
  • HostingWindowFinder is described elsewhere on this site and is a convenient way to get a reference to our NSWindow.

The Edit Window

ContentView contains the code for the editor window.

import SwiftUI

struct ContentView: View {
    
    @StateObject private var vm = ContentViewModel()
    
    var body: some View {
        VStack {
            TextEditor(text: $vm.editContent)
            Button(action: { vm.convertAndPrint() },
                   label: { Text("Print") })
            
            HostingWindowFinder { win in
                guard let win else { return }
                vm.windowNumber = win.windowNumber
            }.frame(width: 0, height: 0)
        }
        .padding()
    }
}

It presents a simple TextField and a button to initiate the print process.

Edit Window

It also serves to provide a view that we can put the HostingWindowFinder in so we can get to the underlying NSWindow. We don't strictly need this, but it will be useful. The view also creates an instance of the ContentViewModel, where our data is stored and the print operation takes place.

The sample text comes from the view model.

Sample view model

The view model contains everything that the sample view needs to operate.

import SwiftUI
import MarkdownKit

class ContentViewModel: ObservableObject {
    
    @Published var windowNumber: Int = 0
    @Published var editContent: String = ""
    
    var printView: HTMLPrintView?
    
    init() {
        editContent = sampleText
    }
    
    func convertAndPrint() {
        // Step 1: convert markdown to HTML
        let html = htmlFromMarkdown(markdown: editContent)
        
        // Step 2: Get the current window reference
        guard let window = NSApplication.shared.windows.first(
            where: {$0.windowNumber == self.windowNumber})
        else { return }

        // Step 3: Create the parameters
        let options = PrintOptions(
            fileName: "example.txt",
            header: "Report Heading",
            htmlContent: html,
            window: window
        )
        
        // Step 4: Print the HTML
        printView = HTMLPrintView()
        printView!.printView(printOptions: options)
    }

    func htmlFromMarkdown(markdown: String) -> String {
        let markdown = MarkdownParser.standard.parse(markdown)
        return HtmlGenerator.standard.generate(doc: markdown)
    }

    private var sampleText = """
# This is a heading 1

## This is a heading 2

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do 
eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ornare aenean euismod elementum nisi quis eleifend 
quam adipiscing vitae.

* Ornare aenean euismod elementum
* Amet cursus sit amet dictum
* Volutpat sed cras ornare arcu dui.

Diam vulputate ut pharetra sit. Egestas quis ipsum suspendisse 
ultrices. Tempus imperdiet nulla malesuada pellentesque
elit eget gravida cum sociis. Amet cursus sit amet dictum sit 
amet justo donec enim. 

Diam maecenas ultricies mi eget mauris. Tincidunt augue 
interdum velit euismod in pellentesque. Nulla facilisi cras
fermentum odio eu feugiat pretium nibh. 
"""
}

We have to import MarkdownKit because we're going to convert our Markdown to HTML.

There are two published properties that the view uses. One contains the windowNumber of our NSWindow and the other the content of the TextField. Below those is a variable for the HTMLPrintView.

It is VERY important that the HTMLPrintView variable is defined outside of our printing function. Printing involves some async code that we are not going to await. If we define the variable in the print function, it will be destroyed before this async code is run and the print window will never be displayed. By defining it at the class level, the variable will persist after the print function has terminated.

The convertAndPrint function is where the magic happens. It has four steps;

  1. Convert the Markdown to HTML. This is handled for us using the MarkdownKit framework.
  2. Get the current NSWindow. Not strictly necessary as we could use nil instead. A window isn't required but we want our print dialog to appear over the window it is printing. To do that we need to tell the print function which window initiated the print.
  3. Create the print options. My first pass at this stuff ended up with an ever growing list of parameters. Once I get over three, I generally create an options struct to pass around. This allows me to expand the parameters without making the function call more and more complex. hence the PrintOptions struct.
  4. The final step is to create an instance of HMLPrintView and call the printView function to initiate the print. It is important to understand that the printView function will start printing asynchronously so this function call will end immediately and the function with end. At this point, the print dialog may not have displayed yet.

At this point, a print options/preview window will be displayed to the users, allowing them to select the printer and all the print options (including print to PDF).

Print Dialog

There are a lot of printing options available for the user. We do not need to worry about any of them as the print engine will deal with it all for us. That's just nice!

The HTMLPrintView

The heavy lifting is done via the HTMLPrintView. It's actually not a lot of lines of code but getting those line right took a lot of searching and trial and error.

import Foundation
import WebKit

public struct PrintOptions {
    var fileName: String
    var header: String
    var footer: String = "\(Bundle.main.appName)\(Bundle.main.appVersionLong)"
    var htmlContent: String
    var window: NSWindow
}

public class HTMLPrintView: WKWebView, WKNavigationDelegate {
    private var pop: NSPrintOperation?
    private var printOptions: PrintOptions?

    // MARK: Initiate a print
    
    public func printView(printOptions: PrintOptions) {
        self.navigationDelegate = self
        self.printOptions = printOptions
        self.loadHTMLString(printOptions.htmlContent, baseURL: nil)
    }
    
    // MARK: Callback when page loaded
    
    @objc public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        
        guard let printOptions = self.printOptions else { return }
        
        DispatchQueue.main.async {
            let printInfo = NSPrintInfo()
            printInfo.horizontalPagination = .fit
            printInfo.verticalPagination = .fit
            printInfo.topMargin = 60
            printInfo.bottomMargin = 60
            printInfo.leftMargin = 40
            printInfo.rightMargin = 40
            printInfo.isVerticallyCentered = false
            printInfo.isHorizontallyCentered = false
            
            self.pop = self.printOperation(with: printInfo)
            
            self.pop!.printPanel.options.insert(.showsPaperSize)
            self.pop!.printPanel.options.insert(.showsOrientation)
            self.pop!.printPanel.options.insert(.showsPreview)
            self.pop!.view?.frame = NSRect(x: 0.0, y: 0.0, width: 300.0, height: 900.0)
            
            self.pop!.runModal(
                for: printOptions.window,
                delegate: self,
                didRun: #selector(self.didRun),
                contextInfo: nil
            )
        }
    }
    
    @objc func didRun() {
        self.printOptions = nil
        self.pop = nil
    }

    // MARK: Page headers and footers
    
    fileprivate func addHeadings(_ printOptions: PrintOptions,
                                 _ pop: NSPrintOperation,
                                 _ borderSize: NSSize) {
        let headerAttributes: [NSAttributedString.Key: Any] = [
            .font: NSFont.systemFont(ofSize: 10),
            .foregroundColor: NSColor.black
        ]
        let headerString = NSAttributedString(string: printOptions.header, attributes: headerAttributes)
        headerString.draw(at: NSPoint(x: 30, y: borderSize.height - 50))
    }
    
    fileprivate func addFooters(_ printOptions: PrintOptions,
                                _ pop: NSPrintOperation,
                                _ borderSize: NSSize) {
        let footerAttributes: [NSAttributedString.Key: Any] = [
            .font: NSFont.systemFont(ofSize: 9),
            .foregroundColor: NSColor.black
        ]
        
        let footer = "\(printOptions.fileName)\n\(printOptions.footer)"
        let footerString = NSAttributedString(string: footer, attributes: footerAttributes)
        footerString.draw(at: NSPoint(x: 30, y: 30))
        
        let pageString = NSAttributedString(string: "Page \(pop.currentPage)", attributes: footerAttributes)
        pageString.draw(at: NSPoint(x: borderSize.width - 80, y: 30))
    }
    
    /// Draws the headers and footers on the page
    ///
    /// - Parameter borderSize: The size of the page into which we want to print the headers and footers
    ///
    public override func drawPageBorder(with borderSize: NSSize) {
        super.drawPageBorder(with: borderSize)
        
        guard let printOptions, let pop else { return }
        
        addHeadings(printOptions, pop, borderSize)
        addFooters(printOptions, pop, borderSize)
    }
}

The PrintOptions struct at the top is my convenience struct to hold the parameters that I need to pass in. It allows me to add new parameters without making the initialiser more complex.

public class HTMLPrintView: WKWebView, WKNavigationDelegate

Our class (it has to be a class) inherits from a WKWebView and implements the WKNavigationDelegate. The delegate is necessary because we need to be notified when the print operation completes.

    public func printView(printOptions: PrintOptions) {
        self.navigationDelegate = self
        self.printOptions = printOptions
        self.loadHTMLString(printOptions.htmlContent, baseURL: nil)
    }

The caller will call printView to initiate the printing process. This will set itself as the navigation delegate, save the print options and load the HTML into the WKWebView. We cannot know when the HTML finishes loading but, at this point, we don't care because we are the navigation delegate; WKWebView will tell us.

The real work happens once the HTML has loaded. When that happens the WKWebView didFinish function is called. We get this because we are the navigation delegate. It has to be decorated as #objc as we are being called from some very old code written in Objective-c.

    @objc public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        
        guard let printOptions = self.printOptions else { return }
        
        DispatchQueue.main.async {
            let printInfo = NSPrintInfo()
            printInfo.horizontalPagination = .fit
            printInfo.verticalPagination = .fit
            printInfo.topMargin = 60
            printInfo.bottomMargin = 60
            printInfo.leftMargin = 40
            printInfo.rightMargin = 40
            printInfo.isVerticallyCentered = false
            printInfo.isHorizontallyCentered = false
            
            self.pop = self.printOperation(with: printInfo)
            
            self.pop!.printPanel.options.insert(.showsPaperSize)
            self.pop!.printPanel.options.insert(.showsOrientation)
            self.pop!.printPanel.options.insert(.showsPreview)
            self.pop!.view?.frame = NSRect(x: 0.0, y: 0.0, width: 300.0, height: 900.0)
            
            self.pop!.runModal(
                for: printOptions.window,
                delegate: self,
                didRun: #selector(self.didRun),
                contextInfo: nil
            )
        }
    }
    
    @objc func didRun() {
        self.printOptions = nil
        self.pop = nil
    }

The main function of this code is to create an NSPrintInfo class and populate it, then to create an NSPrintOperation to handle the printing. The WkWebView has its own NSPrintOperation, which we need to use. If we create a new print operation, it's not going to work as we want the WKWebView to handle the printing for us.

The final step is to call the runModal function of the print operation. We pass this our window reference and a function to call when the print operation completes. For our purposes, we use this function to reset our environment.

Headers and Footers

I wanted to be able to add headings and footers to my print and did a lot of searching to find out how. i saw some vague references to overriding drawPageBorder but didn't find any examples. This is what I ended up coding.

    public override func drawPageBorder(with borderSize: NSSize) {
        super.drawPageBorder(with: borderSize)
        
        guard let printOptions, let pop else { return }
        
        addHeadings(printOptions, pop, borderSize)
        addFooters(printOptions, pop, borderSize)
    }

drawPageBorder will be called for every page that gets printed. I use this to call the two functions for printing headings and footers. Such a simple function took far too long to investigate.

The code for the headings and footings isn't complicated. It builds attributed strings and draws them on the page. One really useful feature comes into play when you want to include page numbers. In my first attempt, I tried to manage this myself and never even got close. That's when I discovered that the print operation manages it for me and provides a currentPage property with the current page number.

What next

And that's all there is to it. We can now print HTML content. There are extensions that can make this more flexible. For example, the margins on the page are hard coded and could easily be moved to the print options. We could provide a notification when the print completes so our calling view knows. For now, I don't need these, so there is no rush to add them.

Get The Code
Testimonials
Testimonials

Am I really any good?

Don't take my word for my abilities, take a look at other peoples opinions about me.