html5

IndexedDBにblob保存されたPDFファイルを外部アプリに頼らずにJavascriptで表示(後編)

投稿日:2019年6月11日

はじめに

前回の続きです。

PDFをJavascriptでブラウザに表示するライブラリはMozillaの「PDF.js」がデファクトスタンダード、PDF内容をパースしてcanvasにレンダリングしていてテキスト選択も可能です。公式デモも素晴らしいです。


上記のデモを作成しているPDF.jsをnpmモジュールとして使うことが出来るのが同じくMozilla公式がリリースしているpdfjs-distです。

ただしこのモジュールはPDF処理を制御するクラスのみの提供になっていて、デモで行われているような全ページ表示、テキスト分離、選択、検索他諸々の機能は自分で実装することになります。

最低限の機能を実装する分には時間も掛からないのですが、公式デモレベルの機能レベルまで実装しようとすると時間が掛かります。出来るだけ楽をしたい怠惰な人間としては公式デモのようなビューワ機能を提供してくれるモジュールを使わせて頂きたいです。

ビューワ機能を提供してくれるnpmモジュールを選別する

「ng2-pdfjs-viewer」と「ng2-pdf-viewer」から選別します。
似た名前でまぎらわ(ry

npm trendsで比較してみると後者のng2-pdf-viewerの方がダウンロードされているようです。

https://www.npmtrends.com/ng2-pdfjs-viewer-vs-ng2-pdf-viewer

ただ今回は以下の理由から前者のng2-pdfjs-viewerを使うことにしました。

  • 最近の更新頻度がかなり多め
  • 使えるAngularのバージョンが広い
  • 公式デモのビューワ機能をそのまま組み込める

実装開始

ng2-pdfjs-viewerを使えるようにする

npmインストールします。3.2.9が入りました。

C:\src\ionic\pdfdl>npm install ng2-pdfjs-viewer
npm WARN ng2-pdfjs-viewer@3.2.9 requires a peer of @angular/core@^8.0.0 but none is installed. You must install peer dependencies yourself.
npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.9 (node_modules\fsevents):
npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.9: wanted {"os":"darwin","arch":"any"} (current: {"os":"win32","arch":"x64"})

+ ng2-pdfjs-viewer@3.2.9
added 1 package in 10.902s

次にnode_modules\ng2-pdfjs-viewer\pdfjsディレクトリを
src/assetsにコピーします。公式デモのWebリソースはこの中に入っています。

PDFビューワコンポーネントを作る

ionic-cliでpdf-viewerページを追加。angularのComponentになります。

C:\src\ionic\pdfdl>ionic g page pdf-viewer
> ng.cmd generate page pdf-viewer
CREATE src/app/pdf-viewer/pdf-viewer.module.ts (559 bytes)
CREATE src/app/pdf-viewer/pdf-viewer.page.html (129 bytes)
CREATE src/app/pdf-viewer/pdf-viewer.page.spec.ts (713 bytes)
CREATE src/app/pdf-viewer/pdf-viewer.page.ts (271 bytes)
CREATE src/app/pdf-viewer/pdf-viewer.page.scss (0 bytes)
UPDATE src/app/app-routing.module.ts (536 bytes)
[OK] Generated page!

src/app/pdf-viewer/pdf-viewer.page.htmlを編集。

<ion-header>
    <ion-toolbar color="dark">
        <ion-title>pdf-viewer</ion-title>
    </ion-toolbar>
</ion-header>

<ion-content>
    <ng2-pdfjs-viewer #pdfViewer></ng2-pdfjs-viewer>
</ion-content>

src/app/pdf-viewer/pdf-viewer.page.tsを編集

import { Component, ViewChild } from "@angular/core";
import { ActivatedRoute, ParamMap } from "@angular/router";

@Component({
    selector: "app-pdf-viewer",
    templateUrl: "./pdf-viewer.page.html",
    styleUrls: ["./pdf-viewer.page.scss"]
})
export class PdfViewerPage {

    // ng2-pdf-viewer
    @ViewChild("pdfViewer") pdfViewer;

    // 表示するPDFのBlobデータ
    content;

    constructor(private route: ActivatedRoute) {
        // 遷移してきたHome画面からのPDFのBlobパラメータを取得
        route.queryParamMap.subscribe((params: ParamMap) => {
            this.content = this.route.snapshot.queryParamMap.get("content");
        });
    }

    // PDFビューワページが表示されたらPDFを表示
    ionViewDidEnter() {
        this.pdfViewer.pdfSrc = this.content;
        this.pdfViewer.refresh();
    }
}

src/app/pdf-viewer/pdf-viewer.module.tsを編集。pdf-viewerモジュール内で先ほどインストールしたng2-pdfjs-viewerを使えるようにします。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
import { Routes, RouterModule } from "@angular/router";

import { IonicModule } from "@ionic/angular";

import { PdfViewerPage } from "./pdf-viewer.page";
import { PdfJsViewerModule } from "ng2-pdfjs-viewer";  ← 追加

const routes: Routes = [
    {
        path: "",
        component: PdfViewerPage
    }
];

@NgModule({
    imports: [
        CommonModule,
        FormsModule,
        IonicModule,
        RouterModule.forChild(routes),
        PdfJsViewerModule              ← 追加
    ],
    declarations: [PdfViewerPage]
})
export class PdfViewerPageModule {}

Home画面からPDFビューワに遷移させる

src/app/home/home.page.tsに以下を追加します。前編で「表示」ボタンがクリックされたらshowPdfメソッドを呼ぶようにしてあるのでshowPdfメソッドを実装します。

import { PdfViewerPage } from "../pdf-viewer/pdf-viewer.page"; ← 追加

@Component({
    selector: "app-home",
    templateUrl: "home.page.html",
    styleUrls: ["home.page.scss"]
})
export class HomePage {

    (snip)

    // 追加
    public showPdf(content: Blob) {
        // PDFのBlobデータをパラメータにしてpdf-viewerに遷移
        this.navController.navigateForward("/pdf-viewer", {
            queryParams: { content: content }
        });
    }

実装終わりです。

動作確認してみる

PC & Chromeで確認してみます。表示ボタンをクリックすると、PDF.jsのデモで使われているビューワでPDFが見れるようになりました。

スマホのブラウザでも同様の確認が出来ます。が、しかし・・・

Androidでピンチズームが出来ない

スマホだとズームはピンチイン、ピンチアウトで行うのが直感的ですよね。ですがPDF.jsのWebビューワリソースにはピンチ系のイベントハンドラが実装されていない為、ズームしようとしてもスマホで操作するには小さい「-」「+」をなんとか押すしかありません。これでは使い勝手が悪いです。

iPhone & Safariだとズームしますが、これはブラウザ自体のズームなのでPDF.jsビューワのコントロール部分までズームされてしまいます。

PDFを描画しているcanvas部分だけレンダリングするようにしたいです。

しょうがないので自前でピンチ処理を実装する

src/app/pdf-viewer/pdf-viewer.page.tsのionViewDidEnterメソッドを編集。ピンチイン、アウトを検知して画面上の「-」「+」ボタンを押してあげることでズームイン、アウトするようにしてみます。

    ionViewDidEnter() {
        // viewer.htmlのiframe
        let iframe = this.pdfViewer["iframe"].nativeElement;
        this.pdfViewer.pdfSrc = this.content;
        this.pdfViewer.refresh();

        // PDF読込完了
        iframe.onload = () => {
            // viewer.htmlのbody
            let iframeBody = iframe.contentWindow.document.body;

            // ピンチ操作記録オブジェクト
            let behavior = { ratio: 1, start: 0, end: 0 };

            // true : ピンチ操作中
            let active: boolean = false;

            // 二本指間の距離を算出
            let getDistance = function(e: TouchEvent) {
                return Math.sqrt(
                    Math.abs(
                        Math.pow(e.touches[0].pageX - e.touches[1].pageX, 2) -
                        Math.pow(e.touches[0].pageY - e.touches[1].pageY, 2)
                    )
                );
            };

            iframeBody.addEventListener(
                "touchstart",
                (e: TouchEvent) => {
                    // 二本指でタッチされたらピンチ方向検知開始
                    if (e.touches.length > 1) {
                        behavior.start = behavior.end = Math.round(
                            getDistance(e)
                        );
                        active = true;
                    }
                },
                false
            );

            iframeBody.addEventListener(
                "touchmove",
                (e: TouchEvent) => {
                    if (active && e.touches.length > 1) {
                        behavior.end = Math.round(getDistance(e));
                        behavior.ratio = behavior.end / behavior.start;
                    }
                },
                false
            );

            iframeBody.addEventListener(
                "touchend",
                (e: TouchEvent) => {
                    if (behavior.ratio < 1) {
                        // ピンチインはズームアウト
                        iframe.contentWindow.document
                            .querySelector("#zoomOut")
                            .click();
                    } else if (behavior.ratio > 1) {
                        // ピンチアウトはズームイン
                        iframe.contentWindow.document
                            .querySelector("#zoomIn")
                            .click();
                    }
                    console.log(behavior);
                    // ピンチ操作記録をクリア
                    behavior.start = behavior.end = 0;
                    behavior.ratio = 1;
                },
                false
            );
        };
    }

ピンチ操作によるズームイン、アウト処理の実装が終わりました。

ピンチズームの動作確認

ピンチ操作してみます。

canvasへの再レンダリングが走る為スムーズには行きません。画像としてズームすればピンチ操作にズームが追従するように出来ますが、ジャギが目立ってしまうようになるのでPDF.jsの機能でズームするようにしています。

まとめ

外部PDFアプリに頼らずにブラウザだけでPDFを表示出来るようになりました。ハイブリッドアプリをPWAにしたりするとネイティブ機能が使えなくなってブラウザだけでなんとかしなければならなくなります。代替手段を確保しておけば安心ですね。

補足

当初、ピンチ処理の実装はIonicに組み込まれているHammer.jsで以下のように実装しました。

        iframe.onload = () => {
            var iframeBody = iframe.contentWindow.document.body;

            let hammer = new window['Hammer'](iframeBody);
            hammer.get('pinch').set({ enable: true });

            hammer.on("pinch", (event) => {
                if (event.type == 'pinch') {

                    if (event.additionalEvent == "pinchout" && event.eventType == 4) {
                        iframe.contentWindow.document.querySelector("#zoomIn").click();
                    }

                    if (event.additionalEvent == "pinchin" && event.eventType == 4) {
                        iframe.contentWindow.document.querySelector("#zoomOut").click();
                    }
                }
            });
        }

ところがiframe越しに操作している為か操作を続けているとピンチインとアウトがテレコになって帰ってくるような挙動をした為、Hammer.jsは使わずに自前で実装しました。Hammer.jsを使った方が簡単ですので解決出来る方はHammer.jsを使ってください。

-html5
-, , ,

執筆者:

関連記事

IndexedDBにblob保存されたPDFファイルを外部アプリに頼らずにJavascriptで表示(前編)

目次1 はじめに2 検証環境3 Ionicプロジェクト作成4 PDFダウンロード、IndexedDB保存を実装5 実行してみる はじめに PDFをHTTPダウンロードするとHDDに保存されるか、外部ビ …

UserAgent判定JSライブラリ「UAParser.js」と「Platform.js」の比較

2019年時点で開発が継続しているUA判定JSライブラリから2つ選択して動作を確認しました。 目次1 Webリソースから比較1.1 github比較1.2 NPMリポジトリ比較2 実際に使用して比較2 …

Chrome75に実装された「Web Share API Level 2」を使ってみた

(2019/06現在、個人的な感想としては実戦投入はまだ早い印象でした) Webアプリにシェア機能を付けたい時があります。OSネイティブAPIを呼べないWebアプリではsharer.jsやremote …

IndexedDBにストアしたオブジェクトのキー値を部分的に更新する

目次1 はじめに2 課題3 より良い方法4 まとめ5 補足 はじめに IndexedDBは「key : value」でレコードを保存するキーバリューストアです。 バリューには「単値」または「Javas …

B2BスマホアプリをGooglePlay、AppStoreに公開することを安易にお勧め出来ない7つの理由とその対策

商材の性質やシーンに応じてスマホアプリをGooglePlayやAppleStoreのようなストアに公開することがマイナスに働くこともあります。 商材として価値の有る電子データをお持ちの商社さんとアプリ …

 

shingo.nakanishi
 

東京在勤、1977年生まれ、IT職歴2n年、生涯現役技術者を目指しています。健康第一。