[Astro] #163 Office Inspector — Word / Excel / PowerPointの中身を覗くツールを作成
はじめに
前回(#162)は、PDF の中身を覗くツールでした。
[Astro] #162 PDF Inspector — PDFの中身を覗くツールを作成 // PROTOCOL.LAIN
PDF の内部構造を見るツールを、ブラウザだけで動く形で作りました。オブジェクト・描画命令・16 進ダンプの 3 つを同時に表示して、どこを触っても対応する場所が光ります。
lain-lab.com今回は、Word / Excel / PowerPoint の中身を覗くツール(/office-inspector) です。
PDF Inspector を触っているときに、「Word も同じなのか」と気になったのがきっかけです。答えは「似ているが、もっと読みやすい」でした。docx の実体は ZIP ファイルで、中身は XML です。
先に書いておくと、これは 閲覧ソフトではありません。Word の代わりに文書を読むためのものではなく、ファイルを分解して「何が入っているか」を調べる道具です。ページの見た目は描きません。
要点は次の 3 つです。
- 文書の中身・XML・16 進ダンプを、バイト範囲で連動させる
- 画面には出ないが、ファイルに入っているものを一覧にする
- 旧形式(doc / xls / ppt)と、パスワード付きのファイルも開く
以降は、実装の全体を簡単に残しておきます。
完成画面
消したはずの金額が残っている
XML と 16 進ダンプ
操作動画
タブは 7 つあります。
- FINDINGS:見つかったものの一覧。項目を押すと、それが書かれている場所へ飛ぶ
- CONTENT:文書の中身。Word は段落、Excel はセル、PowerPoint は図形ごとの文字とノート
- PART:選んだパートの中身。XML は 1 タグ 1 行で、左端は展開後のバイト位置
- ZIP:ZIP を構成するすべての構造体を、ファイル内の位置の順に
- RELS:パート同士の関係を、入口から辿った木
- MEDIA:画像の一覧と、EXIF(撮影日時・機種・位置情報)
- STATS:サイズの内訳
右には常に 16 進ダンプがあって、どのタブで何を選んでも、対応するバイトが光ります。
docx は「ZIP に入った XML」
実装の前に、作っていて分かったことを書きます。
docx の拡張子を .zip に変えると、ふつうの展開ソフトで開けます。中身はこうです。
[Content_Types].xml 各パートの種類
_rels/.rels 入口。「本体はこのパート」
docProps/core.xml 作成者・最終更新者・日時
docProps/app.xml 会社名・編集時間・アプリのバージョン
word/document.xml 本文
word/styles.xml スタイル
word/comments.xml コメント
word/_rels/document.xml.rels 本文から出る関係(→ 画像、→ コメント)
本文の XML は、こういう形です。
<w:p>
<w:r><w:t>合計金額: </w:t></w:r>
<w:del w:author="佐藤 花子" w:date="2026-09-28T10:12:00Z">
<w:r><w:delText>1,200,000 円</w:delText></w:r>
</w:del>
<w:ins w:author="田中 一郎" w:date="2026-09-29T16:40:00Z">
<w:r><w:t>980,000 円</w:t></w:r>
</w:ins>
</w:p>
w:p が段落、w:r が同じ書式の一区切り、w:t が文字です。HTML の <p> と <span> に近い作りです。
そして w:del が変更履歴の「削除」です。削除した文字は w:delText に、そのまま残っています。 誰が、いつ消したかも一緒に入っています。Word の画面で「最終版」の表示にしていても、ファイルには入ったままです。
Excel のシートの非表示は、もっと単純です。
<sheet name="原価" sheetId="2" state="hidden" r:id="rId2"/>
<sheet name="メモ" sheetId="3" state="veryHidden" r:id="rId3"/>
state="hidden" という属性が 1 つ付くだけで、シートの中身はそのまま入っています。veryHidden にすると Excel の「再表示」メニューにも出なくなりますが、ファイルの中では同じです。
HTML を「ソースを表示」で覚えたのと同じで、構造が分かれば、XML を書いて docx を作ることもできます。サンプルの docx の変更履歴は、実際にそうやって作りました。
構成
src/pages/office-inspector.astro ページ本体(マークアップと HELP)
public/office-inspector/
├─ oi-zip.js ZIP の構造体を、フィールド単位で読む
├─ oi-inflate.js deflate の展開と CRC-32
├─ oi-xml.js XML をバイト列のまま読む
├─ oi-opc.js [Content_Types].xml と .rels。パート同士の関係
├─ oi-word.js Word の本文・変更履歴・隠し文字
├─ oi-sheet.js Excel のセル・共有文字列・非表示のもの
├─ oi-slide.js PowerPoint の図形・ノート・コメント
├─ oi-meta.js プロパティ、画像の EXIF、プリンター名
├─ oi-cfb.js 旧形式の入れ物(CFB)、プロパティセット、VBA
├─ oi-legacy.js doc / xls / ppt から文字と名前を拾う
├─ oi-crypto.js パスワード付きファイルの復号
├─ oi-model.js 全体をまとめて、表示用の行を作る
├─ oi-worker.js Worker の受付
└─ ui-*.js / office-inspector.js 画面
全部で約 7,600 行です。外部ライブラリは使っていません。
画面の部品(仮想スクロール、行ビュー、ツリー、16 進ダンプ)と deflate の展開、AES と SHA は、PDF Inspector のものをそのまま使っています。新しく書いたのは、ZIP・XML・CFB のパーサと、文書の中身を読む部分です。
PDF との違いは、入れ物が 2 段 になっていることです。
sample.docx(ZIP)
└ word/document.xml(パート。圧縮されている)
└ <w:p> … </w:p>(XML の要素)
「ファイルの何バイト目」と「展開後のパートの何バイト目」の 2 種類の位置があります。16 進ダンプは、この 2 つを切り替えて表示します。
ZIP をフィールド単位で読む
ZIP は、目次が後ろにあります。
[ローカルヘッダ 1][データ 1] [ローカルヘッダ 2][データ 2] … [目次] [EOCD]
読む側は、末尾の EOCD → 目次 → 各ローカルヘッダ の順に辿ります。
展開するだけなら、名前とデータの位置が分かれば足ります。このツールでは、構造体の 1 項目ずつに、バイト範囲を持たせています。
const r = new Rec(u8, pos, 'local', '');
r.u32('シグネチャ', v => hex(v) + ' "PK\\3\\4"');
const flags = r.u16('汎用フラグ', v => hex(v, 4));
const method = r.u16('圧縮方式', v => `${v} = ${methodName(v)}`);
Rec は、読むたびに { 名前, 開始, 終了, 値 } を記録します。ZIP タブで「フィールドを表示」を押して、16 進ダンプにカーソルを乗せると、「この 2 バイトが圧縮方式」と出るのは、これをそのまま並べているだけです。
目次に載っていないものを探す
目次を読んだあと、どの構造体にも属さない領域(隙間) を計算して、その中からローカルヘッダのシグネチャ(PK\3\4)を探します。
圧縮データの中にも、偶然同じ 4 バイトが出ます。なので、隙間だけを探します。
見つかったものは、目次に載っていないエントリです。ふつうのソフトからは見えませんが、ファイルには入っています。
目次が無いとき
ファイルが途中で切れていると、末尾の EOCD がありません。そのときは、先頭からローカルヘッダを順に辿って復元します。
サイズがデータの後ろに書かれているエントリは、長さが分かりません。実際に展開してみて、どこで終わったか で、次の位置を決めています。
XML をバイト列のまま読む
ブラウザには DOMParser があります。使わなかった理由は 2 つです。
- 要素が「何バイト目から何バイト目か」を取れない
- Web Worker の中で使えない
文字列にしてから読むと、位置がずれます。日本語は UTF-8 で 1 文字 3 バイトなので、文字の位置とバイトの位置が合いません。そのため、Uint8Array のまま <(0x3C)を探して走査しています。
ノードをオブジェクトにしない
Excel のシートは、数百万ノードになることがあります。ノードごとに { type, s, e } を作ると、メモリが足りなくなります。
項目ごとに、型付き配列で持つことにしました。
doc.type[i] // Uint8Array 種類(要素 / 終了タグ / 文字 …)
doc.s[i] // Int32Array 開始バイト
doc.e[i] // Int32Array 終了バイト
doc.next[i] // Int32Array この要素の中身が終わった、次のノード番号
next があるので、子要素を順に見るのは、こう書けます。
*children(i) {
const end = this.next[i];
let j = i + 1;
while (j < end) {
if (this.type[j] === T.ELEM) { yield j; j = this.next[j]; }
else j++;
}
}
接頭辞を信用しない
w:p の w は、ただのあだ名です。本体は、宣言されている URI です。
<ns0:p xmlns:ns0="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
こう書いてあっても、正しい docx です。Office 以外のソフトが作ったファイルには、実際にあります。
doc.name(i) は、URI を引いて、いつもの接頭辞に直した名前を返します。中身を読むコードは doc.name(i) === 'w:p' と書けば、接頭辞が何であっても動きます。
パート同士の関係を辿る
本文の XML には、画像のパスは書かれていません。あるのは r:embed="rId5" のような ID だけです。対応は、別のファイル(.rels)に書かれています。
<Relationship Id="rId5" Type="…/image" Target="media/image1.png"/>
全部の .rels を読んで、入口(_rels/.rels)から順に辿ります。辿れなかったパートは、どこからも参照されていません。
Office の画面には出ませんが、ファイルには入っています。差し替える前の画像が、こうして残っていることがあります。
PART タブで r:id="rId5" を押すと参照先へ飛べるのは、この対応を使っています。
Word の中身を読む
Walker が本文を上から辿って、段落 1 つを「色付きの断片の並び」にします。
case 'w:ins': case 'w:del': case 'w:moveFrom': case 'w:moveTo':
this.inline(j, this.change(j, nm.slice(2)), segs, st, nested, link); break;
w:del に入ったら、「いまは削除の中」という状態を持って、中のランを読みます。中の文字は、種類 del の断片になります。画面では赤い取り消し線です。
ほかに拾っているもの:
- 隠し文字:ランの書式に
w:vanishがある - 見えにくい文字:色が白(
FFFFFF)、または 1 ポイント以下 - フィールド:
INCLUDETEXTやDDEは、開いたときに外のファイルを読みに行く - 添付テンプレート:パスに、PC のユーザー名が入っている
1 つ注意が要るのは mc:AlternateContent です。「新しい書き方」と「古いソフト向けの代替」の両方が入っています。両方読むと、文字が 2 回出ます。片方だけ読みます。
Excel の中身を読む
セルは、こうなっています。
<c r="B2" t="s" s="3"><v>12</v></c>
t="s" は「共有文字列の 12 番」という意味です。文字そのものは、別のファイル(xl/sharedStrings.xml)にまとめて入っています。同じ文字列を、何度も書かないためです。
日付は、数値です。「45294」のような数に、日付の書式が付いているだけです。書式を見て、日付なら変換して横に出しています。
// 1900 年方式は「1900 年がうるう年」という Lotus 1-2-3 由来の誤りを引き継いでいる。
const base = date1904 ? Date.UTC(1904, 0, 1) : Date.UTC(1899, 11, 30);
1900-02-29 は存在しません。Excel は、昔の表計算ソフトとの互換のために、今もこの日があることにしています。
非表示まわりで拾っているもの:
- 非表示のシート(
hidden/veryHidden) - 値の入った非表示の行と列
- 表示形式
;;;のセル。値はあるが、何も表示しない書式 - 非表示の「名前の定義」
- 最後に保存したフォルダのパス(
x15ac:absPath) - 外部ブックへのリンクと、リンク先のセルの値のコピー
- ピボットテーブルのキャッシュ。元のシートを消しても、明細がここに残る
PowerPoint の中身を読む
スライドの文字は、図形(p:sp)の中にあります。
非表示のスライドは show="0" です。図形の位置(a:off)が、スライドの大きさの外にあれば「枠外」です。スライドショーには出ませんが、ファイルには入っています。
発表者ノートは、スライドの .rels から辿ります。画面には出ませんが、ファイルを渡せば一緒に渡ります。
旧形式も開く
Office 2003 までの doc / xls / ppt は、ZIP ではありません。CFB という、「ファイルの中の小さなファイルシステム」です。
512 バイトのヘッダの後ろに、固定長のセクタが並びます。FAT が「次のセクタ番号」を持っていて、ストリーム(中のファイル)は、セクタの連結リストです。フロッピーディスクの FAT と同じ仕組みです。
FAT なので、消したストリームのセクタは「空き」になるだけで、中身は残ります。 すべてのセクタに用途の印を付けて、どこからも使われていないのにゼロでないセクタを数えています。16 進ダンプでは赤く出ます。
中身は、全部を読み解くのは大仕事なので、文字と、残りやすい情報だけを拾っています。
- doc:本文、保存した人とフォルダの履歴(Word 97 / 2000 は直近 10 回分を記録していた)
- xls:シートの一覧と表示状態、文字列、最後に保存した人の名前
- ppt:文字、最後に編集した人の名前
マクロのソースを取り出す
CFB は、docx の中でも使われています。マクロ(vbaProject.bin)です。
ソースは、独自の圧縮で入っています。「何バイト戻るか」と「何バイト写すか」を 16 ビットに詰めるのですが、その配分が、いまの位置で変わります。
const diff = out.length - chunkStart;
let bc = 4;
while ((1 << bc) < diff) bc++; // 進むほど、「戻る距離」に多くのビットを使う
const len = (token & (0xffff >> bc)) + 3;
const back = (token >> (16 - bc)) + 1;
取り出したソースから、AutoOpen や Workbook_Open のような「開いたときに自動で動く手続き」と、Shell や CreateObject のような「外に作用する呼び出し」を拾って、FINDINGS に出します。
パスワード付きのファイルを開く
パスワードを付けて保存した docx は、ZIP ではなく CFB になります。中に、暗号化された ZIP が入っています。
復号の手順は、こうです(Office 2010 以降の方式)。
- パスワードをハッシュにかけ、それを 10 万回 繰り返す
- 用途ごとの番号を足して、もう一度ハッシュ → 鍵
- 「乱数」と「その乱数のハッシュ」を復号して、突き合わせる → パスワードが合っているか分かる
- 本体を暗号化している鍵を復号する
- 本体を 4096 バイトごとに復号する
10 万回繰り返すのは、総当たりを遅くするためです。1 回試すのに、ブラウザで 0.8 秒ほどかかります。
AES と SHA は、PDF Inspector で書いたものをそのまま使いました。
作っていて知ったのは、Excel の既定のパスワード です。「ブックの保護」だけを掛けたファイルは、VelvetSweatshop という決まったパスワードで、ファイル全体が暗号化されます。開いたときにこれを試して、通れば「誰でも復号できます」と出します。
検証
Apache POI(Java の Office ライブラリ)のテストデータを使いました。1,449 ファイルあって、壊れたファイルや、ファジングで作られた入力も含まれています。
python のライブラリと、結果を突き合わせています。
- ZIP:zipfile と、全エントリの CRC が一致(572 ファイル。残りは zipfile が開けない壊れたファイルで、こちらは復元して開く)
- XML:expat と、要素の位置・名前・名前空間・属性・文字が一致(12,355 パート)
- CFB:olefile と、全ストリームの内容が一致(775 ファイル)
- マクロ:olevba と、ソースが一致(356 モジュール)
- 復号:msoffcrypto-tool と、結果が一致(日本語のパスワードを含む 6 ファイル)
- 全体:画面が呼ぶ関数を全部呼んで、1,449 ファイルで例外 0
文字の取りこぼしは、python-docx / openpyxl / python-pptx が読んだ文字が、こちらに全部あるかで確かめました。
Microsoft Office 本体では、確認していません。サンプルは、LibreOffice と python のライブラリで開けることだけ確かめています。
ハマりどころ
1. サンプルが LibreOffice で開けない
「ZIP の目次に載っていないエントリ」を、サンプルの docx に入れました。このツールの見せ場のつもりでした。
LibreOffice で開いたら、「読み込めません」と拒否されました。目次に無いデータを含む ZIP は、開かないようになっているようです。
サンプルからは外して、テスト用のファイルにだけ残しました。
2. どの doc も「高速保存 15 回」になる
doc の先頭に、高速保存の回数を入れる欄(4 ビット)があります。そのまま出したら、ほとんどの doc が「15 回」でした。
Word 2000 以降は、この欄を使っていません。常に 15 が入っています。別のフラグ(最後の保存が差分の追記か)で判定するようにしました。
これは、テストデータ全体で「どの検出が何件出るか」を数えて気づきました。4 割のファイルで出る検出は、検出ではありません。
3. 暗号化された xls に「非表示のシートが 3 枚」
暗号化された xls では、シート名と表示状態のレコードも暗号化されています。それをそのまま読んで、でたらめな値を「非表示」と判定していました。
暗号化のレコードを見つけたら、その後ろは読まないようにしました。
4. 空の保護
「ブックの保護」の要素があれば、保護されていると判定していました。openpyxl は、保護していなくても空の <workbookProtection/> を書きます。属性があるときだけ、出すようにしました。
5. Worker に渡した配列が消える
16 進ダンプの色分け用の配列を、Worker からメイン側へ渡すとき、コピーせずに所有権ごと渡していました。速いからです。
同じパートをもう一度開くと、色が付きません。Worker 側のキャッシュが、空になっていました。渡した側からは消える、という仕様どおりの動きです。渡す前に複製するようにしました。
6. 入れ子のファイルを開いても、16 進ダンプが前のまま
埋め込まれた xlsx を開いたり、復号した中身を開いたりすると、16 進ダンプだけ前のファイルのままでした。
「いま表示しているものと ID が同じなら、何もしない」としていたのが原因です。どのファイルでも、ファイル全体の ID は file です。ID ではなく、実体が同じかで比べるようにしました。
7. VBA の目次の、1 つだけサイズが合わないレコード
VBA の目次は「ID・サイズ・中身」の繰り返しです。PROJECTVERSION だけ、サイズ欄は 4 なのに、実際は 6 バイト続きます。仕様書にそう書いてあります。
まだ入れていないこと
- 見た目の再現。ページやスライドのレイアウトは描きません
- 消す機能。見つけるだけです。消すのは、Office の「ドキュメント検査」でできます
- XML を書き換えて保存する機能
- xls のセルの位置。文字列の一覧までです
- doc の変更履歴を、本文と区別すること
- 旧形式の暗号化(RC4 / XOR)の復号
- xlsb(バイナリ形式のブック)のシート
- 電子署名の検証
おわりに
作る前は、「これは誰が使うのか」と思っていました。作った後も、半分はそう思っています。
Word には「ドキュメント検査」という機能があって、変更履歴やコメントを見つけて、消してくれます。ふつうは、それで足ります。このツールにできるのは、何が、どこに、どう書かれているかを見せる ことだけです。
それでも、触ってみると面白いです。docx が ZIP で、中身が HTML のような XML だったこと。シートの非表示が、属性 1 つだったこと。消した金額が、消した人の名前と一緒に残っていたこと。
調べてみると、この形式になったのは Office 2007 からです。それまでの doc / xls / ppt は、メモリの中身をそのまま書き出したようなバイナリで、仕様も長く公開されていませんでした。ZIP と XML にしたことで、Office 以外のソフトからも読み書きできるようになりました。
旧形式に「高速保存」や「消し残り」があるのは、フロッピーディスクの頃に、全体を書き直すのが遅かったからです。PDF の追記と、同じ理由でした。
次は、「ファイルを送る前に、何が残っているか」を、Office を知らない人向けのガイド記事にまとめる予定です。