[iOS] 如何使用 HTML 模版和 iOS 中的 UIPrintPageRenderer 来生成 PDF 文档
zzit0721
8年前
<p>作者:GABRIEL THEODOROPOULOS,时间:2016/7/10</p> <p>翻译:BigNerdCoding, 如有错误欢迎指出。 <a href="/misc/goto?guid=4959677148298624791" rel="nofollow,noindex">原文链接</a></p> <p>你是否曾经被要求过在app中直接将内容生成为PDF文档?如果没有的话,你是否思考过这个需求该如何实现呢?</p> <p>虽然使用提问的方式作为文章开头有点不按套路出牌,但是这些问题就是本文要讨论的重点。在app中创建PDF文档,看起来就是一条布满坑的路,但是事实上可能并没有那么恐怖。作为开发者,在面对困难的时候我们总是需要一些替换方案,避免一条道走到黑。手动生成PDF页面确实是一个非常痛苦的过程(取决于文档的内容)并且最终可能会是事倍功半的结果。计算位置、添加线、配色、插入、偏移等等,可能有趣(也可能没有)。但是如果文档内容复杂的话,那么肯定是一件坑爹的事。不太可能有人喜欢干这样的事。</p> <p>在本文中我会给你介绍一种新思路来创建PDF文档,并且比手动绘制要简单不少。处理方法是基于使用 <em>HTML templates</em> ,并且可以概括为以下几步:</p> <ol> <li>为那些需要打印为PDF的表单或者内容创建 <strong>HTML templates</strong></li> <li>使用上面的 <strong>HTML templates</strong> 来生成真实的内容(可以在web view中进行预览)</li> <li>将HTML内容打印为PDF文档</li> </ol> <p>最后一步由iOS系统来完成。</p> <p>我想你也一定会赞同处理HTML比直接绘制PDF文档更容易一些。在这种情况下,你只需要将你的文档处理成一个HTML页面就行了,当然对重复内容手动创建HTML也很低效。例如,如果我们的app要将学生信息打印或者导出为PDF文档。因为每个学生的信息格式是一样的,为每一个学生创建单独的HTML页面显然并不可取。理想的做法是创建一个HTML页面作为模版,然后使用“占位符”来表示那些需要打印的信息。然后在你的app里面,我们再使用真实信息来替换掉占位符,而且这种处理可以重复进行。</p> <p>当你将那些真实信息表示为HTML代码后,你可以做任何HTML支持的功能。这意味着你可以在一个WebView中展示内容,将其保存为外部文件,分享内容,当然还有将其打印为PDF文档。</p> <p>所以,文章接下来的内容是什么呢?</p> <p>本文最终目标是让你知道如何将内容生成为一个PDF文档。但是首先我们需要将HTML模版中的“占位符”替换为真实信息。文中的演示应用功能就是打印发票,这与现实中PDF文档打印需求相符。当然一些默认的功能已经给出了,我们不需要从头开始构建整个应用,毕竟那并不是文章的目的。在起始工程中已经有了HTML模版,后面会对模版中的内容做介绍,这样你就能知道那些“占位符”所代表的真实含义并对模版整体有清晰的认识。不管怎样,我们都要一步步来实现最终的目标:生成HTML并将其打印为PDF文档。除此之外,我还会给你展示如何在最终的PDF文档中添加页眉、页脚。</p> <p>是不是想想都激动?好戏开场了!</p> <h2>起始工程</h2> <p>接下来,我们会快速的浏览这个发票打印工具的Demo。在开始之前,你需要先去 <a href="/misc/goto?guid=4959677148388713954" rel="nofollow,noindex">下载</a> 工程代码文件并打开工程。</p> <p>你会发现该工程中的很多功能已经实现了。运行程序,首先看到的就是用来展示新建发票的视图控制器 <em>InvoiceListViewController</em> 。在该视图控制器中你可以通过右上角的 <strong>+</strong> 按键来创建新的发票。点击该视图中的任一发票就会跳转到预览视图。在预览视图中我们需要实现PDF文档的预览和打印功能。当然,预览视图里面的功能还等着我们去完成,这也是文章的重点。最后,在展示视图中我们可以通过左划来实现对发票的删除操作,具体看下面演示截图:</p> <p><img src="https://simg.open-open.com/show/2500c87fdc132a2456cc9dbd666fa031.png"></p> <p>如上所说,点击新建按键后Demo会跳转到 <strong>CreatorViewController</strong> 视图中完成新增发票的功能。界面如下:</p> <p><img src="https://simg.open-open.com/show/bf19b4e0bf269688364ece7c24f2aada.png"></p> <p>在生成订单之前,我们需要填写很多信息。其中一些可以手动设置,一些通过计算得到,还有一些通过代码进行硬编码。其中需要手动添加的信息有:</p> <ul> <li><em>recipient info</em> 是发票收件人的地址,对应上图中的灰色区域。</li> <li><em>invoice items</em> 对应一个发票中具体项目,主要由服务提供商和服务费组成。为了程序的简洁性,这里并没有设置增值税。使用屏幕下方的 <strong>+</strong> 按键实现添加(更多内容等会再说)。</li> </ul> <p>程序计算得到的信息:</p> <ul> <li>发票单号(导航栏上的标题)</li> <li>总共的发票金额(左下角)</li> </ul> <p>需要硬编码的部分:</p> <ul> <li>寄件人信息</li> <li>发票到期日(这里默认设置为空,你也可以自己定制)</li> <li>付款方式</li> <li>发票的Logo</li> </ul> <p>针对 <em>invoice items</em> 我们可以在 <strong>AddItemViewController</strong> 视图中进行数据录入。录入的数据包括服务描述和价格,维护好数据后可以点击保存回到前一个视图。</p> <p><img src="https://simg.open-open.com/show/c28388a5b91f566a2d2e95c9348945bf.png"></p> <p>每个新建的发票子项的信息都被存放在一个字典的结构中,并被追加到数组中。该数组也是 <strong>CreatorViewController</strong> 视图中 <em>tableview</em> 的datasource。当一个发票保存后,所有的子项和计算得到的信息都会被保存到字典中并返回到 <em>InvoiceListViewController</em> 中,返回的信息包括:</p> <ul> <li>发票编号</li> <li>收件人信息</li> <li>总金额</li> <li>发票中包含的具体子项</li> </ul> <p>保存完该发票后我们会计算一个新的编号并设置到 <em>NSUserDefaults</em> 中,以便后面的继续使用。每一次用户创建新发票后,返回的信息以 <em>dictionary</em> 类型追加到 <strong>InvoiceListViewController</strong> 里的数组中并且该数组也会被保存到 <em>NSUserDefaults</em> 中。在该视图的 <em>viewWillAppear</em> 中我们会将信息重新加载出来。请注意:这里之所以将信息保存到 <em>NSUserDefaults</em> 中,主要是因为对于演示app来说这个方案简单。但是在真实的app开发时不建议这样做,毕竟存在很多更好的方案。</p> <p>对于现有的代码我并没有做什么分析,你可以自己去每个视图中跟着流程去查看具体的细节。唯一我希望大家注意的是 <em>AppDelegate.swift</em> 。里面有获取application delegate、文档目录、获取金额对应货币字符串表示的三个convenient方法,在后面的代码中还会使用到它们。还有我们通过 <em>currencyCode</em> 将默认货币单位设置为乐"eur",你可以自行修改。</p> <p>最后,我来说下起始工程中需要我们在后面继续完成的功能。当我们点击 <em>InvoiceListViewController</em> 中tableview的某一行发票的时候, <em>PreviewViewController</em> 会收到包含发票信息的 <em>dictionary</em> 类型数据。在这个视图控制器里面我们会使用webview来展示HTML格式的发票内容,并且点击导出按键生成对应的PDF文档。这些功能需要我们来实现,不过我们需要确保 <em>PreviewViewController</em> 已经有可以直接使用的发票数据。</p> <h2>HTML模版文件</h2> <p>正如在前面介绍的那样,我们会先用HTML模版对发票数据做初步处理,然后将生成的真实HTML内容打印为PDF文件。这里的主要操作方法是:先在HTML模版文件中设置一些“占位符”,然后将需要展示的信息替换这些“占位符”。为了实现这一目的首先就是要创建符合展示效果的自定义模版。但是本文的关注点并不是这个,所以我们会使用一个已有的模版 <a href="/misc/goto?guid=4959677148473617500" rel="nofollow,noindex">地址</a> (感谢原作者)。本文已经对模版做了一些修改,去除了边界和阴影并给logo添加了灰色背景。</p> <p>在你下载的起始工程里面,你可以看见下面三个HTML模版文件:</p> <ol> <li>invoice.html</li> <li>last_item.html</li> <li>single_item.html</li> </ol> <p>第一个模版文件用来处理除发票里子项\物料外的其他内容;第二个模版用来处理发票里最后一行外的子项\物料行内容;最后一个当然就是针对除最后一行外的其它子项\物料行内容了;之所以对物料行做区分,主要是最后一行的底部边界与其它有差异。</p> <p>每个模版文件中的“占位符”都会用 <strong>#</strong> 符号进行标记。例如,下面的内容就展示了发票编号、签发日期和失效日期的“占位符”:</p> <pre> <code class="language-swift">> Invoice #: #INVOICE_NUMBER<br> #INVOICE_DATE#<br> #DUE_DATE# </td></code></pre> <p>注意:虽然在模版中有失效日期的“占位符”,但在文中我们并不会真的用到。我们会使用一个空字符串来替换这个“占位符”,当然如果你想使用也没有任何问题。</p> <p>你可以在三个模版文件中找到所有的“占位符”以及它们的位置。下面列出全部的“占位符”:</p> <ul> <li>LOGO_IMAGE</li> <li>INVOICE_NUMBER</li> <li>INVOICE_DATE</li> <li>DUE_DATE</li> <li>SENDER_INFO</li> <li>RECIPIENT_INFO</li> <li>PAYMENT_METHOD</li> <li>ITEMS</li> <li>TOTAL_AMOUNT</li> <li>ITEM_DESC</li> <li>PRICE</li> </ul> <p>最后两个“占位符”只在single_item.html和last_item.html模版文件中。当然,invoice.html模版中的 <em>#ITEMS#</em> 占位符会被其他两个模本文件创建的子项的代码替换掉。</p> <p>如你所见,为输出的内容创建一个或者多个HTML模版并不是件困难的事情。并且当我们完成这部分工作之后,剩下的基于模版生成真实信息并将其导出为PDF文件将会变的很轻松。</p> <h2>给内容排版</h2> <p>一系列准备工作完成后,接下来就是动手完成缺失的关键功能了。第一步,我们需要使用模版将 <em>InvoiceListViewController</em> 中的选中行的发票信息生成为HTML文件。完成这步后,接下来会在 <em>PreviewViewController</em> 中使用webview将内容展示出来,以验证功能是否实现了。</p> <p>这里最主要也是最重要的任务就是:必须将模版中的"占位符"正确的替换为发票中的真实信息。在后面你会发现这一步的处理是非常直接和简单的。但是在此之前,我们先新建一个类用于生成真实的HTML文件和后面的PDF打印操作。所以我们创建一个继承自 <em>NSObject</em> 的类: <em>InvoiceComposer</em> 。</p> <p><img src="https://simg.open-open.com/show/e23eefac5a7bd1c71e6ef154bde085fa.png"></p> <p>打开新建的类文件并声明一些常量和变量属性:</p> <pre> <code class="language-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! }</code></pre> <p>前三个属性对应三个HTML模版的文件路径。这些文件路径信息能方便后面的文档信息的读写操作。</p> <p>如前所诉,在Demo中并不能设置所有的发票信息( <em>senderInfo, dueDate, paymentMethod, logoImageURL</em> 都会采用硬编码的方式)。当然在真实的应用中这些信息应该是可以被用户设置和修改的。紧接着的属性是为发票选定的logo的链接,你也可以对这些的信息进行修改。</p> <p>最后, <em>invoiceNumber</em> 属性对应在当前预览的发票编号,而 <em>pdfFilename</em> 对应PDF文件的全路径。还有一些信息我们等到后面要用的时候再来处理。</p> <p>除了这些属性,还需要添加默认的初始化方法 <em>init()</em> :</p> <pre> <code class="language-swift">class InvoiceComposer: NSObject { ... override init() { super.init() } }</code></pre> <p>接下来我们实现处理替换HTML模版“占位符”重任的函数。函数声明如下:</p> <pre> <code class="language-swift">funnc renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String: String]], totalAmount: String) -> String! { }</code></pre> <p>该函数的参数包含了所有使用demo创建出来的发票信息也是程序所需的全部。</p> <p>现在我们开始动手来完善代码。在下面的代码中有两个重要的步骤,首先我们字符串格式读取了模版文件 <em>invoice.html</em> 以便后面的修改操作,然后我们替换了除发票子项之外的“占位符”。详见:</p> <pre> <code class="language-swift">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 }</code></pre> <p>在代码中,我们通过 <em>stringByReplacingOccurrencesOfString(...)</em> 函数就轻松的完成了占位符的替换。虽然大量“占位符”的替换操作可能会很烦躁和无聊,但是最起码这个操作并不难。</p> <p>另外需要注意的是,在使用文件内容初始化一个字符串变量的时候可能会抛出异常,所以上面的操作都是在 <em>do-catch</em> 结构里完成的。另外,如果出现问题的话我们会返回 <em>nil</em> ,至于最终需要返回的HTML内容还要下一步处理。</p> <p>现在将注意力放到发票的子项处理上面。因为子项的数量可能会比较多,我们将采取循环遍历数组来进行处理。最后一项的“占位符”替换会使用 <em>last_item.html</em> 模版,其他的都将使用 <em>single_item.html</em> 模版。所有这些子项处理的结果都会被追加到 <em>allItems</em> 字符串变量中,该变量会被用来替换 <em>HTMLContent</em> 字符串中的 <em>#ITEMS#</em> 占位符。最后我们将处理结果返回。</p> <p>代码如下:</p> <pre> <code class="language-swift">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 }</code></pre> <p>注意: <em>getAppDelegate</em> 和 <em>getStringValueFormattedAsCurrency</em> 方法的具体实现,我已经在前面提过了。它们都在 <em>AppDelegate.swift</em> 文件中。</p> <p>这一步到这里就结束了,我们成功实现了真实发票HTML格式信息的生成。接下来就是对该结果的进一步处理了。</p> <h2>预览处理后的HTML内容</h2> <p>在上一步处理完成后,接下来就需要验证结果是否正确了。因此这一部分内容的目的就是使用 <em>PreviewViewController</em> 视图中的webview来加载该HTML内容,查看我们前面努力的效果。需要注意的是:在真实的应用中这一步是可选的,我们可以跳过预览直接打印PDF,这里之所以需要预览仅仅是为了Demo的功能完整性而已。</p> <p>我们在 <em>PreviewViewController.swift</em> 文件中声明属性:</p> <pre> <code class="language-swift">class PreviewViewController: UIViewController { ... var invoiceComposer: InvoiceComposer! var HTMLContent: String! }</code></pre> <p>第一个属性就是新建的类的实例,而 <em>HTMLContent</em> 属性则是对应最终内容的 <em>String</em> 类型变量我们会在后面用到它。</p> <p>接下来我们创建一个函数来实现如下功能:</p> <ol> <li>初始化 <em>invoiceComposer</em> 对象</li> <li>调用 <em>invoiceComposer</em> 对象的 <em>renderInvoice(...)</em> 函数得到发票的HTML编码内容</li> <li>在webview中加载该内容</li> <li>将得到的HTML编码内容赋值给 <em>HTMLContent</em> 属性</li> </ol> <p>代码如下:</p> <pre> <code class="language-swift">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 } }</code></pre> <p>代码很简单,唯一需要注意的是:只有 <em>renderInvoice(...)</em> 函数返回的内容不是 <em>nil</em> 的时候才能进行加载、赋值等操作。</p> <p>下面就是函数调用了:</p> <pre> <code class="language-swift">override func viewWillAppear(animated: Bool) { super.viewWillAppear(animated) createInvoiceAsHTML() }</code></pre> <p>如果你想看到显示效果,你可以先去创建一个新发票,然后在列表中点击该发票你就会看见加载后的效果图了。如下:</p> <p><img src="https://simg.open-open.com/show/807d4f307d8aac045a8627e72c6fc167.png"></p> <h2>打印前的准备工作</h2> <p>工作完成了一半接下来该轮到打印部分的处理了,这样才能完成最终导出PDF格式的发票的目标。我们将会使用到 <em>UIPrintPageRenderer</em> 类。如果你之前没有使用会听说过这个类的话,一句话来说就是:这个类就是用来打印内容的(打印成文件或者使用AirPrint链接打印机打印)。详见 <a href="/misc/goto?guid=4959677148566511722" rel="nofollow,noindex">点我</a> 。</p> <p><em>UIPrintPageRenderer</em> 类提供了很多打印绘制的方法,一半情况下我们不需要重载这些方法。当然为了使打印内容有更灵活的掌控(例如添加页眉、页脚),我们可以在 <em>UIPrintPageRenderer</em> 子类中对这些方法进行重载。在文中最终的打印文档中会添加页眉、页脚,所以我们会新建一个 <em>UIPrintPageRenderer</em> 子类。</p> <p>与之前的新建过程类似,不过需要注意以下两点:</p> <ol> <li>新建的类继承自 <em>UIPrintPageRenderer</em></li> <li>类名为 <em>CustomPrintPageRenderer</em></li> </ol> <p>新建完成后,我们先来A4纸尺寸来初始化 <em>width</em> 和 <em>height</em> 。请注意我们的目标是将发票导出为PDF文件,那么这个PDF文件也应该能够被打印机完美打印出来,所以定义尺寸是很重要的一件事。</p> <pre> <code class="language-swift">class CustomPrintPageRenderer: UIPrintPageRenderer { let A4PageWidth: CGFloat = 595.2 let A4PageHeight: CGFloat = 841.8 }</code></pre> <p>接下来我们在 <em>init()</em> 中使用这两个属性来指定 <em>CustomPrintPageRenderer</em> 的纸张大小和打印区域大小。</p> <pre> <code class="language-swift">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") }</code></pre> <p>因为 <em>paperRect</em> 和 <em>printableRect</em> 都是只读属性,所以才会使用上面的方法来设置对应的属性值。</p> <p>上面的代码中,纸张大小和打印区域大小是一样大的。也许你希望打印的时候能有一些边距,那么你可以将最后一行代码替换为:</p> <pre> <code class="language-swift">setValue(NSValue(CGRect: CGRectInset(pageFrame, 10.0, 10.0)), forKey: "printableRect")</code></pre> <p>上面的代码在水平和垂直方向都设置了十个点的边距。上面的设置即使不是使用 <em>UIPrintPageRenderer</em> 子类也应该要配置。换句话说,只要使用 <em>UIPrintPageRenderer</em> 对象都都不能忘了设置打印配置。</p> <h2>打印为PDF</h2> <p>打印为PDF意味着需要将一些内容绘制为PDF文档,并将文档发送给打印机或者保存为文档。因为本文的关注点是导出文档,所有我们会保存绘制后的 <em>NSData</em> 对象,最后将该返回结果保存为PDF文件。下面我们一步步来实现:</p> <p>首先在 <em>InvoiceComposer.swift</em> 文件中,实现一个名为 <em>exportHTMLContentToPDF(...)</em> 新函数,该函数将需要打印的内容 <em>HTMLContent</em> 作为唯一参数。但是在我们对该函数进行编码之前,我们有必要了解与打印相关的另一个概念:打印格式 <em>UIPrintFormatter</em> 。下面是官方文档中该类的描述:</p> <p><em>UIPrintFormatter</em> 是打印格式的抽象基类。该类能够对打印内容进行布局,打印系统会自动将与打印格式绑定的内容打印出来。</p> <p>这意味着:只需要简单的将打印的内容与打印格式绑定并传递给打印渲染器,iOS打印系统会完成后面的任务。建议你去该 <a href="/misc/goto?guid=4959677148643450896" rel="nofollow,noindex">网页</a> 了解详情。简单来说,我们可以把打印格式理解为需要打印渲染器打印的内容。另外,虽然 <em>UIPrintFormatter</em> 是抽象类,iOS SDK还是提供了几个具体的子类。这里我们需要使用的就是打印标记语言内容的 <em>UIMarkupTextPrintFormatter</em> ,这些具体的打印格式类也可以在上面的链接中找到。</p> <p>下面就是具体的实现代码:</p> <pre> <code class="language-swift">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) }</code></pre> <p>注释如下:</p> <ol> <li>首先创建 <em>CustomPrintPageRenderer</em> 类型实例。</li> <li>接下来使用打印内容创建 <em>UIMarkupTextPrintFormatter</em> 类型实例。</li> <li>将 <em>printFormatter</em> 作为参数传给了 <em>printPageRenderer</em> 的 <em>addPrintFormatter</em> 函数。该函数的第二个参数表示当前打印内容的起始页,这里默认为0。</li> <li>使用紧接着会实现的自定义函数 <em>drawPDFUsingPrintPageRenderer</em> 得到待打印的 <em>NSData</em> 对象。</li> <li>保存上一步的到的数据为PDF文件。</li> <li>最后我们打印出该文件的路径。</li> </ol> <p>在真实的复杂应用中,我们可能会需要为每一个起始页的打印内容自定义对应的打印格式,但是对于本文的Demo来说上面的代码够用了。</p> <p>下面我们来实现是第四步中的自定义函数。在函数中我们使用了 <em>Core Graphics</em> 来实现PDF文件内容的绘制。整个函数的代码简短清晰:</p> <pre> <code class="language-swift">func drawPDFUsingPrintPageRenderer(printPageRenderer: UIPrintPageRenderer) -> NSData! { let data = NSMutableData() UIGraphicsBeginPDFContextToData(data, CGRectZero, nil) UIGraphicsBeginPDFPage() printPageRenderer.drawPageAtIndex(0, inRect: UIGraphicsGetPDFContextBounds()) UIGraphicsEndPDFContext() return data }</code></pre> <p>首先创建了一个 <em>NSMutableData</em> 对象用于写入后面的输出,这也是开始创建文档前的前奏。然后就是创建新文档了,不过真正绘制部分的是下面的代码:</p> <pre> <code class="language-swift">printPageRenderer.drawPageAtIndex(0, inRect: UIGraphicsGetPDFContextBounds())</code></pre> <p>该段代码完成了PDF文件上下文的绘制,并且自定义的页眉和页脚也会完成绘制。因为 <em>drawPageAtIndex</em> 函数会调用渲染器中的其他部分绘制方法。</p> <p>最后我们关闭PDF文件的Graphics上下文,并将绘制的结果数据对象返回。</p> <p>上面的代码只完成了单页文件的绘制,如果你要绘制多页文档的话可以将开始绘制、和真正绘制部分的代码放在一个循环结构里面。</p> <p>到目前为止,与PDF文档绘制的任务都已经完成了。但是在后面还会实现自定义页眉和页脚的绘制。当然我们还需要在 <em>PreviewViewController.swift</em> 文件的 <em>exportToPDF</em> 中调用上面实现的功能函数:</p> <pre> <code class="language-swift">@IBAction func exportToPDF(sender: AnyObject) { invoiceComposer.exportHTMLContentToPDF(HTMLContent) }</code></pre> <p>现在我们可以来测试效果了,为了方便查看我建议使用模拟器。我们进入发票的预览界面后,点击右上角的导出PDF按键:</p> <p><img src="https://simg.open-open.com/show/f57856d270b2c2dcd065794f45d2544d.png"></p> <p>等创建文档任务完成后,我们可以在控制台看见该文件的路径。我们打开Finder窗口并使用 <em>Shift-Command-G</em> 定位到文件的父目录中你就可以你创建的PDF文件了:</p> <p><img src="https://simg.open-open.com/show/1c0e24c37563f47f6e58f9198706f928.png"></p> <p>双击新建的文件,你可以看见:</p> <p><img src="https://simg.open-open.com/show/f398b6da931511e1f3accf2bc31c9743.png"></p> <h2>绘制自定义页眉、页脚</h2> <p>现在让我们来对打印结果做一些拓展,添加页眉和页脚。这也是为什么在前面我会自定义一个 <strong>UIPrintPageRenderer</strong> 类。我们所说的打印内容,除了使用HTML模版生成部分还包括页眉和页脚。我们会在右上角添加"Invoice"作为页眉、下方添加“Thank you!”作为页脚。最终效果如下图:</p> <p><img src="https://simg.open-open.com/show/06bc620872c0a8035ba657b477fe5b01.png"></p> <p>在了解实现细节之前,我们需要在 <em>CustomPrintPageRenderer</em> 类的 <em>init()</em> 函数中初始化页眉、页脚的高度:</p> <pre> <code class="language-swift">override init() { ... self.headerHeight = 50.0 self.footerHeight = 50.0 }</code></pre> <p>接下来我们重载 <em>UIPrintPageRenderer</em> 类中绘制页眉的函数:</p> <pre> <code class="language-swift">override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) { }</code></pre> <p>在函数体内我们实现的步骤如下:</p> <ol> <li>初始化我们需要在页眉中绘制的"Invoice"。</li> <li>初始化与text格式相关的属性值,例如字体、颜色、字间距。</li> <li>计算页眉显示内容的显示区域大小,并设置与右边距。</li> <li>计算绘制页眉的起始位置。</li> <li>绘制页眉内容。</li> </ol> <p>下面就是对应的代码,每一行都带有注释:</p> <pre> <code class="language-swift">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) }</code></pre> <p>上面的代码中惟一需要注意的就是函数 <em>getTextSize(...)</em> 。在该函数会计算显示内容的大小,因为后面打印页脚的时候也需要使用所以就抽离出来了。代码如下:</p> <pre> <code class="language-swift">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 }</code></pre> <p>上面代码是计算text文本size大小的通用方法。先创建一个UILabel对象,设置简单文本的字体或者attributedText属性之后使用 <em>sizeToFit()</em> 方法让系统来计算真实的size。</p> <p>页脚部分的处理和上面类似,并没有什么太多需要额外讲的。惟一需要注意的是页脚的位置是水平居中、字体颜色也与页眉存在差异,还有就是字母之间没有间距。</p> <pre> <code class="language-swift">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) }</code></pre> <p>页脚已经正确显示了,下面我们补上页脚上面的水平线:</p> <pre> <code class="language-swift">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) }</code></pre> <p>在结束这一部分内容之前,关于页眉、页脚的处理有一个小细节需要跟大家说一下。如果你足够细心的话,你会发现函数中使用了 <em>NSString</em> 而不是 <em>String</em> 来处理页眉、页脚。之所以这么做是因为:处理文本绘制的函数 <em>drawAtPoint(...)</em> 属于 <em>NSString</em> 类,如果你使用 <em>String</em> 的话则需要进行类型转换:</p> <pre> <code class="language-swift">(text as! NSString).drawAtPoint(...)</code></pre> <p>再次运行程序你就可以看见带页眉、页脚的PDF了。</p> <h2>附赠部分:预览并Email发送PDF文档</h2> <p>文中到了这里其实主要的内容已经讲解完了。然而,在设备中运行Demo的时候我们没有什么方法直接查看导出的PDF文档(除了每次创建新文档的时候通过XCode去找文档路径)。所以最后这部分提供两种可选的方法:使用 <em>PreviewViewController</em> 中的webview视图预览PDF文档;使用Email将PDF文档发送出去。我们会弹出一个提示窗口让用户自己选择最终的处理。该部分代码已经超出了文章的内容,所以不会有太多的细节。实现代码如下( <em>PreviewViewController.swift</em> 文件中):</p> <pre> <code class="language-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) }</code></pre> <p>下面来实现不同选项对应的动作。针对预览操作,我们使用 <em>NSURLRequest</em> 对象来实现webview中对内容的加载和显示:</p> <pre> <code class="language-swift">let actionPreview = UIAlertAction(title: "Preview it", style: UIAlertActionStyle.Default) { (action) in let request = NSURLRequest(URL: NSURL(string: self.invoiceComposer.pdfFilename)!) self.webPreview.loadRequest(request) }</code></pre> <p>对于Email发送的功能,我们会创建一个新的函数并将PDF文件作为Eamil的附件:</p> <pre> <code class="language-swift">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) } }</code></pre> <p>为了正常使用 <em>MFMailComposeViewController</em> ,我们需要在文件中加上:</p> <pre> <code class="language-swift">import MessageUI</code></pre> <p>回到函数 <em>showOptionsAlert()</em> 中,补全 <em>actionPreview</em> 动作中的代码:</p> <pre> <code class="language-swift">let actionEmail = UIAlertAction(title: "Send by Email", style: UIAlertActionStyle.Default) { (action) in dispatch_async(dispatch_get_main_queue(), { self.sendEmail() }) }</code></pre> <p>函数代码都已经写好了,剩下的就是在合适的地方调用了。调用的时机很明显就是当我们点击右上角按键创建PDF文档的时候,所以代码如下:</p> <pre> <code class="language-swift">@IBAction func exportToPDF(sender: AnyObject) { ... showOptionsAlert() }</code></pre> <p>一切就绪,现在你可以预览文档并通过Email发送了:</p> <h2>总结</h2> <p>对于创建PDF而言,无论现在的其他方案或者以后的新技巧,本文所提及的解决方案总会是标准、灵活和安全的之一。该方案惟一的缺点就是:我们需要编写那些HTML模版文件。不过对于我来说,这工作实在是物超所值。与花大量工作去手动绘制PDF相比,我坚信替换模版文件中的“占位符”的做法更加可取。除此之外,真实情况中的PDF文档绘制都是非常标准的,只需要对Demo中的代码进行部分调整就能实现复用了。不管怎样,我都希望本文中的方法能够真正的帮到你。</p> <p>本文的完整Demo代码 <a href="/misc/goto?guid=4959677148733415374" rel="nofollow,noindex">地址</a> ,仅供读者参考。</p> <p> </p> <p>来自:http://www.jianshu.com/p/8b3197f90c64</p> <p> </p> <p><span style="background:rgb(189, 8, 28) url("data:image/svg+xml; border-radius:2px; border:medium none; color:rgb(255, 255, 255); cursor:pointer; display:none; font:bold 11px/20px "Helvetica Neue",Helvetica,sans-serif; opacity:1; padding:0px 4px 0px 0px; position:absolute; text-align:center; text-indent:20px; width:auto; z-index:8675309">Save</span></p>