💬

iOSのPDFKitを利用してPDFを編集する

2023/11/16に公開

こんにちは!アルダグラムでエンジニアをしているbingyiです

直近KANNAで開発した機能ではPDF編集を実装しました、今回の記事はiOSで実際のPDFファイルに線をどう書き込みを紹介します。

まずはPDFKitを簡単に紹介します、PDFKitはiOSとmacOS用のフレームワークで、PDFドキュメントの表示、編集、アノテーションが可能です。主要なクラスには、PDFの内容を表すPDFDocument、PDFページを表すPDFPage、そしてPDFの表示に使われるPDFViewがあります。このフレームワークを使用することで、アプリ内でPDFの操作が容易になります。

PDFKit関連APIがまだUIKitが主に使われてるので今回PDF編集の実装もUIKitを利用します、この記事のため参考用のリポジトリも用意しました、興味がある方自分で試しても良いです

PDFの表示

PDFを編集する前にまずPDFを表示します、ここではPDFViewを使ってPDFファイルを表示します

// pdfViewを初期化した後 ViewControllerのviewに追加します
let pdfView = PDFView()
pdfView.displayMode = .singlePageContinuous
pdfView.usePageViewController(false)
pdfView.displayDirection = .vertical
pdfView.autoScales = true

view.addSubview(pdfView)
NSLayoutConstraint.activate([
    pdfView.topAnchor.constraint(equalTo: view.topAnchor),
    pdfView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    pdfView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    pdfView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])

// ローカルのPDFファイルをロードします
let document = PDFDocument(url: documentURL)
pdfView.document = document

このようにPDFファイルをViewで表示することができるようになります

PDFPage上でCanvasの表示

PDFを編集する時各ページの上で手書きの入力と書いてるものを表示するViewが必要です、ここはiOS16から利用できるPDFPageOverlayViewProviderを利用すれば指定するPDFページの上で特定のViewを表示することができます、PDFPageOverlayViewProviderに関する情報がこちらのWWDC sessionを参考しても良いです。

PDFPageで独自のViewを表示させるためいくつの手順が必要です

まずはCanvasViewの作成です。CanvasViewがUIViewを継承する必要があります、ここでは後で確認しやすくため背景がorange色に設定しました

final class CanvasView: UIView {
    init() {
        super.init(frame: .zero)
        backgroundColor = .orange
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

次は独自のPDFPageの作成です、ここではPDFPageを継承してCanvasPDFPageを作成しました、そしてCanvasPDFPageがCanvasViewのプロパティを持つようにします

final class CanvasPDFPage: PDFPage {
    var canvasView: CanvasView?
}

次はPDFDocumentDelegateの実装です、CanvasPDFViewControllerがPDFDocumentDelegate中のclassForPageを実現し、そして初期化したPDFDocumentに渡します、ここの目的はPDFDocumentがPDFPageではなく指定したクラスCanvasPDFPageを利用してPageを生成することです

extension CanvasPDFViewController: PDFDocumentDelegate {
    func classForPage() -> AnyClass {
        return CanvasPDFPage.self
    }
}

let document = PDFDocument(url: documentURL)
document?.delegate = self // (selfはCanvasViewController)

次はPDFPageOverlayViewProvideの実装です、ここは各PDFPageの上にCanvasViewを載せるため、指定したPDFPageに対して相応のCanvasViewを返します

final class CanvasProvider: NSObject, PDFPageOverlayViewProvider {
    // すでに作成したCanvasViewをここに保存して、PDFPageを表示するとき、ここから取り出す
    private var pageToCanvasViewMapping = [PDFPage: CanvasView]()

    func pdfView(_ view: PDFView, overlayViewFor page: PDFPage) -> UIView? {
        let canvasView: CanvasView
        
        if let view = pageToCanvasViewMapping[page] {
            canvasView = view
        } else {
            let view = CanvasView()
            view.isUserInteractionEnabled = false
            pageToCanvasViewMapping[page] = view
            canvasView = view
        }

        (page as? CanvasPDFPage)?.canvasView = canvasView
        return canvasView
    }

    func pdfView(_ pdfView: PDFView, willDisplayOverlayView overlayView: UIView, for page: PDFPage) {}

    func pdfView(_ pdfView: PDFView, willEndDisplayingOverlayView overlayView: UIView, for page: PDFPage) {}
}

最後はCanvasProviderをPDFViewのpageOverlayViewProviderに渡します、ここで一つの注意点があります、pageOverlayViewProviderに渡すタイミングはPDFDocumentをロードする前にしないと行けないです

private let canvasProvider = CanvasProvider()
pdfView.pageOverlayViewProvider = canvasProvider

もう一回画面を開くと今回PDFPageの上にちゃんとCanvasViewが表示されます

Canvasで手書きの入力

続いてCanvasViewで手書きものを入力する実装をします、今回がユーザーのタッチイベントを受け取って、そしてUIBezierPathに変換してViewの上で表示させます

まず手で書き込んだものを表示させるLayerをCanvasViewに追加します

final class CanvasView: UIView {
    private let editingLayer: CAShapeLayer = {
        let layer = CAShapeLayer()
        layer.strokeColor = UIColor.orange.cgColor
        layer.lineWidth = 3
	layer.fillColor = nil
        return layer
    }()
    
    init() {
        super.init(frame: .zero)
        layer.addSublayer(editingLayer)
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        editingLayer.frame = bounds
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

次はユーザーのタッチイベントをハンドリングします、まずは独自のUIGestureRecognizerを実装します、ここではユーザーのタッチイベントをUIBezierPathに変換します

protocol PDFDrawGestureRecognizerDelegate: AnyObject {
    // 編集中のUIBezierPathをdelegateに渡します
    func didUpdateEditingPath(path: UIBezierPath)
     // 編集完了のUIBezierPathをdelegateに渡します
    func didFinishEditingPath(path: UIBezierPath)
}

final class PDFDrawGestureRecognizer: UIGestureRecognizer {
    private var editingPath: UIBezierPath?
    weak var editPathDelegate: PDFDrawGestureRecognizerDelegate?
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event!)
        
        if let touch = touches.first,touch.type == .direct,
           let numberOfTouches = event?.allTouches?.count,
           numberOfTouches == 1 {
            state = .began
            let location = touch.location(in: self.view)
            gestureRecognizerBegan(location)
        } else {
            state = .failed
        }
    }
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        state = .changed
        guard let location = touches.first?.location(in: self.view) else { return }
        gestureRecognizerMoved(location)
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let location = touches.first?.location(in: self.view) else  {
            state = .ended
            return
        }
        gestureRecognizerEnded(location)
        state = .ended
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
        state = .failed
    }
}

extension PDFDrawGestureRecognizer {
    func gestureRecognizerBegan(_ location: CGPoint) {
        editingPath = UIBezierPath()
        editingPath?.lineJoinStyle = .round
        editingPath?.lineCapStyle = .round
        editingPath?.move(to: location)
    }
    
    func gestureRecognizerMoved(_ location: CGPoint) {
        guard let path = editingPath else { return }
        path.addLine(to: location)
        path.move(to: location)
        editPathDelegate?.didUpdateEditingPath(path: path)
    }
    
    func gestureRecognizerEnded(_ location: CGPoint) {
        guard let path = editingPath else { return }
        path.addLine(to: location)
        path.move(to: location)
        editPathDelegate?.didFinishEditingPath(path: path)
    }
}

そしてCanvasViewにこのPDFDrawGestureRecognizerを追加して、さらにPDFDrawGestureRecognizerDelegateを実装します。

final class CanvasView: UIView {
   // 編集終了後UIBezierPathを保存用のプロパティー
   private var editFinishedPath: UIBezierPath?
   //.........	
   private lazy var drawGesture: PDFDrawGestureRecognizer = {
        let gesture = PDFDrawGestureRecognizer()
        // gestureにDelegateを設定する
        gesture.editPathDelegate = self
        return gesture
    }()
    
    init() {
        super.init(frame: .zero)
        layer.addSublayer(editingLayer)
	// gestureをViewに追加する
        addGestureRecognizer(drawGesture)
    }
    //...........
}

extension CanvasView: PDFDrawGestureRecognizerDelegate {
    func didUpdateEditingPath(path: UIBezierPath) {
        editingLayer.path = path.cgPath
    }
    
    func didFinishEditingPath(path: UIBezierPath) {
        editingLayer.path = path.cgPath
	editFinishedPath = path
    }
}

すると、書き込んだものをCanvasViewに反映することができるようになりました

書き込んだものをPDFに保存

最後は書き込んだものを新しいPDFファイルに保存します、書き込んだものをPDFに出力するためPDFAnnotationというクラスを使う必要があります、ここではPDFAnnotationというクラスを継承して新しいPathAnnotationを作成します。

final class PathAnnotation: PDFAnnotation{
    private let path: UIBezierPath
    private let stencilColor: UIColor = UIColor.orange
    private let stencilWidth: CGFloat = 3
    
    // ここで先ほど生成したUIBezierPathを受け取る
    init(bounds: CGRect, path: UIBezierPath) {
        self.path = path
        super.init(bounds: bounds, forType: .ink, withProperties: nil)
    }

    override func draw(with box: PDFDisplayBox, in context: CGContext) {
        super.draw(with: box, in: context)
    }
 
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

続いてPDFAnnotationのdraw関数を実装します、draw関数がPDFで指定した領域でCGContextを用いてコンテンツを描画する関数です。

ここでは一つ注意点があります、PDFの座標システムがViewの座標システムが異なるところがあります、PDFのY軸が上方向です、ViewのY軸が下方向です、ですのでdraw関数を実装する際に、PDFの座標に合わせるため縦方向の座標を逆にする必要があります

override func draw(with box: PDFDisplayBox, in context: CGContext) {
    let localPath = path.copy() as! UIBezierPath
    super.draw(with: box, in: context)
       
    UIGraphicsPushContext(context)
    context.saveGState()

    // !! ここでcontextを縦方向でひっくり返します !!     
    context.concatenate(CGAffineTransformMake(1, 0, 0, -1, 0.0, 2 * bounds.origin.y + bounds.size.height))
    
    localPath.lineWidth = stencilWidth
    stencilColor.setStroke()
    localPath.stroke(with: CGBlendMode.sourceOut, alpha: 1.0)
        
    context.restoreGState()
    UIGraphicsPopContext()
}

draw関数を完成した後、保存する際にPDFPageにそのAnnotationを追加する必要があります。

ここでCanvasViewの中でPathAnnotationを書き出しの関数とCanvasをリセットの関数を追加します

final class CanvasView: UIView {
	  // ........
    // 生成したUIBezierPathをPDFAnnotationnに変更した後返します
    func exportPathAnnotation() -> PDFAnnotation? {
        guard let path = editFinishedPath else { return nil }
        return PathAnnotation(bounds: bounds, path: path)
    }
    
        // Canvasをリセット
    func resetCanvas() {
        editFinishedPath = nil
        editingLayer.path = nil
    }
    // ..........
}

その後書き出したPDFAnnotationをPDFPageに保存します、ここでCanvasProviderでsave関数を実装します

final class CanvasProvider: NSObject, PDFPageOverlayViewProvider {
    // .........
    func save() {
        for (page, canvas) in pageToCanvasViewMapping {
            if let annotation = canvas.exportPathAnnotation() {
                canvas.resetCanvas()
		page.addAnnotation(annotation)
           }
       }
    }
    // .........
}

最後が編集したPDFDocumentを保存すれば終わります

func savePDF() {
    canvasProvider.save()
    let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
    let fileURL = documentDirectory.appendingPathComponent("saved.pdf")
    // PDFPageがAnnotationを追加ずみの状態で指定したfileURLに書き出し
    pdfView.document?.write(to: fileURL)
}

まとめ

今回はiOSのPDFKitを利用して線の書き込みを実際に実装しました、他にはテキスト、画像などもPDFに出力することができます、その場合は独自なAnnotationを実装して、CGContextでテキスト、画像などを描画すれば線と同じようにPDFファイルに出力することができます、これに関してもっと詳しい情報が欲しい場合PDFKit公式のドキュメントを参照してください。

このブログを興味持っていただきありがとうございました。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion