PDF.jsとReactでブラウザ上で完結する暗記帳的なものをつくった。

PDF.jsとReactでブラウザ上で完結する暗記帳的なものをつくった。

マーカーをひいて、シートをかぶせるとマスクされるおなじみのやつですね。

画像がブラウザだけで開けるというのは知っていたのですが、PDFがJavaScriptで扱えると聞いてつくってみた次第です。

ファイルのアップロードなし、バックエンドサーバーなしで動かせたので、だいたい目標は達成できた感じです。

FireShot Capture 78 - Document - http___workbook.mmmpa.net_.png

PDF.jsその他、いろいろ躓いたポイントがあったのでメモとして残しておきます。

動いてるもの

Marker Workbook

ソース

mmmpa/marker_workbook

PDF.js

まず動かしてみたときにはこちらのサイトを参照しました。 PDF.js で遊んでみた (ページの描画,テキスト・注釈の表示など) - きちぽよ〜

また、ここわからんよ……となったときにはこちらのサイトを読んで解決できました。 pdf.jsを使いブラウザで見られるPDFスライド表示ツールを作った | Web Scratch

PDFの取得にはファイルパスかTypedArrayをわたす

レンダリングにはPDFJS.getDocumentメソッドを使います。このメソッドが引数をみて動作を変えるので、考えることはあまりありません。

PDFJS.getDocumentPromiseを返しますので、細やかなレンダリングの設定はそこで行います。

サーバーなどにファイルがある場合

Marker Workbookでは初期ページとしてsample.pdfを表示するようにしています。この場合は

PDFJS.getDocument('/sample/sample.pdf').then((pdf)=> {
  // あれこれ
})

とするとPromiseが返ってきますので、あれこれします。

input[type="file"]経由でファイルをわたす場合

今回のMarker Workbookはサーバーへのファイルのアップロードはしない、という前提で作りましたから、ローカルファイルを表示する場合はブラウザがアクセスできるパスがありません。

そこで、input[type="file"]からファイル情報を得て、それをデータに変換する手順が必要となります。

input[type="file"]onChangeイベントにリスナーを付与して、

onChange(e){
  let reader = new FileReader();
  reader.addEventListener('load', (e)=>{
    //let typedArray = new Uint8Array(e.target.result);
    PDFJS.getDocument(e.target.result).then((pdf)=> {
      // あれこれ
    })
  });
  reader.readAsArrayBuffer(e.file);
}

得られた情報からあれこれします。

(最初試した時にUint8Arrayしないと動かなかった気がしたのですが、今ためすとそのままでも解釈できたのでよくわかりませんが残しています。)

レンダリングは非同期

PDFJS.getDocumentにより生っぽいデータを得ることができました。

これはまだ生っぽい、webページのためのHTMLのような状態なので、表示できる形にしなければなりません。

PDF.jsはpdfが持つ個別のpagerenderメソッドを呼び出すまでレンダリングしません。これのおかげで最初のPDFJS.getDocumentで長時間かかってしまうということがありません。

そのかわり、render時には文字やその他のレイアウトのため、多少時間がかかります。動作をブロックしないために、ここでもPromiseが使われ、非同期的に処理が行われます。

renderでは任意のCanvasRenderingContext2Dに転写し、表示するという形になります。

let pageNumber = 1;
let scale = 1;

PDFJS.getDocument(e.target.result).then((pdf)=> {
  pdf.getPage(pageNumber).then((page)=> {
    let viewport = page.getViewport(scale);
    let {canvas, canvasContext} = this.setupCanvas(viewport);

    page.render({canvasContext, viewport})
  })
})
setupCanvas(viewport) {
  let canvas = document.createElement('canvas');
  let canvasContext = canvas.getContext('2d');
  canvas.height = viewport.height;
  canvas.width = viewport.width;

  return {canvas, canvasContext};
}
  1. pdfから指定したページのpageを得る
  2. pageからサイズ情報を含むviewportを得る
  3. viewportをもとにCanvasElementCanvasRenderingContext2Dを用意する
  4. page.renderviewportCanvasRenderingContext2Dを渡す
  5. page.renderCanvasRenderingContext2Dに転写、Canvasに表示される

CanvasからdataURLを得る

アプリケーションではCanvasでの表示を考えず、Reactとの兼ね合いもあって単純にImageタグで表示しようとおもっていましたので、canvas.toDataURL()dataURLを得なければなりません。

CanvasContextもとのCanvasを利用する場合はこの非同期は気にしなくてよい)

しかしpage.renderは非同期なので、

page.render({canvasContext, viewport})
let dataURL = canvas.toDataURL();

とすると悲しくなることになります。

残念ながらpage.renderPromiseを返すわけではないので、ダイレクトにreturn page.render({canvasContext, viewport})とはできませんが、page.renderが返すRenderTaskからPromiseを得ることができます。

page.render({canvasContext, viewport}).promise.then((e)=> {
  let dataURL = canvas.toDataURL();
  // あれこれ
});

これでレンダリングが終わった状態のdataURLを得ることができました。

React

複雑なオブジェクトを条件としたshouldComponentUpdateをどうするか

shouldComponentUpdateをていねいに設定していくことにより、無駄なrenderが減り、幸せになることができます。

しかし、ちょっと条件が増えてくるとちょっとした比較もめんどくさくなってくるので、stateにいるインスタンスにバージョンをもたせて、それをみてそれでよしとする乱暴な方法にしました。

以下はマーカーを表示するコンポーネントの部分です。

shouldComponentUpdate(props, _) {
  return this.state.markerVersion !== props.workbook.currentPage.markerVersion
}

componentWillReceiveProps(props) {
  this.setState({
    markerVersion: props.workbook.currentPage.markerVersion
  })
}

マーカーを追加したり、消したりするたびにmarkerVersionはインクリメントされます。

shouldComponentUpdateが通るとstatemarkerVersionが更新されて、次回に備えるという塩梅です。

KeyBoardEvent

今回はキーボードショートカットに多少挑戦しました。

ところでキー押下には、アクティブになっているinputエレメントに対して思わぬ動作を引き起こすものがあります。

スペースはアクティブになっているbuttonを押下してしまいますし、カーソルキーはselectを次の項目に動かしてしまいます。

Chromeでは単純にe.preventDefaultを用いるだけでそのような動作を抑止出来ましたが、Firefoxではそうはいきませんでした。残念ですね。

let active = document.activeElement as HTMLElement;
active.blur();

let retrieve = ()=> {
  active && active.focus()
  $(window).unbind('mosueup', retrieve);
};

$(window).bind('mosueup', retrieve);
e.preventDefault();

ということで、上記のようにキーイベントに合わせてフォーカスをつけるはずすなどを用いてみました。

しかしFirefoxではbuttonに対する抑止は有効であるものの、selectでは相変わらずキーを拾ってしまうので、onChangeでフォーカスを外すことになりました。

<select className="scale" value={this.props.scale} onChange={(e)=> {
            e.target.blur()
            this.dispatch('workbook:scale', +e.target.value)
        }}>

うーん、残念。

以上です

以上です。