コンテンツにスキップ

PDFツール

PDFの加工を1か所にまとめた、ブラウザだけで動くツールです。サーバーはなく、静的ファイルを Cloudflare Pages に置いているだけです。PDFは自分のパソコンの外に出ません。 開いたファイルの処理はすべてブラウザの中(JavaScript)で行います。

このページは、その仕組みと、作るうえで判断したことの解説です。

全体の構成

PDFツールの全体像
PDFツールの全体像

ビルド工程のない素のHTML / CSS / JavaScript です。バンドラーもフレームワークも使っていません。

public/
  index.html      画面(4機能のパネルを1ページに並べ、ボタンで切り替え)
  app.js          機能の切り替え(#redact などのURLハッシュ+localStorage)
  common.js       共通部品(PageViewer・ドラッグ&ドロップ・ダウンロード)
  redact.js       墨消し・検索
  hand.js         手書き風文字追加
  pages.js        結合・分割・回転
  meta.js         メタデータ編集削除
  vendor/         pdf.js・pdf-lib(同梱)

PDFの扱いは、役割の違う2つのライブラリで分担しています。

ライブラリ 役割 使う場面
pdf.js PDFを読んで描く(canvasへの描画・文字位置の取得) 画面表示、墨消しの画像化、文字検索
pdf-lib PDFを組み立てて書く(ページのコピー、画像の貼り付け、プロパティ変更) 結合・分割・回転、書き出し

pdf.js は表示が得意で、書き出しはできません。pdf-lib は書き出しが得意で、描画はできません。この分担が全体の骨格です。

外部通信をなくす

pdf.js は当初CDNから読み込んでいましたが、2026年9月にリポジトリへ同梱する形に変えました。「PDFは送信されない」と謳うツールが、ページを開くたびに外部サーバーへアクセスしているのは筋が悪いためです。同梱時は pdf.js の評価実行(eval による高速化)も、isEvalSupported: false で切っています。

唯一の外部通信は、手書き風の書体を読み込む Google Fonts です。ここで取得するのはフォント定義だけで、PDFの内容は渡りません。

墨消し:「隠す」のではなく「画像にして焼く」

PDFの墨消しで最もよくある事故は、黒い四角を上に重ねただけで、下の文字がそのまま残っているケースです。コピー&ペーストや検索で元の文字が出てきます。

このツールは、この事故が構造的に起きない方法を選びました。

  1. 各ページを pdf.js で画像(canvas)に描く
  2. その画像のピクセルそのものを黒で塗りつぶす
  3. 塗りつぶした画像だけを貼った、新しいPDFを作る

墨消しの流れ
墨消しの流れ

await page.render({ canvasContext: cx, viewport: vp }).promise;
cx.fillStyle = "#000";
for (const r of rects) cx.fillRect(r.x * c.width, r.y * c.height, r.w * c.width, r.h * c.height);
// → PNGにして、まっさらな PDFDocument.create() に貼る

出力は元のPDFを書き換えたものではなく、ゼロから作ったPDFです。元の文字データ、注釈、しおり、添付ファイルは引き継がれません。黒塗り部分の下には、もう画素が存在しません。

代償もあります。

  • 出力は画像PDFなので、文字の選択・検索ができなくなります(墨消しした箇所以外も)
  • 解像度(dpi)を上げるほど鮮明ですが、ファイルは大きくなります
  • ページ数が多いと時間とメモリを使います

墨消しの確実さを優先して、この代償を受け入れています。文字の検索性が必要なら、別途OCRをかける必要があります(後述)。

枠は 0〜1 の相対座標で持つ

墨消し枠は {x, y, w, h} をすべて「ページ幅・高さに対する割合(0〜1)」で保存します。画面の表示倍率と、書き出し時の解像度(150〜300dpi)が違っても、同じ枠をそのまま使えるからです。画面で選んだ位置と、出力時に塗る位置が一致します。

文字検索で枠を自動作成する

文字入りのPDFなら、語を入力すると該当箇所に枠が自動で付きます。単純に見えて、実装には気を遣っています。

断片をまたぐ語を探す。 pdf.js は1行を複数の「テキスト断片」に分けて返すことがあります。断片ごとに検索すると、「山田」「太郎」と分かれた「山田太郎」を取りこぼします。そこで、ページ内の全断片を1本の文字列に連結し、各文字が「どの断片の何文字目か」を覚えたうえで検索します。ヒットした範囲は、断片ごとの枠に分割して返します。

表記ゆれを吸収する。 照合の前に、全角/半角・大文字/小文字・空白の違いを NFKC 正規化で揃えます(ABC と abc が同じ扱い)。

断片内の位置は推定。 断片の「何文字目から何文字目まで」は、等幅とみなせないため、全角を幅1・半角を幅0.55とした重みで按分して枠の位置を決めます。ここは推定で、ずれることがあります。

そのため、画面にも「自動検索は取りこぼし・位置ずれがありえます。全ページを目視で確認してください」と明記し、枠はクリックで削除、ドラッグで追加できるようにしています。自動検出は補助であり、確認の手間を省くものではありません。

スキャンPDF(文字データがない)は、先頭5ページで文字数を数えて判定し、検索を無効にします。この場合はドラッグで枠を作ります。

古い出力を渡さない

「枠を変更したのに、前に作った出力をダウンロードしてしまう」事故を避けるため、枠を変えるたびに世代番号(editVersion)を進め、生成済みの出力を破棄します。適用処理の途中で枠が編集された場合も、世代番号の不一致を検知して出力を捨てます。墨消しでは、「最新の状態が反映されていない成果物」が最も危険だからです。

手書き風文字追加

ページをクリックして文字を置くと、手書きのような書体と、不揃いなゆらぎで描画します。四角・マル(手書き風の線)、消しゴムも使えます。

「ゆらぎ」の作り方

1文字ごとに、位置・傾き・大きさ・字送りを少しずつずらします。ただし、乱数をそのまま使うと、再描画のたびに文字が震えてしまいます。そこで、要素ごとに固定の種(seed)を持たせ、同じ種からは常に同じ乱数列が出る擬似乱数(mulberry32)を使います。何度描き直しても同じ見た目になります。

四角やマルの線は、位相の違う sin 波を重ねてゆらぎを作っています。隣り合う点が連続して動くので、「ペンの手ぶれ」に近くなります。さらに、太さと不透明度の異なる線を2度重ね、始点と終点を少しずらして重ねることで、「描き終わりが閉じきっていない」感じを出しています。

保存は「透明PNGの重ね貼り」

書き出しでは、置いた文字をページ全面の透明PNGにして、元ページの上に重ねます。元のPDFの内容(文字データを含む)はそのまま維持されます。一方で、追加した文字は画像なので、PDF上で選択・検索はできません。

ここで一番はまったのが、座標系の違いです。

  • 画面(pdf.js)は、ページの CropBox を基準に表示する
  • PDFの座標は、MediaBox の原点(0,0)が基準とは限らない
  • ページには /Rotate(90度単位の回転)がつくことがある

このため、「ページ左下に貼る」と単純に決めると、CropBox が MediaBox より小さいPDFや、原点がずれたPDF、回転されたスキャンPDFで、文字が思った場所に載りません。書き出しでは、ページの CropBox の位置・寸法と回転角を取得し、回転ごとの貼り付け位置に変換しています。

消しゴムの実装

消しゴムは元データを消さず、軌跡だけを要素ごとに記録します。描画時にいったん別のcanvasへ要素を描き、destination-out(描いた部分を抜く合成)で軌跡の形に透明にしてから、ページに重ねます。軌跡は「要素の左上を原点としたpt座標」で持つので、要素を動かしても消した跡がついてきます。1回の操作ぶんをまとめて「元に戻す」こともできます。

結合・分割・回転

1つのPDFに対する操作ではなく、ページ単位の部品を並べる設計です。読み込んだPDFは、1ページずつサムネイルのカードになります。カードは、並べ替え・削除・回転・選択ができます。

  • 内部では、ソースファイル → ページ番号 の参照と、回転量(0/90/180/270)だけを持つ
  • 書き出し時に、カードの並び順に copyPages で新しいPDFへ集める
  • 回転は元ページの /Rotate に加算する(画像化しない=文字は残る)

複数のPDFから、ページを混ぜて1つにまとめるのも、「選んだページだけ抜き出す」も「Nページごとに分割」も、同じ「カードの並びからPDFを組む」処理の使い回しです。

連打に強いサムネイル

回転ボタンを連打すると、同じcanvasへの描画が重なり、pdf.js が例外を出します。そこで、新しい描画を始める前に、前の描画タスクを cancel() して終わるのを待ちます。さらに「描画の予約番号」を持たせ、待っている間にもっと新しい予約が入ったら、古いほうを破棄します。

メタデータ編集削除

PDFには、タイトル・作成者・作成アプリなどの「文書のプロパティ」が入っています。作成者に本名や会社名が入ったまま公開してしまう、といった事故を防ぐための機能です。

小さな落とし穴が1つあります。pdf-lib は、PDFを読み込んだ時点で既定では Producer(作成アプリ)と更新日を自動で書き換えます。確認しただけで勝手に変わっては、削除したつもりの確認にならないので、updateMetadata: false で読み込みます。

サブジェクト・キーワードは、利用者が変更した場合だけ書き戻します。キーワードは「区切りの再解釈」で表記が変わりうるため、触っていない項目は元のままにしておくのが安全だからです。

このツールでは消えないものがあります。 「文書のプロパティ」の欄だけが対象で、別の場所(XMPメタデータ)に埋め込まれた情報、本文・画像・しおりに含まれる情報は削除しません。画面にもその旨を明記しています。確実に個人情報を消したい場合は、墨消し機能で画像化したPDFを書き出す(元の構造を引き継がない)のが確実です。

画面を重くしない:PageViewer

墨消しと手書き風文字追加は、全ページを縦に並べて表示します。数百ページのPDFを全部描くとブラウザが重くなるため、共通部品 PageViewer で次のようにしています。

  • ページの「枠」だけ先に全部作る(スクロール位置が安定する)
  • IntersectionObserver で、画面の近く(上下800px)に来たページだけを描画する
  • 離れたページの canvas は、サイズを0にしてメモリを解放する
  • 読み込みのたびに「世代番号」を進め、古いファイルの描画が新しいファイルの画面を上書きしないようにする

別のファイルを続けて開いたとき、遅れて終わった古い描画が新しい画面に混ざる問題は、この世代番号で防いでいます。

限界と使うときの注意

  • パスワード保護されたPDFは扱えません
  • 自動検索による墨消しは補助です。 公開前に、出力PDFを開いて目視で確認してください
  • 墨消しの出力は画像PDFです(検索不可・ファイルは大きめ)
  • 手書き風の追加文字も画像です(PDF上で選択・検索できません)
  • メタデータ削除は「文書のプロパティ」のみが対象です

姉妹ツール:スキャン墨消し(OCR)

墨消しの後に「検索できるPDF」が欲しい場合は、同じ作者のローカルアプリ scan-ocr が使えます。こちらは Python と日本語OCR(YomiToku)で動き、考え方は同じです(黒塗りした画像を最終成果物にする)。違いは順序で、「OCRして位置を探す → 元画像を黒塗り → 黒塗り後の画像をもう一度OCR」と、墨消しした文字がOCR結果にも残らないよう組み立てています。

ブラウザだけで手軽に済ませたいならPDFツール、スキャン書類を検索可能な形で残したいなら scan-ocr、という使い分けです。

デザインシステム

掲載予定です。