返回 ppt-master
doc_to_md.py
1 #!/usr/bin/env python3
2 """
3 Document to Markdown Converter (hybrid Python + Pandoc fallback)
4
5 Primary formats (pure Python, no external tools required):
6 .docx → mammoth (tables preserved; OMML equations rewritten to inline LaTeX)
7 .html → markdownify + BeautifulSoup
8 .epub → ebooklib + markdownify
9 .ipynb → nbconvert
10
11 Fallback formats (require pandoc installed):
12 .doc .odt .rtf .tex .latex .rst .org .typ
13
14 All paths produce the same output convention:
15 <input>.md Markdown file
16 <input>_files/<asset> Extracted media (relative references in MD)
17 """
18
19 import argparse
20 import base64
21 import hashlib
22 import json
23 import mimetypes
24 import posixpath
25 import re
26 import shutil
27 import subprocess
28 import sys
29 import tempfile
30 import uuid
31 import zipfile
32 from pathlib import Path
33 from urllib.parse import unquote, urlparse
34 from xml.etree import ElementTree as ET
35
36 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
37 if str(_SCRIPTS_DIR) not in sys.path:
38 sys.path.insert(0, str(_SCRIPTS_DIR))
39
40 from console_encoding import configure_utf8_stdio # noqa: E402
41 from _batch import run_path_batch # noqa: E402
42 from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
43
44 configure_utf8_stdio()
45
46 # ─────────────────────────────────────────────────────────────
47 # Format registry
48 # ─────────────────────────────────────────────────────────────
49
50 # Formats handled by pure-Python paths
51 NATIVE_FORMATS = {".docx", ".html", ".htm", ".epub", ".ipynb"}
52
53 # Formats handled by pandoc fallback: suffix → (pandoc input format, description)
54 PANDOC_FORMATS = {
55 ".doc": ("doc", "Microsoft Word 97-2003"),
56 ".odt": ("odt", "OpenDocument Text"),
57 ".rtf": ("rtf", "Rich Text Format"),
58 ".tex": ("latex", "LaTeX"),
59 ".latex": ("latex", "LaTeX"),
60 ".rst": ("rst", "reStructuredText"),
61 ".org": ("org", "Emacs Org-mode"),
62 ".typ": ("typst", "Typst"),
63 }
64
65 # Formats pandoc should extract embedded media from
66 PANDOC_MEDIA_FORMATS = {".odt"}
67 OFFICE_VECTOR_EXTENSIONS = {".emf", ".wmf"}
68 IMAGE_ASSET_SUFFIXES = {
69 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif",
70 ".emf", ".wmf", ".svg",
71 }
72
73 DOCX_NS = {
74 "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
75 "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
76 "a": "http://schemas.openxmlformats.org/drawingml/2006/main",
77 "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
78 "rel": "http://schemas.openxmlformats.org/package/2006/relationships",
79 "v": "urn:schemas-microsoft-com:vml",
80 "o": "urn:schemas-microsoft-com:office:office",
81 "mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
82 "m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
83 }
84 EMU_PER_INCH = 914400
85 MATH_NS = DOCX_NS["m"]
86 W_NS = DOCX_NS["w"]
87 XML_SPACE_ATTR = "{http://www.w3.org/XML/1998/namespace}space"
88
89 # OMML n-ary operator chars (m:nary/m:naryPr/m:chr) → LaTeX command.
90 NARY_OPS = {
91 "∑": r"\sum", "∏": r"\prod", "∐": r"\coprod",
92 "∫": r"\int", "∬": r"\iint", "∭": r"\iiint", "∮": r"\oint",
93 "⋃": r"\bigcup", "⋂": r"\bigcap", "⋁": r"\bigvee", "⋀": r"\bigwedge",
94 }
95 # OMML accent chars (m:acc/m:accPr/m:chr) → LaTeX command.
96 ACCENT_CMDS = {
97 "̂": r"\hat", "̃": r"\tilde", "̄": r"\bar", "→": r"\vec", "⃗": r"\vec",
98 "̇": r"\dot", "̈": r"\ddot", "̌": r"\check", "́": r"\acute", "̀": r"\grave",
99 }
100
101
102 # ─────────────────────────────────────────────────────────────
103 # Shared helpers
104 # ─────────────────────────────────────────────────────────────
105
106 def _format_size(size: int) -> str:
107 for unit in ("B", "KB", "MB"):
108 if size < 1024:
109 return f"{size:.0f} {unit}"
110 size /= 1024
111 return f"{size:.1f} GB"
112
113
114 def _ensure_media_dir(out_file: Path) -> tuple[Path, str]:
115 """Return (absolute media dir, relative dir name) and create the dir."""
116 rel_media_dir = f"{out_file.stem}_files"
117 media_dir = out_file.parent / rel_media_dir
118 media_dir.mkdir(parents=True, exist_ok=True)
119 return media_dir, rel_media_dir
120
121
122 _HTML_IMG_PATTERNS = (
123 re.compile(
124 r'<img\s[^>]*?src="(?P<src>[^"]+)"[^>]*?(?:alt="(?P<alt>[^"]*)")?[^>]*/?\s*>'
125 ),
126 re.compile(
127 r'<img\s[^>]*?alt="(?P<alt>[^"]*)"[^>]*?src="(?P<src>[^"]+)"[^>]*/?\s*>'
128 ),
129 )
130
131
132 def _html_img_to_md(markdown_content: str) -> str:
133 """Convert any leftover <img> HTML tags to ![alt](src) syntax."""
134 def _repl(match: re.Match[str]) -> str:
135 src = match.group("src")
136 alt = match.group("alt") or Path(src).stem
137 return f"![{alt}]({src})"
138
139 for pattern in _HTML_IMG_PATTERNS:
140 markdown_content = pattern.sub(_repl, markdown_content)
141 return markdown_content
142
143
144 def _report_result(out_file: Path, media_dir: Path | None) -> None:
145 size = out_file.stat().st_size
146 print(f"[OK] Saved Markdown to: {out_file} ({_format_size(size)})")
147 if media_dir and media_dir.exists():
148 files = [f for f in media_dir.rglob("*") if f.is_file()]
149 if files:
150 print(f" Extracted {len(files)} media file(s) → {media_dir}")
151
152
153 def _normalize_ext(ext: str | None) -> str:
154 """Return a normalized image extension, including the leading dot."""
155 if not ext:
156 return ".bin"
157 ext = ext.lower()
158 if not ext.startswith("."):
159 ext = f".{ext}"
160 if ext == ".jpe":
161 return ".jpg"
162 return ext
163
164
165 def _image_size(path: Path) -> tuple[int | None, int | None]:
166 """Return bitmap dimensions when Pillow can read the file."""
167 try:
168 from PIL import Image
169 except ImportError:
170 return None, None
171 try:
172 with Image.open(path) as img:
173 return img.width, img.height
174 except (OSError, ValueError):
175 return None, None
176
177
178 def _is_office_vector(ext: str) -> bool:
179 """Return whether an extension is an Office vector preview format."""
180 return ext.lower() in OFFICE_VECTOR_EXTENSIONS
181
182
183 def _write_generic_image_manifest(
184 media_dir: Path,
185 rel_media_dir: str,
186 markdown: str,
187 source_kind: str,
188 ) -> None:
189 """Write lightweight image metadata for non-DOCX converter paths."""
190 if not media_dir.exists():
191 return
192
193 ref_pattern = re.compile(rf"{re.escape(rel_media_dir)}/([^)\s]+)")
194 refs = [Path(match.group(1)).name for match in ref_pattern.finditer(markdown)]
195 occurrence_map: dict[str, list[dict[str, object]]] = {}
196 for index, filename in enumerate(refs, 1):
197 occurrence_map.setdefault(filename, []).append({
198 "occurrence_index": index,
199 "source_ref": f"{rel_media_dir}/{filename}",
200 })
201
202 manifest: list[dict[str, object]] = []
203 for file_path in sorted(path for path in media_dir.iterdir() if path.is_file()):
204 ext = _normalize_ext(file_path.suffix)
205 if ext not in IMAGE_ASSET_SUFFIXES:
206 continue
207 width, height = _image_size(file_path)
208 ratio = width / height if width and height else None
209 asset_kind = "office_vector" if _is_office_vector(ext) else "bitmap"
210 occurrences = occurrence_map.get(file_path.name, [])
211 entry: dict[str, object] = {
212 "index": len(manifest) + 1,
213 "filename": file_path.name,
214 "original_filename": file_path.name,
215 "asset_kind": asset_kind,
216 "svg_renderable": asset_kind != "office_vector",
217 "pptx_native_supported": True,
218 "source_kind": source_kind,
219 "source_ext": ext,
220 "pixel_width": width,
221 "pixel_height": height,
222 "pixel_ratio": round(ratio, 6) if ratio else None,
223 "display_ratio": round(ratio, 6) if ratio else None,
224 "occurrences": occurrences,
225 "usage_count": len(occurrences) if occurrences else 1,
226 }
227 manifest.append(entry)
228
229 if manifest:
230 (media_dir / "image_manifest.json").write_text(
231 json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
232 encoding="utf-8",
233 )
234
235
236 def _local_name(elem: ET.Element) -> str:
237 """Return an XML element local name without its namespace."""
238 return elem.tag.rsplit("}", 1)[-1]
239
240
241 def _relationship_target_path(target: str) -> str:
242 """Normalize a Word relationship target to a DOCX zip path."""
243 target = unquote(target)
244 if target.startswith("/"):
245 normalized = posixpath.normpath(target.lstrip("/"))
246 else:
247 normalized = posixpath.normpath(posixpath.join("word", target))
248 return normalized
249
250
251 def _zip_sha256_for_target(media_hashes: dict[str, str], target: str) -> str | None:
252 """Return the SHA-256 digest for an embedded DOCX part target."""
253 if not target:
254 return None
255 return media_hashes.get(_relationship_target_path(target))
256
257
258 def _length_to_emu(value: str) -> int | None:
259 """Parse a VML CSS length into EMU."""
260 match = re.match(r"^\s*([\d.]+)\s*([a-zA-Z]*)\s*$", value)
261 if not match:
262 return None
263 number = float(match.group(1))
264 unit = (match.group(2) or "pt").lower()
265 factors = {
266 "in": EMU_PER_INCH,
267 "cm": EMU_PER_INCH / 2.54,
268 "mm": EMU_PER_INCH / 25.4,
269 "pt": EMU_PER_INCH / 72,
270 "px": EMU_PER_INCH / 96,
271 }
272 factor = factors.get(unit)
273 if factor is None:
274 return None
275 return int(round(number * factor))
276
277
278 def _vml_display_size_emu(shape: ET.Element | None) -> tuple[int, int]:
279 """Read VML shape width/height from its style attribute."""
280 if shape is None:
281 return 0, 0
282 style = shape.attrib.get("style", "")
283 values: dict[str, str] = {}
284 for part in style.split(";"):
285 if ":" not in part:
286 continue
287 key, value = part.split(":", 1)
288 values[key.strip().lower()] = value.strip()
289 width = _length_to_emu(values.get("width", "")) or 0
290 height = _length_to_emu(values.get("height", "")) or 0
291 return width, height
292
293
294 def _occurrence_entry(
295 *,
296 rel_id: str | None,
297 target: str,
298 width_emu: int,
299 height_emu: int,
300 source_sha256: str | None,
301 source_kind: str,
302 ) -> dict[str, object]:
303 """Build one image metadata occurrence row."""
304 display_ratio = (
305 width_emu / height_emu
306 if width_emu > 0 and height_emu > 0
307 else None
308 )
309 return {
310 "relationship_id": rel_id,
311 "source_target": target,
312 "source_path": _relationship_target_path(target) if target else "",
313 "source_ext": _normalize_ext(Path(target).suffix),
314 "source_sha256": source_sha256,
315 "source_kind": source_kind,
316 "display_width_emu": width_emu,
317 "display_height_emu": height_emu,
318 "display_width_in": round(width_emu / EMU_PER_INCH, 4) if width_emu else None,
319 "display_height_in": round(height_emu / EMU_PER_INCH, 4) if height_emu else None,
320 "display_ratio": round(display_ratio, 6) if display_ratio else None,
321 }
322
323
324 def _docx_image_occurrences(input_file: Path) -> list[dict[str, object]]:
325 """Read DOCX drawing order and Word display dimensions."""
326 try:
327 with zipfile.ZipFile(input_file) as docx:
328 rels_root = ET.fromstring(docx.read("word/_rels/document.xml.rels"))
329 doc_root = ET.fromstring(docx.read("word/document.xml"))
330 media_hashes = {
331 name: hashlib.sha256(docx.read(name)).hexdigest()
332 for name in docx.namelist()
333 if name.startswith("word/media/")
334 }
335 except (KeyError, ET.ParseError, zipfile.BadZipFile, OSError):
336 return []
337
338 rels: dict[str, str] = {}
339 for rel in rels_root.findall("rel:Relationship", DOCX_NS):
340 rel_id = rel.attrib.get("Id")
341 target = rel.attrib.get("Target")
342 if rel_id and target:
343 rels[rel_id] = target
344
345 parent_map = {child: parent for parent in doc_root.iter() for child in parent}
346
347 def _inside_mc_choice(elem: ET.Element) -> bool:
348 parent = parent_map.get(elem)
349 while parent is not None:
350 if _local_name(parent) == "Choice":
351 return True
352 parent = parent_map.get(parent)
353 return False
354
355 occurrences: list[dict[str, object]] = []
356 for elem in doc_root.iter():
357 if _inside_mc_choice(elem):
358 continue
359 local = _local_name(elem)
360 if local == "drawing":
361 container = elem.find(".//wp:inline", DOCX_NS)
362 if container is None:
363 container = elem.find(".//wp:anchor", DOCX_NS)
364 if container is None:
365 continue
366 extent = container.find("wp:extent", DOCX_NS)
367 blip = container.find(".//a:blip", DOCX_NS)
368 if extent is None or blip is None:
369 continue
370
371 rel_id = blip.attrib.get(f"{{{DOCX_NS['r']}}}embed")
372 if not rel_id:
373 rel_id = blip.attrib.get(f"{{{DOCX_NS['r']}}}link")
374 target = rels.get(rel_id or "", "")
375 if not target:
376 continue
377
378 try:
379 width_emu = int(extent.attrib.get("cx", "0"))
380 height_emu = int(extent.attrib.get("cy", "0"))
381 except ValueError:
382 width_emu = 0
383 height_emu = 0
384
385 occurrences.append(_occurrence_entry(
386 rel_id=rel_id,
387 target=target,
388 width_emu=width_emu,
389 height_emu=height_emu,
390 source_sha256=_zip_sha256_for_target(media_hashes, target),
391 source_kind="drawing",
392 ))
393 elif local == "imagedata":
394 rel_id = elem.attrib.get(f"{{{DOCX_NS['r']}}}id")
395 if not rel_id:
396 rel_id = elem.attrib.get(f"{{{DOCX_NS['r']}}}pict")
397 target = rels.get(rel_id or "", "")
398 if not target:
399 continue
400 width_emu, height_emu = _vml_display_size_emu(parent_map.get(elem))
401 occurrences.append(_occurrence_entry(
402 rel_id=rel_id,
403 target=target,
404 width_emu=width_emu,
405 height_emu=height_emu,
406 source_sha256=_zip_sha256_for_target(media_hashes, target),
407 source_kind="vml",
408 ))
409 return occurrences
410
411
412 def _match_occurrence(
413 occurrences: list[dict[str, object]],
414 used_indexes: set[int],
415 index: int,
416 image_bytes: bytes,
417 ) -> dict[str, object] | None:
418 """Match a Mammoth image callback to DOCX metadata."""
419 image_hash = hashlib.sha256(image_bytes).hexdigest()
420 for occurrence_index, occurrence in enumerate(occurrences):
421 if occurrence_index in used_indexes:
422 continue
423 if occurrence.get("source_sha256") == image_hash:
424 used_indexes.add(occurrence_index)
425 return occurrence
426
427 fallback_index = index - 1
428 if fallback_index < len(occurrences) and fallback_index not in used_indexes:
429 used_indexes.add(fallback_index)
430 return occurrences[fallback_index]
431 return None
432
433
434 def _manifest_entry(
435 index: int,
436 filename: str,
437 meta: dict[str, object] | None,
438 file_path: Path,
439 *,
440 original_filename: str | None = None,
441 asset_kind: str = "bitmap",
442 svg_renderable: bool = True,
443 pptx_native_supported: bool = True,
444 ) -> dict[str, object]:
445 width, height = _image_size(file_path)
446 pixel_ratio = width / height if width and height else None
447 entry: dict[str, object] = {
448 "index": index,
449 "filename": filename,
450 "original_filename": original_filename or filename,
451 "asset_kind": asset_kind,
452 "svg_renderable": svg_renderable,
453 "pptx_native_supported": pptx_native_supported,
454 "pixel_width": width,
455 "pixel_height": height,
456 "pixel_ratio": round(pixel_ratio, 6) if pixel_ratio else None,
457 }
458 if meta:
459 entry.update(meta)
460 if entry.get("display_ratio") is None and pixel_ratio:
461 entry["display_ratio"] = round(pixel_ratio, 6)
462 return entry
463
464
465 # ─────────────────────────────────────────────────────────────
466 # OMML (Office Math) → LaTeX
467 # ─────────────────────────────────────────────────────────────
468 #
469 # mammoth drops all math content, so Word-native equations and MathType
470 # formulas saved as Office Math (OMML) vanish from the output. This pure-Python
471 # converter rewrites each <m:oMath> into inline `$...$` LaTeX before mammoth
472 # runs, so formulas survive into the Markdown in document order.
473 #
474 # Scope: OMML only. Classic MathType OLE objects (Equation.DSMT4 / MTEF binary)
475 # carry no OMML — they expose only a WMF/EMF preview image, which mammoth still
476 # emits as a picture. Decoding MTEF is out of scope.
477
478 def _m_child(elem: ET.Element, name: str) -> ET.Element | None:
479 """Return the first OMML child with the given local name."""
480 for child in elem:
481 if _local_name(child) == name:
482 return child
483 return None
484
485
486 def _m_pr_val(elem: ET.Element, prop: str) -> str | None:
487 """Return m:val of a property inside the element's *Pr block (e.g. chr)."""
488 for child in elem:
489 if not _local_name(child).endswith("Pr"):
490 continue
491 for sub in child:
492 if _local_name(sub) == prop:
493 return sub.get(f"{{{MATH_NS}}}val")
494 return None
495
496
497 def _brace(latex: str) -> str:
498 """Wrap multi-char LaTeX in braces so it binds as one super/subscript arg."""
499 return latex if len(latex) <= 1 else "{" + latex + "}"
500
501
502 def _omml_part(elem: ET.Element, name: str) -> str:
503 """Convert a named OMML child (e/num/den/sup/sub/...) to LaTeX."""
504 child = _m_child(elem, name)
505 return _omml_to_latex(child) if child is not None else ""
506
507
508 def _omml_run(elem: ET.Element) -> str:
509 """Concatenate text from an OMML run, skipping property children."""
510 return "".join(
511 c.text or "" for c in elem if _local_name(c) == "t"
512 )
513
514
515 def _omml_children(elem: ET.Element) -> str:
516 """Convert all non-property children in order (default/passthrough rule)."""
517 return "".join(
518 _omml_to_latex(c) for c in elem if not _local_name(c).endswith("Pr")
519 )
520
521
522 def _omml_matrix(elem: ET.Element, *, environment: str) -> str:
523 """Convert a matrix (m:m) or equation array (m:eqArr) to a LaTeX env."""
524 rows: list[str] = []
525 for row in elem:
526 if _local_name(row) not in ("mr", "e"):
527 continue
528 if _local_name(row) == "e": # eqArr stores rows as bare <m:e>
529 rows.append(_omml_to_latex(row))
530 continue
531 cells = [_omml_to_latex(cell) for cell in row if _local_name(cell) == "e"]
532 rows.append(" & ".join(cells))
533 body = r" \\ ".join(rows)
534 return rf"\begin{{{environment}}} {body} \end{{{environment}}}"
535
536
537 def _omml_to_latex(elem: ET.Element) -> str:
538 """Recursively convert one OMML element subtree to a LaTeX string.
539
540 Unknown elements degrade to a concatenation of their children rather than
541 being dropped, so rare constructs lose markup but never lose content.
542 """
543 local = _local_name(elem)
544
545 if local == "t":
546 return elem.text or ""
547 if local == "r":
548 return _omml_run(elem)
549 if local in ("oMath", "oMathPara", "e", "num", "den", "sup", "sub",
550 "deg", "fName", "lim", "box", "borderBox"):
551 return _omml_children(elem)
552 if local == "sSup":
553 return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "sup"))
554 if local == "sSub":
555 return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "sub"))
556 if local == "sSubSup":
557 return (_brace(_omml_part(elem, "e"))
558 + "_" + _brace(_omml_part(elem, "sub"))
559 + "^" + _brace(_omml_part(elem, "sup")))
560 if local == "sPre":
561 return ("{}_" + _brace(_omml_part(elem, "sub"))
562 + "^" + _brace(_omml_part(elem, "sup"))
563 + _brace(_omml_part(elem, "e")))
564 if local == "f":
565 return r"\frac{" + _omml_part(elem, "num") + "}{" + _omml_part(elem, "den") + "}"
566 if local == "rad":
567 deg = _m_child(elem, "deg")
568 body = _omml_part(elem, "e")
569 deg_latex = _omml_to_latex(deg) if deg is not None and len(deg) else ""
570 return rf"\sqrt[{deg_latex}]{{{body}}}" if deg_latex else rf"\sqrt{{{body}}}"
571 if local == "d":
572 beg = _m_pr_val(elem, "begChr")
573 end = _m_pr_val(elem, "endChr")
574 beg = "(" if beg is None else (beg or ".")
575 end = ")" if end is None else (end or ".")
576 inner = "".join(_omml_to_latex(c) for c in elem if _local_name(c) == "e")
577 return rf"\left{beg}{inner}\right{end}"
578 if local == "nary":
579 chr_ = _m_pr_val(elem, "chr") or "∫"
580 op = NARY_OPS.get(chr_, chr_)
581 sub, sup = _m_child(elem, "sub"), _m_child(elem, "sup")
582 out = op
583 if sub is not None and len(sub):
584 out += "_" + _brace(_omml_to_latex(sub))
585 if sup is not None and len(sup):
586 out += "^" + _brace(_omml_to_latex(sup))
587 return out + _brace(_omml_part(elem, "e"))
588 if local == "func":
589 return "\\" + _omml_part(elem, "fName").strip() + _brace(_omml_part(elem, "e"))
590 if local == "limLow":
591 return _brace(_omml_part(elem, "e")) + "_" + _brace(_omml_part(elem, "lim"))
592 if local == "limUpp":
593 return _brace(_omml_part(elem, "e")) + "^" + _brace(_omml_part(elem, "lim"))
594 if local == "bar":
595 cmd = r"\underline" if _m_pr_val(elem, "pos") == "bot" else r"\overline"
596 return cmd + "{" + _omml_part(elem, "e") + "}"
597 if local == "acc":
598 cmd = ACCENT_CMDS.get(_m_pr_val(elem, "chr") or "̂", r"\hat")
599 return cmd + "{" + _omml_part(elem, "e") + "}"
600 if local == "groupChr":
601 return _omml_part(elem, "e")
602 if local == "m":
603 return _omml_matrix(elem, environment="matrix")
604 if local == "eqArr":
605 return _omml_matrix(elem, environment="aligned")
606
607 return _omml_children(elem)
608
609
610 def _make_text_run(text: str) -> ET.Element:
611 """Build a <w:r><w:t xml:space="preserve">text</w:t></w:r> element."""
612 run = ET.Element(f"{{{W_NS}}}r")
613 t = ET.SubElement(run, f"{{{W_NS}}}t")
614 t.set(XML_SPACE_ATTR, "preserve")
615 t.text = text
616 return run
617
618
619 def _make_text_paragraph(text: str) -> ET.Element:
620 """Build a simple Word paragraph containing text."""
621 paragraph = ET.Element(f"{{{W_NS}}}p")
622 paragraph.append(_make_text_run(text))
623 return paragraph
624
625
626 def _docx_inject_math_latex(
627 input_file: Path,
628 ) -> tuple[Path, dict[str, str]] | None:
629 """Replace OMML equations with alphanumeric placeholders in a temp DOCX.
630
631 Returns ``(temp_file, {placeholder: latex})`` or None when the document has
632 no OMML math. Placeholders are plain ``[A-Za-z0-9]`` tokens so mammoth never
633 markdown-escapes the LaTeX; the caller swaps each token for its `$...$` value
634 after mammoth has produced the Markdown.
635 """
636 try:
637 with zipfile.ZipFile(input_file) as docx:
638 document_xml = docx.read("word/document.xml")
639 except (KeyError, zipfile.BadZipFile, OSError):
640 return None
641 try:
642 root = ET.fromstring(document_xml)
643 except ET.ParseError:
644 return None
645
646 parent_map = {child: parent for parent in root.iter() for child in parent}
647 targets: list[tuple[ET.Element, bool]] = []
648 for elem in root.iter():
649 local = _local_name(elem)
650 if local == "oMathPara":
651 targets.append((elem, True))
652 elif local == "oMath":
653 parent = parent_map.get(elem)
654 if parent is None or _local_name(parent) != "oMathPara":
655 targets.append((elem, False))
656 if not targets:
657 return None
658
659 token_base = uuid.uuid4().hex
660 replacements: dict[str, str] = {}
661 for index, (elem, display) in enumerate(targets):
662 parent = parent_map.get(elem)
663 if parent is None:
664 continue
665 latex = _omml_to_latex(elem).strip()
666 position = list(parent).index(elem)
667 parent.remove(elem)
668 if latex:
669 token = f"MATHEQ{token_base}{index:04d}"
670 delim = "$$" if display else "$"
671 replacements[token] = f"{delim}{latex}{delim}"
672 parent.insert(position, _make_text_run(token))
673 if not replacements:
674 return None
675
676 for prefix, uri in DOCX_NS.items():
677 if prefix != "rel":
678 ET.register_namespace(prefix, uri)
679 patched_xml = ET.tostring(root, encoding="utf-8", xml_declaration=True)
680
681 tmp = tempfile.NamedTemporaryFile(suffix=".docx", delete=False)
682 tmp.close()
683 out_path = Path(tmp.name)
684 with zipfile.ZipFile(input_file) as zin, \
685 zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout:
686 for item in zin.infolist():
687 data = patched_xml if item.filename == "word/document.xml" else zin.read(item.filename)
688 zout.writestr(item, data)
689 return out_path, replacements
690
691
692 # ─────────────────────────────────────────────────────────────
693 # DOCX tables → pipe Markdown
694 # ─────────────────────────────────────────────────────────────
695
696 def _docx_paragraph_text(paragraph: ET.Element) -> str:
697 """Extract readable text from one Word paragraph."""
698 parts: list[str] = []
699 for elem in paragraph.iter():
700 local = _local_name(elem)
701 if local == "t":
702 parts.append(elem.text or "")
703 elif local == "tab":
704 parts.append("\t")
705 elif local in {"br", "cr"}:
706 parts.append(" ")
707 return "".join(parts).strip()
708
709
710 def _docx_table_has_media(table: ET.Element) -> bool:
711 """Return whether a table contains image-bearing nodes."""
712 return any(_local_name(elem) in {"drawing", "imagedata"} for elem in table.iter())
713
714
715 def _docx_table_cell_text(cell: ET.Element) -> str:
716 """Extract table-cell text, preserving paragraph breaks as Markdown breaks."""
717 paragraphs: list[str] = []
718 for child in cell:
719 if _local_name(child) == "p":
720 text = _docx_paragraph_text(child)
721 if text:
722 paragraphs.append(text)
723 return "<br>".join(paragraphs)
724
725
726 def _markdown_table_cell(text: str) -> str:
727 """Escape Markdown table delimiters in one cell."""
728 text = re.sub(r"[ \t\r\n]+", " ", text).strip()
729 return text.replace("|", r"\|")
730
731
732 def _docx_table_to_markdown(table: ET.Element) -> str:
733 """Convert a Word table XML node to a pipe Markdown table."""
734 rows: list[list[str]] = []
735 for row in table.findall("w:tr", DOCX_NS):
736 cells = [
737 _markdown_table_cell(_docx_table_cell_text(cell))
738 for cell in row.findall("w:tc", DOCX_NS)
739 ]
740 if cells:
741 rows.append(cells)
742 if not rows:
743 return ""
744
745 width = max(len(row) for row in rows)
746 rows = [row + [""] * (width - len(row)) for row in rows]
747 header, body = rows[0], rows[1:]
748 lines = [
749 "| " + " | ".join(header) + " |",
750 "| " + " | ".join("---" for _ in range(width)) + " |",
751 ]
752 lines.extend("| " + " | ".join(row) + " |" for row in body)
753 return "\n".join(lines)
754
755
756 def _docx_inject_tables_markdown(
757 input_file: Path,
758 ) -> tuple[Path, dict[str, str]] | None:
759 """Replace text-only DOCX tables with Markdown placeholders in a temp DOCX."""
760 try:
761 with zipfile.ZipFile(input_file) as docx:
762 document_xml = docx.read("word/document.xml")
763 except (KeyError, zipfile.BadZipFile, OSError):
764 return None
765 try:
766 root = ET.fromstring(document_xml)
767 except ET.ParseError:
768 return None
769
770 parent_map = {child: parent for parent in root.iter() for child in parent}
771 token_base = uuid.uuid4().hex
772 replacements: dict[str, str] = {}
773 for index, table in enumerate(root.findall(".//w:tbl", DOCX_NS)):
774 if _docx_table_has_media(table):
775 continue
776 markdown = _docx_table_to_markdown(table)
777 if not markdown:
778 continue
779 parent = parent_map.get(table)
780 if parent is None:
781 continue
782 position = list(parent).index(table)
783 token = f"MARKDOWNTABLE{token_base}{index:04d}"
784 parent.remove(table)
785 parent.insert(position, _make_text_paragraph(token))
786 replacements[token] = markdown
787 if not replacements:
788 return None
789
790 for prefix, uri in DOCX_NS.items():
791 if prefix != "rel":
792 ET.register_namespace(prefix, uri)
793 patched_xml = ET.tostring(root, encoding="utf-8", xml_declaration=True)
794
795 tmp = tempfile.NamedTemporaryFile(suffix=".docx", delete=False)
796 tmp.close()
797 out_path = Path(tmp.name)
798 with zipfile.ZipFile(input_file) as zin, \
799 zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout:
800 for item in zin.infolist():
801 data = patched_xml if item.filename == "word/document.xml" else zin.read(item.filename)
802 zout.writestr(item, data)
803 return out_path, replacements
804
805
806 def _clean_mammoth_markdown(markdown: str) -> str:
807 """Remove Mammoth escapes for punctuation that is safe as literal text."""
808 def _repl(match: re.Match[str]) -> str:
809 char = match.group(1)
810 if char == ".":
811 line_start = markdown.rfind("\n", 0, match.start()) + 1
812 line_prefix = markdown[line_start:match.start()]
813 if re.fullmatch(r"\s*\d+", line_prefix):
814 return match.group(0)
815 return char
816
817 return re.sub(r"\\([.(),:])", _repl, markdown)
818
819
820 # ─────────────────────────────────────────────────────────────
821 # DOCX → Markdown (mammoth)
822 # ─────────────────────────────────────────────────────────────
823
824 def _convert_docx(input_file: Path, out_file: Path) -> str:
825 try:
826 import mammoth
827 except ImportError:
828 print("[ERROR] mammoth not installed. Run: pip install mammoth")
829 return ""
830
831 media_dir, rel_media_dir = _ensure_media_dir(out_file)
832 counter = {"n": 0}
833 occurrences = _docx_image_occurrences(input_file)
834 used_occurrence_indexes: set[int] = set()
835 manifest: list[dict[str, object]] = []
836
837 def _save_image(image):
838 counter["n"] += 1
839 index = counter["n"]
840 with image.open() as stream:
841 image_bytes = stream.read()
842
843 meta = _match_occurrence(
844 occurrences,
845 used_occurrence_indexes,
846 index,
847 image_bytes,
848 )
849 source_ext = meta.get("source_ext") if meta else None
850 ext = _normalize_ext(source_ext if isinstance(source_ext, str) else None)
851 if ext == ".bin":
852 ext = _normalize_ext(mimetypes.guess_extension(image.content_type))
853
854 original_filename = f"image_{index:03d}{ext}"
855 original_path = media_dir / original_filename
856 original_path.write_bytes(image_bytes)
857
858 filename = original_filename
859 output_path = original_path
860 asset_kind = "office_vector" if _is_office_vector(ext) else "bitmap"
861 svg_renderable = asset_kind != "office_vector"
862 pptx_native_supported = True
863
864 manifest.append(_manifest_entry(
865 index,
866 filename,
867 meta,
868 output_path,
869 original_filename=original_filename,
870 asset_kind=asset_kind,
871 svg_renderable=svg_renderable,
872 pptx_native_supported=pptx_native_supported,
873 ))
874 return {"src": f"{rel_media_dir}/{filename}"}
875
876 # Rewrite OMML equations to LaTeX placeholders before mammoth (which would
877 # otherwise drop them); the placeholders are swapped back below.
878 math_injection = _docx_inject_math_latex(input_file)
879 if math_injection is not None:
880 math_file, math_replacements = math_injection
881 else:
882 math_file, math_replacements = None, {}
883 table_file = None
884 table_replacements: dict[str, str] = {}
885 mammoth_source = math_file or input_file
886 table_injection = _docx_inject_tables_markdown(mammoth_source)
887 if table_injection is not None:
888 table_file, table_replacements = table_injection
889 mammoth_source = table_file
890 try:
891 with mammoth_source.open("rb") as f:
892 result = mammoth.convert_to_markdown(
893 f,
894 convert_image=mammoth.images.img_element(_save_image),
895 )
896 finally:
897 if table_file is not None:
898 try:
899 table_file.unlink()
900 except OSError:
901 pass
902 if math_file is not None:
903 try:
904 math_file.unlink()
905 except OSError:
906 pass
907
908 markdown = result.value
909 for token, table_markdown in table_replacements.items():
910 markdown = markdown.replace(token, table_markdown)
911 for token, latex in math_replacements.items():
912 markdown = markdown.replace(token, latex)
913 markdown = _html_img_to_md(markdown)
914 markdown = _clean_mammoth_markdown(markdown)
915 out_file.write_text(markdown, encoding="utf-8")
916
917 if manifest:
918 (media_dir / "image_manifest.json").write_text(
919 json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
920 encoding="utf-8",
921 )
922
923 if not any(media_dir.iterdir()):
924 media_dir.rmdir()
925 media_dir = None # type: ignore[assignment]
926
927 for msg in result.messages:
928 if msg.type == "warning":
929 print(f" [warn] {msg.message}")
930
931 _report_result(out_file, media_dir)
932 return markdown
933
934
935 # ─────────────────────────────────────────────────────────────
936 # HTML → Markdown (markdownify + BeautifulSoup)
937 # ─────────────────────────────────────────────────────────────
938
939 def _save_data_uri(data_uri: str, media_dir: Path, index: int) -> str | None:
940 """Decode data:image/...;base64,... into a file; return filename or None."""
941 match = re.match(r"data:(?P<mime>[^;]+);base64,(?P<data>.+)", data_uri)
942 if not match:
943 return None
944 mime = match.group("mime")
945 ext = mimetypes.guess_extension(mime) or ".bin"
946 if ext == ".jpe":
947 ext = ".jpg"
948 filename = f"image_{index:03d}{ext}"
949 try:
950 (media_dir / filename).write_bytes(base64.b64decode(match.group("data")))
951 except Exception:
952 return None
953 return filename
954
955
956 def _copy_local_image(src: str, base_dir: Path, media_dir: Path, index: int) -> str | None:
957 """Copy a local image (relative or file://) into media_dir."""
958 parsed = urlparse(src)
959 if parsed.scheme in ("http", "https"):
960 return None
961 path_str = unquote(parsed.path if parsed.scheme == "file" else src)
962 candidate = Path(path_str)
963 if not candidate.is_absolute():
964 candidate = (base_dir / candidate).resolve()
965 if not candidate.is_file():
966 return None
967 ext = candidate.suffix or ".bin"
968 filename = f"image_{index:03d}{ext}"
969 shutil.copy2(candidate, media_dir / filename)
970 return filename
971
972
973 def _download_remote_image(url: str, media_dir: Path, index: int) -> str | None:
974 """Best-effort download of a remote image. Silent on failure."""
975 try:
976 import requests
977 except ImportError:
978 return None
979 try:
980 resp = requests.get(url, timeout=10, stream=True)
981 resp.raise_for_status()
982 except Exception:
983 return None
984 content_type = resp.headers.get("Content-Type", "").split(";")[0].strip()
985 ext = mimetypes.guess_extension(content_type) if content_type else None
986 if not ext:
987 ext = Path(urlparse(url).path).suffix or ".bin"
988 if ext == ".jpe":
989 ext = ".jpg"
990 filename = f"image_{index:03d}{ext}"
991 (media_dir / filename).write_bytes(resp.content)
992 return filename
993
994
995 def _process_html_images(html: str, base_dir: Path, media_dir: Path, rel_media_dir: str) -> str:
996 """Extract & rewrite all <img> srcs in an HTML string."""
997 try:
998 from bs4 import BeautifulSoup
999 except ImportError:
1000 print("[ERROR] beautifulsoup4 not installed. Run: pip install beautifulsoup4")
1001 return html
1002
1003 soup = BeautifulSoup(html, "html.parser")
1004 index = 0
1005 for img in soup.find_all("img"):
1006 src = img.get("src", "")
1007 if not src:
1008 continue
1009 index += 1
1010 if src.startswith("data:"):
1011 filename = _save_data_uri(src, media_dir, index)
1012 elif urlparse(src).scheme in ("http", "https"):
1013 filename = _download_remote_image(src, media_dir, index)
1014 else:
1015 filename = _copy_local_image(src, base_dir, media_dir, index)
1016 if filename:
1017 img["src"] = f"{rel_media_dir}/{filename}"
1018 return str(soup)
1019
1020
1021 def _convert_html(input_file: Path, out_file: Path) -> str:
1022 try:
1023 from markdownify import markdownify
1024 except ImportError:
1025 print("[ERROR] markdownify not installed. Run: pip install markdownify")
1026 return ""
1027
1028 try:
1029 from bs4 import BeautifulSoup
1030 except ImportError:
1031 print("[ERROR] beautifulsoup4 not installed. Run: pip install beautifulsoup4")
1032 return ""
1033
1034 media_dir, rel_media_dir = _ensure_media_dir(out_file)
1035 raw_html = input_file.read_text(encoding="utf-8", errors="replace")
1036
1037 # Strip non-content elements (head/style/script) so metadata doesn't leak into MD
1038 soup = BeautifulSoup(raw_html, "html.parser")
1039 for tag in soup(["head", "style", "script", "noscript"]):
1040 tag.decompose()
1041 html = str(soup)
1042 html = _process_html_images(html, input_file.parent, media_dir, rel_media_dir)
1043
1044 markdown = markdownify(html, heading_style="ATX", bullets="-")
1045 # Collapse 3+ blank lines to 2 for tidier output
1046 markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
1047 out_file.write_text(markdown, encoding="utf-8")
1048 _write_generic_image_manifest(media_dir, rel_media_dir, markdown, "html_image")
1049
1050 if not any(media_dir.iterdir()):
1051 media_dir.rmdir()
1052 media_dir = None # type: ignore[assignment]
1053
1054 _report_result(out_file, media_dir)
1055 return markdown
1056
1057
1058 # ─────────────────────────────────────────────────────────────
1059 # EPUB → Markdown (ebooklib + markdownify)
1060 # ─────────────────────────────────────────────────────────────
1061
1062 def _sanitize_epub_manifest(src: Path) -> tuple[Path, bool]:
1063 """Return an EPUB path that ebooklib can read.
1064
1065 Some EPUBs contain OPF manifest entries pointing at files that are missing
1066 from the ZIP archive. ebooklib reads every manifest item eagerly, so one
1067 stale entry can abort the whole conversion. When broken entries are found,
1068 this writes a temporary EPUB with those manifest items and matching spine
1069 refs removed.
1070 """
1071 OPF_NS = "http://www.idpf.org/2007/opf"
1072 CONT_NS = "urn:oasis:names:tc:opendocument:xmlns:container"
1073
1074 try:
1075 with zipfile.ZipFile(src, "r") as zin:
1076 names = set(zin.namelist())
1077 if "META-INF/container.xml" not in names:
1078 return src, False
1079
1080 container_root = ET.fromstring(zin.read("META-INF/container.xml"))
1081 rootfile_el = container_root.find(f".//{{{CONT_NS}}}rootfile")
1082 if rootfile_el is None:
1083 return src, False
1084
1085 opf_path = rootfile_el.get("full-path")
1086 if not opf_path or opf_path not in names:
1087 return src, False
1088
1089 opf_root = ET.fromstring(zin.read(opf_path))
1090 manifest_el = opf_root.find(f"{{{OPF_NS}}}manifest")
1091 if manifest_el is None:
1092 return src, False
1093
1094 opf_dir = posixpath.dirname(opf_path)
1095 bad_ids: list[str] = []
1096 bad_hrefs: list[str] = []
1097 for item_el in list(manifest_el.findall(f"{{{OPF_NS}}}item")):
1098 href = item_el.get("href", "")
1099 if not href:
1100 continue
1101 rel = unquote(href)
1102 zpath = posixpath.normpath(
1103 posixpath.join(opf_dir, rel) if opf_dir else rel
1104 )
1105 if zpath in names:
1106 continue
1107
1108 item_id = item_el.get("id", "")
1109 if item_id:
1110 bad_ids.append(item_id)
1111 bad_hrefs.append(href)
1112 manifest_el.remove(item_el)
1113
1114 if not bad_hrefs:
1115 return src, False
1116
1117 spine_el = opf_root.find(f"{{{OPF_NS}}}spine")
1118 if spine_el is not None and bad_ids:
1119 for itemref in list(spine_el.findall(f"{{{OPF_NS}}}itemref")):
1120 if itemref.get("idref") in bad_ids:
1121 spine_el.remove(itemref)
1122
1123 ET.register_namespace("", OPF_NS)
1124 ET.register_namespace("dc", "http://purl.org/dc/elements/1.1/")
1125 new_opf = ET.tostring(opf_root, encoding="utf-8", xml_declaration=True)
1126
1127 tmp = tempfile.NamedTemporaryFile(suffix=".epub", delete=False)
1128 tmp.close()
1129 out_path = Path(tmp.name)
1130
1131 with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zout:
1132 if "mimetype" in names:
1133 zout.writestr(
1134 zipfile.ZipInfo("mimetype"),
1135 zin.read("mimetype"),
1136 compress_type=zipfile.ZIP_STORED,
1137 )
1138 for name in zin.namelist():
1139 if name == "mimetype":
1140 continue
1141 if name == opf_path:
1142 zout.writestr(name, new_opf)
1143 else:
1144 zout.writestr(name, zin.read(name))
1145
1146 preview = ", ".join(bad_hrefs[:3])
1147 if len(bad_hrefs) > 3:
1148 preview += " ..."
1149 print(
1150 f"[INFO] EPUB manifest sanitized: removed "
1151 f"{len(bad_hrefs)} broken item(s) [{preview}]"
1152 )
1153 return out_path, True
1154 except (zipfile.BadZipFile, ET.ParseError, OSError) as exc:
1155 print(
1156 f"[WARN] EPUB sanitize skipped "
1157 f"({exc.__class__.__name__}: {exc}); using original file"
1158 )
1159 return src, False
1160
1161
1162 def _epub_image_candidates(src: str, document_name: str) -> list[str]:
1163 """Build lookup candidates for an image reference inside an EPUB document."""
1164 parsed = urlparse(src)
1165 raw_path = parsed.path if parsed.scheme else src
1166 decoded_path = unquote(raw_path)
1167 normalized_path = posixpath.normpath(decoded_path).lstrip("/")
1168 document_dir = posixpath.dirname(document_name)
1169 relative_path = posixpath.normpath(
1170 posixpath.join(document_dir, decoded_path)
1171 ).lstrip("/")
1172
1173 candidates = [
1174 src,
1175 raw_path,
1176 decoded_path,
1177 normalized_path,
1178 relative_path,
1179 Path(decoded_path).name,
1180 Path(normalized_path).name,
1181 ]
1182 return [candidate for candidate in dict.fromkeys(candidates) if candidate]
1183
1184
1185 def _convert_epub(input_file: Path, out_file: Path) -> str:
1186 try:
1187 import ebooklib
1188 from ebooklib import epub
1189 from markdownify import markdownify
1190 from bs4 import BeautifulSoup
1191 except ImportError as e:
1192 print(f"[ERROR] Missing dependency: {e.name}. "
1193 f"Run: pip install ebooklib markdownify beautifulsoup4")
1194 return ""
1195
1196 media_dir, rel_media_dir = _ensure_media_dir(out_file)
1197 sanitized_path, is_temp_copy = _sanitize_epub_manifest(input_file)
1198 try:
1199 book = epub.read_epub(str(sanitized_path))
1200 finally:
1201 if is_temp_copy:
1202 try:
1203 sanitized_path.unlink()
1204 except OSError:
1205 pass
1206
1207 # Extract images, remembering original path → new filename mapping
1208 img_map: dict[str, str] = {}
1209 index = 0
1210 for item in book.get_items_of_type(ebooklib.ITEM_IMAGE):
1211 index += 1
1212 ext = Path(item.file_name).suffix or ".bin"
1213 filename = f"image_{index:03d}{ext}"
1214 (media_dir / filename).write_bytes(item.get_content())
1215 # Map both full and basename for robust lookup
1216 img_map[item.file_name] = filename
1217 img_map[Path(item.file_name).name] = filename
1218
1219 # Iterate document items in spine order
1220 html_parts: list[str] = []
1221 spine_ids = [sid for sid, _ in book.spine]
1222 id_to_item = {it.get_id(): it for it in book.get_items_of_type(ebooklib.ITEM_DOCUMENT)}
1223 for sid in spine_ids:
1224 item = id_to_item.get(sid)
1225 if item is None:
1226 continue
1227 soup = BeautifulSoup(item.get_content(), "html.parser")
1228 for img in soup.find_all("img"):
1229 src = img.get("src", "")
1230 if not src:
1231 continue
1232 # Try exact match, basename, decoded path, and path relative to this XHTML file.
1233 candidates = _epub_image_candidates(src, item.file_name)
1234 resolved = next((img_map[c] for c in candidates if c in img_map), None)
1235 if resolved:
1236 img["src"] = f"{rel_media_dir}/{resolved}"
1237 body = soup.find("body") or soup
1238 html_parts.append(str(body))
1239
1240 combined_html = "\n\n".join(html_parts)
1241 markdown = markdownify(combined_html, heading_style="ATX", bullets="-")
1242 markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
1243 out_file.write_text(markdown, encoding="utf-8")
1244 _write_generic_image_manifest(media_dir, rel_media_dir, markdown, "epub_image")
1245
1246 if not any(media_dir.iterdir()):
1247 media_dir.rmdir()
1248 media_dir = None # type: ignore[assignment]
1249
1250 _report_result(out_file, media_dir)
1251 return markdown
1252
1253
1254 # ─────────────────────────────────────────────────────────────
1255 # IPYNB → Markdown (nbconvert)
1256 # ─────────────────────────────────────────────────────────────
1257
1258 def _convert_ipynb(input_file: Path, out_file: Path) -> str:
1259 try:
1260 import nbformat
1261 from nbconvert import MarkdownExporter
1262 from nbconvert.writers import FilesWriter
1263 except ImportError:
1264 print("[ERROR] nbconvert not installed. Run: pip install nbconvert")
1265 return ""
1266
1267 # Pre-process cell-level markdown attachments: nbconvert leaves
1268 # `attachment:<name>` references intact but doesn't write the files.
1269 # Extract them into our outputs dict so FilesWriter picks them up.
1270 nb = nbformat.read(str(input_file), as_version=4)
1271 extra_outputs: dict[str, bytes] = {}
1272 rel_media_dir = f"{out_file.stem}_files"
1273
1274 attach_counter = 0
1275 for cell in nb.cells:
1276 if cell.cell_type != "markdown":
1277 continue
1278 attachments = getattr(cell, "attachments", None) or {}
1279 if not attachments:
1280 continue
1281 for att_name, mime_data in attachments.items():
1282 for mime, b64 in mime_data.items():
1283 attach_counter += 1
1284 ext = mimetypes.guess_extension(mime) or ".bin"
1285 if ext == ".jpe":
1286 ext = ".jpg"
1287 filename = f"attachment_{attach_counter:03d}{ext}"
1288 out_path = f"{rel_media_dir}/{filename}"
1289 try:
1290 extra_outputs[out_path] = base64.b64decode(b64)
1291 except Exception:
1292 continue
1293 # Rewrite source references: attachment:<name> → <rel_path>
1294 src = cell.source if isinstance(cell.source, str) else "".join(cell.source)
1295 src = src.replace(f"attachment:{att_name}", out_path)
1296 cell.source = src
1297
1298 exporter = MarkdownExporter()
1299 body, resources = exporter.from_notebook_node(nb)
1300
1301 # Merge attachment outputs with whatever nbconvert collected
1302 resources.setdefault("outputs", {}).update(extra_outputs)
1303 resources["output_extension"] = ".md"
1304
1305 writer = FilesWriter(build_directory=str(out_file.parent))
1306 writer.write(body, resources, notebook_name=out_file.stem)
1307
1308 markdown = out_file.read_text(encoding="utf-8") if out_file.exists() else body
1309 media_dir = out_file.parent / rel_media_dir
1310 _write_generic_image_manifest(media_dir, rel_media_dir, markdown, "ipynb_image")
1311 _report_result(out_file, media_dir if media_dir.exists() else None)
1312 return markdown
1313
1314
1315 # ─────────────────────────────────────────────────────────────
1316 # Pandoc fallback
1317 # ─────────────────────────────────────────────────────────────
1318
1319 def _check_pandoc() -> bool:
1320 return shutil.which("pandoc") is not None
1321
1322
1323 def _convert_with_pandoc(input_file: Path, out_file: Path, suffix: str) -> str:
1324 if not _check_pandoc():
1325 print(f"[ERROR] Format '{suffix}' requires pandoc. Install it:")
1326 print(" macOS: brew install pandoc")
1327 print(" Ubuntu: sudo apt install pandoc")
1328 print(" Windows: https://pandoc.org/installing.html")
1329 return ""
1330
1331 input_format, _ = PANDOC_FORMATS[suffix]
1332 rel_media_dir = f"{out_file.stem}_files"
1333 media_dir = out_file.parent / rel_media_dir
1334
1335 cmd = [
1336 "pandoc",
1337 "-f", input_format,
1338 "-t", "gfm",
1339 str(input_file.resolve()),
1340 "-o", str(out_file.resolve()),
1341 "--wrap", "none",
1342 "--strip-comments",
1343 ]
1344 if suffix in PANDOC_MEDIA_FORMATS:
1345 cmd.extend(["--extract-media", rel_media_dir])
1346
1347 result = subprocess.run(cmd, capture_output=True, text=True,
1348 cwd=str(out_file.parent))
1349 if result.returncode != 0:
1350 print(f"[ERROR] Pandoc conversion failed:\n{result.stderr}")
1351 return ""
1352 if not out_file.exists():
1353 print("[ERROR] Conversion completed but no output file was generated")
1354 return ""
1355
1356 markdown = out_file.read_text(encoding="utf-8")
1357
1358 # Flatten nested media/ subdir that pandoc creates
1359 nested_media = media_dir / "media"
1360 if nested_media.exists():
1361 for f in nested_media.iterdir():
1362 if f.is_file():
1363 shutil.move(str(f), str(media_dir / f.name))
1364 try:
1365 nested_media.rmdir()
1366 except OSError:
1367 pass
1368 markdown = markdown.replace(f"{rel_media_dir}/media/", f"{rel_media_dir}/")
1369
1370 # Normalize absolute paths to relative
1371 for abs_str in (str(media_dir.resolve()).replace("\\", "/"),
1372 str(media_dir.resolve())):
1373 if abs_str in markdown:
1374 markdown = markdown.replace(abs_str, rel_media_dir)
1375
1376 markdown = _html_img_to_md(markdown)
1377 out_file.write_text(markdown, encoding="utf-8")
1378 _write_generic_image_manifest(media_dir, rel_media_dir, markdown, "pandoc_image")
1379
1380 _report_result(out_file, media_dir if media_dir.exists() else None)
1381 return markdown
1382
1383
1384 # ─────────────────────────────────────────────────────────────
1385 # Dispatcher
1386 # ─────────────────────────────────────────────────────────────
1387
1388 _FORMAT_DESC = {
1389 ".docx": "Microsoft Word (mammoth)",
1390 ".html": "HTML (markdownify)",
1391 ".htm": "HTML (markdownify)",
1392 ".epub": "EPUB (ebooklib)",
1393 ".ipynb": "Jupyter Notebook (nbconvert)",
1394 }
1395
1396
1397 def convert_to_markdown(input_path: str, output_path: str | None = None) -> str:
1398 input_file = Path(input_path)
1399 if not input_file.exists():
1400 print(f"[ERROR] File not found: {input_path}")
1401 return ""
1402
1403 suffix = input_file.suffix.lower()
1404 if suffix not in NATIVE_FORMATS and suffix not in PANDOC_FORMATS:
1405 supported = ", ".join(sorted(NATIVE_FORMATS | PANDOC_FORMATS.keys()))
1406 print(f"[ERROR] Unsupported format: {suffix}")
1407 print(f" Supported: {supported}")
1408 return ""
1409
1410 out_file = Path(output_path) if output_path else input_file.with_suffix(".md")
1411 out_file.parent.mkdir(parents=True, exist_ok=True)
1412
1413 if suffix in NATIVE_FORMATS:
1414 desc = _FORMAT_DESC[suffix]
1415 print(f"[INFO] Converting {desc}: {input_file.name}")
1416 if suffix == ".docx":
1417 markdown = _convert_docx(input_file, out_file)
1418 elif suffix in (".html", ".htm"):
1419 markdown = _convert_html(input_file, out_file)
1420 elif suffix == ".epub":
1421 markdown = _convert_epub(input_file, out_file)
1422 elif suffix == ".ipynb":
1423 markdown = _convert_ipynb(input_file, out_file)
1424 else:
1425 markdown = ""
1426 if markdown:
1427 profile_path = write_conversion_profile_best_effort(
1428 input_path=str(input_file),
1429 markdown_path=out_file,
1430 converter="doc_to_md.py",
1431 conversion_type=suffix.lstrip("."),
1432 )
1433 if profile_path:
1434 print(f" Wrote conversion profile -> {profile_path}")
1435 return markdown
1436
1437 _, format_desc = PANDOC_FORMATS[suffix]
1438 print(f"[INFO] Converting {format_desc} via pandoc: {input_file.name}")
1439 markdown = _convert_with_pandoc(input_file, out_file, suffix)
1440 if markdown:
1441 profile_path = write_conversion_profile_best_effort(
1442 input_path=str(input_file),
1443 markdown_path=out_file,
1444 converter="doc_to_md.py",
1445 conversion_type=suffix.lstrip("."),
1446 )
1447 if profile_path:
1448 print(f" Wrote conversion profile -> {profile_path}")
1449 return markdown
1450
1451
1452 def main() -> int:
1453 parser = argparse.ArgumentParser(
1454 description="Convert documents to Markdown "
1455 "(pure-Python for common formats, pandoc fallback for the rest)",
1456 formatter_class=argparse.RawDescriptionHelpFormatter,
1457 epilog="""
1458 Examples:
1459 python doc_to_md.py lecture.docx # Word → Markdown (mammoth)
1460 python doc_to_md.py lecture.docx notes.html # Convert multiple files
1461 python doc_to_md.py ./docs -o ./markdown # Convert supported files in a directory
1462 python doc_to_md.py article.html # HTML → Markdown (markdownify)
1463 python doc_to_md.py book.epub # EPUB → Markdown (ebooklib)
1464 python doc_to_md.py notebook.ipynb # Jupyter → Markdown (nbconvert)
1465 python doc_to_md.py manuscript.tex # LaTeX → Markdown (pandoc fallback)
1466
1467 Native formats (no pandoc required):
1468 .docx .html/.htm .epub .ipynb
1469
1470 Pandoc fallback formats (require system pandoc):
1471 .doc .odt .rtf .tex/.latex .rst .org .typ
1472 """,
1473 )
1474 parser.add_argument("inputs", nargs="+", help="Input document file(s) or directories")
1475 parser.add_argument(
1476 "-o",
1477 "--output",
1478 help="Output Markdown file for one input, or output directory for multiple inputs/directories",
1479 )
1480 args = parser.parse_args()
1481
1482 supported_suffixes = set(NATIVE_FORMATS) | set(PANDOC_FORMATS)
1483 return run_path_batch(
1484 args.inputs,
1485 supported_suffixes,
1486 args.output,
1487 lambda source, output: bool(convert_to_markdown(str(source), str(output))),
1488 )
1489
1490
1491 if __name__ == "__main__":
1492 raise SystemExit(main())
1493
1493 lines PYTHON