PDFや画像から文字を読み取れるウェブアプリを作りました【PDF.js + tesseract.js】

8 min read読了の目安(約7400字

これは何

PDFや画像から一部を切り出してOCRするウェブアプリを作りました。
Demo

構成

  • TypeScript v4.0.5
  • React.js v17.0.1 (本記事では特に解説しない)
  • tesseract.js v2.1.4
  • pdfjs-dist v2.5.207

ソースコード

https://github.com/hapo31/charcoal

作った経緯

11月の頭頃から、GoToトラベルのアレを利用して安くなってたので飛びついた自動車の免許合宿に行ってたんですが、そこで一緒の部屋で仲良くなった人に「文字がコピペ出来ないPDFがあるんだけど見てほしい」という相談を受けました。
とりあえず見てみると、該当するページは90度回転して記録されており、色々といじくってみたところ「見た目は文字だけどデータ的にはただの図形」という状態になっていることがわかりました。
免許合宿中は正直言ってかなり暇だったので、暇つぶしを兼ねてこれを解決するアプリを作ってみようということで作り始めました。
とりあえず出来上がったものをその方に後日見せたところ、とても作業が捗ったと仰っていたので、お役に立ててよかったなと思います。

余談ですが、自動車免許は無事ストレートで取得出来ました

以下、使ったライブラリ等について解説

Tesseract.js

Googleが主導している Tesseract というOCRエンジンがあるのですが、こいつを Emscripten でJavaScriptに移植したものが Tesseract.js です。
Tesseract自体の仕組みについては僕は全くわからないので、ここではどういう感じで使ってるかのみ解説します。

こいつの使い方自体は非常に単純で、APIをこねこねして画像データを渡してあげるだけです。
コードを示すとこんな感じです。(TypeScriptなのでJavaScriptで使う場合は型周りのコードを削ってください)

tesseract-test.ts
import { createWorker, Worker } from "tesseract.js";

type LoggerResult = {
  workerId: string;
  jobId: string;
  status: string;
  progress: number;
};

function async recognize(imageLike: Tesseract.ImageLike) {
  const worker = createWoker({
      logger: (log: LoggerResult) => {
        // ここを書いてあげることで tesseract.js が内部で出力するログが取得出来る。
	// jobId とか progress とかも取れるので、これでプログレスバーを表示してあげたりといったことも可能。
        console.log({log}); 
      },
  });
  
  await worker.loadLanguage("jpn");
  await worker.initialize("jpn");
  const { data: { text } } = await worker.recognize(imageLike);
  await worker.terminate();
  
  return text;
}

// 以下の画像をファイルから読み込む方法については自由です
// Tesseract.ImageLike という型の定義は以下の様な感じなので、
// マジでなんでも渡せると思います。
// type ImageLike = string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement | CanvasRenderingContext2D | File | Blob | ImageData | Buffer

const input = document.getElementById("input");
if (input == null) {
  return;
}
input.onchange = async (event) => {
  const file = input.files[0];
  const result = await recognize(file);
  
  console.log(result);
};

テストに使った画像はこちら

如何にも簡単そう

これを実行して↑の画像を読み込むと、こんな感じで出力されます。


なんか変に半角スペースが入ってるのが嫌なときは、 text.replace(/\s/g, "") などとしてあげるときれいになります。

・・・これだと逆に半角スペース消しすぎな気もしますが、そこはたぶん↑の正規表現をちょっと調節してあげると良いと思います。(2個以上連続の半角スペースは1個にする、ぐらいのほうがたぶんおとなしい)

画像の一部を切り出す

さて、このように Tesseract.js 自体は非常に簡単に使えることがわかります。
しかし、このままだと画像全体を一度にOCRしてしまうため、あまり使い勝手はよくありません。
Tesseract.js そのものには、特に文章の塊を自動で抽出するような機能はついていないので、自前で実装する必要があります。(思い切ってユーザー側で画像を切り出させるのもそれはそれで一つの道かもしれないけど)
とはいえ、文章の区切りを機械で判定させるのはかなり難易度が高い作業だと思われるので、ここは人間の手を借りることにしましょう。

一般化すると「画像の一部を範囲指定して、それを別の画像として切り出す」という処理が欲しいということになります。
このときに便利なのが Canvas です。
実は、Canvasには画像をそのままCanvasへ描画する drawImage というメソッドがあります。

canvas-sample.ts
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx) {
  ctx.drawImage(
    image,
    x,      // image の切り取りたい矩形の一番左のx座標
    y,      // image の切り取りたい矩形の一番左のy座標
    width,  // image の切り取りたい矩形の横幅
    height, // image の切り取りたい矩形の縦幅
    0,      // 切り取った画像を描画するx座標
    0,      // 切り取った画像を描画するy座標
    width,  // 切り取った画像を描画する横幅
    height  // 切り取った画像を描画する縦幅
  );
}

これと、マウスのイベント周りを頑張って座標を取得する処理を組み合わせれば、いい感じに画像を切り出す処理が書けるはずです。

tesseract-test.ts
// 中略

const img = document.getElementById("image");
if (img == null) {
  return;
}
let startX = 0;
let startY = 0;

img.onmousedown = event => {
 // マウスがクリックされた座標を保存
  const { pageX, pageY } = event;
  startX = pageX;
  startY = pageX;
};

img.onmouseup = async (event) => {
  // マウスが放された座標を使って範囲を確定する
  const { pageX, pageY } = event;
  // createElement でも getElementById でもOK, getElementByIdのほうがプレビューが見られて楽しいかも
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  // 本来は大きさが負の数になるとマズいので、いい感じに補正したほうがいい(負の数になるならpageXとstartX入れ替えるとか…)
  const width = pageX - startX; 
  const heigt = pageY - startY;
  if (ctx) {
    ctx.drawImage(
      img,
      startX,
      startY,
      width, 
      height,
      0,
      0,
      width,  
      height  
    );
    // さっき作った関数にcanvasをそのまま渡せばOK(普通に受け入れてくれる)
    const text = await recognize(canvas); 
  }
};

ちなみに今回作ったウェブアプリでは範囲選択中に赤い四角形が描画されるようにしてあるので、実際にはこれよりだいぶ複雑になってます。(Reactで作ってるせいもある)

さて、これで画像を一部切り出す処理が書けたので、一応ツールとしては成立するようにはなりました。
実際作っているときも一瞬ここで満足したのですが、あくまで問題は「文字がコピペできない PDF がある」ということ。
つまり、PDFにも対応してようやくこの問題は解決されることになります。
実際、何かをOCRするときも画像データよりもPDFから行うことのほうが多いと思います。

PDF.js でPDFに対応させる

というわけで、ブラウザ上でなんとかしてPDFを表示する方法が欲しくなりました。
ここで登場するのがずばり PDF.js です。
なんと、あの天下の Mozilla 謹製のPDF描画ライブラリが存在していました。ありがとうございます。
これは Firefox のPDFビューワにも使われている実績あるPDFライブラリのようです。

色々使い方を調べたところ、以下が最小コードの模様。

pdf-js-test.ts
import {
  GlobalWorkerOptions,
  getDocument,
} from "pdfjs-dist";

GlobalWorkerOptions.worker = "./pdf.worker.min.js"; // PDF.js は WebWorker を使って描画タスクを裏に逃しており、その WebWorker を動かすための js ファイルへのパスを指定してあげる必要がある

const input = document.getElementById("input");
const resultImg = document.getElementById("resultImg");
if (input == null || resultImg == null) {
  return;
}

input.onchange = async (event) => {
  const file = input.files[0];
  const reader = new Reader();
  reader.onload => async () => {
    const doc = await getDocument(reader.result as string).promise;
    const pageNum = 1; 
    const page = await doc.getPage(pageNum); // PDF.js のページ番号は 1 origin なので注意(0を渡すとエラーになる)
    const viewport = page.getViewPort({ scale: 1, rotation: 0 });
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");
    if (ctx == null) {
      return;
    }
    await page.render({
      canvasContext: ctx, // ここで渡した Canvas Context に描画される
      viewport,
    }).promise;
    
    resultImg.src = canvas.toDataURL(); // Canvasで持ち回るより画像にしちゃったほうが楽(この開発中最大の気付き)
  };
  reader.readAsDataURL(file);
};

こうしてみるとこちらも意外とコンパクトに書けている気がしますね、さすがMozilla。

このPDF.js、少しだけ癖があり、タスクをいくつも立ち上げることができた Tesseract.js と違い、描画タスクを一度に2つ以上立ち上げることが出来ません。(page.render がすでに動いているときはそれが終わるまで実行してはいけない)
なので、例えばページ送りのような機能を実装する場合は、すでにレンダータスクが実行されているときは描画待ちとしてキューに突っ込んであげる、のような気遣いが必要のようです。

さて、ここまで揃えば、それぞれを組み合わせることでだいぶいい感じになりそうです。

前述の3つを組み合わせる

ここまでで

  • 画像から文字をOCRする
  • 画像の一部を切り出す
  • PDFを画像化する

という3つの処理を作ってきました。
これらを組み合わせると、「PDFから一部を切り出してOCRする」という、それっぽい動きを実現することが出来ます。
「PDFをOCRする」という如何にも厄介そうな課題ですが、それぞれの問題に対する処理を上手く統合すると、このような処理もブラウザ上だけで実現することが出来ます。ほんと最近のブラウザはすごいな。。

ちなみに、今回解説したウェブアプリは以下より試すことが出来ます。
ここまで読んでくれた皆さんにお礼としてリンクを貼り付けておきます。

https://hapo31.github.io/charcoal/

ちなみに charcoal というURLですが、以前に作った デスクトップキャプチャツール「TAKE」 から一部アルゴリズムやコードを流用しているからです。(ついでに解説も流用している)

竹(TAKE)を焼くと木炭(Charcoal)になるというところから、というなんともない理由です、すみません。。

最後に

免許合宿中の暇つぶしをくれたSさんには大変感謝しております、とても楽しかったです。

最後の最後に

現在無職なためお仕事を探しています。 (お金がもうないのでだいぶ緊急です)
スキル的には、本記事にあるようにTypeScriptと、あとReact.jsも使えます。このアプリではHooksもちゃんと活用しつつ、ContextAPI や useReducer を使って、なるべくデータの状態管理が複雑にならないように気をつけて作っています。(とはいえまだまだリファクタリングは足りていないつもり。。)
また、「困っている人を助けてあげたい」というマインドで何かを作り始めると、自分は最後まで走り抜けられる気がしています。
こんな自分にご興味を持って頂けたら、ぜひ @hapo31_t までDM頂ければ幸いです。