Skip to content
Draft
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
532 changes: 529 additions & 3 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@ csv = "1.4.0"
flate2 = "1"
encoding_rs = "0.8.35"
log = "0.4"
pdf-inspector = "0.1.8"
pdf-inspector = { git = "https://github.com/qin-ctx/pdf-inspector", rev = "0d3ab538de89125109b501c9a7a6c320fcf9e338" }
png = { version = "0.18", optional = true }
quick-xml = "0.41.0"
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }

[features]
default = []
pdf-images = ["pdf-inspector/render", "dep:png"]

[profile.release]
lto = "thin"
strip = "symbols"
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,33 @@ let markdown = anydoc::to_markdown_bytes(&bytes, anydoc::Format::Csv)?;
let document = anydoc::to_document(&bytes, None)?;
```

### PDF images

The native bindings can return each positioned PDF image as PNG bytes. The
Markdown target is already the image's file name, so consumers only need to
save the returned bytes under that name:

```python
from pathlib import Path
import anydoc

result = anydoc.pdf_to_markdown_with_images(Path("report.pdf").read_bytes())
for image in result.images:
Path(image.filename).write_bytes(image.data)

Path("report.md").write_text(result.markdown)
# Markdown contains, for example: ![Image: Im0](p1_i1.png)
```

The result also reports one-based OCR pages and reasons, table/column pages,
layout complexity, and font-encoding issues from the same PDF pass.

The same API is `pdfToMarkdownWithImages` in Node.js and WebAssembly. Rust
callers enable the `pdf-images` feature and use
`anydoc::pdf_to_markdown_with_images`. The feature currently requires Rust
1.92 because of the optional renderer; the default AnyDoc build remains on
its Rust 1.88 minimum.

## Features

- **One output for every format.** Each format parses into a shared document model and renders through a single Markdown serializer, so escaping, tables, heading anchors, and footnotes behave identically whether the input was a `.doc` from 2003 or a `.pptx` from yesterday.
Expand All @@ -133,7 +160,7 @@ let document = anydoc::to_document(&bytes, None)?;
- **Content-based format detection.** The format is read from the bytes themselves (PDF header, RTF open group, OLE stream names, ZIP package mimetype), so mislabeled files still convert correctly.
- **Fast.** Pure Rust, no ML models, no external services. Median conversion time is under 5ms per document.
- **Bindings that stay out of the way.** Node.js conversion runs on the libuv thread pool and never blocks the event loop; Python releases the GIL so other threads keep running. TypeScript types and Python stubs ship with the packages.
- **PDF support built in.** Text-based PDFs convert locally through [pdf-inspector](https://github.com/firecrawl/pdf-inspector), no OCR service required.
- **PDF support built in.** Text-based PDFs convert locally through [pdf-inspector](https://github.com/firecrawl/pdf-inspector), with an opt-in API for positioned PNG images and no OCR service required.
- **Agent ready.** Ships as an [Agent Skill](#agent-skill): one `npx skills add firecrawl/anydoc` and any agent can read office documents.

## Supported formats
Expand Down
2 changes: 1 addition & 1 deletion node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ publish = false
crate-type = ["cdylib"]

[dependencies]
anydoc = { path = ".." }
anydoc = { path = "..", features = ["pdf-images"] }
napi = { version = "3", default-features = false, features = ["napi4"] }
napi-derive = "3"

Expand Down
13 changes: 11 additions & 2 deletions node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ Markdown goes to stdout, errors to stderr, and `anydoc --help` covers the rest.
## Usage

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

// From a file path:
const markdown = await toMarkdown('report.docx');
Expand All @@ -52,6 +57,10 @@ const fromCsv = await toMarkdownBytes(bytes, 'csv');

// Or stop at the document model, which also carries embedded assets:
const document = await toDocument(bytes);

// PDF Markdown plus positioned PNG files. Each Markdown image target is the
// matching image.filename.
const pdf = await pdfToMarkdownWithImages(pdfBytes);
```

## Errors
Expand Down Expand Up @@ -94,7 +103,7 @@ formatFromPath('report.odt'); // 'odt'

## Images and embedded objects

Markdown cannot embed bytes, so an embedded image renders as its alt text while the bytes stay on `document.assets`, tagged with a media type and the part they came from. Images that carry an external URL render as ordinary Markdown images.
For office documents, an embedded image renders as its alt text while the bytes stay on `document.assets`, tagged with a media type and the part they came from. PDF images are available through `pdfToMarkdownWithImages` as rendered PNG bytes whose `filename` matches the Markdown target. Images that carry an external URL render as ordinary Markdown images.

Full behavior notes and benchmarks live in the [repository README](https://github.com/firecrawl/anydoc#readme).

Expand Down
37 changes: 37 additions & 0 deletions node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,43 @@ export declare const enum NoteKind {
endnote = 'endnote'
}

export interface PdfConversion {
markdown: string
images: Array<PdfImage>
pageCount: number
pagesNeedingOcr: Array<number>
ocrReasonsByPage: Array<PdfOcrReasons>
pagesWithTables: Array<number>
pagesWithColumns: Array<number>
isComplexLayout: boolean
hasEncodingIssues: boolean
}

export interface PdfImage {
filename: string
/** One-based source page number. */
page: number
/** Source placement as [x, y, width, height] in PDF points. */
bbox: Array<number>
width: number
height: number
/** Complete PNG file bytes. */
data: Buffer
warnings: Array<string>
}

export interface PdfOcrReasons {
/** One-based source page number. */
page: number
reasons: Array<string>
}

/**
* Convert PDF bytes to Markdown and return the PNG data for each positioned
* image. An image's `filename` is the exact target used in `markdown`.
*/
export declare function pdfToMarkdownWithImages(bytes: Uint8Array): Promise<PdfConversion>

/** Fully resolved character style. */
export interface Style {
bold: boolean
Expand Down
1 change: 1 addition & 0 deletions node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ module.exports.InlineKind = nativeBinding.InlineKind
module.exports.LinkTargetKind = nativeBinding.LinkTargetKind
module.exports.MarkerKind = nativeBinding.MarkerKind
module.exports.NoteKind = nativeBinding.NoteKind
module.exports.pdfToMarkdownWithImages = nativeBinding.pdfToMarkdownWithImages
module.exports.TableKind = nativeBinding.TableKind
module.exports.toDocument = nativeBinding.toDocument
module.exports.toMarkdown = nativeBinding.toMarkdown
Expand Down
95 changes: 95 additions & 0 deletions node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,79 @@ pub fn to_markdown_bytes(
})
}

#[napi(object)]
pub struct PdfImage {
pub filename: String,
/// One-based source page number.
pub page: u32,
/// Source placement as [x, y, width, height] in PDF points.
pub bbox: Vec<f64>,
pub width: u32,
pub height: u32,
/// Complete PNG file bytes.
pub data: Buffer,
pub warnings: Vec<String>,
}

#[napi(object)]
pub struct PdfConversion {
pub markdown: String,
pub images: Vec<PdfImage>,
pub page_count: u32,
pub pages_needing_ocr: Vec<u32>,
pub ocr_reasons_by_page: Vec<PdfOcrReasons>,
pub pages_with_tables: Vec<u32>,
pub pages_with_columns: Vec<u32>,
pub is_complex_layout: bool,
pub has_encoding_issues: bool,
}

#[napi(object)]
pub struct PdfOcrReasons {
/// One-based source page number.
pub page: u32,
pub reasons: Vec<String>,
}

impl From<anydoc::PdfConversion> for PdfConversion {
fn from(conversion: anydoc::PdfConversion) -> Self {
Self {
markdown: conversion.markdown,
images: conversion
.images
.into_iter()
.map(|image| PdfImage {
filename: image.filename,
page: image.page,
bbox: image.bbox.into_iter().map(f64::from).collect(),
width: image.width,
height: image.height,
data: image.data.into(),
warnings: image.warnings,
})
.collect(),
page_count: conversion.page_count,
pages_needing_ocr: conversion.pages_needing_ocr,
ocr_reasons_by_page: conversion
.ocr_reasons_by_page
.into_iter()
.map(|reason| PdfOcrReasons { page: reason.page, reasons: reason.reasons })
.collect(),
pages_with_tables: conversion.pages_with_tables,
pages_with_columns: conversion.pages_with_columns,
is_complex_layout: conversion.is_complex_layout,
has_encoding_issues: conversion.has_encoding_issues,
}
}
}

/// Convert PDF bytes to Markdown and return the PNG data for each positioned
/// image. An image's `filename` is the exact target used in `markdown`.
#[napi(ts_return_type = "Promise<PdfConversion>")]
pub fn pdf_to_markdown_with_images(bytes: Uint8Array) -> AsyncTask<PdfConversionTask> {
AsyncTask::new(PdfConversionTask { bytes: bytes.to_vec(), failure: Failure::default() })
}

/// Parse an in-memory document into the document model, which also carries
/// the embedded assets. Without a format, it is detected from the content.
///
Expand Down Expand Up @@ -188,6 +261,28 @@ pub struct MarkdownBytesTask {
failure: Failure,
}

pub struct PdfConversionTask {
bytes: Vec<u8>,
failure: Failure,
}

impl Task for PdfConversionTask {
type Output = anydoc::PdfConversion;
type JsValue = PdfConversion;

fn compute(&mut self) -> Result<Self::Output> {
anydoc::pdf_to_markdown_with_images(&self.bytes).map_err(|e| self.failure.capture(e))
}

fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output.into())
}

fn reject(&mut self, env: Env, error: Error) -> Result<Self::JsValue> {
Err(self.failure.reject(env, error))
}
}

impl Task for MarkdownBytesTask {
type Output = String;
type JsValue = String;
Expand Down
2 changes: 1 addition & 1 deletion python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ publish = false
crate-type = ["cdylib"]

[dependencies]
anydoc = { path = ".." }
anydoc = { path = "..", features = ["pdf-images"] }
# abi3-py310 pins the stable ABI: one wheel per platform covers every
# CPython from 3.10 up.
pyo3 = { version = "0.29", features = ["abi3-py310", "extension-module"] }
18 changes: 17 additions & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ markdown = anydoc.to_markdown_bytes(data, "csv")
document = anydoc.to_document(data)
```

For a PDF, `pdf_to_markdown_with_images` returns positioned PNG files as well
as Markdown. Every Markdown image target equals one returned `filename`:

```python
from pathlib import Path

result = anydoc.pdf_to_markdown_with_images(Path("report.pdf").read_bytes())
for image in result.images:
Path(image.filename).write_bytes(image.data)
Path("report.md").write_text(result.markdown)
```

`result.pages_needing_ocr` and `result.ocr_reasons_by_page` identify pages
whose extracted text is incomplete; table, column, layout, and encoding
diagnostics are returned alongside them.

## Errors

A conversion raises only when no meaningful Markdown could come out of the file. The exception type names what went wrong:
Expand Down Expand Up @@ -80,7 +96,7 @@ anydoc.format_from_path("report.odt") # 'odt'

## Images and embedded objects

Markdown cannot embed bytes, so an embedded image renders as its alt text while the bytes stay on `document.assets`, tagged with a media type and the part they came from. Images that carry an external URL render as ordinary Markdown images.
For office documents, an embedded image renders as its alt text while the bytes stay on `document.assets`, tagged with a media type and the part they came from. PDF images are available through `pdf_to_markdown_with_images` as rendered PNG bytes whose `filename` matches the Markdown target. Images that carry an external URL render as ordinary Markdown images.

Full behavior notes and benchmarks live in the [repository README](https://github.com/firecrawl/anydoc#readme).

Expand Down
8 changes: 8 additions & 0 deletions python/anydoc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@
MalformedError,
MissingPartError,
Note,
PdfConversion,
PdfImage,
PdfOcrReasons,
ResourceLimitError,
Style,
Table,
UnsupportedError,
format_from_bytes,
format_from_extension,
format_from_path,
pdf_to_markdown_with_images,
to_document,
to_markdown,
to_markdown_bytes,
Expand Down Expand Up @@ -54,13 +58,17 @@
"MalformedError",
"MissingPartError",
"Note",
"PdfConversion",
"PdfImage",
"PdfOcrReasons",
"ResourceLimitError",
"Style",
"Table",
"UnsupportedError",
"format_from_bytes",
"format_from_extension",
"format_from_path",
"pdf_to_markdown_with_images",
"to_document",
"to_markdown",
"to_markdown_bytes",
Expand Down
37 changes: 37 additions & 0 deletions python/anydoc/_anydoc.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,43 @@ def to_markdown_bytes(data: bytes | bytearray, format: Format | None = None) ->
detected from the content, which signature-less formats (CSV) have to
name explicitly."""

def pdf_to_markdown_with_images(data: bytes | bytearray) -> PdfConversion:
"""Convert PDF bytes to Markdown and return the PNG data for each
positioned image. A `PdfImage.filename` is the exact target used in
`PdfConversion.markdown`."""

@final
class PdfConversion:
markdown: str
images: list[PdfImage]
page_count: int
pages_needing_ocr: list[int]
ocr_reasons_by_page: list[PdfOcrReasons]
pages_with_tables: list[int]
pages_with_columns: list[int]
is_complex_layout: bool
has_encoding_issues: bool

@final
class PdfOcrReasons:
page: int
"""One-based source page number."""
reasons: list[str]

@final
class PdfImage:
filename: str
"""File name used by the corresponding Markdown image."""
page: int
"""One-based source page number."""
bbox: tuple[float, float, float, float]
"""Source placement as `(x, y, width, height)` in PDF points."""
width: int
height: int
data: bytes
"""Complete PNG file bytes."""
warnings: list[str]

def to_document(data: bytes | bytearray, format: Format | None = None) -> Document:
"""Parse an in-memory document into the document model, which also
carries the embedded assets. Without a format, it is detected from the
Expand Down
Loading