[iOS] 如何使用 HTML 模版和 iOS 中的 UIPrintPageRenderer 來生成 PDF 文檔
作者:GABRIEL THEODOROPOULOS,時間:2016/7/10
翻譯:BigNerdCoding, 如有錯誤歡迎指出。 原文鏈接
你是否曾經被要求過在app中直接將內容生成為PDF文檔?如果沒有的話,你是否思考過這個需求該如何實現呢?
雖然使用提問的方式作為文章開頭有點不按套路出牌,但是這些問題就是本文要討論的重點。在app中創建PDF文檔,看起來就是一條布滿坑的路,但是事實上可能并沒有那么恐怖。作為開發者,在面對困難的時候我們總是需要一些替換方案,避免一條道走到黑。手動生成PDF頁面確實是一個非常痛苦的過程(取決于文檔的內容)并且最終可能會是事倍功半的結果。計算位置、添加線、配色、插入、偏移等等,可能有趣(也可能沒有)。但是如果文檔內容復雜的話,那么肯定是一件坑爹的事。不太可能有人喜歡干這樣的事。
在本文中我會給你介紹一種新思路來創建PDF文檔,并且比手動繪制要簡單不少。處理方法是基于使用 HTML templates ,并且可以概括為以下幾步:
- 為那些需要打印為PDF的表單或者內容創建 HTML templates
- 使用上面的 HTML templates 來生成真實的內容(可以在web view中進行預覽)
- 將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文檔的預覽和打印功能。當然,預覽視圖里面的功能還等著我們去完成,這也是文章的重點。最后,在展示視圖中我們可以通過左劃來實現對發票的刪除操作,具體看下面演示截圖:
如上所說,點擊新建按鍵后Demo會跳轉到 CreatorViewController 視圖中完成新增發票的功能。界面如下:
在生成訂單之前,我們需要填寫很多信息。其中一些可以手動設置,一些通過計算得到,還有一些通過代碼進行硬編碼。其中需要手動添加的信息有:
- recipient info 是發票收件人的地址,對應上圖中的灰色區域。
- invoice items 對應一個發票中具體項目,主要由服務提供商和服務費組成。為了程序的簡潔性,這里并沒有設置增值稅。使用屏幕下方的 + 按鍵實現添加(更多內容等會再說)。
程序計算得到的信息:
- 發票單號(導航欄上的標題)
- 總共的發票金額(左下角)
需要硬編碼的部分:
- 寄件人信息
- 發票到期日(這里默認設置為空,你也可以自己定制)
- 付款方式
- 發票的Logo
針對 invoice items 我們可以在 AddItemViewController 視圖中進行數據錄入。錄入的數據包括服務描述和價格,維護好數據后可以點擊保存回到前一個視圖。
每個新建的發票子項的信息都被存放在一個字典的結構中,并被追加到數組中。該數組也是 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模版文件中設置一些“占位符”,然后將需要展示的信息替換這些“占位符”。為了實現這一目的首先就是要創建符合展示效果的自定義模版。但是本文的關注點并不是這個,所以我們會使用一個已有的模版 地址 (感謝原作者)。本文已經對模版做了一些修改,去除了邊界和陰影并給logo添加了灰色背景。
在你下載的起始工程里面,你可以看見下面三個HTML模版文件:
- invoice.html
- last_item.html
- 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 。
打開新建的類文件并聲明一些常量和變量屬性:
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
}
注意: getAppDelegate 和 getStringValueFormattedAsCurrency 方法的具體實現,我已經在前面提過了。它們都在 AppDelegate.swift 文件中。
這一步到這里就結束了,我們成功實現了真實發票HTML格式信息的生成。接下來就是對該結果的進一步處理了。
預覽處理后的HTML內容
在上一步處理完成后,接下來就需要驗證結果是否正確了。因此這一部分內容的目的就是使用 PreviewViewController 視圖中的webview來加載該HTML內容,查看我們前面努力的效果。需要注意的是:在真實的應用中這一步是可選的,我們可以跳過預覽直接打印PDF,這里之所以需要預覽僅僅是為了Demo的功能完整性而已。
我們在 PreviewViewController.swift 文件中聲明屬性:
class PreviewViewController: UIViewController {
...
var invoiceComposer: InvoiceComposer!
var HTMLContent: String!
}
第一個屬性就是新建的類的實例,而 HTMLContent 屬性則是對應最終內容的 String 類型變量我們會在后面用到它。
接下來我們創建一個函數來實現如下功能:
- 初始化 invoiceComposer 對象
- 調用 invoiceComposer 對象的 renderInvoice(...) 函數得到發票的HTML編碼內容
- 在webview中加載該內容
- 將得到的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()
}
如果你想看到顯示效果,你可以先去創建一個新發票,然后在列表中點擊該發票你就會看見加載后的效果圖了。如下:
打印前的準備工作
工作完成了一半接下來該輪到打印部分的處理了,這樣才能完成最終導出PDF格式的發票的目標。我們將會使用到 UIPrintPageRenderer 類。如果你之前沒有使用會聽說過這個類的話,一句話來說就是:這個類就是用來打印內容的(打印成文件或者使用AirPrint鏈接打印機打印)。詳見 點我 。
UIPrintPageRenderer 類提供了很多打印繪制的方法,一半情況下我們不需要重載這些方法。當然為了使打印內容有更靈活的掌控(例如添加頁眉、頁腳),我們可以在 UIPrintPageRenderer 子類中對這些方法進行重載。在文中最終的打印文檔中會添加頁眉、頁腳,所以我們會新建一個 UIPrintPageRenderer 子類。
與之前的新建過程類似,不過需要注意以下兩點:
- 新建的類繼承自 UIPrintPageRenderer
- 類名為 CustomPrintPageRenderer
新建完成后,我們先來A4紙尺寸來初始化 width 和 height 。請注意我們的目標是將發票導出為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")
}
因為 paperRect 和 printableRect 都是只讀屬性,所以才會使用上面的方法來設置對應的屬性值。
上面的代碼中,紙張大小和打印區域大小是一樣大的。也許你希望打印的時候能有一些邊距,那么你可以將最后一行代碼替換為:
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)
}
注釋如下:
- 首先創建 CustomPrintPageRenderer 類型實例。
- 接下來使用打印內容創建 UIMarkupTextPrintFormatter 類型實例。
- 將 printFormatter 作為參數傳給了 printPageRenderer 的 addPrintFormatter 函數。該函數的第二個參數表示當前打印內容的起始頁,這里默認為0。
- 使用緊接著會實現的自定義函數 drawPDFUsingPrintPageRenderer 得到待打印的 NSData 對象。
- 保存上一步的到的數據為PDF文件。
- 最后我們打印出該文件的路徑。
在真實的復雜應用中,我們可能會需要為每一個起始頁的打印內容自定義對應的打印格式,但是對于本文的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按鍵:
等創建文檔任務完成后,我們可以在控制臺看見該文件的路徑。我們打開Finder窗口并使用 Shift-Command-G 定位到文件的父目錄中你就可以你創建的PDF文件了:
雙擊新建的文件,你可以看見:
繪制自定義頁眉、頁腳
現在讓我們來對打印結果做一些拓展,添加頁眉和頁腳。這也是為什么在前面我會自定義一個 UIPrintPageRenderer 類。我們所說的打印內容,除了使用HTML模版生成部分還包括頁眉和頁腳。我們會在右上角添加"Invoice"作為頁眉、下方添加“Thank you!”作為頁腳。最終效果如下圖:
在了解實現細節之前,我們需要在 CustomPrintPageRenderer 類的 init() 函數中初始化頁眉、頁腳的高度:
override init() {
...
self.headerHeight = 50.0
self.footerHeight = 50.0
}
接下來我們重載 UIPrintPageRenderer 類中繪制頁眉的函數:
override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) {
}
在函數體內我們實現的步驟如下:
- 初始化我們需要在頁眉中繪制的"Invoice"。
- 初始化與text格式相關的屬性值,例如字體、顏色、字間距。
- 計算頁眉顯示內容的顯示區域大小,并設置與右邊距。
- 計算繪制頁眉的起始位置。
- 繪制頁眉內容。
下面就是對應的代碼,每一行都帶有注釋:
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代碼 地址 ,僅供讀者參考。
來自:http://www.jianshu.com/p/8b3197f90c64