Microsoft Word 文書からテキストを抽出するモジュールを書いた

CPAN にはなさそげだったので書いてみました。

名前空間がどうよって気がするけど Word 文書に該当する名前空間が見当たらず,こんな感じにしてしまいました。まぁ coderepos の段階なので。なんかいい名前があったら教えてください。もし CPAN にあげるときにはそれにします。


図をおこしたほうがわかりやすくなるのですが,あんまりに面倒だったので図は描いてません。

Microsoft Word バイナリフォーマット

Word バイナリフォーマット*1の仕様は Microsoft が公開しています。下記からダウンロードすることが可能です。

以下の文章でページ数を記述しているところでは,この仕様書のページ数を示しています。

なお仕様書を読むなら,仕様書 Page 10 「Definitions」セクションと後述する FIB の部分を印刷してそれを参照しながら読むのが得策です。

しかし歴史的経緯*2などがあるためかほんとにひどい仕様です。仕様書もさまざまな略語が飛び交いまるで暗号です。いやほんとに OOo(というより StarOffice?)などの互換ソフトウェアを作る開発者や,この仕様をメンテナンスしつつ実装している Microsoft の技術者を尊敬します。

Compound File Binary Format

Microsoft Word バイナリフォーマット(などの Microsoft Office バイナリフォーマット)は,Compound File Binary Format という形式になってます。概要は Compound File Binary Format - Wikipedia を参照してほしいんですが,簡単にいうと,いくつかの(サブ)ファイルを格納したディスクイメージのような形式です(FAT とかあるし)。

仕様については

からおとせます。


といってもイメージがわきにくいので,実際にみてみましょう。OLE::Storage に付属する lls というコマンドを使うと,ファイルに格納されている「ストリーム」一覧をみることができます。

(なお,OLE::Storage_Lite のディストリビューションに付属する sample/smplls.pl というスクリプトで同様の表示が得られます。てか,Lite じゃないほうの OLE::Storage をインストールするのはあまりおすすめしないです。メンテナンスされてなさそうだし,前述の lls とかインストールしちゃうし,普通の解析なら Lite で十分だし)

$ lls sample.doc
Created directory "analyze"
Processing "sample.doc": 
00:  1 'Root Entry' (pps 0)                           ROOT 13.09.2009 08:08:50
01:  1 'Data' (pps 1)                                 FILE          16b1 bytes 
02:  2 '1Table' (pps 2)                               FILE          4786 bytes 
03:  3 ' CompObj' (pps 6)                             FILE            66 bytes 
04:  4 'WordDocument' (pps 3)                         FILE          6c3d bytes 
05:  5 ' SummaryInformation' (pps 4)                  FILE          1000 bytes 
06:  6 ' DocumentSummaryInformation' (pps 5)          FILE          1000 bytes 
Done.

このように Word 文書の場合はフラットに格納されてますが,Compound File Binary Format にはディレクトリの概念もあるようです。


さて,テキストを抽出するうえでは,上記のうち以下の2つのストリームが重要です。

  • Main Stream
  • Table Stream

Main Stream は上記では「WordDocument」という名前になっているもので,ここに FIB(ファイルのヘッダ; 文書のヘッダではない)やテキスト部分が格納されています。

Table Stream は上記では「1Table」という名前になっているもので,ここには書式設定などさまざまな設定情報が格納されています。なおここでは「1Table」という名前になっていますが,「0Table」の場合もあります。それは後述する FIB の fWhichTblStm というフラグで指定されます。


Compound File からストリームを抜き出すには OLE::Storage_Lite を使うとできます。こんな感じです。

use OLE::Storage_Lite;

my $oledoc = OLE::Storage_Lite->new('sample.doc');

my $name = OLE::Storage_Lite::Asc2Ucs('WordDocument');

# Encode を使って下記のように書いてもよい。
#   use Encode;
#   my $name = encode('UTF-16LE', 'WordDocument');

my ($pps) = $oledoc->getPpsSearch([ $name ], 1);

my $stream = $pps->{Data};

あまりドキュメントに書いてないので難儀しましたが,第一引数にはストリームの名前の候補群を配列リファレンスで指定します。第二引数に上記例のように 1 を指定すると実際のバイナリデータを取得することができます(指定しない場合はストリーム情報のみ取得します)。得られる結果は,マッチしたストリーム群です。なので上記コードでは,ひとつめのストリームを取得しています。んで,ストリームの実体データは Data というキーに格納されています。

Main Stream (WordDocument) の構造

さて,Main Stream は以下のようなレイアウトになっています。

  • FIB (File Information Block)
  • Text
  • PAPX in FKP (Formatted disK Page)
  • CHPX in FKP
  • LVC in FKP

FIB はこのファイルに格納されている様々な情報を示すポインタなどが格納されたヘッダです。仕様書 Page 141 「File Information Block (FIB)」表に詳細な記載があります。

FKP というのは格納されている情報というより概念で,PAPX や CHPX(後述)などの情報が 512 byte のページ単位(セクタ)で格納されている*3ということを示しています。

今回一番重要な Text は文書の純粋なテキスト部分を格納したブロックです。仕様書 Page 29 「Text」セクションに概要が記載されています。

  • main document
  • footnote
  • header
  • macro
  • annotation
  • endnote
  • textbox
  • header textbox

の順番でベタ書きで保存されています(順序は必ずこのとおり)。つまりセパレータがあるわけでもなく,HTML 程度の構造化すらなされていません。

ではどのように各ブロックのテキストを取り出せばよいのかというと,FIB の ccpTextccpHdrTxbx フィールドに各ブロックの「文字数」が保存されているので,それをもとに先頭から切り出せばよいのです。

「文字数」と書きましたが,基本的に Text は UnicodeUTF-16 (Little Endian) で格納されています。なので文字数×2がバイト数となります*4。ただセクタによっては CP1252(ISO-8859-1 の亜種; ASCII 的なもの)で格納されている場合もあるそうです。なので,本当は後述のように PCD マップをもとにバイトオフセットを計算していくべきです。

んで,Text の開始位置と終了位置は FIB の fcMinfcMac に Main Stream 先頭からのオフセットとして格納されています。なので,単純に fcMin から読み出していけばテキスト部分を抽出できるはず。なのですが……

Complex File

Word 文書によっては Complex File 形式になっている可能性があります。文書を高速保存をした場合にComplex File 形式になったりするそうです。

ここまでは,テキストは Main Stream の Text セクションにおいて連続的に保存されていると説明してきました(non Complex File 形式)。Complex File 形式では,この Text セクションには文字列の断片(仕様書の用語的には piece)がばらばらに格納されており,その断片の並び順を記述したテーブル(piece table)を参照しながらテキストを取得していくことになります。

piece table は,Table Stream に information for complex file(CLX)の一部として格納されています。CLX の格納位置は,FIB の fcClx をみるとわかります(Table Stream の先頭位置からのバイトオフセット)。バイト長は FIB の lcbClx にあります。

この CLX の形式は仕様書 Page 83 「Complex File Format」セクションに記述があります。実例をみてみましょう。

00000000  02 1c 00 00 00 00 00 00  00 fa 03 00 00 7c 07 00
00000010  00 40 00 00 08 00 00 00  00 40 00 00 2c 00 00 00
00000020  00                                              

この例では,CLX には piece table しか格納されていません(先頭に grpprl 群が格納されている場合もあります)。で,仕様書 Page 183 「Piece Descriptor (PCD)」をもとに読み解くと次のように分解できます。

  • clxt: 02 (== pclfpcd)
  • cb: 1c 00 00 00 (28 bytes)
  • CPs
    • CP[0]: 00 00 00 00
    • CP[1]: fa 03 00 00
    • CP[2]: 7c 07 00 00
  • PCDs
    • PCD[0]
      • flags: 40 00
      • FC: 00 08 00 00
      • PRM: 00 00
    • PCD[1]
      • flags: 40 00
      • FC: 00 2c 00 00
      • PRM: 00 00

CP 群が先頭にまとまっていたり CP の格納数が PCD の格納数と一致しなかったり(CP 群のほうが一つ多い)しているところに違和感をおぼえますが,これは PLCF という形式で格納されているからです。詳しくは仕様書 Page 16 「PLCF (PLex of Cps(or FCs) stored in File)」を参照してください。

で,この piece table のあらわしているのは

  • 文字オフセット(CP) 0x0000 〜 0x03f9 の文字列断片が Main Stream オフセット(FC) 0x0800〜 に格納されている
  • 文字オフセット(CP) 0x03fa 〜 0x077b の文字列断片が Main Stream オフセット(FC) 0x2c00〜 に格納されている

となります。

Text オフセットは今回の例では単純に Main Stream の先頭からのオフセットですが,第2上位ビット(0x40000000)が立っていない場合(この例)は格納されている文字列が UTF-16LE であること,立っている場合は CP1252 であることを示します。CP1252 の場合,この上位ビットをクリアして2で割る必要があるそうです。


んで。

ちとはまったのは,本来 FIB の fComplex フラグが立っている場合は Complex File 形式です。のはずなんですが,fComplex が立っていないのに Complex File 形式のファイルが手元にありました。なので,fComplex フラグによらず,つねに piece table をもとにファイルの文字列ストリームを処理した方がよさそうです。さいわい non Complex File 形式の場合も piece table は(経験上)存在しているみたいですし。万一それがない場合でも,ダミーの piece table を作るのはそんなに大変ではないですし。

Text に格納される特殊文字

これで Text ブロックから文字列を切り出していけばプレインテキストを取得することができます。ですが,一部特殊な文字コードで表現されている場合があります。

詳細例は仕様書 Page 29 「Text」セクションに記述されています。たとえば段落の終了(改行)はコード 13(0x0d)だったり,テーブルのセル区切り文字はコード 7 だったりします。そのへんは適宜置換すればよいです。

問題は,コンテキストによって特殊文字の意味が定まる場合です。たとえばCHP(「文字」の書式設定)の fSpec が立っている場合,コード 39(0x27)は長い表記の日付をあらわしたりします。これは通常の文字のコードとバッティングしていますね。またさきほどテーブルのセル区切り文字がコード 7 だと書きましたが,PAP(「段落」の書式設定)の fInTable フラグと fTtp フラグが立っている場合はテーブルのロウ(行)区切りとなります。

わたしのモジュールではそのへんの処理はめんどうなので,あっさり無視しています*5

*1:そう,今回作成したモジュールはバイナリフォーマットのみ扱います。Office Open XML Document (.docx) には対応していません。

*2:本文ではネガティブなことを書きましたが,メモリが少ない環境で読み書きしたりディスクを節約しながら高速セーブをしたりということに対応するための工夫のつまったフォーマットでもあります。しかし仕様が相当複雑なのでかえってメモリを消費してしまう気がする。てかこのフォーマットを2006年までひきずったというのはなんとも驚き。はやく Office Open XML 形式に駆逐されてしまえ。ODF でもいいけど。

*3:実際には FKP 部分だけではなく,FIB や Text も 512 byte 単位で alignment されています。

*4:サロゲートペアが存在した場合にどうなるかは知りません。

*5:てか PAP だの CHP だのをまだうまくとりだせてないんです。