BLACK FRIDAY: Save 50% on all my Swift books and bundles! >>

SOLVED: Printing almost works but...

Forums > macOS

I've been struggling with printing some HTML in a Mac app. The code below almost works in that the print window opens and the preview shows the correct content. However, when I press the print button, the page that is printed is blank. I'm assuming that the fact that the page thumbnail shows the content indicates that the data has been correctly loaded, but am at a loss as to why the page that prints is blank.

Does anyone have any suggestions what I'm doing wrong?

        let webView = WKWebView(frame: .zero)
        webView.allowsLinkPreview = true
        webView.loadHTMLString(htmlDocument, baseURL: nil)

        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            let printInfo = NSPrintInfo()
            printInfo.horizontalPagination = .fit
            printInfo.verticalPagination = .fit
            printInfo.topMargin = 20.5
            printInfo.bottomMargin = 30.5
            printInfo.leftMargin = 40.5
            printInfo.rightMargin = 50.5
            printInfo.isVerticallyCentered = true
            printInfo.isHorizontallyCentered = true

            let printOperation = webView.printOperation(with: printInfo)

            printOperation.printPanel.options.insert(.showsPaperSize)
            printOperation.printPanel.options.insert(.showsOrientation)
            printOperation.printPanel.options.insert(.showsPreview)
            printOperation.view?.frame = NSRect(x: 0.0, y: 0.0, width: 100.0, height: 100.0)
            printOperation.run()
        }

'htmlDocument' is a String containing the HTML for the page.

If it matters I'm on Sonoma 14.5, Xcode 15.4 targeting MacOS 14.0.

FWIW, I also get these four purple warnings... No idea what they mean:

  • Failed to connect (genericPrinterImage) outlet from (PMPrinterSelectionController) to (NSImageView): missing setter or instance variable
  • Failed to connect (localPrintersLabel) outlet from (PMPrinterSelectionController) to (NSTextField): missing setter or instance variable
  • Failed to connect (otherPrintersLabel) outlet from (PMPrinterSelectionController) to (NSTextField): missing setter or instance variable
  • Failed to connect (recentPrintersLabel) outlet from (PMPrinterSelectionController) to (NSTextField): missing setter or instance variable

I am making the massive assumption that these aren't the problem, again based on the preview being correct.

Thanks

   

Some small progress... I now have the print window appearing with the preview. When I press the print button, the content actually appears on the printer (it also works to preview). So, I'm calling that a bit of a win.

I still get the four purple errors, but they don't appear to be affecting anything.

My next challenge is to get the print dialog to pop up somewhere sensible. Right now, it pops up in the lower left corner of the screen, but that's related to the NSWindow position, so I can fix that. I actually have a reference to the NSWindow hosting my SwiftUI view, so I will probably end up using that window.

The trick to make it work was to use runModal and give it a window. Without these, I could get the print dialog to show but the output was always blank. With a window, it seems to work.

public class HTMLPrintView {
    let htmlContent: String

    public init(htmlContent: String) {
        self.htmlContent = htmlContent
    }

    public func printView() {
        let uiView = WKWebView(frame: .zero)
        let window = NSWindow(contentRect: .zero,
                              styleMask: .borderless,
                              backing: .buffered,
                              defer: false)

        DispatchQueue.main.async {
            uiView.loadHTMLString(self.htmlContent, baseURL: nil)

            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                let printInfo = NSPrintInfo()
                printInfo.horizontalPagination = .fit
                printInfo.verticalPagination = .fit
                printInfo.topMargin = 40
                printInfo.bottomMargin = 60
                printInfo.leftMargin = 40
                printInfo.rightMargin = 40
                printInfo.isVerticallyCentered = true
                printInfo.isHorizontallyCentered = true

                let printOperation = uiView.printOperation(with: printInfo)

                printOperation.printPanel.options.insert(.showsPaperSize)
                printOperation.printPanel.options.insert(.showsOrientation)
                printOperation.printPanel.options.insert(.showsPreview)
                printOperation.view?.frame = NSRect(x: 0.0, y: 0.0, width: 300.0, height: 300.0)

                printOperation.runModal(for: window, delegate: nil, didRun: nil, contextInfo: nil)
            }
        }
    }
}

The calling site just creates the class and calls the print function.

    func printSelected() {
        guard let selection else { return }
        let baseHtml = htmlText(node: selection)
        let htmlDocument = buildHtml(formattedNote: baseHtml, title: "Outline Print")

        let printView = HTMLPrintView(htmlContent: htmlDocument)
        printView.printView()
    }

Now for some tidying up and renaming of stuff...

   

Reverting to your original version with a non-modal print dialog, if you want to print immediately without displaying a print dialog to the user, you could try printOperation.showsPrintPanel = false in lieu of setting all the printPanel options.

This is somewhat off-topic, but I don’t see any need for your two calls to DispatchQueue. loadHTMLString() should complete instantaneously because you're loading data from a local variable, not a remote URL. It's all on the main thread anyway.

   

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

@bobstern I wanted the print window as it gives the user the chance to see what they are going to print and to select the printer they want to use. They also get the chance to save the output to PDF or print to Preview. Saves me writing all that functionality.

You're right about the DispatchQueue calls and they have gone in the final code. Having been trying to get this working for a while I have been trying all sorts of things along the way and they're a hangover from the experimental stages.

In my final version, I have also disposed of that window. I already have the windowNumber of my SwiftUI view, so I lookup the actual NSWindow in NSApplication. By using the main window, it also solves my positioning problem as the print dialog now centers on the main window.

Next job is to try and discover whether I can add headers and footers to the page and to be able to style it. Styling will be easy as all I need to do is add a style sheet to the HTML. I suspect headers and footers may take a tad longer.

   

I have, I believe, made significant progress. Having tidied up the code, I've largely thrown it away and have new code that gives me the ability to print my HTML content and, most importantly, the ability to add a page header and footer to my prints. The headers and footers was a right pain to work out, so I'm posting the raw code here on the off-chance that someone other than me actually wants to print stuff on actual paper. I will need to code review and tidy it up before I call it production!

I created a struct to contain my printing options. This is just a useful way of grouping options when I'm prety sure that the options list will grow and when the function signature starts to grow too long. I now create a PrintOptions instance for my parameters:

public struct PrintOptions {
    var header: String
    var footer: String
    var htmlContent: String
}

My HTMLPrintView class is now an extension of WKWebView. It has to be that because I need to override the drawPageBorder function and the override didn't work when I used NSView with an embedded WKWebView:

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

    public override func drawPageBorder(with borderSize: NSSize) {

        super.drawPageBorder(with: borderSize)

        guard let pop,
              let printOptions
        else { return }

        // Drawing the header
        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))

        // Drawing the footer
        let footerAttributes: [NSAttributedString.Key: Any] = [
            .font: NSFont.systemFont(ofSize: 10),
            .foregroundColor: NSColor.black
        ]

        let footerString = NSAttributedString(string: printOptions.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))
    }

    public func printView(printOptions: PrintOptions, window parentWindow: NSWindow) {
        loadHTMLString(printOptions.htmlContent, baseURL: nil)

        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            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.printOptions = printOptions

            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: parentWindow,
                delegate: self,
                didRun: nil,
                contextInfo: nil
            )
        }
    }
}

drawPageBorder is called every time a logical page is created... so it can be called a lot. I've coded it such that it will do nothing other than call the super class function if we are not currently printing. If we have a printOperation and we have printOptions, then we must be in the process of printing the page, so I drop into the code that adds the header and footer text to the page and that prints the page number (bottom right). It's important NOT to try and keep track of the current page yourself, as I could see no way to reliably know when a page is being printed or displayed (such as in the print preview). Instead, the printOperation provides us with a page number to use.

It's also important that this run on the main thread. Hence the DispatchQueue.main.asyncAfter call. This gives the web view 1 second to load the HTML and puts the print operation on the main thread.

In my calling code, I have access to the windowNumber of the SwiftUI window, so can look up the NSWindow and pass that to my print routine. Without the window, I have found it impossible to print.

    func printSelected() {
        guard let selection else { return }
        let baseHtml = htmlText(node: selection)
        let htmlDocument = buildHtml(formattedNote: baseHtml, title: "Outline Print")

        guard let window = NSApplication.shared.windows.first(
            where: {$0.windowNumber == self.windowNumber})
        else { return }

        let printView = HTMLPrintView()
        let options = PrintOptions(
            header: "Heading String",
            footer: "Footing String",
            htmlContent: htmlDocument
        )
        printView.printView(printOptions: options, window: window)
    }

Don't know if this will help anyone else, but hope it does.

Steve

   

@barnettsab: It's also important that this run on the main thread. Hence the DispatchQueue.main.asyncAfter call. This gives the web view 1 second to load the HTML and puts the print operation on the main thread.

If this is just for personal use, that's ok, but you should be aware that inserting a fixed delay to allow time for an async operation (loadHTMLString()) to complete is very bad practice. It's an example of a "data race", which is when the program's success depends on how long a particular operation takes. The Swift 6.0 compiler is expected to flag data races as errors.

When you have time, I suggest that you read Paul's "Swift Concurrency by Example" book so you can start using await instead of fixed delays to perform async tasks. I think it's Paul's best book.

   

If this is just for personal use, that's ok, but you should be aware that inserting a fixed delay to allow time for an async operation (loadHTMLString()) to complete is very bad practice.

Absolutely right and this would never make it into a production app. Now that I am extending WKWebView, the plan is to hook into an event that fires when the load completes and to start the print process from there. All part of the cleanup process.

I suspect I may be a bit old style in the way I develop. My way of working is to do whatever it takes to just make it work. Once it works, I go back and optimise the code to streamline as much as possible and fix anything that looks suspicious. I'm lucky in that I have the opportunity to do that.

As a professional developer there was constant pressure to just check stuff in as soon as it worked. Basically, a recipe for disaster IMHO. As a retired developer, the pressure is gone but the desire to do things properly persists... thankfully.

   

I think I'm done with this now. It's likely to evolve as I use the functionality but I can move on with functional print code with headers and footers on the page. Down side is that the HTMLPrintView needs to be defined at the View Model level, so cannot be created in the calling function. Reason for that is that the object gets destroyed when the function ends and the code to do the print is fired after the HTML page loads. This means that the HTMLPrintView will be destroyed before the callback that triggers the print. I can live with that.

import Foundation
import WebKit

public struct PrintOptions {
    var header: String
    var footer: String = "\(Bundle.main.appName)\n\(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 footerString = NSAttributedString(string: printOptions.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)
    }
}

printView is called to start the print process. That calls the WKWebView loadHTMLString function to load the HTML of the page I created. I cannot assume that, when loadHTMLString completes, the page has finished loading. So, I set myself as a navigationDelegate for the WKWebView. This sends me a notification once the page has finished loading. Once I know that, I can perform the print.

I override drawPageBorder which gets called for each page to create the headers and footers.

When the print completes, my selector function is called and I reset the HTMLPrintView state.

Not sure how many other people do printing, but it is my hope that someone might be able to use this to get started printing HTML. Since I have the print dialog, I can also use this function to create PDF files, which is an added bonus.

Steve

   

Steve,

this is a valuable source of info, and your continuing conversation with yourself in this thread, together with Bob's comment, is one i'll favorite so i can find it later.

you've done a great service to the community on this.

thanks very much,

DMG

   

@delawaremathguy I know how hard it was to find any resources on printing and I hate that it was so hard. I really hope someone will get some use out of this code. You are most welcome to it and I thank you for your kind words.

   

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.