你曾經被要求在你的app內建立PDF文件嗎?如果你目前仍未寫過這類的應用程式,那你之前曾經想過如何製作這個功能嗎?
本篇教程透過提問的方式來起頭,上述這些問題都是關於本文所要探討的,而在iOS中建立PDF文件通常看似是條通往地獄的道路,但是其實你可以避開它,做為一個開發者,必須要手握許多資源,建立多元的解決方案,透過不同方式在可控制成本內達成你的目標,我必須承認,手動繪製PDF頁面可能會是相當艱辛的過程(根據開發需求),而且也是一項降低生產力的任務,它需要計算points,增添線條,設定顏色、insets、offsets等等,儘管這可能是一項很有趣的過程(對某些人來說),但若是需要繪製的文件太過複雜,這無疑會是個繁雜的工作。
這篇教程中,我的目標是呈現給讀者一個不同的PDF文件生成方式,且這些方式都將會比過去手動繪製更簡單,這套解決方案是建立在使用HTML templates之上,概括可歸納在下列這些步驟:
- 根據需要繪製的PDF文件內容或格式創建HTML templates。
- 使用這些HTML templates去生成真正的內容(可以選擇把它呈現在web view)。
- 把你想要製作的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中往左滑即可完成刪除動作,下列圖片會示範這個頁面所呈現的樣貌:
就像我說的,想要生成一組新發票只要點擊+按鈕即可,而這個動作將會把我們帶往CreatorViewController
,如下圖:
一組發票資訊要被列印輸出前,一般來說要先填入各式各樣的數值,有一部分是在這個view controller被手動設定,一部份則被自動計算,也有一些是被固定設置在程式碼裡面,具體來說,demo app裡面可以被手動添加的數值包括:
- recipient info可以手動記載該發票的收件人資訊。這部分就呈現在新增頁面的灰色區塊
- 發票消費細項, 這裡分成兩個區塊: description 可以記載消費服務項目,而price 則是紀錄消費金額。但是在這裡我們不做增值稅這類較繁雜的計算。一個新品項可以透過該頁面下方toolbar中的+按鈕來新增(稍後的篇幅會有更多介紹)。
會被自動被計算的數值包括:
- 發票號碼 (號碼將會顯示在navigation bar的title).
- 發票的消費總數 (顯示在bottom toolbar的左側).
最後,我們將發票的部分資訊固定設置的內容包含:
- 發件人資訊用來顯示的是發行者的資訊
- 發票的due date (我們在範例中不會用到,但你可以對它進行設定)。
- 付款方式。
- 這組發票的logo。
關於發票內的項目,AddItemViewController
提供一個簡單的資料輸入方式,內部有兩個textfields供使用者填入預期的數值,save按鈕則能將填入資訊添加進該組發票內,並返回到前一個展示頁面。
所有的發票項目會被加進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文件:
- invoice.html
- last_item.html
- 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#
你可以在上述的三個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,請跟著指示一同完成新檔案的建立工作。
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
}
MARKDOWN_HASH9be45a8449d308e6aba6ff17060c2c17MARKDOWN_HASH
和MARKDOWN_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內容。
接下來,我們新增一個新的方法,請參考下列動作:
- 初始化
invoiceComposer
這個物件。 - 呼叫
renderInvoice(...)
這個函式,去產生發票的HTML內部程式碼。 - 並且將這個HTML載入至web view裡面。
- 我們把回傳的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裡面,請參考下圖:
準備輸出工作
目前一半的作業我們都已經完成了,可以進入到下一步輸出程序了,它讓我們可以將發票轉為PDF檔,為了達到這個成果,這邊必須來使用一個特殊的class,這個class被命名為UIPrintPageRenderer
,如果你從未使用或沒有聽過它,讓我在這邊做個簡短介紹,這個class可以將內容轉送至下一步輸出工作(製作成一份文件或提供給AirPrint使用),這裡是官方文件頁面,請瀏覽該頁面獲得關於這個class的更多資訊。
<UIPrintPageRenderer
這個class提供許多的繪製方法,若在一些簡單的範例中,並不需要去覆寫這些函式。這些繪製的相關函式只能被 UIPrintPageRenderer
class的子類別覆寫,若是開發上需要更有彈性去控制頁面上的header或footer輸出內容,那我們可以付出額外的工作去滿足所需繪製功能,在這個demo app中,由於有客製化的header與footer繪製需求,所以我們會需要繼承這個類別。
所以,請再次回到Xcode,按照先前的步驟創建一個新的class,這裡請注意兩件事情:
- 請將它設定為
UIPrintPageRenderer
的subclass。 - 將它命名為
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按鈕:
點擊之後,即可將資料輸出為PDF,當所有動作完成以後,你可以在console內看到輸出檔案所置放的路徑,請複製這段路徑(沒有檔案名稱),並在視窗內打開Finder,請同時按下 Shift-Command-G鍵,將路徑貼到輸入框中,將會前往存放這個新增PDF檔案的資料夾中,檔案名稱是由這張發票的號碼命名。
連續點擊這個檔案,打開預覽程式來閱覽它(或是你也可以選擇其他應用程式進行瀏覽動作):
繪製客製化Header與Footer
現在我們把這個範例多做一些延伸,添加客製化的內容到列印頁面的header與footer,畢竟,這是我們繼承UIPrintPageRenderer
這個class的原因,談到客製化內容,是指非HTML templates的部分,它們不會與其他的HTML內容一起呈現,我們想要做的,就是添加”Invoice”到頁面的右上方(當作header),另外,添加”Thank you!”到畫面的下方(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內進行下面這些步驟:
- 我們將指定文字繪製在header上(“Invoice”這個字樣)。
- 同時,在這邊設定一些屬性,包含:文字格式,像是字型,顏色,以及字母間的距離。
- 設定的格式後,計算顯示文字內容所需的矩形空間大小,並且指定頁面距離右側邊界的offset值。
- 接下來,我們必須決定從哪個位置開始繪製作業。
- 最終,開始進行文本繪製作業。
這裡我們將上述的動作轉換為程式碼,下面會附帶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檔案。
總結
不論現在或未來會出現哪些PDF檔案建立的技巧,本篇教程所呈現的方法仍是一個標準、有彈性而且安全的PDF文件輸出方式,在大部分的案例都很實用,而它只有一個缺陷,需要使用HTML templates去生成真正的內容,對我來說,相對於得到的成果,付出的成本很低,我深深相信,儘管它需要面對HTML程式碼,以及處理placeholder替換作業,但比起花費很長時間去手動繪製一個PDF檔,這個方式會是比較吸引人的。此外,本文中的PDF繪製程式碼是很標準的範例,demo app只要做些微調整就可以達到很理想的成效,希望你喜歡這篇文章教給你的技巧,並將它實際使用在你自己的專案中,感謝你的觀看,快樂的體驗PDF文檔輸出作業吧!
你可以在Github.com上面看到完整的專案內容。
FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS
原文:How to Generate PDF using HTML Templates and UIPrintPageRenderer in iOS