返回 ppt-master
ppt_to_md.py
1 #!/usr/bin/env python3
2 """
3 PowerPoint to Markdown Converter
4
5 Extracts slide text, tables, SmartArt node structure, speaker notes, and
6 embedded pictures from Open XML PowerPoint files into Markdown.
7
8 Primary use case: PPTX source decks -> Markdown for PPT generation input.
9
10 Hyperlinks present in the source deck are preserved: run-level external URLs
11 and slide-internal jumps are emitted as ``[text](url)`` / ``[text](#slide-N)``,
12 with a shape-level ``click_action`` fallback.
13
14 Dependency:
15 pip install python-pptx
16
17 API stability note:
18 Detecting slide-internal jumps (``ppaction://hlinksldjump``) reads
19 ``run._r`` (the CT_TextRun lxml element) because python-pptx exposes no
20 public API to distinguish an internal jump from an external URL. XY chart
21 extraction likewise reads ``series._element`` for X values and bubble sizes,
22 which the public chart API does not expose. Keep these private accesses
23 localized here and covered by conversion smoke tests.
24 """
25
26 from __future__ import annotations
27
28 import argparse
29 import hashlib
30 import json
31 import re
32 import shutil
33 import sys
34 import zipfile
35 from dataclasses import dataclass
36 from io import BytesIO
37 from pathlib import Path
38 from urllib.parse import quote
39 from xml.etree import ElementTree as ET
40
41 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
42 if str(_SCRIPTS_DIR) not in sys.path:
43 sys.path.insert(0, str(_SCRIPTS_DIR))
44
45 from console_encoding import configure_utf8_stdio # noqa: E402
46 from _batch import run_path_batch # noqa: E402
47 from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
48 from template_fill_pptx.diagram_read import ( # noqa: E402
49 read_smartart_diagrams,
50 smartart_to_markdown,
51 )
52
53 from pptx import Presentation
54 from pptx.enum.action import PP_ACTION
55 from pptx.enum.shapes import MSO_SHAPE_TYPE
56 from pptx.oxml.ns import qn
57
58 configure_utf8_stdio()
59
60
61 EMU_PER_INCH = 914400
62 DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
63 PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
64 RELATIONSHIP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
65 CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
66 OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"}
67 IMAGE_EXT_BY_CONTENT_TYPE = {
68 "image/bmp": "bmp",
69 "image/gif": "gif",
70 "image/jpeg": "jpg",
71 "image/jpg": "jpg",
72 "image/png": "png",
73 "image/svg+xml": "svg",
74 "image/tiff": "tiff",
75 "image/x-emf": "emf",
76 "image/x-wmf": "wmf",
77 }
78 LEGACY_GENERATED_IMAGE_RE = re.compile(r"^slide_\d{2}_image_\d{2}\.[A-Za-z0-9]+$")
79 _READBACK_SLIDE_HEADING_RE = re.compile(r"^## Slide\s+\d+\s*$")
80 _READBACK_NOTES_HEADING_RE = re.compile(r"^### Speaker Notes\s*$")
81
82 # Hyperlink schemes dropped during extraction (a blacklist of known-dangerous
83 # schemes). PowerPoint also rejects unrecognized schemes at open time, so the
84 # residual risk from schemes not listed here is low.
85 UNSUPPORTED_URL_SCHEMES = ("javascript:", "data:", "vbscript:", "file:")
86
87
88 SUPPORTED_FORMATS = {
89 ".pptx": "PowerPoint Presentation",
90 ".pptm": "Macro-enabled PowerPoint Presentation",
91 ".ppsx": "PowerPoint Slide Show",
92 ".ppsm": "Macro-enabled PowerPoint Slide Show",
93 ".potx": "PowerPoint Template",
94 ".potm": "Macro-enabled PowerPoint Template",
95 }
96
97
98 @dataclass
99 class LeafShape:
100 """Flattened leaf shape with stable position ordering."""
101
102 shape: object
103 top: int
104 left: int
105
106
107 @dataclass
108 class SavedPicture:
109 """Extracted image asset plus manifest metadata."""
110
111 filename: str
112 manifest_entry: dict[str, object]
113 is_new_asset: bool
114
115
116 def normalize_text(value: str) -> str:
117 """Collapse whitespace while preserving paragraph boundaries elsewhere."""
118 value = value.replace("\r\n", "\n").replace("\r", "\n")
119 lines = [re.sub(r"\s+", " ", line).strip() for line in value.split("\n")]
120 lines = [line for line in lines if line]
121 return "\n".join(lines)
122
123
124 def _escape_readback_control_lines(value: str) -> str:
125 """Escape ordinary text lines that collide with converter section markers."""
126 lines = value.split("\n")
127 return "\n".join(
128 f"\\{line}"
129 if (
130 _READBACK_SLIDE_HEADING_RE.fullmatch(line)
131 or _READBACK_NOTES_HEADING_RE.fullmatch(line)
132 )
133 else line
134 for line in lines
135 )
136
137
138 def normalize_ext(ext: str | None, content_type: str | None = None) -> str:
139 """Return a lowercase extension without a leading dot."""
140 if ext:
141 ext = ext.lower().lstrip(".")
142 if ext == "jpeg":
143 return "jpg"
144 return ext
145 if content_type:
146 return IMAGE_EXT_BY_CONTENT_TYPE.get(content_type.lower(), "bin")
147 return "bin"
148
149
150 def sanitize_filename(value: str) -> str:
151 """Return a filesystem-safe basename."""
152 value = re.sub(r"[^\w.\-]+", "_", value, flags=re.UNICODE)
153 return value.strip("._") or "asset"
154
155
156 def escape_table_cell(value: str) -> str:
157 """Escape Markdown table syntax inside a cell."""
158 normalized = value.replace("\r\n", "\n").replace("\r", "\n")
159 lines = [re.sub(r"\s+", " ", line).strip() for line in normalized.split("\n")]
160 with_breaks = "<br>".join(lines)
161 return with_breaks.replace("|", r"\|") or " "
162
163
164 def _safe_position(shape: object, attr: str) -> int:
165 """Read a shape's ``top`` / ``left`` EMU, tolerating broken inheritance.
166
167 A placeholder with no explicit position resolves it by walking up to its
168 master. A deck that ships notesSlides without a notesMaster (or any other
169 partial inheritance chain) makes python-pptx raise on that lookup, so treat
170 an unresolvable position as 0 rather than aborting the whole conversion.
171 """
172 try:
173 return int(getattr(shape, attr, 0) or 0)
174 except Exception:
175 return 0
176
177
178 def iter_leaf_shapes(shapes: object) -> list[LeafShape]:
179 """Return a flattened, reading-order list of shapes."""
180 items: list[LeafShape] = []
181 for shape in shapes:
182 if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
183 items.extend(iter_leaf_shapes(shape.shapes))
184 continue
185 items.append(
186 LeafShape(
187 shape=shape,
188 top=_safe_position(shape, "top"),
189 left=_safe_position(shape, "left"),
190 )
191 )
192 items.sort(key=lambda item: (item.top, item.left))
193 return items
194
195
196 def _is_supported_url(url: str) -> bool:
197 """Reject empty URLs and the known-dangerous schemes."""
198 return bool(url) and not any(
199 url.lower().startswith(scheme) for scheme in UNSUPPORTED_URL_SCHEMES
200 )
201
202
203 def _escape_md_link_text(text: str) -> str:
204 """Backslash-escape characters that would break a Markdown link label.
205
206 A stray ``]`` in anchor text would otherwise close the ``[...]`` early.
207 """
208 return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
209
210
211 def _encode_md_url(url: str) -> str:
212 """Percent-encode a URL so Markdown link syntax stays unambiguous.
213
214 Notably encodes ``(`` / ``)`` to ``%28`` / ``%29`` so a parenthesised URL
215 does not terminate the ``[text](url)`` form early.
216 """
217 return quote(url, safe="/:?=&%#@!$'*+,;")
218
219
220 def _resolve_internal_jump(run: object, shape: object) -> str | None:
221 """Return ``#slide-N`` for a run carrying a slide-internal jump, else None.
222
223 Reads ``run._r`` (private python-pptx API) because the public
224 ``run.hyperlink.address`` cannot tell an internal jump apart from an
225 external URL — see the module docstring's API stability note.
226 """
227 r_id = ""
228 try:
229 rpr = run._r.find(qn("a:rPr"))
230 if rpr is None:
231 return None
232 hlink = rpr.find(qn("a:hlinkClick"))
233 if hlink is None or "hlinksldjump" not in (hlink.get("action", "") or ""):
234 return None
235 r_id = hlink.get(qn("r:id"), "")
236 if not r_id:
237 return None
238 target_slide = shape.part.related_part(r_id).slide
239 prs = shape.part.slide.part.package.presentation_part.presentation
240 return f"#slide-{list(prs.slides).index(target_slide) + 1}"
241 except (KeyError, ValueError, AttributeError):
242 print(f"[WARN] ppt_to_md: could not resolve slide jump rId={r_id}", file=sys.stderr)
243 return None
244
245
246 def _run_url(run: object, shape: object) -> str | None:
247 """Resolve a run's hyperlink target to a markdown-ready URL, or None."""
248 if shape is not None:
249 internal = _resolve_internal_jump(run, shape)
250 if internal:
251 return internal
252 try:
253 addr = run.hyperlink.address
254 except AttributeError:
255 return None
256 if _is_supported_url(addr or ""):
257 return _encode_md_url(addr)
258 return None
259
260
261 def _paragraph_to_markdown(paragraph: object, shape: object) -> str:
262 """Render one paragraph, merging consecutive runs that share a URL.
263
264 Run text is concatenated verbatim — including the spaces between runs — and
265 normalized only once over the assembled paragraph, so a link in the middle
266 of a sentence does not swallow its surrounding spaces. A link group's own
267 leading / trailing whitespace is kept outside the ``[...]`` so it separates
268 words rather than padding the anchor text.
269 """
270 parts = []
271 current_url = None
272 current_text = ""
273
274 def flush():
275 if not current_text:
276 return
277 if current_url is None:
278 parts.append(current_text)
279 return
280 lead = current_text[: len(current_text) - len(current_text.lstrip())]
281 trail = current_text[len(current_text.rstrip()):]
282 core = current_text.strip()
283 display = _escape_md_link_text(core) if core else current_url
284 parts.append(f"{lead}[{display}]({current_url}){trail}")
285
286 has_run_hyperlink = False
287 for run in paragraph.runs:
288 url = _run_url(run, shape)
289 if url:
290 has_run_hyperlink = True
291 if url != current_url:
292 flush()
293 current_text = ""
294 current_url = url
295 current_text += run.text or ""
296 flush()
297
298 text = normalize_text("".join(parts))
299
300 # Shape-level click_action only matters when no run carried its own link.
301 if not has_run_hyperlink and shape is not None:
302 text = _apply_shape_click_action(text, shape)
303 return text
304
305
306 def _apply_shape_click_action(text: str, shape: object) -> str:
307 """Wrap paragraph text in a link from the shape's click_action, if any."""
308 try:
309 action = shape.click_action
310 if action.action == PP_ACTION.HYPERLINK:
311 url = action.hyperlink.address or ""
312 if _is_supported_url(url):
313 return f"[{_escape_md_link_text(text)}]({_encode_md_url(url)})"
314 elif action.action == PP_ACTION.NAMED_SLIDE:
315 target = action.target_slide
316 if target is not None:
317 prs = shape.part.slide.part.package.presentation_part.presentation
318 idx = list(prs.slides).index(target) + 1
319 return f"[{_escape_md_link_text(text)}](#slide-{idx})"
320 except (AttributeError, ValueError):
321 print("[WARN] ppt_to_md: could not process shape click_action", file=sys.stderr)
322 return text
323
324
325 def _paragraph_has_hyperlink(paragraph: object) -> bool:
326 """True if any run carries an external URL or an internal slide jump."""
327 for run in paragraph.runs:
328 try:
329 if run.hyperlink.address:
330 return True
331 except AttributeError:
332 pass
333 try:
334 rpr = run._r.find(qn("a:rPr"))
335 if rpr is not None and rpr.find(qn("a:hlinkClick")) is not None:
336 return True
337 except AttributeError:
338 continue
339 return False
340
341
342 def text_frame_to_markdown(text_frame: object, shape: object = None) -> str:
343 """Convert a PowerPoint text frame into Markdown, preserving hyperlinks.
344
345 Run-level external URLs and slide-internal jumps are emitted as
346 ``[text](url)`` / ``[text](#slide-N)``; consecutive runs sharing a URL are
347 merged. When no run carries a link, the shape's ``click_action`` is used as
348 a paragraph-level fallback. Pass ``shape`` to enable hyperlink extraction;
349 without it the frame degrades to plain text.
350 """
351 visible_paragraphs = [
352 paragraph for paragraph in text_frame.paragraphs
353 if normalize_text(paragraph.text) or _paragraph_has_hyperlink(paragraph)
354 ]
355 if not visible_paragraphs:
356 return ""
357
358 list_like = any(paragraph.level > 0 for paragraph in visible_paragraphs)
359 if not list_like:
360 list_like = len(visible_paragraphs) > 1
361
362 paragraphs = []
363 for paragraph in visible_paragraphs:
364 text = _escape_readback_control_lines(
365 _paragraph_to_markdown(paragraph, shape)
366 )
367 if not text:
368 continue
369 if list_like:
370 indent = " " * max(paragraph.level, 0)
371 paragraphs.append(f"{indent}- {text}")
372 else:
373 paragraphs.append(text)
374
375 if list_like:
376 return "\n".join(paragraphs)
377 return "\n\n".join(paragraphs)
378
379
380 def table_to_markdown(table: object) -> str:
381 """Convert a PowerPoint table to a Markdown table."""
382 rows = []
383 for row in table.rows:
384 cells = [escape_table_cell(cell.text) for cell in row.cells]
385 rows.append(cells)
386
387 if not rows:
388 return ""
389
390 column_count = max(len(row) for row in rows)
391 normalized_rows = [row + [" "] * (column_count - len(row)) for row in rows]
392 header = normalized_rows[0]
393 separator = ["---"] * column_count
394 body = normalized_rows[1:]
395
396 lines = [
397 "| " + " | ".join(header) + " |",
398 "| " + " | ".join(separator) + " |",
399 ]
400 for row in body:
401 lines.append("| " + " | ".join(row) + " |")
402 return "\n".join(lines)
403
404
405 def _format_chart_value(value: object) -> str:
406 """Render a chart data point, trimming whole-number floats."""
407 if value is None:
408 return ""
409 if isinstance(value, float) and value.is_integer():
410 return str(int(value))
411 return str(value)
412
413
414 def _chart_header(chart: object, name: str) -> tuple[str, str]:
415 """Return the Markdown chart header and its best-effort type label."""
416 try:
417 chart_type = str(chart.chart_type)
418 except (ValueError, AttributeError, KeyError):
419 chart_type = ""
420 raw_name = "" if name is None else str(name)
421 chart_name = normalize_text(raw_name).replace("\n", " ") or "Chart"
422 header = f"> [Chart] {chart_name}" + (f" — {chart_type}" if chart_type else "")
423 return header, chart_type
424
425
426 def _chart_warning_lines(warnings: list[str]) -> list[str]:
427 """Return stable, de-duplicated Markdown warning blocks."""
428 lines: list[str] = []
429 seen: set[str] = set()
430 for warning in warnings:
431 normalized = normalize_text(warning).replace("\n", " ") or "unknown warning"
432 if normalized in seen:
433 continue
434 seen.add(normalized)
435 lines.append(f"> [Chart data warning: {normalized}]")
436 return lines
437
438
439 def _chart_data_unavailable(
440 header: str,
441 reason: str,
442 *,
443 warnings: list[str] | None = None,
444 ) -> str:
445 """Attach an explicit data-read failure to a chart heading."""
446 normalized_reason = normalize_text(reason).replace("\n", " ") or "unknown reason"
447 lines = [header]
448 lines.extend(_chart_warning_lines(warnings or []))
449 lines.append(f"> [Chart data unavailable: {normalized_reason}]")
450 return "\n".join(lines)
451
452
453 def _chart_value_cell(value: object) -> str:
454 """Return one chart table cell while keeping missing values visibly empty."""
455 rendered = _format_chart_value(value)
456 return escape_table_cell(rendered) if rendered else ""
457
458
459 def _chart_series_element(series: object) -> object | None:
460 """Return python-pptx's series XML carrier when its public API is insufficient."""
461 element = getattr(series, "_element", None)
462 if element is not None:
463 return element
464 return getattr(series, "_ser", None)
465
466
467 def _chart_series_name(series: object, index: int) -> str:
468 """Return a stable, Markdown-safe series name."""
469 try:
470 raw_name = series.name
471 except (ValueError, AttributeError, KeyError):
472 raw_name = None
473 label = str(raw_name) if raw_name not in (None, "") else f"Series {index}"
474 return escape_table_cell(label)
475
476
477 def _chart_numeric_cache_values(
478 parent: object | None,
479 ) -> tuple[list[object | None] | None, str | None]:
480 """Read one XY display cache through python-pptx's OOXML value helpers."""
481 if parent is None:
482 return None, "missing numeric value container"
483 try:
484 point_count = int(parent.ptCount_val)
485 values = [parent.pt_v(index) for index in range(point_count)]
486 except (AttributeError, IndexError, TypeError, ValueError):
487 return None, "invalid or unavailable numeric display cache"
488 if point_count <= 0 or all(value is None for value in values):
489 return None, "numeric display cache contains no values"
490 return values, None
491
492
493 def _chart_family(chart_type: str, series: list[object]) -> str:
494 """Classify category, scatter, and bubble charts without misreading XY as category."""
495 type_key = chart_type.upper()
496 if "BUBBLE" in type_key:
497 return "bubble"
498 if "SCATTER" in type_key:
499 return "scatter"
500 for item in series:
501 element = _chart_series_element(item)
502 if element is None:
503 continue
504 if element.find(qn("c:bubbleSize")) is not None:
505 return "bubble"
506 if element.find(qn("c:xVal")) is not None or element.find(qn("c:yVal")) is not None:
507 return "scatter"
508 return "category"
509
510
511 def _xy_chart_to_markdown(
512 series: list[object],
513 *,
514 family: str,
515 header: str,
516 ) -> str:
517 """Render scatter/bubble series as typed per-point X/Y[/size] rows."""
518 table_header = ["Series", "Point", "X", "Y"]
519 if family == "bubble":
520 table_header.append("Size")
521 rows: list[list[str]] = []
522 warnings: list[str] = []
523
524 for series_index, item in enumerate(series, start=1):
525 series_name = _chart_series_name(item, series_index)
526 element = _chart_series_element(item)
527 if element is None:
528 x_values = None
529 warnings.append(f"{series_name}: series XML is unavailable for X data")
530 else:
531 x_values, x_error = _chart_numeric_cache_values(
532 element.find(qn("c:xVal"))
533 )
534 if x_error:
535 warnings.append(f"{series_name}: X data {x_error}")
536 try:
537 y_values = list(item.values)
538 except (ValueError, TypeError, AttributeError, KeyError):
539 y_values = []
540 warnings.append(f"{series_name}: Y values are unavailable")
541
542 size_values: list[object | None] | None = None
543 if family == "bubble":
544 if element is None:
545 size_error = "missing series XML"
546 else:
547 size_values, size_error = _chart_numeric_cache_values(
548 element.find(qn("c:bubbleSize"))
549 )
550 if size_error:
551 warnings.append(f"{series_name}: bubble sizes {size_error}")
552
553 x_values = x_values or []
554 point_count = max(
555 len(x_values),
556 len(y_values),
557 len(size_values or []),
558 )
559 if point_count == 0:
560 warnings.append(f"{series_name}: no readable points")
561 continue
562
563 point_counts = {len(x_values), len(y_values)}
564 if family == "bubble":
565 point_counts.add(len(size_values or []))
566 if len(point_counts) > 1:
567 dimensions = "X/Y/size" if family == "bubble" else "X/Y"
568 warnings.append(
569 f"{series_name}: {dimensions} point counts differ; "
570 "missing cells are blank"
571 )
572
573 for point_index in range(point_count):
574 x_value = x_values[point_index] if point_index < len(x_values) else None
575 y_value = y_values[point_index] if point_index < len(y_values) else None
576 row = [
577 series_name,
578 str(point_index + 1),
579 _chart_value_cell(x_value),
580 _chart_value_cell(y_value),
581 ]
582 if family == "bubble":
583 size_value = (
584 size_values[point_index]
585 if size_values is not None and point_index < len(size_values)
586 else None
587 )
588 row.append(_chart_value_cell(size_value))
589 rows.append(row)
590
591 if not rows:
592 return _chart_data_unavailable(
593 header,
594 "chart has no readable XY points",
595 warnings=warnings,
596 )
597 lines = [header]
598 lines.extend(_chart_warning_lines(warnings))
599 lines.extend([
600 "",
601 "| " + " | ".join(table_header) + " |",
602 "| " + " | ".join(["---"] * len(table_header)) + " |",
603 ])
604 lines.extend("| " + " | ".join(row) + " |" for row in rows)
605 return "\n".join(lines)
606
607
608 def _category_chart_to_markdown(chart: object, series: list[object], header: str) -> str:
609 """Render a conventional category chart through python-pptx's public API."""
610 categories: list[str] = []
611 warnings: list[str] = []
612 has_category_xml = any(
613 (element := _chart_series_element(item)) is not None
614 and element.find(qn("c:cat")) is not None
615 for item in series
616 )
617 try:
618 plots = list(chart.plots)
619 if plots:
620 categories = [
621 escape_table_cell(str(category)) if category is not None else ""
622 for category in plots[0].categories
623 ]
624 except (ValueError, TypeError, IndexError, AttributeError, KeyError):
625 if has_category_xml:
626 warnings.append("chart categories are unavailable; using point numbers")
627 if not has_category_xml and not categories:
628 warnings.append("chart categories are missing; using point numbers")
629 elif has_category_xml and not categories:
630 warnings.append("chart categories are empty; using point numbers")
631
632 series_data: list[tuple[str, list[object]]] = []
633 for index, item in enumerate(series, start=1):
634 series_name = _chart_series_name(item, index)
635 try:
636 values = list(item.values)
637 except (ValueError, TypeError, AttributeError, KeyError):
638 warnings.append(f"{series_name}: series values are unavailable")
639 continue
640 series_data.append((series_name, values))
641
642 row_count = max(
643 len(categories),
644 max((len(values) for _, values in series_data), default=0),
645 )
646 if not series_data or row_count == 0:
647 return _chart_data_unavailable(
648 header,
649 "chart has no readable category-series data",
650 warnings=warnings,
651 )
652 point_counts = {len(values) for _, values in series_data}
653 if categories:
654 point_counts.add(len(categories))
655 if len(point_counts) > 1:
656 warnings.append("category/series point counts differ; missing cells are blank")
657
658 table_header = (["Category"] if categories else ["#"]) + [
659 series_name for series_name, _ in series_data
660 ]
661 lines = [header]
662 lines.extend(_chart_warning_lines(warnings))
663 lines.extend([
664 "",
665 "| " + " | ".join(table_header) + " |",
666 "| " + " | ".join(["---"] * len(table_header)) + " |",
667 ])
668 for row_index in range(row_count):
669 if categories:
670 label = categories[row_index] if row_index < len(categories) else ""
671 else:
672 label = str(row_index + 1)
673 cells = [label]
674 for _, values in series_data:
675 value = values[row_index] if row_index < len(values) else None
676 cells.append(_chart_value_cell(value))
677 lines.append("| " + " | ".join(cells) + " |")
678 return "\n".join(lines)
679
680
681 def chart_to_markdown(chart: object, name: str) -> str:
682 """Render category, scatter, and bubble data without flattening chart semantics.
683
684 A native PowerPoint chart stores its data in embedded XML, not in any text
685 frame. Public python-pptx APIs cover category values and scatter/bubble Y
686 values, but not XY X coordinates or bubble sizes. Read only those missing
687 display caches from the series XML. Preserve every readable value and emit
688 explicit warnings for missing dimensions or series rather than discarding
689 the chart's remaining content.
690 """
691 header, chart_type = _chart_header(chart, name)
692 try:
693 series = list(chart.series)
694 except (ValueError, TypeError, AttributeError, KeyError):
695 return _chart_data_unavailable(header, "chart series are unavailable")
696 if not series:
697 return _chart_data_unavailable(header, "chart has no readable series")
698
699 family = _chart_family(chart_type, series)
700 if family in {"scatter", "bubble"}:
701 return _xy_chart_to_markdown(series, family=family, header=header)
702 return _category_chart_to_markdown(chart, series, header)
703
704
705 def _chart_reference_id(element: object) -> str | None:
706 """Return the first chart relationship id carried by an OOXML shape subtree."""
707 for descendant in element.iter():
708 if descendant.tag.rsplit("}", 1)[-1] != "chart":
709 continue
710 relationship_id = descendant.get(f"{{{RELATIONSHIP_NS}}}id")
711 if relationship_id:
712 return relationship_id
713 return None
714
715
716 def _unexposed_chartex_markdown(
717 slide: object,
718 emitted_relationship_ids: set[str],
719 ) -> list[str]:
720 """Report ChartEx objects omitted from ``slide.shapes`` by python-pptx."""
721 blocks: list[str] = []
722 seen_relationship_ids: set[str] = set()
723 slide_element = getattr(slide, "element", None)
724 if slide_element is None:
725 slide_element = getattr(slide, "_element", None)
726 if slide_element is None:
727 return blocks
728 for graphic_data in slide_element.iter(f"{{{DRAWINGML_NS}}}graphicData"):
729 if graphic_data.get("uri") != CHARTEX_URI:
730 continue
731 relationship_id = _chart_reference_id(graphic_data)
732 if relationship_id and (
733 relationship_id in emitted_relationship_ids
734 or relationship_id in seen_relationship_ids
735 ):
736 continue
737 if relationship_id:
738 seen_relationship_ids.add(relationship_id)
739
740 name = "ChartEx chart"
741 current = graphic_data
742 while current is not None:
743 name_element = current.find(f".//{{{PRESENTATIONML_NS}}}cNvPr")
744 if name_element is not None and name_element.get("name"):
745 name = name_element.get("name")
746 break
747 current = current.getparent() if hasattr(current, "getparent") else None
748 chart_name = normalize_text(str(name)).replace("\n", " ") or "ChartEx chart"
749 header = f"> [Chart] {chart_name} — ChartEx"
750 blocks.append(_chart_data_unavailable(header, "unsupported ChartEx data model"))
751 return blocks
752
753
754 def _image_part_for_shape(shape: object) -> object | None:
755 """Return the first embedded image part referenced by a shape."""
756 element = getattr(shape, "element", None)
757 part = getattr(shape, "part", None)
758 if element is None or part is None:
759 return None
760
761 try:
762 blips = element.xpath(".//a:blip")
763 except Exception:
764 return None
765
766 for blip in blips:
767 rel_id = blip.get(qn("r:embed")) or blip.get(qn("r:link"))
768 if not rel_id:
769 continue
770 try:
771 return part.related_part(rel_id)
772 except Exception:
773 continue
774 return None
775
776
777 def _image_size_from_bytes(blob: bytes) -> tuple[int | None, int | None]:
778 """Return bitmap dimensions when Pillow can decode the bytes."""
779 try:
780 from PIL import Image
781 except ImportError:
782 return None, None
783 try:
784 with Image.open(BytesIO(blob)) as img:
785 return img.width, img.height
786 except (OSError, ValueError):
787 return None, None
788
789
790 def _shape_emu(shape: object, attr: str) -> int:
791 value = getattr(shape, attr, 0) or 0
792 return int(value)
793
794
795 def _shape_occurrence(
796 shape: object,
797 slide_index: int,
798 ) -> dict[str, object]:
799 """Return slide-specific image placement metadata."""
800 display_width_emu = _shape_emu(shape, "width")
801 display_height_emu = _shape_emu(shape, "height")
802 display_ratio = (
803 display_width_emu / display_height_emu
804 if display_width_emu > 0 and display_height_emu > 0
805 else None
806 )
807 return {
808 "slide_index": slide_index,
809 "shape_name": str(getattr(shape, "name", "")),
810 "display_left_emu": _shape_emu(shape, "left"),
811 "display_top_emu": _shape_emu(shape, "top"),
812 "display_width_emu": display_width_emu,
813 "display_height_emu": display_height_emu,
814 "display_width_in": round(display_width_emu / EMU_PER_INCH, 4) if display_width_emu else None,
815 "display_height_in": round(display_height_emu / EMU_PER_INCH, 4) if display_height_emu else None,
816 "display_ratio": round(display_ratio, 6) if display_ratio else None,
817 }
818
819
820 def _update_manifest_usage(entry: dict[str, object]) -> None:
821 """Refresh aggregate fields after adding an occurrence."""
822 occurrences = entry.get("occurrences")
823 if not isinstance(occurrences, list):
824 occurrences = []
825 entry["usage_count"] = len(occurrences)
826 ratios = sorted({
827 occurrence.get("display_ratio")
828 for occurrence in occurrences
829 if isinstance(occurrence, dict)
830 and isinstance(occurrence.get("display_ratio"), (int, float))
831 })
832 if ratios:
833 entry["display_ratio_variants"] = ratios
834 if entry.get("display_ratio") is None:
835 entry["display_ratio"] = ratios[0]
836
837
838 def _manifest_entry(
839 *,
840 index: int,
841 filename: str,
842 image_part: object,
843 ext: str,
844 blob: bytes,
845 occurrence: dict[str, object],
846 ) -> dict[str, object]:
847 """Build image_manifest.json metadata for one unique PowerPoint media part."""
848 pixel_width, pixel_height = _image_size_from_bytes(blob)
849 pixel_ratio = (
850 pixel_width / pixel_height
851 if pixel_width and pixel_height
852 else None
853 )
854 is_office_vector = ext in OFFICE_VECTOR_EXTENSIONS
855 partname = str(getattr(image_part, "partname", ""))
856 content_type = str(getattr(image_part, "content_type", ""))
857
858 entry: dict[str, object] = {
859 "index": index,
860 "filename": filename,
861 "original_filename": filename,
862 "asset_kind": "office_vector" if is_office_vector else "bitmap",
863 "svg_renderable": not is_office_vector,
864 "pptx_native_supported": True,
865 "source_kind": "pptx_picture",
866 "source_ext": f".{ext}",
867 "source_target": partname.lstrip("/"),
868 "content_type": content_type,
869 "display_left_emu": occurrence.get("display_left_emu"),
870 "display_top_emu": occurrence.get("display_top_emu"),
871 "display_width_emu": occurrence.get("display_width_emu"),
872 "display_height_emu": occurrence.get("display_height_emu"),
873 "display_width_in": occurrence.get("display_width_in"),
874 "display_height_in": occurrence.get("display_height_in"),
875 "display_ratio": occurrence.get("display_ratio"),
876 "pixel_width": pixel_width,
877 "pixel_height": pixel_height,
878 "pixel_ratio": round(pixel_ratio, 6) if pixel_ratio else None,
879 "occurrences": [occurrence],
880 }
881 if entry["display_ratio"] is None and pixel_ratio:
882 entry["display_ratio"] = round(pixel_ratio, 6)
883 _update_manifest_usage(entry)
884 return entry
885
886
887 def _asset_cache_key(image_part: object, blob: bytes) -> str:
888 """Return a stable key for deduplicating repeated PPTX media references."""
889 partname = str(getattr(image_part, "partname", ""))
890 if partname:
891 return partname
892 return hashlib.sha256(blob).hexdigest()
893
894
895 def _asset_filename(
896 image_part: object,
897 ext: str,
898 asset_index: int,
899 used_filenames: set[str],
900 ) -> str:
901 """Return a unique asset filename, preferring the PPTX media basename."""
902 partname = str(getattr(image_part, "partname", ""))
903 base = sanitize_filename(Path(partname).name) if partname else f"image_{asset_index:03d}.{ext}"
904 if "." not in base:
905 base = f"{base}.{ext}"
906 if base not in used_filenames:
907 used_filenames.add(base)
908 return base
909
910 path = Path(base)
911 stem = path.stem
912 suffix = path.suffix or f".{ext}"
913 counter = 2
914 while True:
915 candidate = f"{stem}_{counter}{suffix}"
916 if candidate not in used_filenames:
917 used_filenames.add(candidate)
918 return candidate
919 counter += 1
920
921
922 def save_picture(
923 shape: object,
924 asset_dir: Path,
925 slide_index: int,
926 asset_index: int,
927 asset_cache: dict[str, SavedPicture],
928 used_filenames: set[str],
929 ) -> SavedPicture | None:
930 """Persist a shape image to the output asset directory."""
931 image_part = _image_part_for_shape(shape)
932 if image_part is None:
933 return None
934
935 content_type = getattr(image_part, "content_type", None)
936 part_ext = getattr(getattr(image_part, "partname", None), "ext", None)
937 ext = normalize_ext(part_ext, content_type)
938 blob = bytes(getattr(image_part, "blob", b""))
939 if not blob:
940 return None
941
942 occurrence = _shape_occurrence(shape, slide_index)
943 cache_key = _asset_cache_key(image_part, blob)
944 cached = asset_cache.get(cache_key)
945 if cached is not None:
946 occurrences = cached.manifest_entry.setdefault("occurrences", [])
947 if isinstance(occurrences, list):
948 occurrences.append(occurrence)
949 _update_manifest_usage(cached.manifest_entry)
950 return SavedPicture(
951 filename=cached.filename,
952 manifest_entry=cached.manifest_entry,
953 is_new_asset=False,
954 )
955
956 filename = _asset_filename(image_part, ext, asset_index, used_filenames)
957 output_path = asset_dir / filename
958 output_path.write_bytes(blob)
959 saved = SavedPicture(
960 filename=filename,
961 manifest_entry=_manifest_entry(
962 index=asset_index,
963 filename=filename,
964 image_part=image_part,
965 ext=ext,
966 blob=blob,
967 occurrence=occurrence,
968 ),
969 is_new_asset=True,
970 )
971 asset_cache[cache_key] = saved
972 return saved
973
974
975 def _reset_generated_asset_dir(asset_dir: Path) -> None:
976 """Remove a previously generated asset directory."""
977 if not asset_dir.exists():
978 return
979 if not (asset_dir / "image_manifest.json").is_file():
980 for path in asset_dir.iterdir():
981 if path.is_file() and LEGACY_GENERATED_IMAGE_RE.match(path.name):
982 path.unlink()
983 return
984 shutil.rmtree(asset_dir)
985
986
987 def extract_notes(slide: object) -> str:
988 """Extract speaker notes text from a slide, if available."""
989 try:
990 notes_slide = slide.notes_slide
991 except Exception:
992 return ""
993
994 blocks = []
995 for item in iter_leaf_shapes(notes_slide.shapes):
996 shape = item.shape
997 if not getattr(shape, "has_text_frame", False):
998 continue
999 text = text_frame_to_markdown(shape.text_frame, shape)
1000 if text:
1001 blocks.append(text)
1002
1003 return "\n\n".join(blocks).strip()
1004
1005
1006 def convert_presentation_to_markdown(
1007 input_path: str,
1008 output_path: str | None = None,
1009 ) -> str:
1010 """Convert a supported PowerPoint file to Markdown."""
1011 input_file = Path(input_path)
1012 if not input_file.exists():
1013 print(f"[ERROR] File not found: {input_path}")
1014 return ""
1015
1016 suffix = input_file.suffix.lower()
1017 if suffix not in SUPPORTED_FORMATS:
1018 supported = ", ".join(sorted(SUPPORTED_FORMATS.keys()))
1019 print(f"[ERROR] Unsupported format: {suffix}")
1020 print(f" Supported: {supported}")
1021 print(" Legacy .ppt files should be resaved as .pptx or exported to PDF first.")
1022 return ""
1023
1024 print(f"[INFO] Converting {SUPPORTED_FORMATS[suffix]}: {input_file.name}")
1025
1026 if output_path:
1027 out_file = Path(output_path)
1028 else:
1029 out_file = input_file.with_suffix(".md")
1030
1031 out_file.parent.mkdir(parents=True, exist_ok=True)
1032 asset_dir = out_file.parent / f"{out_file.stem}_files"
1033 _reset_generated_asset_dir(asset_dir)
1034
1035 presentation = Presentation(str(input_file))
1036 conversion_warnings: list[str] = []
1037 diagrams_by_slide: dict[int, list[dict[str, object]]] = {}
1038 diagram_scan_failures: dict[int, str] = {}
1039 try:
1040 with zipfile.ZipFile(input_file) as package:
1041 for slide_index, slide in enumerate(presentation.slides, 1):
1042 slide_part = str(slide.part.partname).lstrip("/")
1043 try:
1044 diagrams = read_smartart_diagrams(package, slide_part, slide_index)
1045 except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
1046 diagrams_by_slide[slide_index] = []
1047 diagram_scan_failures[slide_index] = str(exc)
1048 conversion_warnings.append(
1049 f"Slide {slide_index}: SmartArt scan failed: {exc}"
1050 )
1051 continue
1052 diagrams_by_slide[slide_index] = diagrams
1053 for diagram in diagrams:
1054 issues = [str(item) for item in diagram.get("warnings", []) if item]
1055 if diagram.get("status") != "ok":
1056 issues.insert(0, f"status={diagram.get('status')}")
1057 if not issues:
1058 continue
1059 conversion_warnings.append(
1060 f"Slide {slide_index}, {diagram.get('shape_name') or diagram.get('diagram_id')}: "
1061 f"SmartArt content {'; '.join(issues)}"
1062 )
1063 except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
1064 conversion_warnings.append(f"SmartArt package scan failed: {exc}")
1065 for slide_index in range(1, len(presentation.slides) + 1):
1066 diagrams_by_slide.setdefault(slide_index, [])
1067 diagram_scan_failures.setdefault(slide_index, str(exc))
1068
1069 lines = [
1070 f"# {input_file.stem}",
1071 "",
1072 f"- Source: `{input_file.name}`",
1073 f"- Total slides: {len(presentation.slides)}",
1074 "",
1075 ]
1076
1077 image_count = 0
1078 image_ref_count = 0
1079 asset_dir_used = False
1080 image_manifest: list[dict[str, object]] = []
1081 asset_cache: dict[str, SavedPicture] = {}
1082 used_filenames: set[str] = set()
1083
1084 for slide_index, slide in enumerate(presentation.slides, 1):
1085 lines.append(f"## Slide {slide_index}")
1086 lines.append("")
1087
1088 blocks = []
1089 slide_diagrams = diagrams_by_slide.get(slide_index, [])
1090 diagrams_by_shape_id = {
1091 str(diagram.get("shape_id")): diagram
1092 for diagram in slide_diagrams
1093 if diagram.get("shape_id") is not None
1094 }
1095 emitted_diagram_ids: set[str] = set()
1096 emitted_chart_relationship_ids: set[str] = set()
1097 for item in iter_leaf_shapes(slide.shapes):
1098 shape = item.shape
1099
1100 if getattr(shape, "has_table", False):
1101 table_md = table_to_markdown(shape.table)
1102 if table_md:
1103 blocks.append(table_md)
1104 continue
1105
1106 shape_id = str(getattr(shape, "shape_id", ""))
1107 diagram = diagrams_by_shape_id.get(shape_id)
1108 if diagram is not None:
1109 blocks.append(smartart_to_markdown(diagram))
1110 emitted_diagram_ids.add(str(diagram.get("diagram_id")))
1111 continue
1112
1113 is_picture_shape = shape.shape_type in {
1114 MSO_SHAPE_TYPE.PICTURE,
1115 MSO_SHAPE_TYPE.LINKED_PICTURE,
1116 }
1117 has_shape_image = is_picture_shape or _image_part_for_shape(shape) is not None
1118 if has_shape_image:
1119 image_ref_count += 1
1120 next_image_index = image_count + 1
1121 asset_dir.mkdir(parents=True, exist_ok=True)
1122 saved_picture = save_picture(
1123 shape,
1124 asset_dir,
1125 slide_index,
1126 next_image_index,
1127 asset_cache,
1128 used_filenames,
1129 )
1130 if saved_picture is None:
1131 if is_picture_shape:
1132 blocks.append(f"> [Image] {getattr(shape, 'name', 'Picture')}")
1133 continue
1134 else:
1135 if saved_picture.is_new_asset:
1136 image_count = next_image_index
1137 image_manifest.append(saved_picture.manifest_entry)
1138 asset_dir_used = True
1139 blocks.append(
1140 f"![Slide {slide_index} Image {image_ref_count}]"
1141 f"({asset_dir.name}/{saved_picture.filename})"
1142 )
1143 if is_picture_shape:
1144 continue
1145
1146 if getattr(shape, "has_text_frame", False):
1147 text_md = text_frame_to_markdown(shape.text_frame, shape)
1148 if text_md:
1149 blocks.append(text_md)
1150 continue
1151
1152 if getattr(shape, "has_chart", False):
1153 shape_element = getattr(shape, "element", None)
1154 if shape_element is None:
1155 shape_element = getattr(shape, "_element", None)
1156 relationship_id = (
1157 _chart_reference_id(shape_element)
1158 if shape_element is not None
1159 else None
1160 )
1161 if relationship_id:
1162 emitted_chart_relationship_ids.add(relationship_id)
1163 try:
1164 blocks.append(chart_to_markdown(shape.chart, getattr(shape, "name", "Chart")))
1165 except (ValueError, TypeError, AttributeError, KeyError) as exc:
1166 raw_name = getattr(shape, "name", "Chart")
1167 name = normalize_text("" if raw_name is None else str(raw_name)) or "Chart"
1168 blocks.append(
1169 _chart_data_unavailable(
1170 f"> [Chart] {name}",
1171 f"chart read failed ({type(exc).__name__})",
1172 )
1173 )
1174
1175 blocks.extend(
1176 _unexposed_chartex_markdown(slide, emitted_chart_relationship_ids)
1177 )
1178
1179 for diagram in slide_diagrams:
1180 if str(diagram.get("diagram_id")) in emitted_diagram_ids:
1181 continue
1182 blocks.append(smartart_to_markdown(diagram))
1183 if slide_index in diagram_scan_failures:
1184 blocks.append(
1185 f"> [SmartArt scan unavailable: {diagram_scan_failures[slide_index]}]"
1186 )
1187
1188 if blocks:
1189 lines.append("\n\n".join(blocks))
1190 lines.append("")
1191 else:
1192 lines.append("_No extractable text content._")
1193 lines.append("")
1194
1195 notes_md = extract_notes(slide)
1196 if notes_md:
1197 lines.append("### Speaker Notes")
1198 lines.append("")
1199 lines.append(notes_md)
1200 lines.append("")
1201
1202 markdown_content = "\n".join(lines).strip() + "\n"
1203 out_file.write_text(markdown_content, encoding="utf-8")
1204 if image_manifest:
1205 (asset_dir / "image_manifest.json").write_text(
1206 json.dumps(image_manifest, ensure_ascii=False, indent=2) + "\n",
1207 encoding="utf-8",
1208 )
1209 profile_path = write_conversion_profile_best_effort(
1210 input_path=str(input_file),
1211 markdown_path=out_file,
1212 converter="ppt_to_md.py",
1213 conversion_type=suffix.lstrip("."),
1214 asset_dir=asset_dir,
1215 warnings=conversion_warnings,
1216 )
1217
1218 print(f"[OK] Saved Markdown to: {out_file}")
1219 if profile_path:
1220 print(f" Wrote conversion profile -> {profile_path}")
1221 if asset_dir_used:
1222 media_files = [
1223 path for path in asset_dir.iterdir()
1224 if path.is_file() and path.name != "image_manifest.json"
1225 ]
1226 print(f" Extracted {len(media_files)} image file(s) -> {asset_dir}")
1227 if image_ref_count != len(media_files):
1228 print(
1229 f" Deduplicated {image_ref_count} image reference(s) "
1230 f"into {len(media_files)} asset file(s)"
1231 )
1232 print(f" Wrote image manifest -> {asset_dir / 'image_manifest.json'}")
1233
1234 return markdown_content
1235
1236
1237 def main() -> int:
1238 """Run the CLI entry point."""
1239 parser = argparse.ArgumentParser(
1240 description="Convert PowerPoint files to Markdown",
1241 formatter_class=argparse.RawDescriptionHelpFormatter,
1242 epilog="""
1243 Examples:
1244 python ppt_to_md.py slides.pptx
1245 python ppt_to_md.py slides.pptx appendix.pptx
1246 python ppt_to_md.py ./decks -o ./markdown
1247 python ppt_to_md.py slides.pptx -o output.md
1248 python ppt_to_md.py deck.ppsx -o notes/deck.md
1249
1250 Supported formats:
1251 .pptx .pptm .ppsx .ppsm .potx .potm
1252
1253 Legacy .ppt is not parsed directly. Resave it as .pptx or export it to PDF first.
1254 """,
1255 )
1256 parser.add_argument("inputs", nargs="+", help="Input PowerPoint file(s) or directories")
1257 parser.add_argument(
1258 "-o",
1259 "--output",
1260 help="Output Markdown file for one input, or output directory for multiple inputs/directories",
1261 )
1262
1263 args = parser.parse_args()
1264
1265 return run_path_batch(
1266 args.inputs,
1267 set(SUPPORTED_FORMATS),
1268 args.output,
1269 lambda source, output: bool(convert_presentation_to_markdown(str(source), str(output))),
1270 )
1271
1272
1273 if __name__ == "__main__":
1274 raise SystemExit(main())
1275
1275 lines PYTHON