💬

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

2023/11/16に公開

今回の記事はiOSで実際のPDFファイルに線をどう書き込みを紹介します。

ソースコードはこちらで公開されてますので興味ある方チェックしてください。
https://github.com/Cookiezby/ios-pdf-edit-example

まずは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公式のドキュメントを参照してください。

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

Discussion