Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ version = "0.2.4"
edition = "2024"
# Edition 2024 needs 1.85; zip raises it to 1.88.
rust-version = "1.88"
description = "Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown"
description = "Convert documents (doc, docx, odt, rtf, epub, pdf, ofd, presentations, spreadsheets, csv) to GitHub-Flavored Markdown"
license = "MIT"
repository = "https://github.com/firecrawl/anydoc"
readme = "README.md"
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![skills.sh](https://skills.sh/b/firecrawl/anydoc)](https://skills.sh/firecrawl/anydoc)

Fast Rust library that converts documents (Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF) into clean GitHub-Flavored Markdown. Includes bindings for [Node.js](node/README.md), [Python](python/README.md), and the [browser](wasm/README.md) (WebAssembly).
Fast Rust library that converts documents (Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, PDF, and OFD) into clean GitHub-Flavored Markdown. Includes bindings for [Node.js](node/README.md), [Python](python/README.md), and the [browser](wasm/README.md) (WebAssembly).

Built by [Firecrawl](https://firecrawl.dev) to turn any office document into LLM-ready Markdown in single-digit milliseconds, with one consistent output no matter which format goes in. It powers [Firecrawl Parse](https://firecrawl.dev/parse), so if you'd rather not run it yourself, the hosted API gives you the same conversion plus our OCR models for the scanned pages anydoc can't read on its own.

Expand Down Expand Up @@ -134,7 +134,7 @@ let document = anydoc::to_document(&bytes, None)?;

## OCR

anydoc reads text-based PDFs locally but does no OCR, so a PDF with scanned or image-only pages fails with `NeedsOcr`. Opt in and those documents go to [Firecrawl Parse](https://firecrawl.dev/parse), which OCRs them and returns the same Markdown. No signup needed; set `FIRECRAWL_API_KEY` for higher limits.
anydoc reads text-based PDFs and OFDs locally but does no OCR, so scanned or image-only pages fail with `NeedsOcr`. For PDFs, opt in and those documents go to [Firecrawl Parse](https://firecrawl.dev/parse), which OCRs them and returns the same Markdown. The hosted PDF parser does not accept OFD; scanned OFD pages still return `NeedsOcr`. No signup needed; set `FIRECRAWL_API_KEY` for higher limits.

| | Opt in | Key, else `FIRECRAWL_API_KEY` |
| ------ | ---------------------------------------------- | ----------------------------- |
Expand Down Expand Up @@ -168,6 +168,9 @@ Only documents that need OCR leave the machine, and the whole document goes, sin
| EPUB | `.epub` |
| CSV | `.csv` |
| PDF | `.pdf` |
| OFD | `.ofd` (text objects) |

OFD conversion preserves outline headings, large-font headings, bold and italic text, URI links, printed lists, and complete bordered table grids. Tables and large-font headings are inferred from fixed-page geometry; pages without those cues remain paragraphs. Image-only pages return `NeedsOcr`; images, vector drawings, and non-link annotations are not included in Markdown.

## Benchmark

Expand Down Expand Up @@ -242,7 +245,7 @@ match anydoc::to_markdown(path) {
| Variant | Meaning |
| --------------- | ------------------------------------------------------------------- |
| `Unsupported` | Unknown format, or one that cannot be converted |
| `NeedsOcr` | Scanned or image-only pages of a PDF, listed in `pages` |
| `NeedsOcr` | Scanned or image-only PDF/OFD pages, listed in `pages` |
| `Malformed` | Structurally unusable: no meaningful content could be extracted |
| `Encrypted` | Encrypted or password-protected |
| `ResourceLimit` | Crossed a fixed safety limit (decompression, nesting, node count) |
Expand All @@ -251,7 +254,7 @@ match anydoc::to_markdown(path) {

Node and wasm publish the variant name on `error.code`; Python raises one `anydoc.ConvertError` subclass per variant, or `OSError` when the file cannot be read.

anydoc converts locally and does not do OCR, so a PDF with scanned pages fails with `NeedsOcr`. Opt in with `ocr: 'hosted'` in Node, `ocr="hosted"` in Python or `--ocr hosted` on the CLI to send that document to [Firecrawl Parse](https://firecrawl.dev/parse). No signup needed. Set `FIRECRAWL_API_KEY` for higher limits.
anydoc converts locally and does not do OCR, so a PDF or OFD with scanned pages fails with `NeedsOcr`. For PDFs, opt in with `ocr: 'hosted'` in Node, `ocr="hosted"` in Python or `--ocr hosted` on the CLI to send that document to [Firecrawl Parse](https://firecrawl.dev/parse). OFD pages continue to return `NeedsOcr` because this hosted mode uses a PDF parser. No signup needed. Set `FIRECRAWL_API_KEY` for higher limits.

## How it works

Expand Down
9 changes: 6 additions & 3 deletions node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![npm](https://img.shields.io/npm/v/@firecrawl/anydoc.svg)](https://www.npmjs.com/package/@firecrawl/anydoc)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/firecrawl/anydoc/blob/main/LICENSE)

Convert Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF files into clean GitHub-Flavored Markdown. Node.js bindings for the [anydoc](https://github.com/firecrawl/anydoc) Rust crate, built by [Firecrawl](https://firecrawl.dev). Also available as a hosted API through [Firecrawl Parse](https://firecrawl.dev/parse), which adds our OCR models for the scanned pages anydoc can't read on its own.
Convert Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, PDF, and OFD files into clean GitHub-Flavored Markdown. Node.js bindings for the [anydoc](https://github.com/firecrawl/anydoc) Rust crate, built by [Firecrawl](https://firecrawl.dev). Also available as a hosted API through [Firecrawl Parse](https://firecrawl.dev/parse), which adds our OCR models for the scanned pages anydoc can't read on its own.

Every format parses into one shared document model and renders through a single Markdown serializer, so headings, tables, lists, and footnotes come out the same no matter which format goes in. Conversion runs on the libuv thread pool and never blocks the event loop. TypeScript types ship with the package.

Expand All @@ -23,6 +23,9 @@ npm install @firecrawl/anydoc
| EPUB | `.epub` |
| CSV | `.csv` |
| PDF | `.pdf` |
| OFD | `.ofd` (text objects) |

OFD conversion preserves outline headings, large-font headings, bold and italic text, URI links, printed lists, and complete bordered table grids. Tables and large-font headings are inferred from fixed-page geometry; pages without those cues remain paragraphs. Image-only pages return `needsOcr`; images, vector drawings, and non-link annotations are not included in Markdown.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

## CLI

Expand Down Expand Up @@ -57,7 +60,7 @@ const document = await toDocument(bytes);

## Scanned pages

anydoc converts locally and does not do OCR, so a PDF with scanned or image-only pages rejects with `needsOcr`. Opt in with `ocr: 'hosted'` to send that document to [Firecrawl Parse](https://firecrawl.dev/parse). No signup needed. Set `apiKey` or `FIRECRAWL_API_KEY` for higher limits.
anydoc converts locally and does not do OCR, so a PDF or OFD with scanned or image-only pages rejects with `needsOcr`. For PDFs, opt in with `ocr: 'hosted'` to send that document to [Firecrawl Parse](https://firecrawl.dev/parse). This hosted PDF parser does not accept OFD; OFD pages still reject with `needsOcr` even when hosted OCR is requested. No signup needed. Set `apiKey` or `FIRECRAWL_API_KEY` for higher limits.

```js
const markdown = await toMarkdown('scan.pdf', { ocr: 'hosted' });
Expand Down Expand Up @@ -91,7 +94,7 @@ try {
| `code` | Meaning |
| --------------- | ------------------------------------------------------------------- |
| `unsupported` | Unknown format, or one that cannot be converted |
| `needsOcr` | Scanned or image-only pages of a PDF, listed in `pages` |
| `needsOcr` | Scanned or image-only PDF/OFD pages, listed in `pages` |
| `malformed` | Structurally unusable: no meaningful content could be extracted |
| `encrypted` | Encrypted or password-protected |
| `resourceLimit` | Crossed a fixed safety limit (decompression, nesting, node count) |
Expand Down
5 changes: 4 additions & 1 deletion node/anydoc.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ async function toMarkdown(path, options) {
return await native.toMarkdown(path)
} catch (error) {
if (!sendsToHosted(error, options)) throw error
return parseHosted(await readFile(path), basename(path), options)
const bytes = await readFile(path)
if ((native.formatFromBytes(bytes) ?? native.formatFromPath(path)) !== 'pdf') throw error
return parseHosted(bytes, basename(path), options)
}
}

Expand All @@ -33,6 +35,7 @@ async function toMarkdownBytes(bytes, format, options) {
return await native.toMarkdownBytes(bytes, format)
} catch (error) {
if (!sendsToHosted(error, options)) throw error
if ((format ?? native.formatFromBytes(bytes)) !== 'pdf') throw error
return parseHosted(bytes, 'document.pdf', options)
}
}
Expand Down
8 changes: 4 additions & 4 deletions node/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

const { readFile, writeFile } = require('node:fs/promises')

const FORMATS = 'doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv'
const FORMATS = 'doc, docx, odt, pdf, ofd, ppt, pptx, rtf, epub, xlsx, ods, odp, csv'
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

const HELP = `anydoc: convert documents to GitHub-Flavored Markdown

Expand Down Expand Up @@ -37,14 +37,14 @@ Options:
The format is detected from the file content; the file extension is the
fallback for signature-less formats (CSV). stdin has no extension, so CSV
input from stdin needs --format csv. Scanned or image-only pages need OCR,
which anydoc does not do: the document exits 3, or goes to Firecrawl Parse
with --ocr hosted.
which anydoc does not do: the document exits 3. PDF can go to Firecrawl
Parse with --ocr hosted; OFD still exits 3.

Exit codes:
0 success
1 the document could not be read or converted
2 usage error: unknown option, missing input, or invalid --format
3 pages of a PDF need OCR
3 PDF or OFD pages need OCR

Examples:
anydoc report.docx
Expand Down
2 changes: 1 addition & 1 deletion node/dts-header.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type ConvertErrorCode =
/** Unknown format, or one that cannot be converted. */
| 'unsupported'
/**
* Pages of a PDF are scanned or image-only and need OCR, which anydoc does
* PDF or OFD pages are scanned or image-only and need OCR, which anydoc does
* not do. The error is a `NeedsOcrError` naming them.
*/
| 'needsOcr'
Expand Down
3 changes: 2 additions & 1 deletion node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type ConvertErrorCode =
/** Unknown format, or one that cannot be converted. */
| 'unsupported'
/**
* Pages of a PDF are scanned or image-only and need OCR, which anydoc does
* PDF or OFD pages are scanned or image-only and need OCR, which anydoc does
* not do. The error is a `NeedsOcrError` naming them.
*/
| 'needsOcr'
Expand Down Expand Up @@ -124,6 +124,7 @@ export declare const enum Format {
* `needsOcr` naming them.
*/
pdf = 'pdf',
ofd = 'ofd',
ppt = 'ppt',
pptx = 'pptx',
rtf = 'rtf',
Expand Down
2 changes: 1 addition & 1 deletion node/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@firecrawl/anydoc",
"version": "0.2.4",
"description": "Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown",
"description": "Convert documents (doc, docx, odt, rtf, epub, pdf, ofd, presentations, spreadsheets, csv) to GitHub-Flavored Markdown",
"license": "MIT",
"homepage": "https://github.com/firecrawl/anydoc#readme",
"repository": {
Expand Down
3 changes: 3 additions & 0 deletions node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub enum Format {
/// need OCR, which anydoc does not do: the document rejects with
/// `needsOcr` naming them.
pdf,
ofd,
ppt,
pptx,
rtf,
Expand All @@ -39,6 +40,7 @@ impl From<Format> for anydoc::Format {
Format::docx => anydoc::Format::Docx,
Format::odt => anydoc::Format::Odt,
Format::pdf => anydoc::Format::Pdf,
Format::ofd => anydoc::Format::Ofd,
Format::ppt => anydoc::Format::Ppt,
Format::pptx => anydoc::Format::Pptx,
Format::rtf => anydoc::Format::Rtf,
Expand All @@ -58,6 +60,7 @@ impl From<anydoc::Format> for Format {
anydoc::Format::Docx => Format::docx,
anydoc::Format::Odt => Format::odt,
anydoc::Format::Pdf => Format::pdf,
anydoc::Format::Ofd => Format::ofd,
anydoc::Format::Ppt => Format::ppt,
anydoc::Format::Pptx => Format::pptx,
anydoc::Format::Rtf => Format::rtf,
Expand Down
46 changes: 45 additions & 1 deletion node/test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Smoke test: the bindings load and every entry point round-trips a fixture.
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
Expand All @@ -25,6 +25,7 @@ const RICH = fixture('docx/handmade-rich.docx')
const CSV = fixture('csv/sheet.csv')
const ENCRYPTED = fixture('malformed/encrypted--errors.odt')
const MIXED = fixture('pdf/handmade-mixed.pdf')
const SCANNED_OFD = fixture('ofd/image-only.ofd')

test('toMarkdown detects the format from the file content', async () => {
const markdown = await toMarkdown(OUTLINE)
Expand Down Expand Up @@ -67,6 +68,7 @@ test('format detection reads content, extension, and path', async () => {
assert.equal(formatFromBytes(await readFile(CSV)), null)
assert.equal(formatFromExtension('.pptm'), 'pptx')
assert.equal(formatFromExtension('xls'), 'xlsx')
assert.equal(formatFromExtension('ofd'), 'ofd')
assert.equal(formatFromPath('/tmp/report.odt'), 'odt')
assert.equal(formatFromPath('/tmp/report.unknown'), null)
})
Expand Down Expand Up @@ -149,6 +151,39 @@ test("ocr: 'hosted' sends a pdf with scanned pages to Firecrawl Parse, and nothi
})
})

test("ocr: 'hosted' leaves image-only OFD as needsOcr", async () => {
await withHostedStub(HOSTED, async (hits) => {
for (const convert of [
() => toMarkdown(SCANNED_OFD, { ocr: 'hosted' }),
async () => toMarkdownBytes(await readFile(SCANNED_OFD), 'ofd', { ocr: 'hosted' }),
async () => toMarkdownBytes(await readFile(SCANNED_OFD), undefined, { ocr: 'hosted' }),
]) {
await assert.rejects(convert(), (error) => error.code === 'needsOcr')
}
assert.deepEqual(hits, [])
})
})

test('hosted fallback uses file content before a misleading extension', async () => {
const dir = await mkdtemp(join(tmpdir(), 'anydoc-hosted-'))
try {
const ofdNamedPdf = join(dir, 'scan.pdf')
const pdfNamedOfd = join(dir, 'scan.ofd')
await Promise.all([
writeFile(ofdNamedPdf, await readFile(SCANNED_OFD)),
writeFile(pdfNamedOfd, await readFile(MIXED)),
])
await withHostedStub(HOSTED, async (hits) => {
await assert.rejects(toMarkdown(ofdNamedPdf, { ocr: 'hosted' }), (error) => error.code === 'needsOcr')
assert.deepEqual(hits, [])
assert.equal(await toMarkdown(pdfNamedOfd, { ocr: 'hosted' }), HOSTED.body.data.markdown)
assert.deepEqual(hits, [['/v2/parse', true]])
})
} finally {
await rm(dir, { recursive: true, force: true })
}
})

test("parseMode picks the Parse pdf mode, 'auto' by default", async () => {
await withHostedStub(HOSTED, async (hits, url, sent) => {
await toMarkdown(MIXED, { ocr: 'hosted' })
Expand Down Expand Up @@ -241,6 +276,15 @@ test('cli --ocr hosted converts a pdf with scanned pages through Firecrawl Parse
})
})

test('cli --ocr hosted keeps image-only OFD at needs OCR exit code', async () => {
await withHostedStub(HOSTED, async (hits, url) => {
const { code, stderr } = await runCli([SCANNED_OFD, '--ocr', 'hosted', '--api-url', url])
assert.equal(code, 3)
assert.match(stderr, /needs OCR/)
assert.deepEqual(hits, [])
})
})

test('cli --parse-mode picks the Parse pdf mode', async () => {
await withHostedStub(HOSTED, async (hits, url, sent) => {
const { code } = await runCli([MIXED, '--ocr', 'hosted', '--parse-mode', 'ocr', '--api-url', url])
Expand Down
Loading