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 ¶
- Constants
- Variables
- func CacheKey(content []byte, converter string, info StreamInfo, opts *Options) string
- func CacheableError(err error) bool
- func CheckCacheDir(dir string) error
- func DecodeHTMLBytes(raw []byte, declared string) string
- func DefaultCacheDir() (string, error)
- func HTMLTitle(htmlSrc string) string
- func HTMLToMarkdown(htmlSrc string, baseURL string) (string, error)
- func RewriteLinks(md, from string, mapping map[string]string) (out string)
- func Version() string
- type AudioConverter
- type CSVConverter
- type Cache
- type CacheStats
- type CachedEngine
- func (ce *CachedEngine) Cache() Cache
- func (ce *CachedEngine) ConvertBytes(b []byte, info StreamInfo, opts *Options) (Result, error)
- func (ce *CachedEngine) ConvertFile(path string, opts *Options) (Result, error)
- func (ce *CachedEngine) ConvertStream(r io.Reader, info StreamInfo, opts *Options) (Result, error)
- func (ce *CachedEngine) Converters() []string
- func (ce *CachedEngine) Engine() *Engine
- func (ce *CachedEngine) Stats() CacheStats
- type Converter
- type Describer
- type DiskCache
- func (c *DiskCache) Clean() (int, error)
- func (c *DiskCache) Dir() string
- func (c *DiskCache) Get(key string) (Result, bool)
- func (c *DiskCache) GetErr(key string) (error, bool)
- func (c *DiskCache) MaxBytes() int64
- func (c *DiskCache) Put(key string, res Result)
- func (c *DiskCache) PutErr(key string, err error)
- func (c *DiskCache) Stats() CacheStats
- func (c *DiskCache) Sweep() error
- type DocxConverter
- type EPUBConverter
- type Engine
- func (e *Engine) ConvertBytes(b []byte, info StreamInfo, opts *Options) (Result, error)
- func (e *Engine) ConvertFile(path string, opts *Options) (Result, error)
- func (e *Engine) ConvertStream(r io.Reader, info StreamInfo, opts *Options) (Result, error)
- func (e *Engine) Converters() []string
- func (e *Engine) Register(c Converter)
- type ErrorCache
- type HTMLConverter
- type ImageConverter
- type IpynbConverter
- type JSONConverter
- type MemoryCache
- type MsgConverter
- type Named
- type Options
- type PDFConverter
- type PlainTextConverter
- type PptxConverter
- type Prioritized
- type RSSConverter
- type Result
- type StreamInfo
- type Transcriber
- type UnsupportedError
- type XLSConverter
- type XlsxConverter
- type ZipConverter
Examples ¶
Constants ¶
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.
const DefaultCacheBytes = 256 << 20 // 256 MiB
DefaultCacheBytes is the disk budget NewDiskCache uses for maxBytes <= 0.
const DefaultMemoryEntries = 256
DefaultMemoryEntries is the entry bound NewMemoryCache uses for max <= 0.
Variables ¶
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.
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.
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.
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.
var ErrUnsafeCacheDir = errors.New("anymd: refusing to operate on this cache directory")
ErrUnsafeCacheDir reports a cache directory that must not be operated on.
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
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
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 ¶
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
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 ¶
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 ¶
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 ¶ added in v0.2.0
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:
- the anymd module's version from the importing program's build info (bi.Deps — a consumer's bi.Main is THEIR module, not ours);
- bi.Main.Version when anymd itself is the main module and was built from a tagged download;
- the VCS stamps go embeds for a build inside a git checkout (vcs.revision, plus "+dirty" when the tree was modified);
- 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
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
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.
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
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
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) Get ¶ added in v0.2.0
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) PutErr ¶ added in v0.2.0
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
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.
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 (*Engine) ConvertBytes ¶
ConvertBytes converts an in-memory document.
func (*Engine) ConvertFile ¶
ConvertFile converts a file from disk, seeding the hints from its path.
func (*Engine) ConvertStream ¶
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 ¶
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 ¶
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
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) 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 ¶
Depth reports how many containers deep the current conversion is (0 at the top level).
func (*Options) HasDescriber ¶ added in v0.2.0
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.
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 ¶
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
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 ¶
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.
Source Files
¶
- builtins.go
- cache.go
- cache_disk.go
- conv_audio.go
- conv_csv.go
- conv_docx.go
- conv_epub.go
- conv_html.go
- conv_image.go
- conv_ipynb.go
- conv_json.go
- conv_msg.go
- conv_pdf.go
- conv_plaintext.go
- conv_pptx.go
- conv_rss.go
- conv_xls.go
- conv_xlsx.go
- conv_zip.go
- converter.go
- doc.go
- engine.go
- linkrewrite.go
- llm.go
- llm_wiring.go
- options.go
- streaminfo.go
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. |