作者:GABRIEL THEODOROPOULOS,原文鏈接,原文日期:2016-7-10
譯者:X140Yu;校對(duì):saitjr;定稿:CMB
你是否曾經(jīng)遇到過(guò)「使用 app 中的內(nèi)容生成 PDF 文件」這樣的需求?如果你之前沒(méi)有做過(guò),那你有想過(guò)該如何實(shí)現(xiàn)嗎?
好吧,通過(guò)拋出問(wèn)題來(lái)開(kāi)篇有點(diǎn)不太好,但上述內(nèi)容總結(jié)了我將要在這篇文章中討論的事情。要在 iOS 應(yīng)用內(nèi)創(chuàng)建一個(gè) PDF 文檔,看起來(lái)像不是一個(gè)容易的需求,但事實(shí)并不是這樣。作為開(kāi)發(fā)人員,你必須要隨機(jī)應(yīng)變,為自己創(chuàng)造可供選擇的方案,盡量達(dá)到目標(biāo)。這是件很有挑戰(zhàn)性的事情。確實(shí),手動(dòng)繪制 PDF 是一個(gè)非常痛苦的過(guò)程(取決于內(nèi)容),最終可能會(huì)變得非常低效。計(jì)算坐標(biāo)、加線、設(shè)置顏色、縮進(jìn)、偏移等。這可能很有趣(或并不是),但如果你要繪制的內(nèi)容非常復(fù)雜,那到最后可能會(huì)變得一團(tuán)糟。
本文會(huì)介紹另一種創(chuàng)建 PDF 文件的方式,這種方式比手動(dòng)繪制要簡(jiǎn)單得多。它的思想是使用 HTML 模版,?大致有以下幾個(gè)步驟:
- 為要生成 PDF 的表單創(chuàng)建 HTML 模版。
- 使用這些 HTML 模版來(lái)渲染真正的內(nèi)容(或者把它顯示在 web view 中)。
- 把 HTML 的內(nèi)容轉(zhuǎn)換為 PDF。
在最后一步,iOS 會(huì)替你做所有麻煩的事情。
說(shuō)白了,我認(rèn)為你也更加愿意處理 HTML,而不是直接繪制 PDF 文件吧。這種情況下,你需要的是將內(nèi)容展現(xiàn)在 HTML 文件中,但手動(dòng)去創(chuàng)建重復(fù)的內(nèi)容,確實(shí)不太明智,也不太高效。舉個(gè)例子,有一個(gè) app 能把學(xué)生的信息打印或輸出為 PDF。為每個(gè)學(xué)生創(chuàng)建一個(gè) HTML 頁(yè)面顯然不可取,因?yàn)闉榱舜蛴∵@些信息,一直在做重復(fù)的事情。你真正需要的是創(chuàng)建一個(gè) HTML 模版。使用一種特殊的方式在關(guān)鍵位置用占位符占位,而不是直接使用實(shí)際的值,接著在 app 中,把這些占位符換成實(shí)際值。當(dāng)然,最后一步的值替換是可以是重復(fù)且自動(dòng)化的。
當(dāng) HTML 代碼中包含實(shí)際值之后,就可以為所欲為了。你可以在 web view 中顯示,保存為文件,分享,當(dāng)然也可以輸出為 PDF。
那么我們到底應(yīng)該怎么做呢?
將內(nèi)容輸出為 PDF 是本文的最終目標(biāo),但我們是從如何用實(shí)際值替換占位符這一步開(kāi)始的。Demo 的 app 是一個(gè)生成發(fā)票的小應(yīng)用,非常符合本文的需求。再次說(shuō)明我們不會(huì)從頭來(lái)做這個(gè)應(yīng)用,這不是最終目的。應(yīng)用的默認(rèn)功能已經(jīng)實(shí)現(xiàn)好了,也提供了 HTML 模版,我們也會(huì)一步一步說(shuō)明,所以你也有機(jī)會(huì)明白這到底是怎么一回事,占位符到底有什么意義。不管怎么樣,我們會(huì)一起,一步一步地走通生成真正 HTML 內(nèi)容的流程,然后將它輸出為 PDF 文檔。這還沒(méi)完,我還會(huì)告訴你們?nèi)绾谓o最終的 PDF 加上 header 和 footer。
如果你對(duì)以上的內(nèi)容感興趣,那就一起開(kāi)始做吧!
上手項(xiàng)目
我們先快速瀏覽一下這個(gè)教程的 demo app,其實(shí)就是一個(gè)制作發(fā)票的工具。在開(kāi)始之前,你應(yīng)該先下載這個(gè)上手項(xiàng)目,然后在 Xcode 中打開(kāi)。
在上手項(xiàng)目中,你會(huì)發(fā)現(xiàn)已經(jīng)有好多工作已經(jīng)做完了。InvoiceListViewController 這個(gè) view controller 是用來(lái)顯示創(chuàng)建和保存的發(fā)票信息列表。在這個(gè) VC 里,你也可以通過(guò)點(diǎn)擊右上角的加號(hào)來(lái)新建發(fā)票信息。點(diǎn)擊列表中的任何一列都可以去到對(duì)應(yīng)的預(yù)覽界面,在那個(gè)界面可以看到發(fā)票的詳細(xì)信息。注意,還有一部分的功能在上手項(xiàng)目中沒(méi)有實(shí)現(xiàn),我們會(huì)在這篇教程中實(shí)現(xiàn)它。新建的發(fā)票信息可以通過(guò)向左滑動(dòng)對(duì)應(yīng) cell 來(lái)刪除。下面的截圖就是這個(gè) VC 的界面。

就像我說(shuō)過(guò)的一樣,可以通過(guò)右上角的加號(hào)按鈕來(lái)新建發(fā)票。這個(gè)動(dòng)作會(huì)帶我們?nèi)サ揭粋€(gè)新的 VC —— CreatorViewController,它長(zhǎng)這個(gè)樣子:

在發(fā)票可以被打印出來(lái)之前,需要填一些必要的信息。其中的一部分可以在上個(gè) VC 中設(shè)置,還有一些可以被自動(dòng)計(jì)算出來(lái),另一些會(huì)硬編碼在代碼中。為了詳細(xì)一些,應(yīng)用中可以被手動(dòng)添加的值有:
- 接收者的信息,其實(shí)就是接收人的地址。上圖中的灰色區(qū)域。
- 需要打發(fā)票的條目,每個(gè)條目都由兩部分組成:提供服務(wù)的描述,這項(xiàng)服務(wù)的價(jià)格。為了簡(jiǎn)單起見(jiàn),這里沒(méi)有增值稅??梢酝ㄟ^(guò)底部 toolbar 的加號(hào)來(lái)添加新的條目。
自動(dòng)生成的值有:
- 發(fā)票的號(hào)碼(顯示在 navigation bar 中的號(hào)碼)
- 這張發(fā)票的總價(jià)格(顯示在底部 toolbar 的左邊)
之后我們要硬編碼的值有:
- 發(fā)送者的信息,也就是發(fā)行人的信息。
- 發(fā)票的截止日期(如果你想用也可以用,但在這里用不到,所以設(shè)為空)。
- 付款的方式。
- 發(fā)票的圖標(biāo)。
項(xiàng)目中已有一個(gè) AddItemViewController 作為創(chuàng)建發(fā)票條目的入口。這個(gè)界面很簡(jiǎn)單,只有兩個(gè) textfield,還有一個(gè)保存按鈕,點(diǎn)擊完成后會(huì)跳到之前的 VC 中。

所有的發(fā)票條目都在一個(gè)有字典元素的數(shù)組中,每個(gè)字典有兩個(gè)值分別為描述和價(jià)格。這個(gè)數(shù)組作為 CreatorViewController 中 tableview 的數(shù)據(jù)源。當(dāng)一個(gè)條目被創(chuàng)建出來(lái)的時(shí)候,手動(dòng)和自動(dòng)添加的數(shù)據(jù)都會(huì)被加入到字典中,返回給 InvoiceListViewController。下面是它返回的數(shù)據(jù):
- 發(fā)票號(hào)碼(string)。
- 接收人的信息(string)。
- 全部金額(string)。
- 發(fā)票的條目(裝字典的數(shù)組)。
在保存發(fā)票的發(fā)票號(hào)碼的時(shí)候,下一個(gè)號(hào)碼已經(jīng)計(jì)算出來(lái)并且存儲(chǔ)在 NSUserDefaults 中了。裝著發(fā)票數(shù)據(jù)的字典被加在 InvoiceListViewController 的數(shù)組中,而數(shù)組的每一次有新值的時(shí)候,存儲(chǔ)到 user defaults 中。當(dāng) VC 將要出現(xiàn)時(shí),發(fā)票數(shù)據(jù)從 user defaults 被加載。記住,demo 是因?yàn)檠菔痉奖?,才把?yīng)用主要數(shù)據(jù)保存到 user defaults 的,對(duì)于真正的應(yīng)用程序,不建議這么做??隙ㄟ€有更好的方法來(lái)存儲(chǔ)你的數(shù)據(jù)。
對(duì)于現(xiàn)有的代碼,我沒(méi)有什么好說(shuō)的。你所要做的就是到每個(gè) VC 中或按照應(yīng)用程序的流程看代碼的細(xì)節(jié)實(shí)現(xiàn)。還有一點(diǎn)我想提一下,那就是 AppDelegate.swift 文件。在這個(gè)文件中有三個(gè)便捷的方法:一個(gè)用于獲取 appdelegate,一個(gè)用于獲取沙盒的 documents 路徑,一個(gè)用于將表示為字符串的金額轉(zhuǎn)換成貨幣字符串(連同適當(dāng)?shù)呢泿欧?hào))。除了上手項(xiàng)目,這些方法我們之后也還會(huì)用到。在 AppDelegate 中,還有個(gè) currencyCode 屬性被默認(rèn)設(shè)置為了「eur」(歐元)??梢酝ㄟ^(guò)改變它來(lái)設(shè)置你自己的貨幣單位。
最后,讓我告訴你,上手項(xiàng)目在哪結(jié)束,還有我們將從哪里開(kāi)始。點(diǎn)擊 InvoiceListViewController tableview 的發(fā)票數(shù)據(jù),一個(gè)包含匹配發(fā)票數(shù)據(jù)的字典會(huì)被傳遞到 PreviewViewController 中。在這其中,有一個(gè)已渲染完成,可供預(yù)覽的 HTML 文件,和一個(gè)導(dǎo)出到 PDF 的按鈕。這些功能都不在上手項(xiàng)目中,我們將要實(shí)現(xiàn)它們,我們需要的所有數(shù)據(jù)都已經(jīng)存在于 PreviewViewController 中,所以可以直接使用它。
HTML 模版文件
正如我在引言中闡述的,我們將使用 HTML 模板來(lái)產(chǎn)生相應(yīng)發(fā)票內(nèi)容的 HTML,然后將真正的 HTML 內(nèi)容渲染成一個(gè) PDF 文件。這里的基本邏輯是把占位符放在 HTML 文件中占位,然后用真實(shí)的數(shù)據(jù)替換這些占位符。這樣的話,我們就必須找到或創(chuàng)建自定義 HTML 表單。對(duì)于這篇教程來(lái)說(shuō),我們不會(huì)創(chuàng)建任何自定義的 HTML 模板。相反,我們將使用一個(gè)在這里找到的模版(特別感謝作者)。該模板已被修改了一點(diǎn),所以它沒(méi)有陰影的邊框,而且在 logo 處添加的灰色的背景顏色。
在你下載的上手項(xiàng)目里,有三個(gè) HTML 文件:
- invoice.html
- last_item.html
- single_item.html
第一個(gè)包含了將產(chǎn)生整個(gè)發(fā)票樣式的代碼,除了項(xiàng)目的單元行。我們有專門的兩個(gè)模板來(lái)應(yīng)對(duì)行:single_item.html 將用來(lái)顯示一個(gè)項(xiàng)目除了最后一行的任意一行,last_item.html 將被用來(lái)顯示最后一行。這是因?yàn)樽詈笠恍械牡撞窟吙蚓€是不同的。
所有的占位符將會(huì)被 # 號(hào)給包起來(lái)。舉個(gè)例子,下面的這個(gè)就顯示了發(fā)票號(hào)碼,發(fā)行日期和截止日期的占位符:
html
<td> Invoice #: #INVOICE_NUMBER<br>
#INVOICE_DATE#<br>
#DUE_DATE# </td>
備注:即使截止日期是以占位符的形式存在的,但是我們不會(huì)真正使用它,只會(huì)用空的字符來(lái)替換它。但如果你需要使用的話,可以任意使用。
你可以在三個(gè) HTML 文件中找到所有的占位符,和它們適合的位置:
- #LOGO_IMAGE#
- #INVOICE_NUMBER#
- #INVOICE_DATE#
- #DUE_DATE#
- #SENDER_INFO#
- #RECIPIENT_INFO#
- #PAYMENT_METHOD#
- #ITEMS#
- #TOTAL_AMOUNT#
- #ITEM_DESC#
- #PRICE#
最后兩個(gè)占位符只存在于 single_item.html 和 last_item.html 文件中。同時(shí),#ITEMS# 占位符會(huì)被替換為用那兩個(gè) HTML 模版文件創(chuàng)建完成的發(fā)票條目(細(xì)節(jié)會(huì)在后文描述)。
正如你所看到的,準(zhǔn)備一個(gè)或多個(gè) HTML 模板來(lái)創(chuàng)建一個(gè)表單自定義輸出(在發(fā)票這個(gè)案例中)不是什么難事。而經(jīng)歷了這整個(gè)過(guò)程后,你會(huì)意識(shí)到,基于這些模板內(nèi)容,來(lái)生成并輸出到 PDF 文件并不難。
搭建內(nèi)容
已經(jīng)了解了 demo app 和發(fā)票模版,我們現(xiàn)在應(yīng)該開(kāi)始實(shí)現(xiàn)應(yīng)用沒(méi)有實(shí)現(xiàn)的關(guān)鍵部分。首先,根據(jù) InvoiceListViewController 選中的發(fā)票信息,使用模板,創(chuàng)建含有實(shí)際發(fā)票內(nèi)容的 HTML。然后,在 PreviewViewController 中使用 web view 顯示生成的 HTML 代碼,我們可以通過(guò)這種方式驗(yàn)證正確性。
這部分最重要的一項(xiàng)任務(wù)是把 HTML 模板文件的占位符替換為真正的內(nèi)容。那些值實(shí)際上是從 InvoiceListViewController 傳到 PreviewViewController 中的。正如你將看到的,更換占位符是一項(xiàng)簡(jiǎn)單的工作。在我們開(kāi)始之前,讓我們創(chuàng)建一個(gè)新的類用于生成真正的 HTML 內(nèi)容,然后它就可以生成 PDF。在 Xcode 中,選擇 File > New > File… 菜單,創(chuàng)建一個(gè)新的 Cocoa Touch 類。讓它繼承自 NSObject。并將其命名為 InvoiceComposer。一路跟隨向?qū)瓿尚挛募膭?chuàng)建。

打開(kāi) 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!
}
前三個(gè)屬性(pathToInvoiceHTMLTemplate, pathToSingleItemHTMLTemplate, pathToLastItemHTMLTemplate),我們指定了三個(gè) HTML 模版的文件路徑,方便之后的使用。因?yàn)槲覀儠?huì)打開(kāi)它們,獲取模版并修改代碼。
之前提到過(guò),我們的 demo 不提供選項(xiàng)來(lái)設(shè)置所有的參數(shù)(senderInfo, dueDate, paymentMethod,logoImageURL),所以這些在這里直接被硬編碼了。在真正的應(yīng)用程序中,這些值,都應(yīng)該能讓用戶自定義。最后一個(gè)是作為發(fā)票的標(biāo)志的圖像的地址。你可以改變上面的屬性,設(shè)置成自己喜歡的值(例如,把 senderInfo 改成你自己的信息)。
最后,invoiceNumber 屬性存儲(chǔ)的是隨時(shí)都能展示的發(fā)票號(hào)碼,pdfFilename 將包含展示 PDF 的路徑。這是我們需要的東西,雖然現(xiàn)在還不必要,但是我們最好先把它們聲明出來(lái)。以后要用的時(shí)候就方便了。
除了以上這些屬性,給這個(gè)類加上默認(rèn)的 init() 方法。
class InvoiceComposer: NSObject{
...
override init() {
super.init()
}
}
我們現(xiàn)在創(chuàng)建一個(gè)新的方法,處理在 HTML 模板文件替換占位符的重要工作。我們將它命名為 renderInvoice,方法如下:
func renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String:String]], totalAmount: String) -> String! {
}
參數(shù)實(shí)際上是新建發(fā)票信息手動(dòng)輸入的值,它們都是生成 PDF 需要的(還有硬編碼的值)。這個(gè)方法的返回值是包含最終的 HTML 內(nèi)容的字符串。
讓我們實(shí)現(xiàn)該方法,先執(zhí)行第一個(gè)重要的任務(wù)。在下面的代碼片段中,主要處理了兩件事:首先 invoice.html 模板內(nèi)容被加載到一個(gè)字符串變量中,方便我們修改。然后將除了發(fā)票條目外,所有的占位符都替換成真實(shí)的值。下面這些注釋能夠幫助你理解這個(gè)過(guò)程:
func renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String:String]], totalAmount: String) -> String! {
// 為了將來(lái)的使用,把發(fā)票號(hào)碼先存起來(lái)
self.invoiceNumber = invoiceNumber
do {
// 把 發(fā)票模版的 HTML 文件內(nèi)容載入到一個(gè)字符串變量中
var HTMLContent = try String(contentsOfFile: pathToInvoiceHTMLTemplate!)
// 除了發(fā)票條目的所有占位符都替換成真實(shí)的值
// 圖標(biāo)。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#LOGO_IMAGE#", withString:logoImageURL)
// 發(fā)票號(hào)碼。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#INVOICE_NUMBER#", withString:invoiceNumber)
// 開(kāi)票時(shí)間。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#INVOICE_DATE#", withString:invoiceDate)
// 截止日期(默認(rèn)為空)。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#DUE_DATE#", withString:dueDate)
// 發(fā)行人信息。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#SENDER_INFO#", withString:senderInfo)
// 接收人信息。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#RECIPIENT_INFO#", withString:recipientInfo.stringByReplacingOccurrencesOfString("\n", withString:""))
// 支付方法。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#PAYMENT_METHOD#", withString:paymentMethod)
// 總計(jì)金額。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#TOTAL_AMOUNT#", withString:totalAmount)
}
catch {
print("Unable to open and use HTML template files.")
}
return nil
}
上面替換占位符的實(shí)現(xiàn)看起來(lái)很簡(jiǎn)單,實(shí)際上...就這么簡(jiǎn)單。利用 stringbyreplacingoccurrencesofstring(...) 方法,把第一個(gè)參數(shù)(占位符)替換為第二個(gè)參數(shù)(真實(shí)的值)。完全沒(méi)難度...好無(wú)聊。
再進(jìn)一步,注意到所有的代碼都包含在 do-catch 語(yǔ)句中了嗎?因?yàn)榘岩粋€(gè)文件的內(nèi)容加載為 HTMLContent 字符串可能會(huì)拋出異常。同時(shí),如果有異常拋出,它就會(huì)返回 nil,而現(xiàn)在沒(méi)有真正的返回值與實(shí)際的 HTML 內(nèi)容,這是下面要講到的內(nèi)容。
讓我們現(xiàn)在把重點(diǎn)放在設(shè)置發(fā)票條目上。由于他們的號(hào)碼可能會(huì)有所不同,所以使用循環(huán)來(lái)處理。除了最后一個(gè),其余條目,我們都將打開(kāi) single_item.html 模板,替換占位符。因?yàn)樽詈笠粭l的底部線是不同的,所以使用last_item.html 模板操作。產(chǎn)生的 HTML 代碼將被加到另一個(gè)字符串中(allItems 變量),該字符串包含所有的條目信息,它將在 HTMLContent 字符串中,替換 #ITEMS# 占位符。函數(shù)的返回值是該字符串。
在 do 中加入以下代碼段:
func renderInvoice(invoiceNumber: String, invoiceDate: String,recipientInfo: String, items: [[String:String]], totalAmount: String) -> String! {
...
do{
...
// 通過(guò)循環(huán)來(lái)添加發(fā)票條目。
var allItems = ""
// 除了最后一個(gè),都使用 "single_item.html" 模版。
// 對(duì)于最后一個(gè),使用 "last_item.html" 模版。
for i in 0..<items.count {
var itemHTMLContent:String!
// 判斷該使用哪個(gè)模版文件
if i != items.count - 1 {
itemHTMLContent = try String(contentsOfFile: pathToSingleItemHTMLTemplate!)
}
else{
itemHTMLContent = try String(contentsOfFile: pathToLastItemHTMLTemplate!)
}
// 把描述和價(jià)格替換為真正的值。
itemHTMLContent = itemHTMLContent.stringByReplacingOccurrencesOfString("#ITEM_DESC#", withString: items[i]["item"]!)
// 把每個(gè)價(jià)格格式化為貨幣值。
let formattedPrice = AppDelegate.getAppDelegate().getStringValueFormattedAsCurrency(items[i]["price"]!)
itemHTMLContent = itemHTMLContent.stringByReplacingOccurrencesOfString("#PRICE#", withString: formattedPrice)
// 把當(dāng)前條目的內(nèi)容加到整體的條目字符串中
allItems += itemHTMLContent
}
// 替換條目。
HTMLContent = HTMLContent.stringByReplacingOccurrencesOfString("#ITEMS#", withString:allItems)
// HTML 代碼已經(jīng) ready。
return HTMLContent
}
catch {
print("Unable to open and use HTML template files.")
}
return nil
}
備注:你可以在 AppDelegate.swift 文件中找到
getAppDelegate()和getStringValueFormattedAsCurrency()方法的實(shí)現(xiàn)。
目前就是這些內(nèi)容。模板代碼已被修改成我們真正需要的發(fā)票的內(nèi)容。下一步,我們將利用上述方法的返回結(jié)果。
預(yù)覽 HTML 內(nèi)容
在創(chuàng)建了真正的 HTML 內(nèi)容后,是時(shí)候驗(yàn)證結(jié)果了。我們?cè)谶@一部分的目標(biāo)是加載剛剛構(gòu)建的 HTML 字符串,把它加載到 PreviewViewController 中已有的 web view 中,然后就可以看到我們之前努力的結(jié)果了。請(qǐng)注意,這是一個(gè)可選的步驟,在實(shí)際應(yīng)用中不必在輸出 PDF 之前使用 web view 。我們?cè)谶@里做的,為了 demo 的完整性。
切換到 PreviewViewController.swift 文件,去到類的頂部,先聲明幾個(gè)屬性:
class PreviewViewController: UIViewController {
...
var invoiceComposer: InvoiceComposer!
var HTMLContent: String!
}
第一個(gè)是我們?cè)谥靶陆ǖ纳?HTML 內(nèi)容類的對(duì)象。HTMLContent 字符串是用來(lái)存放將來(lái)要用到的真正的 HTML 內(nèi)容。
接下來(lái),新建一個(gè)方法,做下面幾件事情:
- 初始化
invoiceComposer對(duì)象。 - 調(diào)用
renderInvoice(...)方法,產(chǎn)生發(fā)票內(nèi)容的 HTML 代碼。 - 把 HTML 加載到 web view 中。
- 把返回的 HTML 字符串存入
HTMLContent屬性中。
下面來(lái)看看這個(gè)方法:
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
}
}
上面的代碼沒(méi)什么特別的,關(guān)注一下傳入 renderInvoice(...) 方法的參數(shù)就可以了。一旦我們從那個(gè)方法中獲得了真正的 HTML 字符串(而不是 nil),就把它加載進(jìn) web view 中。
是時(shí)候調(diào)用我們的新方法了:
override func viewWillAppear(animated:Bool) {
super.viewWillAppear(animated)
createInvoiceAsHTML()
}
如果你想看到的結(jié)果,運(yùn)行應(yīng)用程序,并創(chuàng)建一個(gè)新的發(fā)票信息(如果你還沒(méi)這樣做過(guò))。然后從列表中選中它,就能看到類似下圖的效果:

準(zhǔn)備輸出
任務(wù)已經(jīng)完成一半了,我們現(xiàn)在可以進(jìn)行把發(fā)票信息輸出為 PDF 的工作了。接下來(lái)會(huì)使用一個(gè)特殊的類,UIPrintPageRenderer。如果你之前從來(lái)沒(méi)有聽(tīng)說(shuō),也沒(méi)有使用過(guò),那我可以先簡(jiǎn)單地告訴你,這個(gè)類是是把內(nèi)容輸出來(lái)打印用的(輸出為文件或者使用 AirPrint 的打印機(jī))。這里是官方的文檔,可以看到更多信息。
UIPrintPageRenderer 類提供了多種繪制方法,但是對(duì)于我們這種簡(jiǎn)單的情況,其實(shí)不需要重寫(xiě)這些方法。這些繪制方法只能被 UIPrintPageRenderer 的子類重寫(xiě),雖然略為麻煩,但是多做一些工作就可以把輸出內(nèi)容控制地更好,比如在本例中的 header 和 footer,那我們?yōu)槭裁床蝗プ瞿兀?/p>
再次回到 Xcode,按照下面的步驟創(chuàng)建一個(gè)新的類:
- 讓它繼承自
UIPrintPageRenderer。 - 把它命名為
CustomPrintPageRenderer。
一旦你完成了上面的工作(當(dāng)看到 CustomPrintPageRenderer.swift 出現(xiàn)在工程目錄中時(shí)),還需要為后面的工作做一些準(zhǔn)備。先讓我們指定一下 A4 紙 的寬和高(以像素為單位)。記住,我們要把發(fā)票輸出成 PDF,PDF 文件也是能夠打印的,所以限制一下紙的尺寸還是有必要的。
class CustomPrintPageRenderer: UIPrintPageRenderer {
let A4PageWidth: CGFloat = 595.2
let A4PageHeight: CGFloat = 841.8
}
上面的值描述了在全世界通用的 A4 紙的準(zhǔn)確寬高。
在 CustomPrintPageRenderer 類生成的對(duì)象中,指定紙的尺寸是很有必要的。我們將在 init() 方法中使用上面聲明的兩個(gè)屬性。
override init() {
super.init()
// 指定 A4 紙的尺寸
let pageFrame = CGRect(x: 0.0, y: 0.0, width: A4PageWidth, height: A4PageHeight)
// 設(shè)定頁(yè)面的尺寸
self.setValue(NSValue(CGRect:pageFrame), forKey:"paperRect")
// 設(shè)定水平和垂直的縮進(jìn)(這一步是可選的)
self.setValue(NSValue(CGRect:pageFrame), forKey:"printableRect")
}
以上代碼包含了一種直接而標(biāo)準(zhǔn)的,設(shè)置紙張尺寸與打印區(qū)域的技巧。paperRect 和 printableRect 屬性都是只讀的,這也就是為什么我們需要在這里給它們賦值。
在上面的代碼中可以發(fā)現(xiàn),我們把紙張的大小和打印的區(qū)域設(shè)置為一樣大的。但是到后面你會(huì)發(fā)現(xiàn),周圍留出一些邊距,打印出來(lái)的效果會(huì)更好。為了達(dá)到這種效果,可以把上面代碼的最后一行,換成下面的代碼:
self.setValue(NSValue(CGRect:CGRectInset(pageFrame,10.0,10.0)),forKey:"printableRect")
上面的代碼給水平和垂直都加了 10 邊距。即使你沒(méi)有子類化 UIPrintPageRenderer,對(duì)于這部分的設(shè)置也已經(jīng)生效了。換句話來(lái)說(shuō),你永遠(yuǎn)不會(huì)忘記設(shè)置你要打印內(nèi)容的紙張尺寸和打印區(qū)域的大小。
輸出為 PDF
說(shuō)是「輸出為 PDF」,其實(shí)是把內(nèi)容繪制到一個(gè) PDF 圖形的上下文。一旦繪制完成,完成好的內(nèi)容可以發(fā)送到打印機(jī)打印,也可以被保存成一個(gè)文件。我們對(duì)第二種情況比較感興趣,所以我們會(huì)把繪制好的 PDF 上下文轉(zhuǎn)換成 NSData 對(duì)象,然后把這個(gè)對(duì)象保存到文件中(最終的 .pdf 文件)。讓我們來(lái)一步一步進(jìn)行。
先打開(kāi) InvoiceComposer.swift 文件,在這里我們要實(shí)現(xiàn)一個(gè)新的方法 exportHTMLContentToPDF(...)。它只接受一個(gè)參數(shù),我們想要輸出到 PDF 的 HTML 內(nèi)容。在看這個(gè)方法的實(shí)現(xiàn)之前,我們先來(lái)看看另一個(gè)跟打印相關(guān)的概念,也就是 print formatter (UIPrintFormatter class)。下面是 Apple 文檔對(duì)它的介紹:
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 內(nèi)容作為打印的 formatter 添加到打印的 renderer,iOS 打印系統(tǒng)將接管頁(yè)面布局和實(shí)際的打印頁(yè)面。我建議你看一看這里,有詳細(xì)的解釋。簡(jiǎn)單來(lái)說(shuō),就是把 print formatter 想要打印的內(nèi)容傳遞給 iOS 打印系統(tǒng)的一種中介。此外,雖然 UIPrintFormatter 是一個(gè)抽象類,但 iOS 的 SDK 提供了有實(shí)現(xiàn)的子類來(lái)給我們使用。其中之一是 UIMarkupTextPrintFormatter,我們可以用它把 HTML 內(nèi)容轉(zhuǎn)換成 page renderer 對(duì)象。還有一些其它的子類信息可以在上面的鏈接中找到。
光說(shuō)還是有些不清楚,看看代碼吧:
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)
}
來(lái)一起看看上面的幾行代碼做了什么事情:
- 首先初始化了一個(gè)
CustomPrintPageRenderer對(duì)象來(lái)執(zhí)行繪制工作。 - 接著初始化了一個(gè)
UIMarkupTextPrintFormatter對(duì)象,在初始化的時(shí)候,我們把 HTML content 作為參數(shù)傳了進(jìn)去。 - 第三行,把 printFormatter 加到了 printPageRenderer 對(duì)象中。
addPrintFormatter(...)方法的第二個(gè)參數(shù)是指定 printFormatter 起始生效的頁(yè)面。我們?cè)谶@里設(shè)置為 0,因?yàn)榇蛴〉膬?nèi)容只有一頁(yè)。 - 真正的繪制即將發(fā)生。
drawPDFUsingPrintPageRenderer(...)是一個(gè)我們?cè)诤竺娌艜?huì)創(chuàng)建的自定義方法。繪制完成的 PDF 會(huì)被存放在pdfData對(duì)象中,它實(shí)際上是一個(gè)NSData類型的對(duì)象。 - 接下來(lái)就是把 PDF 數(shù)據(jù)存入文件。首先我們聲明了文件路徑,以發(fā)票的號(hào)碼來(lái)指定文件名。然后把 PDF 數(shù)據(jù)寫(xiě)入這個(gè)文件中。
- 最后一步顯然不是必要的,但是我們可以通過(guò)在 Finder 中找到這個(gè)新創(chuàng)建的文件,來(lái)驗(yàn)證我們繪制的結(jié)果。
在一個(gè)更復(fù)雜的應(yīng)用中,你可以使用多個(gè) print formatter 對(duì)象,當(dāng)然也可以對(duì)不同的 print formatter 指定不同的起始頁(yè)面。但是對(duì)于我們來(lái)說(shuō),創(chuàng)建一個(gè)對(duì)象能夠說(shuō)明問(wèn)題就足夠了。
現(xiàn)在我們來(lái)把上面沒(méi)有實(shí)現(xiàn)的,也就是真正繪制的方法給實(shí)現(xiàn)了。在這里我們使用了 Core Graphics,下面的方法也很直白,一起來(lái)看看吧:
func drawPDFUsingPrintPageRenderer(printPageRenderer:UIPrintPageRenderer) -> NSData! {
let data = NSMutableData()
UIGraphicsBeginPDFContextToData(data, CGRectZero, nil)
UIGraphicsBeginPDFPage()
printPageRenderer.drawPageAtIndex(0, inRect: UIGraphicsGetPDFContextBounds())
UIGraphicsEndPDFContext()
return data
}
首先我們初始化了一個(gè) NSMutableData 對(duì)象,是用來(lái)寫(xiě)入 PDF 數(shù)據(jù)的。然后我們創(chuàng)建了 PDF 圖形上下文來(lái)開(kāi)始 PDF 繪制。接下來(lái)才是繪制的代碼:
printPageRenderer.drawPageAtIndex(0,inRect:UIGraphicsGetPDFContextBounds())
作為參數(shù)的 printPageRenderer 對(duì)象在這一行開(kāi)始了繪制工作,它會(huì)把內(nèi)容繪制在 PDF 上下文的區(qū)域中。注意,在這里自定義的 header 和 footer 也會(huì)被自動(dòng)繪制,因?yàn)?drawPageAtIndex(...) 調(diào)用了 printPageRenderer 對(duì)象中所有的繪制方法。
最后我們關(guān)閉了 PDF 圖形上下文,然后返回了 data 對(duì)象。
上面的方法只能打印一個(gè)單頁(yè)面,如果你想要打印多個(gè)頁(yè)面,或者你想要擴(kuò)展這個(gè) demo 應(yīng)用,可以把上面的操作放到一個(gè)循環(huán)中。
到此為止,所有關(guān)于 PDF 輸出的部分就已經(jīng)結(jié)束了,但是我們的工作還沒(méi)有結(jié)束,在下一部分我們會(huì)繪制 header 和 footer。不過(guò)在那之前,我們先把上面的工作串聯(lián)起來(lái)。
打開(kāi) PreviewViewController.swift 文件,定位到 exportToPDF(...) IBAction 方法。把下面幾行加進(jìn)去。點(diǎn)擊按鈕的時(shí)候就可以把發(fā)票導(dǎo)出為 PDF 文件了。
@IBAction func exportToPDF(sender: AnyObject){
invoiceComposer.exportHTMLContentToPDF(HTMLContent)
}
你現(xiàn)在就可以測(cè)試應(yīng)用了,但是為了快速看到結(jié)果,我建議你在模擬器中進(jìn)行下面的操作。在預(yù)覽發(fā)票界面,點(diǎn)擊 PDF 按鈕:

之后,輸出 PDF 這個(gè)過(guò)程就已經(jīng)發(fā)生了,當(dāng)一切都結(jié)束的時(shí)候,你將會(huì)在控制臺(tái)看到 PDF 文件的路徑。把路徑復(fù)制一下(不要帶上文件名),打開(kāi)一個(gè) Finder 窗口,使用 Shift-Command-G 快捷鍵,粘貼上路徑,在打開(kāi)的文件夾中你就可以看到以發(fā)票號(hào)碼為名字的新創(chuàng)建的 PDF 文件。

雙擊打開(kāi)它,用你喜歡的 PDF 程序就好。

繪制自定義的 Header 和 Footer
現(xiàn)在擴(kuò)展一下我們的 demo,往打印頁(yè)面添加自定義的 header 和 footer。畢竟這也是我們最初子類化 UIPrintPageRenderer 的原因。自定義的意思是,不是 HTML 模板中的一部分,不是和其它的 HTML 內(nèi)容一起渲染的內(nèi)容。我們想要實(shí)現(xiàn)的是把 「Invoice」放在頁(yè)面的頂部,作為 header,把「Thank you!」放在頁(yè)面的底部,作為頁(yè)面的 footer,在它上面還有一條水平線。下面的這張圖就是我們要達(dá)到的效果:

在開(kāi)始之前,我們先聲明一下 header 和 footer 的高度。打開(kāi) CustomPrintPageRenderer.swift 文件,添加下面兩行(這兩個(gè)屬性都是繼承自 UIPrintPageRenderer 的)。
override init() {
...
self.headerHeight = 50.0
self.footerHeight = 50.0
}
我們先從 header 做起。先重寫(xiě)一下父類中的下面這個(gè)方法:
override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) {
}
在這個(gè)方法中我們要做的事情步驟如下所示:
- 首先指定我們要繪制的 header 文字(也就是「Invoice」單詞)。
- 指定 header 文字的一些屬性,比如字體、顏色、字間距等。
- 計(jì)算字在加上上述屬性后占據(jù)的空間,然后指定文字到頁(yè)面右側(cè)頁(yè)面的邊距。
- 設(shè)置文字起始繪制的點(diǎn)。
- 繪制文字(終于到這一步了)。
下面就是我上面文字轉(zhuǎn)化為代碼的實(shí)現(xiàn)。每句都有注釋,方便大家理解:
override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) {
// 聲明 header 文字。
let headerText: NSString = "Invoice"
// 設(shè)置字體。
let font = UIFont(name: "AmericanTypewriter-Bold", size: 30.0)
// 設(shè)置字的屬性。
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]
// 計(jì)算字的大小。
let textSize = getTextSize(headerText as String, font: nil, textAttributes: textAttributes)
// 右邊的空距。
let offsetX: CGFloat = 20.0
// 指定字應(yīng)該從哪里開(kāi)始繪制。
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)
}
還有一件事我沒(méi)有在上面的代碼里說(shuō)明的就是 getTextSize(...) 方法。跟你猜的一樣,這又是另一個(gè)自定義方法,用于計(jì)算并返回文字的 frame。計(jì)算發(fā)生在另一個(gè)方法中,因?yàn)樵诶L制 footer 的時(shí)候也會(huì)用到這個(gè)方法。
下面就是 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
}
上面的方法對(duì)于計(jì)算文字占據(jù)的 frame 尺寸是一個(gè)通用的策略。我們把 textAttributes 設(shè)置到這個(gè)臨時(shí)的 label 上。通過(guò)對(duì)其調(diào)用 sizeToFit() 方法,讓系統(tǒng)幫助我們計(jì)算這個(gè) label 的尺寸。
現(xiàn)在我們開(kāi)始繪制 footer。下面的步驟跟上面繪制 header 的步驟十分相似,所以我也就沒(méi)注釋下面的代碼。注意,footer 中的文字是水平居中的,文字顏色也和之前的不一樣,字母之間也沒(méi)有間距:
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)
}
上述代碼創(chuàng)建了「Thank you!」的 footer,但是在它上面還沒(méi)有一條分隔線。因此,我們?cè)侔焉厦娴姆椒ㄑa(bǔ)充一下:
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)
}
現(xiàn)在我們已經(jīng)有了一條水平線!
在這部分結(jié)束之前,關(guān)于 header 和 footer 還有幾句話想說(shuō)。不知你注意到了沒(méi)有,header 和 footer 中的文字都是 NSString 對(duì)象而不是 String 對(duì)象,這是因?yàn)閳?zhí)行真正繪制的 drawAtPoint(...) 方法屬于 NSString 類。如果你使用了 String 對(duì)象,那通過(guò)下面的方式把它轉(zhuǎn)換成 NSString 的對(duì)象:
(text as! NSString).drawAtPoint(...)
運(yùn)行應(yīng)用然后檢查一下結(jié)果,這一次已經(jīng)包含了 header 和 footer。
Bonus Part:預(yù)覽并使用 Email 發(fā)送 PDF 文件
到此為止,我們已經(jīng)完成了這篇教程的主要目的。然而,當(dāng)你在真機(jī)上運(yùn)行時(shí),沒(méi)辦法直接看到導(dǎo)出的 PDF 文件(你可以用 Xcode 查看,但是每次創(chuàng)建 PDF 都這么做就太麻煩了),所以我要給這個(gè) app 增加兩個(gè)額外的功能:在 web view 中預(yù)覽 PDF 的功能(已在 PreviewViewController 中實(shí)現(xiàn)),還有通過(guò) Email 發(fā)送 PDF 文件的功能。我們可以顯示一個(gè)有各種選項(xiàng)的 alert controller 來(lái)讓用戶做出最終選擇。這里不會(huì)講得太細(xì),因?yàn)橄旅娴拇a已經(jīng)超出了這篇教程的范圍。
我們會(huì)把代碼寫(xiě)在 PreviewViewController.swift 文件中,所以在 Project Navigator 找到并打開(kāi)它。加入以下顯示 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)
}
每個(gè)選項(xiàng)的 action 還沒(méi)有被實(shí)現(xiàn),所以我們現(xiàn)在開(kāi)始實(shí)現(xiàn)。對(duì)于預(yù)覽動(dòng)作,我們通過(guò) NSURLRequest 對(duì)象把 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)
}
對(duì)于發(fā)送郵件,可以按照下面的方法來(lái)實(shí)現(xiàn):
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()
})
}
還差一點(diǎn)就完成了,別忘了我們還得調(diào)用 showOptionsAlert() 方法。Alert controller 會(huì)在發(fā)票被輸出為 PDF 文件之后出現(xiàn),回到 exportToPDF(...) IBAction 方法,加上下面的一句話:
@IBAction func exportToPDF(sender: AnyObject) {
...
showOptionsAlert()
}
完成!現(xiàn)在你可以在真機(jī)上運(yùn)行這個(gè)應(yīng)用并且使用導(dǎo)出的 PDF 文件了。

總結(jié)
不管現(xiàn)在還是以后在創(chuàng)建 PDF 文檔方面出現(xiàn)了什么新的技術(shù),本文展現(xiàn)的這個(gè)方法在創(chuàng)建 PDF 文件方面永遠(yuǎn)會(huì)是基本的、高靈活性的,且安全的。它適用于幾乎所有的情形,但只有一個(gè)缺點(diǎn):要用到 HTML 模板來(lái)渲染真正的內(nèi)容 。但我認(rèn)為,創(chuàng)建它的成本真得很低。相比于寫(xiě) HTML、創(chuàng)建 placeholder、替換字符串來(lái)說(shuō),手動(dòng)繪制 PDF 文件真得是太麻煩了。除此之外,真正繪制 PDF 部分的代碼是很基本的,并且通過(guò) demo 應(yīng)用的代碼,你可以獲得很理想的結(jié)果。不管怎樣,我希望你能喜歡本文中介紹的這種方法。感謝閱讀!希望你能開(kāi)心地處理輸出 PDF 文檔的問(wèn)題!
你可以在 Github.com 獲取本文的 Xcode 項(xiàng)目 作為參考。
本文由 SwiftGG 翻譯組翻譯,已經(jīng)獲得作者翻譯授權(quán),最新文章請(qǐng)?jiān)L問(wèn) http://swift.gg。