Ruby の中から JavaScript のライブラリを使うために Jscall という gem を作ったのですが、例題として pdf.js を使って pdf ファイルをブラウザに表示するプログラムを書いてみました。 Ruby on Rails 上で pdf を生成して出力する話はよくありますが、以下の話は Rails とは関係ありません。 ローカルで動かす Ruby プログラムから pdf ファイルを表示する、という話です。 適当な pdf ビューアを system メソッドで起動するのと違いはありませんが、あえて JavaScript のライブラリである pdf.js を使ってブラウザで pdf ファイルを表示しよう、という趣向です。

まず Jscall をインストールします。

gem install jscall

次のような URL の pdf ファイルを表示することにします。 なおブラウザで表示するので、この URL は任意のオリジンとの間でリソース共有 (CORS) を許可している必要があります。

https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf

Ruby のプログラムは次のようになります。

require 'jscall'

# 標準では node.js を使うのでブラウザを使うように指示
Jscall.config browser: true

# 最初は白紙の web page なので、以下の HTML コードをページに
# 書き込んで pdf の表示領域を用意
Jscall.dom.append_to_body(<<CODE)
  <h1>PDF.js 'Hello, world!' example</h1>
  <canvas id="the-canvas"></canvas>
CODE

# pdf.js をインポート
pdfjs = Jscall.dyn_import('https://mozilla.github.io/pdf.js/build/pdf.js')

# 表示する pdf ファイルの URL
url = "https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"

pdf = Jscall.exec 'window["pdfjs-dist/build/pdf"]'

pdf.GlobalWorkerOptions.workerSrc = "https://mozilla.github.io/pdf.js/build/pdf.worker.js"

loadingTask = pdf.getDocument(url)
loadingTask.async.promise.then(-> (pdf) {
    puts "PDF loaded"

    # 最初のページを読み込む
    pageNumber = 1;
    pdf.async.getPage(pageNumber).then(-> (page) {
        puts "Page loaded"

        scale = 1.5;
        viewport = page.getViewport({ scale: scale })

        canvas = Jscall.document.getElementById("the-canvas")
        context = canvas.getContext("2d")
        canvas.height = viewport.height
        canvas.width = viewport.width
    
        # 読み込んだ pdf のページを表示
        renderContext = {
            canvasContext: context,
            viewport: viewport,
        }
        renderTask = page.render(renderContext)
        renderTask.async.promise.then(-> (r) {
            # 表示が成功したらメッセージを出力
            puts "Page rendered #{r}"
        })
    })
},
-> (reason) {
    # エラーが発生した場合
    puts reason  
})

変数 url の値を例えば

url = "/doc/tiger.pdf"

のように変えると、ローカル・ファイルシステム上の ./doc/tiger.pdf が表示されます。 カレント・ディレクトリがルート / になります。

上のプログラムは pdf.js の例題の JavaScript プログラムをそのまま移植したものです。

var url = 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf';

var pdfjsLib = window['pdfjs-dist/build/pdf'];

pdfjsLib.GlobalWorkerOptions.workerSrc = '//mozilla.github.io/pdf.js/build/pdf.worker.js';

var loadingTask = pdfjsLib.getDocument(url);
loadingTask.promise.then(function(pdf) {
    console.log('PDF loaded');

    var pageNumber = 1;
    pdf.getPage(pageNumber).then(function(page) {
        console.log('Page loaded');

        var scale = 1.5;
        var viewport = page.getViewport({scale: scale});

        var canvas = document.getElementById('the-canvas');
        var context = canvas.getContext('2d');
        canvas.height = viewport.height;
        canvas.width = viewport.width;

        var renderContext = {
          canvasContext: context,
          viewport: viewport
        };
        var renderTask = page.render(renderContext);
        renderTask.promise.then(function () {
          console.log('Page rendered');
        });
    });
}, function (reason) {
  console.error(reason);
});

二つのプログラムは Ruby と JavaScript の構文の違いをのぞけば、ほぼ同じです。 ただし Ruby のプログラムの場合、then の引数で渡されるハンドラ関数はあくまで Ruby の関数なので Ruby VM 上で実行されます。 puts の出力はブラウザのコンソールではなくて、Ruby を動かしているターミナル上に出力されます。

プログラム上、唯一の本質的な違いは JavaScript では

loadingTask.then
getPage(pageNumber).then
renderTask.promise

であるところが、Ruby では間に .async. が挿入されていることです。 つまり Ruby では、

loadingTask.async.then
getPage(pageNumber).async.then
renderTask.async.promise

のようになっています。

これは Jscall の仕様です。 Ruby はスレッド・モデルで動いていて基本的に同期的ですが、JavaScript は promise を使っていて非同期的です。 この違いを自動的に吸収するように Jscall は作ってあるのですが、上の例では明示的に promise を返して Ruby 側も非同期的に動くようにしています。 その場合、promise を戻り値で返す JavaScript の関数を呼び出すときは、前に .async を付ける仕様になっているのです。

Jscall は非同期的な JavaScript ライブラリの振る舞いを、なるべく同期的に扱えるように努めるので、実は上に示したプログラムはもう少し簡単なプログラムに書き直すこともできます。

require 'jscall'

Jscall.config browser: true

Jscall.dom.append_to_body(<<CODE)
  <h1>PDF.js 'Hello, world!' example</h1>
  <canvas id="the-canvas"></canvas>
CODE

pdfjs = Jscall.dyn_import('https://mozilla.github.io/pdf.js/build/pdf.js')
url = "https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/examples/learning/helloworld.pdf"

pdf = Jscall.exec 'window["pdfjs-dist/build/pdf"]'

pdf.GlobalWorkerOptions.workerSrc = "https://mozilla.github.io/pdf.js/build/pdf.worker.js"

loadingTask = pdf.getDocument(url)
# ここまでは同じ

pdf = nil
pdf = loadingTask.promise
puts "PDF loaded"

pageNumber = 1;
page = pdf.getPage(pageNumber)
puts "Page loaded"

scale = 1.5;
viewport = page.getViewport({ scale: scale })

canvas = Jscall.document.getElementById("the-canvas")
context = canvas.getContext("2d")
canvas.height = viewport.height
canvas.width = viewport.width

renderContext = {
    canvasContext: context,
    viewport: viewport,
}
renderTask = page.render(renderContext)
r = renderTask.promise
puts "Page rendered #{r}"

何が違うかというとコールバック地獄に陥っていないということです。

このように書くと例えば pdf.getPage(pageNumber) は promise を返しません(間に .async. がないので)。 getPage は、ページの非同期的な読み込みが終了した後に戻り値を Ruby に返します。 同期的なのです。 戻り値は、元のプログラムでは then の引数に渡される関数が受け取るはずだった値です。

Jscall について

世の中には色々なライブラリがありますから、Ruby 以外の言語で書かれたライブラリを Ruby の中から使えれば色々と便利なこともあるでしょう。 Jscall は Ruby の中から pdf.js のような JavaScript のライブラリを使うためのライブラリです。 Ruby の中から Python ライブラリを使うための Pycall の親戚です。