請求書や領収書の PDF に「日付_取引先_金額」のファイル名を付ける作業を自動化したくて、PDF から 3 項目を読み取る処理をブラウザだけで作りました。書類には取引先名や金額が入っているので、サーバーに送らずに処理を完結させたかったのが理由です。
この記事では、pdf.js で文字と座標を取り出してから、日本語の請求書に特有の問題を乗り越えて 3 項目を推定するまでを、実際に踏んだ落とし穴と一緒に書きます。OCR は使いません。文字が埋め込まれた PDF が対象です。
作ったものは 電帳リネーム として公開しています(ブラウザ完結。文字が埋め込まれた PDF なら無料で 1 回 5 ファイルまで)。仕組みより先に動くものを見たい方は、そちらで PDF を放り込んでみてください。
全体の流れ
- pdf.js の
getTextContent()で「文字列 + 座標」の断片を取る - 同じ高さの断片を 1 行にまとめる
- 行の配列から、日付・金額・取引先・書類種別・書類番号を推定する
3 は pdf.js に依存しない純粋な関数にしました。テストは文字列から行の配列を作って回せるので、PDF を用意しなくても済みます。
// 推定の入口。pdf.js を知らない
export function extractFields(lines: Line[], options: ExtractOptions = {}): ExtractedFields {
const date = extractDate(lines);
const amount = extractAmount(lines);
const party = extractParty(lines, { selfName: options.selfName });
const docType = extractDocType(lines);
return { date: date.value, amount: amount.value, party: party.value, docType,
docNumber: extractDocNumber(lines, docType),
sources: { date: date.source, amount: amount.source, party: party.source } };
}
1. pdf.js から断片を取る
Vite + TypeScript で pdfjs-dist を使います。Worker の URL は Vite の ?url で渡します。
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
export async function readPdfLines(data: ArrayBuffer, maxPages = 2) {
const doc = await pdfjs.getDocument({ data: new Uint8Array(data) }).promise;
try {
return await linesFromDocument(doc, maxPages);
} finally {
await doc.destroy();
}
}
getTextContent() の各 item から使うのは str、transform[4](x)、transform[5](y)、width、height です。y は左下が原点なので、上の行ほど大きくなります。
pdf.js は大きい(約 1 MB)ので、import() で最初のファイルが投入されたときに読み込むようにしました。
2. 断片を行にまとめる
ここが日本語 PDF の最初の壁でした。日本語の請求書は 1 文字ずつ別の断片になっていることが多く、そのままでは「請求書」という語すら 1 つの文字列として得られません。
同じ高さの断片を 1 行にまとめます。「同じ高さ」は、文字の高さの半分以内の y のずれとしました。
const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x);
const buckets: TextItem[][] = [];
for (const it of sorted) {
const last = buckets[buckets.length - 1];
if (last) {
const ref = last[0];
const tol = Math.max(ref.height, it.height, 1) * 0.5;
if (Math.abs(ref.y - it.y) <= tol) { last.push(it); continue; }
}
buckets.push([it]);
}
行の中では x 順に並べて結合します。断片の間隔が文字の高さの 2 倍を超えていたら空白を 2 つ入れ、あとで「宛先」と「差出人」のように左右に並んだ欄を分けられるようにしました。
3. 文字の正規化で踏んだ 3 つの落とし穴
行にまとめたあと、照合の前に文字を整えます。ここで 3 つの問題に当たりました。
康熙部首(見た目は同じ「日」なのに別の文字)
Mac や Chrome で「PDF として保存」した書類は、「日」が U+2F47(康熙部首の「⽇」)になっていることがあります。見た目は同じでも /日付/ にマッチしません。normalize("NFKC") で通常の漢字に戻ります。
字間を空けた表題「請 求 書」
表題を「請 求 書」のように字間を空けて組んだ書類が多くあります。書類種別の照合のときだけ、日本語の文字の間の空白を詰めてから照合します。
Stripe が作る PDF の U+0000
Stripe 経由で発行される請求書(海外の SaaS に多い)は、フォントに無いハイフンや括弧を U+0000 で出力します。請求書番号の ABC-123 が ABC と 123 の間に U+0000 が入った文字列になり、番号が壊れます。数字を含む英数字の並びに挟まれた U+0000 は「-」に、それ以外は空白に置き換えました。
export function normalizeLineText(s: string): string {
return s
.replace(/([A-Z0-9]*[0-9][A-Z0-9]*)\u0000(?=[0-9])/g, "$1-")
.replace(/\u0000/g, " ")
.normalize("NFKC")
.replace(/[!-~]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0))
.replace(/¥/g, "¥")
.replace(/[ \t ]/g, " ")
.replace(/ {3,}/g, " ")
.trim();
}
4. 日付の推定
日付の形式は 9 種類に対応しました。R6.1.31、令和6年1月31日、2024/01/31、2024-1-31、2024.1.31、Jan 31, 2024、31 Jan 2024 など。元号付きは西暦形式より先に判定します。
20240131 のような 8 桁は日付として扱いません。金額や番号と区別できないからです。
候補が複数あるときの優先順位は次のとおりです。
- 「発行日」「請求日」「Date」のようなラベルがある行の日付(同じ行に無ければ直下の行)
- 「支払期限」「Due Date」「有効期限」の行は除外
- どちらも無ければ、除外行以外で最初に出てくる日付
ブラウザの印刷機能で作った PDF は、ヘッダーに印刷日時(2026/9/14 10:23)が入ります。これを取引日と誤認したので、「日付 + 時刻」の形の行と「表示日」「印刷日」を含む行は除外しました。
5. 金額の推定
「税込の合計」が欲しいのですが、書類には小計・税額・税抜・合計が並んでいます。
- 「ご請求金額」「合計」「Total」「Amount Due」のようなラベルがある行で、ラベルの右にある金額(無ければ直下の行)。複数あれば最大
- 「小計」「消費税」「税抜」「Subtotal」「Sales Tax」の行は除外
- ラベルが無ければ、除外行以外の金額らしい候補の最大
「Tax」は要注意で、Tax (10%) の行は除外したいが Total (tax included) は採りたい。Tax を含み、かつ incl/税込 も合計のラベルも含まない行だけ除外しています。
外貨($、€、USD など)で合計が書かれた書類は、円の金額が書類に無いので推定せず、画面で「円で入力してください」と案内します。
6. 取引先の推定
一番むずかしい項目です。書類には「自分(宛先)」と「相手(差出人)」の両方の名前が入っています。
- 「販売:」「領収者:」「発行元:」「Sold by:」のように差出人を示すラベルの付いた欄を最優先
- 「御中」「様」「Bill To」の付く欄、利用者が設定した自分の事業者名、金額の文、著作権表示は除外
- 文書の上部 30% にある行で、「株式会社」「(株)」「合同会社」「Inc.」「Ltd.」などの法人格を含む欄
- 無ければ、インボイスの登録番号(
T+ 13 桁)の直前の行、または上部で最も文字が大きい欄
「株式会社 ココナ」「ラ」のように名前の末尾が次の行に折り返されていることがあり、上の行の欄が法人格で始まって短ければ次の行を足す処理も入れました。
精度
自分で作った試験用 PDF 5 件と、実際に受け取った請求書・領収書 11 件(通販の領収書、海外 SaaS の請求書、ブラウザの印刷、スキャン画像)で確かめました。会計ソフト(freee、マネーフォワードなど)の出力はまだ含まれていません。11 件のうち文字の層がある 10 件では、日付・金額・取引先・書類種別・書類番号の 50 項目すべてが期待どおりでした。残る 1 件はスキャン画像で、文字が取れないので手入力の対象です。
最初は 31/55 でした。上に書いた康熙部首、U+0000、印刷ヘッダーの日付、外貨、取引先のラベルの対応で 50/55 まで上がっています。母数が小さいので、この先は利用者の書類で崩れる場所が出てくるはずです。
テストの作り方
推定の関数は Line[] を受け取るので、テストでは文字列から行の配列を作ります。
const lines = linesFromText(`
請求書
発行日: 2024年1月31日
株式会社サンプル
合計 ¥110,000
`);
expect(extractFields(lines).date).toBe("2024-01-31");
実際の PDF を使うテストは、pdf.js の Node 用(legacy)ビルドで別に用意しました。行にまとめる処理は pdf.js 本体を import せず、PDFDocumentProxy のうち使う部分だけを型で受け取るようにしたので、ブラウザ版と Node 版の両方で同じコードが動きます。
まとめ
- pdf.js の断片は「同じ高さでまとめる」だけで日本語の書類がかなり読める
- NFKC 正規化、字間の空白、U+0000 の 3 つは日本語と Stripe の PDF を扱うなら避けて通れない
- 推定は「ラベル優先 → 除外 → 残りから選ぶ」の順で組むと、書類ごとの違いに耐えやすい
ツールは https://dencho-rename.com/ で使えます(文字が埋め込まれた PDF なら無料で 1 回 5 ファイルまで)。読み取りに失敗する書類があれば、どんな形の書類だったかを 意見箱 で教えてもらえると助かります(PDF そのものは送らないでください)。