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?

25万文字のdocxをPythonで殴る ―― 紛失原稿のPDFコピペ地獄から完全版を作るまで

0
Posted at

はじめに

『シュガーテイルへようこそ』の最終章を書き上げ、いよいよ前3巻と合わせた完全版を作ろう――というところで、ひとつ問題が発覚しました。

2巻だけ、原稿そのものが行方不明。手元にあるのはPDFだけ。

仕方なくPDFから本文を救出するわけですが、ここで地味に重要な判断があります。「テキスト抽出ツールを通すか」「PDFビューア上で素直にコピペするか」。色々試した結果、コピペが一番レイアウトの崩れが少ないという結論に落ち着きました。抽出ツールは賢いぶん、たまに余計なことをして段落をぐちゃぐちゃにしてくれるんですよね。

ところが、コピペにはコピペの呪いがあります。貼り付けた本文を見ると、

  • 段落の区切りがない、のっぺりした文体の塊
  • 文字と文字の間に、なぜか入り込んだ空白
  • そして約80ページぶんのページ番号が本文に紛れている

……という、なかなか手強い状態でした。

しかも嫌な予感がします。「2巻がこれなら、ほかの巻も実は同じような不備を抱えているのでは?」と。だったら全部まとめて、合計25万文字のdocxをPythonにかけて一気にレイアウト調整してしまおう、というのが今回の話です。

やりたかったこと

整理すると、欲しい処理は3つでした。

  1. 行末についてくるページ番号を消す(........ 42本文 42 のような形)
  2. 文字の間に入った**空白(半角・全角・タブ)**をまとめて除去する
  3. 必要なら、見出しの先頭番号1.1.1 のような節番号)も落とす

そして入力は .docx.txt の両方を受けられるようにしておきたい。せっかくスクリプトを書くなら、2巻だけでなく全巻、ついでに今後の原稿整理にも使い回せる形にしたかったからです。

設計方針

ポイントは「1行ずつ処理する」ことです。25万文字を1つの文字列としてドカッと正規表現にかけると、ページ番号判定の $(行末)が思った通りに効かなかったり、意図しない箇所を巻き込んだりします。なので、いったん splitlines() で行に分解し、各行に対してクリーニングをかけてから \n で再結合する、という流れにしました。

処理のオン・オフはコマンドライン引数で切り替えられるようにして、「空白は消したいけどページ番号判定の挙動だけ確認したい」みたいなときに柔軟に対応できるようにしています。

コアになるクリーニング関数

心臓部はこの関数だけです。

import re

def clean_text(text: str, remove_end: bool = True,
               remove_start: bool = False, remove_spaces: bool = True) -> str:
    """ページ番号・見出し番号・連続する空白を除去する"""
    result = text

    # 行末のページ番号(ドット連続 or 空白)+ 数字 を除去
    if remove_end:
        result = re.sub(r'(?:\.+|[ \t\u3000]+)\d+$', '', result, flags=re.MULTILINE)

    # 行頭の節番号(1. / 1.1 など)を除去
    if remove_start:
        result = re.sub(r'^\d+(?:\.\d+)*\s+', '', result, flags=re.MULTILINE)

    # 半角スペース・タブ・全角スペースをまとめて除去
    if remove_spaces:
        result = re.sub(r'[ \t\u3000]+', '', result)

    return result

正規表現を3つに分けて、それぞれの役割を独立させたのがミソです。

行末ページ番号: (?:\.+|[ \t\u3000]+)\d+$

目次やフッターから紛れ込むページ番号は、たいてい「ドットの連続」か「空白」のあとに数字が続く形をしています。(?:\.+|[ \t\u3000]+) でそのどちらかを受け、\d+$ で行末の数字を捕まえます。\u3000(全角スペース)を入れているのが日本語原稿ならではのポイントで、これを忘れると全角空白区切りのページ番号を取りこぼします。

行頭の節番号: ^\d+(?:\.\d+)*\s+

1. 1.1.2 のような階層番号に対応。(?:\.\d+)* で「.数字」の繰り返しを許しているので、何階層でも食べてくれます。小説本文では基本オフ(デフォルト False)にしておき、技術文書を整形するときだけ有効化する想定です。

空白除去: [ \t\u3000]+

ここでも \u3000 が効きます。PDFコピペで紛れ込む謎の空白は半角とは限らないので、半角スペース・タブ・全角スペースを一網打尽にします。

docxの読み込み

.docxpython-docx で段落テキストを引っ張り出します。

import docx
from pathlib import Path

def extract_text_from_docx(path: Path) -> str:
    document = docx.Document(path)
    return '\n'.join(para.text for para in document.paragraphs)

段落(paragraph)ごとに改行を挟んで結合するだけ。シンプルですが、これで「行単位処理」に持ち込めます。

ファイル種別の振り分け

入力ファイルの拡張子を見て、読み込み方法を切り替えます。

def process_file(input_path, output_path, remove_end, remove_start, remove_spaces):
    suffix = input_path.suffix.lower()
    if suffix == '.docx':
        raw_text = extract_text_from_docx(input_path)
    elif suffix in {'.txt', '.md', '.py', '.csv'}:
        raw_text = load_text_file(input_path)
    else:
        raise ValueError(f'Unsupported file type: {suffix}')

    cleaned_lines = [
        clean_text(line, remove_end=remove_end,
                   remove_start=remove_start, remove_spaces=remove_spaces)
        for line in raw_text.splitlines()
    ]
    cleaned_text = '\n'.join(cleaned_lines)
    save_text_file(output_path, cleaned_text)
    return cleaned_text

.txt だけでなく .md .py .csv も読めるようにしておいたのは、原稿整理の周辺作業(メモやスクリプトの掃除)にもそのまま使えるからです。

CLIとして仕上げる

argparse でコマンドラインツールにします。

import argparse
from pathlib import Path

def parse_args():
    parser = argparse.ArgumentParser(description='TXT・DOCXファイルのテキストを整形する')
    parser.add_argument('input', type=Path, help='入力ファイル (.docx または .txt)')
    parser.add_argument('-o', '--output', type=Path, help='出力テキストファイルのパス')
    parser.add_argument('--no-remove-end', action='store_false', dest='remove_end',
                        help='行末のページ番号を残す')
    parser.add_argument('--remove-start', action='store_true',
                        help='行頭の節番号 (1. や 1.1) を除去する')
    parser.add_argument('--no-remove-spaces', action='store_false', dest='remove_spaces',
                        help='文字間の空白を除去しない')
    return parser.parse_args()

action='store_false' を使うと「デフォルトON、フラグを付けるとOFF」という挙動になります。--no-remove-end のような否定形フラグはこのパターンで素直に書けるので、普段使いの安全側(消す)をデフォルトにしつつ、必要なときだけ止められます。

出力ファイル名は、指定がなければ元ファイル名に _cleaned.txt を足して自動生成します。

def create_output_path(input_path: Path) -> Path:
    return input_path.with_name(input_path.stem + '_cleaned.txt')

使い方

# 基本(ページ番号と空白を除去)
python textclean.py sugertail_vol2.docx

# 節番号も落としたい技術文書向け
python textclean.py manual.docx --remove-start

# ページ番号はあえて残す
python textclean.py draft.txt --no-remove-end

# 出力先を指定
python textclean.py sugertail_vol2.docx -o vol2_clean.txt

実行すると、処理後に保存先と総行数を出力します。

Saved cleaned text to: sugertail_vol2_cleaned.txt
Total lines: 3812

やってみてわかったこと

実際に全巻通したところ、案の定2巻以外にも細かい空白の混入が見つかりました。「予感は当たる」というやつです。一括でかけておいて正解でした。

一方で、完全自動では終わらないのも事実です。たとえば段落の区切り(空行)の復元は、ページ番号や空白の除去とは別問題で、文意を読まないと正しく入れられません。今回のスクリプトは「機械的に消せるノイズを一掃する」ところまでが守備範囲で、そこから先の段落整形は目視で詰めました。とはいえ、80ページぶんのページ番号を手で消す地獄からは確実に解放されたので、費用対効果は十分です。

まとめ

紛失した原稿のPDFを救出する、という後ろ向きな作業から始まりましたが、

  • PDFは抽出ツールよりコピペのほうがレイアウトが崩れにくい
  • コピペ後のノイズ(ページ番号・空白)は正規表現で機械的に一掃できる
  • 日本語原稿では \u3000(全角スペース)対応が地味に効く
  • 「1行ずつ処理」にすると行末判定が安定する
  • CLI化しておくと全巻・今後の原稿にも使い回せる

といった形で、25万文字を現実的な時間で整えることができました。同じように「昔の原稿がPDFしか残ってない」と頭を抱えている人の参考になれば幸いです。

完全版『シュガーテイルへようこそ』、無事に世に出せそうです。

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?