anydoc_ex
Convert Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, and PDF files into clean GitHub-Flavored Markdown. Elixir bindings for the anydoc Rust crate, built by Firecrawl.
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 as a Rustler NIF on dirty CPU schedulers, so it never blocks the BEAM's normal schedulers — median conversion time upstream is under 5 ms per document.
Installation
Add anydoc_ex to your dependencies:
def deps do
[
{:anydoc_ex, "~> 0.1"}
]
end
The native extension compiles from source at build time, so a Rust toolchain is required. Precompiled binaries (rustler_precompiled) are on the roadmap.
Usage
# From a file path:
{:ok, markdown} = Anydoc.to_markdown("report.docx")
# From bytes, with the format detected from the content:
{:ok, markdown} = Anydoc.to_markdown_bytes(bytes)
# Or name it, which signature-less formats (CSV) need:
{:ok, markdown} = Anydoc.to_markdown_bytes(bytes, :csv)
# Or stop at the document model, which also carries embedded assets:
{:ok, %Anydoc.Document{markdown: markdown, assets: assets}} = Anydoc.to_document(bytes)
Each function has a bang variant (Anydoc.to_markdown!/1, Anydoc.to_markdown_bytes!/2, Anydoc.to_document!/2) that returns the value directly and raises Anydoc.Error on failure.
Supported formats
| Format | Atom | Extensions |
|---|---|---|
| Word | :doc, :docx | .doc, .docx, .docm |
| PowerPoint | :ppt, :pptx | .ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm |
| Excel | :excel | .xls, .xlsx, .xlsm, .xlsb |
| OpenDocument | :odt, :ods, :odp | .odt, .ods, .odp |
| Rich Text Format | :rtf | .rtf |
| EPUB | :epub | .epub |
| CSV | :csv | .csv |
:pdf | .pdf |
Container variants that share a parser map onto one atom: .docm is :docx, .pptm/.ppsx/.ppsm are :pptx, and every Excel container is :excel.
Errors
A conversion fails only when no meaningful Markdown could come out of the file. The error's :code names what went wrong:
case Anydoc.to_markdown(path) do
{:ok, markdown} ->
markdown
{:error, %Anydoc.Error{code: code}} when code in [:encrypted, :unsupported] ->
# No document comes out of these, so record the file and take the next one.
nil
{:error, error} ->
raise error
end
| Code | Meaning |
|---|---|
:unsupported | Unknown format, or one that cannot be converted (an image-only PDF) |
:malformed | Structurally unusable: no meaningful content could be extracted |
:encrypted | Encrypted or password-protected |
:resource_limit | Crossed a fixed safety limit (decompression, nesting, node count) |
:missing_part | A part required for any meaningful output is absent |
:io | The file could not be read, from Anydoc.to_markdown/1 only |
Anydoc.Error is an exception struct, so the same value works in pattern matches and in raise. :part names the package part at fault (:malformed, :missing_part), :limit names the safety limit crossed (:resource_limit), and Exception.message/1 carries the whole message. Passing anything but a known format atom raises FunctionClauseError.
Format detection
The format is read from the file content, using the marker its specification designates: the PDF header, the RTF open group, OLE stream names, the ZIP package mimetype and content types. CSV has no such marker, so detection returns nil for it and the extension, or an explicit format, names it instead.
Anydoc.format_from_bytes(bytes) # :docx, or nil when nothing matches
Anydoc.format_from_extension(".pptm") # :pptx
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 Anydoc.Document.assets, tagged with a media type and the package part they came from. Images that carry an external URL render as ordinary Markdown images.
{:ok, document} = Anydoc.to_document(File.read!("report.docx"))
for %Anydoc.Asset{id: id, media_type: media_type, bytes: bytes} <- document.assets do
File.write!("asset-#{id}#{extension_for(media_type)}", bytes)
end
The upstream crate exposes a full block-level document model (headings, tables, lists, notes); this binding does not mirror that tree yet — Anydoc.Document carries the rendered Markdown plus the embedded assets. The full model is a candidate for a future release.
Development
mix deps.get
mix test # compiles the Rust NIF on first run
mix format --check-formatted
mix credo --strict
mix dialyzer
The NIF crate lives in native/anydoc_nif and wraps only the public API of the anydoc crate. Test fixtures under test/fixtures are self-describing sample documents; with_image.docx is a minimal hand-built OOXML package with one embedded PNG, used to exercise the asset path.
Full behavior notes and benchmarks live in the anydoc repository README.