Setting the file. One moment.
Convert Word To Md · Convert Word To Md · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page scripts/ convert_word_to_md.py
Python · 301 lines · 11 KB
containing the Markdown and its images, in this layout:
17
18 <name>/
19 img/
20 img001.<ext>
21 img002.<ext>
22 ...
23 <name>.md (image references are relative: img/imgNNN.ext)
24
25 - Single file mode: the "<name>/" folder is created next to the source
26 file, or at -o/--output (treated as the exact destination folder) if
27 given.
28 - Batch/directory mode: a "<name>/" folder is created next to each source
29 file, or under -o/--output (treated as a parent directory, created if
30 missing) if given, preserving relative subfolder structure when
31 --recursive is used.
32 - If a document has no embedded images, no "img/" folder is created.
33
34 Exit codes:
35 0 - all requested conversions succeeded
36 1 - one or more conversions failed (partial success in batch mode)
37 2 - required dependency ("markitdown") is not installed
38 3 - invalid input (path not found, or single-file input is not .docx)
39 """
40 import argparse
41 import re
42 import shutil
43 import sys
44 import zipfile
45 from pathlib import Path
46 from xml.etree import ElementTree as ET
47
48 EXIT_OK = 0
49 EXIT_CONVERSION_FAILED = 1
50 EXIT_MISSING_DEPENDENCY = 2
51 EXIT_INVALID_INPUT = 3
52
53 _W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
54 _R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
55 _REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
56
57 # MarkItDown embeds images as a literal truncated placeholder, e.g.
58 #  -- NOT real base64 data. This pattern
59 # matches that placeholder so it can be swapped for a real relative path.
60 _PLACEHOLDER_IMAGE_RE = re.compile(
61 r '! \[ ([ ^ \] ] * ) \]\( data:image/ [ a-zA-Z0-9.+- ] + ;base64 [ ^) ] * \) '
62 )
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[docx]"' ,
75 file = sys.stderr,
76 )
77 sys.exit( EXIT_MISSING_DEPENDENCY )
78
79
80 def _document_order_media (docx_path: Path):
81 """Return [(rel_id, media_zip_path), ...] in the order images appear in
82 word/document.xml (via r:embed / r:id), resolved through
83 word/_rels/document.xml.rels. Returns [] if the document has no body
84 part or no images (e.g. malformed docx falls back gracefully)."""
85 try :
86 with zipfile.ZipFile(docx_path) as z:
87 if "word/document.xml" not in z.namelist() or \
88 "word/_rels/document.xml.rels" not in z.namelist():
89 return []
90 rels_xml = z.read( "word/_rels/document.xml.rels" )
91 doc_xml = z.read( "word/document.xml" )
92 except (zipfile.BadZipFile, KeyError , OSError ):
93 return []
94
95 try :
96 rels_root = ET .fromstring(rels_xml)
97 doc_root = ET .fromstring(doc_xml)
98 except ET .ParseError:
99 return []
100
101 rel_map = {}
102 for rel in rels_root.findall( f " {{{ _REL_NS }}} Relationship" ):
103 rel_map[rel.get( "Id" )] = rel.get( "Target" )
104
105 ordered_rel_ids = []
106 for elem in doc_root.iter():
107 tag = elem.tag.rsplit( "}" , 1 )[ - 1 ]
108 if tag == "blip" :
109 rid = elem.get( f " {{{ _R_NS }}} embed" )
110 elif tag == "imagedata" :
111 rid = elem.get( f " {{{ _R_NS }}} id" )
112 else :
113 rid = None
114 if rid:
115 ordered_rel_ids.append(rid)
116 ordered_media = []
117 for rid in ordered_rel_ids:
118 target = rel_map.get(rid)
119 if not target or "media/" not in target:
120 continue
121 import posixpath
122 media_path = (
123 target.lstrip( "/" )
124 if target.startswith( "/" )
125 else posixpath.normpath(
126 target if target.startswith( "word/" ) else posixpath.join( "word" , target)
127 )
128 )
129 ordered_media.append((rid, media_path))
130 return ordered_media
131
132
133 def _extract_images (docx_path: Path, img_dir: Path):
134 """Extract embedded images from docx_path into img_dir as img001.ext,
135 img002.ext, ... in document order. Returns the list of written filenames
136 (relative to img_dir), in that same order."""
137 ordered_media = _document_order_media(docx_path)
138 if not ordered_media:
139 return []
140
141 written = []
142 with zipfile.ZipFile(docx_path) as z:
143 names_in_zip = set (z.namelist())
144 for idx, (rid, media_path) in enumerate (ordered_media, start = 1 ):
145 if media_path not in names_in_zip:
146 print ( f "WARNING: { media_path } (rel { rid } ) not found in { docx_path } " , file = sys.stderr)
147 continue
148 ext = Path(media_path).suffix.lstrip( "." ).lower() or "bin"
149 if ext == "jpg" :
150 ext = "jpeg"
151 out_name = f "img { idx :03d} . { ext } "
152 img_dir.mkdir( parents = True , exist_ok = True )
153 (img_dir / out_name).write_bytes(z.read(media_path))
154 written.append(out_name)
155 return written
156
157
158 def _rewrite_image_refs (markdown_text: str , image_files) -> str :
159 """Replace MarkItDown's truncated base64 image placeholders with real
160 relative img/imgNNN.ext references, in left-to-right order. If the
161 counts don't match (unexpected), the placeholders are left as-is rather
162 than risk mismatched references."""
163 matches = list ( _PLACEHOLDER_IMAGE_RE .finditer(markdown_text))
164 if not matches:
165 return markdown_text
166 if len (matches) != len (image_files):
167 print (
168 f "WARNING: found { len (matches) } image placeholder(s) in markdown but "
169 f "extracted { len (image_files) } image file(s); leaving placeholders "
170 "unreplaced to avoid mismatched references." ,
171 file = sys.stderr,
172 )
173 return markdown_text
174
175 counter = { "i" : 0 }
176
177 def _replace (m):
178 name = image_files[counter[ "i" ]]
179 counter[ "i" ] += 1
180 return f ""
181
182 return _PLACEHOLDER_IMAGE_RE .sub(_replace, markdown_text)
183
184
185 def convert_one (md, source: Path, dest_dir: Path) -> bool :
186 """Convert a single .docx file to a "<name>/" folder containing the
187 Markdown file and an "img/" folder of extracted images. Returns True on
188 success."""
189 try :
190 result = md.convert( str (source))
191 except ImportError as exc:
192 print (
193 f "ERROR: A required dependency for converting ' { source.name } ' is not installed. \n "
194 f " { exc }\n "
195 "See references/setup.md for this skill, or run: \n "
196 ' pip install "markitdown[docx]"' ,
197 file = sys.stderr,
198 )
199 sys.exit( EXIT_MISSING_DEPENDENCY )
200 except Exception as exc: # noqa: BLE001 - surface any conversion error
201 print ( f "FAILED { source } -> { exc } " , file = sys.stderr)
202 return False
203
204 try :
205 if dest_dir.exists():
206 shutil.rmtree(dest_dir)
207 dest_dir.mkdir( parents = True , exist_ok = True )
208 image_files = _extract_images(source, dest_dir / "img" )
209 text = _rewrite_image_refs(result.text_content, image_files)
210 md_path = dest_dir / f " { source.stem } .md"
211 md_path.write_text(text, encoding = "utf-8" )
212 except OSError as exc:
213 print ( f "FAILED { source } -> could not write output in { dest_dir } : { exc } " , file = sys.stderr)
214 return False
215
216 img_note = f ", { len (image_files) } image(s)" if image_files else ""
217 print ( f "OK { source } -> { md_path }{ img_note } " )
218 return True
219
220
221 def find_docx_files (root: Path, recursive: bool ):
222 """Return (docx_files, skipped_count) for files directly/recursively under root."""
223 pattern_iter = root.rglob( "*" ) if recursive else root.iterdir()
224 docx_files = []
225 skipped = 0
226 for entry in pattern_iter:
227 if entry.is_dir():
228 continue
229 if entry.suffix.lower() == ".docx" :
230 docx_files.append(entry)
231 else :
232 skipped += 1
233 return sorted (docx_files), skipped
234
235
236 def main () -> int :
237 parser = argparse.ArgumentParser( description = __doc__ .splitlines()[ 0 ])
238 parser.add_argument( "input" , help = "Path to a .docx file or a directory of .docx files" )
239 parser.add_argument(
240 "-o" , "--output" ,
241 help = (
242 "Destination folder for the '<name>/' output (single-file mode), "
243 "or parent directory under which each '<name>/' output folder is "
244 "created (batch mode)"
245 ),
246 )
247 parser.add_argument(
248 "--recursive" , action = "store_true" ,
249 help = "When input is a directory, also search subdirectories" ,
250 )
251 args = parser.parse_args()
252
253 #MarkItDown = _import_markitdown()
254 #md = MarkItDown()
255
256 source = Path(args.input)
257 if not source.exists():
258 print ( f "ERROR: Input path not found: { source } " , file = sys.stderr)
259 return EXIT_INVALID_INPUT
260
261 if source.is_file() and source.suffix.lower() != ".docx" :
262 print (
263 f "ERROR: Unsupported file type ' { source.suffix } '. "
264 "This skill only converts .docx files." ,
265 file = sys.stderr,
266 )
267 return EXIT_INVALID_INPUT
268
269 MarkItDown = _import_markitdown()
270 md = MarkItDown()
271
272 if source.is_file():
273 dest_dir = Path(args.output) if args.output else source.parent / source.stem
274 return EXIT_OK if convert_one(md, source, dest_dir) else EXIT_CONVERSION_FAILED
275
276 # Directory / batch mode
277 docx_files, skipped = find_docx_files(source, args.recursive)
278 if skipped:
279 print ( f "NOTE: skipped { skipped } non-.docx file(s) in { source } " )
280 if not docx_files:
281 print ( f "ERROR: No .docx files found under { source } " , file = sys.stderr)
282 return EXIT_INVALID_INPUT
283
284 out_dir = Path(args.output) if args.output else None
285 success_count = 0
286 for docx_path in docx_files:
287 if out_dir is not None :
288 rel = docx_path.relative_to(source)
289 dest_dir = out_dir / rel.parent / docx_path.stem
290 else :
291 dest_dir = docx_path.parent / docx_path.stem
292 if convert_one(md, docx_path, dest_dir):
293 success_count += 1
294
295 total = len (docx_files)
296 print ( f " \n Converted { success_count } / { total } file(s)." )
297 return EXIT_OK if success_count == total else EXIT_CONVERSION_FAILED
298
299
300 if __name__ == "__main__" :
301 sys.exit(main())