Create PDF with multiple pages
Asked Answered
G

3

15

I need to implement a functionality of creating pdf with multiple pages of a text.

class PDFCreator {

func prepareData() -> Data {
    //1
    let pdfMetaData = [
      kCGPDFContextCreator: "PDF Creator",
      kCGPDFContextAuthor: "Pratik Sodha",
      kCGPDFContextTitle: "My PDF"
    ]

    //2
    let format = UIGraphicsPDFRendererFormat()
    format.documentInfo = pdfMetaData as [String: Any]

    //3
    let pageWidth = 8.5 * 72.0
    let pageHeight = 11 * 72.0
    let pageRect = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)

    //4
    let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)

    //5
    let data = renderer.pdfData { (context) in
        //6
        context.beginPage()
        self.addText("Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.", pageRect: pageRect)

    }

    return data
}

@discardableResult
func addText(_ text : String, pageRect: CGRect) -> CGFloat {

    // 1
    let textFont = UIFont.systemFont(ofSize: 60.0, weight: .regular)

    // 2
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.alignment = .natural
    paragraphStyle.lineBreakMode = .byWordWrapping

    // 3
    let textAttributes = [
        NSAttributedString.Key.paragraphStyle: paragraphStyle,
        NSAttributedString.Key.font: textFont
    ]
    let attributedText = NSAttributedString(string: text, attributes: textAttributes)
    let textSize = attributedText.boundingRect(with: pageRect.size, options: [.usesFontLeading, .usesLineFragmentOrigin], context: nil)

    // 4
    let textRect = CGRect(x: 10,
                          y: 10,
                          width: pageRect.width - 20,
                          height: textSize.height)

    attributedText.draw(in: textRect)

    return textRect.origin.y + textRect.size.height
  }
}

Using PDFCreator class prepare pdf data and display using PDFView.

import UIKit
import PDFKit

class PDFPreviewViewController: UIViewController {

    //1
    @IBOutlet weak private var pdfView : PDFView!

    override func viewDidLoad() {

        super.viewDidLoad()

        //2
        let pdfData = PDFCreator().prepareData()

        //3
        pdfView.document = PDFDocument(data: pdfData)
        pdfView.autoScales = true
    }
}

Actual Output

enter image description here

Excepted Output

Whole text will be in PDF with new PDF page without decreasing font size.

Any help much appreciated. Thank you.

Grogshop answered 21/10, 2019 at 10:0 Comment(0)
G
10

Output

enter image description here

Fixed using CTFramesetterCreateFrame and CFAttributedStringGetLength


class PDFCreator {

lazy var pageWidth : CGFloat  = {
    return 8.5 * 72.0
}()

lazy var pageHeight : CGFloat = {
    return 11 * 72.0
}()

lazy var pageRect : CGRect = {
    CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)
}()

lazy var marginPoint : CGPoint = {
    return CGPoint(x: 10, y: 10)
}()

lazy var marginSize : CGSize = {
    return CGSize(width: self.marginPoint.x * 2 , height: self.marginPoint.y * 2)
}()


func prepareData() -> Data {
    //1
    let pdfMetaData = [
      kCGPDFContextCreator: "PDF Creator",
      kCGPDFContextAuthor: "Pratik Sodha",
      kCGPDFContextTitle: "My PDF"
    ]

    //2
    let format = UIGraphicsPDFRendererFormat()
    format.documentInfo = pdfMetaData as [String: Any]

    //3
    let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)

    //5
    let data = renderer.pdfData { (context) in

        //6
        self.addText("Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.", context: context)
    }

    return data
}

@discardableResult
func addText(_ text : String, context : UIGraphicsPDFRendererContext) -> CGFloat {

    // 1
    let textFont = UIFont.systemFont(ofSize: 60.0, weight: .regular)

    // 2
    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.alignment = .natural
    paragraphStyle.lineBreakMode = .byWordWrapping

    // 3
    let textAttributes = [
        NSAttributedString.Key.paragraphStyle: paragraphStyle,
        NSAttributedString.Key.font: textFont
    ]

    //4
    let currentText = CFAttributedStringCreate(nil,
                                               text as CFString,
                                               textAttributes as CFDictionary)
    //5
    let framesetter = CTFramesetterCreateWithAttributedString(currentText!)

    //6
    var currentRange = CFRangeMake(0, 0)
    var currentPage = 0
    var done = false
    repeat {

        //7
        /* Mark the beginning of a new page.*/
        context.beginPage()

        //8
        /*Draw a page number at the bottom of each page.*/
        currentPage += 1
        drawPageNumber(currentPage)


        //9
        /*Render the current page and update the current range to
          point to the beginning of the next page. */
        currentRange = renderPage(currentPage,
                                  withTextRange: currentRange,
                                  andFramesetter: framesetter)

        //10
        /* If we're at the end of the text, exit the loop. */
        if currentRange.location == CFAttributedStringGetLength(currentText) {
            done = true
        }

    } while !done

    return CGFloat(currentRange.location + currentRange.length)
}

func renderPage(_ pageNum: Int, withTextRange currentRange: CFRange, andFramesetter framesetter: CTFramesetter?) -> CFRange {
    var currentRange = currentRange
    // Get the graphics context.
    let currentContext = UIGraphicsGetCurrentContext()

    // Put the text matrix into a known state. This ensures
    // that no old scaling factors are left in place.
    currentContext?.textMatrix = .identity

    // Create a path object to enclose the text. Use 72 point
    // margins all around the text.
    let frameRect = CGRect(x: self.marginPoint.x, y: self.marginPoint.y, width: self.pageWidth - self.marginSize.width, height: self.pageHeight - self.marginSize.height)
    let framePath = CGMutablePath()
    framePath.addRect(frameRect, transform: .identity)

    // Get the frame that will do the rendering.
    // The currentRange variable specifies only the starting point. The framesetter
    // lays out as much text as will fit into the frame.
    let frameRef = CTFramesetterCreateFrame(framesetter!, currentRange, framePath, nil)

    // Core Text draws from the bottom-left corner up, so flip
    // the current transform prior to drawing.
    currentContext?.translateBy(x: 0, y: self.pageHeight)
    currentContext?.scaleBy(x: 1.0, y: -1.0)

    // Draw the frame.
    CTFrameDraw(frameRef, currentContext!)

    // Update the current range based on what was drawn.
    currentRange = CTFrameGetVisibleStringRange(frameRef)
    currentRange.location += currentRange.length
    currentRange.length = CFIndex(0)

    return currentRange
}

func drawPageNumber(_ pageNum: Int) {

    let theFont = UIFont.systemFont(ofSize: 20)

    let pageString = NSMutableAttributedString(string: "Page \(pageNum)")
    pageString.addAttribute(NSAttributedString.Key.font, value: theFont, range: NSRange(location: 0, length: pageString.length))

    let pageStringSize =  pageString.size()

    let stringRect = CGRect(x: (pageRect.width - pageStringSize.width) / 2.0,
                            y: pageRect.height - (pageStringSize.height) / 2.0 - 15,
                            width: pageStringSize.width,
                            height: pageStringSize.height)

    pageString.draw(in: stringRect)

}
}

Using PDFCreator class prepare pdf data and display using PDFView.

import UIKit
import PDFKit

class PDFPreviewViewController: UIViewController {

    //1
    @IBOutlet weak private var pdfView : PDFView!

    override func viewDidLoad() {

        super.viewDidLoad()

        //2
        let pdfData = PDFCreator().prepareData()

        //3
        pdfView.document = PDFDocument(data: pdfData)
        pdfView.autoScales = true
    }
}
Grogshop answered 22/10, 2019 at 7:29 Comment(2)
Would ALL of this PDF's text be editable in something like Adobe Acrobat Pro?Deathbed
@pratol sodha, also, how do you control where vertically on the FIRST page this starts printing?Deathbed
O
4

Similar to Pratik Sodha's answer, but using TextKit, instead of CoreText. The main advantage of this is that TextKit fully supports NSAttributedString, rather than CFAttributedString. This means that I can now put NSTextAttachments in the text, which CFAttributedString doesn't support.

class AttributedStringToPDFConverter {
    
    lazy var pageWidth : CGFloat  = {
        return 8.5 * 72.0
    }()
    
    lazy var pageHeight : CGFloat = {
        return 11 * 72.0
    }()
    
    lazy var pageRect : CGRect = {
        CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)
    }()
    
    lazy var marginPoint : CGPoint = {
        return CGPoint(x: 50, y: 50)
    }()
    
    lazy var marginSize : CGSize = {
        return CGSize(width: self.marginPoint.x * 2 , height: self.marginPoint.y * 2)
    }()
    
    func pdfData(from attributedString: NSAttributedString) -> Data {
        let pdfMetaData = [
            kCGPDFContextCreator: "...",
            kCGPDFContextTitle: "..."
        ]
        let format = UIGraphicsPDFRendererFormat()
        format.documentInfo = pdfMetaData as [String: Any]
        let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)
        
        let data = renderer.pdfData { (context) in
            self.addText(attributedString, context: context)
        }
        
        return data
    }
    
    private func addText(_ text : NSAttributedString, context : UIGraphicsPDFRendererContext) {
        let layoutManager = NSLayoutManager()
        let textStorage = NSTextStorage()
        textStorage.append(text)
        textStorage.addLayoutManager(layoutManager)
        let textContainerSize = CGSize(width: pageWidth - marginSize.width, height: pageHeight - marginSize.height)
        var textContainer: NSTextContainer
        var textViews = [UITextView]()
        
        // keep adding text containers and text views...
        repeat {
            textContainer = NSTextContainer(size: textContainerSize)
            layoutManager.addTextContainer(textContainer)
            textViews.append(UITextView(frame: CGRect(origin: marginPoint, size: textContainerSize), textContainer: textContainer))
        // while the last glyph is not in a text container
        } while layoutManager.textContainer(forGlyphAt: layoutManager.numberOfGlyphs - 1, effectiveRange: nil) == nil
        
        // draw each text view
        for textView in textViews {
            context.beginPage()
            context.cgContext.translateBy(x: marginPoint.x, y: marginPoint.y)
            textView.textContainerInset = .zero
            textView.backgroundColor = .white
            textView.layer.render(in: context.cgContext)
        }
    }
}
Overspill answered 18/1, 2021 at 1:51 Comment(5)
I have test this code , it is working in iOS14.X but not working in iOS15.XAntiperistalsis
@Antiperistalsis Thanks for notifying me. I tried a few things in iOS 15, and it should be fixed now.Overspill
My apologies, it is working. I had to change the text type from '''NSMutableAttributedString''' to '''NSAttributedString'''Deathbed
The only issue with this method is that the text is not edible in programs like Adobe Acrobat Pro.Deathbed
I tested this code and it worked fine. i wish i can upvote this 100 timesSecundines
T
3

The answer is very inspiring. But i also need to render texts with different styles, so in order to reuse your code, i changed renderPage(_:withTextRange:andFramesetter) to renderPage(_:withTextRange:andFramesetter:from):

func renderPage(_ pageNum: Int, withTextRange currentRange: CFRange, andFramesetter framesetter: CTFramesetter?, from lastPosition: CGFloat = 0) -> CFRange {
    var currentRange = currentRange
    let currentContext = UIGraphicsGetCurrentContext()
    currentContext?.textMatrix = .identity

    // MARK: - Chanages1: Resize rect based on the last ending point
    let frameRect = CGRect(
        x: self.marginPoint.x, 
        y: self.marginPoint.y + lastPosition, 
        width: self.pageWidth - self.marginSize.width, 
        height: self.pageHeight - self.marginSize.height - lastPosition
        )

    let framePath = CGMutablePath()
    framePath.addRect(frameRect)
    let frameRef = CTFramesetterCreateFrame(framesetter!, currentRange, framePath, nil)

    // MARK: - Changes2
    if lastPosition == 0 {
        currentContext?.translateBy(x: 0, y: self.pageHeight)

        currentContext?.scaleBy(x: 1.0, y: -1.0)
    } else {
        // MARK: - Already in the context, no need to "mirror"
        currentContext?.translateBy(x: 0, y: -lastPosition)
    }
    CTFrameDraw(frameRef, currentContext!)
    currentRange = CTFrameGetVisibleStringRange(frameRef)
    currentRange.location += currentRange.length
    currentRange.length = CFIndex(0)

    return currentRange
}

and addText(_:context) to add(_:font:in:at:from)

func add(_ text: String,
               font: UIFont,
               in context: UIGraphicsPDFRendererContext,
               at currentPage: Int,
               from lastPosition: CGFloat = 0) -> (Int, CGFloat) {
    // ...
    var lastPosition = lastPosition
    var newPosition: CGFloat = 0
    repeat {
        // ...
        // MARK: - Changes1: text left needed to fill
        let textRect = CTFramesetterSuggestFrameSizeWithConstraints(
            framesetter, currentRange, nil, 
            .init(
                width: self.pageWidth - self.marginSize.width, 
                height: self.pageHeight - self.marginSize.height - lastPosition
                ), 
            nil
            )

        currentRange = renderPage(2, withTextRange: currentRange, andFramesetter: framesetter, from: lastPosition)

        // MARK: - Changes2: reset after first drawing
        lastPosition = 0
        // MARK: - save the newPosition
        newPosition = textRect.height > newPosition ? textRect.height : newPosition
        // ...
    } while !done

    return (currentPage, newPosition)
}

Now, in prepareData I can add texts with different styles like this:

let data = renderer.pdfData { (context) in
    context.beginPage()
    drawPageNumber(1)
    var lastGroup: (Int, CGFloat)
    lastGroup = add(body, font: .systemFont(ofSize: 23), in: context, at: 1)

    lastGroup = add(body, font: .systemFont(ofSize: 33), in: context, at: lastGroup.0, from: lastGroup.1)
    lastGroup = add(body, font: .systemFont(ofSize: 43), in: context, at: lastGroup.0, from: lastGroup.1)
    lastGroup = add(body, font: .systemFont(ofSize: 53), in: context, at: lastGroup.0, from: lastGroup.1)
}

Preview


Update

Previous version only works when the text exceeds the page height. Drawing shorter text will be messy. This time im reserving the position where last drawing was finished at.

Here is the updated version:

// MARK: - lazy vars
lazy var maxTextBounds: CGSize = {
        return CGSize(width: pageWidth - marginSize.width,
                      height: pageHeight - marginSize.height)
    }()
    lazy var cgContext: CGContext = {
        print("getting cgContext")
        let context = UIGraphicsGetCurrentContext()!
        context.textMatrix = .identity
        // MARK: - flip context
        context.translateBy(x: 0, y: pageHeight)
        context.scaleBy(x: 1, y: -1)
        return context
    }()
// MARK: - Render page
func renderPage(_ pageNum: Int, withTextRange currentRange: CFRange, andFramesetter framesetter: CTFramesetter?, from lastPosition: CGFloat = 0) -> (CFRange, CGFloat) {


        // MARK: - text height in current page
        let textBounds = CTFramesetterSuggestFrameSizeWithConstraints(framesetter!,
                                                                      currentRange,
                                                                      nil,
                                                                      .init(width: maxTextBounds.width, height: maxTextBounds.height - lastPosition),
                                                                      nil)

        if maxTextBounds.height == lastPosition {
            // not enough space in this page
            // MARK: - reset
            return (currentRange, 0)
        }
        // MARK: - path where text drawn at
        let framePath = CGMutablePath()
        // MARK: - invisble rect surrounds the text, when drawing the rect will be move to marginPoint
        framePath.addRect(CGRect(origin: .zero, size: textBounds))

        // MARK: - text frame
        let frameRef = CTFramesetterCreateFrame(framesetter!, currentRange, framePath, nil)

        // MARK: - move up
        print("move up by", pageHeight - (textBounds.height + lastPosition + marginPoint.y))
        cgContext.translateBy(x: marginPoint.x, y: pageHeight - (textBounds.height + lastPosition + marginPoint.y))
        // MARK: - draw
        CTFrameDraw(frameRef, cgContext)
        // MARK: - move back for next
        cgContext.translateBy(x: -marginPoint.x, y: -pageHeight + (textBounds.height + lastPosition + marginPoint.y))

        // MARK: - udpate current range
        var currentRange = currentRange
        currentRange = CTFrameGetVisibleStringRange(frameRef)
        currentRange.location += currentRange.length
        currentRange.length = CFIndex(0)

        // MARK: - updating the succeeding position
        var newPosition = textBounds.height + lastPosition
        if newPosition >= pageHeight - marginSize.height {
            newPosition = 0
        }
        return (currentRange, newPosition)
    }

When the text is too long to fit in current page, add(_:font:in:at:from) will repeatedly call renderPage(_:withTextRange:andFramesetter:from), before it happens, we need to "reset" the context.

// in else block
context.beginPage()
currentPage += 1

drawPageNumber(currentPage)
lastPosition = 0
// MARK: - new Page, reset context for those texts not finished drawing
cgContext.textMatrix = .identity
cgContext.translateBy(x: 0, y: pageHeight)
cgContext.scaleBy(x: 1, y: -1)
Threshold answered 12/3, 2020 at 18:29 Comment(2)
I am struggling on how to get this working. I see the other examples above and can get the main answer to work, but would much rather apply your answer but i get a bunch of errors.. is this the complete code for this? is there something missing?Katinka
Here is a working example, written in playground.Threshold

© 2022 - 2024 — McMap. All rights reserved.