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

做者:GABRIEL THEODOROPOULOS,原文連接,原文日期:2016-7-10
譯者:X140Yu;校對:saitjr;定稿:CMBhtml

你是否曾經遇到過「使用 app 中的內容生成 PDF 文件」這樣的需求?若是你以前沒有作過,那你有想過該如何實現嗎?ios

好吧,經過拋出問題來開篇有點不太好,但上述內容總結了我將要在這篇文章中討論的事情。要在 iOS 應用內建立一個 PDF 文檔,看起來像不是一個容易的需求,但事實並非這樣。做爲開發人員,你必需要隨機應變,爲本身創造可供選擇的方案,儘可能達到目標。這是件頗有挑戰性的事情。確實,手動繪製 PDF 是一個很是痛苦的過程(取決於內容),最終可能會變得很是低效。計算座標、加線、設置顏色、縮進、偏移等。這可能頗有趣(或並非),但若是你要繪製的內容很是複雜,那到最後可能會變得一團糟。git

本文會介紹另外一種建立 PDF 文件的方式,這種方式比手動繪製要簡單得多。它的思想是使用 HTML 模版,大體有如下幾個步驟:github

  1. 爲要生成 PDF 的表單建立 HTML 模版。web

  2. 使用這些 HTML 模版來渲染真正的內容(或者把它顯示在 web view 中)。swift

  3. 把 HTML 的內容轉換爲 PDF。數組

在最後一步,iOS 會替你作全部麻煩的事情。安全

說白了,我認爲你也更加願意處理 HTML,而不是直接繪製 PDF 文件吧。這種狀況下,你須要的是將內容展示在 HTML 文件中,但手動去建立重複的內容,確實不太明智,也不過高效。舉個例子,有一個 app 能把學生的信息打印或輸出爲 PDF。爲每一個學生建立一個 HTML 頁面顯然不可取,由於爲了打印這些信息,一直在作重複的事情。你真正須要的是建立一個 HTML 模版。使用一種特殊的方式在關鍵位置用佔位符佔位,而不是直接使用實際的值,接着在 app 中,把這些佔位符換成實際值。固然,最後一步的值替換是能夠是重複且自動化的。app

當 HTML 代碼中包含實際值以後,就能夠隨心所欲了。你能夠在 web view 中顯示,保存爲文件,分享,固然也能夠輸出爲 PDF。composer

那麼咱們到底應該怎麼作呢?

將內容輸出爲 PDF 是本文的最終目標,但咱們是從如何用實際值替換佔位符這一步開始的。Demo 的 app 是一個生成發票的小應用,很是符合本文的需求。再次說明咱們不會從頭來作這個應用,這不是最終目的。應用的默認功能已經實現好了,也提供了 HTML 模版,咱們也會一步一步說明,因此你也有機會明白這究竟是怎麼一回事,佔位符到底有什麼意義。無論怎麼樣,咱們會一塊兒,一步一步地走通生成真正 HTML 內容的流程,而後將它輸出爲 PDF 文檔。這還沒完,我還會告訴大家如何給最終的 PDF 加上 header 和 footer。

若是你對以上的內容感興趣,那就一塊兒開始作吧!

上手項目

咱們先快速瀏覽一下這個教程的 demo app,其實就是一個製做發票的工具。在開始以前,你應該先下載這個上手項目,而後在 Xcode 中打開。

在上手項目中,你會發現已經有好多工做已經作完了。InvoiceListViewController 這個 view controller 是用來顯示建立和保存的發票信息列表。在這個 VC 裏,你也能夠經過點擊右上角的加號來新建發票信息。點擊列表中的任何一列均可以去到對應的預覽界面,在那個界面能夠看到發票的詳細信息。注意,還有一部分的功能在上手項目中沒有實現,咱們會在這篇教程中實現它。新建的發票信息能夠經過向左滑動對應 cell 來刪除。下面的截圖就是這個 VC 的界面。

就像我說過的同樣,能夠經過右上角的加號按鈕來新建發票。這個動做會帶咱們去到一個新的 VC —— CreatorViewController ,它長這個樣子:

在發票能夠被打印出來以前,須要填一些必要的信息。其中的一部分能夠在上個 VC 中設置,還有一些能夠被自動計算出來,另外一些會硬編碼在代碼中。爲了詳細一些,應用中能夠被手動添加的值有:

  • 接收者的信息,其實就是接收人的地址。上圖中的灰色區域。

  • 須要打發票的條目,每一個條目都由兩部分組成:提供服務的描述,這項服務的價格。爲了簡單起見,這裏沒有增值稅。能夠經過底部 toolbar 的加號來添加新的條目。

自動生成的值有:

  • 發票的號碼(顯示在 navigation bar 中的號碼)

  • 這張發票的總價格(顯示在底部 toolbar 的左邊)

以後咱們要硬編碼的值有:

  • 發送者的信息,也就是發行人的信息。

  • 發票的截止日期(若是你想用也能夠用,但在這裏用不到,因此設爲空)。

  • 付款的方式。

  • 發票的圖標。

項目中已有一個 AddItemViewController 做爲建立發票條目的入口。這個界面很簡單,只有兩個 textfield,還有一個保存按鈕,點擊完成後會跳到以前的 VC 中。

全部的發票條目都在一個有字典元素的數組中,每一個字典有兩個值分別爲描述和價格。這個數組做爲 CreatorViewController 中 tableview 的數據源。當一個條目被建立出來的時候,手動和自動添加的數據都會被加入到字典中,返回給 InvoiceListViewController 。下面是它返回的數據:

  • 發票號碼(string)。

  • 接收人的信息(string)。

  • 所有金額(string)。

  • 發票的條目(裝字典的數組)。

在保存發票的發票號碼的時候,下一個號碼已經計算出來而且存儲在 NSUserDefaults 中了。裝着發票數據的字典被加在 InvoiceListViewController 的數組中,而數組的每一次有新值的時候,存儲到 user defaults 中。當 VC 將要出現時,發票數據從 user defaults 被加載。記住,demo 是由於演示方便,才把應用主要數據保存到 user defaults 的,對於真正的應用程序,不建議這麼作。確定還有更好的方法來存儲你的數據。

對於現有的代碼,我沒有什麼好說的。你所要作的就是到每一個 VC 中或按照應用程序的流程看代碼的細節實現。還有一點我想提一下,那就是 AppDelegate.swift 文件。在這個文件中有三個便捷的方法:一個用於獲取 appdelegate,一個用於獲取沙盒的 documents 路徑,一個用於將表示爲字符串的金額轉換成貨幣字符串(連同適當的貨幣符號)。除了上手項目,這些方法咱們以後也還會用到。在 AppDelegate 中,還有個 currencyCode 屬性被默認設置爲了「eur」(歐元)。能夠經過改變它來設置你本身的貨幣單位。

最後,讓我告訴你,上手項目在哪結束,還有咱們將從哪裏開始。點擊 InvoiceListViewController tableview 的發票數據,一個包含匹配發票數據的字典會被傳遞到 PreviewViewController 中。在這其中,有一個已渲染完成,可供預覽的 HTML 文件,和一個導出到 PDF 的按鈕。這些功能都不在上手項目中,咱們將要實現它們,咱們須要的全部數據都已經存在於 PreviewViewController 中,因此能夠直接使用它。

HTML 模版文件

正如我在引言中闡述的,咱們將使用 HTML 模板來產生相應發票內容的 HTML,而後將真正的 HTML 內容渲染成一個 PDF 文件。這裏的基本邏輯是把佔位符放在 HTML 文件中佔位,而後用真實的數據替換這些佔位符。這樣的話,咱們就必須找到或建立自定義 HTML 表單。對於這篇教程來講,咱們不會建立任何自定義的 HTML 模板。相反,咱們將使用一個在這裏找到的模版(特別感謝做者)。該模板已被修改了一點,因此它沒有陰影的邊框,並且在 logo 處添加的灰色的背景顏色。

在你下載的上手項目裏,有三個 HTML 文件:

  1. invoice.html

  2. last_item.html

  3. 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 和發票模版,咱們如今應該開始實現應用沒有實現的關鍵部分。首先,根據 InvoiceListViewController 選中的發票信息,使用模板,建立含有實際發票內容的 HTML。而後,在 PreviewViewController 中使用 web view 顯示生成的 HTML 代碼,咱們能夠經過這種方式驗證正確性。

這部分最重要的一項任務是把 HTML 模板文件的佔位符替換爲真正的內容。那些值其實是從 InvoiceListViewController 傳到 PreviewViewController 中的。正如你將看到的,更換佔位符是一項簡單的工做。在咱們開始以前,讓咱們建立一個新的類用於生成真正的 HTML 內容,而後它就能夠生成 PDF。在 Xcode 中,選擇 File > New > File… 菜單,建立一個新的 Cocoa Touch 類。讓它繼承自 NSObject。並將其命名爲 InvoiceComposer。一路跟隨嚮導完成新文件的建立。

打開 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!
}

前三個屬性(pathToInvoiceHTMLTemplate, pathToSingleItemHTMLTemplate, pathToLastItemHTMLTemplate),咱們指定了三個 HTML 模版的文件路徑,方便以後的使用。由於咱們會打開它們,獲取模版並修改代碼。

以前提到過,咱們的 demo 不提供選項來設置全部的參數(senderInfo, dueDate, paymentMethod,logoImageURL),因此這些在這裏直接被硬編碼了。在真正的應用程序中,這些值,都應該能讓用戶自定義。最後一個是做爲發票的標誌的圖像的地址。你能夠改變上面的屬性,設置成本身喜歡的值(例如,把 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
}

上面替換佔位符的實現看起來很簡單,實際上...就這麼簡單。利用 stringbyreplacingoccurrencesofstring(...) 方法,把第一個參數(佔位符)替換爲第二個參數(真實的值)。徹底沒難度...好無聊。

再進一步,注意到全部的代碼都包含在 do-catch 語句中了嗎?由於把一個文件的內容加載爲 HTMLContent 字符串可能會拋出異常。同時,若是有異常拋出,它就會返回 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 。咱們在這裏作的,爲了 demo 的完整性。

切換到 PreviewViewController.swift 文件,去到類的頂部,先聲明幾個屬性:

class PreviewViewController: UIViewController {

    ...

    var invoiceComposer: InvoiceComposer!

    var HTMLContent: String!
}

第一個是咱們在以前新建的生成 HTML 內容類的對象。HTMLContent 字符串是用來存放未來要用到的真正的 HTML 內容。

接下來,新建一個方法,作下面幾件事情:

  1. 初始化 invoiceComposer 對象。

  2. 調用 renderInvoice(...) 方法,產生髮票內容的 HTML 代碼。

  3. 把 HTML 加載到 web view 中。

  4. 把返回的 HTML 字符串存入 HTMLContent 屬性中。

下面來看看這個方法:

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

上面的代碼沒什麼特別的,關注一下傳入 renderInvoice(...) 方法的參數就能夠了。一旦咱們從那個方法中得到了真正的 HTML 字符串(而不是 nil),就把它加載進 web view 中。

是時候調用咱們的新方法了:

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

若是你想看到的結果,運行應用程序,並建立一個新的發票信息(若是你還沒這樣作過)。而後從列表中選中它,就能看到相似下圖的效果:

準備輸出

任務已經完成一半了,咱們如今能夠進行把發票信息輸出爲 PDF 的工做了。接下來會使用一個特殊的類,UIPrintPageRenderer。若是你以前歷來沒有據說,也沒有使用過,那我能夠先簡單地告訴你,這個類是是把內容輸出來打印用的(輸出爲文件或者使用 AirPrint 的打印機)。這裏是官方的文檔,能夠看到更多信息。

UIPrintPageRenderer 類提供了多種繪製方法,可是對於咱們這種簡單的狀況,其實不須要重寫這些方法。這些繪製方法只能被 UIPrintPageRenderer 的子類重寫,雖然略爲麻煩,可是多作一些工做就能夠把輸出內容控制地更好,好比在本例中的 header 和 footer,那咱們爲何不去作呢?

再次回到 Xcode,按照下面的步驟建立一個新的類:

  1. 讓它繼承自 UIPrintPageRenderer

  2. 把它命名爲 CustomPrintPageRenderer

一旦你完成了上面的工做(當看到 CustomPrintPageRenderer.swift 出如今工程目錄中時),還須要爲後面的工做作一些準備。先讓咱們指定一下 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 上下文轉換成 NSData 對象,而後把這個對象保存到文件中(最終的 .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 對象。還有一些其它的子類信息能夠在上面的連接中找到。

光說仍是有些不清楚,看看代碼吧:

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 對象,在初始化的時候,咱們把 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 按鈕:

以後,輸出 PDF 這個過程就已經發生了,當一切都結束的時候,你將會在控制檯看到 PDF 文件的路徑。把路徑複製一下(不要帶上文件名),打開一個 Finder 窗口,使用 Shift-Command-G 快捷鍵,粘貼上路徑,在打開的文件夾中你就能夠看到以發票號碼爲名字的新建立的 PDF 文件。

雙擊打開它,用你喜歡的 PDF 程序就好。

繪製自定義的 Header 和 Footer

如今擴展一下咱們的 demo,往打印頁面添加自定義的 header 和 footer。畢竟這也是咱們最初子類化 UIPrintPageRenderer 的緣由。自定義的意思是,不是 HTML 模板中的一部分,不是和其它的 HTML 內容一塊兒渲染的內容。咱們想要實現的是把 「Invoice」放在頁面的頂部,做爲 header,把「Thank you!」放在頁面的底部,做爲頁面的 footer,在它上面還有一條水平線。下面的這張圖就是咱們要達到的效果:

在開始以前,咱們先聲明一下 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」單詞)。

  2. 指定 header 文字的一些屬性,好比字體、顏色、字間距等。

  3. 計算字在加上上述屬性後佔據的空間,而後指定文字到頁面右側頁面的邊距。

  4. 設置文字起始繪製的點。

  5. 繪製文字(終於到這一步了)。

下面就是我上面文字轉化爲代碼的實現。每句都有註釋,方便你們理解:

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 都這麼作就太麻煩了),因此我要給這個 app 增長兩個額外的功能:在 web view 中預覽 PDF 的功能(已在 PreviewViewController 中實現),還有經過 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 文件了。

總結

無論如今仍是之後在建立 PDF 文檔方面出現了什麼新的技術,本文展示的這個方法在建立 PDF 文件方面永遠會是基本的、高靈活性的,且安全的。它適用於幾乎全部的情形,但只有一個缺點:要用到 HTML 模板來渲染真正的內容 。但我認爲,建立它的成本真得很低。相比於寫 HTML、建立 placeholder、替換字符串來講,手動繪製 PDF 文件真得是太麻煩了。除此以外,真正繪製 PDF 部分的代碼是很基本的,而且經過 demo 應用的代碼,你能夠得到很理想的結果。無論怎樣,我但願你能喜歡本文中介紹的這種方法。感謝閱讀!但願你能開心地處理輸出 PDF 文檔的問題!

你能夠在 Github.com 獲取本文的 Xcode 項目 做爲參考。

本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 http://swift.gg

相關文章
相關標籤/搜索