
Doc To Markdown
- 605 installs
- 1.3k repo stars
- Updated August 4, 2026
- daymade/claude-code-skills
doc-to-markdown is a Claude Code skill that converts Word, PDF, and rich office documents into clean Markdown for developers who need repo-ready docs without hand-fixing headings and tables.
About
doc-to-markdown is a documentation conversion skill from daymade/claude-code-skills that transforms Word documents, PDFs, and other rich office files into clean Markdown suitable for Git repositories, static docs sites, and agent-readable knowledge bases. Developers reach for doc-to-markdown when onboarding legacy specifications, compliance PDFs, or stakeholder Word drafts into a docs-as-code workflow without manually rebuilding every heading, list, and table. The skill focuses on structural fidelity—preserving headings, lists, and tabular data while stripping presentation cruft that breaks Markdown linters and static site generators.
- Multi-format ingestion
- Heading and list cleanup
- Table preservation
- Repo-ready Markdown output
- Knowledge base migration
Doc To Markdown by the numbers
- 605 all-time installs (skills.sh)
- Ranked #143 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/daymade/claude-code-skills --skill doc-to-markdownAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 605 |
|---|---|
| repo stars | ★ 1.3k |
| Last updated | August 4, 2026 |
| Repository | daymade/claude-code-skills ↗ |
How do you convert Word or PDF docs to Markdown?
Convert Word, PDF, or rich office files into clean Markdown for repos, docs sites, and agent-readable knowledge without hand-reformatting every heading and table.
Who is it for?
Developers migrating legacy office documents into docs-as-code repositories or agent knowledge bases.
Skip if: Scanned-image PDFs needing OCR or workflows that require preserving complex Word macros and embedded OLE objects.
When should I use this skill?
A developer asks to convert Word, PDF, or office files to Markdown for a repo or documentation site.
What you get
Clean Markdown files with preserved headings, lists, tables, and repo-ready formatting.
- Clean Markdown file
- Repo-ready documentation
Files
Doc to Markdown
Convert documents to high-quality markdown with intelligent multi-tool orchestration and automatic DOCX post-processing.
Architecture: Pandoc (best-in-class extraction) + 8 post-processing fixes (our value-add).
Quick Start
# DOCX → Markdown (one command, zero manual fixes)
uv run --with pymupdf4llm --with markitdown scripts/convert.py document.docx -o output.md --assets-dir ./media
# PDF → Markdown
uv run --with pymupdf4llm --with markitdown scripts/convert.py document.pdf -o output.md
# Run tests
uv run --with pytest pytest scripts/test_convert.py -vDual Mode
| Mode | Speed | Quality | Use Case |
|---|---|---|---|
| Quick (default) | Fast | Good | Drafts, simple documents |
| Heavy | Slower | Best | Final documents, complex layouts |
Tool Selection
| Format | Quick Mode | Heavy Mode |
|---|---|---|
| pymupdf4llm | pymupdf4llm + markitdown | |
| DOCX | pandoc + post-processing | pandoc + markitdown |
| PPTX | markitdown | markitdown + pandoc |
| XLSX | markitdown | markitdown |
DOCX Post-Processing (automatic)
When converting DOCX via pandoc, 8 cleanups are applied automatically:
| Problem | Fix | Test coverage |
|---|---|---|
Grid tables (+:---+) | Single-column → blockquote, multi-column → pipe table | TestPostprocessPipeline |
Simple tables ( ---- ----) | Multi-column images → pipe table with captions | TestSimpleTable |
Image path nesting (media/media/) | Flatten to media/, absolute → relative | test_stats_tracking |
Pandoc attributes ({width="..."}) | Removed | test_pandoc_attributes_removed |
CJK bold spacing (**粗体**中文) | Add space around ** for CJK bold spans | TestCjkBoldSpacing (15 cases) |
| Indented dashed code blocks | → fenced ``` with language detection | test_code_block_with_language |
Escaped brackets (\[...\]) | → [...] | test_escaped_brackets_fixed |
Double-bracket links ([[text]](url)) | → [text](url) | test_double_bracket_links_fixed |
CJK Bold Spacing — why and how
DOCX uses run-level styling (no spaces between bold/normal runs in CJK text). Markdown renderers need whitespace around ** to recognize bold boundaries.
Rule: if a **content** span contains any CJK character, ensure both sides have a space — unless already spaced or at line boundary. This handles CJK punctuation, emoji adjacency, and mixed content.
Before: 打开**飞书**,就可以 → some renderers fail to bold
After: 打开 **飞书** ,就可以 → universally renders correctlyHeavy Mode Workflow
Heavy Mode runs multiple tools in parallel and selects the best segments:
1. Parallel Execution: Run all applicable tools simultaneously 2. Segment Analysis: Parse each output into segments (tables, headings, images, paragraphs) 3. Quality Scoring: Score each segment based on completeness and structure 4. Intelligent Merge: Select best version of each segment across tools
Merge Criteria
| Segment Type | Selection Criteria |
|---|---|
| Tables | More rows/columns, proper header separator |
| Images | Alt text present, local paths preferred |
| Headings | Proper hierarchy, appropriate length |
| Lists | More items, nested structure preserved |
| Paragraphs | Content completeness |
Image Extraction
# Extract images with metadata
uv run --with pymupdf scripts/extract_pdf_images.py document.pdf -o ./extracted-images
# Generate markdown references file
uv run --with pymupdf scripts/extract_pdf_images.py document.pdf --markdown refs.mdOutput:
- Images:
extracted-images/img_page1_1.png,extracted-images/img_page2_1.jpg - Metadata:
extracted-images/images_metadata.json(page, position, dimensions)
Quality Validation
# Validate conversion quality
uv run --with pymupdf scripts/validate_output.py document.pdf output.md
# Generate HTML report
uv run --with pymupdf scripts/validate_output.py document.pdf output.md --report report.htmlQuality Metrics
| Metric | Pass | Warn | Fail |
|---|---|---|---|
| Text Retention | >95% | 85-95% | <85% |
| Table Retention | 100% | 90-99% | <90% |
| Image Retention | 100% | 80-99% | <80% |
Merge Outputs Manually
# Merge multiple markdown files
python scripts/merge_outputs.py output1.md output2.md -o merged.md
# Show segment attribution
python scripts/merge_outputs.py output1.md output2.md -o merged.md --verbosePath Conversion (Windows/WSL)
# Windows to WSL conversion
python scripts/convert_path.py "C:\Users\<windows-user>\Documents\file.pdf"
# Output: /mnt/c/Users/<windows-user>/Documents/file.pdfCommon Issues
"No conversion tools available"
# Install all tools
pip install pymupdf4llm
uv tool install "markitdown[pdf]"
brew install pandocFontBBox warnings during PDF conversion
- Harmless font parsing warnings, output is still correct
Images missing from output
- Use Heavy Mode for better image preservation
- Or extract separately with
scripts/extract_pdf_images.py
Tables broken in output
- Use Heavy Mode - it selects the most complete table version
- Or validate with
scripts/validate_output.py
Bundled Scripts
| Script | Purpose |
|---|---|
convert.py | Main orchestrator with Quick/Heavy mode + DOCX post-processing |
test_convert.py | 31 tests covering all post-processing functions |
merge_outputs.py | Merge multiple markdown outputs |
validate_output.py | Quality validation with HTML report |
extract_pdf_images.py | PDF image extraction with metadata |
convert_path.py | Windows to WSL path converter |
References
references/benchmark-2026-03-22.md- 5-tool benchmark (Docling/MarkItDown/Pandoc/Mammoth/ours)references/heavy-mode-guide.md- Detailed Heavy Mode documentationreferences/tool-comparison.md- Tool capabilities comparisonreferences/conversion-examples.md- Batch operation examples
Next Step: Clean Up Converted Content
After converting documents to markdown, suggest cleanup:
Conversion complete: [N] files converted to markdown.
Options:
A) Clean up docs — run /daymade-docs:docs-cleaner to consolidate redundant content (Recommended if multiple files)
B) Check facts — run /fact-checker to verify claims in the converted content
C) No thanks — the markdown conversion is sufficientSecurity scan passed
Scanned at: 2026-06-13T19:44:41.778833
Tool: gitleaks + pattern-based validation
Content hash: 186ebedf7650edacd16882f4103e608a7c2b987ad545512f9853750029c65af7
DOCX→Markdown 转换方案基准测试
测试日期:2026-03-22
>
测试文件:助教-【腾讯云🦞】小白实践 OpenClaw 保姆级教程.docx(19MB,77 张图片,含 grid table 布局、JSON 代码块、多列图片并排、信息框)>
测试方法:5 个方案对同一文件转换,按 5 个维度各 10 分制打分
---
综合评分
| 维度 | Docling (IBM) | MarkItDown (MS) | Pandoc | Mammoth | doc-to-markdown(我们) |
|---|---|---|---|---|---|
| 表格质量 | 5 | 3 | 5 | 1~3 | 6 |
| 图片提取 | 4 | 2 | 10 | 5 | 7 |
| 文本完整性 | 8 | 7 | 9 | 7 | 9 |
| 格式清洁度 | 5 | 5 | 5 | 3 | 7 |
| 代码块 | 2 | 1 | N/A | 1 | 9 |
| 综合 | 4.8 | 3.6 | 7.3 | 3.4~3.6 | 7.6 |
---
各方案详细分析
1. IBM Docling(综合 4.8)
- 版本:docling 2.x + Granite-Docling-258M
- 架构:AI 驱动(VLM 视觉语言模型),DocTags 中间格式 → Markdown
致命问题:
- 图片引用全部是
<!-- image -->占位符(77 张图 0 张可显示),ImageRefModeAPI 对 DOCX 不可用 - 标题层级全部丢失(0 个
#),所有标题退化为粗体文本 - 代码块为零,JSON 和命令全部输出为普通段落
api_key被错误转义为api\_key
优点:
- 文本内容完整,中文/emoji/链接保留良好
- 无 grid table 或 HTML 残留
- 表格语法正确(pipe table),但内容是占位符
结论:Docling 的优势在 PDF(AAAI 2025 论文场景),DOCX 支持远未达到生产级别。
2. Microsoft MarkItDown(综合 3.6)
- 版本:markitdown 0.1.5
- 架构:底层调用 mammoth → HTML → markdownify → Markdown
致命问题:
- 77 张图片全部是截断的 base64 占位符(
data:image/png;base64,...),默认keep_data_uris=False主动丢弃图片数据 - 标题全部变为粗体文本(mammoth 无法识别 WPS 自定义样式)
- 代码块为零,JSON 被塞入表格单元格
- 有序列表编号全部错误(输出为
1. 1. 1.)
优点:
- 无 HTML 标签残留
- 文本内容基本完整
结论:MarkItDown 的 markdownify 后处理反而引入破坏性截断。轻量场景可用,复杂 DOCX 不可靠。
3. Pandoc(综合 7.3)
- 版本:pandoc 3.9
- 架构:Haskell 原生 AST 解析,支持 60+ 格式
测试了 3 种参数:
| 参数 | 结果 |
|---|---|
-t gfm | 最差:24 个 HTML <table> 嵌套,74 个 HTML <img> |
-t markdown | 最佳:grid table(可后处理),无 HTML |
-t markdown-raw_html-... | 与 markdown 完全相同,参数无效果 |
问题:
- Grid table 不可避免(原 docx 有多行单元格和嵌套表格,pipe table 无法表达)
{width="..." height="..."}68 处{.underline}6 处- 反斜杠过度转义 37 处
优点:
- 图片提取 10/10(77 张全部正确,路径结构一致)
- 文本完整性 9/10(内容、链接、emoji 全部保留)
- 最成熟稳定的底层引擎
结论:Pandoc 是最可靠的底层引擎,输出质量最高但需要后处理清洗 pandoc 私有语法。
4. Mammoth(综合 3.4~3.6)
- 版本:mammoth 1.11.0
- 架构:python-docx 解析 → HTML/Markdown(Markdown 支持已废弃)
测试了 2 种方式:
| 方式 | 综合 |
|---|---|
| 方式A:直接转 Markdown | 3.4(表格完全丢失) |
| 方式B:转 HTML → markdownify | 3.6(有表格但嵌套被压扁) |
致命问题:
- 标题全部丢失(WPS
styles.xml中样式定义为空,mammoth 无法映射 Heading) - 代码块为零
- 图片全部 base64 内嵌,单文件 28MB
- 方式B 中 markdownify 丢失 14 张图片(63/77)
结论:Mammoth 的 Markdown 支持已废弃,对 WPS 导出的 docx 兼容性差。不推荐。
5. doc-to-markdown / 我们的方案(综合 7.6)
- 版本:doc-to-markdown 1.0(基于 pandoc + 6 个后处理函数)
- 架构:Pandoc 转换 → 自动后处理(grid table 清理、图片路径修复、属性清理、代码块修复、转义修复)
后处理实际效果:
| 后处理函数 | 修复数量 |
|---|---|
_convert_grid_tables | 11 处 grid table → pipe table / blockquote |
_clean_pandoc_attributes | 3437 字符属性清理 |
_fix_code_blocks | 22 处缩进虚线 → ``` 代码块 |
_fix_escaped_brackets | 10 处 |
_fix_double_bracket_links | 1 处 |
_fix_image_paths | 77 张图片路径修复 |
已知问题(待修复):
- 图片路径双层嵌套 bug:
--assets-dir指定目录内被 pandoc 再建一层media/ - 2 处 grid table 残留(文末并排图片组未完全转换)
优点:
- 代码块识别 9/10(JSON 带语言标签,命令行正确包裹)
- 格式清洁度 7/10(attributes、转义、grid table 大部分清理干净)
- 文本完整性 9/10(关键内容全部保留)
结论:综合最优,核心价值在 pandoc 后处理层。剩余 2 个 bug 可修。
---
架构决策
最终方案:Pandoc(底层引擎)+ doc-to-markdown 后处理(增值层)
理由:
1. Pandoc 图片提取最可靠(10/10),文本最完整(9/10)
2. Pandoc 的问题(grid table、属性、转义)全部可后处理解决
3. Docling/MarkItDown/Mammoth 的致命问题(图片丢失、标题丢失)无法后处理修复
4. 后处理层是我们的核心竞争力,成本低、可迭代---
测试文件特征
本次测试文件的难点在于:
| 特征 | 说明 | 影响 |
|---|---|---|
| WPS 导出 | 非标准 Word 样式(Style ID 2/3 而非 Heading 1/2) | mammoth/markitdown/docling 标题全丢 |
| 多列图片布局 | 2x2、1x4 图片网格用表格排版 | pandoc 输出 grid table |
| 信息框/提示框 | 单列表格包裹文字 | pandoc 输出 grid table |
| 嵌套表格 | 表格内套表格 | pipe table 无法表达 |
| JSON 代码块 | 非代码块样式,用文本框/缩进表示 | 多数工具无法识别为代码 |
| 19MB 文件 | 77 张截图嵌入 | base64 方案导致 28MB 输出 |
这些特征代表了真实世界中 WPS/飞书文档导出 docx 的典型困难,是有效的基准测试场景。
Document Conversion Examples
Comprehensive examples for converting various document formats to markdown.
Basic Document Conversions
PDF to Markdown
# Simple PDF conversion
markitdown "document.pdf" > output.md
# WSL path example
markitdown "/mnt/c/Users/<windows-user>/Documents/report.pdf" > report.md
# With explicit output
markitdown "slides.pdf" > "slides.md"Word Documents to Markdown
# Modern Word document (.docx)
markitdown "document.docx" > output.md
# Legacy Word document (.doc)
markitdown "legacy-doc.doc" > output.md
# Preserve directory structure
markitdown "/path/to/docs/file.docx" > "/path/to/output/file.md"PowerPoint to Markdown
# Convert presentation
markitdown "presentation.pptx" > slides.md
# WSL path
markitdown "/mnt/c/Users/<windows-user>/Desktop/slides.pptx" > slides.md---
Windows/WSL Path Conversion
Basic Path Conversion Rules
# Windows path
C:\Users\<windows-user>\Documents\file.doc
# WSL equivalent
/mnt/c/Users/<windows-user>/Documents/file.docConversion Examples
# Single backslash to forward slash
C:\folder\file.txt
→ /mnt/c/folder/file.txt
# Path with spaces (must use quotes)
C:\Users\<windows-user>\Documents\report.pdf
→ "/mnt/c/Users/<windows-user>/Documents/report.pdf"
# OneDrive path
C:\Users\<windows-user>\OneDrive\Documents\file.doc
→ "/mnt/c/Users/<windows-user>/OneDrive/Documents/file.doc"
# Different drive letters
D:\Projects\document.docx
→ /mnt/d/Projects/document.docxUsing convert_path.py Helper
# Automatic conversion
python scripts/convert_path.py "C:\Users\<windows-user>\Downloads\document.doc"
# Output: /mnt/c/Users/<windows-user>/Downloads/document.doc
# Use in conversion command
wsl_path=$(python scripts/convert_path.py "C:\Users\<windows-user>\file.docx")
markitdown "$wsl_path" > output.md---
Batch Conversions
Convert Multiple Files
# Convert all PDFs in a directory
for pdf in /path/to/pdfs/*.pdf; do
filename=$(basename "$pdf" .pdf)
markitdown "$pdf" > "/path/to/output/${filename}.md"
done
# Convert all Word documents
for doc in /path/to/docs/*.docx; do
filename=$(basename "$doc" .docx)
markitdown "$doc" > "/path/to/output/${filename}.md"
doneBatch Conversion with Path Conversion
# Windows batch (PowerShell)
Get-ChildItem "C:\Documents\*.pdf" | ForEach-Object {
$wslPath = "/mnt/c/Documents/$($_.Name)"
$outFile = "/mnt/c/Output/$($_.BaseName).md"
wsl markitdown $wslPath > $outFile
}---
Confluence Export Handling
Simple Confluence Export
# Direct conversion for exports without special characters
markitdown "confluence-export.doc" > output.mdExport with Special Characters
For Confluence exports containing special characters:
1. Save the .doc file to an accessible location 2. Try direct conversion first:
markitdown "confluence-export.doc" > output.md3. If special characters cause issues:
- Open in Word and save as .docx
- Or use LibreOffice to convert:
libreoffice --headless --convert-to docx export.doc - Then convert the .docx file
Handling Encoding Issues
# Check file encoding
file -i "document.doc"
# Convert if needed (using iconv)
iconv -f ISO-8859-1 -t UTF-8 input.md > output.md---
Advanced Conversion Scenarios
Preserving Directory Structure
# Mirror directory structure
src_dir="/mnt/c/Users/<windows-user>/Documents"
out_dir="/path/to/output"
find "$src_dir" -name "*.docx" | while read file; do
# Get relative path
rel_path="${file#$src_dir/}"
out_file="$out_dir/${rel_path%.docx}.md"
# Create output directory
mkdir -p "$(dirname "$out_file")"
# Convert
markitdown "$file" > "$out_file"
doneConversion with Metadata
# Add frontmatter to converted file
{
echo "---"
echo "title: $(basename "$file" .pdf)"
echo "converted: $(date -I)"
echo "source: $file"
echo "---"
echo ""
markitdown "$file"
} > output.md---
Error Recovery
Handling Failed Conversions
# Check if markitdown succeeded
if markitdown "document.pdf" > output.md 2> error.log; then
echo "Conversion successful"
else
echo "Conversion failed, check error.log"
fiRetry Logic
# Retry failed conversions
for file in *.pdf; do
output="${file%.pdf}.md"
if ! [ -f "$output" ]; then
echo "Converting $file..."
markitdown "$file" > "$output" || echo "Failed: $file" >> failed.txt
fi
done---
Quality Verification
Check Conversion Quality
# Compare line counts
wc -l document.pdf.md
# Check for common issues
grep "TODO\|ERROR\|MISSING" output.md
# Preview first/last lines
head -n 20 output.md
tail -n 20 output.mdValidate Output
# Check for empty files
if [ ! -s output.md ]; then
echo "Warning: Output file is empty"
fi
# Verify markdown syntax
# Use a markdown linter if available
markdownlint output.md---
Best Practices
1. Path Handling
- Always quote paths with spaces
- Verify paths exist before conversion
- Use absolute paths for scripts
2. Batch Processing
- Log conversions for audit trail
- Handle errors gracefully
- Preserve original files
3. Output Organization
- Mirror source directory structure
- Use consistent naming conventions
- Separate by document type or date
4. Quality Assurance
- Spot-check random conversions
- Validate critical documents manually
- Keep conversion logs
5. Performance
- Use parallel processing for large batches
- Skip already converted files
- Clean up temporary files
---
Common Patterns
Pattern: Convert and Review
#!/bin/bash
file="$1"
output="${file%.*}.md"
# Convert
markitdown "$file" > "$output"
# Open in editor for review
${EDITOR:-vim} "$output"Pattern: Safe Conversion
#!/bin/bash
file="$1"
backup="${file}.backup"
output="${file%.*}.md"
# Backup original
cp "$file" "$backup"
# Convert with error handling
if markitdown "$file" > "$output" 2> conversion.log; then
echo "Success: $output"
rm "$backup"
else
echo "Failed: Check conversion.log"
mv "$backup" "$file"
fiPattern: Metadata Preservation
#!/bin/bash
# Extract and preserve document metadata
file="$1"
output="${file%.*}.md"
# Get file metadata
created=$(stat -c %w "$file" 2>/dev/null || stat -f %SB "$file")
modified=$(stat -c %y "$file" 2>/dev/null || stat -f %Sm "$file")
# Convert with metadata
{
echo "---"
echo "original_file: $(basename "$file")"
echo "created: $created"
echo "modified: $modified"
echo "converted: $(date -I)"
echo "---"
echo ""
markitdown "$file"
} > "$output"Heavy Mode Guide
Detailed documentation for doc-to-markdown Heavy Mode conversion.
Overview
Heavy Mode runs multiple conversion tools in parallel and intelligently merges their outputs to produce the highest quality markdown possible.
When to Use Heavy Mode
Use Heavy Mode when:
- Document has complex tables that need precise formatting
- Images must be preserved with proper references
- Structure hierarchy (headings, lists) must be accurate
- Output quality is more important than conversion speed
- Document will be used for LLM processing
Use Quick Mode when:
- Speed is priority
- Document is simple (mostly text)
- Output is for draft/review purposes
Tool Capabilities
PyMuPDF4LLM (Best for PDFs)
Strengths:
- Native table detection with multiple strategies
- Image extraction with position metadata
- LLM-optimized output format
- Preserves reading order
Usage:
import pymupdf4llm
md_text = pymupdf4llm.to_markdown(
"document.pdf",
write_images=True,
table_strategy="lines_strict",
image_path="./assets",
dpi=150
)markitdown (Universal Converter)
Strengths:
- Supports many formats (PDF, DOCX, PPTX, XLSX)
- Good text extraction
- Simple API
Limitations:
- May miss complex tables
- No native image extraction
pandoc (Best for Office Docs)
Strengths:
- Excellent DOCX/PPTX structure preservation
- Proper heading hierarchy
- List formatting
Limitations:
- Requires system installation
- PDF support limited
Merge Strategy
Segment-Level Selection
Heavy Mode doesn't just pick one tool's output. It:
1. Parses each output into segments 2. Scores each segment independently 3. Selects the best version of each segment
Segment Types
| Type | Detection Pattern | Scoring Criteria |
|---|---|---|
| Table | `\ | .*\ |
| Heading | ^#{1-6} | Proper level, reasonable length |
| Image | !\[.*\]\(.*\) | Alt text present, local path |
| List | ^[-*+\d.] | Item count, nesting depth |
| Code | Triple backticks | Line count, language specified |
| Paragraph | Default | Word count, completeness |
Scoring Example
Table from pymupdf4llm:
- 10 rows × 5 columns = 5.0 points
- Header separator present = 1.0 points
- Total: 6.0 points
Table from markitdown:
- 8 rows × 5 columns = 4.0 points
- No header separator = 0.0 points
- Total: 4.0 points
→ Select pymupdf4llm versionAdvanced Usage
Force Specific Tool
# Use only pandoc
uv run scripts/convert.py document.docx -o output.md --tool pandocCustom Assets Directory
# Heavy mode with custom image output
uv run scripts/convert.py document.pdf -o output.md --heavy --assets-dir ./imagesValidate After Conversion
# Convert then validate
uv run scripts/convert.py document.pdf -o output.md --heavy
uv run scripts/validate_output.py document.pdf output.md --report quality.htmlTroubleshooting
Low Text Retention Score
Causes:
- PDF has scanned images (not searchable text)
- Encoding issues in source document
- Complex layouts confusing the parser
Solutions:
- Use OCR preprocessing for scanned PDFs
- Try different tool with
--toolflag - Manual cleanup may be needed
Missing Tables
Causes:
- Tables without visible borders
- Tables spanning multiple pages
- Merged cells
Solutions:
- Use Heavy Mode for better detection
- Try pymupdf4llm with different table_strategy
- Manual table reconstruction
Image References Broken
Causes:
- Assets directory not created
- Relative path issues
- Image extraction failed
Solutions:
- Ensure
--assets-dirpoints to correct location - Check
images_metadata.jsonfor extraction status - Use
extract_pdf_images.pyseparately
Tool Comparison
Comparison of document-to-markdown conversion tools.
Feature Matrix
| Feature | pymupdf4llm | markitdown | pandoc |
|---|---|---|---|
| PDF Support | ✅ Excellent | ✅ Good | ⚠️ Limited |
| DOCX Support | ❌ No | ✅ Good | ✅ Excellent |
| PPTX Support | ❌ No | ✅ Good | ✅ Good |
| XLSX Support | ❌ No | ✅ Good | ⚠️ Limited |
| Table Detection | ✅ Multiple strategies | ⚠️ Basic | ✅ Good |
| Image Extraction | ✅ With metadata | ❌ No | ✅ Yes |
| Heading Hierarchy | ✅ Good | ⚠️ Variable | ✅ Excellent |
| List Formatting | ✅ Good | ⚠️ Basic | ✅ Excellent |
| LLM Optimization | ✅ Built-in | ❌ No | ❌ No |
Installation
pymupdf4llm
pip install pymupdf4llm
# Or with uv
uv pip install pymupdf4llmDependencies: None (pure Python with PyMuPDF)
markitdown
# With PDF support
uv tool install "markitdown[pdf]"
# Or
pip install "markitdown[pdf]"Dependencies: Various per format (pdfminer, python-docx, etc.)
pandoc
# macOS
brew install pandoc
# Ubuntu/Debian
apt-get install pandoc
# Windows
choco install pandocDependencies: System installation required
Performance Benchmarks
PDF Conversion (100-page document)
| Tool | Time | Memory | Output Quality |
|---|---|---|---|
| pymupdf4llm | ~15s | 150MB | Excellent |
| markitdown | ~45s | 200MB | Good |
| pandoc | ~60s | 100MB | Variable |
DOCX Conversion (50-page document)
| Tool | Time | Memory | Output Quality |
|---|---|---|---|
| pandoc | ~5s | 50MB | Excellent |
| markitdown | ~10s | 80MB | Good |
Best Practices
For PDFs
1. First choice: pymupdf4llm
- Best table detection
- Image extraction with metadata
- LLM-optimized output
2. Fallback: markitdown
- When pymupdf4llm fails
- Simpler documents
For DOCX/DOC
1. First choice: pandoc
- Best structure preservation
- Proper heading hierarchy
- List formatting
2. Fallback: markitdown
- When pandoc unavailable
- Quick conversion needed
For PPTX
1. First choice: markitdown
- Good slide content extraction
- Handles speaker notes
2. Fallback: pandoc
- Better structure preservation
For XLSX
1. Only option: markitdown
- Table to markdown conversion
- Sheet handling
Common Issues by Tool
pymupdf4llm
| Issue | Solution |
|---|---|
| "Cannot import fitz" | pip install pymupdf |
| Tables not detected | Try different table_strategy |
| Images not extracted | Enable write_images=True |
markitdown
| Issue | Solution |
|---|---|
| PDF support missing | Install with [pdf] extra |
| Slow conversion | Expected for large files |
| Missing content | Try alternative tool |
pandoc
| Issue | Solution |
|---|---|
| Command not found | Install via package manager |
| PDF conversion fails | Use pymupdf4llm instead |
| Images not extracted | Add --extract-media flag |
API Comparison
pymupdf4llm
import pymupdf4llm
md = pymupdf4llm.to_markdown(
"doc.pdf",
write_images=True,
table_strategy="lines_strict",
image_path="./assets"
)markitdown
from markitdown import MarkItDown
md = MarkItDown()
result = md.convert("document.pdf")
print(result.text_content)pandoc
pandoc document.docx -t markdown --wrap=none --extract-media=./assetsimport subprocess
result = subprocess.run(
["pandoc", "doc.docx", "-t", "markdown", "--wrap=none"],
capture_output=True, text=True
)
print(result.stdout)#!/usr/bin/env python3
"""
Convert Windows paths to WSL format.
Usage:
python convert_path.py "C:\\Users\\<windows-user>\\Downloads\\file.doc"
Output:
/mnt/c/Users/<windows-user>/Downloads/file.doc
"""
import sys
import re
def convert_windows_to_wsl(windows_path: str) -> str:
"""
Convert a Windows path to WSL format.
Args:
windows_path: Windows path (e.g., "C:\\Users\\<windows-user>\\file.doc")
Returns:
WSL path (e.g., "/mnt/c/Users/<windows-user>/file.doc")
"""
# Remove quotes if present
path = windows_path.strip('"').strip("'")
# Handle drive letter (C:\ or C:/)
drive_pattern = r'^([A-Za-z]):[\\\/]'
match = re.match(drive_pattern, path)
if not match:
# Already a WSL path or relative path
return path
drive_letter = match.group(1).lower()
path_without_drive = path[3:] # Remove "C:\"
# Replace backslashes with forward slashes
path_without_drive = path_without_drive.replace('\\', '/')
# Construct WSL path
wsl_path = f"/mnt/{drive_letter}/{path_without_drive}"
return wsl_path
def main():
if len(sys.argv) < 2:
print("Usage: python convert_path.py <windows_path>")
print('Example: python convert_path.py "C:\\Users\\<windows-user>\\Downloads\\file.doc"')
sys.exit(1)
windows_path = sys.argv[1]
wsl_path = convert_windows_to_wsl(windows_path)
print(wsl_path)
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""
Multi-tool document to markdown converter with intelligent orchestration.
Supports Quick Mode (fast, single tool) and Heavy Mode (best quality, multi-tool merge).
DOCX files get automatic post-processing to fix pandoc artifacts.
Usage:
# Quick Mode (default) - fast, single best tool
uv run --with pymupdf4llm --with markitdown scripts/convert.py document.pdf -o output.md
# Heavy Mode - multi-tool parallel execution with merge
uv run --with pymupdf4llm --with markitdown scripts/convert.py document.pdf -o output.md --heavy
# DOCX deep mode - python-docx direct parsing (experimental)
uv run --with python-docx scripts/convert.py document.docx -o output.md --docx-deep
# With image extraction
uv run --with pymupdf4llm scripts/convert.py document.pdf -o output.md --assets-dir ./images
Dependencies:
- pymupdf4llm: PDF conversion (LLM-optimized)
- markitdown: PDF/DOCX/PPTX conversion
- pandoc: DOCX/PPTX conversion (system install: brew install pandoc)
- python-docx: DOCX deep parsing (optional, for --docx-deep)
"""
import argparse
import json
import re
import subprocess
import sys
import shutil
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class ConversionResult:
"""Result from a single tool conversion."""
markdown: str
tool: str
images: list[str] = field(default_factory=list)
success: bool = True
error: str = ""
# ── Post-processing stats ────────────────────────────────────────────────────
@dataclass
class PostProcessStats:
"""Track what the DOCX post-processor fixed."""
grid_tables_converted: int = 0
image_paths_fixed: int = 0
attributes_removed: int = 0
code_blocks_fixed: int = 0
escaped_brackets_fixed: int = 0
double_brackets_fixed: int = 0
def any_fixes(self) -> bool:
return any(
getattr(self, f) > 0
for f in self.__dataclass_fields__
)
def summary(self) -> str:
parts = []
if self.grid_tables_converted:
parts.append(f"grid tables: {self.grid_tables_converted}")
if self.image_paths_fixed:
parts.append(f"image paths: {self.image_paths_fixed}")
if self.attributes_removed:
parts.append(f"attributes: {self.attributes_removed}")
if self.code_blocks_fixed:
parts.append(f"code blocks: {self.code_blocks_fixed}")
if self.escaped_brackets_fixed:
parts.append(f"escaped brackets: {self.escaped_brackets_fixed}")
if self.double_brackets_fixed:
parts.append(f"double brackets: {self.double_brackets_fixed}")
return ", ".join(parts) if parts else "no fixes needed"
# ── DOCX post-processing ─────────────────────────────────────────────────────
# Regex patterns compiled once
_RE_GRID_BORDER = re.compile(r"^\+[:=-][-:=]+(?:\+[:=-][-:=]+)*\+$")
_RE_GRID_ROW = re.compile(r"^\|(.+)\|$")
_RE_NESTED_GRID_BORDER = re.compile(r"^\|\s*\+[:=-][-:=]+\+\s*\|$")
_RE_PANDOC_ATTR = re.compile(r"\{[^}]*(?:width|height)\s*=\s*\"[^\"]*\"[^}]*\}")
_RE_PANDOC_CLASS = re.compile(r"\{\.(?:underline|mark)\}")
_RE_DOUBLE_BRACKET_LINK = re.compile(r"\[\[([^\]]+)\]\(([^)]+)\)")
_RE_DOUBLE_BRACKET_CLOSED = re.compile(r"\[\[([^\]]+)\]\]\(([^)]+)\)")
_RE_DOUBLE_BRACKET_ATTR_LINK = re.compile(r"\[\[([^\]]+)\]\{[^}]*\}\]\(([^)]+)\)")
_RE_ESCAPED_BRACKET = re.compile(r"\\(\[|])")
# Matches single-column dashed line: " ------"
# AND multi-column simple table border: " ---- -----"
_RE_DASHED_LINE = re.compile(r"^(\s{2,})-{3,}[\s-]*$")
_RE_ESCAPED_QUOTE = re.compile(r'\\"')
# CJK + fullwidth punctuation range for bold spacing checks
_RE_CJK_PUNCT = re.compile(r'[\u4e00-\u9fff\u3000-\u303f\uff01-\uffef,。、;:!?()【】「」《》""'']')
_RE_BOLD_PAIR = re.compile(r'\*\*(.+?)\*\*')
def _is_grid_border(line: str) -> bool:
"""Check if a line is a grid table border like +---+ or +:---+."""
stripped = line.strip()
return bool(_RE_GRID_BORDER.match(stripped))
def _is_nested_grid_border(line: str) -> bool:
"""Check if a line is a nested grid border like | +---+ |."""
stripped = line.strip()
return bool(_RE_NESTED_GRID_BORDER.match(stripped))
def _count_grid_columns(border_line: str) -> int:
"""Count columns in a grid table border line."""
stripped = border_line.strip()
if not stripped.startswith("+"):
return 0
# Count + separators minus 1 = number of columns
return stripped.count("+") - 1
# Languages recognized as code block hints in pandoc dashed-line blocks
_KNOWN_CODE_LANGS = frozenset({
"json", "bash", "shell", "python", "javascript", "js",
"html", "css", "yaml", "xml", "sql", "plain text",
"text", "plaintext", "typescript", "ts", "go", "rust",
"java", "c", "cpp", "ruby", "php",
})
def _build_pipe_table(rows: list[list[str]]) -> list[str]:
"""Build a standard markdown pipe table from rows of cells."""
if not rows:
return []
col_count = max(len(r) for r in rows)
lines = [
"| " + " | ".join([""] * col_count) + " |",
"| " + " | ".join(["---"] * col_count) + " |",
]
for row in rows:
padded = row + [""] * (col_count - len(row))
lines.append("| " + " | ".join(padded) + " |")
return lines
def _collect_images(directory: Path) -> list[str]:
"""Collect image files from a directory (single glob pass)."""
if not directory.exists():
return []
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
return sorted(
str(p) for p in directory.rglob("*")
if p.suffix.lower() in image_exts
)
def _convert_grid_tables(text: str, stats: PostProcessStats) -> str:
"""Convert pandoc grid tables to standard markdown.
Single-column grid tables (info boxes) -> blockquotes.
Multi-column grid tables (side-by-side images) -> split into individual elements.
Nested grid tables are flattened.
"""
lines = text.split("\n")
result = []
i = 0
while i < len(lines):
line = lines[i]
# Detect grid table start
if _is_grid_border(line):
# Collect the entire grid table
table_lines = [line]
i += 1
while i < len(lines):
table_lines.append(lines[i])
if _is_grid_border(lines[i]) and len(table_lines) > 1:
i += 1
break
i += 1
else:
# Reached end of file without closing border
# Just output as-is
result.extend(table_lines)
continue
stats.grid_tables_converted += 1
num_cols = _count_grid_columns(table_lines[0])
# Extract content lines (skip borders)
content_lines = []
for tl in table_lines:
if _is_grid_border(tl) or _is_nested_grid_border(tl):
continue
m = _RE_GRID_ROW.match(tl.strip())
if m:
content_lines.append(m.group(1).strip())
else:
# Non-standard line inside grid, keep content
stripped = tl.strip()
if stripped and stripped != "|":
content_lines.append(stripped)
if num_cols <= 1:
# Single column -> blockquote
result.append("")
for cl in content_lines:
# Strip outer pipes if present from nested grids
cleaned = cl.strip()
if cleaned.startswith("|") and cleaned.endswith("|"):
cleaned = cleaned[1:-1].strip()
# Skip nested grid borders
if _RE_GRID_BORDER.match(cleaned):
continue
if cleaned:
result.append(f"> {cleaned}")
else:
result.append(">")
result.append("")
else:
# Multi-column -> convert to standard pipe table
# Parse rows: each content_line is a row, split by | into cells
table_rows = []
for cl in content_lines:
cells = [c.strip() for c in cl.split("|") if c.strip() and not _RE_GRID_BORDER.match(c.strip())]
if cells:
table_rows.append(cells)
if table_rows:
result.append("")
result.extend(_build_pipe_table(table_rows))
result.append("")
else:
result.append(line)
i += 1
return "\n".join(result)
def _fix_image_paths(text: str, assets_dir: Optional[Path], stats: PostProcessStats) -> str:
"""Fix pandoc's double media path and verify images exist.
Pandoc extracts to <assets_dir>/media/<files> but references as
<assets_dir>/media/media/<files>. Fix the references.
Also flatten the actual directory if needed.
"""
def fix_path(m: re.Match) -> str:
alt = m.group(1)
path = m.group(2)
new_path = path
# Fix double media/ path
if "media/media/" in path:
new_path = path.replace("media/media/", "media/")
stats.image_paths_fixed += 1
return f""
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", fix_path, text)
# Flatten double media/ nesting if present (pandoc artifact)
if assets_dir:
double_media = assets_dir / "media" / "media"
single_media = assets_dir / "media"
try:
for f in double_media.iterdir():
dest = single_media / f.name
if not dest.exists():
shutil.move(str(f), str(dest))
double_media.rmdir()
except (FileNotFoundError, OSError):
pass
return text
def _clean_pandoc_attributes(text: str, stats: PostProcessStats) -> str:
"""Remove pandoc attribute annotations from markdown.
Removes: {width="..." height="..."}, {.underline}, {.mark}, etc.
"""
count_before = len(text)
# Remove width/height attributes on images
text = _RE_PANDOC_ATTR.sub("", text)
# Remove class attributes like {.underline}
text = _RE_PANDOC_CLASS.sub("", text)
if len(text) != count_before:
# Rough count of removals
stats.attributes_removed = count_before - len(text)
return text
def _is_code_content(lines: list[str]) -> bool:
"""Heuristic: decide if content between dashed lines is code or a note/callout.
Code indicators:
- Has a language hint on the first line
- Contains JSON/code-like syntax ({, }, =, ;, ->, //)
- Contains URLs with protocols
- Has backslash line continuations
Note indicators:
- Mostly CJK/prose text without code syntax
- Short single-line content
"""
text = "\n".join(lines)
stripped = text.strip()
if not stripped:
return False
# Code syntax indicators
code_chars = set('{}[]();=<>/\\')
code_char_count = sum(1 for c in stripped if c in code_chars)
# If >5% of content is code syntax characters, treat as code
if len(stripped) > 0 and code_char_count / len(stripped) > 0.05:
return True
# JSON-like structure
if stripped.startswith("{") or stripped.startswith("["):
return True
# Command-like (starts with common command patterns)
first_line = lines[0].strip() if lines else ""
if re.match(r"^(curl|wget|npm|pip|brew|apt|docker|git|ssh|cd|ls|cat|echo|python|node|uv)\s", first_line):
return True
return False
def _fix_code_blocks(text: str, stats: PostProcessStats) -> str:
"""Convert pandoc's indented dashed-line blocks to fenced code blocks or blockquotes.
Pandoc wraps both code and notes in:
------------------------------------------------------------------
content here
------------------------------------------------------------------
With language hint -> code block:
```json
content here
```
Without language hint + prose content -> blockquote:
> content here
Without language hint + code-like content -> code block:
```
content here
```
"""
lines = text.split("\n")
result = []
i = 0
known_langs = _KNOWN_CODE_LANGS
while i < len(lines):
line = lines[i]
# Detect indented dashed line (2+ leading spaces, 3+ dashes)
if _RE_DASHED_LINE.match(line):
# Check if this is a pandoc simple table (multiple dashed columns
# on the same line, or content between dashes contains images)
# Simple table pattern: " ---- ----" (multiple dash groups separated by spaces)
# Gap can be 1+ spaces (pandoc uses varying gaps)
dash_parts = [p for p in line.strip().split() if p.strip()]
is_simple_table_border = len(dash_parts) > 1 and all(
re.match(r"^-+$", p.strip()) for p in dash_parts
)
if is_simple_table_border:
# This is a pandoc simple table border - collect rows until
# next simple table border, convert to pipe table
table_rows = []
j = i + 1
while j < len(lines):
next_line = lines[j]
# Check for closing simple table border
next_parts = [p for p in next_line.strip().split() if p.strip()]
is_next_border = len(next_parts) > 1 and all(
re.match(r"^-+$", p.strip()) for p in next_parts
)
if is_next_border:
j += 1
break
if next_line.strip():
# Split by 2+ spaces to get columns (pandoc uses varying gaps)
cells = [c.strip() for c in re.split(r"\s{2,}", next_line.strip()) if c.strip()]
if cells:
table_rows.append(cells)
j += 1
if table_rows:
stats.code_blocks_fixed += 1
result.append("")
result.extend(_build_pipe_table(table_rows))
result.append("")
i = j
continue
# Not a simple table - look for content and closing dashed line
block_content = []
lang_hint = ""
j = i + 1
while j < len(lines):
next_line = lines[j]
if _RE_DASHED_LINE.match(next_line):
# Found closing dashed line
j += 1
break
block_content.append(next_line)
j += 1
else:
# No closing dashed line found - not a block, keep as-is
result.append(line)
i += 1
continue
# If content contains images, treat as simple table (single-column)
has_images = any("![" in cl for cl in block_content)
if has_images:
result.append("")
for cl in block_content:
cl = cl.strip()
if cl:
result.append(cl)
result.append("")
i = j
continue
# Check if first line is a language hint (e.g., " JSON\", " Plain Text\")
has_lang_hint = False
if block_content:
first = block_content[0].strip()
first_clean = first.rstrip("\\").strip()
if first_clean.lower() in known_langs:
lang_hint = first_clean.lower()
if lang_hint in ("plain text", "text", "plaintext"):
lang_hint = "" # No language tag for plain text
has_lang_hint = True
block_content = block_content[1:]
# Clean content: remove leading 2-space indent, fix escaped quotes
cleaned = []
for cl in block_content:
if cl.startswith(" "):
cl = cl[2:]
cl = cl.replace('\\"', '"')
if cl.endswith("\\"):
cl = cl[:-1]
cleaned.append(cl)
# Remove trailing/leading empty lines
while cleaned and not cleaned[-1].strip():
cleaned.pop()
while cleaned and not cleaned[0].strip():
cleaned.pop(0)
if cleaned:
stats.code_blocks_fixed += 1
# Decide: code block vs blockquote
if has_lang_hint or _is_code_content(cleaned):
# Code block — try to pretty-print JSON
code_lines = cleaned
if lang_hint == "json":
try:
raw = "\n".join(cleaned)
parsed = json.loads(raw)
code_lines = json.dumps(parsed, indent=2, ensure_ascii=False).split("\n")
except (json.JSONDecodeError, ValueError):
pass # Keep original if not valid JSON
result.append("")
result.append(f"```{lang_hint}")
result.extend(code_lines)
result.append("```")
result.append("")
else:
# Note/callout -> blockquote
result.append("")
for cl in cleaned:
if cl.strip():
result.append(f"> {cl}")
else:
result.append(">")
result.append("")
i = j
else:
result.append(line)
i += 1
return "\n".join(result)
def _fix_escaped_brackets(text: str, stats: PostProcessStats) -> str:
r"""Fix pandoc's escaped brackets: \[ -> [, \] -> ]."""
count = len(_RE_ESCAPED_BRACKET.findall(text))
if count:
stats.escaped_brackets_fixed = count
text = _RE_ESCAPED_BRACKET.sub(r"\1", text)
return text
def _fix_double_bracket_links(text: str, stats: PostProcessStats) -> str:
"""Fix double-bracket links: [[text]{.underline}](url) -> [text](url)."""
count = 0
def _replace_link(m: re.Match) -> str:
nonlocal count
count += 1
return f"[{m.group(1)}]({m.group(2)})"
text = _RE_DOUBLE_BRACKET_ATTR_LINK.sub(_replace_link, text)
text = _RE_DOUBLE_BRACKET_CLOSED.sub(_replace_link, text)
text = _RE_DOUBLE_BRACKET_LINK.sub(_replace_link, text)
stats.double_brackets_fixed = count
return text
def _fix_cjk_bold_spacing(text: str) -> str:
"""Add space around **bold** spans that contain CJK characters.
DOCX uses run-level styling for bold — no spaces between runs in CJK text.
Markdown renderers need whitespace around ** to recognize bold boundaries.
Rule: if a **content** span contains any CJK character, ensure both sides
have a space (unless already spaced or at line boundary). This handles:
- CJK directly touching **: 打开**飞书** → 打开 **飞书**
- Emoji touching **: **密码】**➡️ → **密码】** ➡️
- Already spaced: 已有 **粗体** → unchanged
- English bold: English **bold** text → unchanged
"""
result = []
last_end = 0
for m in _RE_BOLD_PAIR.finditer(text):
start, end = m.start(), m.end()
content = m.group(1)
result.append(text[last_end:start])
# Only add spaces for bold spans containing CJK
if _RE_CJK_PUNCT.search(content):
# Space before ** if previous char is not whitespace
if start > 0 and text[start - 1] not in (' ', '\t', '\n'):
result.append(' ')
result.append(m.group(0))
# Space after ** if next char is not whitespace
if end < len(text) and text[end] not in (' ', '\t', '\n'):
result.append(' ')
else:
result.append(m.group(0))
last_end = end
result.append(text[last_end:])
return ''.join(result)
def _cleanup_excessive_blank_lines(text: str) -> str:
"""Collapse 3+ consecutive blank lines to 2."""
return re.sub(r"\n{4,}", "\n\n\n", text)
def postprocess_docx_markdown(
text: str,
assets_dir: Optional[Path] = None,
) -> tuple[str, PostProcessStats]:
"""Apply all DOCX-specific post-processing to pandoc markdown output.
Returns (cleaned_text, stats).
"""
stats = PostProcessStats()
# Order matters: grid tables first (they contain images with attributes)
text = _convert_grid_tables(text, stats)
text = _fix_image_paths(text, assets_dir, stats)
text = _clean_pandoc_attributes(text, stats)
text = _fix_code_blocks(text, stats)
text = _fix_double_bracket_links(text, stats)
text = _fix_escaped_brackets(text, stats)
text = _fix_cjk_bold_spacing(text)
text = _cleanup_excessive_blank_lines(text)
return text, stats
# ── DOCX deep parsing (python-docx) ──────────────────────────────────────────
def convert_with_docx_deep(
file_path: Path, assets_dir: Optional[Path] = None
) -> ConversionResult:
"""Convert DOCX using python-docx direct parsing (experimental).
More precise than pandoc for:
- Table structure preservation
- Comment extraction
- Image extraction with position info
"""
try:
from docx import Document
from docx.opc.constants import RELATIONSHIP_TYPE as RT
except ImportError:
return ConversionResult(
markdown="",
tool="docx-deep",
success=False,
error="python-docx not installed. Run: pip install python-docx",
)
try:
doc = Document(str(file_path))
md_parts = []
images = []
image_counter = 0
# Extract images from docx zip
if assets_dir:
assets_dir.mkdir(parents=True, exist_ok=True)
media_dir = assets_dir / "media"
media_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(str(file_path), "r") as zf:
for name in zf.namelist():
if name.startswith("word/media/"):
img_name = Path(name).name
img_dest = media_dir / img_name
with zf.open(name) as src, open(img_dest, "wb") as dst:
dst.write(src.read())
images.append(str(img_dest))
# Process paragraphs
for para in doc.paragraphs:
style_name = para.style.name if para.style else ""
text = para.text.strip()
if not text:
md_parts.append("")
continue
# Headings
if style_name.startswith("Heading"):
try:
level = int(style_name.split()[-1])
except (ValueError, IndexError):
level = 1
md_parts.append(f"{'#' * level} {text}")
md_parts.append("")
continue
# Check for bold-only paragraphs (often sub-headings in Chinese docs)
all_bold = all(run.bold for run in para.runs if run.text.strip())
if all_bold and para.runs and len(text) < 100:
md_parts.append(f"**{text}**")
md_parts.append("")
continue
# Regular paragraph
md_parts.append(text)
md_parts.append("")
# Process tables
for table in doc.tables:
md_parts.append("")
rows = table.rows
if not rows:
continue
# Header row
header_cells = [cell.text.strip() for cell in rows[0].cells]
md_parts.append("| " + " | ".join(header_cells) + " |")
md_parts.append("| " + " | ".join(["---"] * len(header_cells)) + " |")
# Data rows
for row in rows[1:]:
cells = [cell.text.strip() for cell in row.cells]
md_parts.append("| " + " | ".join(cells) + " |")
md_parts.append("")
markdown = "\n".join(md_parts)
return ConversionResult(
markdown=markdown,
tool="docx-deep",
images=images,
success=True,
)
except Exception as e:
return ConversionResult(
markdown="", tool="docx-deep", success=False, error=str(e)
)
# ── Existing tool converters ─────────────────────────────────────────────────
def check_tool_available(tool: str) -> bool:
"""Check if a conversion tool is available."""
if tool == "pymupdf4llm":
try:
import pymupdf4llm
return True
except ImportError:
return False
elif tool == "markitdown":
try:
import markitdown
return True
except ImportError:
return False
elif tool == "pandoc":
return shutil.which("pandoc") is not None
elif tool == "docx-deep":
try:
from docx import Document
return True
except ImportError:
return False
return False
def select_tools(file_path: Path, mode: str) -> list[str]:
"""Select conversion tools based on file type and mode."""
ext = file_path.suffix.lower()
# Tool preferences by format
tool_map = {
".pdf": {
"quick": ["pymupdf4llm", "markitdown"], # fallback order
"heavy": ["pymupdf4llm", "markitdown"],
},
".docx": {
"quick": ["pandoc", "markitdown"],
"heavy": ["pandoc", "markitdown"],
},
".doc": {
"quick": ["pandoc", "markitdown"],
"heavy": ["pandoc", "markitdown"],
},
".pptx": {
"quick": ["markitdown", "pandoc"],
"heavy": ["markitdown", "pandoc"],
},
".xlsx": {
"quick": ["markitdown"],
"heavy": ["markitdown"],
},
}
tools = tool_map.get(ext, {"quick": ["markitdown"], "heavy": ["markitdown"]})
if mode == "quick":
# Return first available tool
for tool in tools["quick"]:
if check_tool_available(tool):
return [tool]
return []
else: # heavy
# Return all available tools
return [t for t in tools["heavy"] if check_tool_available(t)]
def convert_with_pymupdf4llm(
file_path: Path, assets_dir: Optional[Path] = None
) -> ConversionResult:
"""Convert using PyMuPDF4LLM (best for PDFs)."""
try:
import pymupdf4llm
kwargs = {}
images = []
if assets_dir:
assets_dir.mkdir(parents=True, exist_ok=True)
kwargs["write_images"] = True
kwargs["image_path"] = str(assets_dir)
kwargs["dpi"] = 150
# Use best table detection strategy
kwargs["table_strategy"] = "lines_strict"
md_text = pymupdf4llm.to_markdown(str(file_path), **kwargs)
if assets_dir:
images = _collect_images(assets_dir)
return ConversionResult(
markdown=md_text, tool="pymupdf4llm", images=images, success=True
)
except Exception as e:
return ConversionResult(
markdown="", tool="pymupdf4llm", success=False, error=str(e)
)
def convert_with_markitdown(
file_path: Path, assets_dir: Optional[Path] = None
) -> ConversionResult:
"""Convert using markitdown."""
try:
# markitdown CLI approach
result = subprocess.run(
["markitdown", str(file_path)],
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
return ConversionResult(
markdown="",
tool="markitdown",
success=False,
error=result.stderr,
)
return ConversionResult(
markdown=result.stdout, tool="markitdown", success=True
)
except FileNotFoundError:
# Try Python API
try:
from markitdown import MarkItDown
md = MarkItDown()
result = md.convert(str(file_path))
return ConversionResult(
markdown=result.text_content, tool="markitdown", success=True
)
except Exception as e:
return ConversionResult(
markdown="", tool="markitdown", success=False, error=str(e)
)
except Exception as e:
return ConversionResult(
markdown="", tool="markitdown", success=False, error=str(e)
)
def convert_with_pandoc(
file_path: Path, assets_dir: Optional[Path] = None
) -> ConversionResult:
"""Convert using pandoc.
Pandoc's --extract-media=DIR creates a media/ subdirectory inside DIR.
We point --extract-media at assets_dir's parent so pandoc's media/
subdirectory lands exactly at assets_dir (when assets_dir ends with 'media'),
or we use a temp dir and move files afterward.
"""
try:
cmd = ["pandoc", str(file_path), "-t", "markdown", "--wrap=none"]
extract_dir = None
if assets_dir:
assets_dir.mkdir(parents=True, exist_ok=True)
# Pandoc always creates a media/ subdirectory inside --extract-media.
# Point it at the parent so media/ lands at assets_dir.
if assets_dir.name == "media":
extract_dir = assets_dir.parent
else:
extract_dir = assets_dir
cmd.extend(["--extract-media", str(extract_dir)])
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
return ConversionResult(
markdown="", tool="pandoc", success=False, error=result.stderr
)
md = result.stdout
# Convert absolute image paths to relative paths based on output location
if extract_dir:
abs_media = str(extract_dir / "media")
# Replace absolute paths with relative 'media/' prefix
md = md.replace(abs_media + "/", "media/")
images = _collect_images(assets_dir) if assets_dir else []
return ConversionResult(
markdown=md, tool="pandoc", images=images, success=True
)
except Exception as e:
return ConversionResult(
markdown="", tool="pandoc", success=False, error=str(e)
)
def convert_single(
file_path: Path, tool: str, assets_dir: Optional[Path] = None
) -> ConversionResult:
"""Run a single conversion tool."""
converters = {
"pymupdf4llm": convert_with_pymupdf4llm,
"markitdown": convert_with_markitdown,
"pandoc": convert_with_pandoc,
"docx-deep": convert_with_docx_deep,
}
converter = converters.get(tool)
if not converter:
return ConversionResult(
markdown="", tool=tool, success=False, error=f"Unknown tool: {tool}"
)
return converter(file_path, assets_dir)
def merge_results(results: list[ConversionResult]) -> ConversionResult:
"""Merge results from multiple tools, selecting best segments."""
if not results:
return ConversionResult(markdown="", tool="none", success=False)
# Filter successful results
successful = [r for r in results if r.success and r.markdown.strip()]
if not successful:
# Return first error
return results[0] if results else ConversionResult(
markdown="", tool="none", success=False
)
if len(successful) == 1:
return successful[0]
# Multiple successful results - merge them
# Strategy: Compare key metrics and select best
best = successful[0]
best_score = score_markdown(best.markdown)
for result in successful[1:]:
score = score_markdown(result.markdown)
if score > best_score:
best = result
best_score = score
# Merge images from all results
all_images = []
seen = set()
for result in successful:
for img in result.images:
if img not in seen:
all_images.append(img)
seen.add(img)
best.images = all_images
best.tool = f"merged({','.join(r.tool for r in successful)})"
return best
def score_markdown(md: str) -> float:
"""Score markdown quality for comparison."""
score = 0.0
# Length (more content is generally better)
score += min(len(md) / 10000, 5.0) # Cap at 5 points
# Tables (proper markdown tables)
table_count = md.count("|---|") + md.count("| ---")
score += min(table_count * 0.5, 3.0)
# Images (referenced images)
image_count = md.count("![")
score += min(image_count * 0.3, 2.0)
# Headings (proper hierarchy)
h1_count = md.count("\n# ")
h2_count = md.count("\n## ")
h3_count = md.count("\n### ")
if h1_count > 0 and h2_count >= h1_count:
score += 1.0 # Good hierarchy
# Lists (structured content)
list_count = md.count("\n- ") + md.count("\n* ") + md.count("\n1. ")
score += min(list_count * 0.1, 2.0)
# Penalize pandoc artifacts (grid tables, attributes)
artifact_count = md.count("+:---") + md.count("+---+")
artifact_count += md.count('{width="') + md.count("{.underline}")
score -= artifact_count * 0.5
return score
def main():
parser = argparse.ArgumentParser(
description="Convert documents to markdown with multi-tool orchestration",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Quick mode (default)
python convert.py document.pdf -o output.md
# Heavy mode (best quality)
python convert.py document.pdf -o output.md --heavy
# DOCX deep mode (python-docx parsing)
python convert.py document.docx -o output.md --docx-deep
# With custom assets directory
python convert.py document.pdf -o output.md --assets-dir ./images
""",
)
parser.add_argument("input", type=Path, nargs="?", help="Input document path")
parser.add_argument(
"-o", "--output", type=Path, help="Output markdown file"
)
parser.add_argument(
"--heavy",
action="store_true",
help="Enable Heavy Mode (multi-tool, best quality)",
)
parser.add_argument(
"--docx-deep",
action="store_true",
help="Use python-docx direct parsing (experimental, DOCX only)",
)
parser.add_argument(
"--no-postprocess",
action="store_true",
help="Disable DOCX post-processing (keep raw pandoc output)",
)
parser.add_argument(
"--assets-dir",
type=Path,
default=None,
help="Directory for extracted images (default: <output>_assets/)",
)
parser.add_argument(
"--tool",
choices=["pymupdf4llm", "markitdown", "pandoc", "docx-deep"],
help="Force specific tool (overrides auto-selection)",
)
parser.add_argument(
"--list-tools",
action="store_true",
help="List available tools and exit",
)
args = parser.parse_args()
# List tools mode
if args.list_tools:
tools = ["pymupdf4llm", "markitdown", "pandoc", "docx-deep"]
print("Available conversion tools:")
for tool in tools:
status = "+" if check_tool_available(tool) else "-"
print(f" {status} {tool}")
sys.exit(0)
# Validate input
if args.input is None:
parser.error("the following arguments are required: input")
if not args.input.exists():
print(f"Error: Input file not found: {args.input}", file=sys.stderr)
sys.exit(1)
# Determine output path
output_path = args.output or args.input.with_suffix(".md")
# Determine assets directory
assets_dir = args.assets_dir
if assets_dir is None:
assets_dir = output_path.parent / f"{output_path.stem}_assets"
is_docx = args.input.suffix.lower() in (".docx", ".doc")
# Handle --docx-deep mode
if args.docx_deep:
if not is_docx:
print("Error: --docx-deep only works with DOCX files.", file=sys.stderr)
sys.exit(1)
tools = ["docx-deep"]
elif args.tool:
tools = [args.tool] if check_tool_available(args.tool) else []
else:
# Select tools
mode = "heavy" if args.heavy else "quick"
tools = select_tools(args.input, mode)
mode = "docx-deep" if args.docx_deep else ("heavy" if args.heavy else "quick")
if not tools:
print("Error: No conversion tools available.", file=sys.stderr)
print("Install with:", file=sys.stderr)
print(" pip install pymupdf4llm", file=sys.stderr)
print(" uv tool install markitdown[pdf]", file=sys.stderr)
print(" brew install pandoc", file=sys.stderr)
sys.exit(1)
print(f"Converting: {args.input}")
print(f"Mode: {mode.upper()}")
print(f"Tools: {', '.join(tools)}")
# Run conversions
results = []
for tool in tools:
print(f" Running {tool}...", end=" ", flush=True)
# Use separate assets dirs for each tool in heavy mode
tool_assets = None
if assets_dir and mode == "heavy" and len(tools) > 1:
tool_assets = assets_dir / tool
elif assets_dir:
tool_assets = assets_dir
result = convert_single(args.input, tool, tool_assets)
results.append(result)
if result.success:
print(f"ok ({len(result.markdown):,} chars, {len(result.images)} images)")
else:
print(f"FAIL ({result.error[:50]}...)")
# Merge results if heavy mode
if mode == "heavy" and len(results) > 1:
print(" Merging results...", end=" ", flush=True)
final = merge_results(results)
print(f"ok (using {final.tool})")
else:
final = merge_results(results)
if not final.success:
print(f"Error: Conversion failed: {final.error}", file=sys.stderr)
sys.exit(1)
# Apply DOCX post-processing
if is_docx and not args.no_postprocess and "pandoc" in final.tool:
print(" Post-processing DOCX output...", end=" ", flush=True)
final.markdown, pp_stats = postprocess_docx_markdown(
final.markdown, assets_dir
)
print(f"ok ({pp_stats.summary()})")
# Write output
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(final.markdown)
print(f"\nOutput: {output_path}")
print(f" Size: {len(final.markdown):,} characters")
if final.images:
print(f" Images: {len(final.images)} extracted")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Extract images from PDF files with metadata using PyMuPDF.
Features:
- Extracts all images with page and position metadata
- Generates JSON metadata file for each image
- Supports markdown reference generation
- Optional DPI control for quality
Usage:
uv run --with pymupdf scripts/extract_pdf_images.py document.pdf
uv run --with pymupdf scripts/extract_pdf_images.py document.pdf -o ./images
uv run --with pymupdf scripts/extract_pdf_images.py document.pdf --markdown refs.md
Examples:
# Basic extraction
uv run --with pymupdf scripts/extract_pdf_images.py document.pdf
# With custom output and markdown references
uv run --with pymupdf scripts/extract_pdf_images.py doc.pdf -o assets --markdown images.md
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional
@dataclass
class ImageMetadata:
"""Metadata for an extracted image."""
filename: str
page: int # 1-indexed
index: int # Image index on page (1-indexed)
width: int # Original width in pixels
height: int # Original height in pixels
x: float # X position on page (points)
y: float # Y position on page (points)
bbox_width: float # Width on page (points)
bbox_height: float # Height on page (points)
size_bytes: int
format: str # png, jpg, etc.
colorspace: str # RGB, CMYK, Gray
bits_per_component: int
def extract_images(
pdf_path: Path,
output_dir: Path,
markdown_file: Optional[Path] = None
) -> list[ImageMetadata]:
"""
Extract all images from a PDF file with metadata.
Args:
pdf_path: Path to the PDF file
output_dir: Directory to save extracted images
markdown_file: Optional path to write markdown references
Returns:
List of ImageMetadata for each extracted image
"""
try:
import fitz # PyMuPDF
except ImportError:
print("Error: PyMuPDF not installed. Run with:")
print(' uv run --with pymupdf scripts/extract_pdf_images.py <pdf_path>')
sys.exit(1)
output_dir.mkdir(parents=True, exist_ok=True)
doc = fitz.open(str(pdf_path))
extracted: list[ImageMetadata] = []
markdown_refs: list[str] = []
for page_num in range(len(doc)):
page = doc[page_num]
image_list = page.get_images(full=True)
for img_index, img_info in enumerate(image_list):
xref = img_info[0]
try:
base_image = doc.extract_image(xref)
except Exception as e:
print(f" Warning: Could not extract image xref={xref}: {e}")
continue
image_bytes = base_image["image"]
image_ext = base_image["ext"]
width = base_image.get("width", 0)
height = base_image.get("height", 0)
colorspace = base_image.get("colorspace", 0)
bpc = base_image.get("bpc", 8)
# Map colorspace number to name
cs_names = {1: "Gray", 3: "RGB", 4: "CMYK"}
cs_name = cs_names.get(colorspace, f"Unknown({colorspace})")
# Get image position on page
# img_info: (xref, smask, width, height, bpc, colorspace, alt, name, filter, referencer)
# We need to find the image rect on page
bbox_x, bbox_y, bbox_w, bbox_h = 0.0, 0.0, 0.0, 0.0
# Search for image instances on page
for img_block in page.get_images():
if img_block[0] == xref:
# Found matching image, try to get its rect
rects = page.get_image_rects(img_block)
if rects:
rect = rects[0] # Use first occurrence
bbox_x = rect.x0
bbox_y = rect.y0
bbox_w = rect.width
bbox_h = rect.height
break
# Create descriptive filename
img_filename = f"img_page{page_num + 1}_{img_index + 1}.{image_ext}"
img_path = output_dir / img_filename
# Save image
with open(img_path, "wb") as f:
f.write(image_bytes)
# Create metadata
metadata = ImageMetadata(
filename=img_filename,
page=page_num + 1,
index=img_index + 1,
width=width,
height=height,
x=round(bbox_x, 2),
y=round(bbox_y, 2),
bbox_width=round(bbox_w, 2),
bbox_height=round(bbox_h, 2),
size_bytes=len(image_bytes),
format=image_ext,
colorspace=cs_name,
bits_per_component=bpc
)
extracted.append(metadata)
# Generate markdown reference
alt_text = f"Image from page {page_num + 1}"
md_ref = f""
markdown_refs.append(f"<!-- Page {page_num + 1}, Position: ({bbox_x:.0f}, {bbox_y:.0f}) -->\n{md_ref}")
print(f" ✓ {img_filename} ({width}x{height}, {len(image_bytes):,} bytes)")
doc.close()
# Write metadata JSON
metadata_path = output_dir / "images_metadata.json"
with open(metadata_path, "w") as f:
json.dump(
{
"source": str(pdf_path),
"image_count": len(extracted),
"images": [asdict(m) for m in extracted]
},
f,
indent=2
)
print(f"\n📋 Metadata: {metadata_path}")
# Write markdown references if requested
if markdown_file and markdown_refs:
markdown_content = f"# Images from {pdf_path.name}\n\n"
markdown_content += "\n\n".join(markdown_refs)
markdown_file.parent.mkdir(parents=True, exist_ok=True)
markdown_file.write_text(markdown_content)
print(f"📝 Markdown refs: {markdown_file}")
print(f"\n✅ Total: {len(extracted)} images extracted to {output_dir}/")
return extracted
def main():
parser = argparse.ArgumentParser(
description="Extract images from PDF files with metadata",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic extraction
uv run --with pymupdf scripts/extract_pdf_images.py document.pdf
# Custom output directory
uv run --with pymupdf scripts/extract_pdf_images.py doc.pdf -o ./images
# With markdown references
uv run --with pymupdf scripts/extract_pdf_images.py doc.pdf --markdown refs.md
Output:
Images are saved with descriptive names: img_page1_1.png, img_page2_1.jpg
Metadata is saved to: images_metadata.json
"""
)
parser.add_argument(
"pdf_path",
type=Path,
help="Path to the PDF file"
)
parser.add_argument(
"-o", "--output",
type=Path,
default=Path("assets"),
help="Directory to save images (default: ./assets)"
)
parser.add_argument(
"--markdown",
type=Path,
help="Generate markdown file with image references"
)
parser.add_argument(
"--json",
action="store_true",
help="Output metadata as JSON to stdout"
)
args = parser.parse_args()
if not args.pdf_path.exists():
print(f"Error: File not found: {args.pdf_path}", file=sys.stderr)
sys.exit(1)
print(f"📄 Extracting images from: {args.pdf_path}")
extracted = extract_images(
args.pdf_path,
args.output,
args.markdown
)
if args.json:
print(json.dumps([asdict(m) for m in extracted], indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Multi-tool markdown output merger with segment-level comparison.
Merges markdown outputs from multiple conversion tools by selecting
the best version of each segment (tables, images, headings, paragraphs).
Usage:
python merge_outputs.py output1.md output2.md -o merged.md
python merge_outputs.py --from-json results.json -o merged.md
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class Segment:
"""A segment of markdown content."""
type: str # 'heading', 'table', 'image', 'list', 'paragraph', 'code'
content: str
level: int = 0 # For headings
score: float = 0.0
@dataclass
class MergeResult:
"""Result from merging multiple markdown files."""
markdown: str
sources: list[str] = field(default_factory=list)
segment_sources: dict = field(default_factory=dict) # segment_idx -> source
def parse_segments(markdown: str) -> list[Segment]:
"""Parse markdown into typed segments."""
segments = []
lines = markdown.split('\n')
current_segment = []
current_type = 'paragraph'
current_level = 0
in_code_block = False
in_table = False
def flush_segment():
nonlocal current_segment, current_type, current_level
if current_segment:
content = '\n'.join(current_segment).strip()
if content:
segments.append(Segment(
type=current_type,
content=content,
level=current_level
))
current_segment = []
current_type = 'paragraph'
current_level = 0
for line in lines:
# Code block detection
if line.startswith('```'):
if in_code_block:
current_segment.append(line)
flush_segment()
in_code_block = False
continue
else:
flush_segment()
in_code_block = True
current_type = 'code'
current_segment.append(line)
continue
if in_code_block:
current_segment.append(line)
continue
# Heading detection
heading_match = re.match(r'^(#{1,6})\s+(.+)$', line)
if heading_match:
flush_segment()
current_type = 'heading'
current_level = len(heading_match.group(1))
current_segment.append(line)
flush_segment()
continue
# Table detection
if '|' in line and re.match(r'^\s*\|.*\|\s*$', line):
if not in_table:
flush_segment()
in_table = True
current_type = 'table'
current_segment.append(line)
continue
elif in_table:
flush_segment()
in_table = False
# Image detection
if re.match(r'!\[.*\]\(.*\)', line):
flush_segment()
current_type = 'image'
current_segment.append(line)
flush_segment()
continue
# List detection
if re.match(r'^[\s]*[-*+]\s+', line) or re.match(r'^[\s]*\d+\.\s+', line):
if current_type != 'list':
flush_segment()
current_type = 'list'
current_segment.append(line)
continue
elif current_type == 'list' and line.strip() == '':
flush_segment()
continue
# Empty line - potential paragraph break
if line.strip() == '':
if current_type == 'paragraph' and current_segment:
flush_segment()
continue
# Default: paragraph
if current_type not in ['list']:
current_type = 'paragraph'
current_segment.append(line)
flush_segment()
return segments
def score_segment(segment: Segment) -> float:
"""Score a segment for quality comparison."""
score = 0.0
content = segment.content
if segment.type == 'table':
# Count rows and columns
rows = [l for l in content.split('\n') if '|' in l]
if rows:
cols = rows[0].count('|') - 1
score += len(rows) * 0.5 # More rows = better
score += cols * 0.3 # More columns = better
# Penalize separator-only tables
if all(re.match(r'^[\s|:-]+$', r) for r in rows):
score -= 5.0
# Bonus for proper header separator
if len(rows) > 1 and re.match(r'^[\s|:-]+$', rows[1]):
score += 1.0
elif segment.type == 'heading':
# Prefer proper heading hierarchy
score += 1.0
# Penalize very long headings
if len(content) > 100:
score -= 0.5
elif segment.type == 'image':
# Prefer images with alt text
if re.search(r'!\[.+\]', content):
score += 1.0
# Prefer local paths over base64
if 'data:image' not in content:
score += 0.5
elif segment.type == 'list':
items = re.findall(r'^[\s]*[-*+\d.]+\s+', content, re.MULTILINE)
score += len(items) * 0.3
# Bonus for nested lists
if re.search(r'^\s{2,}[-*+]', content, re.MULTILINE):
score += 0.5
elif segment.type == 'code':
lines = content.split('\n')
score += min(len(lines) * 0.2, 3.0)
# Bonus for language specification
if re.match(r'^```\w+', content):
score += 0.5
else: # paragraph
words = len(content.split())
score += min(words * 0.05, 2.0)
# Penalize very short paragraphs
if words < 5:
score -= 0.5
return score
def find_matching_segment(
segment: Segment,
candidates: list[Segment],
used_indices: set
) -> Optional[int]:
"""Find a matching segment in candidates by type and similarity."""
best_match = None
best_similarity = 0.3 # Minimum threshold
for i, candidate in enumerate(candidates):
if i in used_indices:
continue
if candidate.type != segment.type:
continue
# Calculate similarity
if segment.type == 'heading':
# Compare heading text (ignore # symbols)
s1 = re.sub(r'^#+\s*', '', segment.content).lower()
s2 = re.sub(r'^#+\s*', '', candidate.content).lower()
similarity = _text_similarity(s1, s2)
elif segment.type == 'table':
# Compare first row (header)
h1 = segment.content.split('\n')[0] if segment.content else ''
h2 = candidate.content.split('\n')[0] if candidate.content else ''
similarity = _text_similarity(h1, h2)
else:
# Compare content directly
similarity = _text_similarity(segment.content, candidate.content)
if similarity > best_similarity:
best_similarity = similarity
best_match = i
return best_match
def _text_similarity(s1: str, s2: str) -> float:
"""Calculate simple text similarity (Jaccard on words)."""
if not s1 or not s2:
return 0.0
words1 = set(s1.lower().split())
words2 = set(s2.lower().split())
if not words1 or not words2:
return 0.0
intersection = len(words1 & words2)
union = len(words1 | words2)
return intersection / union if union > 0 else 0.0
def merge_markdown_files(
files: list[Path],
source_names: Optional[list[str]] = None
) -> MergeResult:
"""Merge multiple markdown files by selecting best segments."""
if not files:
return MergeResult(markdown="", sources=[])
if source_names is None:
source_names = [f.stem for f in files]
# Parse all files into segments
all_segments = []
for i, file_path in enumerate(files):
content = file_path.read_text()
segments = parse_segments(content)
# Score each segment
for seg in segments:
seg.score = score_segment(seg)
all_segments.append((source_names[i], segments))
if len(all_segments) == 1:
return MergeResult(
markdown=files[0].read_text(),
sources=[source_names[0]]
)
# Use first file as base structure
base_name, base_segments = all_segments[0]
merged_segments = []
segment_sources = {}
for i, base_seg in enumerate(base_segments):
best_segment = base_seg
best_source = base_name
# Find matching segments in other files
for other_name, other_segments in all_segments[1:]:
used = set()
match_idx = find_matching_segment(base_seg, other_segments, used)
if match_idx is not None:
other_seg = other_segments[match_idx]
if other_seg.score > best_segment.score:
best_segment = other_seg
best_source = other_name
merged_segments.append(best_segment)
segment_sources[i] = best_source
# Check for segments in other files that weren't matched
# (content that only appears in secondary sources)
base_used = set(range(len(base_segments)))
for other_name, other_segments in all_segments[1:]:
for j, other_seg in enumerate(other_segments):
match_idx = find_matching_segment(other_seg, base_segments, set())
if match_idx is None and other_seg.score > 0.5:
# This segment doesn't exist in base - consider adding
merged_segments.append(other_seg)
segment_sources[len(merged_segments) - 1] = other_name
# Reconstruct markdown
merged_md = '\n\n'.join(seg.content for seg in merged_segments)
return MergeResult(
markdown=merged_md,
sources=source_names,
segment_sources=segment_sources
)
def merge_from_json(json_path: Path) -> MergeResult:
"""Merge from JSON results file (from convert.py)."""
with open(json_path) as f:
data = json.load(f)
results = data.get('results', [])
if not results:
return MergeResult(markdown="", sources=[])
# Filter successful results
successful = [r for r in results if r.get('success') and r.get('markdown')]
if not successful:
return MergeResult(markdown="", sources=[])
if len(successful) == 1:
return MergeResult(
markdown=successful[0]['markdown'],
sources=[successful[0]['tool']]
)
# Parse and merge
all_segments = []
for result in successful:
tool = result['tool']
segments = parse_segments(result['markdown'])
for seg in segments:
seg.score = score_segment(seg)
all_segments.append((tool, segments))
# Same merge logic as merge_markdown_files
base_name, base_segments = all_segments[0]
merged_segments = []
segment_sources = {}
for i, base_seg in enumerate(base_segments):
best_segment = base_seg
best_source = base_name
for other_name, other_segments in all_segments[1:]:
match_idx = find_matching_segment(base_seg, other_segments, set())
if match_idx is not None:
other_seg = other_segments[match_idx]
if other_seg.score > best_segment.score:
best_segment = other_seg
best_source = other_name
merged_segments.append(best_segment)
segment_sources[i] = best_source
merged_md = '\n\n'.join(seg.content for seg in merged_segments)
return MergeResult(
markdown=merged_md,
sources=[r['tool'] for r in successful],
segment_sources=segment_sources
)
def main():
parser = argparse.ArgumentParser(
description="Merge markdown outputs from multiple conversion tools"
)
parser.add_argument(
"inputs",
nargs="*",
type=Path,
help="Input markdown files to merge"
)
parser.add_argument(
"-o", "--output",
type=Path,
help="Output merged markdown file"
)
parser.add_argument(
"--from-json",
type=Path,
help="Merge from JSON results file (from convert.py)"
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show segment source attribution"
)
args = parser.parse_args()
if args.from_json:
result = merge_from_json(args.from_json)
elif args.inputs:
# Validate inputs
for f in args.inputs:
if not f.exists():
print(f"Error: File not found: {f}", file=sys.stderr)
sys.exit(1)
result = merge_markdown_files(args.inputs)
else:
parser.error("Either input files or --from-json is required")
if not result.markdown:
print("Error: No content to merge", file=sys.stderr)
sys.exit(1)
# Output
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(result.markdown)
print(f"Merged output: {args.output}")
print(f"Sources: {', '.join(result.sources)}")
else:
print(result.markdown)
if args.verbose and result.segment_sources:
print("\n--- Segment Attribution ---", file=sys.stderr)
for idx, source in result.segment_sources.items():
print(f" Segment {idx}: {source}", file=sys.stderr)
if __name__ == "__main__":
main()
"""Tests for doc-to-markdown convert.py post-processing functions.
Run: uv run pytest scripts/test_convert.py -v
"""
import pytest
import re
import sys
from pathlib import Path
# Import the module under test
sys.path.insert(0, str(Path(__file__).parent))
from convert import (
_fix_cjk_bold_spacing,
_build_pipe_table,
_collect_images,
PostProcessStats,
postprocess_docx_markdown,
)
# ── CJK Bold Spacing ─────────────────────────────────────────────────────────
class TestCjkBoldSpacing:
"""Test _fix_cjk_bold_spacing: spaces between **bold** and CJK chars."""
def test_bold_followed_by_cjk_punctuation(self):
"""**text** directly touching CJK colon → add space after **."""
inp = "**打开阶跃开放平台链接**:https://platform.stepfun.com/"
out = _fix_cjk_bold_spacing(inp)
assert "**打开阶跃开放平台链接** :" in out
def test_cjk_before_bold(self):
"""CJK char directly before ** → add space before **."""
assert _fix_cjk_bold_spacing("可用**手机号**进行") == "可用 **手机号** 进行"
def test_bold_with_emoji_neighbor(self):
"""**text** touching emoji ➡️ → still add space (CJK content rule)."""
inp = "点击**【接口密码】**➡️**【创建新的密钥**】"
out = _fix_cjk_bold_spacing(inp)
# Each CJK-containing bold span should have spaces on both sides
assert "点击 **【接口密码】** ➡️" in out
assert "➡️ **【创建新的密钥**" in out
def test_full_emoji_line(self):
"""Complete line with emoji separators between bold spans."""
inp = "点击**【接口密码】**➡️**【创建新的密钥**】➡️**【输入密钥名称】**(输入你想取的名称),生成API Key"
out = _fix_cjk_bold_spacing(inp)
assert "点击 **【接口密码】** ➡️" in out
assert "**【输入密钥名称】** (输入" in out
def test_bold_between_cjk(self):
"""CJK **text** CJK → spaces on both sides."""
assert _fix_cjk_bold_spacing("打开**飞书**,就可以") == "打开 **飞书** ,就可以"
def test_bold_with_chinese_quotes(self):
"""Bold containing Chinese quotes."""
inp = '有个**"企鹅戴龙虾头套的机器人"**,开始'
out = _fix_cjk_bold_spacing(inp)
assert '**"企鹅戴龙虾头套的机器人"** ,' in out
def test_multiple_bold_spans(self):
"""Multiple bold spans in one line."""
assert _fix_cjk_bold_spacing("这是**测试**和**验证**的效果") == "这是 **测试** 和 **验证** 的效果"
def test_already_spaced(self):
"""Already has spaces → no double spaces."""
inp = "已有空格 **粗体** 不需要再加"
assert _fix_cjk_bold_spacing(inp) == inp
def test_english_unchanged(self):
"""English bold text should not be modified."""
inp = "English **bold** text should not change"
assert _fix_cjk_bold_spacing(inp) == inp
def test_line_start_bold(self):
"""Bold at line start followed by CJK."""
assert _fix_cjk_bold_spacing("**重要**内容") == "**重要** 内容"
def test_line_start_bold_standalone(self):
"""Bold at line start with no CJK neighbor → no change."""
assert _fix_cjk_bold_spacing("**这是纯粗体不需要改**") == "**这是纯粗体不需要改**"
def test_no_bold(self):
"""Text without bold markers → unchanged."""
inp = "这是普通文本,没有粗体"
assert _fix_cjk_bold_spacing(inp) == inp
def test_empty_string(self):
assert _fix_cjk_bold_spacing("") == ""
def test_bold_at_line_end(self):
"""Bold at line end → no trailing space needed."""
assert _fix_cjk_bold_spacing("内容是**粗体**") == "内容是 **粗体**"
def test_mixed_cjk_and_english_bold(self):
"""English bold between CJK → no change (no CJK in content)."""
inp = "请使用 **API Key** 进行认证"
assert _fix_cjk_bold_spacing(inp) == inp
# ── Pipe Table Builder ────────────────────────────────────────────────────────
class TestBuildPipeTable:
"""Test _build_pipe_table: rows → markdown pipe table."""
def test_basic_table(self):
rows = [["a", "b"], ["c", "d"]]
result = _build_pipe_table(rows)
assert result == [
"| | |",
"| --- | --- |",
"| a | b |",
"| c | d |",
]
def test_uneven_rows(self):
"""Rows with different column counts → padded."""
rows = [["a", "b", "c"], ["d"]]
result = _build_pipe_table(rows)
assert "| d | | |" in result
def test_single_cell(self):
rows = [["only"]]
result = _build_pipe_table(rows)
assert len(result) == 3 # header + sep + 1 row
def test_empty_rows(self):
assert _build_pipe_table([]) == []
def test_image_with_caption(self):
"""Images and captions should pair correctly in table."""
rows = [
["", ""],
["Step 1", "Step 2"],
]
result = _build_pipe_table(rows)
assert "|  |  |" in result
assert "| Step 1 | Step 2 |" in result
# ── Full Post-Processing Pipeline ─────────────────────────────────────────────
class TestPostprocessPipeline:
"""Integration tests for the full postprocess_docx_markdown pipeline."""
def test_grid_table_single_column_to_blockquote(self):
"""Single-column grid table → blockquote."""
inp = """+:---+
| 注意事项 |
+----+"""
out, stats = postprocess_docx_markdown(inp)
assert "> 注意事项" in out
assert "+:---+" not in out
def test_pandoc_attributes_removed(self):
"""Pandoc {width=...} and {.underline} removed."""
inp = '{width="5in" height="3in"} and [text]{.underline}'
out, stats = postprocess_docx_markdown(inp)
assert "{width=" not in out
assert "{.underline}" not in out
assert "" in out
def test_escaped_brackets_fixed(self):
r"""Pandoc \[ and \] → [ and ]."""
inp = r"你 \[在飞书里\] 发消息"
out, stats = postprocess_docx_markdown(inp)
assert "你 [在飞书里] 发消息" in out
def test_double_bracket_links_fixed(self):
"""[[text]](url) → [text](url)."""
inp = "[[点击跳转]](https://example.com)"
out, stats = postprocess_docx_markdown(inp)
assert "[点击跳转](https://example.com)" in out
def test_code_block_with_language(self):
"""Indented dashed block with JSON language hint → ```json."""
inp = """ ------------------------------------------------------------------
JSON\\
{\\
"provider": "stepfun"\\
}
------------------------------------------------------------------"""
out, stats = postprocess_docx_markdown(inp)
assert "```json" in out
assert '"provider": "stepfun"' in out
assert "---" not in out
def test_code_block_plain_text_to_blockquote(self):
"""Indented dashed block with plain text → blockquote."""
inp = """ --------------------------
注意:这是一条重要提示
--------------------------"""
out, stats = postprocess_docx_markdown(inp)
assert "> 注意:这是一条重要提示" in out
def test_cjk_bold_spacing_in_pipeline(self):
"""CJK bold spacing is applied in the full pipeline."""
inp = "打开**飞书**,就可以看到"
out, stats = postprocess_docx_markdown(inp)
assert "打开 **飞书** ,就可以看到" in out
def test_excessive_blank_lines_collapsed(self):
"""4+ blank lines → 2 blank lines."""
inp = "line1\n\n\n\n\nline2"
out, stats = postprocess_docx_markdown(inp)
assert out.count("\n") < 5
def test_stats_tracking(self):
"""Stats object correctly tracks fix counts."""
inp = '{width="5in"}'
out, stats = postprocess_docx_markdown(inp)
assert stats.attributes_removed > 0
# ── Simple Table (pandoc) ─────────────────────────────────────────────────────
class TestSimpleTable:
"""Test pandoc simple table (indented dashes with spaces) → pipe table."""
def test_two_column_image_table(self):
"""Two images side by side in simple table → pipe table."""
inp = """ ---- ----
 
---- ----"""
out, stats = postprocess_docx_markdown(inp)
assert "|  |  |" in out
assert "----" not in out
def test_four_column_image_table(self):
"""Four images in simple table → 4-column pipe table."""
inp = """ ---------- ---------- ---------- ----------
   
---------- ---------- ---------- ----------"""
out, stats = postprocess_docx_markdown(inp)
assert "|  |  |  |  |" in out
#!/usr/bin/env python3
"""
Quality validator for document-to-markdown conversion.
Compare original document with converted markdown to assess conversion quality.
Generates HTML quality report with detailed metrics.
Usage:
uv run --with pymupdf scripts/validate_output.py document.pdf output.md
uv run --with pymupdf scripts/validate_output.py document.pdf output.md --report report.html
"""
import argparse
import html
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class ValidationMetrics:
"""Quality metrics for conversion validation."""
# Text metrics
source_char_count: int = 0
output_char_count: int = 0
text_retention: float = 0.0
# Table metrics
source_table_count: int = 0
output_table_count: int = 0
table_retention: float = 0.0
# Image metrics
source_image_count: int = 0
output_image_count: int = 0
image_retention: float = 0.0
# Structure metrics
heading_count: int = 0
list_count: int = 0
code_block_count: int = 0
# Quality scores
overall_score: float = 0.0
status: str = "unknown" # pass, warn, fail
# Details
warnings: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def extract_text_from_pdf(pdf_path: Path) -> tuple[str, int, int]:
"""Extract text, table count, and image count from PDF."""
try:
import fitz # PyMuPDF
doc = fitz.open(str(pdf_path))
text_parts = []
table_count = 0
image_count = 0
for page in doc:
text_parts.append(page.get_text())
# Count images
image_count += len(page.get_images())
# Estimate tables (look for grid-like structures)
# This is approximate - tables are hard to detect in PDFs
page_text = page.get_text()
if re.search(r'(\t.*){2,}', page_text) or '│' in page_text:
table_count += 1
doc.close()
return '\n'.join(text_parts), table_count, image_count
except ImportError:
# Fallback to pdftotext if available
try:
result = subprocess.run(
['pdftotext', '-layout', str(pdf_path), '-'],
capture_output=True,
text=True,
timeout=60
)
return result.stdout, 0, 0 # Can't count tables/images
except Exception:
return "", 0, 0
def extract_text_from_docx(docx_path: Path) -> tuple[str, int, int]:
"""Extract text, table count, and image count from DOCX."""
try:
import zipfile
from xml.etree import ElementTree as ET
with zipfile.ZipFile(docx_path, 'r') as z:
# Extract main document text
if 'word/document.xml' not in z.namelist():
return "", 0, 0
with z.open('word/document.xml') as f:
tree = ET.parse(f)
root = tree.getroot()
# Extract text
wordprocessing_ns = 'http' + '://schemas.openxmlformats.org/wordprocessingml/2006/main'
ns = {'w': wordprocessing_ns}
text_parts = []
for t in root.iter(f'{{{wordprocessing_ns}}}t'):
if t.text:
text_parts.append(t.text)
# Count tables
tables = root.findall('.//w:tbl', ns)
table_count = len(tables)
# Count images
image_count = sum(1 for name in z.namelist()
if name.startswith('word/media/'))
return ' '.join(text_parts), table_count, image_count
except Exception as e:
return "", 0, 0
def analyze_markdown(md_path: Path) -> dict:
"""Analyze markdown file structure and content."""
content = md_path.read_text()
# Count tables (markdown tables with |)
table_lines = [l for l in content.split('\n')
if re.match(r'^\s*\|.*\|', l)]
# Group consecutive table lines
table_count = 0
in_table = False
for line in content.split('\n'):
if re.match(r'^\s*\|.*\|', line):
if not in_table:
table_count += 1
in_table = True
else:
in_table = False
# Count images
images = re.findall(r'!\[.*?\]\(.*?\)', content)
# Count headings
headings = re.findall(r'^#{1,6}\s+.+$', content, re.MULTILINE)
# Count lists
list_items = re.findall(r'^[\s]*[-*+]\s+', content, re.MULTILINE)
list_items += re.findall(r'^[\s]*\d+\.\s+', content, re.MULTILINE)
# Count code blocks
code_blocks = re.findall(r'```', content)
# Clean text for comparison
clean_text = re.sub(r'```.*?```', '', content, flags=re.DOTALL)
clean_text = re.sub(r'!\[.*?\]\(.*?\)', '', clean_text)
clean_text = re.sub(r'\[.*?\]\(.*?\)', '', clean_text)
clean_text = re.sub(r'[#*_`|>-]', '', clean_text)
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
return {
'char_count': len(clean_text),
'table_count': table_count,
'image_count': len(images),
'heading_count': len(headings),
'list_count': len(list_items),
'code_block_count': len(code_blocks) // 2,
'raw_content': content,
'clean_text': clean_text
}
def validate_conversion(
source_path: Path,
output_path: Path
) -> ValidationMetrics:
"""Validate conversion quality by comparing source and output."""
metrics = ValidationMetrics()
# Analyze output markdown
md_analysis = analyze_markdown(output_path)
metrics.output_char_count = md_analysis['char_count']
metrics.output_table_count = md_analysis['table_count']
metrics.output_image_count = md_analysis['image_count']
metrics.heading_count = md_analysis['heading_count']
metrics.list_count = md_analysis['list_count']
metrics.code_block_count = md_analysis['code_block_count']
# Extract source content based on file type
ext = source_path.suffix.lower()
if ext == '.pdf':
source_text, source_tables, source_images = extract_text_from_pdf(source_path)
elif ext in ['.docx', '.doc']:
source_text, source_tables, source_images = extract_text_from_docx(source_path)
else:
# For other formats, estimate from file size
source_text = ""
source_tables = 0
source_images = 0
metrics.warnings.append(f"Cannot analyze source format: {ext}")
metrics.source_char_count = len(source_text.replace(' ', '').replace('\n', ''))
metrics.source_table_count = source_tables
metrics.source_image_count = source_images
# Calculate retention rates
if metrics.source_char_count > 0:
# Use ratio of actual/expected, capped at 1.0
metrics.text_retention = min(
metrics.output_char_count / metrics.source_char_count,
1.0
)
else:
metrics.text_retention = 1.0 if metrics.output_char_count > 0 else 0.0
if metrics.source_table_count > 0:
metrics.table_retention = min(
metrics.output_table_count / metrics.source_table_count,
1.0
)
else:
metrics.table_retention = 1.0 # No tables expected
if metrics.source_image_count > 0:
metrics.image_retention = min(
metrics.output_image_count / metrics.source_image_count,
1.0
)
else:
metrics.image_retention = 1.0 # No images expected
# Determine status based on thresholds
if metrics.text_retention < 0.85:
metrics.errors.append(f"Low text retention: {metrics.text_retention:.1%}")
elif metrics.text_retention < 0.95:
metrics.warnings.append(f"Text retention below optimal: {metrics.text_retention:.1%}")
if metrics.source_table_count > 0 and metrics.table_retention < 0.9:
metrics.errors.append(f"Tables missing: {metrics.table_retention:.1%} retained")
elif metrics.source_table_count > 0 and metrics.table_retention < 1.0:
metrics.warnings.append(f"Some tables may be incomplete: {metrics.table_retention:.1%}")
if metrics.source_image_count > 0 and metrics.image_retention < 0.8:
metrics.errors.append(f"Images missing: {metrics.image_retention:.1%} retained")
elif metrics.source_image_count > 0 and metrics.image_retention < 1.0:
metrics.warnings.append(f"Some images missing: {metrics.image_retention:.1%}")
# Calculate overall score (0-100)
metrics.overall_score = (
metrics.text_retention * 50 +
metrics.table_retention * 25 +
metrics.image_retention * 25
) * 100
# Determine status
if metrics.errors:
metrics.status = "fail"
elif metrics.warnings:
metrics.status = "warn"
else:
metrics.status = "pass"
return metrics
def generate_html_report(
metrics: ValidationMetrics,
source_path: Path,
output_path: Path
) -> str:
"""Generate HTML quality report."""
status_colors = {
"pass": "#28a745",
"warn": "#ffc107",
"fail": "#dc3545"
}
status_color = status_colors.get(metrics.status, "#6c757d")
def metric_bar(value: float, thresholds: tuple) -> str:
"""Generate colored progress bar."""
pct = int(value * 100)
if value >= thresholds[0]:
color = "#28a745" # green
elif value >= thresholds[1]:
color = "#ffc107" # yellow
else:
color = "#dc3545" # red
return f'''
<div style="background: #e9ecef; border-radius: 4px; overflow: hidden; height: 20px;">
<div style="background: {color}; width: {pct}%; height: 100%; transition: width 0.3s;"></div>
</div>
<span style="font-size: 14px; color: #666;">{pct}%</span>
'''
report = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Conversion Quality Report</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; }}
.container {{ max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
h1 {{ color: #333; border-bottom: 2px solid #eee; padding-bottom: 15px; }}
.status {{ display: inline-block; padding: 8px 16px; border-radius: 4px; color: white; font-weight: bold; }}
.metric {{ margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 4px; }}
.metric-label {{ font-weight: bold; color: #333; margin-bottom: 8px; }}
.metric-value {{ font-size: 24px; color: #333; }}
.issues {{ margin-top: 20px; }}
.error {{ background: #f8d7da; color: #721c24; padding: 10px; margin: 5px 0; border-radius: 4px; }}
.warning {{ background: #fff3cd; color: #856404; padding: 10px; margin: 5px 0; border-radius: 4px; }}
table {{ width: 100%; border-collapse: collapse; margin: 15px 0; }}
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #eee; }}
th {{ background: #f8f9fa; }}
.score {{ font-size: 48px; font-weight: bold; color: {status_color}; }}
</style>
</head>
<body>
<div class="container">
<h1>📊 Conversion Quality Report</h1>
<div style="text-align: center; margin: 30px 0;">
<div class="score">{metrics.overall_score:.0f}</div>
<div style="color: #666;">Overall Score</div>
<div class="status" style="background: {status_color}; margin-top: 10px;">
{metrics.status.upper()}
</div>
</div>
<h2>📄 File Information</h2>
<table>
<tr><th>Source</th><td>{html.escape(str(source_path))}</td></tr>
<tr><th>Output</th><td>{html.escape(str(output_path))}</td></tr>
</table>
<h2>📏 Retention Metrics</h2>
<div class="metric">
<div class="metric-label">Text Retention (target: >95%)</div>
{metric_bar(metrics.text_retention, (0.95, 0.85))}
<div style="font-size: 12px; color: #666; margin-top: 5px;">
Source: ~{metrics.source_char_count:,} chars | Output: {metrics.output_char_count:,} chars
</div>
</div>
<div class="metric">
<div class="metric-label">Table Retention (target: 100%)</div>
{metric_bar(metrics.table_retention, (1.0, 0.9))}
<div style="font-size: 12px; color: #666; margin-top: 5px;">
Source: {metrics.source_table_count} tables | Output: {metrics.output_table_count} tables
</div>
</div>
<div class="metric">
<div class="metric-label">Image Retention (target: 100%)</div>
{metric_bar(metrics.image_retention, (1.0, 0.8))}
<div style="font-size: 12px; color: #666; margin-top: 5px;">
Source: {metrics.source_image_count} images | Output: {metrics.output_image_count} images
</div>
</div>
<h2>📊 Structure Analysis</h2>
<table>
<tr><th>Headings</th><td>{metrics.heading_count}</td></tr>
<tr><th>List Items</th><td>{metrics.list_count}</td></tr>
<tr><th>Code Blocks</th><td>{metrics.code_block_count}</td></tr>
</table>
{'<h2>⚠️ Issues</h2><div class="issues">' + ''.join(f'<div class="error">❌ {html.escape(e)}</div>' for e in metrics.errors) + ''.join(f'<div class="warning">⚠️ {html.escape(w)}</div>' for w in metrics.warnings) + '</div>' if metrics.errors or metrics.warnings else ''}
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 12px;">
Generated by markdown-tools validate_output.py
</div>
</div>
</body>
</html>
'''
return report
def main():
parser = argparse.ArgumentParser(
description="Validate document-to-markdown conversion quality"
)
parser.add_argument(
"source",
type=Path,
help="Original document (PDF, DOCX, etc.)"
)
parser.add_argument(
"output",
type=Path,
help="Converted markdown file"
)
parser.add_argument(
"--report",
type=Path,
help="Generate HTML report at this path"
)
parser.add_argument(
"--json",
action="store_true",
help="Output metrics as JSON"
)
args = parser.parse_args()
# Validate inputs
if not args.source.exists():
print(f"Error: Source file not found: {args.source}", file=sys.stderr)
sys.exit(1)
if not args.output.exists():
print(f"Error: Output file not found: {args.output}", file=sys.stderr)
sys.exit(1)
# Run validation
metrics = validate_conversion(args.source, args.output)
# Output results
if args.json:
import json
print(json.dumps({
'text_retention': metrics.text_retention,
'table_retention': metrics.table_retention,
'image_retention': metrics.image_retention,
'overall_score': metrics.overall_score,
'status': metrics.status,
'warnings': metrics.warnings,
'errors': metrics.errors
}, indent=2))
else:
# Console output
status_emoji = {"pass": "✅", "warn": "⚠️", "fail": "❌"}.get(metrics.status, "❓")
print(f"\n{status_emoji} Conversion Quality: {metrics.status.upper()}")
print(f" Overall Score: {metrics.overall_score:.0f}/100")
print(f"\n Text Retention: {metrics.text_retention:.1%}")
print(f" Table Retention: {metrics.table_retention:.1%}")
print(f" Image Retention: {metrics.image_retention:.1%}")
if metrics.errors:
print("\n Errors:")
for e in metrics.errors:
print(f" ❌ {e}")
if metrics.warnings:
print("\n Warnings:")
for w in metrics.warnings:
print(f" ⚠️ {w}")
# Generate HTML report
if args.report:
report_html = generate_html_report(metrics, args.source, args.output)
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(report_html)
print(f"\n📊 HTML report: {args.report}")
# Exit with appropriate code
sys.exit(0 if metrics.status != "fail" else 1)
if __name__ == "__main__":
main()
Related skills
FAQ
Which file types does doc-to-markdown support?
doc-to-markdown converts Word, PDF, and rich office files into clean Markdown. Output is structured for Git repositories, documentation sites, and agent-readable knowledge without manual reformatting.
Why use doc-to-markdown instead of manual copy-paste?
doc-to-markdown preserves headings, lists, and tables while removing presentation cruft that breaks Markdown linters. Developers avoid hand-rebuilding structure from legacy office documents.