
Pdf To Markdown
- 244 installs
- 5 repo stars
- Updated December 22, 2025
- aliceisjustplaying/claude-resources-monorepo
pdf-to-markdown is a Claude skill that converts entire PDF documents into clean, structured Markdown using IBM Docling AI.
About
pdf-to-markdown converts entire PDF documents to clean, structured Markdown for full context loading. It uses IBM Docling AI to preserve headers, bold and italic formatting, tables via the TableFormer model, lists, multi-column reading order, code blocks, and extracted images. A developer uses it when they want the whole PDF in context rather than grepping page by page. Extractions are cached by content hash so repeat runs are instant.
- Converts entire PDFs to structured Markdown using IBM Docling AI
- Preserves headers, tables, lists, multi-column order, code blocks, and images
- Aggressively caches extractions by content hash for instant re-runs
Pdf To Markdown by the numbers
- 244 all-time installs (skills.sh)
- Ranked #214 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
pdf-to-markdown capabilities & compatibility
- Capabilities
- pdf parsing · documentation
- Use cases
- pdf parsing · documentation
- Pricing
- Free
What pdf-to-markdown says it does
Extract complete PDF content as structured Markdown using IBM Docling AI, preserving:
Tables (high-accuracy extraction using TableFormer AI model)
PDFs are **aggressively cached** to avoid re-processing. First extraction is slow (~1 sec/page), every subsequent request is instant.
npx skills add https://github.com/aliceisjustplaying/claude-resources-monorepo --skill pdf-to-markdownAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 244 |
|---|---|
| repo stars | ★ 5 |
| Last updated | December 22, 2025 |
| Repository | aliceisjustplaying/claude-resources-monorepo ↗ |
What it does
Convert a full PDF into structured Markdown so its entire content can be loaded into context.
Who is it for?
Loading a whole PDF, including tables and figures, into context as structured Markdown
Skip if: Quick page-by-page grep or partial search of a PDF, which this skill is meant to replace
When should I use this skill?
The user wants to extract all text from a PDF into context, load or read the entire PDF, or preserve tables and structure that grepping would miss
What you get
A single Markdown file with preserved structure, tables, and referenced images for the whole PDF
- a Markdown file with preserved structure
- an images folder next to the output
By the numbers
- First extraction runs about 1 second per page, then cached runs are instant
Files
PDF to Markdown Converter
Extract complete PDF content as structured Markdown using IBM Docling AI, preserving:
- Headers (detected by font size, converted to # tags)
- Bold, italic, monospace formatting
- Tables (high-accuracy extraction using TableFormer AI model)
- Lists (ordered and unordered)
- Multi-column layouts (correct reading order)
- Code blocks
- Images (extracted and copied next to output with relative paths)
When to Use This Skill
USE THIS when:
- User wants the "whole PDF" or "entire document" in context
- Analyzing, summarizing, or discussing PDF content
- User says "load", "read", "bring in", "extract" a PDF
- Grepping/searching would miss context or structure
- PDF has tables, formatting, or structure to preserve
Environment Setup
This skill uses a dedicated virtual environment at ~/.claude/skills/pdf-to-markdown/.venv/ to avoid polluting the user's working directory.
First-Time Setup (if .venv doesn't exist)
cd ~/.claude/skills/pdf-to-markdown && uv venv .venv && uv pip install --python .venv/bin/python pymupdf docling docling-coreVerify Installation
~/.claude/skills/pdf-to-markdown/.venv/bin/python -c "import pymupdf; import docling; import docling_core; print('OK')"Quick Start
# Convert PDF to markdown (always extracts images)
~/.claude/skills/pdf-to-markdown/.venv/bin/python ~/.claude/skills/pdf-to-markdown/scripts/pdf_to_md.py document.pdf
# Output: document.md + images/ folder (next to the .md file)Standard Workflow
When user provides a PDF and wants full content in context:
Step 1: Ensure the skill venv exists
test -d ~/.claude/skills/pdf-to-markdown/.venv || (cd ~/.claude/skills/pdf-to-markdown && uv venv .venv && uv pip install --python .venv/bin/python pymupdf docling docling-core)Step 2: Convert PDF to Markdown
~/.claude/skills/pdf-to-markdown/.venv/bin/python ~/.claude/skills/pdf-to-markdown/scripts/pdf_to_md.py "/path/to/document.pdf"Step 3: Read the output
# Output is written to document.md in the same directory as the PDF
cat /path/to/document.mdCaching
PDFs are aggressively cached to avoid re-processing. First extraction is slow (~1 sec/page), every subsequent request is instant.
How It Works
- Cache location:
~/.cache/pdf-to-markdown/<cache_key>/ - Cache key: Based on file content hash
- Invalidation: Cache is invalidated when:
- Source PDF is modified (size or mtime changes)
- Extractor version changes (automatic re-extraction)
- Explicitly cleared with
--clear-cacheor--clear-all-cache
Cache Commands
# Clear cache for a specific PDF
~/.claude/skills/pdf-to-markdown/.venv/bin/python ~/.claude/skills/pdf-to-markdown/scripts/pdf_to_md.py document.pdf --clear-cache
# Clear entire cache
~/.claude/skills/pdf-to-markdown/.venv/bin/python ~/.claude/skills/pdf-to-markdown/scripts/pdf_to_md.py --clear-all-cache
# Show cache statistics
~/.claude/skills/pdf-to-markdown/.venv/bin/python ~/.claude/skills/pdf-to-markdown/scripts/pdf_to_md.py --cache-statsCache Contents
~/.cache/pdf-to-markdown/<cache_key>/
├── metadata.json # source path, mtime, size, total_pages
├── full_output.md # cached full markdown
└── images/ # extracted imagesImage Handling
Images are always extracted. They are:
- Cached in
~/.cache/pdf-to-markdown/<cache_key>/images/ - Copied to
images/folder next to the output.mdfile - Referenced in the markdown with relative paths (
images/filename.png) - Summarized in a table at the end of the document
Auto-View Behavior for Images
IMPORTANT: When the extracted markdown contains image references like:
**[Image: figure_1.png (1200x800, 125.3KB)]**And the user asks about something that might be visual (charts, graphs, diagrams, figures, screenshots, layouts, designs, plots, illustrations), automatically use the Read tool to view the relevant image file(s) before answering. Don't ask the user - just look at it.
Examples of when to auto-view images:
- User: "What does the chart on page 3 show?" → Read the image file
- User: "Summarize the figures in this paper" → Read all image files
- User: "What's in the diagram?" → Read the image file
- User: "Describe the architecture shown" → Read the image file
- User: "What are the results?" (and there's a results figure) → Read it
Output Format
The markdown output includes:
Header (metadata)
---
source: document.pdf
total_pages: 42
extracted_at: 2025-01-15T10:30:00
from_cache: true
images_dir: images
---Content with image references
# Main Title
## Section Header
Regular paragraph text with **bold**, *italic*, and `code` formatting.

**[Image: figure_1.png (800x600, 45.2KB)]**
| Column A | Column B |
|----------|----------|
| Data 1 | Data 2 |Image summary table (at end)
---
## Extracted Images
| # | File | Dimensions | Size |
|---|------|------------|------|
| 1 | figure_1.png | 800x600 | 45.2KB |
| 2 | chart_2.png | 1200x800 | 89.1KB |Script Reference
Location: ~/.claude/skills/pdf-to-markdown/scripts/pdf_to_md.py
Usage: pdf_to_md.py <input.pdf> [output.md] [options]
Options:
--no-progress Disable progress indicator
Cache Options:
--clear-cache Clear cache for this PDF and re-extract
--clear-all-cache Clear entire cache directory and exit
--cache-stats Show cache statistics and exitPerformance
- First extraction: ~1 second per page (Docling AI processing)
- First run: Downloads AI models (~500MB one-time)
- Cached extraction: Instant
- High-resolution images: 4x default resolution for crisp output
Troubleshooting
"No module named docling" or venv doesn't exist
Recreate the skill's virtual environment:
cd ~/.claude/skills/pdf-to-markdown && rm -rf .venv && uv venv .venv && uv pip install --python .venv/bin/python pymupdf docling docling-corePoor extraction quality
For scanned PDFs, ensure Tesseract OCR is installed: brew install tesseract
Tables not formatting correctly
This skill uses IBM's TableFormer AI model which has ~93.6% accuracy on complex tables. If tables are still garbled, the PDF may have unusual formatting.
# Virtual environment
.venv/
venv/
env/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
.eggs/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Test/temp files
*.pdf
*.md
!SKILL.md
!README.md
/tmp/
PDF to Markdown Converter
Convert PDF documents to clean, structured Markdown using IBM Docling AI with high-accuracy table and image extraction.
Features
- Text extraction with formatting preservation (headers, bold, italic, lists)
- Table extraction using IBM's TableFormer AI model (~93.6% accuracy)
- Image extraction to cache directory with paths in output
- Aggressive caching - extract once, reuse forever
Installation
cd ~/.claude/skills/pdf-to-markdown
uv venv .venv
uv pip install --python .venv/bin/python pymupdf docling docling-coreUsage
# Basic conversion (outputs to document.md)
.venv/bin/python scripts/pdf_to_md.py document.pdf
# Custom output path
.venv/bin/python scripts/pdf_to_md.py document.pdf output.mdOptions
| Option | Description |
|---|---|
--no-progress | Disable progress indicator |
--clear-cache | Clear cache for this PDF and re-extract |
--clear-all-cache | Clear entire cache |
--cache-stats | Show cache statistics |
Project Structure
scripts/
pdf_to_md.py # Main CLI tool
extractor.py # PDF extraction using DoclingCache
PDFs are cached in ~/.cache/pdf-to-markdown/. Cache is invalidated when:
- Source PDF is modified
- Extractor version changes
- Explicitly cleared with
--clear-cache
License
MIT
"""
PDF extraction using IBM Docling with TableFormer AI for high-accuracy table extraction.
"""
import os
import sys
from pathlib import Path
# Suppress PyMuPDF's "Consider using pymupdf_layout" recommendation
# This prints to stdout and pollutes --stdout output
os.environ.setdefault("PYMUPDF_SUGGEST_LAYOUT_ANALYZER", "0")
# Version for cache invalidation - increment when extraction logic changes
# Format: major.minor.patch
# 4.0.0: Removed fast mode (pymupdf4llm), docling is now the only extraction path
EXTRACTOR_VERSION = "4.0.0"
def check_docling_models():
"""Check if Docling models are downloaded."""
try:
from huggingface_hub import scan_cache_dir
cache_info = scan_cache_dir()
# Check for docling models in HF cache
docling_repos = [r for r in cache_info.repos if "docling" in r.repo_id.lower()]
return len(docling_repos) > 0
except Exception:
return False
def _save_docling_images(result, output_dir: Path, prefix: str = "") -> list:
"""
Save images from a Docling conversion result to output directory.
Images are saved in iteration order, which matches the order of
<!-- image --> placeholders in the exported markdown.
Args:
result: Docling ConversionResult object
output_dir: Directory to save images to
prefix: Prefix for image filenames (prevents clashes between PDFs)
Returns:
List of saved image paths (in iteration order)
"""
output_dir.mkdir(parents=True, exist_ok=True)
image_paths = []
prefix_str = f"{prefix}_" if prefix else ""
for i, (element, _level) in enumerate(result.document.iterate_items()):
if hasattr(element, "image") and element.image is not None:
img_path = output_dir / f"{prefix_str}figure_{i:04d}.png"
element.image.pil_image.save(str(img_path))
image_paths.append(str(img_path))
return image_paths
def extract_pdf_docling(
pdf_path: str,
output_dir: str = None,
images_scale: float = 4.0,
show_progress: bool = False,
image_prefix: str = "",
) -> tuple:
"""
Extract PDF using Docling with accurate tables + high-res images.
Uses IBM's TableFormer AI model for ~93.6% table extraction accuracy.
Also extracts images at configurable resolution (default 4x for crisp images).
Args:
pdf_path: Path to the PDF file
output_dir: Directory to save extracted images (None = skip images)
images_scale: Image resolution multiplier (default: 4.0 for high-res)
show_progress: Whether to show progress output
image_prefix: Prefix for image filenames (prevents clashes between PDFs)
Returns:
tuple: (markdown: str, image_paths: list[str])
"""
from docling.document_converter import DocumentConverter, PdfFormatOption
from docling.datamodel.base_models import InputFormat
from docling.datamodel.pipeline_options import PdfPipelineOptions, TableFormerMode
from docling_core.types.doc.base import ImageRefMode
# Check if this is first run (models need downloading)
if not check_docling_models():
print(
"First run: downloading Docling AI models (one-time setup, ~2-3 minutes)...",
file=sys.stderr,
)
if show_progress:
print(
f"Processing PDF with Docling (~1 sec/page)...",
file=sys.stderr,
)
# Configure pipeline for accurate tables + image extraction
pipeline_options = PdfPipelineOptions(
do_table_structure=True,
generate_picture_images=output_dir is not None,
images_scale=images_scale,
)
pipeline_options.table_structure_options.mode = TableFormerMode.ACCURATE
converter = DocumentConverter(
format_options={
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)
}
)
# Convert the document
result = converter.convert(pdf_path)
# Check for conversion errors
if hasattr(result, "errors") and result.errors:
for error in result.errors:
print(f"WARNING: Docling conversion error: {error}", file=sys.stderr)
# Check conversion status
from docling.datamodel.base_models import ConversionStatus
if hasattr(result, "status") and result.status != ConversionStatus.SUCCESS:
print(
f"WARNING: Docling conversion status: {result.status.name}",
file=sys.stderr,
)
# Save images to output directory (order matters for placeholder replacement)
image_paths = []
if output_dir:
image_paths = _save_docling_images(
result, Path(output_dir), prefix=image_prefix
)
if show_progress and image_paths:
print(
f"Extracted {len(image_paths)} images at {images_scale}x resolution",
file=sys.stderr,
)
# Export markdown with placeholders
md = result.document.export_to_markdown(image_mode=ImageRefMode.PLACEHOLDER)
# Replace placeholders with actual image references (order must match iteration order)
for img_path in image_paths:
md = md.replace("<!-- image -->", f".name})", 1)
return md, image_paths
def get_page_count(pdf_path: str) -> int:
"""Get the number of pages in a PDF using pymupdf (faster than Docling for this)."""
import pymupdf
doc = pymupdf.open(pdf_path)
count = len(doc)
doc.close()
return count
def extract_images(pdf_path: str, output_dir: str, show_progress: bool = False) -> list:
"""
Extract images from PDF to output directory.
Uses pymupdf for image extraction since Docling focuses on document structure.
Deduplicates by xref to avoid extracting the same image multiple times
(e.g., icons/logos reused across pages).
Returns:
List of extracted image paths
"""
import pymupdf
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
doc = pymupdf.open(pdf_path)
extracted = []
image_count = 0
seen_xrefs = set() # Track already-extracted images by xref
for page_num in range(len(doc)):
page = doc[page_num]
# full=True includes images nested inside form XObjects (common in
# documents exported from Word/PowerPoint)
images = page.get_images(full=True)
for img_index, img in enumerate(images):
try:
xref = img[0]
# Skip if we've already extracted this image
if xref in seen_xrefs:
continue
seen_xrefs.add(xref)
pix = pymupdf.Pixmap(doc, xref)
# Convert CMYK to RGB if necessary
if pix.n - pix.alpha > 3:
pix = pymupdf.Pixmap(pymupdf.csRGB, pix)
image_count += 1
img_filename = f"image_{image_count:04d}.png"
img_path = output_path / img_filename
pix.save(str(img_path))
extracted.append(str(img_path))
pix = None
except Exception as e:
# Log instead of silently swallowing errors
print(
f"WARNING: Failed to extract image {img_index} on page {page_num + 1}: {e}",
file=sys.stderr,
)
continue
doc.close()
if show_progress and extracted:
print(f"Extracted {len(extracted)} unique images", file=sys.stderr)
return extracted
#!/usr/bin/env python3
"""
PDF to Markdown Converter for LLM Context
Extracts entire PDF content as clean, structured markdown using IBM Docling.
Images are extracted to cache directory and copied to output location.
Features:
- High-accuracy table extraction using IBM Docling (TableFormer AI model)
- Aggressive persistent caching (extracts once, reuses forever)
- Cache only cleared on explicit request or source file change
Usage:
python pdf_to_md.py <input.pdf> [output.md]
python pdf_to_md.py <input.pdf> --clear-cache # Re-extract
python pdf_to_md.py --clear-all-cache # Clear entire cache
Dependencies:
uv pip install pymupdf docling docling-core
"""
import argparse
import sys
import os
import re
import json
import hashlib
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from datetime import datetime
# =============================================================================
# DATACLASSES
# =============================================================================
@dataclass
class ExtractionConfig:
"""Configuration for PDF extraction."""
pdf_path: str
images_scale: float = 4.0
@dataclass
class ExtractionResult:
"""Result of PDF extraction or cache load."""
markdown: str
image_dir: Path | None
total_pages: int
from_cache: bool = False
# Suppress PyMuPDF's "Consider using pymupdf_layout" recommendation
os.environ.setdefault("PYMUPDF_SUGGEST_LAYOUT_ANALYZER", "0")
# Default cache directory
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "pdf-to-markdown"
# =============================================================================
# CACHE MANAGER
# =============================================================================
class CacheManager:
"""Manages PDF extraction cache."""
def __init__(self, cache_dir: Path = None):
self.cache_dir = cache_dir or DEFAULT_CACHE_DIR
def get_key(self, config: ExtractionConfig) -> str:
"""Generate cache key from file content + size."""
p = Path(config.pdf_path).resolve()
stat = p.stat()
file_size = stat.st_size
chunk_size = 65536 # 64KB
hasher = hashlib.sha256()
with open(p, "rb") as f:
if file_size <= chunk_size * 2:
hasher.update(f.read())
else:
hasher.update(f.read(chunk_size))
f.seek(-chunk_size, 2)
hasher.update(f.read(chunk_size))
mode = f"docling_{config.images_scale}"
raw = f"{file_size}|{hasher.hexdigest()}|{mode}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def _get_dir(self, cache_key: str) -> Path:
"""Get cache directory for a given cache key."""
return self.cache_dir / cache_key
def is_valid(self, config: ExtractionConfig) -> tuple[bool, str]:
"""Check if valid cache exists for this PDF."""
from extractor import EXTRACTOR_VERSION
try:
cache_key = self.get_key(config)
except (FileNotFoundError, OSError):
return False, ""
cache_dir = self._get_dir(cache_key)
metadata_file = cache_dir / "metadata.json"
output_file = cache_dir / "full_output.md"
if not metadata_file.exists() or not output_file.exists():
return False, cache_key
try:
with open(metadata_file) as f:
metadata = json.load(f)
p = Path(config.pdf_path).resolve()
stat = p.stat()
if (
metadata.get("source_size") != stat.st_size
or metadata.get("source_mtime") != stat.st_mtime
):
return False, cache_key
if metadata.get("extractor_version") != EXTRACTOR_VERSION:
return False, cache_key
return True, cache_key
except (json.JSONDecodeError, KeyError, OSError):
return False, cache_key
def load(self, cache_key: str) -> ExtractionResult | None:
"""Load markdown from cache."""
cache_dir = self._get_dir(cache_key)
try:
full_md = (cache_dir / "full_output.md").read_text(encoding="utf-8")
with open(cache_dir / "metadata.json") as f:
metadata = json.load(f)
total_pages = metadata.get("total_pages", 0)
except (FileNotFoundError, IOError, json.JSONDecodeError, OSError) as e:
print(
f"WARNING: Cache corrupted ({e.__class__.__name__}), regenerating...",
file=sys.stderr,
)
try:
if cache_dir.exists():
shutil.rmtree(cache_dir)
except OSError:
pass
return None
# Check if markdown references images
has_image_refs = bool(re.search(r"!\[[^\]]*\]\([^)]+\)", full_md))
# Get cached images directory
cached_image_dir = cache_dir / "images"
has_images = cached_image_dir.exists() and any(cached_image_dir.iterdir())
# If markdown expects images but they're missing, invalidate cache
if has_image_refs and not has_images:
print(
"WARNING: Cache missing images, regenerating...",
file=sys.stderr,
)
try:
shutil.rmtree(cache_dir)
except OSError:
pass
return None
image_dir = cached_image_dir if has_images else None
return ExtractionResult(
markdown=full_md,
image_dir=image_dir,
total_pages=total_pages,
from_cache=True,
)
def _normalize_image_paths(self, markdown: str, source_image_dir: Path) -> str:
"""Normalize image paths in markdown to use relative 'images/' prefix."""
if not source_image_dir:
return markdown
source_image_dir = Path(source_image_dir)
def normalize_ref(match):
alt_text = match.group(1)
filename_raw = match.group(2)
filename = Path(filename_raw).name
if (source_image_dir / filename).exists():
return f""
return match.group(0)
pattern = r"!\[([^\]]*)\]\(([^)]+)\)"
return re.sub(pattern, normalize_ref, markdown)
def save(self, cache_key: str, result: ExtractionResult, config: ExtractionConfig):
"""Save full extraction to cache using atomic writes."""
from extractor import EXTRACTOR_VERSION
cache_dir = self._get_dir(cache_key)
cache_dir.mkdir(parents=True, exist_ok=True)
markdown = result.markdown
if result.image_dir:
markdown = self._normalize_image_paths(markdown, result.image_dir)
p = Path(config.pdf_path).resolve()
stat = p.stat()
mode = f"docling_{config.images_scale}"
metadata = {
"source_path": str(p),
"source_mtime": stat.st_mtime,
"source_size": stat.st_size,
"cache_key": cache_key,
"cached_at": datetime.now().isoformat(),
"total_pages": result.total_pages,
"extractor_version": EXTRACTOR_VERSION,
"mode": mode,
"images_scale": config.images_scale,
}
temp_md = None
temp_json = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
dir=cache_dir,
suffix=".md.tmp",
delete=False,
encoding="utf-8",
) as f:
f.write(markdown)
temp_md = f.name
with tempfile.NamedTemporaryFile(
mode="w", dir=cache_dir, suffix=".json.tmp", delete=False
) as f:
json.dump(metadata, f, indent=2)
temp_json = f.name
os.replace(temp_md, cache_dir / "full_output.md")
temp_md = None
os.replace(temp_json, cache_dir / "metadata.json")
temp_json = None
if result.image_dir and Path(result.image_dir).exists():
temp_images = cache_dir / "images.tmp"
final_images = cache_dir / "images"
if temp_images.exists():
shutil.rmtree(temp_images)
shutil.copytree(result.image_dir, temp_images)
if final_images.exists():
shutil.rmtree(final_images)
os.rename(temp_images, final_images)
finally:
if temp_md and os.path.exists(temp_md):
os.unlink(temp_md)
if temp_json and os.path.exists(temp_json):
os.unlink(temp_json)
def clear(self, pdf_path: str = None) -> bool:
"""Clear cache for specific PDF or entire cache."""
if pdf_path:
try:
config = ExtractionConfig(pdf_path=pdf_path)
cache_key = self.get_key(config)
cache_dir = self._get_dir(cache_key)
if cache_dir.exists():
shutil.rmtree(cache_dir)
return True
except (FileNotFoundError, OSError):
pass
return False
else:
if self.cache_dir.exists():
shutil.rmtree(self.cache_dir)
return True
return False
def get_stats(self) -> dict:
"""Get statistics about the cache."""
if not self.cache_dir.exists():
return {"entries": 0, "total_size_mb": 0, "cache_dir": str(self.cache_dir)}
entries = 0
total_size = 0
for entry in self.cache_dir.iterdir():
if entry.is_dir():
entries += 1
for f in entry.rglob("*"):
if f.is_file():
total_size += f.stat().st_size
return {
"entries": entries,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"cache_dir": str(self.cache_dir),
}
# =============================================================================
# IMAGE MANAGER
# =============================================================================
class ImageManager:
"""Manages image extraction and cleanup."""
def __init__(self):
self._temp_dirs: list[Path] = []
def create_temp_dir(self, pdf_path: str) -> Path:
"""Create tracked temp directory for image extraction."""
pdf_name = Path(pdf_path).stem
safe_name = re.sub(r"[^\w\-_]", "_", pdf_name)
temp_dir = Path(tempfile.mkdtemp(prefix=f"pdf_images_{safe_name}_"))
self._temp_dirs.append(temp_dir)
return temp_dir
def cleanup(self):
"""Clean up all tracked temp directories."""
for temp_dir in self._temp_dirs:
if temp_dir.exists():
shutil.rmtree(temp_dir)
self._temp_dirs.clear()
def extract_references(self, markdown: str) -> set:
"""Extract the set of image filenames referenced in markdown."""
pattern = r"!\[[^\]]*\]\(([^)]+)\)"
matches = re.findall(pattern, markdown)
return {Path(m).name for m in matches}
def get_info(self, image_dir: Path, referenced_only: set = None) -> list:
"""Get information about extracted images."""
if not image_dir or not Path(image_dir).exists():
return []
image_dir = Path(image_dir)
images = []
for img_path in sorted(image_dir.glob("*")):
if img_path.suffix.lower() in (
".png",
".jpg",
".jpeg",
".gif",
".bmp",
".webp",
):
if referenced_only is not None and img_path.name not in referenced_only:
continue
try:
size_bytes = img_path.stat().st_size
size_kb = size_bytes / 1024
try:
import pymupdf
pix = pymupdf.Pixmap(str(img_path))
dimensions = f"{pix.width}x{pix.height}"
pix = None
except Exception:
dimensions = "unknown"
images.append(
{
"filename": img_path.name,
"path": str(img_path),
"size_kb": round(size_kb, 1),
"dimensions": dimensions,
}
)
except Exception:
pass
return images
def enhance_markdown(self, markdown: str, image_dir: Path) -> str:
"""Rewrite image references to use relative paths (portable, Windows-safe)."""
if not image_dir:
return markdown
image_dir = Path(image_dir)
def replace_image_ref(match):
alt_text = match.group(1)
filename_raw = match.group(2)
filename = Path(filename_raw).name
full_path = image_dir / filename
# Use relative path for portability (POSIX format for Windows compatibility)
relative_path = Path("images") / filename
if full_path.exists():
try:
size_kb = round(full_path.stat().st_size / 1024, 1)
try:
import pymupdf
pix = pymupdf.Pixmap(str(full_path))
dims = f"{pix.width}x{pix.height}"
pix = None
except Exception:
dims = "?"
return f"})\n\n**[Image: {filename} ({dims}, {size_kb}KB)]**"
except Exception:
return f"})\n\n**[Image: {filename}]**"
return match.group(0)
pattern = r"!\[([^\]]*)\]\(([^)]+)\)"
return re.sub(pattern, replace_image_ref, markdown)
def create_summary(self, images: list) -> str:
"""Create a summary section listing all extracted images."""
if not images:
return ""
lines = [
"",
"---",
"",
"## Extracted Images",
"",
"| # | File | Dimensions | Size |",
"|---|------|------------|------|",
]
for i, img in enumerate(images, 1):
lines.append(
f"| {i} | {img['filename']} | {img['dimensions']} | {img['size_kb']}KB |"
)
lines.append("")
return "\n".join(lines)
def finalize_images(
self,
temp_dir: Path,
cache_dir: Path,
output_path: Path,
show_progress: bool = False,
) -> Path | None:
"""Finalize image directory after extraction.
Copies images from cache to output location (next to the markdown file).
Cleans up temp directories.
Returns the final image directory (next to output) for reference.
"""
if not temp_dir:
return None
temp_dir = Path(temp_dir)
# Clean up empty temp directories
if not temp_dir.exists() or not any(temp_dir.iterdir()):
if temp_dir.exists():
shutil.rmtree(temp_dir)
if temp_dir in self._temp_dirs:
self._temp_dirs.remove(temp_dir)
return None
# Clean up temp directory (images are saved to cache)
if temp_dir.exists():
shutil.rmtree(temp_dir)
if temp_dir in self._temp_dirs:
self._temp_dirs.remove(temp_dir)
# Copy images from cache to output location
if cache_dir:
cached_image_dir = cache_dir / "images"
if cached_image_dir.exists() and any(cached_image_dir.iterdir()):
return self._copy_images_to_output(
cached_image_dir, output_path, show_progress
)
return None
def _copy_images_to_output(
self, source_dir: Path, output_path: Path, show_progress: bool = False
) -> Path | None:
"""Copy images from cache to output location (next to markdown file)."""
output_path = Path(output_path)
# Determine output images directory (sibling to markdown file)
if output_path.suffix: # It's a file path like "output.md"
output_images_dir = output_path.parent / "images"
else: # It's a directory
output_images_dir = output_path / "images"
# Don't copy if already at output location
if output_images_dir.resolve() == Path(source_dir).resolve():
return output_images_dir
# Copy images to output location
output_images_dir.mkdir(parents=True, exist_ok=True)
copied_count = 0
for img in source_dir.iterdir():
if img.is_file():
shutil.copy2(img, output_images_dir / img.name)
copied_count += 1
if show_progress and copied_count > 0:
print(
f"Copied {copied_count} images to: {output_images_dir}", file=sys.stderr
)
return output_images_dir
# =============================================================================
# PDF PROCESSING
# =============================================================================
def check_dependencies():
"""Check if required packages are installed."""
missing = []
try:
import pymupdf
except ImportError:
missing.append("pymupdf")
try:
import docling
except ImportError:
missing.append("docling")
try:
import docling_core
except ImportError:
missing.append("docling-core")
if missing:
print(f"ERROR: Missing dependencies: {', '.join(missing)}", file=sys.stderr)
print(
"Install with: uv pip install pymupdf docling docling-core", file=sys.stderr
)
return False
return True
def convert_pdf(
pdf_path, image_dir, show_progress=False, images_scale=4.0, image_prefix=""
):
"""Convert PDF to markdown using Docling."""
from extractor import extract_pdf_docling
markdown, _image_paths = extract_pdf_docling(
pdf_path,
output_dir=image_dir,
images_scale=images_scale,
show_progress=show_progress,
image_prefix=image_prefix,
)
return markdown
def add_metadata_header(markdown, pdf_path, total_pages, image_dir=None, cached=False):
"""Add metadata header to markdown output."""
filename = os.path.basename(pdf_path)
header_lines = [
"---",
f"source: {filename}",
f"total_pages: {total_pages}",
f"extracted_at: {datetime.now().isoformat()}",
]
if cached:
header_lines.append("from_cache: true")
if image_dir:
# Use relative path for portability
header_lines.append("images_dir: images")
header_lines.extend(["---", "", ""])
return "\n".join(header_lines) + markdown
# =============================================================================
# MAIN
# =============================================================================
def main():
parser = argparse.ArgumentParser(
description="Convert PDF to Markdown for LLM context (with persistent caching)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python pdf_to_md.py document.pdf # Output to document.md (cached)
python pdf_to_md.py document.pdf output.md # Custom output path
python pdf_to_md.py document.pdf --clear-cache # Clear cache and re-extract
python pdf_to_md.py --clear-all-cache # Clear entire cache
Caching:
PDFs are cached in ~/.cache/pdf-to-markdown/
Cache is keyed by file content hash.
Cache persists until explicitly cleared or source PDF changes.
""",
)
parser.add_argument("input", nargs="?", help="Input PDF file path")
parser.add_argument(
"output", nargs="?", help="Output markdown file path (default: <input>.md)"
)
parser.add_argument(
"--no-progress", action="store_true", help="Disable progress indicator"
)
# Cache options
parser.add_argument(
"--clear-cache",
action="store_true",
help="Clear cache for this PDF before processing",
)
parser.add_argument(
"--clear-all-cache",
action="store_true",
help="Clear entire cache directory and exit",
)
parser.add_argument(
"--cache-stats", action="store_true", help="Show cache statistics and exit"
)
args = parser.parse_args()
cache_mgr = CacheManager()
# Handle cache management commands
if args.clear_all_cache:
if cache_mgr.clear():
print(f"Cache cleared: {cache_mgr.cache_dir}", file=sys.stderr)
else:
print("Cache was already empty.", file=sys.stderr)
sys.exit(0)
if args.cache_stats:
stats = cache_mgr.get_stats()
print(f"Cache directory: {stats['cache_dir']}", file=sys.stderr)
print(f"Cached PDFs: {stats['entries']}", file=sys.stderr)
print(f"Total size: {stats['total_size_mb']} MB", file=sys.stderr)
sys.exit(0)
# Require input for all other operations
if not args.input:
parser.error("the following arguments are required: input")
# Handle --clear-cache
if args.clear_cache:
if cache_mgr.clear(args.input):
print(f"Cache cleared for: {args.input}", file=sys.stderr)
else:
print(f"No cache found for: {args.input}", file=sys.stderr)
# Validate input exists
if not os.path.exists(args.input):
print(f"ERROR: File not found: {args.input}", file=sys.stderr)
sys.exit(1)
if not args.input.lower().endswith(".pdf"):
print(f"WARNING: File may not be a PDF: {args.input}", file=sys.stderr)
show_progress = sys.stderr.isatty() and not args.no_progress
# Check cache
config = ExtractionConfig(pdf_path=args.input)
valid, cache_key = cache_mgr.is_valid(config)
result = None
image_dir = None
cache_hit = False
total_pages = 0
if valid:
if show_progress:
print("Loading from cache...", file=sys.stderr)
cache_result = cache_mgr.load(cache_key)
if cache_result:
result = cache_result.markdown
total_pages = cache_result.total_pages
cache_hit = True
# Copy images from cache to output location
if cache_result.image_dir:
output_path = args.output or os.path.splitext(args.input)[0] + ".md"
img_mgr = ImageManager()
image_dir = img_mgr._copy_images_to_output(
cache_result.image_dir, output_path, show_progress
)
# Extract if no cache hit
if not cache_hit:
if not check_dependencies():
sys.exit(1)
from extractor import get_page_count
total_pages = get_page_count(args.input)
if not cache_key:
cache_key = cache_mgr.get_key(config)
img_mgr = ImageManager()
temp_image_dir = img_mgr.create_temp_dir(args.input)
try:
if show_progress:
print(
f"Extracting {total_pages} pages with Docling AI (~1 sec/page)...",
file=sys.stderr,
)
result = convert_pdf(
args.input,
image_dir=temp_image_dir,
show_progress=show_progress,
image_prefix=cache_key[:8],
)
except Exception as e:
img_mgr.cleanup()
print(f"ERROR: Conversion failed: {e}", file=sys.stderr)
sys.exit(1)
# Save to cache
extraction_result = ExtractionResult(
markdown=result,
image_dir=temp_image_dir,
total_pages=total_pages,
)
cache_mgr.save(cache_key, extraction_result, config)
if show_progress:
print(f"Cached: {cache_mgr._get_dir(cache_key)}", file=sys.stderr)
# Finalize images
output_path = args.output or os.path.splitext(args.input)[0] + ".md"
image_dir = img_mgr.finalize_images(
temp_dir=temp_image_dir,
cache_dir=cache_mgr._get_dir(cache_key),
output_path=output_path,
show_progress=show_progress,
)
# Format output
output = result
img_mgr_for_output = ImageManager() # Fresh instance for output processing
referenced_images = (
img_mgr_for_output.extract_references(result) if result else set()
)
if image_dir:
output = img_mgr_for_output.enhance_markdown(output, image_dir)
images = img_mgr_for_output.get_info(
image_dir, referenced_only=referenced_images
)
if images:
output += img_mgr_for_output.create_summary(images)
output = add_metadata_header(
output, args.input, total_pages, image_dir, cached=cache_hit
)
# Write output
output_path = args.output or os.path.splitext(args.input)[0] + ".md"
with open(output_path, "w", encoding="utf-8") as f:
f.write(output)
msg = f"Converted {total_pages} pages to: {output_path}"
if cache_hit:
msg += " (from cache)"
if image_dir:
images = img_mgr_for_output.get_info(
image_dir, referenced_only=referenced_images
)
if images:
msg += f" ({len(images)} images)"
print(msg, file=sys.stderr)
if __name__ == "__main__":
main()
Related skills
FAQ
What library does it use?
It uses IBM Docling AI with the TableFormer model for high-accuracy table extraction, run in a dedicated Python virtual environment.
Does it re-process the same PDF every time?
No. PDFs are aggressively cached by content hash, so the first extraction is slow but every subsequent request is instant.