microsoft/markitdown
github.com/microsoft/markitdown ↗ · 176 files · 3.1 MB
MarkItDown [!IMPORTANT] MarkItDown performs I/O with the privileges of the current process.
Overview
MarkItDown is a Microsoft-maintained Python monorepo whose core package converts files, URLs, and byte streams into Markdown optimized for LLM consumption. It dispatches input through a priority-ordered registry of pluggable DocumentConverter classes after sniffing content with Google's magika model and charset-normalizer. The repo also ships an MCP (Model Context Protocol) server package exposing conversion as a tool, an official OCR plugin that uses LLM vision models, and a sample plugin demonstrating the entry-point-based plugin API.
System map
The repo is a uv/pip-installable Python monorepo under packages/ with four distributions: markitdown (the library + `markitdown` CLI), markitdown-mcp (an MCP server wrapping the library), markitdown-ocr (a first-party plugin adding LLM-vision OCR), and markitdown-sample-plugin (an RTF reference plugin). The core engine is the MarkItDown class in _markitdown.py, which owns a requests.Session, a magika.Magika identifier, and a priority-sorted list of ConverterRegistration entries. All format logic lives in ~25 DocumentConverter subclasses under converters/ plus shared helpers in converter_utils/. Build is hatchling-based; tests run via `hatch test` or pytest in CI across Python 3.10-3.14 on Linux, Windows, and Windows ARM64.
- The central orchestrator is class MarkItDown in packages/markitdown/src/markitdown/_markitdown.py (line 141); its constructor builds a requests.Session with an 'Accept: text/markdown, text/html;q=0.9, ...' header and instantiates magika.Magika() for file-type identification.
- Built-in converters are registered in enable_builtins() in a deliberate order: PlainTextConverter, ZipConverter, and HtmlConverter get PRIORITY_GENERIC_FILE_FORMAT (10.0) while ~15 specific converters get PRIORITY_SPECIFIC_FILE_FORMAT (0.0); lower values are tried first and registration order within a priority is reversed (insert at index 0).
- Every converter implements the DocumentConverter interface defined in _base_converter.py: accepts(file_stream, stream_info, **kwargs) -> bool and convert(...) -> DocumentConverterResult; DocumentConverterResult carries `markdown` plus optional `title`, with `text_content` kept as a soft-deprecated alias.
- Stream metadata flows through the frozen dataclass StreamInfo (mimetype, extension, charset, filename, local_path, url) with a copy_and_update() merge helper in _stream_info.py.
- The CLI entrypoint is `markitdown = markitdown.__main__:main` declared in pyproject [project.scripts]; __main__.py builds an argparse parser supporting -o/--output, -x/--extension, -m/--mime-type, -c/--charset, -d/--use-docintel, --use-cu, -p/--use-plugins, --list-plugins, and --keep-data-uris.
- The MCP server package markitdown-mcp defines one tool `convert_to_markdown(uri)` on an MCPServer('markitdown') instance and can run over stdio (mcp.run()) or HTTP via a combined Starlette app merging the SSE and Streamable-HTTP sub-apps served by uvicorn on 127.0.0.1:3001 by default.
- Core runtime dependencies are deliberately thin: beautifulsoup4, requests, markdownify, magika~=0.6.1, charset-normalizer, defusedxml; all heavy parsers (mammoth, python-pptx, pandas/openpyxl/xlrd, pdfminer.six, pdfplumber, olefile, pydub, SpeechRecognition, youtube-transcript-api, Azure SDKs) are optional extras declared in pyproject.toml [project.optional-dependencies].
- Third-party plugins are discovered via importlib.metadata entry_points(group='markitdown.plugin') in _load_plugins(); each plugin module must expose register_converters(markitdown, **kwargs), demonstrated by the sample plugin and the markitdown-ocr package's [project.entry-points."markitdown.plugin"] declaration.
- The root Dockerfile builds a python:3.13-slim-trixie image installing ffmpeg and libimage-exiftool-perl, sets EXIFTOOL_PATH/FFMPEG_PATH/ORT_DISABLE_TELEMETRY env vars, pip-installs markitdown[all] plus the sample plugin, drops to USER nobody:nogroup, and uses ENTRYPOINT ['markitdown'] for stdin->stdout conversion.
- CI runs three pytest/hatch jobs in .github/workflows/tests.yml (core hatch test matrix on ubuntu + windows + windows-11-vs2026-arm; ocr-tests installing both local packages; mcp-tests installing markitdown[all] + markitdown-mcp) plus a separate pre-commit workflow, with PIP_CONSTRAINT/UV_CONSTRAINT pinned to .github/constraints.txt.
- The public API surface is explicitly re-exported in markitdown/__init__.py: MarkItDown, DocumentConverter, DocumentConverterResult, StreamInfo, the exception hierarchy, and the two PRIORITY_* constants; converter classes are importable from markitdown.converters.
- Versions are managed per-package via hatch's [tool.hatch.version] pointing at each package's __about__.py: markitdown 0.1.8, markitdown-mcp 0.0.1a7, markitdown-ocr 0.1.1.
Feature catalog
User-facing surface is three-fold: the `markitdown` CLI, the MarkItDown Python API (convert/convert_local/convert_stream/convert_uri/convert_response), and the `markitdown-mcp` server tool. Format coverage spans office docs (docx/xlsx/xls/pptx), PDF, images, audio, HTML plus URL-specialized converters (Wikipedia, YouTube, Bing SERP), EPUB, Outlook .msg, Jupyter notebooks, CSV, ZIP archives, and plain text. Cloud integrations offer Azure Document Intelligence and Azure Content Understanding as opt-in converters, and LLM-based image captions via a caller-supplied OpenAI-compatible client.
- The CLI accepts a filename or stdin (sys.stdin.buffer wrapped in BytesIO), writes markdown to stdout or -o file, and supports hint flags -x/-m/-c that populate a StreamInfo passed to convert().
- --use-docintel / -d requires -e/--endpoint or env var MARKITDOWN_DOCINTEL_ENDPOINT and registers DocumentIntelligenceConverter, which calls Azure's prebuilt-layout model via DocumentIntelligenceClient.begin_analyze_document with markdown output format.
- --use-cu requires --cu-endpoint or env MARKITDOWN_CU_ENDPOINT, accepts --cu-analyzer and --cu-file-types, and registers ContentUnderstandingConverter which supports documents, email, images, video and audio per the ContentUnderstandingFileType enum.
- --use-plugins / --list-plugins controls third-party plugin loading; plugins are disabled by default and discovered via the 'markitdown.plugin' entry-point group.
- --keep-data-uris controls whether base64 data URIs are kept in output; default behavior truncates them via the keep_data_uris option of _CustomMarkdownify.
- DocxConverter subclasses HtmlConverter: it pre-processes the .docx zip (OMML math -> LaTeX via converter_utils/docx), merges caller style_map + embedded style map + a default 'u => u' underline rule, runs mammoth.convert_to_html, optionally adapts embedded images through an overridable _image_to_html hook, then converts the HTML via _CustomMarkdownify.
- PdfConverter (589 lines) uses pdfminer.six and pdfplumber with helpers to emit aligned Markdown tables (_to_markdown_table), merge MasterFormat partial numbering, and extract form content; missing deps raise MissingDependencyException pointing at the [pdf] extra.
- XlsxConverter/XlsConverter share one module using pandas.read_excel with openpyxl/xlrd engines, including a repair path that renames the non-schema 'showZeroes' attribute inside <sheetView> start tags only.
- ImageConverter emits exiftool-extracted metadata fields (ImageSize, GPSPosition, etc.) and, when llm_client+llm_model kwargs are present, calls _get_llm_description which base64-embeds the image into an OpenAI chat.completions vision request.
- AudioConverter combines exiftool metadata with SpeechRecognition transcription (recognize_google) for wav/mp3/m4a/mp4 after pydub conversion to wav; missing pydub/SpeechRecognition raises MissingDependencyException for the [audio-transcription] extra.
- YouTubeConverter accepts HTML streams whose stream_info.url parses to a youtu.be/youtube.com video id, scrapes metadata from the HTML, and optionally fetches transcripts via youtube-transcript-api; WikipediaConverter and BingSerpConverter similarly gate accepts() on the source URL.
- EpubConverter opens the EPUB zip in-memory, parses META-INF/container.xml and content.opf with defusedxml.minidom, extracts Dublin Core metadata, resolves spine hrefs (with percent-decoding fallback), and converts each spine HTML file through HtmlConverter.
Data flow
Data enters through convert()/convert_local()/convert_stream()/convert_uri()/convert_response(), is normalized to a seekable BinaryIO plus a list of StreamInfo guesses, then fanned out to priority-sorted converters. Type detection is layered: caller hints and headers produce a base StreamInfo, mimetypes fills gaps, then magika identifies content from bytes and charset-normalizer guesses text encoding; incompatible guesses produce two candidate StreamInfos tried in order. The winning converter returns a DocumentConverterResult whose markdown is normalized (trailing whitespace stripped, 3+ newlines collapsed) before returning.
- convert() is a type-based dispatcher: str with scheme http/https/file/data routes to convert_uri, other strings and Path to convert_local, requests.Response to convert_response, and readable non-TextIO objects to convert_stream; anything else raises TypeError.
- convert_local() seeds StreamInfo with local_path, os.path.splitext extension, and basename, merges caller stream_info (plus deprecated file_extension/url kwargs), opens the file 'rb', guesses stream info, then calls _convert.
- convert_stream() buffers non-seekable streams fully into io.BytesIO (4 KiB chunks) before guessing — the entire file is resident in memory from that point on.
- convert_uri() branches by scheme: file: URIs are resolved by file_uri_to_path (rejecting non-localhost netlocs and UNC/device paths), data: URIs are decoded by parse_data_uri (base64 or percent), and http(s): is fetched with self._requests_session.get(uri, stream=True) then converted via convert_response.
- convert_response() builds the base StreamInfo from Content-Type (mimetype + charset), Content-Disposition filename (parsed via email.message.Message, RFC 2231-aware), or the URL path extension, then buffers the whole body via response.iter_content(chunk_size=512) into BytesIO.
- _get_stream_info_guesses() enhances the base guess via the mimetypes module, then calls magika.identify_stream; for text predictions it samples up to 64 KiB (completing a split trailing UTF-8 char via _read_charset_sample) and runs charset_normalizer.from_bytes().best() for charset detection.
- When magika's prediction is incompatible with caller-supplied mimetype/extension/charset, both the enhanced base guess and the magika guess are appended as separate StreamInfo candidates, each tried in sequence against all converters in _convert.
- _convert() iterates 'stream_info_guesses + [StreamInfo()]' outer loop and priority-sorted converters inner loop, asserting the stream position is unchanged by accepts(); on convert() success it normalizes text_content (rstrip each line, collapse >=3 newlines to 2) and returns immediately.
- Per-call kwargs are enriched in _convert with global llm_client/llm_model/llm_prompt/style_map/exiftool_path, a private _parent_converters list for nested processing, and legacy file_extension/url keys derived from the current StreamInfo guess.
- Converters signal outcomes via exceptions: convert() failures are collected as FailedConversionAttempt and, if no converter succeeds, raised as FileConversionException (with per-converter trace summary); if no converter even accepted, UnsupportedFormatException is raised.
- Office/HTML converters converge on HTML as an intermediate: DocxConverter (mammoth), EpubConverter (spine files), PptxConverter and RSS/Wikipedia converters produce HTML that is finalized by HtmlConverter -> _CustomMarkdownify, which also strips javascript links and truncates data URIs unless keep_data_uris is set.
- In the MCP server, each convert_to_markdown call constructs a fresh MarkItDown instance (plugins gated by env MARKITDOWN_ENABLE_PLUGINS) and calls convert_uri; every exception type is mapped to a ToolError that deliberately omits internal detail (e.g., FileConversionException becomes 'File conversion failed.', OSError reports only os.strerror).
Behavior traces
Three load-bearing flows were traced end-to-end. (1) CLI file conversion: argparse -> MarkItDown() -> convert -> convert_local -> _get_stream_info_guesses -> _convert -> _handle_output. (2) HTTP/MCP conversion: convert_uri -> requests GET -> convert_response -> _convert, with the MCP tool wrapping it in sanitized ToolErrors. (3) Plugin registration + nested zip/docx conversion showing the recursive and registry-driven design.
- CLI local-file flow: main() parses args -> normalizes extension/mime/charset hints into StreamInfo -> constructs MarkItDown(enable_plugins=args.use_plugins) -> markitdown.convert(filename) -> convert_local -> open(path,'rb') -> _get_stream_info_guesses (mimetypes -> magika -> charset_normalizer) -> _convert -> _handle_output writes markdown to -o file or stdout with errors='replace' encoding.
- Inside _convert, converters are tried in (guess, converter) order; each converter's accepts() runs first under a stream-position assertion, then convert() runs inside try/finally that always seeks the stream back to cur_pos — so every converter sees the stream from the same offset.
- HTTP fetch flow: convert_uri('https://...') -> self._requests_session.get(uri, stream=True) -> raise_for_status -> convert_response parses content-type/content-disposition headers -> iter_content buffering -> _get_stream_info_guesses -> _convert; the default session sends 'Accept: text/markdown, text/html;q=0.9, text/plain;q=0.8, */*;q=0.1'.
- MCP tool flow: client calls tools/call convert_to_markdown{uri} -> handler creates MarkItDown(enable_plugins=check_plugins_enabled()) -> convert_uri -> on success returns result.markdown; on failure the except chain converts UnsupportedFormatException/HTTPError/RequestException/FileConversionException/OSError/ValueError into ToolError messages.
- MCP HTTP startup: main() parses --http/--sse/--host/--port -> for non-localhost hosts prints a security warning to stderr -> create_starlette_app mounts mcp.sse_app() and mcp.streamable_http_app(json_response=True, stateless_http=True) routes under a combined lifespan -> uvicorn.run(app, host, port or 3001).
- Plugin activation flow: MarkItDown(enable_plugins=True) -> enable_plugins() -> _load_plugins() iterates entry_points(group='markitdown.plugin'), entry_point.load() with per-plugin try/except+warn -> plugin.register_converters(self, **kwargs) -> each converter lands in self._converters via register_converter(priority).
- ZIP recursion flow: ZipConverter.convert opens zipfile.ZipFile on the stream -> for each entry reads bytes into BytesIO + a fresh StreamInfo(extension, filename) -> calls self._markitdown.convert_stream (the same MarkItDown instance passed at construction) -> concatenates results under '## File:' headings; failures are swallowed per-member.
- DOCX conversion flow: DocxConverter.convert -> dependency check raising MissingDependencyException -> pre_process_docx (rewrites document.xml: OMML oMath -> LaTeX $...$/$...$ w:t replacements) -> mammoth.convert_to_html with merged style_map -> optional _DocxImages adapter when _image_to_html is overridden -> HtmlConverter.convert_string -> _CustomMarkdownify.
- EPUB conversion flow: EpubConverter.convert -> zipfile -> defusedxml.minidom.parse('META-INF/container.xml') -> locate content.opf -> extract dc:* metadata and manifest/spine -> _resolve_manifest_href per spine item -> HtmlConverter.convert on each zip member stream -> metadata block prepended, title set on result.
- Stdin flow: when no filename is given, main() wraps sys.stdin.buffer.read() in io.BytesIO (because Windows pipe-backed stdin can falsely report seekable()) and calls convert_stream with the hint StreamInfo and keep_data_uris flag.
- LLM image-caption flow: ImageConverter.convert -> exiftool_metadata (subprocess exiftool -json -, version-gated >=12.24 for CVE-2021-22204) -> if llm_client+llm_model kwargs present, _get_llm_description base64-encodes the stream into a data URI and calls client.chat.completions.create(model=..., messages=[...image_url...]).
Testing signals
Tests are pytest-based and live in packages/*/tests. The core package has ~35 test modules anchored by a declarative FileTestVector harness (_test_vectors.py) asserting must_include/must_not_include substrings across 20+ real fixture files in tests/test_files. Coverage spans stream-info guessing, CLI vectors, per-converter edge cases (PDF tables/memory, docx math/images/styles, pptx images/SVG/notes, xlsx images, RSS titles, Outlook ANSI codepages, charset edge cases) and URI handling; remote-URL tests are skipped under GITHUB_ACTIONS. MCP tests drive the real stdio server subprocess and an in-process Starlette app via httpx.
- packages/markitdown/tests/_test_vectors.py defines FileTestVector(filename, mimetype, charset, url, must_include, must_not_include) and two vector sets (GENERAL_TEST_VECTORS with 16 entries, DATA_URI_TEST_VECTORS) that parametrized tests consume.
- test_module_vectors.py parametrizes every vector across test_guess_stream_info, test_convert_local, test_convert_stream_with_hints and more; tests needing live URLs are gated by skip_remote = bool(os.environ.get('GITHUB_ACTIONS')).
- Fixture corpus is real binary files under packages/markitdown/tests/test_files (docx/xlsx/xls/pptx/pdf/msg/epub/zip/html/csv/json/ipynb/jpg/mp3/m4a/wav and edge cases like random.bin, medical-report scans, borderless-table PDFs).
- Edge-case suites are organized per concern: test_pdf_tables.py (1198 lines), test_cu_converter.py (1035 lines), test_pdf_memory.py, test_pdf_masterformat.py, test_docx_math*.py, test_docx_styles.py, test_pptx_images.py, test_xlsx_images.py, test_outlook_msg_ansi.py, test_charset_sample.py, test_undetectable_charset.py, test_csv_line_endings.py, test_csv_blank_runs.py, test_file_paths.py, test_zip_kwargs.py.
- URI/URL parsing is tested in test_module_misc.py: data: URIs (base64, charset attributes, missing mime), file:// URIs including localhost netloc, percent-encoded Windows drive letters, and case-insensitive schemes.
- MCP tests include test_stdio_protocols.py which spawns `python -m markitdown_mcp` as a subprocess and feeds newline-delimited JSON-RPC covering both the legacy initialize handshake and a 2026-07-28 server/discover request, plus test_http_transports.py exercising create_starlette_app in-process with httpx, and test_tool_errors.py for error mapping.
- markitdown-ocr tests cover each enhanced converter (test_pdf/docx/pptx/xlsx_converter.py), inheritance behavior proving the OCR variants subclass the built-ins, and OCR metadata propagation, with fixtures under tests/ocr_test_data.
- CI matrix: tests.yml runs hatch test for packages/markitdown on Python 3.10-3.14 (ubuntu) plus 3.12 on windows-latest and windows-11-vs2026-arm; separate jobs pip-install local packages and run pytest for markitdown-ocr and markitdown-mcp; PYTHONUTF8=1 is set for captured CLI output.
- Coverage config lives in each pyproject ([tool.coverage.run] branch=true, source_pkgs covering src + tests) and hatch envs define `hatch test` (hatch-test env with openai extra dep) and a types env running `mypy --ignore-missing-imports` over src and tests.
- There are no unit tests for the CLI's Azure paths or plugin loading mechanics in the core package, and remote-URL vectors (YouTube/Wikipedia/Bing) are skipped in CI — those converter paths are only exercised by local HTML fixtures. (inferred)
Risk map
The dominant risk theme is I/O breadth: the library deliberately performs filesystem, network, and subprocess I/O with the caller's privileges, and the README explicitly warns callers to sanitize inputs. Specific hotspots: unrestricted HTTP fetching (SSRF) via convert_uri, file:// URI local file reads, unbounded in-memory buffering of remote/stream content, subprocess invocation of exiftool, plugin code execution via entry points, and an unauthenticated MCP HTTP mode. Defusedxml and an exiftool version check show some hardening is present.
- convert_uri fetches arbitrary http(s) URLs with the process's requests session and no allowlist — the README's Security Considerations section tells integrators to block private/loopback/link-local/metadata-service addresses themselves; nothing in code enforces this.
- file:// URIs resolve to local paths and convert_local opens them; file_uri_to_path rejects non-localhost netlocs and UNC/device paths but permits any absolute local path — the MCP tool exposes this to remote clients in HTTP mode.
- The MCP server's --http mode has NO authentication; the code itself prints a warning when --host is non-loopback stating any reachable user 'can read files and fetch network resources accessible to this user'.
- Remote and stream inputs are buffered fully into memory: convert_response iter_content into BytesIO and convert_stream buffers non-seekable streams — a hostile or huge response/stream can exhaust memory.
- exiftool_metadata shells out to subprocess.run([exiftool_path, ...]); the exiftool path is resolved from the EXIFTOOL_PATH env var, kwargs, or a PATH search restricted to well-known directories, and a version gate rejects exiftool <12.24 citing CVE-2021-22204.
- Plugin loading executes arbitrary installed Python code: entry_points(group='markitdown.plugin').load() then register_converters(); failures are only warned about. Plugins are off by default but -p/--use-plugins or enable_plugins=True activates all of them.
- The _convert loop asserts (not raises) on stream-position invariants ('assert cur_pos == file_stream.tell()') — running under python -O disables these checks, silently removing the safety net that protects converters from a misbehaving accepts().
- Each MCP convert_to_markdown call constructs a brand-new MarkItDown() — re-registering ~17 converters and re-instantiating magika.Magika() (a model load) per request, which is a latency/CPU cost on every tool call. (inferred)
- BeautifulSoup 'html.parser' and defusedxml are used for untrusted markup/XML (EPUB container/OPF via defusedxml.minidom), but docx pre-processing parses document.xml with BeautifulSoup and xml.etree.ElementTree — XML bomb surface depends on those parsers' defaults rather than defusedxml. (inferred)
- HtmlConverter's markdownify path can hit RecursionError on deeply nested HTML; it falls back to get_text() unless a 'strict' kwarg is set — acceptable degradation but callers get silently different output shape.
- ZipConverter swallows FileConversionException/UnsupportedFormatException per member and the archive is read via zipObj.read(entry) fully into memory per entry; nested zips recurse through the same converter stack without a depth limit visible in this file.
- Error detail asymmetry: FileConversionException aggregates per-converter exception messages (which can include SDK/internal detail) while the MCP layer deliberately sanitizes to generic ToolError strings — good for the MCP boundary, but library callers receive verbose internals.
Extension points
MarkItDown is explicitly designed for extension at two levels: in-process registration via MarkItDown.register_converter(converter, priority=...) and distribution-level plugins via the 'markitdown.plugin' entry-point group. Converters subclass DocumentConverter and implement accepts()/convert(); several built-ins expose finer hooks (DocxConverter._image_to_html, style_map, llm_* kwargs). The markitdown-ocr package is the canonical example of a plugin that overrides built-ins by registering with negative priority.
- register_converter(converter, *, priority=PRIORITY_SPECIFIC_FILE_FORMAT) inserts a ConverterRegistration at index 0 of self._converters; docstring documents that equal priorities preserve registration order with most-recent-first, and that plugins can pick priorities (e.g., 9) to slot between specific and generic converters.
- Plugin contract: a package declares [project.entry-points."markitdown.plugin"] name = 'module', the module defines __plugin_interface_version__ = 1 and register_converters(markitdown, **kwargs) which calls markitdown.register_converter(...).
- markitdown-ocr demonstrates priority-based replacement: it registers its four converters at priority -1.0 so they run before all built-ins at 0.0, and its *WithOCR classes subclass the built-in converters so inherits tests verify drop-in compatibility.
- New converters plug in by subclassing DocumentConverter and implementing accepts(file_stream, stream_info, **kwargs)->bool and convert(...)->DocumentConverterResult; the docstrings document that accepts() must restore the stream position if it reads.
- DocxConverter._image_to_html is an overridable hook: if a subclass overrides it, convert() wires a _DocxImages adapter into mammoth's convert_image callback so subclasses can render embedded images as custom HTML fragments.
- Global kwargs llm_client, llm_model, llm_prompt, style_map, and exiftool_path are injected into every converter call by _convert(), letting callers extend behavior (e.g., captions, custom docx style maps) without subclassing.
- _parent_converters is passed through kwargs so container converters (ZipConverter holds a MarkItDown reference; epub/docx delegate to HtmlConverter) can recurse with the full registry — a nested-conversion extension channel.
- _CustomMarkdownify subclasses markdownify.MarkdownConverter, so any markdownify option can be passed through kwargs from converters that emit HTML (HtmlConverter, DocxConverter, EpubConverter, RssConverter, etc.).
- Cloud converters are opt-in registrations: passing docintel_endpoint/cu_endpoint (+credential/file_types/analyzer_id kwargs) to MarkItDown() registers DocumentIntelligenceConverter/ContentUnderstandingConverter at default priority, effectively a DI-based extension point.
- register_page_converter() remains as a deprecated alias emitting DeprecationWarning, showing the registration API is the stable public seam.
Contributor handoff
Edit surface is small and regular: to add/change a format, touch one file in converters/ plus tests. The invariants that matter: accepts() must leave the stream position unchanged; converters must raise MissingDependencyException (not ImportError) for optional deps; registration order is reversed within a priority. Run tests per-package (hatch test or pytest). Avoid touching _markitdown.py's _convert loop casually — many subtle contracts (position asserts, guess iteration, legacy kwargs) live there.
- To add a format: create converters/_foo_converter.py with ACCEPTED_MIME_TYPE_PREFIXES/ACCEPTED_FILE_EXTENSIONS constants and a DocumentConverter subclass, export it in converters/__init__.py, register it in MarkItDown.enable_builtins at the right spot, and add the extra to pyproject [project.optional-dependencies].
- Optional-dependency convention: wrap the import in try/except ImportError storing sys.exc_info() in a module-level _dependency_exc_info, then raise MissingDependencyException(MISSING_DEPENDENCY_MESSAGE.format(converter=..., extension=..., feature=...)) inside convert().
- Hard invariant from _base_converter docstring and _convert asserts: if accepts() reads from file_stream it MUST seek back; a converter that violates this triggers AssertionError inside _convert.
- Registration order is counterintuitive: register_converter inserts at index 0, so LATER registrations are tried FIRST among equal priority — the comment in enable_builtins warns 'the most specific converters should appear below the most generic converters' in source order.
- Test workflow: cd packages/markitdown && pip install hatch && hatch test (hatch env auto-installs [all] + openai); or run pytest directly after installing 'markitdown[all]' editable. CI also runs per-package pytest for markitdown-ocr and markitdown-mcp.
- New test-vector files go in tests/test_files plus a FileTestVector entry in tests/_test_vectors.py; expected-output assertions are substring-based (must_include/must_not_include), not snapshot diffs.
- Deprecated kwargs file_extension/url still flow through convert_local/convert_stream/convert_response and _convert injects them from stream_info — preserve them when refactoring signatures or tests like test_zip_kwargs.py will break.
- MCP error contract: do not let raw exceptions leak — convert_to_markdown deliberately maps each exception class to a sanitized ToolError; new exception types from the library need explicit handling there.
- Read first: _markitdown.py (engine), _base_converter.py + _stream_info.py (contracts), one representative converter (_docx_converter.py for the optional-dep + HTML-intermediate pattern), and _markdownify.py for output shaping; for plugin work, _plugin.py in both markitdown-ocr and markitdown-sample-plugin. (inferred)
- Known trap: tests/test_module_vectors.py sets skip_remote only on GITHUB_ACTIONS — local runs may hit raw.githubusercontent.com for some vectors, so sandboxed agents should set GITHUB_ACTIONS=1 or expect network tests.
- Version bumps happen per package in each src/<pkg>/__about__.py via hatch dynamic versioning — there is no root-level version file.
Languages
| Language | Files | Share of source |
|---|---|---|
| Python | 106 | 96% |
| Markdown | 15 | docs/config |
| TOML | 4 | 4% |
| YAML | 4 | docs/config |
| HTML | 3 | docs/config |
| JSON | 2 | docs/config |
Start reading here
Probable source entrypoint: packages/ — inferred from the directory tree and manifest, not the upstream docs.
Source roots
packages/ (161 files)
Source layout — packages/
| Path | Files |
|---|---|
packages/markitdown/ | 105 |
packages/markitdown-mcp/ | 11 |
packages/markitdown-ocr/ | 36 |
packages/markitdown-sample-plugin/ | 9 |
Repository structure
| Top-level path | Files |
|---|---|
packages/ | 161 |
.github/ | 4 |
.devcontainer/ | 1 |
Docs found: README.md
Package
packages/markitdown-mcp/pyproject.toml — markitdown-mcp, 0 declared dependencies.
CI & config entrypoints
README.md— Repository readme.github/workflows/pre-commit.yml— GitHub Actions workflow.github/workflows/tests.yml— GitHub Actions workflow
Structural health signals
Average score 7.7/10 across 52 analyzed source files; 5 hotspots surfaced.
packages/markitdown/src/markitdown/converters/_youtube_converter.py— 256 lines, score 5.9 (Deep nesting, High churn)packages/markitdown/src/markitdown/_markitdown.py— 851 lines, score 6.2 (Large file, Deep nesting)packages/markitdown/src/markitdown/converters/_rss_converter.py— 488 lines, score 6.2 (Large file, Deep nesting)
These are deterministic review leads — size, churn, and structural signals — not proof of a bug in this project.
Recent history
b8f79c57ebc02026-09-21 — Bump versions or markitdown and markitdown-ocr (#2545)d51937de3aca2026-09-21 — fix: bump youtube-transcript-api to >=1.2.3 for Python 3.14 support (#2407)945314a45ddb2026-09-16 — Refactor Office OCR converters to reuse core conversion pipelines (#2506)eb31b5c945362026-09-14 — bump_versions (#2490)cc0ca9edd8e22026-09-12 — Preserve whitepace underlines (#2477)