#!/usr/bin/env python3 """ docx-to-md: Convert Word documents (.docx / .doc) into clean Markdown (.md). Primary path uses MarkItDown for good Markdown (headings, lists, tables). Files that MarkItDown cannot read directly (legacy OLE2 binary .doc, or files with a .docx extension that are actually legacy .doc) are first normalized to a real .docx via Word COM, then converted. On successful conversion the original source file can be moved to a "processed" folder. Usage: python convert.py --input "C:\\path\\a.docx" --out "C:\\out" python convert.py --input-dir "C:\\src" --out "C:\\out" \ --processed-dir "C:\\src\\Processed" [--skip-existing] Output: ".md" in --out. Prints a JSON summary to stdout and progress to stderr. Requires: Python 3.12 with markitdown[docx]. Microsoft Word is only needed for legacy/mislabeled binary files (used headless via COM). """ import argparse import json import os import re import shutil import sys import tempfile WD_FORMAT_FILTERED_HTML = 10 # wdFormatFilteredHTML WD_ALERTS_NONE = 0 # wdAlertsNone MSO_SECURITY_FORCE_DISABLE = 3 # block active content on open _word_app = [None] # lazily-created, reused within one process def log(msg): sys.stderr.write(msg + "\n") sys.stderr.flush() def is_ole2(path): """True if the file starts with the OLE2 compound-document magic (legacy).""" try: with open(path, "rb") as f: return f.read(4) == b"\xD0\xCF\x11\xE0" except Exception: return False def markitdown_to_md(path): from markitdown import MarkItDown md = MarkItDown() return md.convert(path).text_content def get_word(): if _word_app[0] is None: import win32com.client app = win32com.client.DispatchEx("Word.Application") try: app.Visible = False except Exception: pass try: app.DisplayAlerts = WD_ALERTS_NONE except Exception: pass try: app.AutomationSecurity = MSO_SECURITY_FORCE_DISABLE except Exception: pass _word_app[0] = app return _word_app[0] def decode_html_bytes(raw): """Decode Word-exported HTML bytes to a unicode string, honoring the meta charset when present (Word filtered HTML is often windows-1252).""" m = re.search(rb'charset=["\']?([\w\-]+)', raw[:4000], re.I) enc = m.group(1).decode("ascii", "ignore").lower() if m else "windows-1252" if enc in ("unicode", "utf16", "utf-16"): enc = "utf-16" for trial in (enc, "windows-1252", "utf-8"): try: return raw.decode(trial) except (LookupError, UnicodeDecodeError): continue return raw.decode("windows-1252", errors="replace") def normalize_with_word(src, work): """Open a legacy/mislabeled file in Word, export filtered HTML, and return the path to a UTF-8 HTML file (encoding-normalized) ready for MarkItDown. Word ignores OOXML SaveAs for files opened in compatibility mode, so we go through filtered HTML, which reliably carries the full document structure. """ app = get_word() out_html = os.path.join(work, "norm.html") doc = app.Documents.Open( src, ConfirmConversions=False, ReadOnly=True, AddToRecentFiles=False) try: doc.SaveAs2(out_html, FileFormat=WD_FORMAT_FILTERED_HTML) finally: doc.Close(False) with open(out_html, "rb") as f: raw = f.read() text = decode_html_bytes(raw) # Force the declared charset to utf-8 so MarkItDown reads the re-saved bytes # correctly regardless of its own charset detection. text = re.sub(r'charset=["\']?[\w\-]+', "charset=utf-8", text, count=1, flags=re.I) utf8_html = os.path.join(work, "norm_utf8.html") with open(utf8_html, "w", encoding="utf-8") as f: f.write(text) return utf8_html def move_to_processed(src, processed_dir): os.makedirs(processed_dir, exist_ok=True) base = os.path.basename(src) stem, ext = os.path.splitext(base) target = os.path.join(processed_dir, base) n = 1 while os.path.exists(target): target = os.path.join(processed_dir, "%s (%d)%s" % (stem, n, ext)) n += 1 shutil.move(src, target) return target def convert_one(src, out_dir, processed_dir, tmp_root): """Convert one document. Returns dict describing the result.""" basename = os.path.splitext(os.path.basename(src))[0] work = tempfile.mkdtemp(prefix="docmd_", dir=tmp_root) try: # Copy locally first: hydrates OneDrive cloud-only files, avoids locks. local = os.path.join(work, os.path.basename(src)) shutil.copy2(src, local) text = None method = None # Primary path: MarkItDown (works for real, zip-based .docx). if not is_ole2(local): try: text = markitdown_to_md(local) method = "markitdown" except Exception: text = None # Fallback: legacy/mislabeled binary. Export filtered HTML via Word # (encoding-normalized to UTF-8), then MarkItDown that. if text is None or not text.strip(): norm_html = normalize_with_word(local, work) text = markitdown_to_md(norm_html) method = "word+html+markitdown" if text is None or not text.strip(): raise RuntimeError("conversion produced empty output") os.makedirs(out_dir, exist_ok=True) out_md = os.path.join(out_dir, basename + ".md") with open(out_md, "w", encoding="utf-8") as f: f.write(text) moved_to = None if processed_dir: moved_to = move_to_processed(src, processed_dir) return {"file": os.path.basename(src), "output": out_md, "method": method, "moved_to": moved_to} finally: shutil.rmtree(work, ignore_errors=True) def gather_inputs(args): inputs = list(args.input or []) if args.input_dir: exts = (".docx", ".doc") for name in sorted(os.listdir(args.input_dir)): if name.startswith("~$"): continue # Office lock/temp file full = os.path.join(args.input_dir, name) if os.path.isfile(full) and name.lower().endswith(exts): inputs.append(full) seen, ordered = set(), [] for p in inputs: ap = os.path.abspath(p) if ap not in seen: seen.add(ap) ordered.append(ap) return ordered def main(): ap = argparse.ArgumentParser(description="Convert Word documents to Markdown.") ap.add_argument("--input", action="append", default=[], help="Path to a .docx/.doc file. Repeat for multiple files.") ap.add_argument("--input-dir", default="", help="Convert every .docx/.doc in this folder (non-recursive).") ap.add_argument("--out", required=True, help="Output directory for .md files.") ap.add_argument("--processed-dir", default="", help="If set, move each source file here after a successful conversion.") ap.add_argument("--skip-existing", action="store_true", help="Skip a file if its output .md already exists and is non-empty.") args = ap.parse_args() import pythoncom pythoncom.CoInitialize() inputs = gather_inputs(args) if not inputs: print(json.dumps({"processed": [], "errors": [ {"file": "", "error": "no input files (use --input or --input-dir)"}]})) return 1 total = len(inputs) processed, errors, skipped = [], [], [] tmp_root = tempfile.gettempdir() try: for i, src in enumerate(inputs, start=1): name = os.path.basename(src) if not os.path.exists(src): errors.append({"file": src, "error": "not found"}) log("[%d/%d] SKIP (not found): %s" % (i, total, name)) continue if args.skip_existing: out_md = os.path.join(args.out, os.path.splitext(name)[0] + ".md") if os.path.exists(out_md) and os.path.getsize(out_md) > 0: skipped.append(name) log("[%d/%d] skip (exists): %s" % (i, total, name)) continue log("[%d/%d] converting: %s ..." % (i, total, name)) try: res = convert_one(src, args.out, args.processed_dir, tmp_root) processed.append(res) extra = " -> moved" if res.get("moved_to") else "" log("[%d/%d] done: %s (%s)%s" % (i, total, name, res["method"], extra)) except Exception as e: errors.append({"file": name, "error": repr(e)}) log("[%d/%d] ERROR: %s -> %r" % (i, total, name, e)) finally: if _word_app[0] is not None: try: _word_app[0].Quit() except Exception: pass pythoncom.CoUninitialize() log("COMPLETE: %d converted, %d errors, %d skipped (of %d)" % (len(processed), len(errors), len(skipped), total)) print(json.dumps({"processed": processed, "errors": errors, "skipped": skipped}, indent=2)) return 1 if errors and not processed else 0 if __name__ == "__main__": sys.exit(main())