Setting the file. One moment.
Convert PDF To Md · Convert PDF To Md · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page Reference
scripts/ convert_pdf_to_md.py
Python · 321 lines · 11 KB
For each source .pdf (named "<name>.pdf"), a folder is created containing
17 the Markdown and its images, in this layout:
18
19 <name>/
20 img/
21 page001_img001.<ext>
22 page001_img002.<ext>
23 page002_img001.<ext>
24 ...
25 <name>.md
26
27 IMPORTANT: MarkItDown's PDF text extraction does not preserve reliable
28 per-page markers in the returned Markdown (pages are simply joined
29 together, or in some cases returned as a single unmarked block of text).
30 That means there is no safe way to know exactly where, inline, an image
31 should go. Rather than guess and risk misplacing an image next to the
32 wrong paragraph, this script appends a clearly labeled "## Extracted
33 Images" section at the end of the Markdown, with a "### Page N"
34 subheading per page that contains images. This is a deliberate, honest
35 tradeoff -- read the images section separately from the main body text.
36
37 - Single file mode: the "<name>/" folder is created next to the source
38 file, or at -o/--output (treated as the exact destination folder) if
39 given.
40 - Batch/directory mode: a "<name>/" folder is created next to each source
41 file, or under -o/--output (treated as a parent directory, created if
42 missing) if given, preserving relative subfolder structure when
43 --recursive is used.
44 - If a document has no embedded images, no "img/" folder or "Extracted
45 Images" section is created.
46
47 Exit codes:
48 0 - all requested conversions succeeded
49 1 - one or more conversions failed (partial success in batch mode)
50 2 - a required dependency ("markitdown" or "pymupdf") is not installed
51 3 - invalid input (path not found, or single-file input is not .pdf)
52 """
53 import argparse
54 import sys
55 import hashlib
56 import shutil
57 from pathlib import Path
58
59 EXIT_OK = 0
60 EXIT_CONVERSION_FAILED = 1
61 EXIT_MISSING_DEPENDENCY = 2
62 EXIT_INVALID_INPUT = 3
63
64
65 def _import_markitdown ():
66 """Import MarkItDown, failing with a clear, actionable message if absent."""
67 try :
68 from markitdown import MarkItDown
69 return MarkItDown
70 except ImportError :
71 print (
72 "ERROR: The 'markitdown' package is not installed. \n "
73 "See references/setup.md for this skill, or run: \n "
74 ' pip install "markitdown[pdf]"' ,
75 file = sys.stderr,
76 )
77 sys.exit( EXIT_MISSING_DEPENDENCY )
78
79
80 def _import_fitz ():
81 """Import PyMuPDF (module name 'fitz'), failing with a clear message if absent."""
82 try :
83 import fitz
84 import hashlib
85 return fitz
86 except ImportError :
87 print (
88 "ERROR: The 'pymupdf' package is not installed (needed for image "
89 "extraction). \n See references/setup.md for this skill, or run: \n "
90 " pip install pymupdf" ,
91 file = sys.stderr,
92 )
93 sys.exit( EXIT_MISSING_DEPENDENCY )
94
95
96 def extract_images (fitz, pdf_path: Path, img_dir: Path):
97 """Extract embedded images from pdf_path, grouped by 1-based page number.
98 Returns {page_num: [filename, ...]} in per-page image order. Files are
99 named 'page{P:03d}_img{N:03d}.<ext>'. Corrupt/unreadable images are
100 skipped with a warning rather than aborting the whole conversion.
101
102 Two sources are combined and deduplicated:
103 1. Image XObjects via page.get_images(full=True) -- covers most embedded
104 images in modern PDFs.
105 2. Inline image blocks via page.get_text("dict") -- covers images stored
106 directly in the page content stream, which get_images() misses entirely.
107 Deduplication is by image bytes hash so the same raster is never written twice
108 on the same page regardless of which source reported it."""
109 written_by_page = {}
110 try :
111 doc = fitz.open( str (pdf_path))
112 except Exception as exc: # noqa: BLE001
113 print ( f "WARNING: could not open { pdf_path } for image extraction: { exc } " , file = sys.stderr)
114 return written_by_page
115
116 try :
117 for page_index in range ( len (doc)):
118 page = doc[page_index]
119 page_label = page_index + 1
120 seen_hashes: set = set ()
121 raw_images: list[tuple[ bytes , str ]] = [] # (image_bytes, ext)
122
123 # --- Source 1: XObject images ---
124 try :
125 xobjects = page.get_images( full = True )
126 except Exception as exc: # noqa: BLE001
127 print (
128 f "WARNING: failed to enumerate XObject images on page { page_label } "
129 f "of { pdf_path } : { exc } " ,
130 file = sys.stderr,
131 )
132 xobjects = []
133
134 for img in xobjects:
135 xref = img[ 0 ]
136 try :
137 base_image = doc.extract_image(xref)
138 except Exception as exc: # noqa: BLE001
139 print (
140 f "WARNING: failed to extract XObject image xref= { xref } on page "
141 f " { page_label } of { pdf_path } : { exc } " ,
142 file = sys.stderr,
143 )
144 continue
145 img_bytes = base_image.get( "image" ) or b ""
146 if not img_bytes:
147 continue
148 ext = (base_image.get( "ext" ) or "png" ).lower()
149 raw_images.append((img_bytes, ext))
150
151 # --- Source 2: Inline images via get_text("dict") ---
152 try :
153 blocks = page.get_text( "dict" , flags = fitz. TEXT_PRESERVE_IMAGES ).get( "blocks" , [])
154 except Exception as exc: # noqa: BLE001
155 print (
156 f "WARNING: failed to extract text/image dict on page { page_label } "
157 f "of { pdf_path } : { exc } " ,
158 file = sys.stderr,
159 )
160 blocks = []
161
162 for block in blocks:
163 # Image blocks have type == 1
164 if block.get( "type" ) != 1 :
165 continue
166 img_bytes = block.get( "image" ) or b ""
167 if not img_bytes:
168 continue
169 # Derive extension from the block's "ext" key (fitz sets this)
170 ext = (block.get( "ext" ) or "png" ).lower()
171 raw_images.append((img_bytes, ext))
172
173 # --- Write deduplicated images ---
174 page_files = []
175 img_idx = 1
176 for img_bytes, ext in raw_images:
177 h = hashlib.sha256(img_bytes).digest()
178 if h in seen_hashes:
179 continue
180 seen_hashes.add(h)
181 out_name = f "page { page_label :03d} _img { img_idx :03d} . { ext } "
182 img_dir.mkdir( parents = True , exist_ok = True )
183 (img_dir / out_name).write_bytes(img_bytes)
184 page_files.append(out_name)
185 img_idx += 1
186
187 if page_files:
188 written_by_page[page_label] = page_files
189 finally :
190 doc.close()
191
192 return written_by_page
193
194
195 def build_image_appendix (written_by_page) -> str :
196 """Build the '## Extracted Images' appendix text. Returns "" if empty."""
197 if not written_by_page:
198 return ""
199 lines = [ "" , "## Extracted Images" , "" ]
200 for page_num in sorted (written_by_page):
201 lines.append( f "### Page { page_num } " )
202 lines.append( "" )
203 for name in written_by_page[page_num]:
204 lines.append( f "" )
205 lines.append( "" )
206 return " \n " .join(lines).rstrip() + " \n "
207
208
209 def convert_one (md, fitz, source: Path, dest_dir: Path) -> bool :
210 """Convert a single .pdf file to a '<name>/' folder containing the
211 Markdown file and an 'img/' folder of extracted images. Returns True on
212 success."""
213 try :
214 result = md.convert( str (source))
215 except Exception as exc: # noqa: BLE001 - surface any conversion error
216 print ( f "FAILED { source } -> { exc } " , file = sys.stderr)
217 return False
218
219 try :
220 if dest_dir.exists():
221 shutil.rmtree(dest_dir)
222 dest_dir.mkdir( parents = True , exist_ok = True )
223 written_by_page = extract_images(fitz, source, dest_dir / "img" )
224 appendix = build_image_appendix(written_by_page)
225 text = result.text_content.rstrip( " \n " )
226 full_text = f " { text }\n{ appendix } " if appendix else f " { text }\n "
227 md_path = dest_dir / f " { source.stem } .md"
228 md_path.write_text(full_text, encoding = "utf-8" )
229 except OSError as exc:
230 print ( f "FAILED { source } -> could not write output in { dest_dir } : { exc } " , file = sys.stderr)
231 return False
232
233 img_count = sum ( len (v) for v in written_by_page.values())
234 img_note = f ", { img_count } image(s)" if img_count else ""
235 print ( f "OK { source } -> { md_path }{ img_note } " )
236 return True
237
238
239 def find_pdf_files (root: Path, recursive: bool ):
240 """Return (pdf_files, skipped_count) for files directly/recursively under root."""
241 pattern_iter = root.rglob( "*" ) if recursive else root.iterdir()
242 pdf_files = []
243 skipped = 0
244 for entry in pattern_iter:
245 if entry.is_dir():
246 continue
247 if entry.suffix.lower() == ".pdf" :
248 pdf_files.append(entry)
249 else :
250 skipped += 1
251 return sorted (pdf_files), skipped
252
253
254 def main () -> int :
255 parser = argparse.ArgumentParser( description = __doc__ .splitlines()[ 0 ])
256 parser.add_argument( "input" , help = "Path to a .pdf file or a directory of .pdf files" )
257 parser.add_argument(
258 "-o" , "--output" ,
259 help = (
260 "Destination folder for the '<name>/' output (single-file mode), "
261 "or parent directory under which each '<name>/' output folder is "
262 "created (batch mode)"
263 ),
264 )
265 parser.add_argument(
266 "--recursive" , action = "store_true" ,
267 help = "When input is a directory, also search subdirectories" ,
268 )
269 args = parser.parse_args()
270
271 #MarkItDown = _import_markitdown()
272 #fitz = _import_fitz()
273 #md = MarkItDown()
274
275 source = Path(args.input)
276 if not source.exists():
277 print ( f "ERROR: Input path not found: { source } " , file = sys.stderr)
278 return EXIT_INVALID_INPUT
279
280 if source.is_file() and source.suffix.lower() != ".pdf" :
281 print (
282 f "ERROR: Unsupported file type ' { source.suffix } '. "
283 "This skill only converts .pdf files." ,
284 file = sys.stderr,
285 )
286 return EXIT_INVALID_INPUT
287
288 MarkItDown = _import_markitdown()
289 fitz = _import_fitz()
290 md = MarkItDown()
291
292 if source.is_file():
293 dest_dir = Path(args.output) if args.output else source.parent / source.stem
294 return EXIT_OK if convert_one(md, fitz, source, dest_dir) else EXIT_CONVERSION_FAILED
295
296 # Directory / batch mode
297 pdf_files, skipped = find_pdf_files(source, args.recursive)
298 if skipped:
299 print ( f "NOTE: skipped { skipped } non-.pdf file(s) in { source } " )
300 if not pdf_files:
301 print ( f "ERROR: No .pdf files found under { source } " , file = sys.stderr)
302 return EXIT_INVALID_INPUT
303
304 out_dir = Path(args.output) if args.output else None
305 success_count = 0
306 for pdf_path in pdf_files:
307 if out_dir is not None :
308 rel = pdf_path.relative_to(source)
309 dest_dir = out_dir / rel.parent / pdf_path.stem
310 else :
311 dest_dir = pdf_path.parent / pdf_path.stem
312 if convert_one(md, fitz, pdf_path, dest_dir):
313 success_count += 1
314
315 total = len (pdf_files)
316 print ( f " \n Converted { success_count } / { total } file(s)." )
317 return EXIT_OK if success_count == total else EXIT_CONVERSION_FAILED
318
319
320 if __name__ == "__main__" :
321 sys.exit(main())