Swift 程式語言

iOS開發指南 : 如何使用HTML Templates和 UIPrintPageRenderer製作PDF

iOS開發指南 : 如何使用HTML Templates和 UIPrintPageRenderer製作PDF
iOS開發指南 : 如何使用HTML Templates和 UIPrintPageRenderer製作PDF
In: Swift 程式語言

你曾經被要求在你的app內建立PDF文件嗎?如果你目前仍未寫過這類的應用程式,那你之前曾經想過如何製作這個功能嗎?

本篇教程透過提問的方式來起頭,上述這些問題都是關於本文所要探討的,而在iOS中建立PDF文件通常看似是條通往地獄的道路,但是其實你可以避開它,做為一個開發者,必須要手握許多資源,建立多元的解決方案,透過不同方式在可控制成本內達成你的目標,我必須承認,手動繪製PDF頁面可能會是相當艱辛的過程(根據開發需求),而且也是一項降低生產力的任務,它需要計算points,增添線條,設定顏色、insets、offsets等等,儘管這可能是一項很有趣的過程(對某些人來說),但若是需要繪製的文件太過複雜,這無疑會是個繁雜的工作。

編者註: 此教程已更新至 Xcode 8 及 Swift 3。

這篇教程中,我的目標是呈現給讀者一個不同的PDF文件生成方式,且這些方式都將會比過去手動繪製更簡單,這套解決方案是建立在使用HTML templates之上,概括可歸納在下列這些步驟:

  1. 根據需要繪製的PDF文件內容或格式創建HTML templates。
  2. 使用這些HTML templates去生成真正的內容(可以選擇把它呈現在web view)。
  3. 把你想要製作的HTML內容輸出為PDF。

在最後一個步驟中,困難的工作將由iOS幫你完成。

繼續往下介紹,我想你會同意一個事實,開發者會偏好透過HTML操作去取代直接繪製PDF。在這個範例中,你實際要做的,就是把內容呈現在HTML頁面上,但是如果要手動處理多個重複內容的頁面,是非常沒有效率的,舉例來說,想像有一個app可以列印或輸出學生的個人資訊PDF檔,若是要替每一個學生去創建一個HTML頁面,並不是明智的方法,你應該會希望只要建立一個HTML頁面,就可以用來呈現格式相同的學生資料頁面,這就是透過template(模塊)來達成,讓開發者可以在頁面上設定placeholders,之後建立特定頁面時,將app中placeholders更改為實值,如此一來,即可將上述的步驟改成可重複使用的動作。

把你指定的內容寫入HTM程式碼之後,可以隨著不同的使用需求,將它呈現在web view,也能夠把它存為可共用檔案,用來分享給其他人,當然,將它輸出成PDF也沒問題。

那下一步我們要做些什麼呢?

本文最終目的是展示如何將內文輸出為PDF文件,我們將建立HTML templates之上,把placeholder裡面的值改為真實的數值,藉此達到我們想要的效果。在範例中,我們將使用一個簡單的invoice maker,它是最符合我們的需求,可以完美地把資料輸出成PDF檔,在本文中,我們不會重頭建立這個app,畢竟它不是我們的目的,因此,demo app會先將預設功能幫開發者建置好,你也會拿到需要的HTML templates,讀者在建置過程中,將有機會了解到它們的全貌,以及placeholders代表什麼意思,但不論如何,我們將一起逐步建構HTML裡面的真實內容,並將它輸出成PDF文件,不僅如此,也會展示如何在最終的PDF檔中增添header和footer內容。

對上述內容感到興趣了嗎?正式開始實作吧!

The Starter Project

我們在一開始先快速談談這個範例應用,它實際上就是一個invoice maker tool,在我們正式開始前,請你開到這裡下載starter project,完成後,請再Xcode打開它。

打開starter project後,你會發現裡面已經做了一些基本設定,app進入頁面為InvoiceListViewController,它用來展示一系列被生成且儲存的發票資訊,這個頁面可以透過+按鈕來產生新的發票資料,只要點擊某一列發票資料,使用者就會被帶往preview視窗,在這裡你可以看到並且將發票資料輸出為PDF檔案,但是請注意,這個功能在剛才下載的starter project中還沒被實作,但是本篇教程將會在接下來的篇幅完成它,最後,也會提供刪除功能,使用者在cell中往左滑即可完成刪除動作,下列圖片會示範這個頁面所呈現的樣貌:

t54_1_invoice_list_viewcontroller

就像我說的,想要生成一組新發票只要點擊+按鈕即可,而這個動作將會把我們帶往CreatorViewController,如下圖:

t54_2_creator_viewcontroller

一組發票資訊要被列印輸出前,一般來說要先填入各式各樣的數值,有一部分是在這個view controller被手動設定,一部份則被自動計算,也有一些是被固定設置在程式碼裡面,具體來說,demo app裡面可以被手動添加的數值包括:

  • recipient info可以手動記載該發票的收件人資訊。這部分就呈現在新增頁面的灰色區塊
  • 發票消費細項, 這裡分成兩個區塊: description 可以記載消費服務項目,而price 則是紀錄消費金額。但是在這裡我們不做增值稅這類較繁雜的計算。一個新品項可以透過該頁面下方toolbar中的+按鈕來新增(稍後的篇幅會有更多介紹)。

會被自動被計算的數值包括:

  • 發票號碼 (號碼將會顯示在navigation bar的title).
  • 發票的消費總數 (顯示在bottom toolbar的左側).

最後,我們將發票的部分資訊固定設置的內容包含:

  • 發件人資訊用來顯示的是發行者的資訊
  • 發票的due date (我們在範例中不會用到,但你可以對它進行設定)。
  • 付款方式
  • 這組發票的logo

關於發票內的項目,AddItemViewController提供一個簡單的資料輸入方式,內部有兩個textfields供使用者填入預期的數值,save按鈕則能將填入資訊添加進該組發票內,並返回到前一個展示頁面。

t54_3_add_item_viewcontroller

所有的發票項目會被加進dictionaries裡的一個陣列(array)中,每個dictionary都會有兩個值:包含該項目的敘述以及價格,而這個array會被用來當作tableview的datasource,在CreatorViewController當中展示所有的項目,當發票資訊被儲存時,我們手動寫入的資料以及程式自動幫我們計算的資料都會被寫入dictionary,並且回傳至InvoiceListViewController,下面列出回傳的資料項目:

  • 發票號碼 (string value)
  • 發票收受人資訊(string value)
  • 發票總額(string value)
  • 發票項目(array with dictionaries).

當你存入新的一筆發票後,程式會自動將下一張發票的號碼寫入user defaults dictionary(NSUserDefaults)供之後使用,而存在新發票dictionary裡面的資料,會被添加到InvoiceListViewController裡面的一個array,當使用者每次創立一組新發票,這個array就會被存入user defaults,而當對應的view controller被生成以後,發票資料就會從user defaults被載入其中,請注意,在本範例當中,把資料儲存在app的user defaults只是一個快捷的存取方式,但在一般實作中,並不建議這樣做,因為有很多更好的方式可以在應用程式中保存你的資料。

對於範例專案中的預設程式碼不會在這裡多做討論,讀者可以去查看一下各個view controller的程式碼,或是按步就班操作這個應用程式,這樣一來,可以幫助你瞭解這個app的實作細節,在這裡特別提醒,就是關於AppDelegate.swift檔案,你可以在這裡看到三個convenient methods:一個是用來存取application delegate,一個是獲得文件目錄的路徑位置,第三個則是將運算總數轉為一個字串去取代目前的貨幣字串符(總數金額會依照貨幣做適當轉換),這些方法已經被starter project使用,且我們會在之後的實作中再次使用。另外,在AppDelegate裡面,你可以找到一個名為currencyCode的屬性,預設成”eur” (歐元貨幣),開發者可以使用它去設定你自己的貨幣別。

最後,讓我告訴你這個starter project最後會實作哪些東西,以及它將會從哪裡開始。請在InvoiceListViewController中的tableview元件中,點擊一組已被建立的發票資料,將會有一個dictionary將發票內的資料傳送至PreviewViewController。在這裡,有一個web view用來呈現HTML文件內容,它被拿來預覽一組發票資料,並提供一個按鈕讓使用者可以將其輸出為PDF檔案,但這個功能在稍早下載的starter project中尚未被實作,我們將會在接下來的篇幅實作它。當然,關於我們實作需要的全部資料,已經存在於PreviewViewController,可以在專案中直接使用它。

HTML Template文件介紹

就如我在稍早介紹中所解釋的,我們將使用HTML templates去生成一組發票,並且將HTML內容輸出為PDF檔案,這裡主要的運作邏輯就是在 HTML檔案的特定位置上放入placeholder,然後在placeholder裡填入真正的資料,為了達到這個目的,我們必須建立或找尋一個客製的HTML表單,讓它可以滿足我們最終的需求,在本文中,我們不會客製化建立發票的HTML templates,將會使用這裡提供的服務 (特別感謝這個應用的提供者),這個模版已經被稍微修正過,我們將logo設定為灰色背景,並且取消陰影和邊框效果設定。

在你所下載starter project中,你可以看到下列三個HTML文件:

  1. invoice.html
  2. last_item.html
  3. single_item.html

除了所需陳列的項目外,第一個文件內的程式碼可以生成整個發票架構,我們需要透過另外兩個模版去獲取具體的項目:last_item.html只被用來呈現最後一個項目,而single_item.html則被用來展示列表中的其他項目(除了最後一個項目外),這是因為最後一列的底框線不同於其他列。

任何HTML template文件中的placeholder是一個由#圍繞的特殊關鍵字,舉例來說,下列展示代碼就是invoice number, issue date以及due date的placeholder:

 Invoice #: #INVOICE_NUMBER
#INVOICE_DATE#
#DUE_DATE#
註: 即使due date的placeholder存在,但不會真的用到,我們將一個空字串填入,若是開發需要可以隨時拿來使用。

你可以在上述的三個HTML檔案中,找到全部的placeholders,且都陳列在適當的位置上,這裡將它們全數列在下方:

  • #LOGO_IMAGE#
  • #INVOICE_NUMBER#
  • #INVOICE_DATE#
  • #DUE_DATE#
  • #SENDER_INFO#
  • #RECIPIENT_INFO#
  • #PAYMENT_METHOD#
  • #ITEMS#
  • #TOTAL_AMOUNT#
  • #ITEM_DESC#
  • #PRICE#

最後兩項placeholder只置放在single_item.html和last_item.html文件中,同時,當我用使用這兩項HTML template文件創建全部的項目後,#ITEMS#的placeholder將會透過對應的程式碼進行替換(細節將會在之後找適當時機介紹)。

你可以看到,透過一個或是多個HTML templates來建立特定格式的產出(在這裡用來製作發票)並不困難,當我們跑完整個流程之後,你將會了解到,透過這些模版將實際內容輸出為PDF檔案是如此簡單且有效率的。

建立發票內容

當介紹過demo app裡面的發票生成模塊(template)後,該是完備這個應用程式的時候了,接下來開始實作這個demo app仍欠缺的關鍵部分,我們剛開始需要做的,就是使用HTML templates替發票建立實際的內容,生成的發票可以在(InvoiceListViewController)選取,完成後,我們將生成的HTML程式碼呈現在PreviewViewController裡面的web view中,藉此驗證上述工作已被完成。

這裡最主要且重要的任務,就是在負責生成發票的HTML template文件中,將它們placeholder內的字串替換為實際的數值,這些實際的值將會對應你在InvoiceListViewController內所選取的發票,並傳送至PreviewViewController當中,替換placeholder內的值是一個簡單的任務,在我們實作這個動作前,請先建立一個新的class,我們將會用它來生成實際的HTML內容,提供之後的PDF檔案輸出之用,所以,請在Xcode點選menu中的File > New > File…,並建立新的Cocoa Touch Class,並把它設為NSObject的subclass,將它命名為InvoiceComposer,請跟著指示一同完成新檔案的建立工作。

t54_4_create_invoice_composer

InvoiceComposer.swift目前已經存在於專案內,請在Navigator欄中打開它,我們將要開始宣告一些屬性(包含常數與變數):

class InvoiceComposer: NSObject {

    let pathToInvoiceHTMLTemplate = Bundle.main.path(forResource: "invoice", ofType: "html")
    
    let pathToSingleItemHTMLTemplate = Bundle.main.path(forResource: "single_item", ofType: "html")
    
    let pathToLastItemHTMLTemplate = Bundle.main.path(forResource: "last_item", ofType: "html")
    
    let senderInfo = "Gabriel Theodoropoulos
123 Somewhere Str.
10000 - MyCity
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 template文件的路徑,當我們需要打開並修正這些屬性的時候,這些路徑將讓上述動作變得更加便利。

就如我已經說過的,我們的範例不會讓使用者設定全部的發票參數(senderInfo, dueDate, paymentMethod, logoImageURL),所以上述的屬性已被設置為固定值,但是在一個真正的應用程式中,這些值應該是可以讓使用者能夠去設定與改變的,儘管最後一個屬性是已經被我設定的為發票loge的image URL,但不用說,開發者可以自由設定上述屬性的數值(舉例來說,你可以在senderInfo屬性設定你的個人資訊)。

最後,invoiceNumber這個屬性將會記載已經被預覽過的發票號碼,而pdfFilename則會包含PDF檔案的路徑,這是稍晚才會用到的東西;儘管如此,我們仍要先在這裡宣告它,如此一來,待未來需要用到的時候,馬上就可以使用。

除了以上介紹的屬性之外,也請添加一個預設的 init() 方法到這個class裡面:

class InvoiceComposer: NSObject {

    ...

    override init() {
        super.init()
    }
}

我們可以繼續下個步驟,建立一個新的函式用來負責一個很重要的工作,它將在HTML template文件中替換placeholder內的值,我們將其命名為renderInvoice,這個函式附帶一些參數可供調用:

func renderInvoice(invoiceNumber: String, invoiceDate: String, recipientInfo: String, items: [[String: String]], totalAmount: String) -> String! {

}

當我們在demo app建立一組發票的時候,這些參數可以完全被手動設立,而參數在建立發票時必須被全部帶入(包含這個class中被固定賦值的屬性),我們將會得到一個String的回傳值,它包含最後HTML檔案裡的真正內容。

讓我們開始實作這個函式,實現我們第一個重要任務,在下列圖示中,有處理兩件重要的事情:首先,invoice.html文件的內容被存進一個string變數,我們可以根據需求修改它。另外,除了發票項目外,我們可以替換全部的placeholder,下方圖示中的註解(comments)可以幫助你更了解這個函式。

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.replacingOccurrences(of: "#LOGO_IMAGE#", with: logoImageURL)

        // Invoice number.
        HTMLContent = HTMLContent.replacingOccurrences(of: "#INVOICE_NUMBER#", with: invoiceNumber)

        // Invoice date.
        HTMLContent = HTMLContent.replacingOccurrences(of: "#INVOICE_DATE#", with: invoiceDate)

        // Due date (we leave it blank by default).
        HTMLContent = HTMLContent.replacingOccurrences(of: "#DUE_DATE#", with: dueDate)

        // Sender info.
        HTMLContent = HTMLContent.replacingOccurrences(of: "#SENDER_INFO#", with: senderInfo)

        // Recipient info.
        HTMLContent = HTMLContent.replacingOccurrences(of: "#RECIPIENT_INFO#", with: recipientInfo.replacingOccurrences(of: "\n", with: "
")) // Payment method. HTMLContent = HTMLContent.replacingOccurrences(of: "#PAYMENT_METHOD#", with: paymentMethod) // Total amount. HTMLContent = HTMLContent.replacingOccurrences(of: "#TOTAL_AMOUNT#", with: totalAmount) } catch { print("Unable to open and use HTML template files.") } return nil }

替換placeholder的值就如上面程式碼範例一樣簡單,只要使用 stringByReplacingOccurrencesOfString(...)這個string函式,我們將placeholder當作第一個參數,實際的值則會放在第二個參數(用來替換第一個參數的字串),反覆這樣動作可能會有點無聊,但是它並不困難。

接下來,開始對HTMLContent文件的字串內容初始化作業進行例外處理(throw an exception),所有的動作都在do-catch這個陳述式中進行,若程式碼運行時出錯,將會回傳 nil。但是目前程式仍無法將HTML內容回傳,他是我們下一階段的作業。

讓我們聚焦在發票項目的設定作業,當發票號碼變化時,我們會使用一個迴圈去處理,在處理最後一個項目以外的資料時,我們將會打開single_item.html template文件,並且替換裡面的placeholders,反之,若是處理最後一個項目,由於底部框線規格不同,這時我們會使用last_item.html template,生成的HTML程式碼將會被添加進另一個字串 (你將會看到allItems變數),這個字串承載著全部項目的詳細資料,它將會用來把HTMLContent字串內的#ITEMS# placehoder替換掉,最終,這個字串將會被該函式返回。

請添加下列截圖中的程式碼至do的body中:

<

pre class=”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.replacingOccurrences(of: "#ITEM_DESC#", with: items[i]["item"]!)

        // Format each item's price as a currency value.
        let formattedPrice = AppDelegate.getAppDelegate().getStringValueFormattedAsCurrency(value: items[i]["price"]!)
        itemHTMLContent = itemHTMLContent.replacingOccurrences(of: "#PRICE#", with: formattedPrice)

        // Add the item's HTML code to the general items string.
        allItems += itemHTMLContent
    }

    // Set the items.
    HTMLContent = HTMLContent.replacingOccurrences(of: "#ITEMS#", with: allItems)

    // The HTML code is ready.
    return HTMLContent
}
catch {
    print("Unable to open and use HTML template files.")
}

return nil

}

註: 提醒讀者,可以在AppDelegate.swift檔案中找MARKDOWN_HASH9be45a8449d308e6aba6ff17060c2c17MARKDOWN_HASHMARKDOWN_HASH43ec7db1bdfab19b8c7d9b31b17d0ccdMARKDOWN_HASH的實作方法。

這樣就完成了發票建立前的準備工作,template裡面的程式碼已經被適當的修正,已可產生一組擁有實質內容的發票,下一步,我們將使用上面的已實作完成的函式。

預覽HTML內容

在實作發票內的真實資訊後,是時候去驗證相關工作是否已被順利完成,因此,在這裡,我們的目標就是將HTML string載入至PreviewViewController當中的web view裡面,看一下我們先前實作的成果如何,但這一個步驟是選擇性的,在實際操作上,我們不需要在輸出PDF前,在web view上面預覽HTML的樣貌,這個動作的只是要驗證demo app功能的完整性。

現在請移動到PreviewViewController.swift文件裡面,並在將滑動到這個class的最上方,我們一開始會在這裡宣告幾個新的屬性。

class PreviewViewController: UIViewController {

    ...

    var invoiceComposer: InvoiceComposer!

    var HTMLContent: String!

}

第一個宣告的物件繼承InvoiceComposer這個類別,稍後將對它進行初始化的工作,另外,宣告一個 HTMLContent 的string變數,之後將被用來承接HTML內容。

接下來,我們新增一個新的方法,請參考下列動作:

  1. 初始化invoiceComposer這個物件。
  2. 呼叫renderInvoice(...)這個函式,去產生發票的HTML內部程式碼。
  3. 並且將這個HTML載入至web view裡面。
  4. 我們把回傳的HTML string賦值給HTMLContent

讓我們看一下這個函式的樣子:

func createInvoiceAsHTML() {
    invoiceComposer = InvoiceComposer()
    if let invoiceHTML = invoiceComposer.renderInvoice(invoiceNumber: 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!)! as URL)
        HTMLContent = invoiceHTML
    }
}

上面動作沒有太困難的地方,只要耐心把參數傳入到 renderInvoice(...)方法中,隨後將從這個函式拿到一個實際的HTML string回傳值(若回傳值不是nil),並將它載入到web view裡面。

是時候呼叫我們的新函式,如下圖所示:

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

如果你想要看一下成果如何,可以運行這個應用程式並且生成一組新的發票看看(如果你還沒做過),然後在資料列表中點擊它,之後就會看到發票內容呈現在web view裡面,請參考下圖:

t54_5_invoice_webview

準備輸出工作

目前一半的作業我們都已經完成了,可以進入到下一步輸出程序了,它讓我們可以將發票轉為PDF檔,為了達到這個成果,這邊必須來使用一個特殊的class,這個class被命名為UIPrintPageRenderer,如果你從未使用或沒有聽過它,讓我在這邊做個簡短介紹,這個class可以將內容轉送至下一步輸出工作(製作成一份文件或提供給AirPrint使用),這裡是官方文件頁面,請瀏覽該頁面獲得關於這個class的更多資訊。

<UIPrintPageRenderer這個class提供許多的繪製方法,若在一些簡單的範例中,並不需要去覆寫這些函式。這些繪製的相關函式只能被 UIPrintPageRendererclass的子類別覆寫,若是開發上需要更有彈性去控制頁面上的header或footer輸出內容,那我們可以付出額外的工作去滿足所需繪製功能,在這個demo app中,由於有客製化的header與footer繪製需求,所以我們會需要繼承這個類別。

所以,請再次回到Xcode,按照先前的步驟創建一個新的class,這裡請注意兩件事情:

  1. 請將它設定為UIPrintPageRenderer的subclass。
  2. 將它命名為CustomPrintPageRenderer

當你建立完之後(這時候應該要在專案的Navigator中看到CustomPrintPageRenderer.swift),接下來,快速完成幾個準備動作,請給定A4頁面具體的長度與寬度(in pixels),記住,我們想要將發票輸出為PDF,但是這個PDF也要可以被印表機列印出來,所以確實將輸出大小設定將紙張的長寬是很重要的。

class CustomPrintPageRenderer: UIPrintPageRenderer {

    let A4PageWidth: CGFloat = 595.2

    let A4PageHeight: CGFloat = 841.8

}

上圖顯示的數值,就是一般輸出的A4紙張具體長寬值,全世界的使用A4輸出規格是一樣的。

請務必確實指定紙張的規格與列印的區塊,CustomPrintPageRenderer的物件將會在指定的區域內進行繪製工作,我們將在init()方法中進行上述設定,很明顯的,上述兩個屬性會在這裡被使用到:

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屬性是唯讀(read-only)的,因此,我們會透過上圖的方式設定它們的值。

在截圖片段中,你可以看到我們將紙張規格與列印區塊設成一樣的,儘管如此,如果你希望另外設定列印區塊inset值(距離頁面邊界的offset),藉此達到更好的列印成果,那請將最後一行程式碼替換掉,請參考下圖:

self.setValue(NSValue(cgRect: pageFrame.insetBy(dx: 10.0, dy: 10.0)), forKey: "printableRect")

上面程式碼對平行與垂直軸添增10 points的offset,請注意,即使妳沒有繼承UIPrintPageRenderer的subclass,這個架構仍要被實作出來,換句話說,你不應忘記設定紙張以及物件的列印區塊。

輸出PDF文件

一般說”輸出為PDF”,就是表示將指定內容繪製在PDF graphics context,當動作開始時,指定繪製內容會被送至印表機或是儲存成檔案,目前我們將只著重在後者的應用,把HTML content繪製為PDF context,而且我們會將繪製結果轉為一個NSData 物件,並且將這個物件儲存為一個檔案 (最終將是 .pdf 格式檔),步驟雖多,但是過程卻都相當簡單,我們將一步一步介紹。

我們打開InvoiceComposer.swift準備接下來的作業,接者,請實作一個名為exportHTMLContentToPDF(...)的新函式,它僅接受一個參數,就是我們希望輸出至PDF的HTML內容,在我們實作這個功能之前,先來介紹另一個列印作業需要知道的概念, 那就是列印格式 (UIPrintFormatter class),以下摘錄自蘋果官方文件中:

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內容當成print page renderer的列印格式,iOS輸出系統會代為處理layout並列印輸出這個頁面,建議讀者看一下這個頁面,可以獲得更多需要的訊息,所有的問題都可以在這獲得解釋,為了保持簡單的操作流程,請把列印格式當作一般設定,將我們想要列印的內容傳送iOS系統處理,此外,雖然UIPrintFormatter是一個抽象類別(Abstract Class),iOS SDK也提供實體子類(concrete subclasses)讓開發者使用,UIMarkupTextPrintFormatter就是其中一個實體子類,我們只要將HTML內容加入到print page renderer物件即可,其他子類別的詳細介紹可以在上述連結中找到。

話說回來,現在是實作這個新函式的時候了,請參考下圖:

func exportHTMLContentToPDF(HTMLContent: String) {
    let printPageRenderer = CustomPrintPageRenderer()
    
    let printFormatter = UIMarkupTextPrintFormatter(markupText: HTMLContent)
    printPageRenderer.addPrintFormatter(printFormatter, startingAtPageAt: 0)
    
    let pdfData = drawPDFUsingPrintPageRenderer(printPageRenderer: printPageRenderer)
    
    pdfFilename = "\(AppDelegate.getAppDelegate().getDocDir())/Invoice\(invoiceNumber).pdf"
    pdfData?.write(toFile: pdfFilename, atomically: true)
    
    print(pdfFilename)
}

這邊我們來介紹一下這段程式碼做了什麼事:

  • 我們先初始化CustomPrintPageRenderer的物件,它將用來實現真正的繪圖功能(我們通常稱為輸出)。
  • 接下來,生成一個UIMarkupTextPrintFormatter的實例,我們將HTML content當作參數傳送至此進行初始化作業。
  • 頁面格式設定被加至print page renderer物件上,addPrintFormatter(...) 裡的第二個參數被用來指定起始頁面,在這個範例中,我們將該值設為0,因為我們只有一個頁面。
  • 下一步讓我們開始繪製PDF內容, drawPDFUsingPrintPageRenderer(...)是一個客製化函式,我們將在稍後定義它,繪製的結果將會存在pdfData 物件裡,實際上它是一個NSData 物件
  • 再來,請將PDF資料儲存為一個檔案,首先,我們設置一個路徑給這個檔案,同時給它一個合適的檔案名稱(它是invoiceNumber屬性),接著,就把PDF資料寫入這個檔案中。
  • 最後一個步驟並不是必須的,但對於我們現在來說相當實用,請於Finder找到新創建的檔案,當我們運行這個app,這個路徑將會顯示在console中,方便開發者循著路徑打開這個PDF,檢視實作成果。

若是在更複雜的app中,你也可以使用多個不同的列印格式物件,並且針對不一樣的格式設定不同起始頁面,但是我們先專注在目前的任務上。

現在,讓我們移動至PDF文件繪製作業的實作方法中,接下來你會看到這個客製化函式是透過Core Graphics實作這個動作,過程相當簡潔且一目了然,請參考下圖:

func drawPDFUsingPrintPageRenderer(printPageRenderer: UIPrintPageRenderer) -> NSData! {
    let data = NSMutableData()
    
    UIGraphicsBeginPDFContextToData(data, CGRect.zero, nil)
    
    UIGraphicsBeginPDFPage()
    
    printPageRenderer.drawPage(at: 0, in: UIGraphicsGetPDFContextBounds())
    
    UIGraphicsEndPDFContext()
    
    return data
}

首先,我們初始化一個 mutabledata 物件,讓PDF輸出的資料可以寫入這個物件,然後,有一個我們用來創建PDF graphics context的程式碼(第二行),接下來,我們開始建立新的PDF頁面,但真正開始執行繪製作業的是下面這一行code。

printPageRenderer.drawPage(at: 0, in: UIGraphicsGetPDFContextBounds())

在這一行程式碼中,print page renderer物件將透過函式內的參數,在指定框架內繪製PDF文本內容,請注意,當drawPageAtIndex(...)呼叫其他print page renderer物件的繪圖函式時,客製化的header或footer也將會自動被繪製。

最後,我們將PDF繪製作業結束,並且把這個函式的繪製結果回傳至exportHTMLContentToPDF(...)

上方的函式用來列印單一頁面,儘管如此,可能有些案例你必須輸出多個頁面,這時候,可以將PDF頁面輸出作業包裝成一個迴圈,若是你希望在單一PDF文件輸出多個頁面,可以延展這個demo app,或是製作一個符合自己需求的相關應用。

PDF輸出任務到此已經接近尾聲,但本篇教程尚未結束,下一個階段,我們將去看看如何添加客製的header以及footer至輸出頁面上,在那之前請先將上面的動作完成。

請打開PreviewViewController.swift,在 exportToPDF(...)這個IBAction裡面,添加一行程式碼(可參考下圖),當我們點擊PDF這個按鈕時,即可將預覽發票輸出為PDF文件:

@IBAction func exportToPDF(_ sender: AnyObject) {
    invoiceComposer.exportHTMLContentToPDF(HTMLContent: HTMLContent)
}

現在,你可以開始測試這個app,建議先在模擬器運行它,若你想預覽一組發票,它在畫面的右上方點擊PDF按鈕:

t54_6_pdf_button

點擊之後,即可將資料輸出為PDF,當所有動作完成以後,你可以在console內看到輸出檔案所置放的路徑,請複製這段路徑(沒有檔案名稱),並在視窗內打開Finder,請同時按下 Shift-Command-G鍵,將路徑貼到輸入框中,將會前往存放這個新增PDF檔案的資料夾中,檔案名稱是由這張發票的號碼命名。

t54_7_pdf_in_finder

連續點擊這個檔案,打開預覽程式來閱覽它(或是你也可以選擇其他應用程式進行瀏覽動作):

PDF preview in ios apps

繪製客製化Header與Footer

現在我們把這個範例多做一些延伸,添加客製化的內容到列印頁面的header與footer,畢竟,這是我們繼承UIPrintPageRenderer這個class的原因,談到客製化內容,是指非HTML templates的部分,它們不會與其他的HTML內容一起呈現,我們想要做的,就是添加”Invoice”到頁面的右上方(當作header),另外,添加”Thank you!”到畫面的下方(footer),且字串上方有一條水平線,下方顯示圖片清楚表達我們預期的成果:

t54_9_pdf_with_header_footer

在我們檢視header和footer顯示的細節之前,我們必須先指定height值給它們,打開CustomPrintPageRenderer.swift,添加下列兩行程式碼到init()函式內(請注意,上述屬性都來自於UIPrintPageRenderer這個class):

override init() {
    ...

    self.headerHeight = 50.0
    self.footerHeight = 50.0
}

我們必須先對header進行設定,接下來,請覆寫下列這個函式,它原本是被定義在UIPrintPageRenderer這個class:

override func drawHeaderForPageAtIndex(pageIndex: Int, inRect headerRect: CGRect) {

}

我們將會在這個函式的body內進行下面這些步驟:

  1. 我們將指定文字繪製在header上(“Invoice”這個字樣)。
  2. 同時,在這邊設定一些屬性,包含:文字格式,像是字型,顏色,以及字母間的距離。
  3. 設定的格式後,計算顯示文字內容所需的矩形空間大小,並且指定頁面距離右側邊界的offset值。
  4. 接下來,我們必須決定從哪個位置開始繪製作業。
  5. 最終,開始進行文本繪製作業。

這裡我們將上述的動作轉換為程式碼,下面會附帶comments讓每一行更容易理解:

override func drawHeaderForPage(at pageIndex: Int, in 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] as [String : Any]
    
    // Calculate the text size.
    let textSize = getTextSize(text: headerText as String, font: nil, textAttributes: textAttributes as [String : AnyObject]!)
    
    // 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.draw(at: CGPoint(x: pointX, y: pointY), withAttributes: textAttributes)
}

有一件事我沒有在上方的程式碼中提及,就是關於getTextSize(...)這個函式的使用,你可能猜到了,它是另一個客製化的函式,用來計算並回傳文字框的規格,它被寫在獨立的函式中,因為,我們也會在繪製footer時使用到它。

下面就是getTextSize(...)函式:

func getTextSize(text: String, font: UIFont!, textAttributes: [String: AnyObject]! = nil) -> CGSize {
    let testLabel = UILabel(frame: CGRect(x: 0.0, y: 0.0, width: self.paperRect.size.width, height: 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
}

上面程式碼就是一般用來計算置放文字框架規格的方式,先隨意置放一個的label,再設定簡單的文字或attributed text,再讓sizeToFit()這個函式幫我們計算真正需要的大小。

現在我們移動到這個頁面的footer,接下來的步驟跟之前非常像,所以不需要在替每一段程式碼中寫上註解,只在這邊做一點簡單的提醒,在下方程式碼中,text被水平排放在中間位置,文字顏色是不同的,且字母間是緊密排列的:

override func drawFooterForPage(at pageIndex: Int, in footerRect: CGRect) {
    let footerText: NSString = "Thank you!"
    
    let font = UIFont(name: "Noteworthy-Bold", size: 14.0)
    let textSize = getTextSize(text: 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.draw(at: CGPoint(x: centerX, y: centerY), withAttributes: attributes)

}

這段code會產生”Thank you!”字串,但是目前仍未添加區隔線在它上方,因此,必須再額外實作一個方法:

override func drawFooterForPageAtIndex(pageIndex: Int, inRect footerRect: CGRect) {
    ...

    // Draw a horizontal line.
    let lineOffsetX: CGFloat = 20.0
    let context = UIGraphicsGetCurrentContext()
    context!.setStrokeColor(red: 205.0/255.0, green: 205.0/255.0, blue: 205.0/255, alpha: 1.0)
    context!.move(to: CGPoint(x: lineOffsetX, y: footerRect.origin.y))
    context!.addLine(to: CGPoint(x: footerRect.size.width - lineOffsetX, y: footerRect.origin.y))
    context!.strokePath()
}

現在我們順利的生成一條水平線在上方!

在我前往這篇教程的最後一個部分之前,關於 header和footer還有一個需要注意的地方,如果你有觀察到,會看到這些文字都是歸屬NSString類別,而不是String物件,主要是因為draw(at:withAttributes:)函式繪製的內容是屬於NSString類別,若是你想要使用String物件代替,可以進行強制轉型,如下:

(text as! NSString).drawdraw(at:withAttributes:)

現在請再次運行這個app,並且預覽這個輸出的PDF文件,這時候它已經各包含一個header與footer。

更多應用介紹:預覽或是寄送PDF文件

在這裡,本文所要講的重點觀念已經到了尾聲,儘管如此,如果你想要將這個demo app放到實體裝置中運行,這裡還沒有一個方法可以直接看到輸出的PDF(雖然你可以透過Xcode做到,但是每次都要創建一個新的PDF文件仍是很煩人的),所以我們再添加兩個額外的功能至demo app:首先,讓使用者可以在PreviewViewController裡的web view預覽這個PDF文件,並且可以將它透過email傳送,我們透過alert controller呈現上述選項讓使用者點選,因為這裡已經超出原先設定的教學範圍,所以不在此著墨太多。

我們將在PreviewViewController.swift進行作業,請在專案的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)
    
    present(alertController, animated: true, completion: nil)
}

每個選項的動作目前還沒被定義,所以現在來實作吧,在preview選項中,我們將會使用NSURLRequest物件,把PDF檔案載入到web view裡面,如下圖:

let actionPreview = UIAlertAction(title: "Preview it", style: UIAlertActionStyle.default) { (action) in
    if let filename = self.invoiceComposer.pdfFilename, let url = URL(string: filename) {
        let request = URLRequest(url: url)
        self.webPreview.loadRequest(request)
    }
}

在email寄送的部分,請新增一個函式,它會將附加檔案加到這個郵件內:

func sendEmail() {
    if MFMailComposeViewController.canSendMail() {
        let mailComposeViewController = MFMailComposeViewController()
        mailComposeViewController.setSubject("Invoice")
        mailComposeViewController.addAttachmentData(NSData(contentsOfFile: invoiceComposer.pdfFilename)! as Data, mimeType: "application/pdf", fileName: "Invoice")
        present(mailComposeViewController, animated: true, completion: nil)
    }
}

不要忘記在檔案的上方,添加下圖這行程式碼,如此一來,你就可以使用MFMailComposeViewController:

import MessageUI

回到showOptionsAlert()函式,讓我們完成actionPreview這個動作,可以參考下圖:

let actionEmail = UIAlertAction(title: "Send by Email", style: UIAlertActionStyle.default) { (action) in
    DispatchQueue.main.async {
        self.sendEmail()
    }
}

到此,我們幾乎已經完成了,但還忽略了一件事,當PDF檔案被輸出至文件目錄後,alert controller應該要顯示出來,因此,必須呼叫showOptionsAlert()函式,前往exportToPDF(...)這個IBAction,添加下圖中這一行程式碼:

@IBAction func exportToPDF(_ sender: AnyObject) {
    ...

    showOptionsAlert()
}

完成了!現在你可以將這個app運行在裝置內,並且使用已經輸出的PDF檔案。

t54_10_after_export_alert

總結

不論現在或未來會出現哪些PDF檔案建立的技巧,本篇教程所呈現的方法仍是一個標準、有彈性而且安全的PDF文件輸出方式,在大部分的案例都很實用,而它只有一個缺陷,需要使用HTML templates去生成真正的內容,對我來說,相對於得到的成果,付出的成本很低,我深深相信,儘管它需要面對HTML程式碼,以及處理placeholder替換作業,但比起花費很長時間去手動繪製一個PDF檔,這個方式會是比較吸引人的。此外,本文中的PDF繪製程式碼是很標準的範例,demo app只要做些微調整就可以達到很理想的成效,希望你喜歡這篇文章教給你的技巧,並將它實際使用在你自己的專案中,感謝你的觀看,快樂的體驗PDF文檔輸出作業吧!

你可以在Github.com上面看到完整的專案內容

譯者簡介:陳奕先-過去為平面財經記者,專跑產業新聞,2015年起跨進軟體開發世界,希望在不同領域中培養新的視野,於新創學校ALPHA Camp畢業後,積極投入iOS程式開發,目前任職於國內電商公司。聯絡方式:電郵[email protected]

FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS

原文How to Generate PDF using HTML Templates and UIPrintPageRenderer in iOS

作者
Gabriel Theodoropoulos
資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。