Printing NSTextStorage over multiple UITextView produces weird results

I would like to print a NSTextStorage on multiple pages and add annotations to the side margins corresponding to certain text ranges. For example, for all occurrences of # at the start of a line, the side margin should show an automatically increasing number.

My idea was to create a NSLayoutManager and dynamically add NSTextContainer instances to it until all text is laid out. The layoutManager would then allow me to get the bounding rectangle of the interesting text ranges so that I can draw the corresponding numbers at the same height inside the side margin. This approach works well on macOS, but I'm having some issues on iOS.

When running the code below in an iPad Simulator, I would expect that the print preview shows 3 pages, the first with the numbers 0-1, the second with the numbers 2-3, and the last one with the number 4. Instead the first page shows the number 4, the second one the numbers 2-4, and the last one the numbers 0-4. It's as if the pages are inverted, and each page shows the text starting at the correct location but always ending at the end of the complete text (and not the range assigned to the relative textContainer).

I've created FB17026419.

class ViewController: UIViewController {

    override func viewDidAppear(_ animated: Bool) {
        let printController = UIPrintInteractionController.shared
        let printPageRenderer = PrintPageRenderer()
        printPageRenderer.pageSize = CGSize(width: 100, height: 100)
        printPageRenderer.textStorage = NSTextStorage(string: (0..<5).map({ "\($0)" }).joined(separator: "\n"), attributes: [.font: UIFont.systemFont(ofSize: 30)])
        printController.printPageRenderer = printPageRenderer
        printController.present(animated: true) { _, _, error in
            if let error = error {
                print(error.localizedDescription)
            }
        }
    }

}

class PrintPageRenderer: UIPrintPageRenderer, NSLayoutManagerDelegate {
    
    var pageSize: CGSize!
    var textStorage: NSTextStorage!
    
    private let layoutManager = NSLayoutManager()
    private var textViews = [UITextView]()
    
    override var numberOfPages: Int {
        if !Thread.isMainThread {
            return DispatchQueue.main.sync { [self] in
                numberOfPages
            }
        }
        
        printFormatters = nil
        layoutManager.delegate = self
        textStorage.addLayoutManager(layoutManager)
        if textStorage.length > 0 {
            let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: textStorage.length - 1, length: 0), actualCharacterRange: nil)
            layoutManager.textContainer(forGlyphAt: glyphRange.location, effectiveRange: nil)
        }
        
        var page = 0
        for textView in textViews {
            let printFormatter = textView.viewPrintFormatter()
            addPrintFormatter(printFormatter, startingAtPageAt: page)
            page += printFormatter.pageCount
        }
        return page
    }
    
    func layoutManager(_ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool) {
        if textContainer == nil {
            addPage()
        }
    }
    
    private func addPage() {
        let textContainer = NSTextContainer(size: pageSize)
        layoutManager.addTextContainer(textContainer)
        let textView = UITextView(frame: CGRect(origin: .zero, size: pageSize), textContainer: textContainer)
        textViews.append(textView)
    }
    
}
Answered by DTS Engineer in 835151022

Printing isn't my domain, but from the TextKit perspetive, the pages are inverted because creating a new UITextView with a text container triggers a new layout process. If you add print in the following way:

private func addPage() {
    let textContainer = NSTextContainer(size: pageSize)
    print("Appending text container...")
    layoutManager.addTextContainer(textContainer)
    let textView = UITextView(frame: CGRect(origin: .zero, size: pageSize), textContainer: textContainer)
    print("Appending text view...")
    textViews.append(textView)
}

You will see the following log when running your app:

Appending text container...
Appending text container...
Appending text container...
Appending text view...
Appending text view...
Appending text view...

To fix the issue, consider creating the text views after all the text containers are added, as shown below:

override var numberOfPages: Int {
    ...
    if textStorage.length > 0 {
        ...
    }
    for textContainer in layoutManager.textContainers {
        let textView = UITextView(frame: CGRect(origin: .zero, size: textContainer.size), textContainer: textContainer)
        textViews.append(textView)
    }
    for textView in textViews {
        let range = layoutManager.glyphRange(for: textView.textContainer)
        print("textrange = \(range)")
    }

    var page = 0
    ...
}
...
private func addPage() {
    let textContainer = NSTextContainer(size: pageSize)
    layoutManager.addTextContainer(textContainer)
    //let textView = UITextView(frame: CGRect(origin: .zero, size: pageSize), textContainer: textContainer)
    //textViews.append(textView)
}

With that, running your app gets the following text ranges, which I believe is what you are looking for:

textrange = {0, 4}
textrange = {4, 4}
textrange = {8, 1}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Printing isn't my domain, but from the TextKit perspetive, the pages are inverted because creating a new UITextView with a text container triggers a new layout process. If you add print in the following way:

private func addPage() {
    let textContainer = NSTextContainer(size: pageSize)
    print("Appending text container...")
    layoutManager.addTextContainer(textContainer)
    let textView = UITextView(frame: CGRect(origin: .zero, size: pageSize), textContainer: textContainer)
    print("Appending text view...")
    textViews.append(textView)
}

You will see the following log when running your app:

Appending text container...
Appending text container...
Appending text container...
Appending text view...
Appending text view...
Appending text view...

To fix the issue, consider creating the text views after all the text containers are added, as shown below:

override var numberOfPages: Int {
    ...
    if textStorage.length > 0 {
        ...
    }
    for textContainer in layoutManager.textContainers {
        let textView = UITextView(frame: CGRect(origin: .zero, size: textContainer.size), textContainer: textContainer)
        textViews.append(textView)
    }
    for textView in textViews {
        let range = layoutManager.glyphRange(for: textView.textContainer)
        print("textrange = \(range)")
    }

    var page = 0
    ...
}
...
private func addPage() {
    let textContainer = NSTextContainer(size: pageSize)
    layoutManager.addTextContainer(textContainer)
    //let textView = UITextView(frame: CGRect(origin: .zero, size: pageSize), textContainer: textContainer)
    //textViews.append(textView)
}

With that, running your app gets the following text ranges, which I believe is what you are looking for:

textrange = {0, 4}
textrange = {4, 4}
textrange = {8, 1}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks for the suggestion, that solves the issue with the reversed pages. But each text view still contains the actual text I would expect it to contain, plus the text of all the following ones.

class PrintPageRenderer: UIPrintPageRenderer, NSLayoutManagerDelegate {
    
    var pageSize: CGSize!
    var textStorage: NSTextStorage!
    
    private let layoutManager = NSLayoutManager()
    private var textViews = [UITextView]()
    
    override var numberOfPages: Int {
        if !Thread.isMainThread {
            return DispatchQueue.main.sync { [self] in
                numberOfPages
            }
        }
        
        printFormatters = nil
        layoutManager.delegate = self
        textStorage.addLayoutManager(layoutManager)
        if textStorage.length > 0 {
            let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: textStorage.length - 1, length: 0), actualCharacterRange: nil)
            layoutManager.textContainer(forGlyphAt: glyphRange.location, effectiveRange: nil)
        }
        for textContainer in layoutManager.textContainers {
            let textView = UITextView(frame: CGRect(origin: .zero, size: pageSize), textContainer: textContainer)
            textViews.append(textView)
        }
        for textView in textViews {
            let _ = layoutManager.glyphRange(for: textView.textContainer)
        }

        var page = 0
        for textView in textViews {
            let printFormatter = textView.viewPrintFormatter()
            addPrintFormatter(printFormatter, startingAtPageAt: page)
            page += printFormatter.pageCount
        }
        return page
    }
    
    func layoutManager(_ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool) {
        if textContainer == nil {
            addPage()
        }
    }
    
    private func addPage() {
        let textContainer = NSTextContainer(size: pageSize)
        layoutManager.addTextContainer(textContainer)
    }
    
}

But each text view still contains the actual text I would expect it to contain, plus the text of all the following ones.

I don't think that is the case. The text range of each text view is correct, as shown in my previous post. Also, if you add the text views to your viewConteoller.view, you will see that the text views render the text correctly.

Maybe you need to configure your print formatter in some way? I tried the following and it seems to work, but as I mentioned, printing isn't my domain, and I am unclear if that is the right way.

var page = 0
for textView in textViews {
    let printFormatter = textView.viewPrintFormatter()
    printFormatter.maximumContentHeight = pageSize.height // Limit the page content height.
    addPrintFormatter(printFormatter, startingAtPageAt: page)
    page += 1 //printFormatter.pageCount
}

Best,
——
Ziqiao Chen
 Worldwide Developer Relations.

Thanks, that almost solves all of my issues. On macOS, and when showing the text views directly in the iOS view hierarchy, using the page break character produces the expected result. For example with the string

(0..<1).map({ "\($0)" }).joined(separator: "\n") + String(UnicodeScalar(12)) + (0..<5).map({ "\($0)" }).joined(separator: "\n")

the view hierarchy looks like this:

But when printing them, the text views seem to overlap.

I'm afraid this is now a question for a printing expert. The code below produces both the view hierarchy and the print preview.

class ViewController: UIViewController {
 
    override func viewDidAppear(_ animated: Bool) {
        let printController = UIPrintInteractionController.shared
        let printPageRenderer = PrintPageRenderer()
        printController.printPageRenderer = printPageRenderer
        printController.present(animated: true) { _, _, error in
            if let error = error {
                print(error.localizedDescription)
            }
        }
        
        var y = 0.0
        for textView in printPageRenderer.createTextViews.textViews {
            textView.frame.origin = CGPoint(x: 0, y: y)
            view.addSubview(textView)
            y += textView.frame.height
        }
    }
 
}

class CreateTextViews: NSObject, NSLayoutManagerDelegate {
    
    let pageSize: CGSize
    let textStorage: NSTextStorage
    
    private let layoutManager = NSLayoutManager()
    private(set) var textViews = [UITextView]()
    
    override init() {
        pageSize = CGSize(width: 100, height: 100)
        textStorage = NSTextStorage(string: (0..<1).map({ "\($0)" }).joined(separator: "\n") + String(UnicodeScalar(12)) + (0..<5).map({ "\($0)" }).joined(separator: "\n"), attributes: [.font: UIFont.systemFont(ofSize: 30)])
        super.init()
        
        layoutManager.delegate = self
        textStorage.addLayoutManager(layoutManager)
        if textStorage.length > 0 {
            let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: textStorage.length - 1, length: 0), actualCharacterRange: nil)
            layoutManager.textContainer(forGlyphAt: glyphRange.location, effectiveRange: nil)
        }
        for textContainer in layoutManager.textContainers {
            let textView = UITextView(frame: CGRect(origin: .zero, size: pageSize), textContainer: textContainer)
            textViews.append(textView)
        }
    }
    
    func layoutManager(_ layoutManager: NSLayoutManager, didCompleteLayoutFor textContainer: NSTextContainer?, atEnd layoutFinishedFlag: Bool) {
        if textContainer == nil {
            addPage()
        }
    }
    
    private func addPage() {
        let textContainer = NSTextContainer(size: pageSize)
        layoutManager.addTextContainer(textContainer)
    }

}
 
class PrintPageRenderer: UIPrintPageRenderer {
    
    let createTextViews = CreateTextViews()
    
    override var numberOfPages: Int {
        if !Thread.isMainThread {
            return DispatchQueue.main.sync { [self] in
                numberOfPages
            }
        }
        printFormatters = nil
        var page = 0
        for textView in createTextViews.textViews {
            let printFormatter = textView.viewPrintFormatter()
            printFormatter.maximumContentHeight = createTextViews.pageSize.height
            addPrintFormatter(printFormatter, startingAtPageAt: page)
            page += 1
        }
        return page
    }
    
}
Printing NSTextStorage over multiple UITextView produces weird results
 
 
Q