ガンバラナイ

PDF の余白を全ページ同じ範囲で切る — Briss のやり方をブラウザで作る

PDF の余白を全ページ同じ範囲で切る — Briss のやり方をブラウザで作る

はじめに

XTEINK という小さな e-ink 端末で PDF を読むために、PDF を端末用の形式(XTCH)に変換するツールを作っています。ブラウザだけで動き、PDF の描画には pdf.js を使っています。

画面が小さいので、PDF の余白を切り落として本文をできるだけ大きく表示したい。最初の版では、ページごとに文字のある範囲を見つけて切り取っていました。ところが、これだと文字の少ないページだけ極端に大きくなります。

ページごとに余白を切った場合と、全ページ同じ範囲で切った場合の比較。ページごとに切ると、2行しかないページの文字が画面いっぱいの大きさになり、ふつうのページとまるで大きさが違う。全ページ同じ範囲で切ると、どちらのページも同じ大きさで表示される

左がページごと、右が全ページ同じ範囲で切った結果です。本として読むなら、右のように文字の大きさがそろっていてほしいところです。

そこで、PDF の余白を切り取るソフト Briss のやり方をまねて、全ページを重ねた画像の上で枠を描き、その範囲で全ページを切り取る方式を足しました。あわせて、白紙のページを自動で消す機能も足しています。この記事では、その作りで面白かったところを3つ書きます。

(使い方はガンバラナイブログの記事に書きました)

全ページを重ねて、文字の多い場所を1枚にする

できあがりはこんな画面です。

トリミングの設定画面。奇数ページと偶数ページを重ねた画像が左右に並び、本文の部分が濃く、見出しやページ番号は薄く写っている。それぞれ本文だけを囲む緑の枠が描かれている

本文はほぼ全ページにあるので濃く、ページによって位置が変わる見出しやページ番号は薄く写ります。どこまで切ってよいかが一目で分かります。

作り方は単純で、各ページを小さな canvas(長い辺が 420px)に描き、画素ごとに「文字があったページの数」を数えるだけです。

const ink = new Uint16Array(W * H) // 画素ごとに、文字があったページの数
for (const p of pages) {
  // ページ p を W×H の canvas に描いて、画素を読む
  const d = ctx.getImageData(0, 0, W, H).data
  for (let j = 0, q = 0; j < W * H; j++, q += 4) {
    if (lum(d, q) < 245) ink[j]++ // 白でなければ「文字あり」
  }
}
// 数えたページ数を濃さにする。1ページでも文字があれば見えるよう、薄いグレーから始める
const gray = (j) => (ink[j] ? 210 - (180 * ink[j]) / n : 255)

最初に出す枠は、ink が 0 でない範囲を囲んだ矩形に、少し余白を足したものです。たいていはそのままで使えますが、見出しやページ番号まで囲んでしまうので、気になるときは枠をドラッグで狭めます。

細かいところでは、次のようなことをしています。

  • 奇数ページと偶数ページは別に重ねる。 本は綴じる側の余白が広いので、奇数と偶数で本文の位置が左右にずれます。上の画面のように、別々に枠を決められるようにしました。
  • 大きさの違うページはグループに分ける。 縦横の差が 5% 以内なら同じグループにして重ねます。グループの中の少しの大きさの違いは、縦と横で別の倍率をかけて同じ大きさの canvas に描くことで吸収します。スキャンした PDF のように大きさがばらばら(6種類を超える)なら、縦向きか横向きかだけで分けます。
  • 重ねるのは最大 30 ページずつ。 何百ページもある PDF を全部描くと時間がかかるので、奇数・偶数それぞれ 30 ページを超えたら等間隔に選びます。本文の位置を知るにはこれで十分でした。
  • 枠は 0〜1 の比率で持つ。 重ねた画像は小さく、変換するときは大きな解像度で描くので、ピクセルではなく「ページの幅の何割か」で持っておくと、どちらにもそのまま使えます。

切り取る範囲だけを描く

範囲が決まったら、変換のときに各ページを高い解像度で描いて切り取ります。素直に作ると「ページ全体を描いてから、範囲を切り出す」ですが、余白が多いページだと、捨てる部分まで大きな canvas に描くことになります。

pdf.js の page.render() は transform を受け取れるので、描く位置をずらして、範囲の大きさの canvas にだけ描くことができます。

// crop は 0〜1 の比率 { x, y, w, h }
async function renderPdfPage(page, scale, crop) {
  const viewport = page.getViewport({ scale })
  const canvas = document.createElement("canvas")
  canvas.width = Math.ceil(viewport.width * crop.w)
  canvas.height = Math.ceil(viewport.height * crop.h)
  const ctx = canvas.getContext("2d")
  ctx.fillStyle = "white"
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  // 範囲の左上が canvas の左上に来るよう、ページ全体を左上へずらして描く
  const transform = [1, 0, 0, 1, -viewport.width * crop.x, -viewport.height * crop.y]
  await page.render({ canvasContext: ctx, viewport, transform }).promise
  return canvas
}

canvas からはみ出した部分は描かれないので、メモリも時間も切り取る範囲のぶんだけで済みます。重ねた画像を作るときは、同じ transform で縦と横の倍率を変えています。

白紙のページを消す(最初の判定は甘かった)

PDF には白紙のページがよく入っています。また、1ページを何枚かに分割すると、最後の1枚が余白だけになることがあります。e-ink 端末でめくっていて真っ白な画面が出るのは残念なので、こうした画面を消すことにしました。

最初の版

最初は、端末の4階調(白・明るいグレー・暗いグレー・黒)に変換したあとの画面で、白以外の画素が 0.05% 未満なら白紙としていました。480×800 の画面なら 192 画素です。

これには穴がありました。

  • スキャンした PDF の紙は白ではない。 紙が少しグレーだと、4階調にした時点で紙全体が「明るいグレー」になったり、ディザリングの点々になったりして、白紙と判定できません。
  • 小さな見出しだけのページが消える。 章の扉に小さく1行だけ書かれたページは、文字の画素が 192 に届かないことがあります。
  • ページごとに余白を切るモードだと、汚れが拡大される。 ほぼ白紙のページに小さな汚れがあると、余白を切る処理がその汚れを囲んで画面いっぱいに拡大してしまい、白紙と判定されません。

直した版

判定を次のように変えました。

  1. 紙の地色を、そのページから決める。 明るさのヒストグラムを作り、明るい側(160 以上)でいちばん多い明るさを「紙の色」とします。白い紙なら 255 付近、グレーの紙ならその明るさになります。
  2. 紙の色より十分暗い画素だけを「内容」と数える。 紙より 24 以上暗い画素が 20 個未満なら白紙です。割合ではなく画素の数にしたので、小さな見出しやページ番号だけのページも残ります。
  3. 4階調にする前に判定する。 ディザリングの点や白黒反転の影響を受けません。
  4. ページ全体の判定を、余白を切る前・分割する前にする。 ページ全体を出力1画面ぶんの解像度に縮めてから判定するので、汚れが拡大される前に白紙と分かります。分割したあとの1枚ずつも、同じ関数で判定します。
const PAPER_MIN = 160, PAPER_TOL = 24, BLANK_MAX_INK = 20
function isBlankImage(data) {
  const hist = new Uint32Array(256)
  for (let j = 0; j < data.length; j += 4) hist[lum(data, j) | 0]++
  // 明るい側でいちばん多い明るさを、紙の地色とする
  let paper = PAPER_MIN
  for (let v = PAPER_MIN + 1; v < 256; v++) if (hist[v] > hist[paper]) paper = v
  // 紙より十分暗い画素を数える
  let ink = 0
  for (let v = 0; v < paper - PAPER_TOL; v++) ink += hist[v]
  return ink < BLANK_MAX_INK
}

もうひとつ、全ページが白紙と判定されたら、何も消さずに出力し直すようにしました。判定が外れたときに「出力が0ページ」になるより、白紙が混ざっていても全部出たほうが困らないからです。

変換が終わると、消した枚数をログに出します。

変換のログ。「空白だけのページを 1 枚削除しました」「分割で余った空白の画面を 2 枚削除しました」と出ている

まとめ

  • ページごとに余白を切ると、文字の少ないページだけ大きくなる。本として読むなら、全ページ同じ範囲で切るほうがよい
  • 全ページを小さく描いて「文字があったページの数」を数えると、どこに本文があるかが1枚の画像で分かる。奇数・偶数は別に重ねる
  • pdf.js の transform で描く位置をずらせば、切り取る範囲だけを描ける
  • 白紙の判定は、白を前提にせず紙の地色から決め、割合ではなく画素の数で見て、加工する前に判定する

ツールはブラウザだけで動き、PDF はどこにも送りません。XTEINK をお持ちの方はぜひ試してみてください。

👉 PDF→XTCH変換ツール