[Swift] iOSで帳票を作成してAirPrintプリンタに出力する

# 概要

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141950/7e7296f4-3ccc-5b11-f0f1-2f79ffeecf4f.png" alt="" width="50%" height="50%">

iOSで帳票の作成、具体的にはPDFの作成〜確認ダイアログ等なしでの印刷ができるのか調べたところ一応できそうだったので、ほとんど既存記事の切り貼りですが備忘として残しておきます。

プリンタはAirPrint対応機種であれば問題ないと思われますが、手元ではシミュレータでしか確認していないのでご注意ください。

また、あくまで帳票を出力するための要素が揃っていることを調べただけなので、具体的な整形済みの帳票サンプルなども残念ながらありません。

# 印刷

以下の記事を参考にしつつ、SwiftUIで実装していきます。

- [[Swift]UIPrinterPickerControllerを使ってAirPrint印刷する方法](https://qiita.com/SatoTakeshiX/items/264dd293efeae2fdef11)

基本的には

- プリンタ選択画面をモーダル表示
- プリンタを選択 (ここでプリンタのURLを取得できる)
- 選択したプリンタに印刷

という流れになりますが、あらかじめプリンタのURLが分かっている状況であれば、URLから直接UIPrinterオブジェクトを作成できる (プリンタを特定できる) ため、画面を開いた瞬間に確認なしで印刷を実行するようなこともできます。

ここでは、最初にプリンタ選択画面を表示してプリンタURLを取得し、以降は変更がなければそのプリンタに出力するような流れを考えます。
実際のアプリであれば設定画面に設置するのがよいと思いますが、簡単のため同じ画面内にプリンタ選択ボタンと印刷ボタンを設置しています。

ContentViewはこんな感じです。

```swift
/// プリンタを選択してPDFを印刷する
struct ContentView: View {
    @ObservedObject var printerPickerModel = PrinterPickerModel()
    @ObservedObject var printModel = PrintModel()
    /// printingItemの型はNSURL・NSData・UIImageのいずれか
    var printingItem = createPDFData()
    
    var body: some View {
        VStack {
            Text(printerPickerModel.message)
            Text(printerPickerModel.printerUrl?.absoluteString ?? "")
            Button(action: printerPickerModel.showPrinterPicker){
                Text("プリンタを選択する").font(.largeTitle)
            }
            Button(action: {
                printModel.printout(printerUrl: printerPickerModel.printerUrl, printingItem: printingItem)
            }){
                Text("印刷する").font(.largeTitle)
            }.disabled(printerPickerModel.printerUrl == nil)
            Text(printModel.message)
            PDFViewRepresentable(data: printingItem)
        }
        .padding()
    }
}
```

PDFは実際には色々な値を埋め込むことになるでしょうが、ここではやはり簡単のため固定データとしています。
`printingItem` には後述する方法で作成したPDFを指定できるほか、画像を指定したり、それらを指すURLを指定することもできます。



## プリンタシミュレータの導入

一旦、前記記事でも紹介されているプリンタシミュレータを導入しておきます。

現在は [ダウンロードとリソース - Xcode](https://developer.apple.com/jp/xcode/resources/) > [その他のダウンロード (More Downloads - Apple Developer)](https://developer.apple.com/download/all/?q=xcode) からダウンロードできる「Additional Tools for Xcode」の「Hardware」ディレクトリに Printer Simulator.app が入っています。

このシミュレータを起動しておくと、iPhoneシミュレータからプリンタとして認識できるだけでなく、Macと同じネットワークにある実機iPhoneからもプリンタとして認識できたりします。(ポートが開いていれば)

## プリンタの選択

前記記事の `showPrinterPicker()` 関数を参考に、プリンタ選択画面を ObservableObject として実装します。
プリンタを選択すると `printerUrl` に値が格納されるので、これを参照して印刷を行います。

```swift
/// プリンタ選択画面を表示して選択されたプリンタのURLをセットする
class PrinterPickerModel: ObservableObject {
    @Published private(set) var printerUrl: URL?
    @Published private(set) var message = ""
    
    func showPrinterPicker() {
        let printerPicker = UIPrinterPickerController(initiallySelectedPrinter: nil)
        printerPicker.present(animated: true, completionHandler:
            {
                printerPickerController, userDidSelect, error in
                if let error = error {
                    self.message = "プリンタ選択時にエラーが起きました: \(error)"
                } else {
                    // 選択したUIPrinterを取得する
                    if let printer: UIPrinter = printerPickerController.selectedPrinter {
                        self.printerUrl = printer.url
                        self.message = "プリンタが選択されました"
                    }
                    // キャンセル押下時もelseを通るので
                    // メッセージなどは設定しない方が自然そう
                }
            }
        )
    }
}
```


## 印刷の実行

またまた前記記事の `printToPrinter(printer: UIPrinter)` 関数を参考に、印刷処理を ObservableObject として実装します。
折角なので印刷対象を引数で渡せるようにし、簡単なエラー処理も追加しました。

```swift
/// プリンタに指定のデータを出力する
class PrintModel: ObservableObject {
    @Published private(set) var message = ""
    /// 指定されたプリンタに指定されたデータを印刷する
    func printout(printerUrl: URL?, printingItem: Any) {
        guard let printerUrl = printerUrl else {
            self.message = "プリンタが選択されていません"
            return
        }
        let printer = UIPrinter(url: printerUrl)
        let printIntaractionController = UIPrintInteractionController.shared
        let info = UIPrintInfo(dictionary: nil)
        info.jobName = "Sample Print"
        info.orientation = .portrait
        printIntaractionController.printInfo = info
        printIntaractionController.printingItem = printingItem
        printIntaractionController.print(to: printer, completionHandler: {
            controller, completed, error in
            guard error == nil else {
                self.message = "印刷時にエラーが起きました: \(error!)"
                return
            }
            if completed {
                self.message = "印刷が完了しました"
            } else {
                // ここは通らなそう (completedがfalseなのはerrorがnot nilの時だけな気がする)
                self.message = "印刷が完了しませんでした"
            }
        })
    }
}
```

`printingItem` の型が `Any` なのは前述の通り画像やURLを渡したりもできるためです。
詳しくは [UIPrintInteractionController printingItem](https://developer.apple.com/documentation/uikit/uiprintinteractioncontroller/1618157-printingitem) を参照してください。


# PDFの作成

大きく分けて

- HTMLをPDFに変換する方法
- Core Graphics (Quartz 2D) で描画する方法

の2種類があります。

基本的には前者の方が簡単だと思いますが、後者の方が正確な描画ができるのではないかと勝手に想像しているので、両方紹介しておきます。


## プレビュー表示

その前に、作成中のPDFをいちいち印刷したりファイルに出力したりして確認するのが面倒なので、プレビュー機能を付けておきます。

```swift
struct PDFViewRepresentable: UIViewRepresentable {
    var data: Data
    
    func makeUIView(context: Context) -> PDFView {
        let view = PDFView()
        view.document = PDFDocument(data: data)
        view.autoScales = true
        return view
    }
    
    func updateUIView(_ uiView: PDFView, context: Context) {
    }
}
```

これで `VStack{}` 配下に `PDFViewRepresentable(data: printingItem)` などと置くことでプレビューが表示されます。

## HTMLをPDFに変換する

以下のコードをそのまま使わせてもらいます。

- [nyg/iOSCreatePDF.swift](https://gist.github.com/nyg/b8cd742250826cb1471f)

PDFをファイルに書き出している `// 5. Save PDF file` 以下の部分を端折って、代わりに `pdfData` を返すような関数にしてやれば、そのまま印刷部分に渡すことができます。
プレビュー部分にも同じデータを渡したいので、型は `NSMutableData` から `Data` にキャストしておきます。

```swift
func createPDFDataFromHTML() -> Data {
    // 1. Create a print formatter
    let html = "<b>Hello <i>World!</i></b>"
    let fmt = UIMarkupTextPrintFormatter(markupText: html)

    (中略)

    UIGraphicsEndPDFContext();

    return pdfData as Data
}
```

ここではHTMLをベタ書きしていますが、もちろん引数で渡すような作りにすれば汎用的に使えます。

これを

```swift
var printingItem = createPDFDataFromHTML()
```

としてやれば、プレビュー表示とボタン押下での印刷が確認できます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141950/e0c6f64b-f4ef-ed25-32e4-3044e056404a.png" alt="" width="50%" height="50%">


## Core Graphics (Quartz 2D) でPDFを描画する

これも以下のコードをそのまま使わせてもらいます。

- [SwiftでPDFを作成する](https://swiswiswift.com/2020-04-03/)

上記ページの `createPDFData()` はランダムな線を描画しているものですが、代わりに頑張って位置を調節した線を引くことで帳票が作成できるでしょう。

一旦この関数をそのままコピペして

```swift
var printingItem = createPDFData()
```

とすることでプレビュー表示とボタン押下での印刷が確認できます。

# コード全体

以下に ContentView.swift の全体を貼っておきます。
他所様の記事のコードをここに丸コピペするのも何なので、PDF作成部分は少し改変した `createPDFData()` のみとして `createPDFDataFromHTML()` は省略しておきます。

```swift:ContentView.swift
import SwiftUI
import PDFKit

/// プリンタを選択してPDFを印刷する
struct ContentView: View {
    @ObservedObject var printerPickerModel = PrinterPickerModel()
    @ObservedObject var printModel = PrintModel()
    /// printingItemの型はNSURL・NSData・UIImageのいずれか
    var printingItem = createPDFData()
    
    var body: some View {
        VStack {
            Text(printerPickerModel.message)
            Text(printerPickerModel.printerUrl?.absoluteString ?? "")
            Button(action: printerPickerModel.showPrinterPicker){
                Text("プリンタを選択する").font(.largeTitle)
            }
            Button(action: {
                printModel.printout(printerUrl: printerPickerModel.printerUrl, printingItem: printingItem)
            }){
                Text("印刷する").font(.largeTitle)
            }.disabled(printerPickerModel.printerUrl == nil)
            Text(printModel.message)
            PDFViewRepresentable(data: printingItem)
        }
        .padding()
    }
}

/// 画面でプレビュー表示するためにPDFView (UIView)をViewに変換
struct PDFViewRepresentable: UIViewRepresentable {
    var data: Data
    
    func makeUIView(context: Context) -> PDFView {
        let view = PDFView()
        view.document = PDFDocument(data: data)
        view.autoScales = true
        return view
    }
    
    func updateUIView(_ uiView: PDFView, context: Context) {
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

/// プリンタ選択画面を表示して選択されたプリンタのURLをセットする
class PrinterPickerModel: ObservableObject {
    @Published private(set) var printerUrl: URL?
    @Published private(set) var message = ""
    
    func showPrinterPicker() {
        let printerPicker = UIPrinterPickerController(initiallySelectedPrinter: nil)
        printerPicker.present(animated: true, completionHandler:
            {
                printerPickerController, userDidSelect, error in
                if let error = error {
                    self.message = "プリンタ選択時にエラーが起きました: \(error)"
                } else {
                    // 選択したUIPrinterを取得する
                    if let printer: UIPrinter = printerPickerController.selectedPrinter {
                        self.printerUrl = printer.url
                        self.message = "プリンタが選択されました"
                    }
                    // キャンセル押下時はelseを通るがメッセージなどは設定しない方が自然
                }
            }
        )
    }
}

/// プリンタに指定のデータを出力する
class PrintModel: ObservableObject {
    @Published private(set) var message = ""
    /// 指定されたプリンタに指定されたデータを印刷する
    func printout(printerUrl: URL?, printingItem: Any) {
        guard let printerUrl = printerUrl else {
            self.message = "プリンタが選択されていません"
            return
        }
        let printer = UIPrinter(url: printerUrl)
        let printIntaractionController = UIPrintInteractionController.shared
        let info = UIPrintInfo(dictionary: nil)
        info.jobName = "Sample Print"
        info.orientation = .portrait
        printIntaractionController.printInfo = info
        printIntaractionController.printingItem = printingItem
        printIntaractionController.print(to: printer, completionHandler: {
            controller, completed, error in
            guard error == nil else {
                self.message = "印刷時にエラーが起きました: \(error!)"
                return
            }
            if completed {
                self.message = "印刷が完了しました"
            } else {
                // ここは通らなそう (completedがfalseなのはerrorがnot nilの時だけな気がする)
                self.message = "印刷が完了しませんでした"
            }
        })
    }
}

/// PDFデータを作成してData型で返す
/// この戻り値は PDFDocument() のコンストラクタ引数に指定したり
/// UIPrintInteractionControllerのprintingItemプロパティに指定したりできる
func createPDFData() -> Data {
    let pdfMetaData = [
        kCGPDFContextCreator: "@example",
        kCGPDFContextAuthor: "example.com"
    ]
    let format = UIGraphicsPDFRendererFormat()
    format.documentInfo = pdfMetaData as [String: Any]
    
    // A4サイズ
    let pdfWidth: CGFloat = 2100
    let pdfHeight: CGFloat = 2900
    let pageRect = CGRect(x: 0, y: 0, width: pdfWidth, height: pdfHeight)
    let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)
    
    return renderer.pdfData(actions: renderReport)
}

/// UIGraphicsPDFRendererContextを使って帳票を描画する
func renderReport(context: UIGraphicsPDFRendererContext) {
    // これはUIGraphicsPDFRendererContextにしかない
    context.beginPage()
    
    let text = "Hello World!"
    let attributes = [
        NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 72)
    ]
    text.draw(at: CGPoint(x: 0, y: 0), withAttributes: attributes)
    
    let linePath = UIBezierPath()
    linePath.move(to: CGPoint(x: 0, y: 100))
    linePath.addLine(to: CGPoint(x: 1000, y: 100))

    let layer = CAShapeLayer()
    layer.path = linePath.cgPath
    layer.fillColor = UIColor.black.cgColor
    layer.strokeColor = UIColor.black.cgColor
    layer.lineWidth = 4
    layer.render(in: context.cgContext)
}

```