anydoc

Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown

1 — 概要

anydoc は、Word / PowerPoint / Excel / OpenDocument / RTF / EPUB / CSV / PDF を GitHub-Flavored Markdown(GFM)へ変換する Rust ライブラリ。Firecrawl が開発し、同社のホスト型 API Firecrawl Parse の変換エンジンにもなっている。ライセンスは MIT。

14
対応拡張子の系統
4.4 ms
1 文書あたり変換時間の中央値
3
バインディング(Node / Python / wasm)
0
ML モデル・外部サービス依存

設計の中心は「入力形式ごとのパーサはあるが、出力経路は 1 本だけ」という点。各形式のパーサは共通のドキュメントモデルを組み立て、そのモデルを単一の Markdown シリアライザが文字列にする。そのため docx でのテーブルエスケープ修正が、rtf・odt・その他の形式にもそのまま効く。

出力が形式に依存しない
見出しアンカー、テーブル、リスト、脚注のエスケープ規則は入力形式によらず同一。2003 年の .doc でも最新の .pptx でも同じ体裁で出る。
拡張子ではなく中身で判定
PDF ヘッダ、RTF の開きグループ、OLE ストリーム名、ZIP パッケージの mimetype を読む。拡張子が間違っているファイルも正しく変換される。
イベントループと GIL を止めない
Node.js の変換は libuv スレッドプールで走り、Python は変換中に GIL を解放する。TypeScript 型定義と Python 型スタブはパッケージ同梱。
PDF はローカルで処理
テキスト PDF は pdf-inspector 経由でローカル変換。スキャン画像だけの PDF は OCR が必要なため unsupported になる。
2 — 対応形式と形式検出
形式拡張子パーサ(Format の値)
Word.doc, .docx, .docmDoc / Docx
PowerPoint.ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsmPpt / Pptx
Excel.xls, .xlsx, .xlsm, .xlsbExcel
OpenDocument.odt, .ods, .odpOdt / Ods / Odp
Rich Text Format.rtfRtf
EPUB.epubEpub(EPUB 2 / 3)
CSV.csvCsv
PDF.pdfPdf(pdf-inspector に委譲)

検出の 3 経路

形式判定は 3 つの関数に分かれる。to_markdown はまず内容から判定し、判定できない場合に拡張子へフォールバックする。

Format::from_bytes(&bytes);              // Some(Format::Docx)、該当なしは None
Format::from_extension("pptm");          // Some(Format::Pptx)
Format::from_path(Path::new("a.odt"));   // Some(Format::Odt)

同じ 3 関数は Node(formatFromBytes, formatFromExtension, formatFromPath)と Python(anydoc.format_from_bytes など)にも用意されている。

CSV には署名がない: 区切りテキストは内容から判定できないため from_bytes は None を返す。拡張子か、明示的な形式指定(CLI では --format csv)で名指しする。標準入力から CSV を渡す場合は拡張子もないので明示指定が必須。
RTF を装う .doc: 拡張子が .doc でも先頭が {\rtf なら RTF パーサへ回す分岐が入っている。実運用でよくある形式偽装への対処。
3 — 変換パイプライン
document bytes → 形式検出 → 形式別パーサ → Document(共有モデル) → GFM シリアライザ → Markdown

PDF だけはこの流れから外れる。pdf-inspector が Markdown を直接生成するため、ドキュメントモデルを経由しない。したがって PDF に to_document を呼ぶと unsupported エラーになる。

PDF bytes → pdf-inspector → Markdown

コードの入口

src/lib.rs が公開 API を 3 本だけ出している。形式の解決は resolve_format に集約されている。

pub fn to_markdown(path: impl AsRef<Path>) -> Result<String, ConvertError>
pub fn to_markdown_bytes(bytes: &[u8], format: impl Into<Option<Format>>) -> Result<String, ConvertError>
pub fn to_document(bytes: &[u8], format: impl Into<Option<Format>>) -> Result<model::Document, ConvertError>
ディレクトリ役割
src/formats/形式ごとのフロントエンド(doc, docx, ppt, pptx, sheet, odf, epub, rtf, csv, pdf)と detect.rs。
src/model/共有ドキュメントモデル。block.rs / inline.rs / table.rs / list.rs / asset.rs など。
src/render/markdown/GFM シリアライザ。escape.rs(エスケープ)、anchors.rs(見出しアンカー)、table.rs(表出力)。
src/package/ZIP / XML / OLE などパッケージ読み取りと limits.rs の安全上限。
src/shared/形式間で共通の下位処理(番号付け、グリッド、DrawingML、URI 正規化ほか)。
4 — ドキュメントモデル

モデルには解決済みの内容しか入らない。スタイルカスケード、番号付け、参照解決はフロントエンド側で終わらせる。埋め込みアセットはバイト列を保持するため、元アーカイブを閉じた後も Document 単体で使える。

pub struct Document {
    pub blocks: Vec<Block>,   // 読み順の本文
    pub notes: Vec<Note>,     // 脚注・文末注の本体
    pub assets: Vec<Asset>,   // 埋め込みアセットのバイト列
}

Block の種類

バリアント保持する内容
Headinglevel(1 起点、元文書のアウトライン深さ)、anchor(元文書がこの見出しを参照する場合の安定 ID)、content。Word のアウトラインは 6 を超えるため、クランプはレンダラ側の責務。
Paragraph本文のインライン列。
List番号付けを解決済みのリスト。箇条書き・番号付き・入れ子・タスクリストを表現。
Tableセル結合とヘッダ行を含むグリッド。フロントエンドが GridBuilder で組み、消費側は Table::grid を読む。
BlockQuote入れ子可能な引用ブロック。
CodeBlocklang(元文書が言語を示す場合)と改行込みの生テキスト。
Rule水平線。

脚注とアセット

  • Note は id / kind(Footnote か Endnote)/ blocks を持つ。本文側は Inline::NoteRef で id 参照する。
  • Markdown はバイト列を埋め込めないため、埋め込み画像は代替テキストとして出力し、バイト列は document.assets にメディアタイプと出所のパート名付きで残る。
  • 外部 URL を持つ画像は通常の Markdown 画像として出力される。

本文構造として保持されるのは、アンカー付き見出し、太字・斜体・打ち消し、インラインコードとコードブロック、リンクと内部相互参照、元文書の番号を引き継ぐリスト、結合セルを含む表、引用、脚注・文末注、そしてスピーカーノート。

5 — 言語別 API
ターゲットパッケージ名インストール特性
Rustanydoc(crates.io)cargo add anydocedition 2024 / rust-version 1.88
Node.js@firecrawl/anydocnpm install @firecrawl/anydoc非同期。libuv スレッドプールで実行、CLI 同梱
Pythonfirecrawl-anydoc(import は anydoc)pip install firecrawl-anydoc同期。変換中は GIL を解放
ブラウザ@firecrawl/anydoc-wasmnpm install @firecrawl/anydoc-wasm同期・単一スレッド。パス版 API は無い

Rust

// ファイルパスから:
let markdown = anydoc::to_markdown("report.docx")?;

// バイト列から。形式は内容から検出:
let markdown = anydoc::to_markdown_bytes(&bytes, None)?;

// 署名を持たない形式(CSV)は名指しする:
let markdown = anydoc::to_markdown_bytes(&bytes, anydoc::Format::Csv)?;

// ドキュメントモデルで止める(埋め込みアセットも取れる):
let document = anydoc::to_document(&bytes, None)?;

Node.js

import { toDocument, toMarkdown, toMarkdownBytes } from '@firecrawl/anydoc';

const markdown = await toMarkdown('report.docx');   // パスから
const fromBytes = await toMarkdownBytes(bytes);      // 内容から形式検出
const fromCsv = await toMarkdownBytes(bytes, 'csv'); // CSV は名指し
const document = await toDocument(bytes);            // モデルで止める

完全な API は node/README.md、型定義は node/index.d.ts にある。

Python

import anydoc

markdown = anydoc.to_markdown("report.docx")        # パスから
markdown = anydoc.to_markdown_bytes(data)            # 内容から形式検出
markdown = anydoc.to_markdown_bytes(data, "csv")     # CSV は名指し
document = anydoc.to_document(data)                  # モデルで止める

ブラウザ(WebAssembly)

import init, { formatFromBytes, toMarkdownBytes, toDocument } from '@firecrawl/anydoc-wasm';

await init();

const markdown = toMarkdownBytes(bytes);        // 同期呼び出し
const fromCsv = toMarkdownBytes(bytes, 'csv');
const document = toDocument(bytes);
formatFromBytes(bytes);                         // 'docx'、該当なしは undefined
wasm の制約: ファイルシステムが無いため、パスを取る to_markdown は存在せず、変換は常にバイト列から始まる。呼び出しは同期で呼び出し元スレッドを占有するので、メインスレッドを空けたいならワーカーで変換する。ビルドは wasm-pack --target web。Node で使う場合は init ではなくモジュールのバイト列を initSync に渡す。

デモページ(firecrawl.github.io/anydoc)はこの wasm パッケージをそのまま読み込む静的サイトで、ソースは wasm/www/。ブラウザ内で変換するためファイルは端末外に出ない。

6 — CLI と Agent Skill

npm パッケージが anydoc コマンドを同梱するため、インストールなしで npx から使える。初回実行時にプラットフォーム向けのビルド済みバイナリが取得される。常設したい場合は npm install -g @firecrawl/anydoc。

npx @firecrawl/anydoc report.docx               # Markdown を標準出力へ
npx @firecrawl/anydoc slides.pptx -o slides.md  # ファイルへ書き出す
npx @firecrawl/anydoc - --format csv < data.csv # 標準入力から読む
オプション意味
-o, --output <path>標準出力の代わりに指定パスへ書き出す
-f, --format <format>検出に頼らず入力形式を名指しする(拡張子エイリアス xls / docm / ppsx も解決される)
-h, --helpヘルプを表示して終了
-V, --versionバージョンを表示して終了

終了コード

コード意味
0成功
1文書を読めなかった、または変換できなかった
2使い方の誤り(未知のオプション、入力なし、不正な --format)

1 回の呼び出しで 1 文書のみを変換する。失敗時は anydoc: <message> の 1 行を標準エラーへ出す。CLI は対話プロンプトを出さない。

anydoc は Agent Skill 形式でも配布されており、エージェントに文書読解手段を追加できる。スキル定義は skills/convert-documents-to-markdown/SKILL.md。

npx skills add firecrawl/anydoc

スキルがエージェントへ与える指示は次の通り。

RULE 01
Node 20 以上で npx -y 実行
インストール不要。標準出力・-o・標準入力の 3 パターンを使う。
RULE 02
--format は例外時のみ
形式は内容から検出される。標準入力の CSV、拡張子が無い・誤っている場合だけ名指しする。
RULE 03
大きい文書は -o で退避
全文をコンテキストへ流し込まず、ファイルへ書いて必要な部分だけ読む。
RULE 04
コードベース内ではライブラリを使う
Node / Python / Rust のプロジェクトではシェル呼び出しより各言語のパッケージを優先する。API 名は共通。
スキャン PDF の扱い: 画像だけの PDF は OCR が必要で anydoc は OCR を行わないため unsupported として失敗する。ホスト型の Firecrawl Parse API 側で処理する。
7 — エラーと安全上限

変換が失敗するのは「そのファイルから意味のある Markdown が出ない」場合だけ。プロデューサ由来の軽微な破損は回復またはスキップし(log ファサードへ debug / warn で記録)、変換自体は続行する。ログのメッセージは安定 API ではない。

Rust ConvertErrorNode / wasm error.codePython 例外意味
UnsupportedunsupportedUnsupportedError未知の形式、または変換できない形式(画像のみの PDF)
MalformedmalformedMalformedError構造的に使えない。意味のある内容を取り出せなかった
EncryptedencryptedEncryptedError暗号化・パスワード保護されている
ResourceLimitresourceLimitResourceLimitError固定の安全上限(展開・入れ子・ノード数)を超えた
MissingPartmissingPartMissingPartError出力に必須のパートが存在しない
IoioOSErrorファイルを読めなかった(パス版 API のみ。wasm には無い)

Rust 側では Malformed と MissingPart が問題のあったパート名を、ResourceLimit が超えた上限名を持つ。Python では 5 種の変換失敗がすべて anydoc.ConvertError のサブクラスなので、まとめて捕まえられる。format 引数に未対応の名前を渡すと ValueError。

失敗を記録して次へ進むパターン

match anydoc::to_markdown(path) {
    Ok(markdown) => Some(markdown),
    // これらからは文書が出てこないので、記録して次のファイルへ。
    Err(error @ (ConvertError::Encrypted | ConvertError::Unsupported(_))) => {
        unconverted.push((path, error));
        None
    }
    Err(error) => return Err(error),
}

展開爆弾・病的な入れ子・暴走展開への対策として、すべての変換に同じ上限が適用される。上限は設定変更できない。実文書はどの値も桁違いに下回る想定で決められている。

上限値対象
MAX_ENTRY_BYTES128 MiBアーカイブ 1 エントリの展開後サイズ
MAX_TOTAL_BYTES512 MiB1 アーカイブから読む展開後の合計バイト数
MAX_ENTRY_COUNT100,0001 アーカイブ内のエントリ数
MAX_XML_DEPTH256XML 要素の入れ子深さ
MAX_XML_NODES2,000,0001 パート内の XML ノード数(要素+テキストラン)
MAX_EXPANSION4,000,000表の繰り返し展開が生成できる内容ありセル数
MAX_EXPANSION_TEXT_BYTES64 MiB繰り返し展開で複製されるテキストの合計バイト数
MAX_ASSET_TOTAL_BYTES128 MiBDocument に保持する埋め込みアセットの合計バイト数
MAX_RECORD_DEPTH64バイナリレコードコンテナの入れ子深さ(レガシー PPT ストリーム)
MAX_RECORDS16,000,0001 レコードストリームで走査するレコード総数
8 — ベンチマーク結果

14 形式にまたがる実文書 100 件で、他 6 種の変換ツールと速度・品質を比較した結果。スコアは 0〜100 で高いほど良く、速度は 1 文書を変換する時間の中央値。

ツール対応形式中央値 ms判定文書数スコア完全性構造書式清潔さ
anydoc14/144.4948187797881
libreoffice12/141129.5874059424024
unstructured8/14572.9586376595163
markitdown6/14134.8336578666052
pandoc5/14102.1345674575638
docling4/14513.6215760605751
mammoth1/1452.587084717551
行ごとに平均対象が違う: 各ツールの score は、そのツールが対応する形式のスコアを平均している。mammoth の 70 は docx 単独、anydoc の 81 は 14 形式全体。したがって公平な比較は下の形式別テーブル。

形式別スコア

形式anydoclibreofficeunstructuredmarkitdownpandocdoclingmammoth
doc875767----
docm8448-----
docx88565371687170
epub77-727252--
odp8623-----
ods8238-----
odt805168-60--
ppt8026-----
pptx7424-66-52-
rtf885346-45--
xls80386662---
xlsm7632-----
xlsx72306655-47-
METHOD 01
品質は LLM 審査員のペア比較
2 ツールの出力をブラインドで正解と照合する。正解は文書の先頭 6 ページを LibreOffice で画像化したもの。EPUB は元 XHTML から抽出したテキストを使い、CSV は描画が無意味なため除外。
METHOD 02
位置バイアスを打ち消す
各ペアを A/B の位置を入れ替えて 2 回判定し、合計 482 の判定を得る。位置によって結果が変わったものは引き分け扱い。審査モデルは Claude Sonnet 5(Message Batches API 経由)。
METHOD 03
速度はウォーム 1 回
Ryzen 9 9950X3D(Windows 11 / 64 GB DDR5-6400)で 1 文書 1 回のウォーム変換。anydoc と Python ライブラリはプロセス起動を除外、CLI ツールは実運用に合わせて含める。
METHOD 04
決定的な指標も併用
出力ごとの見出し・表・リスト・リンク・脚注の個数と、anydoc 出力との単語 3-gram 包含率。欠落検出用の安価な指標で、正解そのものではない。

ハーネスは bench/ にあり、convert.py → render_truth.py → metrics.py → judge.py → report.py の順に実行して out/report.md を作る。judge.py --dry-run で呼び出し回数を見積もれ、--limit / --formats / --opponents で範囲を絞れる。コーパスは再配布不可のためリポジトリには入っていない。

9 — 開発とリリース
cargo test
cd node && npm install && npm run build && npm test
cd python && pip install maturin && maturin develop && python -m unittest discover -s tests
wasm-pack build wasm --release --target web --scope firecrawl && node --test wasm/test.mjs

テストの層

  • tests/fixtures/ にコミット済みのフィクスチャコーパスをスナップショットテストする。
  • tests/robustness.rs が全フィクスチャをミューテーションテストする。
  • fuzz/ に形式ごとの cargo-fuzz ターゲットを置く(nightly 依存のため Cargo ワークスペースからは除外)。
  • 速度と品質のベンチマークは bench/。

バージョンの置き場所

リリースタグは v<version> で、クレート・npm パッケージ・PyPI ホイールを .github/workflows/release.yml から公開する。バージョンは 3 箇所にあり、リリース時にまとめて上げる。

ファイル対象
Cargo.tomlクレート
node/package.jsonnpm パッケージ
python/Cargo.tomlホイール(python/pyproject.toml が読む)

Cargo ワークスペースのメンバは node / python / wasm。主要な依存は calamine(Excel)、cfb(OLE)、csv、quick-xml、zip、encoding_rs、flate2、pdf-inspector。リリースプロファイルは lto = "thin" とシンボル strip。