作ろうと思ったきっかけ
ClaudeCodeに困っている人が多い分野のツールがないか聞いたら
PDFの一部を黒い四角で隠して公開したら範囲選択とコピーで中身が読めてしまったという事故が繰り返し起きていてそれを防ぐツールは需要があるとの返答だった
この問題を避けるためにブラウザの中だけで墨消しを行うツール クロヌリ を作りました
この記事ではなぜ黒い四角では隠れないのか、それをどう実装で避けたのか、出力をどう検査しているのかを書きます。
なぜ図形を重ねても消えないのか
PDFのページは画像ではなく、描画命令の列(コンテンツストリーム)です。
テキストを含むページは、たとえば次のような命令を持っています。
BT
/F1 12 Tf
72 700 Td
(Kasou Hanako) Tj % ← 隠したい文字
ET
ここに黒い四角を「重ねる」と命令列の後ろに長方形の塗りつぶしが足されるだけです。
0 0 0 rg
70 695 120 18 re f % ← 黒い長方形を描く
後から描くので画面では上に見えますが、Tj(文字を描く命令)はそのまま残っています。
そのためテキスト選択・コピー・検索・テキスト抽出のどれでも読めます。
重ねた図形が注釈であれば、図形を選んで削除するだけで元に戻ります。
PDF編集ソフトの正式な墨消し機能は、下にあるテキストまで削除してくれます。
それでも事故は起きます。
原因は機能が無いことではなく操作の取り違えで、墨消し機能ではなく注釈の図形で隠してしまう、マークを付けたあとの「適用」を忘れるといったケースです。
直さずに作り直す
元のPDFから危ないものを1つずつ消していく方法は、消し忘れとの戦いになります。
テキスト、注釈、フォームの入力値、埋め込みファイル、JavaScript、メタデータ、増分更新の履歴とPDFには隠れる場所が多いからです。
そこで方針を逆にしました。
- ページをCanvasに画像として描く(PDF.js)
- Canvasの上で直接塗る(
ctx.fillRect) - CanvasをJPEG/PNGに書き出す
- 新しいPDFを空の状態から組み立てる(pdf-lib)
出力に入るのは「塗ったあとのページ画像」だけです。
元のテキストや注釈は新しいファイルに持ち込まないので、1つずつ削除する処理は要りません。
作り直すこと自体がサニタイズになります。
1. 描いて塗って書き出す
分かりやすくするために簡略化したコードが以下
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs';
type Rect = { x: number; y: number; w: number; h: number }; // 0.0〜1.0
async function rasterizePage(
pdf: pdfjs.PDFDocumentProxy,
pageNumber: number,
rects: Rect[],
dpi: number,
): Promise<Blob> {
const page = await pdf.getPage(pageNumber);
// ページの /Rotate を反映しないと縦横が入れ替わる
const viewport = page.getViewport({ scale: dpi / 72, rotation: page.rotate });
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
const ctx = canvas.getContext('2d')!;
await page.render({ canvasContext: ctx, viewport }).promise;
// ★ 図形として重ねるのではなく、画素を塗り替える
ctx.fillStyle = '#000';
for (const r of rects) {
ctx.fillRect(r.x * canvas.width, r.y * canvas.height, r.w * canvas.width, r.h * canvas.height);
}
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.85),
);
// 300dpi の A4 は Canvas だけで約 35MB。すぐに解放する
canvas.width = 0;
canvas.height = 0;
page.cleanup();
return blob;
}
実装で気を付けた点
- 矩形は 0.0〜1.0 の正規化座標で持つ。 画面の倍率と出力の解像度が違うためです。正規化しておけば、あとから解像度を 150 / 200 / 300dpi で切り替えても位置がずれません
- 正規化の基準は 回転を反映した後の見た目の向きにします。MediaBoxを基準にすると、回転したページでずれます
-
devicePixelRatioは出力の解像度に影響させません。表示用と出力用のスケールを分けています - 全ページのCanvasを配列に持たない。 1ページずつ描いて、埋め込んだらすぐ捨てます。これを怠ると、数十ページでタブが落ちます
2. 空のPDFから組み立てる
import { PDFDocument } from 'pdf-lib';
// updateMetadata: false にしないと、pdf-lib が Producer / Creator /
// CreationDate / ModDate を自分で書き込む(=「いつ処理したか」が残る)
const out = await PDFDocument.create({ updateMetadata: false });
for (const img of pageImages) {
const embedded = await out.embedJpg(img.bytes);
// px × 72 / dpi で、元のページと同じ物理サイズに戻す
const width = (img.pixelWidth * 72) / img.dpi;
const height = (img.pixelHeight * 72) / img.dpi;
out.addPage([width, height]).drawImage(embedded, { x: 0, y: 0, width, height });
}
const bytes = await out.save();
PDFDocument.load(元のPDF) から加工しないことが一番の要点です。読み込んだ文書を編集すると、増分更新の履歴や参照されていないオブジェクトを引き継ぐおそれがあります。「元を活かして軽くしたい」という最適化をした時点で、この方式の前提が崩れます。
画像ファイル(JPEG / PNG)も同じように Canvas を通します。書き出すと EXIF(GPS 座標・撮影日時・端末情報)が落ちます。ただし EXIF の Orientation(写真の向き)をデコード時に反映するかどうかはブラウザによって差があるので、Orientation だけは自分で読み、回転してから描いています。
出力をその場で読み直して検査する
作り方が正しくても使う人は自分の操作が正しかったと信じるしかありません。
そこでダウンロードさせる前に、生成したバイト列をPDF.jsでもう一度開いて測っています。
// 簡略化したもの
export async function inspectPdf(bytes: Uint8Array) {
// PDF.js は渡したバッファを使い回すので、複製してから渡す
const pdf = await pdfjs.getDocument({ data: bytes.slice() }).promise;
let textCharacters = 0;
let annotations = 0;
for (let i = 1; i <= pdf.numPages; i += 1) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
for (const item of content.items) {
if ('str' in item) textCharacters += item.str.length;
}
annotations += (await page.getAnnotations()).length;
page.cleanup();
}
const { info } = await pdf.getMetadata();
const attachments = await pdf.getAttachments();
return {
pageCount: pdf.numPages,
textCharacters, // 期待値 0
annotations, // 期待値 0
hasAcroForm: Boolean((info as { IsAcroFormPresent?: boolean }).IsAcroFormPresent),
hasEmbeddedFiles: attachments !== null && Object.keys(attachments).length > 0,
};
}
画面にはこう表示します。
抽出されたテキスト: 0 文字
注釈: 0 件
フォーム: なし
埋め込みファイル: なし
メタデータ: 削除済み
ページ数: 3 ページ
ポイントは検査の対象を「作ったCanvas」ではなく「出力したバイト列」にしていることです。
作った側の理屈で「消したはず」と判断するのではなく受け取った人と同じ手順で読み直した結果を見せます。
表示する文言にも気を付けています。「抽出されたテキスト: 0 文字」は測った事実です。
一方で「完全に削除されました」「復元できません」は保証になってしまうので書きません。
自己検査が見逃すもの
1つ落とし穴がありました。「0文字」は塗って消えたのか描けずに消えたのかを区別できません。
たとえばPDF.jsに日本語用のCMapを渡し忘れると、日本語の文字が 1 つも描かれないまま出力されます。
この場合も、出力を読み直すと0文字で「合格」になります。
内容の消えたファイルが、検査を通って出てくるわけです。
そのためE2Eテストでは例外が出ないことではなく、描いたCanvasの画素が実際に黒いことを確かめています(文字が消えても例外は出ないため)。
// Playwright 内で、描画したページの暗い画素の割合を測る
expect(darkRatio, '日本語が描かれていない(白紙に近い)').toBeGreaterThan(0.001);
この方式の欠点
良いことばかりではないので欠点も書いておきます。
- 画像になるため出力PDFの文字は検索・選択できない。受け取った人が本文を検索したい場合には向きません
- ファイルが大きくなります。 文字だけの書類はグレースケールのJPEGにして抑えていますが、元のテキストPDFよりは重くなります
処理はブラウザの中だけで行う
墨消しを必要とする文書はたいてい外に出したくない文書です。
そのため処理はすべてブラウザ内で行いサーバーには送りません。
これは口で言うだけでは確かめようがないので、開発者ツールのNetworkを開いたまま使えば確かめられるようにしています。ファイルを開いてから保存し終えるまで、リクエストは 1 件も出ません。
これを守るのは思ったより大変でした(PDF.jsのワーカー、CMap / wasm の読み込み、Safari 対応、CSP など)。その話は別の記事にまとめています。
まとめ
- PDF に図形を重ねても、下のテキスト描画命令は残る
- 危ないものを個別に消すのではなく、ページを画像にして空のPDFから作り直す
- 生成したバイト列を読み直して測った値を見せる。ただし「0 文字」は描画失敗でも出るのでテスト側で画素を確かめる
ツールはここで公開しています。
登録なしで3ファイルまで試せます。