はじめに
「PDFでもらった資料の文章をWordで直したい」という場面はよくあります。ただ、PDF→Word変換のオンラインサービスの多くは、ファイルをサーバーにアップロードして処理します。社外秘の資料や個人情報が入った書類だと、アップロードしたくありません。
そこで、PDFを一切サーバーに送らず、ブラウザの中だけで .docx を作る変換ツールを作りました。
変換方法は2種類です。
- 文字を編集できる形式:PDFの文字を取り出して、Wordの段落・見出し・表として組み立てる
- 見た目を保つ形式:各ページを画像にして、Wordのページに貼り付ける
この記事では、主に前者の「PDFの文字から、編集できるWordの文章を組み立てる」部分を紹介します。日本語のPDFならではの落とし穴もいくつかありました。
構成
-
pdf.js:
page.getTextContent()で文字と位置を取り出す - JSZip:.docx(中身は ZIP)を作る
- Office Open XML は手書き:docx 用のライブラリは使わず、必要な最小限の XML を文字列で組み立てています
静的サイト(Next.js の output: "export"、Cloudflare Pages)なので、サーバー側の処理はありません。
1. pdf.js で文字と位置を取り出す
getTextContent() は、文字列の断片(item)と、その位置を表す変換行列 transform を返します。PDFの座標は左下が原点なので、viewport.convertToViewportPoint() で左上原点(ページの回転も反映)に変換します。
const viewport = page.getViewport({ scale: 1 });
const content = await page.getTextContent();
for (const raw of content.items) {
if (!("str" in raw) || !raw.str) continue;
const [a, b, c, d, e, f] = raw.transform as number[];
const [x, y] = viewport.convertToViewportPoint(e, f);
// 文字の大きさは、変換行列の縦方向の長さから求める
const size = Math.hypot(c, d) || Math.hypot(a, b) || raw.height || 10;
items.push({ str: raw.str, x, y, w: raw.width, size });
}
落とし穴:見た目は同じなのに、違う漢字が入っている
日本語のPDFから文字を取り出すと、「見」のつもりが「⾒」(康熙部首 U+2F92)になっていることがあります。見た目はほぼ同じですが、別の文字なので、Wordで検索や置換をしても見つかりません。
一部のPDF作成ソフトが、通常の漢字の代わりに康熙部首や CJK 互換漢字を使うのが原因です。これらは Unicode の NFKC 正規化で通常の漢字に戻せるので、該当する範囲だけ正規化しています。
// 康熙部首・CJK部首補助(⺀-)と CJK 互換漢字(豈-)だけを NFKC で通常の漢字に
const normalizeKanji = (s: string) => s.replace(/[⺀-豈-]/g, (ch) => ch.normalize("NFKC"));
文字列全体を NFKC にすると、全角英数字や「㈱」なども変わってしまうので、範囲を絞っているのがポイントです。
2. 文字の断片を「行」にまとめる
item は、単語や文字の一部だったりと、単位がばらばらです。まず、ベースライン(y座標)が近いものを同じ行とみなし、左から順に並べます。
// y が文字の大きさの半分以上離れたら、別の行
if (current.length && Math.abs(it.y - current[0].y) > Math.min(it.size, current[0].size) * 0.5) flush();
落とし穴:日本語と英語で「スペース」の扱いが違う
断片をつなぐとき、英語では単語の間にスペースが必要ですが、日本語の文章にスペースを入れると「PDFを 変換 する」のような不自然な文になります。
そこで、隣の断片との隙間と、つなぐ文字の種類で判断しています。
const gap = it.x - lastEnd;
if (text && gap > it.size * 0.25 && (isLatin(text.slice(-1)) || isLatin(it.str[0] ?? ""))) {
text += " "; // 英数字どうしの間:半角スペース
} else if (text && gap > it.size * 1.5) {
text += " "; // 日本語でも大きく離れている(申請書の項目名と値など):全角スペース
}
3. 行を「段落」にまとめる
PDFには「段落」という情報がありません。改行は見た目の折り返しにすぎないので、そのまま1行=1段落にすると、Wordで文章を直したときに変な位置で改行が残ってしまいます。
次のような手がかりで、前の行と同じ段落かを判断しています。
- 前の行が、本文の右端近くまで続いている(=折り返し)
- 行の間隔が、本文の典型的な行間(中央値)から大きく離れていない
- 文字の大きさが同じ
- 前の行が「。」「.」「」」などの文末で終わっていない
const ranToEdge = prev.right > textRight - prev.size * 2.5;
const endsSentence = /[。..!?!?::」』))]$/.test(prev.text);
const continues =
!heading && !center &&
Math.abs(line.size - prev.size) < 1 &&
line.y - prev.y < maxGap &&
(ranToEdge || startsLower || (!endsSentence && prev.right > textRight - pageWidth * 0.2));
あわせて、簡単な書式も推定しています。
- 見出し:本文の典型的な文字サイズ(文字数で重み付けした最頻値)の1.25倍以上なら太字
- 中央揃え:行の中心がページの中央近くにあり、幅がページの6割未満なら中央揃え
縦書きのPDF
縦書きの文書では、pdf.js のフォント情報に vertical が付きます。過半数の item が縦書きなら、x座標で「列」にまとめ、右の列から左へ、各列の中は上から下へ読みます。
4. 罫線の表を Word の表にする
請求書や申請書のPDFには、罫線で囲まれた表がよくあります。表の中の文字を普通の段落にすると、どの値がどの項目か分からなくなります。
表の検出は、PDFの描画命令を解析するのではなく、ページを低解像度(100dpi)でレンダリングして、画素から長い横線・縦線を探す方法にしました。罫線が線・四角形・画像のどれで描かれていても同じように検出でき、pdf.js の内部仕様にも依存しません。
- レンダリングした画像を明るさで2値化する
- 横方向・縦方向に、一定以上の長さで続く暗い画素を「線」とする(数ピクセルの太さの線は1本にまとめる)
- 交わる横線と縦線から、表の行と列の境界を求める
- 境界の間に線がないセルは、結合セルとみなす
Word 側では、横の結合を gridSpan、縦の結合を vMerge で表します。
const merge = continues ? "<w:vMerge/>" : startsMerge ? '<w:vMerge w:val="restart"/>' : "";
xml += `<w:tc><w:tcPr><w:tcW w:w="${tw(w)}" w:type="dxa"/>`
+ `${span > 1 ? `<w:gridSpan w:val="${span}"/>` : ""}${merge}</w:tcPr>${body}</w:tc>`;
セルの中の文字も、同じ段落の処理でまとめます。罫線のない表は検出できないので、その場合は普通の段落になります。
5. .docx を手書きの XML で作る
.docx は、XML ファイルをいくつか入れた ZIP です。文章・表・画像だけなら、必要なファイルは5つで足ります。
| ファイル | 中身 |
|---|---|
[Content_Types].xml |
各ファイルの種類 |
_rels/.rels |
本文(document.xml)の場所 |
word/document.xml |
本文:段落・表・画像・ページ設定 |
word/_rels/document.xml.rels |
本文から参照する画像 |
word/media/*.jpg |
画像(見た目を保つ形式のとき) |
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
zip.file("[Content_Types].xml", contentTypes);
zip.file("_rels/.rels", rootRels);
zip.file("word/_rels/document.xml.rels", documentRels);
zip.file("word/document.xml", `<w:document …><w:body>${bodyXml}${sectPr}</w:body></w:document>`);
return zip.generateAsync({ type: "blob", compression: "DEFLATE",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" });
ライブラリを使わないぶん、単位の違いに注意が必要でした。
| 用途 | 単位 | PDFのポイント(pt)からの換算 |
|---|---|---|
文字の大きさ(w:sz) |
半ポイント | pt × 2 |
| 用紙・余白・表の幅 | twip(1/20 pt) | pt × 20 |
| 画像の大きさ(DrawingML) | EMU | pt × 12700 |
ほかにも、Word は表の直後に段落がないと開けないことがある、XML で使えない制御文字は取り除く、といった細かい点がありました。
6. 見た目を保つ形式
こちらは単純で、各ページを pdf.js で 150dpi にレンダリングし、JPEG(品質0.85)にして、Wordのページいっぱいに貼り付けます。文字は編集できませんが、見た目はPDFと同じになるので、Wordでコメントを付けたいときに使えます。
限界:PDFは「見た目」しか持っていない
PDFは印刷したときの見た目を保存する形式で、段落・段組み・表の構造は持っていません。ここで紹介した方法も、すべて「位置と大きさからの推定」です。そのため、次のようなPDFは完全には再現できません。
- 段組み(2段組みの論文など)
- 罫線のない表
- 文字が画像になっているPDF(スキャンしたPDF)
スキャンしたPDFは文字データがないので、先にOCRで文字を読み取る必要があります。同じサイトのOCRツールは、Tesseract.js(日本語の縦書きにも対応)でブラウザ内で文字を認識し、上の textPagesToDocx と同じ仕組みでWordに保存できます。
まとめ
- pdf.js の
getTextContent()から、行・段落・見出し・表を推定して Word の文章を組み立てた - 日本語のPDFでは、康熙部首の漢字、日本語と英語のスペース、縦書きに注意が必要
- 罫線の表は、ページを画像にして線を探すと、描画方法に依存せずに検出できる
- .docx は、最小限の XML と JSZip だけで作れる
実際に動くものはこちらです。PDFはアップロードされず、すべてブラウザの中で処理されます。
- PDF→Word変換:https://pdftoolsjapan.com/pdf-to-word
- OCR(文字認識):https://pdftoolsjapan.com/ocr-pdf
同じ仕組みで、PDF圧縮・結合・分割など30種類ほどのツールを作っています。