Setting the file. One moment.
Convert Excel To Md · Convert Excel To Md · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page scripts/ convert_excel_to_md.py
Python · 354 lines · 14 KB
For each source .xlsx (named "<name>.xlsx"), a folder is created
17 containing the Markdown and its images, in this layout:
18
19 <name>/
20 img/
21 Sheet1_img001.<ext>
22 Sheet2_img001.<ext>
23 ...
24 <name>.md
25
26 MarkItDown renders each sheet as its own "## <SheetName>" section with a
27 Markdown table. This script independently maps embedded images to the
28 sheet they belong to (via the .xlsx zip's drawing relationships) and
29 inserts a "#### Images in this sheet" block right after that sheet's
30 table, before the next "## " heading. This is per-sheet placement (not
31 exact cell position), which is the finest granularity MarkItDown's stable
32 output anchors allow.
33
34 - Single file mode: the "<name>/" folder is created next to the source
35 file, or at -o/--output (treated as the exact destination folder) if
36 given.
37 - Batch/directory mode: a "<name>/" folder is created next to each source
38 file, or under -o/--output (treated as a parent directory, created if
39 missing) if given, preserving relative subfolder structure when
40 --recursive is used.
41 - If a workbook has no embedded images, no "img/" folder or "Images in
42 this sheet" sections are created.
43
44 Exit codes:
45 0 - all requested conversions succeeded
46 1 - one or more conversions failed (partial success in batch mode)
47 2 - required dependency ("markitdown") is not installed
48 3 - invalid input (path not found, or single-file input is not .xlsx)
49 """
50 import argparse
51 import posixpath
52 import re
53 import shutil
54 import sys
55 import zipfile
56 from pathlib import Path
57 from xml.etree import ElementTree as ET
58
59 EXIT_OK = 0
60 EXIT_CONVERSION_FAILED = 1
61 EXIT_MISSING_DEPENDENCY = 2
62 EXIT_INVALID_INPUT = 3
63
64 _REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
65 _MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
66 _R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
67 _A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
68
69 # Matches MarkItDown's per-sheet heading, e.g. "## Sheet1"
70 _SHEET_HEADER_RE = re.compile( r " ^ ## (. + )$ " , re. MULTILINE )
71
72
73 def _import_markitdown ():
74 """Import MarkItDown, failing with a clear, actionable message if absent."""
75 try :
76 from markitdown import MarkItDown
77 return MarkItDown
78 except ImportError :
79 print (
80 "ERROR: The 'markitdown' package is not installed. \n "
81 "See references/setup.md for this skill, or run: \n "
82 ' pip install "markitdown[xlsx]"' ,
83 file = sys.stderr,
84 )
85 sys.exit( EXIT_MISSING_DEPENDENCY )
86
87
88 def _normalize_rel_path (base_dir: str , target: str ) -> str :
89 """Resolve a (possibly relative, e.g. '../media/image1.png') relationship
90 target against the directory containing the part that referenced it."""
91 if target.startswith( "/" ):
92 return target.lstrip( "/" )
93 return posixpath.normpath(posixpath.join(base_dir, target))
94
95
96 def _sheet_name_to_media (xlsx_path: Path):
97 """Return {sheet_name: [media_zip_path, ...]} in per-sheet document
98 order, by walking workbook.xml -> worksheet -> drawing -> media
99 relationships. Returns {} if anything is missing/malformed (falls back
100 gracefully -- images just won't be extracted for that sheet)."""
101 try :
102 with zipfile.ZipFile(xlsx_path) as z:
103 names = set (z.namelist())
104 if "xl/workbook.xml" not in names or "xl/_rels/workbook.xml.rels" not in names:
105 return {}
106 workbook_xml = z.read( "xl/workbook.xml" )
107 workbook_rels_xml = z.read( "xl/_rels/workbook.xml.rels" )
108
109 sheet_rid = {}
110 for sheet_el in ET .fromstring(workbook_xml).iter( f " {{{ _MAIN_NS }}} sheet" ):
111 name = sheet_el.get( "name" )
112 rid = sheet_el.get( f " {{{ _R_NS }}} id" )
113 if name and rid:
114 sheet_rid[name] = rid
115
116 rid_target = {}
117 for rel in ET .fromstring(workbook_rels_xml).findall( f " {{{ _REL_NS }}} Relationship" ):
118 rid_target[rel.get( "Id" )] = rel.get( "Target" )
119
120 result = {}
121 for sheet_name, rid in sheet_rid.items():
122 target = rid_target.get(rid)
123 if not target:
124 continue
125 # workbook.xml.rels targets are typically relative to "xl/",
126 # but OOXML allows package-absolute targets (leading "/") too.
127 sheet_path = _normalize_rel_path( "xl" , target)
128 if sheet_path not in names or "/" not in sheet_path:
129 continue
130 sheet_dir, sheet_file = sheet_path.rsplit( "/" , 1 )
131 sheet_rels_path = f " { sheet_dir } /_rels/ { sheet_file } .rels"
132 if sheet_rels_path not in names:
133 continue
134
135 drawing_rid = None
136 for d in ET .fromstring(z.read(sheet_path)).iter( f " {{{ _MAIN_NS }}} drawing" ):
137 drawing_rid = d.get( f " {{{ _R_NS }}} id" )
138 break
139 if not drawing_rid:
140 continue
141
142 drawing_target = None
143 for rel in ET .fromstring(z.read(sheet_rels_path)).findall( f " {{{ _REL_NS }}} Relationship" ):
144 if rel.get( "Id" ) == drawing_rid:
145 drawing_target = rel.get( "Target" )
146 break
147 if not drawing_target:
148 continue
149 drawing_path = _normalize_rel_path(sheet_dir, drawing_target)
150 if drawing_path not in names or "/" not in drawing_path:
151 continue
152 drawing_dir, drawing_file = drawing_path.rsplit( "/" , 1 )
153 drawing_rels_path = f " { drawing_dir } /_rels/ { drawing_file } .rels"
154 if drawing_rels_path not in names:
155 continue
156
157 drawing_rel_map = {}
158 for rel in ET .fromstring(z.read(drawing_rels_path)).findall( f " {{{ _REL_NS }}} Relationship" ):
159 drawing_rel_map[rel.get( "Id" )] = rel.get( "Target" )
160
161 media_paths = []
162 for blip in ET .fromstring(z.read(drawing_path)).iter( f " {{{ _A_NS }}} blip" ):
163 embed_rid = blip.get( f " {{{ _R_NS }}} embed" )
164 if not embed_rid:
165 continue
166 rel_target = drawing_rel_map.get(embed_rid)
167 if not rel_target:
168 continue
169 media_path = _normalize_rel_path(drawing_dir, rel_target)
170 if media_path in names:
171 media_paths.append(media_path)
172
173 if media_paths:
174 result[sheet_name] = media_paths
175 return result
176 except (zipfile.BadZipFile, KeyError , OSError , ET .ParseError):
177 return {}
178
179
180 def _sanitize_filename_part (name: str ) -> str :
181 safe = re.sub( r " [ ^A-Za-z0-9_.- ] + " , "_" , name).strip( "_" )
182 return safe or "sheet"
183
184
185 def extract_images (xlsx_path: Path, img_dir: Path):
186 """Extract embedded images from xlsx_path, grouped by sheet name.
187 Returns {sheet_name: [filename, ...]} in per-sheet order. Files are
188 named '<sanitized_sheet_name>_img{N:03d}.<ext>'."""
189 sheet_media = _sheet_name_to_media(xlsx_path)
190 if not sheet_media:
191 return {}
192
193 written = {}
194 with zipfile.ZipFile(xlsx_path) as z:
195 names_in_zip = set (z.namelist())
196 for sheet_idx, (sheet_name, media_paths) in enumerate (sheet_media.items(), start = 1 ):
197 safe_name = f "sheet { sheet_idx :03d} _ { _sanitize_filename_part(sheet_name) } "
198 files = []
199 for idx, media_path in enumerate (media_paths, start = 1 ):
200 if media_path not in names_in_zip:
201 print ( f "WARNING: { media_path } not found in { xlsx_path } " , file = sys.stderr)
202 continue
203 ext = Path(media_path).suffix.lstrip( "." ).lower() or "bin"
204 if ext == "jpg" :
205 ext = "jpeg"
206 out_name = f " { safe_name } _img { idx :03d} . { ext } "
207 img_dir.mkdir( parents = True , exist_ok = True )
208 (img_dir / out_name).write_bytes(z.read(media_path))
209 files.append(out_name)
210 if files:
211 written[sheet_name] = files
212 return written
213
214
215 def insert_sheet_images (markdown_text: str , sheet_images) -> str :
216 """Insert a '#### Images in this sheet' block right after each sheet's
217 section (before the next '## ' heading or end of text). If a sheet has
218 no images, or no '## ' headings are found at all, the text is returned
219 unchanged for that part."""
220 if not sheet_images:
221 return markdown_text
222 matches = list ( _SHEET_HEADER_RE .finditer(markdown_text))
223 if not matches:
224 return markdown_text
225
226 pieces = []
227 last_end = 0
228 for i, m in enumerate (matches):
229 sheet_name = m.group( 1 ).removesuffix( " \r " )
230 start = m.start()
231 end = matches[i + 1 ].start() if i + 1 < len (matches) else len (markdown_text)
232 pieces.append(markdown_text[last_end:start])
233 section = markdown_text[start:end].rstrip( " \n " )
234 images = sheet_images.get(sheet_name)
235 if images:
236 section += " \n\n #### Images in this sheet \n\n "
237 section += " \n " .join( f "" for name in images)
238 pieces.append(section + " \n\n " )
239 last_end = end
240 pieces.append(markdown_text[last_end:])
241 return "" .join(pieces).rstrip() + " \n "
242
243
244 def convert_one (md, source: Path, dest_dir: Path) -> bool :
245 """Convert a single .xlsx file to a '<name>/' folder containing the
246 Markdown file and an 'img/' folder of extracted images. Returns True on
247 success."""
248 try :
249 result = md.convert( str (source))
250 except Exception as exc: # noqa: BLE001 - surface any conversion error
251 print ( f "FAILED { source } -> { exc } " , file = sys.stderr)
252 return False
253
254 try :
255 img_dir = dest_dir / "img"
256 if dest_dir.exists():
257 if img_dir.exists():
258 shutil.rmtree(img_dir)
259 dest_dir.mkdir( parents = True , exist_ok = True )
260 sheet_images = extract_images(source, img_dir)
261 text = insert_sheet_images(result.text_content, sheet_images)
262 md_path = dest_dir / f " { source.stem } .md"
263 md_path.write_text(text, encoding = "utf-8" )
264 except OSError as exc:
265 print ( f "FAILED { source } -> could not write output in { dest_dir } : { exc } " , file = sys.stderr)
266 return False
267
268 img_count = sum ( len (v) for v in sheet_images.values())
269 img_note = f ", { img_count } image(s)" if img_count else ""
270 print ( f "OK { source } -> { md_path }{ img_note } " )
271 return True
272
273
274 def find_xlsx_files (root: Path, recursive: bool ):
275 """Return (xlsx_files, skipped_count) for files directly/recursively under root."""
276 pattern_iter = root.rglob( "*" ) if recursive else root.iterdir()
277 xlsx_files = []
278 skipped = 0
279 for entry in pattern_iter:
280 if entry.is_dir():
281 continue
282 if entry.suffix.lower() == ".xlsx" :
283 xlsx_files.append(entry)
284 else :
285 skipped += 1
286 return sorted (xlsx_files), skipped
287
288
289 def main () -> int :
290 parser = argparse.ArgumentParser( description = __doc__ .splitlines()[ 0 ])
291 parser.add_argument( "input" , help = "Path to a .xlsx file or a directory of .xlsx files" )
292 parser.add_argument(
293 "-o" , "--output" ,
294 help = (
295 "Destination folder for the '<name>/' output (single-file mode), "
296 "or parent directory under which each '<name>/' output folder is "
297 "created (batch mode)"
298 ),
299 )
300 parser.add_argument(
301 "--recursive" , action = "store_true" ,
302 help = "When input is a directory, also search subdirectories" ,
303 )
304 args = parser.parse_args()
305
306 #MarkItDown = _import_markitdown()
307 #md = MarkItDown()
308
309 source = Path(args.input)
310 if not source.exists():
311 print ( f "ERROR: Input path not found: { source } " , file = sys.stderr)
312 return EXIT_INVALID_INPUT
313
314 if source.is_file() and source.suffix.lower() != ".xlsx" :
315 print (
316 f "ERROR: Unsupported file type ' { source.suffix } '. "
317 "This skill only converts .xlsx files." ,
318 file = sys.stderr,
319 )
320 return EXIT_INVALID_INPUT
321
322 MarkItDown = _import_markitdown()
323 md = MarkItDown()
324
325 if source.is_file():
326 dest_dir = Path(args.output) if args.output else source.parent / source.stem
327 return EXIT_OK if convert_one(md, source, dest_dir) else EXIT_CONVERSION_FAILED
328
329 # Directory / batch mode
330 xlsx_files, skipped = find_xlsx_files(source, args.recursive)
331 if skipped:
332 print ( f "NOTE: skipped { skipped } non-.xlsx file(s) in { source } " )
333 if not xlsx_files:
334 print ( f "ERROR: No .xlsx files found under { source } " , file = sys.stderr)
335 return EXIT_INVALID_INPUT
336
337 out_dir = Path(args.output) if args.output else None
338 success_count = 0
339 for xlsx_path in xlsx_files:
340 if out_dir is not None :
341 rel = xlsx_path.relative_to(source)
342 dest_dir = out_dir / rel.parent / xlsx_path.stem
343 else :
344 dest_dir = xlsx_path.parent / xlsx_path.stem
345 if convert_one(md, xlsx_path, dest_dir):
346 success_count += 1
347
348 total = len (xlsx_files)
349 print ( f " \n Converted { success_count } / { total } file(s)." )
350 return EXIT_OK if success_count == total else EXIT_CONVERSION_FAILED
351
352
353 if __name__ == "__main__" :
354 sys.exit(main())