0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザだけでPDFをWord(.docx)に変換する:pdf.js のテキスト抽出と手書きの Office Open XML

0
Posted at

はじめに

「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 の内部仕様にも依存しません。

  1. レンダリングした画像を明るさで2値化する
  2. 横方向・縦方向に、一定以上の長さで続く暗い画素を「線」とする(数ピクセルの太さの線は1本にまとめる)
  3. 交わる横線と縦線から、表の行と列の境界を求める
  4. 境界の間に線がないセルは、結合セルとみなす

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圧縮・結合・分割など30種類ほどのツールを作っています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?