<译> 在 iOS 中使用 HTML 模版和 UIPrintPageRenderer 生成 PDF

你是否曾经被提过「使用 app 中的内容生成 PDF 文件」这样的需求?如果你之前没有做过,那你有想过该如何实现吗?
好了,通过设置问题来开篇有点不太好,但上述内容总结了我将要在这篇文章中讨论的事情。在 iOS 应用程序内创建一个 PDF 文档的想法看起来像是一个不太好完成的功能,但事实并不是这样。作为开发人员,你必须要随机应变,为自己创造可供选择的方案,并尽力达到你的目标。没有人希望这样。我不得不承认,手动绘制 PDF 最终都会转化成为一个非常痛苦的过程(取决于内容),这个任务最终可能会变得非常低效。计算坐标、加线、设置颜色、缩进、偏移等。这可能是有趣的(或并不是),但如果你要绘制的内容非常复杂,那到最后可能会变得一团糟。
这篇文章的目的是介绍给你一种创建 PDF 文件的不同的方式,这种方式比手动绘制要简单得多。它的思想是使用 HTML 模版,它可以被简化为以下几个步骤:
  1. 为这些需要被生成为 PDF 的表单和内容创建 HTML 模版。
  1. 使用这些 HTML 模版来渲染真正的内容(或者把它显示在 web view 中)。
  1. 把 HTML 的内容转换为 PDF。
在最后一步,iOS 会替你做所有麻烦的事情。
更直接一点。我认为你也更加愿意处理 HTML 而不是直接绘制 PDF 文件。你真正想要做的事是把你的内容展现为一个 HTML 文件,但是为了重复的内容手动创建页面是不聪明的而且不高效的做法。举个例子,有一个 app 能把学生的信息打印或输出为 PDF。为每个学生创建一个 HTML 页面不是问题的正确解法,因为为了打印这些信息,一直在做重复的事情。你真正想要做的事情是只创建一个 HTML 模版。使用一种特殊的方式在关键的位置放上占位符而不是使用真正的值,然后在你的 app 中,把这些占位符换成真正的值。当然,最后一步的值替换是可以是重复且自动化的。
当你拥有了真正内容生成的 HTML 代码之后,你就可以用它做任何事情了。这意味着你可以把它显示在 web view 中,保存成文件,分享它,当然也可以输出为 PDF。
那么我们到底应该怎么做呢?
最终的目标是展现给你如何把内容输出为 PDF 文档。但是我们通过带有占位符的最终会被替换为真实值的 HTML 模版来实现这个功能。Demo 的 app 是一个生成发票的小应用,我觉得它非常符合把内容输出成 PDF 的需求。我们不会从头来做这个应用,这不是我们的目的。应用的默认功能已经实现好了,HTML 模版已经写好了,我们也会一步一步说明,所以你也有机会明白这到底是怎么一回事,占位符的意义是什么。但不管怎么样,我们会一起,一步一步地走通生成真正 HTML 内容的流程,然后会把它输出为一个 PDF 文档。这也不会是终点,我还会告诉你们如何给最终的 PDF 加上 header 和 footer。
如果你对以上的内容感兴趣,那就一起开始做吧!

上手项目

我们先快速浏览一下这个教程的 demo app,其实就是一个制作发票的工具。在开始之前,你应该先下载这个上手项目,然后在 Xcode 中打开。
在上手项目中,你会发现已经有好多工作已经做完了。InvoiceListViewController 这个 view controller 是用来显示在应用中创建和保存的发票信息列表。在这个 VC 里,你也可以通过点击右上角的加号来新建发票信息。点击列表中的任何一列都可以去到对应的预览界面,在那个界面可以看到发票的详细信息。注意,我一部分的功能在上手项目中没有实现,我们会在这篇教程中实现它。新建的发票信息可以通过向左滑动对应 cell 来删除。下面的截图就是这个 VC 的界面。
notion image
就像我说过的一样,可以通过右上角的加号按纽来新建发票。这下动作会带我们去一个新的 VC,CreatorViewController,它长这个样子:
notion image
在发票可以被打印出来之前,需要填一些必要的信息。其中的一部分可以在上个 VC 中设置,还有一些可以被自动计算出来,另一些会硬编码在代码中。为了详细一些,应用中可以被手动填加的值有:
  • 接收者的信息,其实就是接收人的地址。上图中的灰色区域。
  • 需要打发票的我条目,每个条目都由两部分组成:提供服务的描述,这项服务的价格。为了简单起见,这里没有增值税。可以通过底部 toolbar 的加号来添加新的条目。
自动生成的值有:
  • 发票的号码(显示在 navigation bar 中的号码)
  • 这张发票的总价格(显示在底部 toolbar 的左边)
之后我们要硬编码的值有:
  • 发送者的信息,也就是发行人的信息。
  • 发票的截止日期(如果你想用也可以用,但在这里用不到,所以设为空)。
  • 付款的方式。
  • 发票的图标。
一个新的 VC AddItemViewController 作为简单发票条目创建的入口。这个界面很简单,只有两个 textfield,还有一个保存按纽,点击完成后会跳到之前的 VC 中。
notion image
所有的发票条目都在一个有字典元素的数组中,每个字典有两个值分别为描述和价格。这个数组作为 CreatorViewController 的数据源展现所有的条目。当一个条目被创建出来的时候,手动和自动添加的数据都会被加入到字典中,返回给 InvoiceListViewController。下面是它返回的数据:
  • 发票号码(string)。
  • 接收人的信息(string)。
  • 全部金额(string)。
  • 发票的条目(装字典的数组)。
在保存发票的发票号码的时候,下一个号码已经计算出来并且存储在用户的默认的字典中了(NSUserDefaults)。装着发票数据字典被加在 InvoiceListViewController 中的数组中,而数组的每一次有新值的时候,存储到 user defaults 中了。当 view controller 将要出现时,发票数据从 user defaults 被加载。记住,把主要应用保存到 user defaults 是一种不好的做法,只是为演示应用才这么做的。对于真正的应用程序,不建议这么做。肯定还有更好的方法来存储你的数据。
对于现有的代码,我没有什么好说的。你所要做的就是到每个 VC 中或按照应用程序的流程看代码的细节实现。还有一点我想提一下,那就是AppDelegate.swift 文件。在这个文件中有三个便捷的方法:一个用于获取 appdelegate,一个用于获取文档目录的路径,一个用于将一个表示为字符串的金额转换成一个货币字符串(连同适当的货币符号)。即使这些方法已经被用于上手项目中,我们也将再次使用它们。在 AppDelegate 中你还找到一个叫 currencyCode 属性被默认设置为 “eur”(欧元)。可以通过改变它来设置你自己的货币代码。
最后,让我告诉你,上手项目在哪结束,还有我们将从哪里开始。通过点击一个现有的在 InvoiceListViewController tableView 的发票数据,一个包含匹配发票数据的字典被传递到 PreviewViewController VC 中。在这其中,有一个可以预览发票数据渲染成的 HTML 文件,和一个导出到 PDF 的按纽。这些功能都不在上手项目中,我们将要实现它们,我们需要的所有数据都已经存在于 PreviewViewController 中,所以我们可以直接使用它。

HTML 模版文件

正如我在引言中阐述的,我们将使用 HTML 模板来产生相应发票内容的 HTML,然后真正的 HTML 内容渲染成一个 PDF 文件。这里的基本逻辑是把占位符放在 HTML 文件在某些点,然后用真实的数据替换这些占位符。但为了做到这一点,我们必须找到或创建自定义 HTML 表单来达到我们最终想要的结果。对于这篇教程的目的,我们不会创建任何自定义的 HTML 发票模板。相反,我们将使用一个在这里找到的模版(特别感谢作者)。该模板已被修改了一点,所以它没有阴影的边框,而且在 logo 处添加的灰色的背景颜色。
在你下载的上手项目里,有三个 HTML 文件:
  1. invoice.html
  1. last_item.html
  1. single_item.html
第一个包含了将产生整个发票样式的代码,除了项目的行。我们有专门的两个模板来应对行:single_item.html 将用来显示一个项目除了最后一行的任意一行,last_item.html 将被用来显示最后一行。这是因为最后一行的底部边框线是不同的。
在任意一个 HTML 中的占位符将会被 # 号给包起来。举个例子,下面的这个就显示了发票号码,发行日期和截止日期的占位符:
<td> Invoice #: #INVOICE_NUMBER<br>#INVOICE_DATE#<br>#DUE_DATE# </td>
备注:即使截止日期是以占位符的形式存在的,但是我们不会真正使用它,只会用空的字符来替换它。但如果你需要使用的话,可以任意使用。
你可以在三个 HTML 文件中找到所有的占位符,和它们适合的位置。下面是它们的名单:
  • #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 文件中。同时,#ITEMS# 占位符会被替换为用那两个 HTML 模版文件创建完成的发票条目(细节会在后文描述)。
正如你所看到的,准备一个或多个 HTML 模板来创建一个表单自定义输出(在这种情况下,发票)不是什么难事。而经历了这整个过程后,你会意识到,基于这些模板内容而生成并输出到 PDF 文件是简单而有效的。

搭建内容

已经了解了 demo app 和发票模版,我们现在应该开始实现应用没有实现的关键部分。我们要做的首先是,对于在第一个 VC (InvoiceListViewController)选中的发票信息,使用 HTML 模板,创建含有真正发票内容的 HTML,得到的第一个视图控制器选择实际的HTML内容(invoicelistviewcontroller)。这样做了之后,我们会在 PreviewViewController 中使用 web view 显示生成的 HTML 代码,我们可以通过这种方式验证是否做对了。
这部分最重要的一项任务是把 HTML 模板文件的占位符替换为真正的内容。那些真正的值实际上是从 InvoiceListViewController 传到 PreviewViewController 发票数据中对应匹配的值。正如你将看到的,更换占位符是一项简单的工作。在我们开始之前,让我们创建一个新的类用于生成真正的 HTML 内容,然后它就可以生成 PDF。在 Xcode 中,选择 File > New > File 菜单,创建一个新的 Cocoa Touch 类。让它继承自 NSObject 。并将其命名为InvoiceComposer。一路跟随向导完成新文件的创建。
notion image
打开 Invoicecomposer.swift 文件。我们先声明一些属性(常量和变量):
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!
}
前三个属性(pathToInvoiceHTMLTemplatepathToSingleItemHTMLTemplate, pathToLastItemHTMLTemplate),我们指定了三个 HTML 模版的文件路径。这些路径在之后会变得非常方便,因为我们会打开它们,获取其中的模版代码。
我已经说过,我们的演示应用程序不提供选项来设置所有的参数(senderInfo, dueDate, paymentMethodlogoImageURL),所以这些特定的被硬编码在这里。在一个真正的应用程序的过程中,这些值,用户都应该能够设置或改变。最后一个是作为发票的标志的图像的地址。你可以改变上面的属性,设置成自己喜欢的值(例如,把 senderInfo 改成你自己的信息)。
最后,该 invoiceNumber 属性将会是在任何时刻都可能被展示出来的发票号码,pdfFilename 将包含该展示 PDF 的路径。这是我们需要的东西,但现在还不必要,但是我们最好先把它们声明出来。以后要用的时候就方便了。
除了以上这些属性,给这个类加上默认的 init() 方法。
class InvoiceComposer: NSObject{
    ...

    override init() {
        super.init()
    }
}
我们现在创建一个新的方法,处理在 HTML 模板文件替换占位符的重要工作。我们将它命名为 renderInvoice,函数如下:
func renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String: String]], totalAmount: String) -> String! {

}
参数实际上是新建发票信息手动输入的值,它们都是为了生成 PDF 而需要的(还有硬编码的值)。这个方法的返回值是包含最终的 HTML 内容的字符串。
让我们开始实现该方法,先执行我们第一个重要的任务。在下面的代码片段中,两个重要的事情正在发生:首先 invoice.html 文件模板内容加载被加载到一个字符串变量中,所以我们可以修改它了。然后我们把除了发票条目的所有占位符都替换成了真实的值。下面这些注释能够帮助你理解这个过程:
func renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String:String]], totalAmount: String) -> String! {
 // 为了将来的使用,把发票号码先存起来
    self.invoiceNumber = invoiceNumber

    do {
    // 把 发票模版的 HTML 文件内容载入到一个字符串变量中
        var HTMLContent = try String(contentsOfFile: pathToInvoiceHTMLTemplate!)

        // 除了发票条目的所有占位符都替换成真实的值

        // 图标。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#LOGO_IMAGE#", withString:logoImageURL)

        // 发票号码。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#INVOICE_NUMBER#", withString:invoiceNumber)

        // 开票时间。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#INVOICE_DATE#", withString:invoiceDate)

        // 截止日期(默认为空)。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#DUE_DATE#", withString:dueDate)

        // 发行人信息。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#SENDER_INFO#", withString:senderInfo)

        // 接收人信息。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#RECIPIENT_INFO#", withString:recipientInfo.stringByReplacingOccurrencesOfString("\n", withString:""))

        // 支付方法。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#PAYMENT_METHOD#", withString:paymentMethod)

        // 总计金额。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#TOTAL_AMOUNT#", withString:totalAmount)

    }
    catch {
        print("Unable to open and use HTML template files.")
    }

    return nil
}
再进一步,注意,所有的工作都是发生在 do-catch 语句中,因为把一个文件的内容加载为字符串可能会抛出异常。同时,注意,如果出错它就会返回 nil,而现在没有真正的返回值与实际的 HTML 内容;我们接下来继续看。
让我们现在把重点放在设置发票条目上。由于他们的号码可能会有所不同,我们使用循环来处理它们。每个项目除了最后一个,我们将打开 single_item.html 模板文件,替换占位符。最后一条的底部线是不同的,我们使用last_item.html 模板操作。产生的 HTML 代码将被加到另一个字符串中(allItems 变量),该字符串包含所有的条目信息,它将在 HTMLContent 字符串中,替换 #ITEMS# 占位符。函数的返回值是该字符串。
do 中加入以下代码段:
func renderInvoice(invoiceNumber: String, invoiceDate: String,recipientInfo: String, items: [[String: String]], totalAmount: String) -> String! {
 ...
    do {
        ...
        // 通过循环来添加发票条目。
        var allItems = ""
        // 除了最后一个,都使用 "single_item.html" 模版。
        // 对于最后一个,使用 "last_item.html" 模版。
        for i in 0 ..< items.count {
            var itemHTMLContent:String!

            // 判断该使用哪个模版文件
            if i != items.count - 1 {
                itemHTMLContent = try String(contentsOfFile: pathToSingleItemHTMLTemplate!)
            }
            else {
                itemHTMLContent = try String(contentsOfFile: pathToLastItemHTMLTemplate!)
            }

            // 把描述和价格替换为真正的值。
            itemHTMLContent = itemHTMLContent.stringByReplacingOccurrencesOfString("#ITEM_DESC#", withString: items[i]["item"]!)

            // 把每个价格格式化为货币值。
            let formattedPrice = AppDelegate.getAppDelegate().getStringValueFormattedAsCurrency(items[i]["price"]!)
            itemHTMLContent = itemHTMLContent.stringByReplacingOccurrencesOfString("#PRICE#", withString: formattedPrice)

            // 把当前条目的内容加到整体的条目字符串中
            allItems += itemHTMLContent
        }

        // 替换条目。
        HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#ITEMS#", withString:allItems)

        // HTML 代码已经 ready。
        return HTMLContent
    }
    catch {
        print("Unable to open and use HTML template files.")
    }

    return nil
}
备注:你可以在 AppDelegate.swift 文件中找到 getAppDelegate() 和 getStringValueFormattedAsCurrency() 方法的实现。
目前就是这些内容。模板代码已被修改成我们真正需要的发票的内容。下一步,我们将利用上述方法的返回结果。

预览 HTML 内容

在创建了真正的 HTML 内容后,是时候验证结果了。我们在这一部分的目标是加载刚刚构建的 HTML 字符串,把它加载到 PreviewViewController 中已有的 web view 中,然后就可以看到我们之前努力的结果了。请注意,这是一个可选的步骤,在实际应用中不必在输出 PDF 之前使用 web view 。我们在这里做的,为了演示应用程序的完整性。
切换到 PreviewViewController.swift 文件,去到类的顶部,先声明几个属性:
classPreviewViewController: UIViewController {

    ...

    var invoiceComposer: InvoiceComposer!

    var HTMLContent: String!
}
第一个是我们在之前新建的生成 HTML 内容类的对象。HTMLContent 字符串是用来存放将来要用到的真正的 HTML 内容。
接下来,新建一个方法,做下面几件事情:
  1. 初始化 invoiceComposer 对象。
  1. 调用 renderInvoice(...) 方法,产生发票内容的 HTML 代码。
  1. 把 HTML 加载到 web view 中。
  1. 把返回的 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(...) 方法的参数就可以了。一旦我们从那个方法中获得了真正的 HTML 字符串(而不是 nil),我们把它加载进 web view 中。
是时候该调用我们的新方法了:
override func viewWillAppear(animated:Bool) {
    super.viewWillAppear(animated)
    createInvoiceAsHTML()
}
如果你想看到的结果,运行应用程序,并创建一个新的发票信息(如果你还没这样做过)。然后从列表中选择它,点击它,就能看到类似下图的效果:
notion image

准备输出

任务已经完成一半了,我们现在可以进行把发票信息输出为 PDF 的工作了。接下来会使用一个特殊的类,UIPrintPageRenderer。如果你之前从来没有听说,也没有使用过,那我可以先简单地告诉你,这个类是是把内容输出来打印用的(输出为文件或者使用 AirPrint 的打印机)。这里是官方的文档,在这里可以看到关于它的更多信息。
UIPrintPageRenderer 类提供了多种的绘制方法,但是对于我们这种简单的情况,其实不需要重写这些方法。这些绘制方法只能被 UIPrintPageRenderer 的子类重写,但是多做一些工作就可以把输出内容控制地更好,比如在本例中的 header 和 footer,我们为什么不去做呢?
再次回到 Xcode,按照下面的步骤创建一个新的类:
  1. 让它继承自 UIPrintPageRenderer
  1. 把它命名为 CustomPrintPageRenderer
一但你完成了上面的工作以后(在你能看到新创建的文件 CustomPrintPageRenderer 出现时),还需要为后面的工作做一些准备。先让我们指定一下 A4 纸 的宽和高(以像素为单位)。记住,我们要把发票输出成 PDF,PDF 文件也是能够打印的,所以限制一下纸的尺寸还是有必要的。
class CustomPrintPageRenderer: UIPrintPageRenderer {

    let A4PageWidth: CGFloat = 595.2

    let A4PageHeight: CGFloat = 841.8
}
上面的值描述了在全世界通用的 A4 纸的准确宽高。
CustomPrintPageRenderer 类生成的对象中指定纸的尺寸是很有必要的。我们将在 init() 方法中使用上面声明的两个属性。
override init() {

    super.init()

// 指定 A4 纸的尺寸
    let pageFrame = CGRect(x: 0.0, y: 0.0, width: A4PageWidth, height: A4PageHeight)

// 设定页面的尺寸
    self.setValue(NSValue(CGRect:pageFrame), forKey:"paperRect")

// 设定水平和垂直的缩进(这一步是可选的)
    self.setValue(NSValue(CGRect:pageFrame), forKey:"printableRect")
}
上面的的代码里包含了一种非常直接的、并且同时是基本的设置纸张打印尺寸区域的技巧。paperRectprintableRect 属性都是只读的,这也就是为什么我们需要在这里给它们赋值。
在上面的代码中可以发现,我们把纸张的大小和打印的区域设置为一样大的。但是到后面你会发现,周围留出一些边距,打印出来的效果会更好。为了达到这种效果,可以把上面代码的最后一行,换成下面的代码:
self.setValue(NSValue(CGRect:CGRectInset(pageFrame,10.0,10.0)),forKey:"printableRect")
上面的代码给水平和垂直都加了 10 边距。即使你没有子类化 UIPrintPageRenderer,对于这部分的设置也已经生效了。换句话来说,你永远不会忘记设置你要打印内容的纸张尺寸和打印区域的大小了。

输出为 PDF

说是「输出为 PDF」,其实是把内容绘制到一个 PDF 图形的上下文。一旦绘制完成,完成好的内容可以发送到打印机打印,也可以被保存成一个文件。我们对第二种情况比较感兴趣,所以我们会把绘制好的 PDF 上下文转换成 NDData 对象,然后把这个对象保存到文件中(最终的 .pdf 文件)。让我们来一步一步进行。
先打开 InvoiceComposer.swift 文件,在这里我们要实现一个新的方法 exportHTMLContentToPDF(...)。它只接受一个参数,我们想要输出到 PDF 的 HTML 内容。在看这个方法的实现之前,我们再来看看另一个跟打印相关的概念,也就是 **print formatter (UIPrintFormatter class)。下面是 Apple 的文档对它的介绍:
UIPrintFormatter is an abstract base class for print formatters: objects that lay out custom printable content that can cross page boundaries. Given a print formatter, the printing system can automate the printing of the type of content associated with the print formatter.
这意味着我们只需把 HTML 内容作为打印的 formatter 添加到打印的 renderer,iOS 打印系统将接管页面布局和实际的打印页面。我建议你看一看这里会有详细的解释。简单来说,把 print formatter 想把要打印的内容传递给 iOS 打印系统的一种中介。此外,虽然 UIPrintFormatter 是一个抽象类,但 iOS 的 SDK 提供了有实现的子类来给我们使用。其中之一是 UIMarkupTextPrintFormatter,我们可以用它把 HTML 内容转换成 page renderer 对象。还有一些其它的子类信息可以在上面的链接中找到。
By having said that, it’s about time to implement our new method. Here it is: 光说还是有些不清楚,看看代码吧:
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)
}
Here’s what happens in that code snippet: 来一起看看上面的几行代码做了什么事情:
  • 首先我们初始化了一个 CustomPrintPageRenderer 对象来执行绘制工作。
  • 接着我们初始化了一个 UIMarkupTextPrintFormatter 对象,在初始化的时候,我们把 HTML content 作为参数传了进去。
  • 第三行,我们把 printFormatter 加到了 printPageRenderer 对象中。addPrintFormatter(...) 方法的第二个参数是指定 printFormatter 起始生效的页面。我们在这里设置为 0,因为打印的内容只有一页。
  • 真正的绘制部分在接下来才会发生。drawPDFUsingPrintPageRenderer(...) 是一个我们在后面才会创建的自定义方法。绘制完成的 PDF 会被存放在 pdfData 对象中,它实际上是一个 NSData 类型的对象。
  • 接下来就是把 PDF 数据存入文件。首先我们声明了文件的路径,以发票的号码来指定文件名。然后把 PDF 数据写入这个文件中。
  • 最后一步显然不是必要的,但是我们可以通过在 Finder 中找到这个新创建的文件,来验证我们绘制的结果。
在一个更复杂的应用中,你可以使用多个 print formatter 对象,当然也可以对不同的 print formatter 指定不同的起始页面。但是对于我们来说,创建一个对象能够说明问题就足够了。
现在我们来把上面没有实现的,也就是真正绘制的方法给实现。在这里我们使用了 Core Graphics,下面的方法也很直白,一起来看看吧:
func drawPDFUsingPrintPageRenderer(printPageRenderer:UIPrintPageRenderer) -> NSData! {
    let data = NSMutableData()

    UIGraphicsBeginPDFContextToData(data, CGRectZero, nil)

    UIGraphicsBeginPDFPage()

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

    UIGraphicsEndPDFContext()

    return data
}
首先我们初始化了一个 NSMutableData 对象,是用来写入 PDF 数据的。然后我们创建了 PDF 图形上下文来开始 PDF 绘制。接下来才是绘制的代码:
printPageRenderer.drawPageAtIndex(0,inRect:UIGraphicsGetPDFContextBounds())
作为参数的 printPageRenderer 对象在这一行开始了绘制工作,它会把内容绘制在 PDF 上下文的区域中。注意,在这里自定义的 header 和 footer 也会被自动绘制,因为 drawPageAtIndex(...) 调用了 printPageRenderer 对象中所有的绘制方法。
最后我们关闭了 PDF 图形上下文,然后返回了 data 对象。
上面的方法打印一个只能打印一个单页面,如果你想要打印多个页面,或者你想要扩展这个 demo 应用,可以把上面的操作放到一个循环中。
到此为止,所有关于 PDF 输出的部分就已经结束了,但是我们的工作还没有结束,在下一部分我们会绘制 header 和 footer。不过在那之前,我们先把上面的工作串联起来。
打开 PreviewViewController.swift 文件,定位到 exportToPDF(...) IBAction 方法。把下面几行加进去。点击按纽的时候就可以把发票导出为 PDF 文件了。
@IBAction func exportToPDF(sender: AnyObject){
    invoiceComposer.exportHTMLContentToPDF(HTMLContent)
}
你现在就可以测试应用了,但是为了快速看到结果,我建议你在模拟器中进行下面的操作。在预览发票界面,点击 PDF 按纽:
notion image
这么做之后,输出为 PDF 这个过程就已经发生了,当一切都结束的时候,你将会在控制台看到 PDF 文件的路径。把路径复制一下(不要带上文件名),打开一个 Finder 窗口,使用 Shift-Command-G 快捷键,粘贴上路径,在打开的文件夹中你就可以看到以发票号码为名字的新创建的 PDF 文件。
notion image
双击打开它,用你喜欢的 PDF 程序就好。
notion image

绘制自定义的 Header 和 Footer

现在扩展一下我们的 demo 应用,往打印页面添加自定义的 header 和 footer。毕竟这也是我们最初子类化 UIPrintPageRenderer 的原因。自定义的意思是,不是 HTML 模板中的一部分,不是和其它的 HTML 内容一起渲染的内容。我们想要实现的是把 「Invoice」放在页面的顶部,作为 header,把「Thank you!」放在页面的底部,作为页面的 footer,在它上面还有一条水平线。下面的这张图就是我们要达到的效果:
notion image
在开始之前,我们先声明一下 header 和 footer 的高度。打开 CustomPrintPageRenderer.swift 文件,添加下面两行(这两个属性都是继承自UIPrintPageRenderer的)。
override init() {
    ...

    self.headerHeight = 50.0
    self.footerHeight = 50.0
}
我们先从 header 做起。先重写一下父类中的下面这个方法:
override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) {

}
在这个方法中我们要做的事情步骤如下所示:
  1. 首先指定我们要绘制的 header 文字(也就是「Invoice」单词)。
  1. 指定 header 文字的一些属性,比如字体、颜色、字间距等。
  1. 计算字在加上上述属性后占据的空间,然后指定文字到页面右侧页面的边距。
  1. 设置文字起始绘制的点。
  1. 绘制文字(终于到这一步了)。
下面就是我上面文字转化为代码的实现。每句都有注释,方便大家理解:
override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) {

    // 声明 header 文字。
    let headerText: NSString = "Invoice"

    // 设置字体。
    let font = UIFont(name: "AmericanTypewriter-Bold", size: 30.0)

    // 设置字的属性。
    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]

    // 计算字的大小。
    let textSize = getTextSize(headerText as String, font: nil, textAttributes: textAttributes)

    // 右边的空距。
    let offsetX: CGFloat = 20.0

    // 指定字应该从哪里开始绘制。
    let pointX = headerRect.size.width - textSize.width - offsetX
    let pointY = headerRect.size.height/2 - textSize.height/2

    // 绘制 header 的文字。
    headerText.drawAtPoint(CGPointMake(pointX, pointY), withAttributes: textAttributes)
}
还有一件事我没有在上面的代码里说明的就是 getTextSize(...) 方法。跟你猜的一样,这又是另一个自定义方法,用于计算并返回文字的 frame。计算发生在另一个方法中,因为在绘制 footer 的时候也会用到这个方法。
下面就是 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
}
上面的方法对于计算文字占据的 frame 尺寸是一个通用的策略。我们把 textAttributes 设置到这个临时的 label 上。通过对其调用 sizeToFit() 方法,让系统帮助我们计算这个 label 的尺寸。
现在我们开始绘制 footer。下面的步骤跟上面绘制 header 的步骤十分相似,所以我也就没注释下面的代码。注意,Footer 中的文字是水平居中的,文字颜色也和之前的不一样,字母之间也没有空距:
override 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)

}
上述代码创建了「Thank you!」的 footer,但是在它上面没有一条分隔线。因此,我们再把上面的方法补充一下:
override func drawFooterForPageAtIndex(pageIndex: Int, inRect footerRect: CGRect) {
    ...

    // 绘制水平线

    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)

}
现在我们已经有了一条水平线!
在这部分结束之前,关于 header 和 footer 还有几句话想说。不知你注意到了没有,header 和 footer 中的文字都是 NSString 对象而不是 String 对象,这是因为执行真正绘制的 drawAtPoint(...) 方法属于 NSString 类。如果你使用了 String 对象,那通过下面的方式把它转换成 NSString 的对象:
(text as! NSString).drawAtPoint(...)
运行应用然后检查一下结果,这一次已经包含了 header 和 footer。

Bonus Part:预览并使用 Email 发送 PDF 文件

到此为止,我们已经完成了这篇教程的主要目的。然而,当你使用真实设备运行程序的时候,没有能够直接看到导出的 PDF 文件的方法(你可以用 Xcode 查看,但是每次创建 PDF f都这么做太麻烦了),所以我要给这个 app 增加两个额外的功能:在 PreviewViewController 已经实现的在 web view 中预览 PDF 的功能,还有通过 Email 发送 PDF 文件的功能。我们可以让用户通过一个有各种可能选项的 alert controller 来让用户做出最终选择。这里不会讲得太细,因为下面的代码已经超出了这篇教程的范围。
我们会把代码写在 PreviewViewController.swift 文件中,所以在 Project Navigator 找到并打开它。加入以下显示 alert controller 的方法:
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)

}
每个选项的 action 还没有被实现,所以我们现在开始实现。对于预览动作,我们通过 NSURLRequest 对象把 PDF 文件载入到 web view 中:
let actionPreview = UIAlertAction(title: "Preview it", style: UIAlertActionStyle.Default) { (action) in

    let request = NSURLRequest(URL: NSURL(string: self.invoiceComposer.pdfFilename)!)

    self.webPreview.loadRequest(request)

}
对于发送邮件,可以按照下面的方法来实现:
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,你还需要引入 MessageUI
import MessageUI
回到 showOptionsAlert() 方法,按下面的代码段完成 actionPreview action:
let actionEmail = UIAlertAction(title: "Send by Email", style: UIAlertActionStyle.Default) { (action) in

    dispatch_async(dispatch_get_main_queue(), {

        self.sendEmail()

    })
}
还差一点就完成了,别忘了我们还得调用 showOptionsAlert() 方法。Alert controller 会在发票被输出为 PDF 文件之后出现,回到 exportToPDF(...) IBAction 方法,加上下面的一句话:
@IBAction func exportToPDF(sender: AnyObject) {

    ...

    showOptionsAlert()
}
完成!现在你可以在真机上运行这个应用并且使用导出的 PDF 文件了。
notion image

总结

不管现在还是以后在创建 PDF 文档方面出现了什么新的技术,本文展现的这个方法在创建 PDF 文件方面永远会是基本的、高灵活性的,且安全的。它适用于几乎所有的情形,但只有一个缺点:用于渲染真正内容的 HTML 模板。但我认为,创建它的成本真得很低。相比于写 HTML、创建 placeholders、替换字符串来说,手动绘制 PDF 文件真得是太麻烦了。除此之外,真正绘制 PDF 部分的代码是很基本的,并且通过 demo 应用的代码,你可以获得很理想的结果。不管怎样,我希望你能喜欢本文中介绍的这种方法。感谢阅读!希望你能开心地处理输出 PDF 文档的问题!
你可以在 Github.com 获取本文的 Xcode 项目 作为参考。

© Xinyu 2014 - 2024