| name | x2md |
| description | File & document to Markdown conversion (wraps markitdown; tested on 0.1.7). Supports DOCX, PDF, PPTX, XLSX, HTML, CSV, JSON, XML, images (OCR), audio, YouTube, EPubs. Goes beyond upstream markitdown: auto fix formula escaping ($...$), encrypted file detection & decryption via keyring + native Windows CredUI dialog, complex table structure validation with FULLY AUTOMATIC AI correction (no user prompt for known defects), XLSX formula evaluation (converts =A+B cells from NaN to real values). Single-file, parallel batch, size-aware resumable batch for large sets/network drives. Default pipeline for all supported formats: convert first. Use when: 转换文件, 文档转md, 转成md, 转md, 转成markdown, md转换, word转md, pdf转md, excel转md, 表格转md, 提取文本, 解析文档, 文件提取, 批量转md, 批量转换, 加密文件解密转换, SOR转换, 技术文件转换, 格式转换, convert to markdown, docx to md, pdf to markdown, file to md, document extraction, extract text from, parse document, bulk convert, resumable batch. Do NOT use for: 纯文本/代码文件(.md .txt .py .js .svg 等)——直接 read_file 即可,无需转换;纯图片描述(无文字提取需求)——直接用视觉模型。 |
x2md — File to Markdown Conversion
File-to-Markdown conversion built on Microsoft MarkItDown (tested on v0.1.7;
the fixes below target bugs still present upstream as of 0.1.7). Goes beyond
upstream markitdown with baked-in fixes for known issues, encrypted file
handling, and table structure validation.
Quick Start
TL;DR — the whole flow on one screen
USER: "convert this file" → run: _convert_core.py <file> -o <out.md>
│
┌────────────────────┬───────┴────────┬─────────────────────┐
▼ ▼ ▼ ▼
exit 0 (clean) exit 1 (table errs) encrypted file no output / err
→ 1-line summary → read .errors.md → keyring lookup → see ⛔ Do NOT
→ AUTO-FIX silently → if None: CredUI (rows 5,6,7,8:
(Known + Unknown- dialog (Win) regex-repad /
with-HTML; STOP pipeline-order /
only if no HTML) → "remember" → keyring CJK-mojibake /
→ delete sidecar → cancel → skip file sidecar-timing)
CJK-mojibake /
sidecar-timing)
Golden rules: (1) never ask the user before fixing a KNOWN defect;
(2) secrets go through keyring / CredUI, never chat; (3) end with one summary line.
Full anti-pattern list: see ⛔ Do NOT below.
Installation
pip install "markitdown[all]" msoffcrypto-tool keyring mammoth pywin32
# For XLSX formula evaluation (converts =A+B cells from NaN to real values):
pip install formulas
Command-Line
# Single file with all enhancements (formula fix + table detect + metadata header)
# Exit codes: 0 = clean; 1 = table errors detected (sidecar .errors.md written).
# IMPORTANT: exit code 1 means the AI MUST auto-fix the .md immediately per
# the Stage-2 AUTO-FIX POLICY below — do NOT ask the user, do NOT wait.
python scripts/_convert_core.py document.docx -o output.md
# Batch convert a directory in parallel — same full pipeline as single-file
# (encryption + formula fix + table detect + metadata header), via per-file
# delegation to _convert_core.convert_file(). Skips MS Office lock files (~$*).
# Exit codes: 0 = clean; 1 = any failure OR any sidecar written (stage-2 fix needed).
python scripts/batch_convert.py input_dir/ output_dir/ --extensions .docx --workers 4
# Dynamic batch — SIZE-AWARE per-file subprocess isolation for large file sets
# (thousands of files / network drives). Inherits all enhancements (encryption +
# formula fix + table detect + metadata). Adds: dynamic timeout by file size,
# small-files-first ordering, large-file concurrency cap, lenient retry, resume.
# Driver pre-decrypts encrypted files IN-PROCESS so _PASSWORD_CACHE is shared
# (one CredUI prompt per password across all files). Sidecars collected to
# _sidecars.txt. Exit codes: 0 = clean; 1 = any failure OR any sidecar written.
python scripts/batch_convert_dynamic.py --source standards_dir/ --recursive \
--outdir output_md/ --workers 13 --heavy-max 3
# List input also accepted (JSON {"files":[...]} or TXT one-path-per-line):
python scripts/batch_convert_dynamic.py --source file_list.json --outdir output_md/
# Slow machine (markitdown cold-start per format import dominates) — multiply
# every timeout band; office formats (doc/xlsx/pptx) already get an automatic
# 40s cold-start floor, so this is mainly for csv/html/pdf on a very slow host:
python scripts/batch_convert_dynamic.py --source standards_dir/ --outdir output_md/ \
--workers 6 --timeout-mult 2
Slow-machine angle (2026-08-06): the size→timeout ladder was calibrated on
a fast host (1382-record failure log; small file ≈ 5s parse). On a slower host
markitdown's per-format import (mammoth/openpyxl/python-pptx), reissued in
every subprocess, dominates → a small report.docx measured ~31s just to
import (before parsing even starts). The <0.5MB→8s band then misfires on
office files. Mitigation, layered (do NOT start at row 14):
| Lever | Scope | When to use |
|---|
--workers N (lower it) | All formats | First thing to try — N simultaneous cold-start imports fight for CPU/disk; halving N often lets each finish within its existing band. Default 13. |
Built-in office_floor=40s (automatic) | doc/docx/xls/xlsx/ppt/pptx only | Already on by default; nothing to pass. Handles the common case (office cold-start). PDF is excluded (its small-file fast-fail is a deliberately hung-PDF detector). |
--timeout-mult MULT (escape hatch) | Every format, every band | Genuine slow host where even csv/html/pdf exceed a band. MULT applies to all files AFTER the office floor. e.g. --timeout-mult 2 doubles every timeout. Default 1.0. |
_convert_core.py <file> -o out.md (single-file, no ladder) | One file at a time | Small doc/xlsx where the run isn't worth a batch call; _convert_core.py has no subprocess timeout so a 30s import just succeeds. |
⚠️ CRITICAL — batch_convert_dynamic.py MUST run as a background VS Code task
(see ⛔ Do NOT row 12). Never foreground — blocks the session on large sets.
4-step launch (frozen template with placeholder table + resume rules in
assets/tasks/x2md-dyn.jsonc):
- Read the template, replace 5 placeholders:
<LABEL>, <SOURCE>, <OUTDIR>, <WORKERS>, <TIMEOUT_MULT>.
List-mode source: also delete "--recursive". heavy-max=3 is frozen.
create_and_run_task with the filled template (workspaceFolder = repo root).
- Monitor via
get_task_output — prints [N/M] ... progress every 10 files.
- Resume: relaunch with new task label. Existing
.md files auto-skipped.
Kill any leftover batch_convert_dynamic/convert_single_enhanced processes first.
Encrypted-set caveat: if the set contains many encrypted files, keep
pre-decrypt ON (default). The driver prompts CredUI once per password and
shares the result across all files via _PASSWORD_CACHE; subprocess-per-file
decryption (the --no-predecrypt path) would re-prompt N times (see ⛔ Do NOT
row 13).
Scan for encrypted files (no conversion)
python scripts/_convert_core.py --scan-encrypted input_dir/
Convert with table detection disabled (faster for simple docs)
python scripts/_convert_core.py document.docx --no-table-detect -o output.md
Convert without the metadata header (Source/Format/--- block)
python scripts/_convert_core.py document.docx --no-metadata -o output.md
python scripts/batch_convert.py input_dir/ output_dir/ --no-metadata
Agent/CI safe: skip encrypted-file password dialog (keyring only)
python scripts/_convert_core.py encrypted.xlsx --no-prompt -o output.md
python scripts/batch_convert.py input_dir/ output_dir/ --no-prompt
Standalone: fix formula escaping in existing .md files
python scripts/fix_formula_escaping.py output.md
python scripts/fix_formula_escaping.py --dir md_output/
Standalone: scan for encrypted files and credential status
python scripts/_decrypt.py input_dir/
### Python API
```python
from markitdown import MarkItDown
# Basic usage with enhanced post-processing
from fix_formula_escaping import fix_formulas_in_text
md = MarkItDown()
result = md.convert("document.docx")
text, n_fixes = fix_formulas_in_text(result.text_content)
print(f"Fixed {n_fixes} formula escaping issue(s)")
# Encrypted file handling
from _decrypt import detect_encrypted, decrypt_docx
from pathlib import Path
encrypted = detect_encrypted(Path("input_dir/"))
for f in encrypted:
# allow_prompt=False by default: reads keyring only, never pops a dialog.
# Pass allow_prompt=True (e.g. from an interactive converter) to fall back
# to the Windows CredUI dialog when no keyring credential decrypts.
buf = decrypt_docx(f)
if buf:
result = md.convert_stream(buf, file_extension=".docx")
# Table structure validation (B+D architecture)
import mammoth
from _table_detect import detect_table_issues, format_issues_for_ai
with open("document.docx", "rb") as f:
mammoth_html = mammoth.convert_to_html(f).value
issues = detect_table_issues(mammoth_html, result.text_content)
print(format_issues_for_ai(issues))
Enhancement Pipeline
Every conversion runs through three stages automatically (no flags needed):
⛔ Do NOT — Anti-patterns & Red Lights
Read this before any conversion. These are recurring failure modes from real
incidents (2026-06..07). Doing any of these silently corrupts output or leaks secrets.
| # | Do NOT | Why it breaks | Do instead |
|---|
| 1 | Ask the user before fixing a KNOWN defect (D1 formula, D2 column-shift / vertical_merge, nested / nested_table) | Violates the AUTO-FIX POLICY — default is fully automatic. Asking per-table creates noise the user explicitly opted out of. (D6/D3/D4 are NOT yet emitted by _table_detect.py — see Stage-1 "Roadmap" sub-table.) | Fix/annotate silently, end with a one-line summary. Only an Unknown defect with no HTML_REFERENCE to infer from may prompt (see "Stage 2 — AUTO-FIX POLICY"). |
| 2 | Store passwords via cmdkey or the Credential Manager GUI | These store Windows Generic Credentials, which (a) keyring's default backend cannot read → lookup returns None → "No credential found" even though cmdkey /list shows it, AND (b) CredUI silently reuses them on the next prompt → the dialog never appears. Confirmed S06_protected, 2026-07-03. | On the desktop, just let the CredUI dialog pop and check "remember" — it persists to keyring automatically. For CI/headless only, use the Python keyring one-liner (see "Headless / CI fallback"). |
| 3 | Type/copy a plaintext password into chat or vscode_askQuestions | Password routes through the model → ends up in chat history/logs. | Let the CredUI dialog collect the password (default desktop flow). Only for headless/CI may you point the user at the keyring one-liner to run in their own terminal; never read the password yourself. |
| 4 | Leave only an HTML comment for a nested table (<!-- ... -->) | Downstream LLM/RAG pipelines often strip HTML comments → the flattened md table alone is semantic garbage. | Write a body blockquote description + keep the flattened table below + <!-- AI-describe ... --> comment. (See nested_table in Stage 2.) |
| 5 | Naively regex-repad columns when you see a short row | Cannot distinguish D2 vertical-merge (needs repad) from a legitimately fewer-column row or horizontal merge → silent data corruption on T9/T7-type tables. |
Decision shortcut: if an action is about to ask the user something other than an
Unknown defect with no HTML_REFERENCE to infer from, or a missing credential,
STOP — it's almost certainly an anti-pattern above. (A Known defect, or an Unknown
defect that still has HTML_REFERENCE, must be fixed silently.)
Encrypted File Handling
🔴 Routing rule (read first):
- ℹ️ Script-vs-agent note:
convert_file() defaults to allow_prompt=True
(it is the script's own default). The "do NOT let a dialog pop in chat context"
rule below is therefore enforced by the agent — you decide whether to call
the script on a path that can trigger CredUI. The script itself does not know
whether it is in chat or desktop context.
- Chat / agent context (no direct desktop session on the user's machine — e.g. you
are an AI running
_convert_core.py on the user's behalf): if keyring lookup returns
None, do NOT let a dialog pop. Instead hand the user the one-line
keyring.set_password('x2md', '<stem>', '<pw>') command and stop.
Resume after they confirm they ran it.
- Interactive desktop context (the user is running the script themselves in a
real Windows terminal): the CredUI dialog may pop (
allow_prompt=True).
Detects password-protected .docx files and, when no usable credential is
already stored, prompts the user through the native Windows CredUI dialog
(in interactive desktop context only — see the routing rule above)
(win32cred.CredUIPromptForCredentials). The password never touches AI chat
history, the terminal, or disk.
Runtime flow (decrypt_docx, password resolution order — first that decrypts wins):
- explicit
password= argument (programmatic callers only)
- process-in-memory cache (per file stem, then any previously-entered password)
- keyring — file stem only (a legacy shared
default entry was removed
for security — one credential leak should not compromise all files; see
_decrypt.py line 14 "There is intentionally NO 'default' shared fallback")
- CredUI dialog — only on the interactive conversion path
(
convert_file() passes allow_prompt=True). The dialog shows which file
the password is for and a "记住 / remember" checkbox. Before each prompt,
any stale Windows Generic Credential (LegacyGeneric store, written by a
previous dialog's "Save" or by cmdkey) is deleted — otherwise CredUI
silently reuses it and skips the dialog entirely. Correctness of the
entered password is verified only by the actual decryption in step 4
(msoffcrypto), NOT inside the prompt loop — a full-document verify per
retry hangs on large ECMA376-Agile files.
- If the user checks "remember" → the password is persisted to keyring
(overwriting any stale entry). If unchecked → used once in memory and dropped.
Read-only paths never prompt. decrypt_docx(allow_prompt=False) is the
default, so --scan-encrypted and scan_and_report() simply report
missing_credential instead of popping a dialog.
User cancels the dialog → that file is skipped and the run continues with
the rest (see batch_convert aggregation). The AI reports a one-line summary,
not a per-file prompt.
pywin32 is a hard dependency for the dialog. If missing, convert_file
returns an actionable error (pip install pywin32) instead of a raw ImportError.
Headless / CI fallback (NOT for desktop use)
CredUI cannot display a dialog in headless environments (SSH, Windows Server
Core, Docker, CI runners, disconnected RDP sessions). For those cases only,
pre-register the password via Python keyring — the next desktop conversion
will then read it silently and skip the dialog:
python -c "import keyring; keyring.set_password('x2md', '<stem>', '<password>')"
# <stem> = filename without extension (e.g. 'S06_protected' for 'S06_protected.docx')
Do NOT use cmdkey / Credential Manager GUI for this — those store Windows
Generic Credentials that keyring's default backend cannot read (see Do-NOT #2).
Password Security
- Passwords stored via Python
keyring (service: x2md, name: file stem).
On Windows the default backend stores in the user's DPAPI-encrypted profile.
- The CredUI dialog is rendered by Windows itself (not by this skill), so its
input cannot be intercepted by skill/Python code beyond the returned string —
this is the security rationale for choosing CredUI over a self-built tkinter window.
- Passwords live in Python memory only for the duration of decryption; the
process cache is in-memory and cleared when the process exits. Nothing is
written to the converted
.md or logs.
- AI never sees plaintext passwords.
Table Structure Validation
After conversion, the skill scans md output for known table issues and runs a
two-stage pipeline: a deterministic Python script (stage 1) detects and
reports errors with precise locations, then an AI agent (stage 2) reads the
structured report and fixes the .md file directly.
Stage 1 — Detection (Python script scripts/_table_detect.py)
Scans mammoth's HTML output (ground truth, preserves rowspan/colspan) vs
the markitdown md output. When an issue is found, the script emits a
structured error report (written to a sidecar <output>.md.errors.md file
and _convert_core.py exits with code 1).
Each error report contains exactly three sections the AI needs:
| Section | Contents |
|---|
| CAUSE | Why the md is wrong (root cause + what to restore) |
| MD_LOCATION | md_path + absolute line range + affected row indices + expected/actual column counts |
| HTML_REFERENCE | The full untruncated <table> block from mammoth (ground truth to reproduce) |
| CURRENT_MD | The full broken md table block (for side-by-side comparison) |
Detected issue types — split by what _table_detect.py actually emits.
Sidecar issue_type token is the authoritative key; defect-id (D2/…) is the doc label.
Currently emitted (these are the only issue_type values you will see in a sidecar):
issue_type (sidecar token) | Defect id | Severity | AI action |
|---|
vertical_merge | D2 | P1 | Realign md table columns using the deterministic pad rule: for each flagged row, (1) pad cells to MD_LOCATION.expected_cols; (2) map each md cell to the HTML_REFERENCE <tr> by document order, using rowspan to carry a cell into subsequent rows and colspan to consume n md columns; (3) any md column with no HTML source → fill an empty cell (` |
nested_table | nested | P2 | LLM-describe: write a natural-language description of the nesting in the md BODY (the flattened md table alone is semantic garbage for downstream LLMs). See the AUTO-FIX POLICY for the required template. |
Roadmap — documented but NOT yet emitted (listed for completeness; if a sidecar ever carries one of these issue_type values, treat it as Unknown per the AUTO-FIX POLICY triage):
| Defect id | Severity | Planned action | Status |
|---|
| D6 (degenerate full-merge) | P1 | Drop orphaned continuation row, keep single merged cell | ⚠️ detection not implemented |
| D3 (cell multiline flattening) | P2 | Accepted (no semantic loss) | not emitted |
| D4 (cell sublist flattening) | P2 | Accepted (no semantic loss) | not emitted |
Stage 2 — AI Correction Flow
⚡ AUTO-FIX POLICY (read first)
Default behavior is FULLY AUTOMATIC correction — do NOT ask the user, do NOT
pause for confirmation. The user has explicitly opted into auto-fix.
The flow below distinguishes three cases (staying quiet unless structure is
truly un-inferrable):
| Case | Trigger | Action |
|---|
| Known defect (currently emitted: D2 / nested — see "Currently emitted" sub-table in Stage 1) | Fixable by AI: YES or Fixable by AI: NO (LLM-describe ...) | Fix immediately, silently. Apply the documented fix (D2 realign) or the LLM-describe treatment (nested — write a body description), mark with the <!-- AI-corrected ... --> / <!-- AI-describe ... --> comment, delete the sidecar, report only a one-line summary at the end. Never ask. (D6/D3/D4 fixes are Roadmap-only — if a sidecar ever carries one, treat as Unknown per row 2.) |
| Unknown defect, has HTML_REFERENCE | A row whose CAUSE does not match the known set, but the sidecar still provides a usable HTML_REFERENCE block | Best-effort fix silently, do NOT stop. Infer the correct structure from HTML_REFERENCE, apply it, and mark with <!-- AI-uncertain: verify — <one-line reason; no documented defect matched> -->. Surface it only in the one-line end summary (e.g. "...plus 1 uncertain best-effort fix, please verify"). This keeps the tool quiet for the ~99% of unknowns that still have ground-truth HTML to reason from. A worked example of how to best-effort is in the "Steps" section below (the Unknown best-effort example block). |
| Unknown defect, NO HTML_REFERENCE | The sidecar is missing or its HTML_REFERENCE block is empty/corrupt (the AI cannot safely infer structure) | 🔴 STOP — ASK USER (the ONLY stopping case): briefly state that no ground-truth HTML is available to infer from, show the CAUSE + CURRENT_MD, and ask whether to (a) leave annotated <!-- AI-blocked: no HTML_REFERENCE --> or (b) skip that table. |
If unsure whether a defect is "known": the known set currently detected by
_table_detect.py is exactly {vertical_merge (D2), nested_table} — see the
"Currently emitted" sub-table above. D6 / D3 / D4 are documented under
"Roadmap" but not currently emitted; treat any sidecar row whose issue_type
is not vertical_merge/nested_table as Unknown. Then apply the two-level triage
in the table (has HTML_REFERENCE → best-effort; no HTML_REFERENCE → STOP). The tool
stays quiet unless the ground-truth HTML is genuinely missing.
Steps
-
Run _convert_core.py input.docx -o output.md (exit code 1 = table errors).
-
If [TABLE_ERRORS] output.md.errors.md appears in stdout, read the sidecar file
immediately — do not ask the user first.
-
For each error block, classify as Known (auto-fix) or Unknown (may ask):
-
Read CAUSE → map to a known defect type from the Stage-1 table, or mark Unknown.
-
Read MD_LOCATION → open output.md at the exact line range.
-
Read HTML_REFERENCE → reproduce the correct structure in md.
-
Known & Fixable by AI: YES (e.g. D2): edit the md table in place with
replace_string_in_file / multi_replace_string_in_file; mark with
<!-- AI-corrected: please verify — <defect id>: <one-line reason> -->.
-
Known & Fixable by AI: NO (LLM-describe ...) (nested_table): the flattened
md table is semantic garbage for downstream LLMs, so DO NOT leave only an HTML
comment. Instead write a natural-language description in the md BODY above
the broken table, then keep the flattened output below it for traceability.
Use this exact shape (replace the bracketed parts from HTML_REFERENCE):
> **[嵌套表格说明 / Nested-table description]**
> 本表为嵌套结构,无法用标准 markdown 表格表达。结构如下:
> 外层为 <N> 列表格(<外层列名,逗号分隔>)。
> 在「<承载嵌套的单元格列名>」单元格内嵌套了一个 <R>×<C> 内表,内容为:<逐行列出内表>;
> 其余列对应:<逐列列出其他列的内容>。
<!-- AI-describe: nested table — natural-language description above; flattened markitdown output preserved below for traceability -->
<flattened markitdown table verbatim>
Rules for the description: (a) it MUST be in the BODY (a blockquote > is
fine — it renders as normal text and is read by LLMs, unlike HTML comments);
(b) it MUST let a reader reconstruct the full nesting without seeing the HTML;
(c) write in the source document's language (Chinese doc → Chinese description);
(d) keep the flattened table below it (do NOT delete it — it is the raw
extraction trace). Never ask the user before describing; this is auto-applied.
-
Unknown: split into two sub-branches (see table above) — if HTML_REFERENCE
is usable, best-effort fix silently with <!-- AI-uncertain: verify -->;
only if HTML_REFERENCE is missing/corrupt, 🔴 STOP — ASK USER.
Unknown best-effort example (a defect NOT in the known set, but HTML is usable):
Formula Escaping Fix
markitdown incorrectly escapes * _ ^ inside $...$ math formulas as
\* \_ \^, causing KaTeX parse errors. Bug confirmed still present in
0.1.7 (verified 2026-08-06: input $a * b = c^2$ → output \ * b = c^2\);
affects at least 0.1.5–0.1.7. The fix runs automatically after every conversion
— no user action needed.
Applied to: batch_convert.py, convert_literature.py, convert_with_ai.py,
and _convert_core.py.
XLSX Formula Evaluation
Problem: When an XLSX is produced programmatically (openpyxl, database export),
formula cells like =A2+B2 are written with an empty cached value (<v></v>).
markitdown reads only the cached value (not the formula string), so all formula
cells appear as NaN in the Markdown output.
Fix (automatic, no flags needed): _xlsx_formula_eval.py computes every
formula cell with the pure-Python formulas
library and writes the results back into the <v> cache tags before markitdown
reads the file. This is a pre-conversion step — it modifies the XLSX bytes
in-memory before handing them to the converter.
Scope: Only .xlsx files with formula cells are processed. Non-xlsx files
and xlsx files without formulas pass through unchanged. Encryption handling:
formulas are evaluated on the already-decrypted bytes.
Dependency: pip install formulas. Before converting any .xlsx
containing formulas, verify the library is present:
python -c "from _xlsx_formula_eval import is_available; print(is_available())"
If this prints False, do NOT silently proceed and emit NaN — the
formulas library is the only way this skill fills the <v> cache that
markitdown reads. Missing it is a degraded mode, not a transparent no-op:
⚠️ Script behavior vs agent duty (claim-vs-code note — read first).
_convert_core._maybe_eval_xlsx is a graceful no-op wrapper: on any
exception (including ImportError when formulas is missing) it returns
the original bytes unchanged. As of 2026-08-10 it also prints a single
stdout line to the conversion summary signalling the degraded state:
formulas missing → XLSX formula eval: \formulas` library not
installed — formula cells will emit NaN (see SKILL.md C-4 STOP policy)`
- some formulas unresolved →
XLSX formula eval: 13/15 cells resolved, 2 unresolved → will emit NaN: sheet1!B6, sheet1!C6
- clean →
XLSX formula eval: 15/15 cells resolved
The script still never raises or stops (graceful no-op contract
preserved) — the line is a signal. Whether to 🔴 STOP remains an
agent-side decision based on the line's content:
- "not installed" or any "unresolved" mention → 🔴 STOP and tell the user
(see below), because downstream output will contain
NaN.
- "N/N cells resolved" with zero unresolved → clean, proceed normally.
- 🔴 STOP and tell the user: "XLSX formula evaluation is disabled
(
formulas not installed). Formula cells will show as NaN. Install with
pip install formulas, then re-run." Append this note to the one-line
conversion summary. Do not return exit 0 with NaN-filled output as if
the conversion succeeded cleanly.
If running in batch/CI where a stop is undesirable, document the degraded
run explicitly in the summary (e.g. "12 files converted, 2 xlsx had NaN
formula cells — formulas library not installed"). Never hide the degraded
state; silent NaN is a data-correctness bug (the C-4 audit issue was
precisely this class of silent loss).
Caveat: The formulas library supports a large subset of Excel functions
but not 100% (e.g. some financial functions, array formulas). Cells it cannot
evaluate are left as-is (they will still show NaN). The conversion summary
now prints which cells were unresolved (evaluate_xlsx_with_report →
EvaluationReport.unresolved_cells) — surface that list to the user rather
than hiding the partial failure.
Metadata Header
Every converted .md gets a small header prepended (mirroring batch_convert.py):
# <title or file stem>
**Source**: <input filename>
**Format**: <input suffix>
---
<body...>
The header is injected before table-structure detection, so the absolute
line numbers reported in the sidecar .errors.md match the final written file.
Disable with --no-metadata (single-file) or --no-metadata (batch).
References (load as needed)
- Per-format capabilities / limitations / dependencies (e.g. when deciding if a given PDF needs OCR, which XLSX features convert cleanly): see references/file_formats.md
- Full MarkItDown Python API (MarkItDown class constructor, convert/convert_stream signatures, plugin options): see references/api_reference.md
- Usage examples (common conversion patterns): see assets/example_usage.md
Regression-test prompts (darwin rubric) live in test-prompts.json at the skill root.
Runtime Warnings
The skill auto-handles all known conditions silently — no runtime prompts.
Table-structure conditions (currently emitted: D2/nested; roadmap: D6/D3/D4; plus Unknown triage) and their actions are fully
specified in Stage 1 issue table + Stage 2 AUTO-FIX POLICY + ⛔ Do NOT
(rows 1, 5, 8, 9) — refer there, not here. The two non-table conditions are:
| Condition (non-table) | Action |
|---|
Formula \* / \_ / \^ detected | Auto-fix silently (see Formula Escaping Fix) |
| Cell multiline/sublist flattened (D3/D4 — Roadmap, not currently emitted) | No action — semantically harmless. If ever emitted, treat as Unknown per AUTO-FIX POLICY triage. |
Scripts
| Script | Purpose |
|---|
_convert_core.py | Single-file enhanced conversion (recommended entry). Full pipeline: encryption + formula eval + formula fix + table detect + metadata header. |
_decrypt.py | Credential Manager integration for encrypted files |
_table_detect.py | Table structure issue detection (B+D architecture) |
_xlsx_formula_eval.py | Evaluates XLSX formulas with formulas library → writes cached values so markitdown reads real numbers, not NaN |
fix_formula_escaping.py | Shared post-processing module (imported by converters) |
batch_convert.py | Batch (parallel) conversion — delegates to _convert_core.convert_file() so capability is identical to single-file. Skips ~$* lock files. Collects sidecar .errors.md paths for stage-2 fixing. |
batch_convert_dynamic.py | Large-set / network-drive batch — size-aware per-file subprocess isolation (dynamic timeout, small-files-first, large-file cap, retry, resume) with full enhancement pipeline. Driver pre-decrypts encrypted files in-process (_PASSWORD_CACHE shared → one CredUI prompt per password). Accepts --source dir, --source list.json, or --source list.txt. Sidecars collected to _sidecars.txt. |
convert_single_enhanced.py | Subprocess entry called by batch_convert_dynamic.py. Thin CLI wrapper around _convert_core.convert_file(); emits [SIDECAR] markers on stdout; exit-code protocol mirrors the original markitdown batch converter. Supports --original-name for pre-decrypted temp files. |
convert_literature.py | Literature conversion with formula fix injected |
convert_with_ai.py | AI-enhanced conversion with formula fix injected |
generate_schematic.py | Schematic diagram generation (optional, not part of the conversion pipeline) |