MENU

使用 HTML 模版和 UIPrintPageRenderer 生成PDF文档

作者:GABRIEL THEODOROPOULOS,时间:2016/7/10
翻译:BigNerdCoding, 如有错误欢迎指出。原文链接

你是否曾经被要求过在app中直接将内容生成为PDF文档?如果没有的话,你是否思考过这个需求该如何实现呢?

虽然使用提问的方式作为文章开头有点不按套路出牌,但是这些问题就是本文要讨论的重点。在app中创建PDF文档,看起来就是一条布满坑的路,但是事实上可能并没有那么恐怖。作为开发者,在面对困难的时候我们总是需要一些替换方案,避免一条道走到黑。手动生成PDF页面确实是一个非常痛苦的过程(取决于文档的内容)并且最终可能会是事倍功半的结果。计算位置、添加线、配色、插入、偏移等等,可能有趣(也可能没有)。但是如果文档内容复杂的话,那么肯定是一件坑爹的事。不太可能有人喜欢干这样的事。

在本文中我会给你介绍一种新思路来创建PDF文档,并且比手动绘制要简单不少。处理方法是基于使用HTML templates,并且可以概括为以下几步:

  1. 为那些需要打印为PDF的表单或者内容创建HTML templates

  2. 使用上面的HTML templates来生成真实的内容(可以在web view中进行预览)

  3. 将HTML内容打印为PDF文档

最后一步由iOS系统来完成。

我想你也一定会赞同处理HTML比直接绘制PDF文档更容易一些。在这种情况下,你只需要将你的文档处理成一个HTML页面就行了,当然对重复内容手动创建HTML也很低效。例如,如果我们的app要将学生信息打印或者导出为PDF文档。因为每个学生的信息格式是一样的,为每一个学生创建单独的HTML页面显然并不可取。理想的做法是创建一个HTML页面作为模版,然后使用“占位符”来表示那些需要打印的信息。然后在你的app里面,我们再使用真实信息来替换掉占位符,而且这种处理可以重复进行。

当你将那些真实信息表示为HTML代码后,你可以做任何HTML支持的功能。这意味着你可以在一个WebView中展示内容,将其保存为外部文件,分享内容,当然还有将其打印为PDF文档。

所以,文章接下来的内容是什么呢?

本文最终目标是让你知道如何将内容生成为一个PDF文档。但是首先我们需要将HTML模版中的“占位符”替换为真实信息。文中的演示应用功能就是打印发票,这与现实中PDF文档打印需求相符。当然一些默认的功能已经给出了,我们不需要从头开始构建整个应用,毕竟那并不是文章的目的。在起始工程中已经有了HTML模版,后面会对模版中的内容做介绍,这样你就能知道那些“占位符”所代表的真实含义并对模版整体有清晰的认识。不管怎样,我们都要一步步来实现最终的目标:生成HTML并将其打印为PDF文档。除此之外,我还会给你展示如何在最终的PDF文档中添加页眉、页脚。

是不是想想都激动?好戏开场了!

起始工程

接下来,我们会快速的浏览这个发票打印工具的Demo。在开始之前,你需要先去下载工程代码文件并打开工程。

你会发现该工程中的很多功能已经实现了。运行程序,首先看到的就是用来展示新建发票的视图控制器InvoiceListViewController。在该视图控制器中你可以通过右上角的+按键来创建新的发票。点击该视图中的任一发票就会跳转到预览视图。在预览视图中我们需要实现PDF文档的预览和打印功能。当然,预览视图里面的功能还等着我们去完成,这也是文章的重点。最后,在展示视图中我们可以通过左划来实现对发票的删除操作,具体看下面演示截图:

01

如上所说,点击新建按键后Demo会跳转到CreatorViewController视图中完成新增发票的功能。界面如下:

02

在生成订单之前,我们需要填写很多信息。其中一些可以手动设置,一些通过计算得到,还有一些通过代码进行硬编码。其中需要手动添加的信息有:

  • recipient info是发票收件人的地址,对应上图中的灰色区域。

  • invoice items对应一个发票中具体项目,主要由服务提供商和服务费组成。为了程序的简洁性,这里并没有设置增值税。使用屏幕下方的+按键实现添加(更多内容等会再说)。

程序计算得到的信息:

  • 发票单号(导航栏上的标题)

  • 总共的发票金额(左下角)

需要硬编码的部分:

  • 寄件人信息

  • 发票到期日(这里默认设置为空,你也可以自己定制)

  • 付款方式

  • 发票的Logo

针对invoice items我们可以在AddItemViewController视图中进行数据录入。录入的数据包括服务描述和价格,维护好数据后可以点击保存回到前一个视图。

03

每个新建的发票子项的信息都被存放在一个字典的结构中,并被追加到数组中。该数组也是CreatorViewController视图中tableview的datasource。当一个发票保存后,所有的子项和计算得到的信息都会被保存到字典中并返回到InvoiceListViewController中,返回的信息包括:

  • 发票编号

  • 收件人信息

  • 总金额

  • 发票中包含的具体子项

保存完该发票后我们会计算一个新的编号并设置到NSUserDefaults中,以便后面的继续使用。每一次用户创建新发票后,返回的信息以dictionary类型追加到InvoiceListViewController里的数组中并且该数组也会被保存到NSUserDefaults中。在该视图的viewWillAppear中我们会将信息重新加载出来。请注意:这里之所以将信息保存到 NSUserDefaults 中,主要是因为对于演示app来说这个方案简单。但是在真实的app开发时不建议这样做,毕竟存在很多更好的方案。

对于现有的代码我并没有做什么分析,你可以自己去每个视图中跟着流程去查看具体的细节。唯一我希望大家注意的是AppDelegate.swift。里面有获取application delegate、文档目录、获取金额对应货币字符串表示的三个convenient方法,在后面的代码中还会使用到它们。还有我们通过currencyCode将默认货币单位设置为乐"eur",你可以自行修改。

最后,我来说下起始工程中需要我们在后面继续完成的功能。当我们点击InvoiceListViewController中tableview的某一行发票的时候,PreviewViewController会收到包含发票信息的dictionary类型数据。在这个视图控制器里面我们会使用webview来展示HTML格式的发票内容,并且点击导出按键生成对应的PDF文档。这些功能需要我们来实现,不过我们需要确保PreviewViewController已经有可以直接使用的发票数据。

HTML模版文件

正如在前面介绍的那样,我们会先用HTML模版对发票数据做初步处理,然后将生成的真实HTML内容打印为PDF文件。这里的主要操作方法是:先在HTML模版文件中设置一些“占位符”,然后将需要展示的信息替换这些“占位符”。为了实现这一目的首先就是要创建符合展示效果的自定义模版。但是本文的关注点并不是这个,所以我们会使用一个已有的模版[地址]3。本文已经对模版做了一些修改,去除了边界和阴影并给logo添加了灰色背景。

在你下载的起始工程里面,你可以看见下面三个HTML模版文件:

  1. invoice.html

  2. last_item.html

  3. single_item.html

第一个模版文件用来处理除发票里子项\物料外的其他内容;第二个模版用来处理发票里最后一行外的子项\物料行内容;最后一个当然就是针对除最后一行外的其它子项物料行内容了;之所以对物料行做区分,主要是最后一行的底部边界与其它有差异。

每个模版文件中的“占位符”都会用#符号进行标记。例如,下面的内容就展示了发票编号、签发日期和失效日期的“占位符”:

> Invoice #: #INVOICE_NUMBER<br>
#INVOICE_DATE#<br>
#DUE_DATE# </td>

注意:虽然在模版中有失效日期的“占位符”,但在文中我们并不会真的用到。我们会使用一个空字符串来替换这个“占位符”,当然如果你想使用也没有任何问题。

你可以在三个模版文件中找到所有的“占位符”以及它们的位置。下面列出全部的“占位符”:

  • LOGO_IMAGE

  • INVOICE_NUMBER

  • INVOICE_DATE

  • DUE_DATE

  • SENDER_INFO

  • RECIPIENT_INFO

  • PAYMENT_METHOD

  • ITEMS

  • TOTAL_AMOUNT

  • ITEM_DESC

  • PRICE

最后两个“占位符”只在single_item.html和last_item.html模版文件中。当然,invoice.html模版中的#ITEMS#占位符会被其他两个模本文件创建的子项的代码替换掉。

如你所见,为输出的内容创建一个或者多个HTML模版并不是件困难的事情。并且当我们完成这部分工作之后,剩下的基于模版生成真实信息并将其导出为PDF文件将会变的很轻松。

给内容排版

一系列准备工作完成后,接下来就是动手完成缺失的关键功能了。第一步,我们需要使用模版将InvoiceListViewController中的选中行的发票信息生成为HTML文件。完成这步后,接下来会在PreviewViewController中使用webview将内容展示出来,以验证功能是否实现了。

这里最主要也是最重要的任务就是:必须将模版中的"占位符"正确的替换为发票中的真实信息。在后面你会发现这一步的处理是非常直接和简单的。但是在此之前,我们先新建一个类用于生成真实的HTML文件和后面的PDF打印操作。所以我们创建一个继承自NSObject的类:InvoiceComposer

04

打开新建的类文件并声明一些常量和变量属性:

class InvoiceComposer: NSObject {
    
    let pathToInvoiceHTMLTemplate = NSBundle.mainBundle().pathForResource("invoice", ofType: "html")
    
    let pathToSingleItemHTMLTemplate = NSBundle.mainBundle().pathForResource("single_item", ofType: "html")
    
    let pathToLastItemHTMLTemplate = NSBundle.mainBundle().pathForResource("last_item", ofType: "html")
    
    let senderInfo = "Gabriel Theodoropoulos<br>123 Somewhere Str.<br>10000 - MyCity<br>MyCountry"
    
    let dueDate = ""
    
    let paymentMethod = "Wire Transfer"
    
    let logoImageURL = "http://www.appcoda.com/wp-content/uploads/2015/12/blog-logo-dark-400.png"
    
    var invoiceNumber: String!
    
    var pdfFilename: String!
    
}

前三个属性对应三个HTML模版的文件路径。这些文件路径信息能方便后面的文档信息的读写操作。

如前所诉,在Demo中并不能设置所有的发票信息(senderInfo, dueDate, paymentMethod, logoImageURL都会采用硬编码的方式)。当然在真实的应用中这些信息应该是可以被用户设置和修改的。紧接着的属性是为发票选定的logo的链接,你也可以对这些的信息进行修改。

最后,invoiceNumber属性对应在当前预览的发票编号,而pdfFilename对应PDF文件的全路径。还有一些信息我们等到后面要用的时候再来处理。

除了这些属性,还需要添加默认的初始化方法init()

class InvoiceComposer: NSObject {
    
    ...
    
    override init() {
        super.init()
    }
}

接下来我们实现处理替换HTML模版“占位符”重任的函数。函数声明如下:

funnc renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String: String]], totalAmount: String) -> String! {
    
}

该函数的参数包含了所有使用demo创建出来的发票信息也是程序所需的全部。

现在我们开始动手来完善代码。在下面的代码中有两个重要的步骤,首先我们字符串格式读取了模版文件invoice.html以便后面的修改操作,然后我们替换了除发票子项之外的“占位符”。详见:

func renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String: String]], totalAmount: String) -> String! {
    // Store the invoice number for future use.
    self.invoiceNumber = invoiceNumber
    
    do {
        // Load the invoice HTML template code into a String variable.
        var HTMLContent = try String(contentsOfFile: pathToInvoiceHTMLTemplate!)
        
        // Replace all the placeholders with real values except for the items.
        // The logo image.
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#LOGO_IMAGE#", withString: logoImageURL)
        
        // Invoice number.
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#INVOICE_NUMBER#", withString: invoiceNumber)
        
        // Invoice date.
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#INVOICE_DATE#", withString: invoiceDate)
        
        // Due date (we leave it blank by default).
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#DUE_DATE#", withString: dueDate)
        
        // Sender info.
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#SENDER_INFO#", withString: senderInfo)
        
        // Recipient info.
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#RECIPIENT_INFO#", withString: recipientInfo.stringByReplacingOccurrencesOfString("\n", withString: "<br>"))
        
        // Payment method.
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#PAYMENT_METHOD#", withString: paymentMethod)
        
        // Total amount.
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#TOTAL_AMOUNT#", withString: totalAmount)
        
    }
    catch {
        print("Unable to open and use HTML template files.")
    }
    
    return nil
}

在代码中,我们通过stringByReplacingOccurrencesOfString(...)函数就轻松的完成了占位符的替换。虽然大量“占位符”的替换操作可能会很烦躁和无聊,但是最起码这个操作并不难。

另外需要注意的是,在使用文件内容初始化一个字符串变量的时候可能会抛出异常,所以上面的操作都是在do-catch结构里完成的。另外,如果出现问题的话我们会返回nil,至于最终需要返回的HTML内容还要下一步处理。

现在将注意力放到发票的子项处理上面。因为子项的数量可能会比较多,我们将采取循环遍历数组来进行处理。最后一项的“占位符”替换会使用last_item.html模版,其他的都将使用single_item.html模版。所有这些子项处理的结果都会被追加到allItems字符串变量中,该变量会被用来替换HTMLContent字符串中的#ITEMS#占位符。最后我们将处理结果返回。

代码如下:

func renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String: String]], totalAmount: String) -> String! {
    ...
    
    do {
        ...
        
        // The invoice items will be added by using a loop.
        var allItems = ""
        
        // For all the items except for the last one we'll use the "single_item.html" template.
        // For the last one we'll use the "last_item.html" template.
        for i in 0..<items.count {
            var itemHTMLContent: String!
            
            // Determine the proper template file.
            if i != items.count - 1 {
                itemHTMLContent = try String(contentsOfFile: pathToSingleItemHTMLTemplate!)
            }
            else {
                itemHTMLContent = try String(contentsOfFile: pathToLastItemHTMLTemplate!)
            }
            
            // Replace the description and price placeholders with the actual values.
            itemHTMLContent = itemHTMLContent.stringByReplacingOccurrencesOfString("#ITEM_DESC#", withString: items[i]["item"]!)
            
            // Format each item's price as a currency value.
            let formattedPrice = AppDelegate.getAppDelegate().getStringValueFormattedAsCurrency(items[i]["price"]!)
            itemHTMLContent = itemHTMLContent.stringByReplacingOccurrencesOfString("#PRICE#", withString: formattedPrice)
            
            // Add the item's HTML code to the general items string.
            allItems += itemHTMLContent
        }
        //Set the items.
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#ITEMS#", withString: allItems)
        
        // The HTML code is ready.
        return HTMLContent
    }
    catch {
        print("Unable to open and use HTML template files.")
    }
    
    return nil
}

注意:getAppDelegategetStringValueFormattedAsCurrency方法的具体实现,我已经在前面提过了。它们都在AppDelegate.swift文件中。

这一步到这里就结束了,我们成功实现了真实发票HTML格式信息的生成。接下来就是对该结果的进一步处理了。

预览处理后的HTML内容

在上一步处理完成后,接下来就需要验证结果是否正确了。因此这一部分内容的目的就是使用PreviewViewController视图中的webview来加载该HTML内容,查看我们前面努力的效果。需要注意的是:在真实的应用中这一步是可选的,我们可以跳过预览直接打印PDF,这里之所以需要预览仅仅是为了Demo的功能完整性而已。

我们在PreviewViewController.swift文件中声明属性:

class PreviewViewController: UIViewController {
    
    ...
    
    var invoiceComposer: InvoiceComposer!
    
    var HTMLContent: String!
    
}

第一个属性就是新建的类的实例,而HTMLContent属性则是对应最终内容的String类型变量我们会在后面用到它。

接下来我们创建一个函数来实现如下功能:

  1. 初始化invoiceComposer对象

  2. 调用invoiceComposer对象的renderInvoice(...)函数得到发票的HTML编码内容

  3. 在webview中加载该内容

  4. 将得到的HTML编码内容赋值给HTMLContent属性

代码如下:

func createInvoiceAsHTML() {
    invoiceComposer = InvoiceComposer()
    if let invoiceHTML = invoiceComposer.renderInvoice(invoiceInfo["invoiceNumber"] as! String,
                                                       invoiceDate: invoiceInfo["invoiceDate"] as! String,
                                                       recipientInfo: invoiceInfo["recipientInfo"] as! String,
                                                       items: invoiceInfo["items"] as! [[String: String]],
                                                       totalAmount: invoiceInfo["totalAmount"] as! String) {
        
        webPreview.loadHTMLString(invoiceHTML, baseURL: NSURL(string: invoiceComposer.pathToInvoiceHTMLTemplate!)!)
        HTMLContent = invoiceHTML
    }
}

代码很简单,唯一需要注意的是:只有renderInvoice(...)函数返回的内容不是nil的时候才能进行加载、赋值等操作。

下面就是函数调用了:

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    
    createInvoiceAsHTML()
}

如果你想看到显示效果,你可以先去创建一个新发票,然后在列表中点击该发票你就会看见加载后的效果图了。如下:

05

打印前的准备工作

工作完成了一半接下来该轮到打印部分的处理了,这样才能完成最终导出PDF格式的发票的目标。我们将会使用到UIPrintPageRenderer类。如果你之前没有使用会听说过这个类的话,一句话来说就是:这个类就是用来打印内容的(打印成文件或者使用AirPrint链接打印机打印)。详见点我

UIPrintPageRenderer类提供了很多打印绘制的方法,一半情况下我们不需要重载这些方法。当然为了使打印内容有更灵活的掌控(例如添加页眉、页脚),我们可以在UIPrintPageRenderer子类中对这些方法进行重载。在文中最终的打印文档中会添加页眉、页脚,所以我们会新建一个UIPrintPageRenderer子类。

与之前的新建过程类似,不过需要注意以下两点:

  1. 新建的类继承自UIPrintPageRenderer

  2. 类名为CustomPrintPageRenderer

新建完成后,我们先来A4纸尺寸来初始化widthheight。请注意我们的目标是将发票导出为PDF文件,那么这个PDF文件也应该能够被打印机完美打印出来,所以定义尺寸是很重要的一件事。

class CustomPrintPageRenderer: UIPrintPageRenderer {
    let A4PageWidth: CGFloat = 595.2
    
    let A4PageHeight: CGFloat = 841.8
    
}

接下来我们在init()中使用这两个属性来指定CustomPrintPageRenderer的纸张大小和打印区域大小。

override init() {
    super.init()
    
    // Specify the frame of the A4 page.
    let pageFrame = CGRect(x: 0.0, y: 0.0, width: A4PageWidth, height: A4PageHeight)
    
    // Set the page frame.
    self.setValue(NSValue(CGRect: pageFrame), forKey: "paperRect")
    
    // Set the horizontal and vertical insets (that's optional).
    self.setValue(NSValue(CGRect: pageFrame), forKey: "printableRect")
}

因为paperRectprintableRect都是只读属性,所以才会使用上面的方法来设置对应的属性值。

上面的代码中,纸张大小和打印区域大小是一样大的。也许你希望打印的时候能有一些边距,那么你可以将最后一行代码替换为:

setValue(NSValue(CGRect: CGRectInset(pageFrame, 10.0, 10.0)), forKey: "printableRect")

上面的代码在水平和垂直方向都设置了十个点的边距。上面的设置即使不是使用UIPrintPageRenderer子类也应该要配置。换句话说,只要使用UIPrintPageRenderer对象都都不能忘了设置打印配置。

打印为PDF

打印为PDF意味着需要将一些内容绘制为PDF文档,并将文档发送给打印机或者保存为文档。因为本文的关注点是导出文档,所有我们会保存绘制后的NSData对象,最后将该返回结果保存为PDF文件。下面我们一步步来实现:

首先在InvoiceComposer.swift文件中,实现一个名为exportHTMLContentToPDF(...)新函数,该函数将需要打印的内容HTMLContent作为唯一参数。但是在我们对该函数进行编码之前,我们有必要了解与打印相关的另一个概念:打印格式UIPrintFormatter。下面是官方文档中该类的描述:

UIPrintFormatter是打印格式的抽象基类。该类能够对打印内容进行布局,打印系统会自动将与打印格式绑定的内容打印出来。

这意味着:只需要简单的将打印的内容与打印格式绑定并传递给打印渲染器,iOS打印系统会完成后面的任务。建议你去该网页了解详情。简单来说,我们可以把打印格式理解为需要打印渲染器打印的内容。另外,虽然UIPrintFormatter是抽象类,iOS SDK还是提供了几个具体的子类。这里我们需要使用的就是打印标记语言内容的UIMarkupTextPrintFormatter,这些具体的打印格式类也可以在上面的链接中找到。

下面就是具体的实现代码:

func exportHTMLContentToPDF(HTMLContent: String) {
    let printPageRenderer = CustomPrintPageRenderer()
    
    let printFormatter = UIMarkupTextPrintFormatter(markupText: HTMLContent)
    printPageRenderer.addPrintFormatter(printFormatter, startingAtPageAtIndex: 0)
    
    let pdfData = drawPDFUsingPrintPageRenderer(printPageRenderer)
    
    pdfFilename = "\(AppDelegate.getAppDelegate().getDocDir())/Invoice\(invoiceNumber).pdf"
    pdfData.writeToFile(pdfFilename, atomically: true)
    
    print(pdfFilename)
}

注释如下:

  1. 首先创建CustomPrintPageRenderer类型实例。

  2. 接下来使用打印内容创建UIMarkupTextPrintFormatter类型实例。

  3. printFormatter作为参数传给了printPageRendereraddPrintFormatter函数。该函数的第二个参数表示当前打印内容的起始页,这里默认为0。

  4. 使用紧接着会实现的自定义函数drawPDFUsingPrintPageRenderer得到待打印的NSData对象。

  5. 保存上一步的到的数据为PDF文件。

  6. 最后我们打印出该文件的路径。

在真实的复杂应用中,我们可能会需要为每一个起始页的打印内容自定义对应的打印格式,但是对于本文的Demo来说上面的代码够用了。

下面我们来实现是第四步中的自定义函数。在函数中我们使用了Core Graphics来实现PDF文件内容的绘制。整个函数的代码简短清晰:

func drawPDFUsingPrintPageRenderer(printPageRenderer: UIPrintPageRenderer) -> NSData! {
    let data = NSMutableData()
    
    UIGraphicsBeginPDFContextToData(data, CGRectZero, nil)
    
    UIGraphicsBeginPDFPage()
    
    printPageRenderer.drawPageAtIndex(0, inRect: UIGraphicsGetPDFContextBounds())
    
    UIGraphicsEndPDFContext()
    
    return data
}

首先创建了一个NSMutableData对象用于写入后面的输出,这也是开始创建文档前的前奏。然后就是创建新文档了,不过真正绘制部分的是下面的代码:

printPageRenderer.drawPageAtIndex(0, inRect: UIGraphicsGetPDFContextBounds())

该段代码完成了PDF文件上下文的绘制,并且自定义的页眉和页脚也会完成绘制。因为drawPageAtIndex函数会调用渲染器中的其他部分绘制方法。

最后我们关闭PDF文件的Graphics上下文,并将绘制的结果数据对象返回。

上面的代码只完成了单页文件的绘制,如果你要绘制多页文档的话可以将开始绘制、和真正绘制部分的代码放在一个循环结构里面。

到目前为止,与PDF文档绘制的任务都已经完成了。但是在后面还会实现自定义页眉和页脚的绘制。当然我们还需要在PreviewViewController.swift文件的exportToPDF中调用上面实现的功能函数:

@IBAction func exportToPDF(sender: AnyObject) {
    invoiceComposer.exportHTMLContentToPDF(HTMLContent)
 }

现在我们可以来测试效果了,为了方便查看我建议使用模拟器。我们进入发票的预览界面后,点击右上角的导出PDF按键:

06

等创建文档任务完成后,我们可以在控制台看见该文件的路径。我们打开Finder窗口并使用Shift-Command-G定位到文件的父目录中你就可以你创建的PDF文件了:

07

绘制自定义页眉、页脚

现在让我们来对打印结果做一些拓展,添加页眉和页脚。这也是为什么在前面我会自定义一个UIPrintPageRenderer类。我们所说的打印内容,除了使用HTML模版生成部分还包括页眉和页脚。我们会在右上角添加"Invoice"作为页眉、下方添加“Thank you!”作为页脚。最终效果如下图:

08

在了解实现细节之前,我们需要在CustomPrintPageRenderer类的init()函数中初始化页眉、页脚的高度:

override init() {
    ...
    
    self.headerHeight = 50.0
    self.footerHeight = 50.0
}

接下来我们重载UIPrintPageRenderer类中绘制页眉的函数:

override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) {
    
}

在函数体内我们实现的步骤如下:

  1. 初始化我们需要在页眉中绘制的"Invoice"。

  2. 初始化与text格式相关的属性值,例如字体、颜色、字间距。

  3. 计算页眉显示内容的显示区域大小,并设置与右边距。

  4. 计算绘制页眉的起始位置。

  5. 绘制页眉内容。

下面就是对应的代码,每一行都带有注释:

override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) {
    // Specify the header text.
    let headerText: NSString = "Invoice"
    
    // Set the desired font.
    let font = UIFont(name: "AmericanTypewriter-Bold", size: 30.0)
    
    // Specify some text attributes we want to apply to the header text.
    let textAttributes = [NSFontAttributeName: font!, NSForegroundColorAttributeName: UIColor(red: 243.0/255, green: 82.0/255.0, blue: 30.0/255.0, alpha: 1.0), NSKernAttributeName: 7.5]
    
    // Calculate the text size.
    let textSize = getTextSize(headerText as String, font: nil, textAttributes: textAttributes)
    
    // Determine the offset to the right side.
    let offsetX: CGFloat = 20.0
    
    // Specify the point that the text drawing should start from.
    let pointX = headerRect.size.width - textSize.width - offsetX
    let pointY = headerRect.size.height/2 - textSize.height/2
    
    // Draw the header text.
    headerText.drawAtPoint(CGPointMake(pointX, pointY), withAttributes: textAttributes)
}

上面的代码中惟一需要注意的就是函数getTextSize(...)。在该函数会计算显示内容的大小,因为后面打印页脚的时候也需要使用所以就抽离出来了。代码如下:

func getTextSize(text: String, font: UIFont!, textAttributes: [String: AnyObject]! = nil) -> CGSize {
    let testLabel = UILabel(frame: CGRectMake(0.0, 0.0, self.paperRect.size.width, footerHeight))
    if let attributes = textAttributes {
        testLabel.attributedText = NSAttributedString(string: text, attributes: attributes)
    }
    else {
        testLabel.text = text
        testLabel.font = font!
    }
    
    testLabel.sizeToFit()
    
    return testLabel.frame.size
}

上面代码是计算text文本size大小的通用方法。先创建一个UILabel对象,设置简单文本的字体或者attributedText属性之后使用sizeToFit()方法让系统来计算真实的size。

页脚部分的处理和上面类似,并没有什么太多需要额外讲的。惟一需要注意的是页脚的位置是水平居中、字体颜色也与页眉存在差异,还有就是字母之间没有间距。

ovrride func drawFooterForPageAtIndex(pageIndex: Int, inRect footerRect: CGRect)      {
        let footerText: NSString = "Thank you!"

        let font = UIFont(name: "Noteworthy-Bold", size: 14.0)
        let textSize = getTextSize(footerText as String, font: font!)

        let centerX = footerRect.size.width/2 - textSize.width/2
        let centerY = footerRect.origin.y + self.footerHeight/2 - textSize.height/2
        let attributes = [NSFontAttributeName: font!, NSForegroundColorAttributeName: UIColor(red: 205.0/255.0, green: 205.0/255.0, blue: 205.0/255, alpha: 1.0)]

        footerText.drawAtPoint(CGPointMake(centerX, centerY), withAttributes: attributes)
}

页脚已经正确显示了,下面我们补上页脚上面的水平线:

ovrride func drawFooterForPageAtIndex(pageIndex: Int, inRect footerRect: CGRect) {
    ...
    
    // Draw a horizontal line.
    let lineOffsetX: CGFloat = 20.0
    let context = UIGraphicsGetCurrentContext()
    CGContextSetRGBStrokeColor(context, 205.0/255.0, 205.0/255.0, 205.0/255, 1.0)
    CGContextMoveToPoint(context, lineOffsetX, footerRect.origin.y)
    CGContextAddLineToPoint(context, footerRect.size.width - lineOffsetX, footerRect.origin.y)
    CGContextStrokePath(context)
}

在结束这一部分内容之前,关于页眉、页脚的处理有一个小细节需要跟大家说一下。如果你足够细心的话,你会发现函数中使用了NSString而不是String来处理页眉、页脚。之所以这么做是因为:处理文本绘制的函数drawAtPoint(...)属于NSString类,如果你使用String的话则需要进行类型转换:

(text as! NSString).drawAtPoint(...)

再次运行程序你就可以看见带页眉、页脚的PDF了。

附赠部分:预览并 Email 发送 PDF 文档

文中到了这里其实主要的内容已经讲解完了。然而,在设备中运行Demo的时候我们没有什么方法直接查看导出的PDF文档(除了每次创建新文档的时候通过XCode去找文档路径)。所以最后这部分提供两种可选的方法:使用PreviewViewController中的webview视图预览PDF文档;使用Email将PDF文档发送出去。我们会弹出一个提示窗口让用户自己选择最终的处理。该部分代码已经超出了文章的内容,所以不会有太多的细节。实现代码如下(PreviewViewController.swift文件中):

func showOptionsAlert() {
    let alertController = UIAlertController(title: "Yeah!", message: "Your invoice has been successfully printed to a PDF file.\n\nWhat do you want to do now?", preferredStyle: UIAlertControllerStyle.Alert)
    
    let actionPreview = UIAlertAction(title: "Preview it", style: UIAlertActionStyle.Default) { (action) in
        
    }
    
    let actionEmail = UIAlertAction(title: "Send by Email", style: UIAlertActionStyle.Default) { (action) in
        
    }
    
    let actionNothing = UIAlertAction(title: "Nothing", style: UIAlertActionStyle.Default) { (action) in
        
    }
    
    alertController.addAction(actionPreview)
    alertController.addAction(actionEmail)
    alertController.addAction(actionNothing)
    
    presentViewController(alertController, animated: true, completion: nil)
}

下面来实现不同选项对应的动作。针对预览操作,我们使用NSURLRequest对象来实现webview中对内容的加载和显示:

let actionPreview = UIAlertAction(title: "Preview it", style: UIAlertActionStyle.Default) { (action) in
    let request = NSURLRequest(URL: NSURL(string: self.invoiceComposer.pdfFilename)!)
    self.webPreview.loadRequest(request)
}

对于Email发送的功能,我们会创建一个新的函数并将PDF文件作为Eamil的附件:

func sendEmail() {
    if MFMailComposeViewController.canSendMail() {
        let mailComposeViewController = MFMailComposeViewController()
        mailComposeViewController.setSubject("Invoice")
        mailComposeViewController.addAttachmentData(NSData(contentsOfFile: invoiceComposer.pdfFilename)!, mimeType: "application/pdf", fileName: "Invoice")
        presentViewController(mailComposeViewController, animated: true, completion: nil)
    }
}

为了正常使用MFMailComposeViewController,我们需要在文件中加上:

import MessageUI

回到函数showOptionsAlert()中,补全actionPreview动作中的代码:

let actionEmail = UIAlertAction(title: "Send by Email", style: UIAlertActionStyle.Default) { (action) in
    dispatch_async(dispatch_get_main_queue(), {
        self.sendEmail()
    })
}

函数代码都已经写好了,剩下的就是在合适的地方调用了。调用的时机很明显就是当我们点击右上角按键创建PDF文档的时候,所以代码如下:

@IBAction func exportToPDF(sender: AnyObject) {
    ...

    showOptionsAlert()
}

一切就绪,现在你可以预览文档并通过Email发送了:

总结

对于创建PDF而言,无论现在的其他方案或者以后的新技巧,本文所提及的解决方案总会是标准、灵活和安全的之一。该方案惟一的缺点就是:我们需要编写那些HTML模版文件。不过对于我来说,这工作实在是物超所值。与花大量工作去手动绘制PDF相比,我坚信替换模版文件中的“占位符”的做法更加可取。除此之外,真实情况中的PDF文档绘制都是非常标准的,只需要对Demo中的代码进行部分调整就能实现复用了。不管怎样,我都希望本文中的方法能够真正的帮到你。

本文的完整Demo代码地址,仅供读者参考。