anymd

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 57 Imported by: 0

README

anymd

Any document → Markdown, in pure Go. One static binary, one go get-able library.

anymd converts 16 file formats to Markdown — documents, spreadsheets, email, archives — and can crawl a site into a Markdown mirror with its links rewritten to the local files. It is a Go library first, with a CLI wrapper and an installable agent skill.

Against Microsoft's markitdown it is 9–31× faster in-process depending on format, ships as a 15 MB binary instead of a 318 MB virtualenv, and scores higher on document fidelity across docling's 130-document corpus. Model-backed OCR, image captioning and audio transcription are available but off by default, so a default build makes no network call at all.

anymd report.docx > report.md

anymd is a Go-native alternative to Microsoft's markitdown. Same idea — feed it a file, get clean GitHub-flavored Markdown suitable for an LLM context window, a diff, or a docs pipeline — with a different set of trade-offs:

  • No cgo. CGO_ENABLED=0 builds. Nothing to link, nothing to apt install.
  • No Python. No interpreter, no virtualenv, no wheels that need a C compiler.
  • No native library to ship. No poppler, no libmagic, no LibreOffice subprocess.
  • Library first. The CLI is a thin shell over the same public API you import.
  • Cross-compiles everywhere Go does. GOOS=windows GOARCH=arm64 go build and you're done.

That is the whole pitch: go install it, drop the binary on a CI runner or into a scratch container, and it works.


Install

Binary:

go install github.com/muthuishere/anymd/cmd/anymd@latest

Library:

go get github.com/muthuishere/anymd

Or build from source:

git clone https://github.com/muthuishere/anymd && cd anymd
make build        # stamps the version from `git describe`
make release      # cross-compiles darwin/linux/windows × amd64/arm64 into dist/

Quickstart

# 1. one file to stdout — the unix default, pipeable
anymd notes.docx | head -40

# 2. stdin, with a hint about what the bytes are
curl -s https://example.com/data.csv | anymd -t csv

# 3. batch a tree into a directory, quietly, in deterministic order
anymd -r docs/ -d build/md --ext .md -q

# 4. opt in to a vision model to caption images and read scanned pages
anymd --llm scan.pdf

As a library:

package main

import (
	"fmt"
	"log"

	"github.com/muthuishere/anymd"
)

func main() {
	res, err := anymd.ConvertFile("report.docx")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(res.Title)    // "Q3 Report", when the format carries one
	fmt.Println(res.Markdown) // the GFM body
}

There is also anymd.ConvertBytes(b, info) and anymd.Convert(reader, info) for streams you already hold, plus anymd.New() for a private registry with your own converters (see Extending it).

CLI

anymd [flags] [file|url ...]
flag what it does
-o FILE|DIR write to FILE; if it exists as a directory, batch into it
-d, --outdir DIR batch output directory, created if missing
--ext .md output extension in batch mode
-t, --type EXT force an extension hint (-t docx), mainly for stdin
-r, --recursive walk directories given as inputs
--charset NAME override the detected text encoding
--max-depth N bound container recursion (a zip inside a zip); default 8
--keep-data-uris keep base64 images inline as data: URIs instead of dropping them
--title prepend # Title when the converter found one and the body has no h1
-q, --quiet suppress the per-file progress lines on stderr
--fail-fast stop at the first error (default: continue, report at the end)
--insecure skip TLS verification when fetching a URL
--list print the registered converters in dispatch order
--version version, commit and Go version
--cache reuse a previous conversion of identical bytes (off by default)
--no-cache force a fresh conversion; wins over --cache
--cache-dir DIR cache location (default os.UserCacheDir()/anymd)
--crawl follow links from each URL argument (off by default); requires -d DIR
--depth N how many links deep to follow (default 1; --depth 0 is the seed only)
--max-pages N cap the total number of pages (default 200)
--crawl-delay D wait between requests to one host (default 500ms); no way to ask for none
--same-host=false allow the crawl to leave the seed's host (default: stay on it)
--include RE / --exclude RE repeatable regexps over the URL; --exclude wins
--sitemap auto|only|off use a sitemap and follow links (default), sitemap only, or never look
--ignore-robots do not read robots.txt — see Crawling a site
--llm enable LLM image captioning — off by default, see LLM features
--llm-config PATH config file; default ~/.config/anymd/anymdconfig.json
--llm-model NAME override the vision model from the config file
--llm-base-url URL override the endpoint (a local Ollama or vLLM works)
--llm-timeout D bound one model call, e.g. 30s (default 60s)
--llm-transcribe also transcribe audio — a separate endpoint and a separate charge
--llm-transcribe-model NAME speech model (default whisper-1)

There are also config, cache and skills subcommands: anymd config path, anymd config show, anymd config init; anymd cache path|stats|clean; and anymd skills install|list|path|uninstall. See LLM features and Agent skill.

Streams. Markdown goes to stdout. Progress, warnings and errors go to stderr, always — they are never interleaved into stdout. That is what makes anymd x.pdf | grep … safe, and it is precisely where naive converters fail.

Exit codes. 0 everything converted · 1 one or more inputs failed · 2 you called it wrong (bad flag, -o FILE with several inputs). Distinct codes let a CI script branch on "the document was bad" versus "the invocation was bad".

Batch. Inputs are converted on a worker pool sized to runtime.NumCPU(), but output is emitted in input order — deterministic and diffable beats marginally faster. A batch run ends with a stderr summary: converted 12, failed 1.

URLs. An http:// or https:// argument is fetched with a 30-second timeout and a real User-Agent; the extension and MIME hints come from the response Content-Type and the final (post-redirect) URL. Unless you pass --llm, this is the only place in the entire project that touches the network — converters are offline by construction, so a document can never make anymd phone home. --insecure skips TLS verification and exists for self-signed internal hosts; do not point it at the public internet.

Supported formats

Format Extensions Extracted Not extracted
Plain text / Markdown .txt .text .md .markdown .log verbatim passthrough (last-resort fallback)
CSV / TSV .csv .tsv .tab delimiter sniffing → GFM pipe table formulas (there are none), cell types
Excel .xlsx .xlsm .xltx .xltm every sheet as a heading + table, computed cell values charts, images, macros, pivot tables
Excel (legacy) .xls .xlt .xlm .xlw same output as .xlsx, byte-identical on the same workbook formula results (the BIFF reader returns a placeholder), Excel's own date formats
Word .docx headings, paragraphs, lists, tables, links, image alt text, core-properties title; embedded image captions with --llm comments, tracked changes
PDF .pdf text layer, page by page, in column-aware reading order; with --llm, pages with no text layer are read by a vision model tables, headings, figures, form fields
HTML .html .htm .xhtml .xht headings, lists, tables, links, code, <title> scripts, styles, anything requiring JS execution, remote assets
Feeds .rss .atom .xml .rdf channel/feed title, per-entry title, date, link, summary full-article fetch (that would be a network call)
JSON .json pretty-printed, fenced as a code block schema inference, semantic flattening
Notebooks .ipynb markdown cells, code cells as fenced blocks, text outputs rendered plots and images, widget state
EPUB .epub spine order, per-chapter HTML → Markdown, metadata title cover art, footnote back-links, DRM'd books
ZIP .zip recursive conversion of each member, bounded by --max-depth encrypted archives
PowerPoint .pptx slide-by-slide shape text, tables, charts, image alt text, speaker notes; embedded image captions with --llm animations, themes, layout geometry
Images .jpg .jpeg .png .gif .webp .tiff .bmp pixel dimensions and EXIF metadata (capture time, camera, lens, exposure, GPS, description, rights); a caption with --llm without --llm, the pixels are never described
Audio .mp3 .m4a .wav .flac .ogg .opus only with --llm-transcribe — the spoken content, transcribed speaker diarization, timestamps; without the flag the format is not accepted at all
Outlook mail .msg subject, From/To/Cc/Date table, HTML or plain body attachments, RTF-compressed bodies, recipient storages

Anything with no matching converter that still decodes as UTF-8 text falls through to the plaintext converter; genuinely binary input with no converter is an error (exit 1), never silent garbage. anymd --list prints the live registry in dispatch order, which is always the authoritative answer for the build you have.

Benchmarks vs markitdown

Measured on the 31 files in markitdown's own test suite. Full method and the reproducible script: bench/.

anymd markitdown 0.1.5
docx (in-process) 0.59 ms 17.86 ms
xlsx (in-process) 0.64 ms 7.35 ms
pdf (in-process) 3.44 ms 74.47 ms
385K HTML (in-process) 11.55 ms 104.15 ms
CLI startup overhead none 309 ms per invocation
Peak RSS (385K HTML) 27 MB 160 MB
Install footprint 15 MB binary 318 MB venv
Files with substantive output 19/31 19/31

One honest caveat: the benchmark is the default, offline build. With --llm the wall clock is whatever the model takes, and the comparison stops being about parsers.

PDF used to be a second caveat — 45.69 ms and a 1.7× win, explained away as both tools being parser-bound. That explanation was wrong. The cost was our own PDF library resolving the same font /Widths array out of the file bytes once per glyph, so page layout was quadratic in glyph count. It is vendored as internal/pdf now, with the object cache upstream's own doc comment recommends; output is byte-identical and the worst corpus document went from 43.77 ms to 2.60 ms. bench/ has the before/after table, and ADR 0002 the reasoning.

Where anymd refuses a file, markitdown often returns an empty string and exit 0 — on the scanned PDF and on both test images. For an ingestion pipeline that is the worse failure: silently indexing nothing looks exactly like a document that had no text. anymd returns ErrNoTextLayer so you can route it to OCR.

Verified against markitdown's own corpus

anymd is checked against the 31 test files in Microsoft markitdown's own test suite, not only against fixtures we wrote ourselves — fixtures only ever assert what we already thought to build. 26 of 31 convert. The five that do not are each a deliberate scope decision, not a bug:

file outcome
MEDRPT-…_medical_report_scan.pdf ErrNoTextLayer — a scan with no text layer. We return a named error instead of empty output, so a caller can tell "nothing to extract" from "extraction failed". --llm reads it with a vision model.
test.m4a test.mp3 test.wav audio: needs a speech model. --llm-transcribe converts these; the default offline build declines them.
random.bin correctly refused rather than emitted as garbage

Output is also checked against markitdown's own canary assertions (PPTX_TEST_STRINGS): all present, including the image alt text and the chart content that live outside the slide XML.

What the default build will not do

markitdown's most impressive-looking converters are the ones that call something else. In anymd every one of those is opt-in behind --llm, and the default build does none of them:

  • No OCR. A scanned PDF comes back as ErrNoTextLayer, not hallucinated text.
  • No audio transcription. An audio file is not even accepted.
  • No image captioning. Images give dimensions and EXIF, nothing more.
  • No network of any kind at convert time. A converter never resolves a remote image, stylesheet, or linked article. The CLI's explicit URL argument is the only fetch, and it happens before any converter sees a byte.

That is the shape of the trade, and it is why the default is off rather than "on if a key happens to be in the environment": turning it on costs money, sends your documents to a third party, and makes the output non-deterministic. Those are decisions, not defaults. See LLM features.

The constraint underneath all four — pure Go, and no network the caller did not ask for — is written down with what it costs in ADR 0001. The rest of docs/adr/ covers the decisions whose result you can see in the code but whose reason you cannot.

Crawling a site

--crawl follows links from a URL argument, converts every page, and writes the result as a directory of Markdown. It is off by default: without it, a URL argument is fetched once and nothing is followed.

anymd --crawl --depth 2 -d ./site https://example.dev/
crawled 1 https://example.dev/
crawled 2 https://example.dev/guide/
crawled 3 https://example.dev/docs/deep/api.html
crawl: fetched 3, written 3, skipped 1, failed 0

Output paths are derived from the URL — / becomes index.md, /guide/ becomes guide/index.md, /docs/deep/api.html becomes docs/deep/api.html.md. The extension is appended, never substituted, so /a.html and /a cannot collide; anything a filename cannot express (a query string, a rewritten character, mixed case on a case-insensitive filesystem) gets an 8-hex suffix of the URL's SHA-256. --crawl writes many files, so it requires -d DIR and rejects -o.

Links are rewritten. A link to a page that was crawled becomes a relative local path, so the directory browses offline; a link to a page that was not crawled stays absolute, so it still works; and a URL inside a fenced or indented code block is left alone, because that is content, not navigation.

# API

See the [home page](../../index.md) and the [spec](https://other.example/spec).

Sitemaps. Before following a link, the crawl looks for a sitemap — the Sitemap: directives in robots.txt first, then /sitemap.xml, then /sitemap_index.xml. Gzipped sitemaps and <sitemapindex> fan-out are handled. A sitemap is a hint, not an authority: its URLs enter the frontier at depth 0 and still pass same-host, --include/--exclude, robots.txt and the page cap, and sitemap documents themselves are only ever fetched from the seed's own host.

Politeness. robots.txt is respected by default and a Crawl-delay it asks for is honoured as a floor; there is a 500 ms inter-request delay per host that cannot be set to zero; the crawl stays on the seed's host unless you pass --same-host=false. --ignore-robots means fetching pages whose owner has published a machine-readable request that you not fetch them — that can get you blocked, and under some terms of service it is the difference between reading a site and breaching a contract. Crawling someone else's site has terms-of-service implications either way.

Crawling lives in the separate crawl package, outside the converter path on purpose: a converter never touches the network, which is what makes anymd safe on untrusted input. Library users call crawl.Crawl(ctx, seed, opts, visit) and crawl.LocalPath, and rewrite with anymd.RewriteLinks.

Agent skill

anymd ships a SKILL.md that tells an AI coding agent the tool exists and when to reach for it. Agents look in well-known directories, and this puts it there:

anymd skills install
anymd: installed /Users/you/.claude/skills/anymd/SKILL.md
anymd: installed /Users/you/.agents/skills/anymd/SKILL.md
anymd: restart your agent if it caches its skill list
anymd skills list       # per target: current / differs / not installed
anymd skills path       # the target directories, one per line
anymd skills uninstall  # remove only the files anymd installed

Targets are ~/.claude/skills/anymd and ~/.agents/skills/anymd, both by default; --target claude|agents|all picks among them, and --dir PATH installs into a skills root of your own (anymd skills install --dir .claude/skills).

Nothing is overwritten silently. A byte-identical file is already current and exit 0; a file that differs is a refusal naming --force and exit 1. uninstall removes only the files anymd installed, keeps a file that differs without --force, and keeps the directory if anything anymd did not write is still in it.

The skill is embedded with go:embed, so anymd skills install works from a bare go install with no repository checked out.

LLM features

Everything in this section is off unless you pass --llm. Without it anymd makes no network calls during conversion at all — that is the guarantee that lets you point it at a document someone emailed you.

What --llm turns on
what it does cost
Images a prose caption under the image, in .png/.jpg/… and embedded in .docx and .pptx one model call per distinct image (identical images — a logo on every slide — are captioned once)
Scanned PDFs pages with no text layer are read by the vision model instead of returning ErrNoTextLayer one call per page that has no text
Audio (--llm-transcribe) .mp3/.m4a/.wav/… become their spoken content one call per file
# caption every image in a deck
anymd --llm deck.pptx

# read a scanned PDF
anymd --llm scan.pdf

# transcribe an interview — a separate endpoint and a separate charge,
# hence a separate flag
anymd --llm --llm-transcribe interview.m4a

# a local model: no key leaves the box, no per-call cost
anymd --llm --llm-base-url http://localhost:11434/v1 --llm-model llava photo.jpg

Be deliberate about the bill. A 60-slide deck with a logo on every slide and 40 distinct figures is 41 calls, not 100 — but a directory of 200 such decks is several thousand. --llm is per-invocation for exactly this reason; there is no environment variable that turns it on globally.

A model failure is never a document failure: a timeout, a rate limit or an outage costs you the caption, and the rest of the output — text, tables, dimensions, EXIF — is still emitted. --llm-timeout bounds a single call so a slow model cannot stall a whole batch.

The config file

~/.config/anymd/anymdconfig.json (or $XDG_CONFIG_HOME/anymd/). Every string field supports ${VAR} interpolation from the environment, which is how a key stays out of the file:

{
  "model": "openai/gpt-4o-mini",
  "base_url": "https://openrouter.ai/api/v1",
  "api_key": "${OPENROUTER_API_KEY}",
  "retries": 2,
  "timeout_ms": 60000
}

An unset ${VAR} is an error, not an empty string — silently expanding a missing key to "" surfaces later as a confusing 401 from the provider. The error names the variable, never a value.

anymd config path    # where is it
anymd config init    # write a commented starter there, mode 0600, never overwriting
anymd config show    # what does it resolve to — with every secret redacted

config show prints api_key: <set from ${OPENROUTER_API_KEY}>, never the key, not even a masked prefix. (A masked key still leaks its length and its prefix, and it teaches people that showing part of a key is fine.) The same redaction applies to header values and to credentials embedded in a proxy URL.

Precedence

explicit flag > config file > environment > default.

So --llm-model llava beats "model" in the file, which beats the built-in default. The API key is the one thing with no flag — by design; a key on a command line ends up in your shell history and in ps. It comes from ${VAR} in the config file, or directly from OPENROUTER_API_KEY, OPENAI_API_KEY or ANTHROPIC_API_KEY. With --llm and no key resolvable, anymd exits 2 and tells you which variable to set.

Transcription reads OPENAI_API_KEY, OPENROUTER_API_KEY or LLM_API_KEY, and posts to <base_url>/audio/transcriptions — an OpenAI-shaped endpoint. If your base_url points at an aggregator that does not implement it, override it for that run with --llm-base-url.

Beside markitdown
# markitdown (Python)
from markitdown import MarkItDown
from openai import OpenAI

md = MarkItDown(llm_client=OpenAI(), llm_model="gpt-4o")
result = md.convert("document_with_images.pdf")
print(result.text_content)
# anymd (CLI)
anymd --llm --llm-model gpt-4o document_with_images.pdf
// anymd (library)
d := llm.New(llm.Config{Model: "gpt-4o"})
res, err := anymd.Default().ConvertFile("document_with_images.pdf",
    &anymd.Options{Describer: d})

The library takes an interface, not an SDK object: anything that can look at bytes and return a description satisfies anymd.Describer, including a local model, a hosted API, or a stub in your tests. github.com/muthuishere/anymd/llm is one implementation of it, not the only way in.

How it differs from markitdown

markitdown anymd
Runtime Python 3 + wheels single static Go binary
Use as a library Python only Go, and the binary is a thin wrapper on it
Native deps several, varying by extra none — CGO_ENABLED=0
OCR / transcription / LLM captions yes, via services and extras opt-in behind --llm, never by default
Network at convert time yes (some converters) never, unless you pass --llm; otherwise only the CLI's explicit URL argument
Model configuration Python SDK object in code a config file with ${VAR} interpolation, or flags
Batch one file per invocation worker pool, deterministic input-order output
Exit codes coarse 0 / 1 / 2, scriptable

The design is a port, and gladly so: the converter registry, the StreamInfo "hints plus sniffing" dispatch, and the first-Accepts-wins ordering are markitdown's ideas, proven at scale. The deterministic GFM emitters (internal/mdutil: one Table, one Heading, one CodeBlock, one Join) come from CiteNexus's emit-markdown capability, where the requirement was that a table from a spreadsheet and a table from a Word document be byte-identical. That property is what makes anymd output safe to commit and diff.

Extending it

A converter is two methods. Implement them, register the converter, done:

package main

import (
	"io"
	"strings"

	"github.com/muthuishere/anymd"
)

type TodoConverter struct{}

func (c *TodoConverter) Name() string  { return "todo" }
func (c *TodoConverter) Priority() int { return anymd.PrioritySpecific }

// Accepts must be cheap: hints and magic bytes only, never a full parse.
func (c *TodoConverter) Accepts(r io.ReadSeeker, info anymd.StreamInfo, opts *anymd.Options) bool {
	return info.HasExt(".todo")
}

func (c *TodoConverter) Convert(r io.ReadSeeker, info anymd.StreamInfo, opts *anymd.Options) (anymd.Result, error) {
	b, err := io.ReadAll(r)
	if err != nil {
		return anymd.Result{}, err
	}
	var out []string
	for _, line := range strings.Split(string(b), "\n") {
		if line = strings.TrimSpace(line); line != "" {
			out = append(out, "- [ ] "+line)
		}
	}
	return anymd.Result{Markdown: strings.Join(out, "\n") + "\n"}, nil
}

func main() {
	e := anymd.New()          // all built-ins
	e.Register(&TodoConverter{}) // yours, ahead of the fallback
	res, _ := e.ConvertFile("chores.todo", nil)
	print(res.Markdown)
}

Notes:

  • Priority() orders dispatch — PrioritySpecific (0) for a unique magic number or extension, PriorityGeneric (10) for a family that would shadow a specific format, PriorityFallback (100) for the one text catch-all. Ties break by registration order, so registering at a lower priority overrides a built-in.
  • A converter that Accepts and then fails is a hard error. The engine does not fall through to a catch-all that would emit plausible-looking garbage.
  • In-tree converters render through internal/mdutil (Table, Heading, CodeBlock, Join) so every emitter agrees byte-for-byte. It is an internal package, so an out-of-tree converter emits its own GFM — match the same shape.
  • Container formats must recurse via opts.Recurse(reader, info), never by constructing a new Engine — that is what makes --max-depth real.

The full rules for in-tree converters live in CONTRACT.md.

Security posture

Every converter is a parser pointed at bytes someone else chose. So:

  • Never panics. Malformed input is an error, not a crash. Lengths, indices, and offsets read out of a document are treated as attacker-controlled.
  • Bounded memory and recursion. Container nesting is capped by Options.MaxDepth (default 8) and enforced centrally by Options.Recurse.
  • No network, no subprocess, no shell. Converters cannot fetch a remote asset or exec anything. There are exactly two exceptions, and both are things you asked for out loud: the CLI's explicit URL argument, which is fetched before any converter sees a byte, and --llm, which sends image or audio bytes to the endpoint you configured. Neither can be triggered by the content of a document.
  • Keys are never printed. Not in an error, not in config show, not masked. A key is read from the environment (or a ${VAR} reference in the config file) and used only as an Authorization header. anymd config init writes mode 0600 and refuses to overwrite.
  • No cgo, so there is no memory-unsafe parser in the dependency graph.

Found a way to panic one of the converters? That is a bug worth a report.

License

MIT © 2026 Muthukumaran Navaneethakrishnan. See LICENSE.

Documentation

Overview

Package anymd converts documents of almost any kind into GitHub-flavored Markdown, in pure Go. Point it at a .docx, .pdf, .xlsx, .pptx, .epub, .msg, .ipynb, .html, .csv, .json, an RSS feed, an image, or a zip full of those, and it hands back a Markdown string suitable for an LLM context window, a docs pipeline, or a diff.

It is a Go-native answer to Microsoft's markitdown: the same converter registry shape, the same "hints plus sniffing" dispatch, and the same Markdown-out goal — with no cgo, no Python, and no native library to ship.

Scope, stated honestly

By default anymd extracts what is already text in the document and invents nothing:

no OCR              — a scanned PDF yields ErrNoTextLayer, not empty output
no transcription    — an audio file is declined, not silently emptied
no LLM captioning   — an image's pixels are never described
no network          — a converter never fetches a remote asset or link

Every one of those is a DEFAULT, not a limit. Supply an Options.Describer or Options.Transcriber and anymd will read scanned pages, caption images, and transcribe audio; see the llm subpackage for an implementation. Nothing here is a native dependency — the pure-Go, no-cgo property is an invariant and is unaffected either way.

The distinction matters: the objection was never that a model is wrong, it was that a model must not be a surprise. With no Describer and no Transcriber, conversion makes no network call of any kind, which is what makes anymd safe to point at untrusted documents.

The pure-Go promise

This is the reason to pick anymd over a wrapper around a Python tool or a native library. There is no cgo anywhere in the dependency graph, no poppler, no libmagic, no LibreOffice subprocess, no interpreter. CGO_ENABLED=0 builds work, and the module cross-compiles anywhere Go does:

GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build ./...

A scratch container or a CI runner needs nothing installed but the binary.

Quick start

The three-line case, using the package-level helpers backed by Default:

res, err := anymd.ConvertFile("report.docx")
if err != nil {
	log.Fatal(err)
}
fmt.Println(res.Markdown) // and res.Title, when the format carries one

For bytes you already hold, use ConvertBytes; for an arbitrary reader, use Convert. Each takes a StreamInfo of hints — extension, MIME type, filename, charset, origin URL — every field optional. With no hints at all, dispatch falls back to sniffing the first 512 bytes.

The converter registry

An Engine is a list of Converter values, each of which implements two methods:

Accepts — cheap. Hints and magic bytes only, never a full parse: it runs
          against every registered converter on every conversion.
Convert — the real work. The stream is rewound to 0 before both calls.

Dispatch is deliberately simple. Converters are tried in ascending Prioritized order — PrioritySpecific (0) for an unambiguous magic number or extension, PriorityGeneric (10) for a family that would otherwise shadow a specific format, PriorityFallback (100) for the single text catch-all — with ties broken by registration order. The first converter whose Accepts returns true wins.

A converter that accepts and then fails is a hard error. The engine does NOT fall through to the next candidate. That is a design choice, not an oversight: falling through means a corrupt .docx quietly comes back as the plaintext converter's rendering of compressed XML, which looks like output, diffs like output, and is garbage. An error the caller can see beats plausible nonsense they cannot.

Engine.Converters returns the live registry in dispatch order, which is always the authoritative answer for the build you have.

Options

Options is optional everywhere; a nil *Options and the zero value both give the defaults. The fields that matter:

MaxDepth     bounds container recursion (a zip inside a zip inside …).
             0 means the default of 8; negative disables recursion.
KeepDataURIs keeps base64 image payloads inline instead of dropping them.
Charset      overrides the detected encoding for text-ish formats.

Container converters (zip, epub, msg) must recurse through Options.Recurse, never by constructing a fresh Engine. Recurse carries the same engine and options down and increments the depth counter, which is what makes MaxDepth real rather than advisory. Past the limit it returns ErrMaxDepth; a container reports that inline against the offending member and keeps walking its siblings.

Errors

When nothing claims a stream, conversion fails with an UnsupportedError, which unwraps to the ErrUnsupported sentinel:

if errors.Is(err, anymd.ErrUnsupported) {
	var ue *anymd.UnsupportedError
	errors.As(err, &ue)
	log.Printf("no converter for ext=%q mime=%q (declined: %v)",
		ue.Ext, ue.Mime, ue.Declined)
}

Format-specific outcomes get their own sentinels:

ErrNoTextLayer  a PDF parsed cleanly but has no text layer at all
ErrEncryptedPDF a PDF is encrypted and would not open with an empty password
ErrMaxDepth     a container hit Options.MaxDepth

ErrNoTextLayer is the one worth explaining. A pure scan — every page a single raster image — could be reported as a successful conversion producing "". It is not, because "" is exactly what a genuinely blank document produces, and the caller would have no way to tell them apart. The distinction is operationally load-bearing: "there is nothing to extract" ends the job, while "the text is locked inside images" is a signal to route the file to an OCR step that anymd deliberately does not ship. Errors that collapse those two cases push the ambiguity onto every caller. Same reasoning for ErrEncryptedPDF: an encrypted file must never be mistaken for an empty one.

Extending it

A converter is two methods, so a consumer's own format is a small type and one Register call:

type TodoConverter struct{}

func (TodoConverter) Name() string  { return "todo" }
func (TodoConverter) Priority() int { return anymd.PrioritySpecific }

func (TodoConverter) Accepts(r io.ReadSeeker, info anymd.StreamInfo, o *anymd.Options) bool {
	return info.HasExt(".todo")
}

func (TodoConverter) Convert(r io.ReadSeeker, info anymd.StreamInfo, o *anymd.Options) (anymd.Result, error) {
	b, err := io.ReadAll(r)
	// … render b as Markdown …
	return anymd.Result{Markdown: string(b)}, err
}

e := anymd.New()             // every built-in
e.Register(TodoConverter{})  // yours, ahead of the fallback
res, err := e.ConvertFile("chores.todo", nil)

Named and Prioritized are optional: a converter that implements neither is registered at PrioritySpecific under its Go type name. To override a built-in, register at a lower priority than it. Register mutates the Engine, so do all registration before the first conversion and before sharing the Engine across goroutines; once built, an Engine is safe for concurrent use.

Supported formats

Plain text / Markdown  .txt .text .md .markdown .log   verbatim (fallback)
CSV / TSV              .csv .tsv .tab                  delimiter sniffing → GFM table
Excel                  .xlsx .xlsm .xltx .xltm         one heading + table per sheet
Excel (legacy BIFF)    .xls .xlt .xlm .xlw             same output as .xlsx
Word                   .docx                           headings, lists, tables, links, title
PDF                    .pdf                            text layer, column-aware order (scans need a Describer)
HTML                   .html .htm .xhtml .xht               headings, lists, tables, links, code
Feeds                  .rss .atom .xml .rdf            feed and entry titles, dates, summaries
JSON                   .json                           pretty-printed, fenced
Notebooks              .ipynb                          markdown cells, code cells, text outputs
EPUB                   .epub                           spine order, chapter by chapter
PowerPoint             .pptx                           slide text, tables, charts, notes
Images                 .jpg .jpeg .png .gif .webp .tiff .bmp   dimensions and EXIF only
Outlook mail           .msg                            subject, header table, body
Audio                  .mp3 .m4a .wav .flac .ogg .webm  (needs an Options.Transcriber)
ZIP                    .zip                            each member converted, bounded by MaxDepth

Anything with no matching converter that still decodes as UTF-8 text falls through to the plaintext converter. Genuinely binary input that nothing claims is an error, never silent garbage.

Security posture

Every converter is a parser aimed at bytes someone else chose, so:

  • Never panics. Malformed input is an error. Lengths, indices, and offsets read out of a document are treated as attacker-controlled, and a dependency that reports corruption by panicking is wrapped in a recover.
  • Allocations are bounded. Per-format input caps, cell and glyph caps, and zip-bomb limits (per-entry, archive-wide, and entry-count) mean a small hostile file cannot expand into an unbounded one. A declared uncompressed size is never trusted to size a buffer.
  • Recursion is bounded centrally by Options.MaxDepth via Options.Recurse.
  • No network, no subprocess, no shell. A document cannot make anymd phone home. The single network call in the project is the CLI's explicit URL argument, which is a fetch you asked for, resolved before any converter sees a byte.
  • No cgo, so there is no memory-unsafe parser in the dependency graph.

Archive member names that try to escape the root — absolute paths, drive letters, ".." components — are refused rather than repeated into output.

Finding a way to panic a converter is a bug worth reporting.

Package anymd converts any document to Markdown, in pure Go.

It is a Go-native answer to Microsoft's markitdown: the same converter registry shape, the same "hints plus sniffing" dispatch, and GitHub-flavored Markdown out — with no cgo, no Python, and no native library to ship. A binary built from this module runs anywhere Go cross-compiles to.

md, err := anymd.ConvertFile("report.docx")
fmt.Println(md.Markdown)
Example

Example is the headline: hand anymd a document and get GitHub-flavored Markdown back. The hints in StreamInfo are optional — with none at all the engine sniffs the first 512 bytes — but passing the extension you already know saves it the guess.

package main

import (
	"fmt"
	"log"
	"strings"

	"github.com/muthuishere/anymd"
)

func main() {
	doc := strings.NewReader(`
		<html><head><title>Quarterly Notes</title></head><body>
		<h1>Quarterly Notes</h1>
		<p>Revenue is <b>up</b>.</p>
		<ul><li>EMEA</li><li>APAC</li></ul>
		</body></html>`)

	res, err := anymd.Convert(doc, anymd.StreamInfo{Extension: ".html"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("title:", res.Title)
	fmt.Println(res.Markdown)
}
Output:
title: Quarterly Notes
# Quarterly Notes

Revenue is **up**.

- EMEA
- APAC

Index

Examples

Constants

View Source
const (
	// PrioritySpecific is for converters keyed to an unambiguous magic number
	// or a unique extension (docx, pdf, xlsx, …). Default for new converters.
	PrioritySpecific = 0
	// PriorityGeneric is for converters that recognize a broad family and
	// would otherwise shadow a specific one (html, zip-as-container, …).
	PriorityGeneric = 10
	// PriorityFallback is for the last-resort text converter, which accepts
	// anything that decodes as text. Exactly one converter should sit here.
	PriorityFallback = 100
)

Priority orders converters within the registry. Lower runs first.

The engine tries converters in ascending priority, and the first whose Accepts returns true wins. Specific formats claim a low number; catch-alls that would swallow anything claim a high one.

View Source
const DefaultCacheBytes = 256 << 20 // 256 MiB

DefaultCacheBytes is the disk budget NewDiskCache uses for maxBytes <= 0.

View Source
const DefaultMemoryEntries = 256

DefaultMemoryEntries is the entry bound NewMemoryCache uses for max <= 0.

Variables

View Source
var ErrEncryptedPDF = errors.New("pdf is encrypted")

ErrEncryptedPDF reports that a PDF is encrypted and could not be opened with an empty password. We surface this instead of emitting empty output, so an encrypted file is never mistaken for an empty one.

View Source
var ErrMaxDepth = errors.New("anymd: max recursion depth exceeded")

ErrMaxDepth is returned when a container converter (zip, epub, mail with attachments) would recurse past Options.MaxDepth.

View Source
var ErrNoTextLayer = errors.New("pdf has no text layer (scanned images only); OCR is out of scope for anymd")

ErrNoTextLayer reports that a PDF parsed cleanly but carries no text layer at all — the classic pure-scan document, where every page is a single image.

This is a distinct, documented outcome rather than an empty success on purpose: emitting "" would be indistinguishable from a genuinely blank document, and the caller could not tell that the bytes it needs are locked inside a raster image. anymd is pure Go with no OCR engine, so recovering that text is out of scope unless the caller supplies an Options.Describer — with one, the page's embedded image is lifted out of the object graph and read by a vision model instead, and this error is returned only when even that produced nothing.

View Source
var ErrParseTimeout = errors.New("anymd: parser exceeded its time budget")

ErrParseTimeout means the underlying parser did not finish within xlsParseBudget and was abandoned. It is distinct from a malformed-file error: the input may be perfectly valid and merely pathological.

View Source
var ErrUnsafeCacheDir = errors.New("anymd: refusing to operate on this cache directory")

ErrUnsafeCacheDir reports a cache directory that must not be operated on.

View Source
var ErrUnsupported = errors.New("anymd: no converter accepted this stream")

ErrUnsupported is returned when no registered converter accepted the stream. Match it with errors.Is; the concrete value is an *UnsupportedError.

Functions

func CacheKey added in v0.2.0

func CacheKey(content []byte, converter string, info StreamInfo, opts *Options) string

CacheKey derives the cache key for one conversion.

The key is SHA-256 over a canonical, length-prefixed encoding of everything that can change the output:

  • the input bytes;
  • the anymd version (see Version for why this is not optional);
  • converter — which converter will handle the stream. The wrapper passes the engine's ordered registry digest, which DETERMINES the answer: dispatch is a pure function of (bytes, hints, options, registry), so two conversions agreeing on all four cannot land on different converters. A caller that already knows the name may pass it instead;
  • the StreamInfo hints, because they steer dispatch (a .txt hint and a .html hint on the same bytes produce different documents);
  • the output-affecting Options: the remaining recursion budget, KeepDataURIs, Charset, and WHETHER a Describer or Transcriber is set. An LLM-captioned conversion is a different document from an uncaptioned one, and confusing the two is the most user-visible way this cache could lie.

Every field is written as a tag byte, then its length as a big-endian uint64, then its bytes. Length prefixing is what stops ("ab","c") and ("a","bc") hashing alike — a concatenation-based key would let a crafted filename move content across a field boundary and collide with a different document.

Note what is NOT in the key: LLMTimeout and the Describer's identity. A Describer makes conversion non-deterministic in the first place; caching an LLM-captioned result caches one sampling of that model's output. That is usually what you want (it is why you are caching), but it is a choice, and two different Describers share a key. Use separate cache directories if that matters.

func CacheableError added in v0.2.0

func CacheableError(err error) bool

CacheableError reports whether err may be stored in a cache.

It is exported so the policy is visible and testable rather than buried in a type switch, and so a caller writing their own Cache can apply exactly the same rule.

func CheckCacheDir added in v0.2.0

func CheckCacheDir(dir string) error

CheckCacheDir rejects a directory that `cache clean` must never be pointed at: the filesystem root, a home directory, or anything else with no path segments below the root.

The failure this prevents is a typo — `--cache-dir /` — turning a cleanup into data loss. Clean is already limited to its own file suffix, so this is the second of two independent guards, not the only one.

func DecodeHTMLBytes

func DecodeHTMLBytes(raw []byte, declared string) string

DecodeHTMLBytes turns raw page bytes into a UTF-8 string.

Precedence is deliberate, because mojibake is the single most common HTML-conversion complaint: an explicitly declared charset (from the transport or the caller) wins, then the document's own BOM / <meta charset> / <meta http-equiv="content-type"> declaration, then statistical detection for bytes that are not valid UTF-8, and finally UTF-8 is assumed. A label we cannot look up is skipped rather than treated as fatal.

func DefaultCacheDir added in v0.2.0

func DefaultCacheDir() (string, error)

DefaultCacheDir returns os.UserCacheDir()/anymd.

Deliberately NOT ~/.config/anymd, where the LLM config lives: a cache is regenerable data, and putting it in the config directory means a user who backs up or syncs their dotfiles carries hundreds of megabytes of derived markdown with them. The XDG split exists for exactly this distinction.

func HTMLTitle

func HTMLTitle(htmlSrc string) string

HTMLTitle returns the document title of an HTML fragment: the <title> element, or the first <h1> when there is no title. It returns "" when neither is present or the input does not parse.

func HTMLToMarkdown

func HTMLToMarkdown(htmlSrc string, baseURL string) (string, error)

HTMLToMarkdown is the shared HTML path: every converter whose payload is HTML (feed item bodies, epub spine parts, mail parts, …) renders through this one function, so all of anymd's HTML comes out identically.

htmlSrc must already be UTF-8 (use DecodeHTMLBytes if you are holding raw bytes). baseURL, when non-empty, is used to turn relative href/src values into absolute URLs so links in a fetched page stay usable; pass "" to leave them relative. Nothing is ever fetched — an <img src> becomes a link, never a network request.

The returned markdown has no trailing newline; compose it with mdutil.Join.

func RewriteLinks(md, from string, mapping map[string]string) (out string)

RewriteLinks rewrites Markdown links that point at crawled URLs so they refer to the local files those pages were written to.

It is a pure function over text: no network, no filesystem. That is what makes a crawl's output self-contained — a mirrored site whose links still point at the internet is not a mirror.

from is the URL the document was fetched from, used to resolve relative targets. mapping is url -> path-relative-to-the-output-root. A link with no entry in mapping is left exactly as it was, absolute, because a link to a page we did not fetch must still work.

func Version added in v0.2.0

func Version() string

Version reports the anymd version that the cache key is bound to.

Why the cache key MUST contain it: commit c53cfbf fixed mdutil.Table emitting a blank line between rows. That changed the output bytes of every table-bearing document in every format. A content-only cache would have gone on serving the broken markdown after the upgrade, with no way for a user to work out why the fix "did not take". With the version in the key, an upgrade invalidates every entry for free, so `cache clean` is never required for correctness — only for disk space.

Resolution order:

  1. the anymd module's version from the importing program's build info (bi.Deps — a consumer's bi.Main is THEIR module, not ours);
  2. bi.Main.Version when anymd itself is the main module and was built from a tagged download;
  3. the VCS stamps go embeds for a build inside a git checkout (vcs.revision, plus "+dirty" when the tree was modified);
  4. buildVersion.

The weakness is step 4. `go build` with VCS stamping off (-buildvcs=false, a tarball with no .git, `go test` in some setups) yields the constant "dev" for every build, so during development the cache can serve output produced by code you have since changed. Step 3 narrows that to "same commit, dirty tree" — the working-tree edit itself is invisible. The remedies are --no-cache while iterating, and `anymd cache clean`; both are documented on the CLI for exactly this reason.

Types

type AudioConverter added in v0.2.0

type AudioConverter struct{}

AudioConverter turns speech into Markdown by handing the bytes to Options.Transcriber.

This is the one converter in the tree that cannot work offline: there is no pure-Go speech recogniser to ship, and inventing one is out of scope. So the converter exists but is *inert* until the caller supplies a Transcriber — which is why Accepts returns false when Options.Transcriber is nil.

That nil check is load-bearing, not defensive. The engine treats a converter that accepts and then fails as a hard error and does NOT fall through to the fallback, so accepting an .mp3 with no Transcriber would replace the honest ErrUnsupported ("nothing handles this stream") with a misleading "audio converter failed". Declining keeps the error truthful and keeps the promise that a default conversion makes no network calls.

func (*AudioConverter) Accepts added in v0.2.0

func (c *AudioConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes audio by magic bytes, mime, then extension — but only when a Transcriber is available to actually read it. See the type doc.

func (*AudioConverter) Convert added in v0.2.0

func (c *AudioConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

Convert transcribes the audio and renders it as prose.

Unlike image captioning — where a Describer failure degrades to "no caption" and the document still carries its text — a Transcriber failure here is a real error: the transcript IS the entire content of the document, so returning an empty success would be exactly the silent-empty-success this project refuses.

func (*AudioConverter) Name added in v0.2.0

func (c *AudioConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type CSVConverter

type CSVConverter struct{}

CSVConverter renders delimiter-separated text as a single GFM table with the first row as the header.

Real-world exports are ragged, so parsing never enforces a field count: a row with too few fields is padded and the header is widened to the widest row rather than truncating data away.

func (*CSVConverter) Accepts

func (c *CSVConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts keys off the extension and mime hints only. Sniffing text for commas would steal prose from the plain-text fallback.

func (*CSVConverter) Convert

func (c *CSVConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

Convert parses the stream and renders the table.

func (*CSVConverter) Name

func (c *CSVConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type Cache added in v0.2.0

type Cache interface {
	Get(key string) (Result, bool)
	Put(key string, res Result)
}

Cache stores conversion results under a key derived by CacheKey.

It is deliberately two methods over a string key: an implementation can be a map, a directory (DiskCache), Redis, S3 or a CDN without anymd knowing. A Cache MUST be safe for concurrent use — the CLI converts with a worker pool.

Put is fire-and-forget: a cache that cannot store an entry must drop it silently rather than fail a conversion that already succeeded.

type CacheStats added in v0.2.0

type CacheStats struct {
	Hits    uint64
	Misses  uint64
	Evicted uint64
	Entries int64
	Bytes   int64
}

CacheStats is a cache's running counters. Entries and Bytes are a snapshot; the counters are cumulative for the life of the value.

func (CacheStats) HitRate added in v0.2.0

func (s CacheStats) HitRate() float64

HitRate returns hits/(hits+misses), or 0 when nothing has been looked up.

type CachedEngine added in v0.2.0

type CachedEngine struct {
	// contains filtered or unexported fields
}

CachedEngine wraps an *Engine so that repeating a conversion serves it from a Cache instead of redoing it.

It is a wrapper rather than an Options field only because options.go and engine.go are owned elsewhere; the seam is deliberately the same one a field would use (see cachedConvert), so promoting it later changes no behaviour.

The zero value is not usable; call NewCachedEngine.

func NewCachedEngine added in v0.2.0

func NewCachedEngine(e *Engine, c Cache) *CachedEngine

NewCachedEngine wraps e so conversions are cached in c. A nil c is legal and disables caching, so a caller can write

eng := anymd.NewCachedEngine(anymd.New(), c)

without branching.

func (*CachedEngine) Cache added in v0.2.0

func (ce *CachedEngine) Cache() Cache

Cache returns the wrapped cache, which may be nil.

func (*CachedEngine) ConvertBytes added in v0.2.0

func (ce *CachedEngine) ConvertBytes(b []byte, info StreamInfo, opts *Options) (Result, error)

ConvertBytes converts an in-memory document, via the cache.

func (*CachedEngine) ConvertFile added in v0.2.0

func (ce *CachedEngine) ConvertFile(path string, opts *Options) (Result, error)

ConvertFile converts a file from disk, via the cache.

func (*CachedEngine) ConvertStream added in v0.2.0

func (ce *CachedEngine) ConvertStream(r io.Reader, info StreamInfo, opts *Options) (Result, error)

ConvertStream converts an arbitrary reader, via the cache.

Caching needs the whole input to hash it, so the stream is read into memory first — the engine buffers a non-seekable reader anyway, and hashing costs two to three orders of magnitude less than the conversion it saves.

func (*CachedEngine) Converters added in v0.2.0

func (ce *CachedEngine) Converters() []string

Converters returns the wrapped engine's converter names in dispatch order.

func (*CachedEngine) Engine added in v0.2.0

func (ce *CachedEngine) Engine() *Engine

Engine returns the wrapped engine.

func (*CachedEngine) Stats added in v0.2.0

func (ce *CachedEngine) Stats() CacheStats

Stats reports this wrapper's hit and miss counts.

type Converter

type Converter interface {
	Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool
	Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)
}

Converter turns one family of formats into Markdown.

The contract is markitdown's two-method shape:

Accepts — cheap, hint-and-sniff only. It may read from r freely to sniff
          magic bytes and need not rewind: the engine seeks r back to 0
          before every Accepts and before Convert.
Convert — the real work. r is rewound to 0 before the call.

Accepts must not be expensive: it runs against every registered converter.

type Describer added in v0.2.0

type Describer interface {
	// Describe returns a short prose description of the image. mime is the
	// image's media type (e.g. "image/png"); hint carries any context the
	// document already provided, such as existing alt text or a caption, and
	// may be empty.
	Describe(ctx context.Context, img []byte, mime, hint string) (string, error)
}

Describer turns an image into text. It is how anymd gets image captioning and OCR without shipping a model or picking a vendor.

This is the equivalent of markitdown's `llm_client=` parameter, but as an interface rather than a concrete SDK object: anything that can look at bytes and return a description satisfies it, including a local model, a hosted API, or a stub in your tests.

Options.Describer is nil by default. That is deliberate and load-bearing: with no Describer, anymd makes no network calls of any kind during conversion, which is the guarantee that lets you point it at untrusted input. Supplying one is opt-in, per-conversion, and visible in the caller's code.

Implementations must respect ctx, must not panic, and should return an error rather than a partial description when the request fails — a converter treats a Describer error as "no caption available" and continues, so a transient outage degrades output instead of failing the document.

func DescriberFunc added in v0.2.0

func DescriberFunc(f func(ctx context.Context, img []byte, mime, hint string) (string, error)) Describer

DescriberFunc adapts f to the Describer interface.

type DiskCache added in v0.2.0

type DiskCache struct {
	// contains filtered or unexported fields
}

DiskCache is a Cache backed by a directory, safe for concurrent use by multiple goroutines AND by multiple processes.

Entries are sharded two levels deep by key prefix (ab/cd/<key>.json), which keeps any one directory to a few hundred files at 100k entries instead of 100k in one directory — a shape that makes ext4 and APFS lookups slow and `ls` unusable.

func NewDiskCache added in v0.2.0

func NewDiskCache(dir string, maxBytes int64) (*DiskCache, error)

NewDiskCache opens (creating if needed) a cache directory with a byte budget. An empty dir means DefaultCacheDir; maxBytes <= 0 means DefaultCacheBytes.

func (*DiskCache) Clean added in v0.2.0

func (c *DiskCache) Clean() (int, error)

Clean removes every entry and every empty shard directory, and returns how many entries it removed.

It deletes only files ending in entrySuffix, and only files that pass contains — so a cache directory that someone also keeps notes in loses the cache and nothing else, and a caller who resolved the directory wrongly cannot turn Clean into `rm -rf`.

func (*DiskCache) Dir added in v0.2.0

func (c *DiskCache) Dir() string

Dir returns the absolute cache directory.

func (*DiskCache) Get added in v0.2.0

func (c *DiskCache) Get(key string) (Result, bool)

Get implements Cache. Anything unexpected — a missing file, a truncated file, invalid JSON, a schema we do not know, a key that does not match — is a MISS. A cache must never be a source of errors or of wrong content; the worst it may do is fail to help.

func (*DiskCache) GetErr added in v0.2.0

func (c *DiskCache) GetErr(key string) (error, bool)

GetErr implements ErrorCache.

func (*DiskCache) MaxBytes added in v0.2.0

func (c *DiskCache) MaxBytes() int64

MaxBytes returns the disk budget.

func (*DiskCache) Put added in v0.2.0

func (c *DiskCache) Put(key string, res Result)

Put implements Cache.

func (*DiskCache) PutErr added in v0.2.0

func (c *DiskCache) PutErr(key string, err error)

PutErr implements ErrorCache. Errors outside the allowlist are dropped.

func (*DiskCache) Stats added in v0.2.0

func (c *DiskCache) Stats() CacheStats

Stats walks the cache and reports entry count and total size alongside this value's cumulative counters.

Hits and misses are per-process and not persisted: a hit rate across invocations would need a counter file written on every lookup, which is a write on the read path — exactly what a cache should not add.

func (*DiskCache) Sweep added in v0.2.0

func (c *DiskCache) Sweep() error

Sweep enforces the byte budget, deleting least-recently-used entries until the cache is at 80% of it. Going to exactly the budget would make the next Put sweep again; leaving headroom amortizes the walk.

Concurrency: another process may be reading, writing or deleting the same files. Every removal tolerates a file that is already gone, and a reader that loses the race gets a miss.

type DocxConverter

type DocxConverter struct{}

DocxConverter renders a WordprocessingML document (.docx) as Markdown using nothing but archive/zip and encoding/xml.

It is deliberately a streaming token walk rather than a struct unmarshal: paragraph content is an *ordered* mix of runs, hyperlinks and change-tracking wrappers, and only a token walk preserves that order without recursing on attacker-controlled nesting.

func (*DocxConverter) Accepts

func (c *DocxConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes .docx by extension, by the WordprocessingML mime type, or by sniffing the zip central directory for word/document.xml. It never parses XML.

func (*DocxConverter) Convert

func (c *DocxConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

Convert renders the document body to Markdown and lifts dc:title, when present, into Result.Title.

func (*DocxConverter) Name

func (c *DocxConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type EPUBConverter

type EPUBConverter struct{}

EPUBConverter renders an EPUB as Markdown by walking the spine in reading order and converting each XHTML part through the shared HTML path.

It is implemented on archive/zip plus encoding/xml — no epub library — so the module keeps its "pure Go, nothing to ship" promise.

func (*EPUBConverter) Accepts

func (c *EPUBConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes an epub from its extension, its mime type, or the self-identifying "mimetype" entry the spec requires at the front of the archive. That last check is what lets a bare, unnamed stream be recognized without unzipping anything.

func (*EPUBConverter) Convert

func (c *EPUBConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (res Result, err error)

Convert reads container.xml, locates the OPF package, and emits the title, a short metadata block, and every spine part in order.

func (*EPUBConverter) Name

func (c *EPUBConverter) Name() string

Name implements Named.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine holds a converter registry. The zero Engine is empty; use New for one with every built-in converter registered.

func Default

func Default() *Engine

Default returns the shared package-level Engine, built on first use.

It is a function, not a variable, on purpose: Go initializes package-level variables BEFORE it runs init(), and every converter registers itself from an init(). A `var Default = New()` therefore captures an empty registry and makes every package-level helper return ErrUnsupported — which is exactly the bug this shape prevents. Building lazily guarantees registration has finished.

func New

func New() *Engine

New returns an Engine with all built-in converters registered.

func (*Engine) ConvertBytes

func (e *Engine) ConvertBytes(b []byte, info StreamInfo, opts *Options) (Result, error)

ConvertBytes converts an in-memory document.

func (*Engine) ConvertFile

func (e *Engine) ConvertFile(path string, opts *Options) (Result, error)

ConvertFile converts a file from disk, seeding the hints from its path.

func (*Engine) ConvertStream

func (e *Engine) ConvertStream(r io.Reader, info StreamInfo, opts *Options) (Result, error)

ConvertStream converts an arbitrary reader. If r is not an io.ReadSeeker it is buffered into memory first, because dispatch needs to rewind.

func (*Engine) Converters

func (e *Engine) Converters() []string

Converters returns the registered converter names in dispatch order.

Example

ExampleEngine_Converters lists the registry in dispatch order: ascending priority, ties broken by registration order.

Only the invariants are printed, not the whole list — new formats land in this project regularly, and an example asserting the full order would break on every one of them. What does not change is that the text catch-all sits alone at PriorityFallback, and so is always last.

package main

import (
	"fmt"
	"slices"

	"github.com/muthuishere/anymd"
)

func main() {
	names := anymd.New().Converters()

	fmt.Println("last:", names[len(names)-1])
	fmt.Println("has csv:", slices.Contains(names, "csv"))
	fmt.Println("has pdf:", slices.Contains(names, "pdf"))
}
Output:
last: plaintext
has csv: true
has pdf: true

func (*Engine) Register

func (e *Engine) Register(c Converter)

Register adds a converter. Its Priority (if it implements Prioritized) decides ordering; ties break by registration order, so a later Register at the same priority runs after an earlier one.

To override a built-in, register at a lower priority than it.

Example

ExampleEngine_Register adds a consumer-defined converter to a private registry. New gives you every built-in; Register layers yours on top.

package main

import (
	"errors"
	"fmt"
	"io"
	"log"
	"strings"

	"github.com/muthuishere/anymd"
)

// vcardConverter is a converter for a made-up format, written the way a
// consumer would write one: two required methods, plus the optional Named and
// Prioritized.
//
// Accepts is the hot path — it runs against every stream the engine sees — so
// it looks at hints and a magic prefix only, and never parses the document.
type vcardConverter struct{}

// Name gives the converter a stable identity in errors and in Engine.Converters.
// Without it the engine falls back to the Go type name.
func (vcardConverter) Name() string { return "vcard" }

// Priority puts this ahead of the plaintext fallback. A .vcf file decodes as
// UTF-8 text, so at PriorityFallback or later the catch-all would claim it
// first and emit the raw file. PrioritySpecific (0) is the right home for a
// converter keyed to a unique extension and magic string.
func (vcardConverter) Priority() int { return anymd.PrioritySpecific }

func (vcardConverter) Accepts(r io.ReadSeeker, info anymd.StreamInfo, opts *anymd.Options) bool {
	if info.HasExt(".vcf") {
		return true
	}
	var head [11]byte
	n, _ := io.ReadFull(r, head[:])
	return string(head[:n]) == "BEGIN:VCARD"
}

func (vcardConverter) Convert(r io.ReadSeeker, info anymd.StreamInfo, opts *anymd.Options) (anymd.Result, error) {
	b, err := io.ReadAll(r)
	if err != nil {
		return anymd.Result{}, err
	}
	var name string
	var rows []string
	for _, line := range strings.Split(string(b), "\n") {
		key, val, ok := strings.Cut(strings.TrimSpace(line), ":")
		if !ok || key == "BEGIN" || key == "END" {
			continue
		}
		if key == "FN" {
			name = val
			continue
		}
		rows = append(rows, "- **"+key+"**: "+val)
	}
	if name == "" {

		return anymd.Result{}, errors.New("vcard has no FN (formatted name) property")
	}
	md := "# " + name + "\n"
	if len(rows) > 0 {
		md += "\n" + strings.Join(rows, "\n") + "\n"
	}
	return anymd.Result{Markdown: md, Title: name}, nil
}

func main() {
	e := anymd.New()
	e.Register(vcardConverter{})

	card := []byte("BEGIN:VCARD\nVERSION:3.0\nFN:Ada Lovelace\nEMAIL:ada@example.com\nEND:VCARD\n")

	res, err := e.ConvertBytes(card, anymd.StreamInfo{Extension: ".vcf"}, nil)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(res.Markdown)

	// An accepting converter that fails is a hard error, never a silent
	// fall-through to the plaintext fallback.
	_, err = e.ConvertBytes([]byte("BEGIN:VCARD\nEND:VCARD\n"), anymd.StreamInfo{Extension: ".vcf"}, nil)
	fmt.Println("err:", err)

}
Output:
# Ada Lovelace

- **VERSION**: 3.0
- **EMAIL**: ada@example.com
err: anymd: vcard: vcard has no FN (formatted name) property

type ErrorCache added in v0.2.0

type ErrorCache interface {
	GetErr(key string) (error, bool)
	PutErr(key string, err error)
}

ErrorCache is an optional interface a Cache may also implement to remember deterministic FAILURES.

It is separate from Cache so that a remote or third-party cache can opt out with no ceremony, and so the Cache interface stays the two-method shape a caller expects. Only errors CacheableError accepts are ever stored.

type HTMLConverter

type HTMLConverter struct{}

HTMLConverter turns an HTML page into GitHub-flavored Markdown.

It sits at PriorityGeneric because "looks like markup" is a broad claim: docx, xlsx and epub are all zip-of-XML and must be given first refusal.

func (*HTMLConverter) Accepts

func (c *HTMLConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes HTML from the extension or mime hint, and otherwise from a cheap sniff of the head of the stream. It deliberately does NOT claim ".xml": a bare XML document is somebody else's format (a feed, an OPF, an office part), and claiming it here would shadow those converters.

func (*HTMLConverter) Convert

func (c *HTMLConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

Convert decodes the stream to UTF-8, strips non-content markup, and renders GitHub-flavored Markdown.

func (*HTMLConverter) Name

func (c *HTMLConverter) Name() string

Name implements Named.

func (*HTMLConverter) Priority

func (c *HTMLConverter) Priority() int

Priority implements Prioritized. Generic, so specific markup-bearing container formats are asked first.

type ImageConverter

type ImageConverter struct{}

ImageConverter renders what can be recovered from an image *losslessly*: its dimensions and its EXIF metadata.

There is deliberately no OCR and no captioning. anymd is a pure-Go library with no model and no native dependency, so inventing a description of the pixels is out of scope. What is in scope is the metadata — capture time, camera, lens, exposure, GPS, description, rights — which is genuine, verifiable text and is often the only part of an image a retrieval index can meaningfully match on.

func (*ImageConverter) Accepts

func (c *ImageConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes an image by magic bytes first, then by mime, then by extension.

func (*ImageConverter) Convert

func (c *ImageConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (res Result, err error)

Convert emits the image placeholder, its dimensions, and its EXIF table.

func (*ImageConverter) Name

func (c *ImageConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type IpynbConverter

type IpynbConverter struct{}

IpynbConverter renders a Jupyter notebook: markdown cells verbatim, code cells fenced in the kernel's language, and textual outputs fenced beneath them. Image and other binary outputs are dropped entirely — a page of base64 is noise to every consumer of this markdown.

func (*IpynbConverter) Accepts

func (c *IpynbConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes a notebook by extension or mime, or by the cheap sniff of a JSON opening brace plus an "nbformat" key in the head. It never parses a whole file here.

func (*IpynbConverter) Convert

func (c *IpynbConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

Convert renders the notebook.

func (*IpynbConverter) Name

func (c *IpynbConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type JSONConverter

type JSONConverter struct{}

JSONConverter renders JSON as a fenced, re-indented code block — except for the common "exported records" shape, a top-level array of flat objects, which becomes a GFM table because a table is far easier for a reader (human or model) to scan than 400 lines of braces.

func (*JSONConverter) Accepts

func (c *JSONConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts requires the bytes to actually be JSON, because the .json extension is frequently wrong and a mis-claim is a hard error for the engine.

func (*JSONConverter) Convert

func (c *JSONConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

Convert renders the document.

func (*JSONConverter) Name

func (c *JSONConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

func (*JSONConverter) Priority

func (c *JSONConverter) Priority() int

Priority sits one step behind PrioritySpecific so the notebook converter, whose files are also JSON, always gets first refusal.

type MemoryCache added in v0.2.0

type MemoryCache struct {
	// contains filtered or unexported fields
}

MemoryCache is a bounded, concurrency-safe in-memory LRU Cache.

It bounds ENTRIES rather than bytes: a Result is a string, and counting bytes would make the common case (a process converting a few hundred documents) pay for accounting it does not need. Use DiskCache when the budget must be in bytes.

func NewMemoryCache added in v0.2.0

func NewMemoryCache(max int) *MemoryCache

NewMemoryCache returns an LRU holding at most max entries (DefaultMemoryEntries when max <= 0).

func (*MemoryCache) Get added in v0.2.0

func (c *MemoryCache) Get(key string) (Result, bool)

Get implements Cache. An entry holding a cached ERROR is not a Get hit: the caller asked for a Result, and handing back the zero Result would turn a remembered failure into an empty document.

func (*MemoryCache) GetErr added in v0.2.0

func (c *MemoryCache) GetErr(key string) (error, bool)

GetErr implements ErrorCache.

func (*MemoryCache) Len added in v0.2.0

func (c *MemoryCache) Len() int

Len reports the number of entries currently held.

func (*MemoryCache) Put added in v0.2.0

func (c *MemoryCache) Put(key string, res Result)

Put implements Cache.

func (*MemoryCache) PutErr added in v0.2.0

func (c *MemoryCache) PutErr(key string, err error)

PutErr implements ErrorCache.

func (*MemoryCache) Stats added in v0.2.0

func (c *MemoryCache) Stats() CacheStats

Stats reports lookups served and entries evicted.

type MsgConverter

type MsgConverter struct{}

MsgConverter converts an Outlook .msg (a MAPI message in a Compound File Binary container) to Markdown.

func (*MsgConverter) Accepts

func (c *MsgConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes a .msg.

The CFB magic alone is NOT enough: it is byte-for-byte the magic of legacy .doc, .xls and .ppt, so accepting on it would hijack every one of those files and — because the engine treats an accepted-then-failed conversion as a hard error — permanently break them rather than letting their own converter run. So unless the filename says .msg, we additionally require the UTF-16LE `__substg1.0_` directory-entry marker, which only a MAPI message carries.

func (*MsgConverter) Convert

func (c *MsgConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (res Result, err error)

Convert renders subject, envelope and body.

func (*MsgConverter) Name

func (c *MsgConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type Named

type Named interface {
	Name() string
}

Named is an optional interface: a converter that reports its own name gets that name in error messages and in `anymd --list`. Converters that do not implement it fall back to their Go type name.

type Options

type Options struct {
	// MaxDepth bounds container recursion (a zip inside a zip …). 0 means the
	// default of 8. Negative disables recursion entirely.
	MaxDepth int

	// KeepDataURIs keeps base64 image payloads inline as data: URIs instead of
	// dropping them to an empty ![](). Matches markitdown's keep_data_uris.
	KeepDataURIs bool

	// Charset overrides the detected encoding for text-ish formats.
	Charset string

	// Cache, when non-nil, serves and stores conversions content-addressed.
	// Nil (the default) disables caching entirely: a library must not write to
	// a caller's disk unasked, and a one-shot conversion pays the hash for
	// nothing. See CacheKey for what the key covers — notably the anymd
	// version, so an upgrade invalidates rather than serving stale output.
	Cache Cache

	// Describer, when non-nil, is used to caption images and to read pages that
	// have no text layer. Nil (the default) means anymd makes NO network calls
	// during conversion — see the Describer docs.
	Describer Describer

	// Transcriber, when non-nil, is used to convert audio to text. Nil (the
	// default) means audio formats are unsupported.
	Transcriber Transcriber

	// LLMTimeout bounds a single Describer or Transcriber call. Zero means
	// 60s. A slow model must not be able to stall a whole document.
	LLMTimeout time.Duration
	// contains filtered or unexported fields
}

Options tunes a conversion. The zero value is valid and gives markitdown's defaults.

Example

ExampleOptions shows MaxDepth bounding container recursion. Containers recurse through Options.Recurse, which carries the depth counter, so the limit is enforced centrally rather than trusted to each converter.

A member that is too deep is reported inline and its siblings still convert: losing a whole archive because one member nested too far would be the wrong trade.

package main

import (
	"archive/zip"
	"bytes"
	"fmt"
	"io"
	"log"
	"slices"

	"github.com/muthuishere/anymd"
)

func main() {
	inner := makeZip(map[string]string{"note.txt": "hello from the inside"})
	outer := makeZip(map[string]string{"inner.zip": string(inner)})

	e := anymd.New()

	// MaxDepth 1: the outer archive's members convert, but the inner archive's
	// members are one level too far.
	shallow, err := e.ConvertBytes(outer, anymd.StreamInfo{Extension: ".zip"}, &anymd.Options{MaxDepth: 1})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(shallow.Markdown)

	fmt.Println("---")

	// MaxDepth 2 reaches all the way down.
	deep, err := e.ConvertBytes(outer, anymd.StreamInfo{Extension: ".zip"}, &anymd.Options{MaxDepth: 2})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(deep.Markdown)

}

// makeZip builds a zip archive in memory. Fixtures are built in Go on purpose:
// the repo carries no binary test data, so these examples pass on a bare clone.
// Names are written in sorted order so the emitted markdown is deterministic.
func makeZip(members map[string]string) []byte {
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	names := make([]string, 0, len(members))
	for name := range members {
		names = append(names, name)
	}
	slices.Sort(names)
	for _, name := range names {
		w, err := zw.Create(name)
		if err != nil {
			log.Fatal(err)
		}
		if _, err := io.WriteString(w, members[name]); err != nil {
			log.Fatal(err)
		}
	}
	if err := zw.Close(); err != nil {
		log.Fatal(err)
	}
	return buf.Bytes()
}
Output:
## inner.zip

## note.txt

*[could not convert: anymd: max recursion depth exceeded]*
---
## inner.zip

## note.txt

hello from the inside

func (*Options) Depth

func (o *Options) Depth() int

Depth reports how many containers deep the current conversion is (0 at the top level).

func (*Options) HasDescriber added in v0.2.0

func (o *Options) HasDescriber() bool

HasDescriber reports whether captioning is available, so a converter can skip the work of extracting image bytes when nothing will read them.

func (*Options) Recurse

func (o *Options) Recurse(r io.ReadSeeker, info StreamInfo) (Result, error)

Recurse converts a nested stream with the same engine and options, tracking depth. Container converters (zip, epub, msg) MUST use this rather than building their own Engine, so MaxDepth is actually enforced.

type PDFConverter

type PDFConverter struct{}

PDFConverter extracts a PDF's text layer as Markdown.

Pages are separated by a `---` horizontal rule rather than a heading: a page number is pagination, not document structure, and injecting it as a heading would corrupt the outline of every document it touches.

func (*PDFConverter) Accepts

func (c *PDFConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts sniffs the %PDF- magic first — it is the only signal that cannot be faked by a wrong filename — and falls back to the mime and extension hints so that a mislabelled or truncated PDF still reaches Convert and produces a real error instead of being swallowed by the plaintext fallback.

func (*PDFConverter) Convert

func (c *PDFConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (res Result, err error)

Convert extracts the text layer page by page.

The whole body runs under a recover: internal/pdf reports malformed structure by panicking (see its errorf), and this package's contract is that hostile bytes produce an error, never a crash in the caller's process.

func (*PDFConverter) Name

func (c *PDFConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type PlainTextConverter

type PlainTextConverter struct{}

PlainTextConverter is the last-resort converter: anything that decodes as text passes through verbatim. It sits at PriorityFallback so it can never shadow a real format.

func (*PlainTextConverter) Accepts

func (c *PlainTextConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

func (*PlainTextConverter) Convert

func (c *PlainTextConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

func (*PlainTextConverter) Name

func (c *PlainTextConverter) Name() string

func (*PlainTextConverter) Priority

func (c *PlainTextConverter) Priority() int

type PptxConverter

type PptxConverter struct{}

PptxConverter renders a PresentationML deck (.pptx) as Markdown using nothing but archive/zip and encoding/xml.

func (*PptxConverter) Accepts

func (c *PptxConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes .pptx by extension, by the PresentationML mime type, or by sniffing the zip central directory for ppt/presentation.xml.

func (*PptxConverter) Convert

func (c *PptxConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

Convert renders every slide in presentation order as "## Slide n" followed by its shape text, tables, and speaker notes.

func (*PptxConverter) Name

func (c *PptxConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type Prioritized

type Prioritized interface {
	Priority() int
}

Prioritized is an optional interface; a converter that does not implement it is registered at PrioritySpecific.

type RSSConverter

type RSSConverter struct{}

RSSConverter renders an RSS 2.0, RDF/RSS 1.0, or Atom feed as Markdown.

It stays at PrioritySpecific so it wins over the generic HTML converter, and it pairs a hint check with a content sniff: a plain .xml file that is not a feed must fall through to whoever really owns it, not be claimed here and then hard-fail the engine.

func (*RSSConverter) Accepts

func (c *RSSConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts requires BOTH a plausible hint (extension or mime) AND a sniff that the head really contains a feed root element.

func (*RSSConverter) Convert

func (c *RSSConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (res Result, err error)

Convert parses the feed and renders it, preserving item order as given.

func (*RSSConverter) Name

func (c *RSSConverter) Name() string

Name implements Named.

type Result

type Result struct {
	// Markdown is the converted body.
	Markdown string
	// Title is an optional document title (docx core properties, <title>, …).
	Title string
}

Result is what a converter produces.

func Convert

func Convert(r io.Reader, info StreamInfo) (Result, error)

Convert converts a reader with the default engine.

func ConvertBytes

func ConvertBytes(b []byte, info StreamInfo) (Result, error)

ConvertBytes converts an in-memory document with the default engine.

Example

ExampleConvertBytes converts a document already in memory. Delimited text becomes one GFM pipe table with the first row promoted to the header.

package main

import (
	"fmt"
	"log"

	"github.com/muthuishere/anymd"
)

func main() {
	csv := []byte("region,seats,renewed\nEMEA,120,yes\nAPAC,64,no\n")

	res, err := anymd.ConvertBytes(csv, anymd.StreamInfo{Extension: ".csv"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(res.Markdown)
}
Output:
| region | seats | renewed |
| --- | --- | --- |
| EMEA | 120 | yes |
| APAC | 64 | no |

func ConvertFile

func ConvertFile(path string) (Result, error)

ConvertFile converts a file from disk with the default engine.

Example

ExampleConvertFile converts a file from disk. The path seeds the extension, filename and MIME hints, so no StreamInfo is needed.

The temp path is deliberately not printed: it changes on every run, and an example's output has to be byte-stable to be worth compiling.

package main

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/muthuishere/anymd"
)

func main() {
	dir, err := os.MkdirTemp("", "anymd-example")
	if err != nil {
		log.Fatal(err)
	}
	defer os.RemoveAll(dir)

	path := filepath.Join(dir, "release.md")
	if err := os.WriteFile(path, []byte("# v1.2.0\n\n- faster zip walk\n"), 0o600); err != nil {
		log.Fatal(err)
	}

	res, err := anymd.ConvertFile(path)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(res.Markdown)
}
Output:
# v1.2.0

- faster zip walk

func (Result) String

func (r Result) String() string

String returns the markdown body, so a Result can be printed directly.

type StreamInfo

type StreamInfo struct {
	// MimeType, e.g. "application/pdf". Parameters are stripped by NormalizedMime.
	MimeType string
	// Extension including the leading dot, lowercased, e.g. ".pdf".
	Extension string
	// Charset, e.g. "utf-8". Empty means unknown.
	Charset string
	// FileName is the base name, when the stream came from a file.
	FileName string
	// URL is the origin URL, when the stream came from the network. Some
	// converters (feeds, wiki exports) key off it.
	URL string
}

StreamInfo carries everything known about a byte stream before conversion. Every field is a HINT and may be empty: converters must tolerate a bare stream and decide from content when the hints are absent.

Mirrors markitdown's StreamInfo (_stream_info.py) so the mental model ports across languages.

func StreamInfoForFile

func StreamInfoForFile(path string) StreamInfo

StreamInfoForFile builds the hints derivable from a path alone.

func (StreamInfo) CopyAndUpdate

func (s StreamInfo) CopyAndUpdate(other StreamInfo) StreamInfo

CopyAndUpdate returns a copy with every non-empty field of other applied.

func (StreamInfo) Ext

func (s StreamInfo) Ext() string

Ext returns Extension lowercased with a guaranteed leading dot ("" stays "").

func (StreamInfo) HasExt

func (s StreamInfo) HasExt(exts ...string) bool

HasExt reports whether the extension hint matches any of exts (each with a leading dot).

func (StreamInfo) HasMimePrefix

func (s StreamInfo) HasMimePrefix(prefixes ...string) bool

HasMimePrefix reports whether the normalized mime type starts with any prefix.

func (StreamInfo) NormalizedMime

func (s StreamInfo) NormalizedMime() string

NormalizedMime returns MimeType lowercased with any ";" parameters removed.

type Transcriber added in v0.2.0

type Transcriber interface {
	// Transcribe returns the spoken content of the audio. mime is the media
	// type (e.g. "audio/mpeg").
	Transcribe(ctx context.Context, audio []byte, mime string) (string, error)
}

Transcriber turns audio into text.

Same contract and same default as Describer: nil means no transcription and no network. Supplying one closes the last format gap against markitdown, which transcribes audio by calling a remote speech service.

type UnsupportedError

type UnsupportedError struct {
	Ext      string
	Mime     string
	Declined []string
}

UnsupportedError reports that nothing claimed the stream, and names every converter that looked at it and declined.

Declined is deliberately NOT part of Error(): a container converter such as zip embeds a member's error text into the document it produces, and a reader should not find our converter registry printed inside their markdown. The list is here for callers that want it — a verbose CLI flag, a bug report — without it leaking into rendered output.

Example

ExampleUnsupportedError shows the two ways to read a "nothing claimed this" failure: the sentinel, for a quick branch, and the typed error, for the detail worth logging.

package main

import (
	"errors"
	"fmt"

	"github.com/muthuishere/anymd"
)

func main() {
	// Binary bytes with a NUL, so even the text fallback declines rather than
	// emitting garbage as "markdown".
	blob := []byte{0x00, 0x01, 0x02, 0x03, 0xff, 0xfe}

	_, err := anymd.ConvertBytes(blob, anymd.StreamInfo{Extension: ".widget"})

	fmt.Println("unsupported:", errors.Is(err, anymd.ErrUnsupported))

	var ue *anymd.UnsupportedError
	if errors.As(err, &ue) {
		fmt.Println("ext:", ue.Ext)
		fmt.Println("mime:", ue.Mime)
		// Declined names every converter that looked and passed. It is kept out
		// of Error() so the registry never leaks into rendered markdown.
		fmt.Println("declined some:", len(ue.Declined) > 0)
	}
}
Output:
unsupported: true
ext: .widget
mime: application/octet-stream
declined some: true

func (*UnsupportedError) Error

func (e *UnsupportedError) Error() string

func (*UnsupportedError) Unwrap

func (e *UnsupportedError) Unwrap() error

Unwrap makes errors.Is(err, ErrUnsupported) work.

type XLSConverter

type XLSConverter struct{}

XLSConverter renders a legacy binary Excel workbook (BIFF inside an OLE2 / Compound File container, the pre-2007 ".xls") as one GFM table per sheet, in the workbook's own sheet order, each under an "## SheetName" heading.

Its output shape is deliberately identical to XlsxConverter's — same heading level, same trailing-blank trimming, same cell cap, and the same split of a sheet into one table per 4-connected region — so a reader cannot tell which of the two Excel formats a document came from. BIFF's merged-cell records are not exposed by the parser, so a legacy sheet's merges do not join regions the way an .xlsx's do; everything else is shared code.

func (*XLSConverter) Accepts

func (c *XLSConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes a legacy workbook.

The CFB magic alone is NOT enough: it is byte-for-byte the magic of legacy .doc and .ppt and of an Outlook .msg, and because the engine treats an accept-then-fail as a hard error, a false accept would permanently break those files rather than letting their own converter run. So on a bare stream we decline unless we can POSITIVELY confirm a top-level "Workbook"/"Book" stream in the CFB directory, and we decline outright when the MAPI `__substg1.0_` marker that MsgConverter keys on is present — which makes the two converters provably disjoint. When in doubt we decline: a false decline only means the extension hint (or the fallback) decides.

func (*XLSConverter) Convert

func (c *XLSConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (res Result, err error)

Convert renders every non-empty sheet.

func (*XLSConverter) Name

func (c *XLSConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type XlsxConverter

type XlsxConverter struct{}

XlsxConverter renders an OOXML workbook as GFM tables, in the workbook's own sheet order, each sheet under an "## SheetName" heading.

A sheet is not one table. Spreadsheets are laid out visually, and a single sheet routinely holds several unrelated blocks separated by blank rows or blank columns — a title block, a revision block, a data grid beside a legend. Dumping the whole used range as one table welds them together and destroys the row/column alignment of every one of them. So the sheet is split into 4-connected regions of non-empty cells and each region becomes its own table with its own header row, which is also what docling's ground truth expects.

Values are rendered as displayed rather than as stored: dates come out as dates instead of serial numbers, and a formula cell emits its cached result. Charts anchored on a sheet contribute their title, type and cached series data, and cell comments are appended after the sheet's tables.

func (*XlsxConverter) Accepts

func (c *XlsxConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts recognizes a workbook from hints, or from the zip magic plus an "xl/workbook.xml" entry — the cheapest sniff that distinguishes an xlsx from every other PK-prefixed container (docx, pptx, jar, epub).

func (*XlsxConverter) Convert

func (c *XlsxConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (res Result, err error)

Convert renders every visible, non-empty sheet.

func (*XlsxConverter) Name

func (c *XlsxConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

type ZipConverter

type ZipConverter struct{}

ZipConverter renders a zip archive as one Markdown document: an H2 per member, followed by that member converted through the same engine.

It is deliberately generic. A zip is a bag of unrelated things, so a member that fails to convert is reported inline and the walk continues — losing 99 good files because the 100th was corrupt would be the wrong trade.

func (*ZipConverter) Accepts

func (c *ZipConverter) Accepts(r io.ReadSeeker, info StreamInfo, opts *Options) bool

Accepts reports whether this is a zip that no more specific converter owns.

It reads the central directory, which is a bounded tail read rather than a walk of the members, so the "Accepts is cheap" rule still holds: no entry is decompressed except an epub's ~20-byte "mimetype".

func (*ZipConverter) Convert

func (c *ZipConverter) Convert(r io.ReadSeeker, info StreamInfo, opts *Options) (Result, error)

Convert walks the archive in its stored order, emitting a heading and the converted body for each member.

func (*ZipConverter) Name

func (c *ZipConverter) Name() string

Name identifies the converter in errors and in `anymd --list`.

func (*ZipConverter) Priority

func (c *ZipConverter) Priority() int

Priority is PriorityGeneric: "it is a zip" is a broad claim, and the specific zip-based formats must get first refusal.

Directories

Path Synopsis
bench
inproc command
Command inproc measures anymd's in-process conversion time on the same files, in the same way, as the markitdown loop in bench/run.sh: one warm-up convert, then the mean of ten.
Command inproc measures anymd's in-process conversion time on the same files, in the same way, as the markitdown loop in bench/run.sh: one warm-up convert, then the mean of ten.
cmd
anymd command
Command anymd converts any document to Markdown.
Command anymd converts any document to Markdown.
Package crawl fetches a site and hands each page to a callback.
Package crawl fetches a site and hands each page to a callback.
internal
mdutil
Package mdutil holds the shared GitHub-flavored-Markdown emitters.
Package mdutil holds the shared GitHub-flavored-Markdown emitters.
ooxml
Package ooxml holds the zip-and-relationships plumbing shared by the Office Open XML converters (docx, pptx, and anything else that is a zip of XML parts).
Package ooxml holds the zip-and-relationships plumbing shared by the Office Open XML converters (docx, pptx, and anything else that is a zip of XML parts).
pdf
Package pdf implements reading of PDF files.
Package pdf implements reading of PDF files.
Package llm gives anymd image captioning and OCR by handing images to a vision model, the way markitdown's llm_client= parameter does.
Package llm gives anymd image captioning and OCR by handing images to a vision model, the way markitdown's llm_client= parameter does.
Package skills carries the anymd agent skill as data embedded in the binary.
Package skills carries the anymd agent skill as data embedded in the binary.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL