Adding Charts to PDF Output

I use Daniel Gindi’s iOS Charts library for graphs in iOS/macOS apps and needed to be able to generate PDF reports with graphs embedded in them.

Initially I used the chartView.layer.render(in:CGContext) function but that produces bitmapped images, which are find for most purposes. However I wanted to produce high quality scalable vector images and struggled to find any useful guidance on how to to this.

Here is an example pdf output file with the bar chart in vector and the line chart in bitmapped image.
SpaceU_Report

Here is a screen shot from the App itself.
iOS Charts Screenshot 01

How the app works

1. The model classes are Project, Building, etc. and these are persisted in CloudKit so before trying to render anything the app fetches the data from CloudKit.

2. The uses can either view the summary reports directly in the app – here the app uses standard UIViews with ChartView objects on them.

3. The user can generate a PDF report and save this or send this to someone. Here the app displays the PDFReportViewController and shows a simple status field while it fetches the selected projects data and generates the PDF file.

4. Once the PDF file has been generated the app uses QuickLook APIs to show a preview of the generated PDF file. The QuickLook view controller takes care of any other user steps, such as saving or sending a copy to someone.

5. To get the charts embedded in the PDF document a ChartController object is created, in this case LineGraphController and BarGraphController, to replace the usual UIViewController one would use containing the chart when displaying in the app. These GraphController classes simply create the required ChartViews and configure their display parameters and populate the data source.

6. The PDFReport class is the main class responsible for generating and saving the PDF output file. The sample code shows how to render the LineChart as a bitmapped image and the BarChart as a vector image. For a vector image simply call the ChartView.draw(rect:CGRect) method passing in the ChartViews frame rectangle. For the bitmapped image use the ChartView.layer.render() method as shown below.

Line Chart Controller Source Code

//
//  LineChartController.swift
//  SpaceU
//
//  Created by Duncan Groenewald on 21/5/18.
//  Copyright © 2018 OSSH Pty Ltd. All rights reserved.
//

import Foundation
import Charts

class LineChartController: ChartViewDelegate {

    let chartView: LineChartView

    let colors = [UIColor.green, UIColor.blue, UIColor.red, UIColor.purple, UIColor.brown, UIColor.cyan]

    let building: Building

    init(rect: CGRect, building: Building){

        self.building = building

        chartView = LineChartView(frame: rect)

        //
        chartView.delegate = self

        chartView.noDataText = "Loading chart data..."

        chartView.xAxis.valueFormatter = DateValueFormatter()
        chartView.xAxis.labelPosition = .bottom
        chartView.xAxis.axisMinLabels = 7
        chartView.xAxis.axisMaxLabels = 7
        chartView.xAxis.setLabelCount(7, force: true)
        chartView.xAxis.granularity = 4 * 3600

        chartView.chartDescription?.enabled = false
        chartView.dragEnabled = false
        chartView.setScaleEnabled(false)
        chartView.pinchZoomEnabled = false

        chartView.xAxis.gridLineDashLengths = [10, 10]
        chartView.xAxis.gridLineDashPhase = 0

        let leftAxis = chartView.leftAxis
        leftAxis.removeAllLimitLines()
        leftAxis.axisMaximum = 100
        leftAxis.axisMinimum = 0
        leftAxis.gridLineDashLengths = [5, 5]
        leftAxis.drawLimitLinesBehindDataEnabled = true

        chartView.rightAxis.enabled = false

        let marker = BalloonMarker(color: UIColor(white: 180/255, alpha: 1),
                                   font: .systemFont(ofSize: 12),
                                   textColor: .white,
                                   insets: UIEdgeInsets(top: 8, left: 8, bottom: 20, right: 8))
        marker.chartView = chartView
        marker.minimumSize = CGSize(width: 80, height: 40)
        chartView.marker = marker

        chartView.legend.form = .line

        self.updateChartData()

    }
    func updateChartData() {

        // Create dummy data points

        self.getChartData(self.building)

    }
    /// Plot each rooms data on a 24 hour x Axis
    func getChartData(_ building: Building) {

        guard let rooms = building.rooms else {
            return
        }

        var i = 0
        var minX = TimeInterval(99999999999999)
        var maxX = TimeInterval(0)

        var dataSets = [LineChartDataSet]()

        for room in rooms {
            i += 1
            var yVals = [BarChartDataEntry]()

            if let capacity = room.capacity, let observations = room.observations {
                if capacity > 0 {
                    for obs in observations {
                        if let x = obs.timestamp?.timeIntervalSince1970 {
                            minX = min(minX, x)
                            maxX = max(maxX, x)
                            let y = Double(obs.numberOfPeople ?? 0) / Double(capacity)
                            yVals.append(BarChartDataEntry(x: x, y: y*100.0))
                        }
                    }
                }

                var setLabel = "Rm \(i)"
                if let label = room.name {
                    setLabel = label
                }

                let set = LineChartDataSet(values: yVals, label: setLabel)
                set.drawIconsEnabled = false

                set.setColor(self.getColor(i - 1))
                set.setCircleColor(.red)
                set.lineWidth = 1
                set.circleRadius = 3
                set.drawCircleHoleEnabled = false
                set.valueFont = .systemFont(ofSize: 9)
                set.drawCirclesEnabled = false
                set.drawValuesEnabled = false
                set.formLineWidth = 1
                set.formSize = 15

                let gradientColors = [ChartColorTemplates.colorFromString("#00ff0000").cgColor,
                                      ChartColorTemplates.colorFromString("#ffff0000").cgColor]
                let gradient = CGGradient(colorsSpace: nil, colors: gradientColors as CFArray, locations: nil)!

                set.fillAlpha = 1
                set.fill = Fill(linearGradient: gradient, angle: 90) //.linearGradient(gradient, angle: 90)
                set.drawFilledEnabled = false

                dataSets.append(set)
            }
        }
        let data = LineChartData(dataSets: dataSets)

        // Set the X Axis min and max values

        let minDate = Date(timeIntervalSince1970: minX)
        let maxDate = Date(timeIntervalSince1970: maxX)
        let startOfDate = NSCalendar.current.startOfDay(for: minDate)
        let endOfDate = NSCalendar.current.startOfDay(for: maxDate).addingTimeInterval(24*3600)

        chartView.xAxis.axisMinimum = startOfDate.timeIntervalSince1970
        chartView.xAxis.axisMaximum = endOfDate.timeIntervalSince1970

        chartView.data = data
    }

    func getColor(_ index: Int)->UIColor{
        // Find the color based on the index
        let count = colors.count

        if index UIColor{
        // Find the color based on the index
        let count = colors.count

        if index Void){

        DispatchQueue.global().async {

            self.building.getRooms {

                DispatchQueue.main.async {

                    self.generatePdfWithFilePath(thefilePath: self.path)

                    completion()
                }
            }
        }
    }
    func drawLineGraph(x: CGFloat, y: CGFloat)->CGRect{

        let width = (pageSize.width - 2*kBorderInset - 2*kMarginInset)/2.0 - 50.0

        let renderingRect = CGRect(x: x, y: y + 50.0, width: width, height: 150.0)

        // Create a view for the Graph
        let graphController = LineChartController(rect: renderingRect, building: self.building)

        if let currentContext = UIGraphicsGetCurrentContext() {

            let frame = graphController.chartView.frame
            currentContext.saveGState()
            currentContext.translateBy(x:frame.origin.x, y:frame.origin.y)
            graphController.chartView.layer.render(in: currentContext)
            currentContext.restoreGState()
        }
        return renderingRect
    }
    func drawBarGraph(x: CGFloat, y: CGFloat)->CGRect{

        let width = (pageSize.width - 2*kBorderInset - 2*kMarginInset)/2.0 - 50.0

        let renderingRect = CGRect(x: x + width + 50, y: y + 50.0, width: width, height: 150.0)

        // Create a view for the Graph
        let graphController = BarChartController(rect: renderingRect, building: self.building)

        if let currentContext = UIGraphicsGetCurrentContext() {

            let frame = graphController.chartView.frame
            currentContext.saveGState()
            currentContext.translateBy(x:frame.origin.x, y:frame.origin.y)
            //graphController.chartView.layer.render(in: currentContext)
            graphController.chartView.draw(frame)
            currentContext.restoreGState()   

        }
        return renderingRect
    }
    func generatePdfWithFilePath(thefilePath: String)
    {

        UIGraphicsBeginPDFContextToFile(thefilePath, CGRect.zero, nil);

        var currentPage = 0
        var done = false
        repeat
        {
            //Start a new page.
            UIGraphicsBeginPDFPageWithInfo(CGRect(x: 0, y: 0, width: pageSize.width, height: pageSize.height), nil);

            //Draw a border for each page.
            self.drawBorder()

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

            //Draw text fo our header.
            self.drawHeader()

            //Draw a line below the header.
            self.drawLine()

            //Draw some text for the page.
            if let textRect = self.drawText() {

                let lineRect = self.drawLineGraph(x: textRect.minX, y:textRect.maxY)

                self.drawBarGraph(x: lineRect.minX, y:textRect.maxY)
            }
            //Draw an image
            self.drawImage()

            done = true
        }
            while (!done)

        // Close the PDF context and write the contents out.
        UIGraphicsEndPDFContext();
    }

    /// Draw a border around the page
    func drawBorder()
    {

        if let currentContext = UIGraphicsGetCurrentContext() {
            let borderColor = UIColor.brown

            let rectFrame = CGRect(x: kBorderInset, y: kBorderInset, width: pageSize.width-kBorderInset*2, height: pageSize.height-kBorderInset*2)

            currentContext.setStrokeColor(borderColor.cgColor)
            currentContext.setLineWidth(CGFloat(kBorderWidth))
            currentContext.stroke(rectFrame)

        }
    }

    // Draw the page number at bottom center
    func drawPageNumber(_ page: Int){

       // if let currentContext = UIGraphicsGetCurrentContext() {

            let pageNumberString = String(format:"Page %d", pageNumber) as NSString

            let attributes = getParaAttributes(textAlignment: .center)

            let pageNumberStringSize = pageNumberString.boundingRect(with: pageSize, options: [.usesLineFragmentOrigin], attributes: attributes, context: nil)

            let stringRenderingRect = CGRect(x: kBorderInset,
                                             y: pageSize.height - 40.0,
                                             width: pageSize.width - 2*kBorderInset,
                                             height: pageNumberStringSize.height)

            pageNumberString.draw(in: stringRenderingRect, withAttributes: attributes)

    }

    // Draw the page header
    func drawHeader(){
        if let currentContext = UIGraphicsGetCurrentContext() {
            currentContext.setFillColor(red: 0.3, green: 0.7, blue: 0.2, alpha: 1.0)

            let textToDraw = "SpaceU Report"  as NSString

            let font = UIFont.systemFont(ofSize:24.0)

            let attributes = getParaAttributes(font: font, textAlignment: .center)
            let paraSize = CGSize(width: pageSize.width - 2*kBorderInset-2*kMarginInset, height: pageSize.height - 2*kBorderInset - 2*kMarginInset)

            let stringSize = textToDraw.boundingRect(with: paraSize, options: [.usesLineFragmentOrigin], attributes: attributes, context: nil)

            let renderingRect = CGRect(x: kBorderInset + kMarginInset, y: kBorderInset + kMarginInset, width: pageSize.width - 2*kBorderInset - 2*kMarginInset, height: stringSize.height)

            textToDraw.draw(in:renderingRect, withAttributes: attributes)

        }
    }

    // Draw a line
    func drawLine(){

        if let currentContext = UIGraphicsGetCurrentContext() {

            currentContext.setLineWidth(kLineWidth)

            currentContext.setStrokeColor(UIColor.blue.cgColor)

            let startPoint = CGPoint(x:kMarginInset + kBorderInset, y:kMarginInset + kBorderInset + 40.0)
            let endPoint = CGPoint(x:pageSize.width - 2*kMarginInset - 2*kBorderInset, y:kMarginInset + kBorderInset + 40.0)

            currentContext.beginPath()
            currentContext.move(to: startPoint)
            currentContext.addLine(to: endPoint)

            currentContext.closePath()
            currentContext.drawPath(using: .fillStroke)

        }
    }

    // Draw text
    func drawText()->CGRect?{

        if let currentContext = UIGraphicsGetCurrentContext() {
            currentContext.setFillColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)

            let textToDraw = "Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Typi non habent claritatem insitam; est usus legentis in iis qui facit eorum claritatem. Investigationes demonstraverunt lectores legere me lius quod ii legunt saepius. Claritas est etiam processus dynamicus, qui sequitur mutationem consuetudium lectorum. Mirum est notare quam littera gothica, quam nunc putamus parum claram, anteposuerit litterarum formas humanitatis per seacula quarta decima et quinta decima. Eodem modo typi, qui nunc nobis videntur parum clari, fiant sollemnes in futurum."

            let font = UIFont.systemFont(ofSize:14.0)

            let attributes = getParaAttributes(font: font, textAlignment: .left)
            let paraSize = CGSize(width: pageSize.width - 2*kBorderInset-2*kMarginInset, height: pageSize.height - 2*kBorderInset - 2*kMarginInset)

            let stringSize = textToDraw.boundingRect(with: paraSize, options: [.usesLineFragmentOrigin], attributes: attributes, context: nil)

            let renderingRect = CGRect(x: kBorderInset + kMarginInset, y: kBorderInset + kMarginInset + 50.0, width: pageSize.width - 2*kBorderInset - 2*kMarginInset, height: stringSize.height)

            textToDraw.draw(in:renderingRect, withAttributes: attributes)

            return renderingRect
        }

        return nil
    }

    // Draw an Image
    func drawImage(){
        /*
         UIImage * demoImage = [UIImage imageNamed:@"demo.png"];
         [demoImage drawInRect:CGRectMake( (pageSize.width - demoImage.size.width/2)/2, 350, demoImage.size.width/2, demoImage.size.height/2)];
         */
    }

    func drawHeaderX(){
        let headerTextX = leftMargin
        let headerTextY = self.pdfHeight - CGFloat(35.0)
        let headerTextWidth = self.pdfWidth - leftMargin - rightMargin
        let headerTextHeight = CGFloat(20.0)

        let headerFont = UIFont(name: "Helvetica", size: 15.0)

        let headerParagraphStyle = NSMutableParagraphStyle()
        headerParagraphStyle.alignment = NSTextAlignment.right

        let headerFontAttributes = [
            NSAttributedStringKey.font: headerFont ?? UIFont.systemFont(ofSize: 12),
            NSAttributedStringKey.paragraphStyle:headerParagraphStyle,
            NSAttributedStringKey.foregroundColor:UIColor.lightGray
        ]
        let headerRect = CGRect(x:headerTextX, y:headerTextY, width:headerTextWidth, height:headerTextHeight)
        self.headerText.draw(in: headerRect, withAttributes: headerFontAttributes)
    }

    func getParaAttributes(fontName: String = "Helvetica", fontSize: CGFloat = 12.0, textAlignment: NSTextAlignment = .left)->[NSAttributedStringKey: Any]{
        if let font = UIFont(name: fontName, size: fontSize) {

            return getParaAttributes(font: font, textAlignment: textAlignment)
        } else {
            return getParaAttributes(textAlignment: textAlignment)
        }
    }

    func getParaAttributes(font: UIFont = UIFont.systemFont(ofSize: 12), textAlignment: NSTextAlignment = .left)->[NSAttributedStringKey: Any]{

        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = textAlignment
        paragraphStyle.lineBreakMode = .byWordWrapping

        let fontAttributes = [
            NSAttributedStringKey.font:             font,
            NSAttributedStringKey.paragraphStyle:   paragraphStyle,
            NSAttributedStringKey.foregroundColor:  UIColor.lightGray
        ]

        return fontAttributes
    }
}

PDFReportViewController Class

//
//  PDFReportViewController.swift
//  SpaceU
//
//  Created by Duncan Groenewald on 30/4/18.
//  Copyright © 2018 OSSH Pty Ltd. All rights reserved.
//

import UIKit
import QuickLook

class PDFReportViewController: UIViewController, QLPreviewControllerDataSource {

    @IBOutlet var statusLabel: UILabel?

    let quickLookController = QLPreviewController()
    var fileURLs = [URL]()

    var project:Project?

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.

        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        let documentsDirectory = paths[0]

        let filePath = documentsDirectory.appendingPathComponent("SpaceU_Report.pdf")

        fileURLs.append(filePath)

        quickLookController.dataSource = self

        self.createPDFReport(sender: nil)
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    /*
    // MARK: - Navigation

    // In a storyboard-based application, you will often want to do a little preparation before navigation
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // Get the new view controller using segue.destinationViewController.
        // Pass the selected object to the new view controller.
    }
    */

    @IBAction func createPDFReport(sender: Any?) {

        guard let project = self.project else {
            DebugLog(" Project is nil!")
            self.progress("Project is nil!")
            return
        }

        self.progress("Fetching project data, please wait...")
        // Get the project data
        CloudKitController.sharedController.projectData(project, completion: { success in

            self.progress("Generating report, please wait...")
            if success {
                self.createReport()
            } else {
                return
            }

        })
    }
    func progress(_ progress:String){
        DispatchQueue.main.async {
            self.statusLabel?.text = progress
        }
    }
    func createReport() {

        guard let building = self.project!.buildings?[0] else {
            DebugLog(" Building is nil!")
            return
        }

        let filePath = self.fileURLs[0].path

        let pdfReport = PDFReport(filePath, building: building)

        self.progress("Creating PDF Report, please wait...")

        pdfReport.createPDF({

            DispatchQueue.main.async {
                self.statusLabel?.text = "PDF Report Created."

                 // Show the report
                self.showMyDocPreview(currIndex: 0)
            }

        })

    }
     @IBAction func showPDFReport(sender: Any?) {
        print("showPDFReport")
        self.showMyDocPreview(currIndex: 0)
    }

    // MARK: QuickLook
    func numberOfPreviewItemsInPreviewController(controller: QLPreviewController) -> Int {
        return fileURLs.count
    }

    func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
        return fileURLs[index] as QLPreviewItem
    }

    @available(iOS 4.0, *)
    func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
        return fileURLs.count
    }

    func showMyDocPreview(currIndex:Int) {

        if QLPreviewController.canPreview(fileURLs[currIndex] as QLPreviewItem) {
            quickLookController.currentPreviewItemIndex = currIndex
            navigationController?.pushViewController(quickLookController, animated: true)
        }
    }
}