2

I'm building an identity app which is stored all locally within the app on device.

Part of the app is the creation of IdentityCard for each user - with the standard things, name, image, position.

The size of the card is a standard credit card size: let cardSize: CGSize = .init(width: 153.8, height: 242.1) in the vertical position.

I want to create a "generate PDF" button so that each user's ID card will be printed into a PDF. I have the ability to select the paper size (A4, Letter, or custom - where they can select their own width, height, and unit).

At the moment I have this to generate the PDF, but I am running into a few issues:

  1. It doesn't output anything in a .sheet
  2. It is slow to run
  3. It crashes when testing on larger sets of users
import SwiftUI
import PDFKit
class PDFDataManager {
  static let shared = PDFDataManager()
  private init() {}
  struct Item<Content : View> {
    let views: [Content]
    let width: CGFloat
    let height: CGFloat
  }
  func generate<Content : View>(
    from item: Item<Content>,
    paper: PageSize = .a4,
    margin: Double = 36,
    bleed: Double = 10
  ) -> PDFDocument {
    let usablePageSize: CGSize = .init(
      width: paper.size.width - margin - (2 * bleed),
      height: paper.size.height - margin - (2 * bleed)
    )
    let itemSize: CGSize = .init(
      width: item.width,
      height: item.height
    )
    let maxRows = Int(floor(usablePageSize.height / itemSize.height))
    let maxCols = Int(floor(usablePageSize.width / itemSize.width))
    let pdfDocument = PDFDocument()
    let pageSize = CGRect(
      x: 0, y: 0,
      width: paper.size.width,
      height: paper.size.height
    )
    var currentItem = 0
    var currentRow = 0
    var currentCol = 0
    DispatchQueue.global(qos: .userInitiated).async {
      while currentItem < item.views.count {
        let pdfPage = PDFPage()
        pdfPage.setBounds(pageSize, for: .trimBox)
        let pdfView = PDFView(frame: pageSize)
        pdfView.autoScales = true
        pdfView.displayDirection = .vertical
        pdfView.displayMode = .singlePageContinuous
        pdfView.document = pdfDocument
        pdfView.pageBreakMargins = .init(top: 0, left: 0, bottom: 0, right: 0)
        while currentItem < item.views.count && currentRow <= maxRows {
          autoreleasepool {
            DispatchQueue.main.async {
              if currentItem < item.views.count {
                let itemView = UIHostingController(rootView: item.views[currentItem])
                let x = CGFloat(currentCol) * (itemSize.width + (bleed * 2))
                let y = CGFloat(currentRow) * (itemSize.height + (bleed * 2))
                let itemRect = CGRect(
                  x: x, y: y,
                  width: itemSize.width,
                  height: itemSize.height
                )
                itemView.view.frame = itemRect
                pdfView.addSubview(itemView.view)
                currentCol += 1
                if currentCol >= maxCols {
                  currentCol = 0
                  currentRow += 1
                }
                currentItem += 1
              }
            }
          }
        }
        DispatchQueue.main.async {
          pdfDocument.insert(pdfPage, at: pdfDocument.pageCount)
        }
      }
    }
    return pdfDocument
  }
}

What I've aimed to do / What my aim is to do:

  1. Pass in all the items through a temp struct
  2. Have a page margin, and an item bleed spacer
  3. Calculate the max items in a row
  4. Calculate the max items in a column
  5. Loop over all the items and place them onto the page

Once I would have that working I would be able to share the PDF, print the PDF, or export the PDF. However, at the moment I cant do any of that and I'm kind of lost of where to go from here.

I am targeting iOS/iPadOS 15 and above.

markb
  • 1,100
  • 1
  • 15
  • 40
  • Since you're starting with SwiftUI Views, I would recommend looking at ImageRenderer, which can generate PDFs directly rather than working through PDFKit and UIKit. See the docs for ImageRenderer for an example (the example generates a single page, but it should scale by calling `.beginPDFPage` multiple times). – Rob Napier Mar 13 '23 at 14:39
  • 1
    @RobNapier thanks for the comment, I forgot to mention I’m targeting iOS 15 and above so ImageRender is one version out of scope – markb Mar 13 '23 at 21:34

1 Answers1

0

This question was a bit harder to answer than what I thought, but I did manage to get it solved.

I do want to flag that there would probably be smarter, more efficient, and better implementation - but this worked for me.

I started with this framework which helped me build the PDF. It is fairly fleshed out and simple to use.

Using that, I had this code as my even spreading across the pages:

func generatePDF(_ items: [Item]) {

  let pageSize: PageSize = .a4
  let pageMargin: UIEdgeInsets = .equal(20)
  let imageSize = CGSize(width: 160, height: 250)
  let pdf = PDFMKit(pageSize: pageSize, pageMargin: pageMargin)
  let usablePageSize = CGSize(
    width: pageSize.size.width - pageMargin.left - pageMargin.right,
    height: pageSize.size.height - pageMargin.top - pageMargin.bottom
  )

  // -- get the max rows and columns per page
  let maxColumnsPerPage = Int(usablePageSize.width / imageSize.width)
  let maxRowsPerPage = Int(usablePageSize.height / imageSize.height)
  let maxItemsPerPage = maxRowsPerPage * maxColumnsPerPage

  // -- get the total item count
  let totalItems = users.count

  // -- create a chunked array
  let pageChunks = users.chunked(into: maxItemsPerPage)

  // -- run the operation in the background
  DispatchQueue.global(qos: .background).async {

    // -- loop over the chunks
    // -- this should be all the items per page
    for (pageIndex, pageChunk) in pageChunks.enumerated() {

      // -- rechunk the page items into the rows
      let pageItemChunks = pageChunk.chunked(into: maxColumnsPerPage)

      // -- loop over the rows
      for pageItemChunk in pageItemChunks {

        // -- get an array of the images
        let images = pageItemChunk.map { user in
          UIImage(
            data: user.cardImageData ?? .init(),
            scale: .screenScale
          ) ?? .init()
        }

        // -- start the horizontal alignment
        pdf.beginHorizontalArrangement()

        // -- add in the images
        for image in images {
          pdf.addImage(image)
        }

        // -- end the horizontal alignment
        pdf.endHorizontalArrangement()

        // -- update the progress bar
        if let lastItem = pageItemChunk.last,
           let lastIndex = users.firstIndex(of: lastItem) {
           DispatchQueue.main.async {
             progress(Double(lastIndex + 1) / Double(totalItems))
           }
         }
       }

       // -- create a new page at the end
       if pageIndex < pageChunks.count - 1 {
         pdf.beginNewPage()
       }
     }

     // -- update the main thread data
     DispatchQueue.main.async {
       let title = UUID().uuidString
       let pdfData = pdf.generate(title,
                                  author: "Author Name",
                                  subject: Bundle.main.displayName)
       do {
         let savedURL = try self.savePDF(pdfData, name: title)
         completion(.success(savedURL))
       } catch {
         completion(.failure(.unableToGenerate))
       }
     }
   }
 }
}

My implementation is similar but I do have some customisations to the source code to make it fully work for me. But the above should help anyone who needs it!

markb
  • 1,100
  • 1
  • 15
  • 40