返回 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(
221 run: object,
222 shape: object,
223 ) -> tuple[bool, str | None]:
224 """Return whether a run is an internal jump and its resolved target.
225
226 Reads ``run._r`` (private python-pptx API) because the public
227 ``run.hyperlink.address`` cannot tell an internal jump apart from an
228 external URL — see the module docstring's API stability note.
229 """
230 r_id = ""
231 try:
232 rpr = run._r.find(qn("a:rPr"))
233 if rpr is None:
234 return False, None
235 hlink = rpr.find(qn("a:hlinkClick"))
236 if hlink is None or "hlinksldjump" not in (hlink.get("action", "") or ""):
237 return False, None
238 r_id = hlink.get(qn("r:id"), "")
239 if not r_id:
240 return True, None
241 target_slide = shape.part.related_part(r_id).slide
242 prs = shape.part.slide.part.package.presentation_part.presentation
243 return True, f"#slide-{list(prs.slides).index(target_slide) + 1}"
244 except (KeyError, ValueError, AttributeError):
245 print(f"[WARN] ppt_to_md: could not resolve slide jump rId={r_id}", file=sys.stderr)
246 return True, None
247
248
249 def _run_url(run: object, shape: object) -> str | None:
250 """Resolve a run's hyperlink target to a markdown-ready URL, or None."""
251 if shape is not None:
252 is_internal, internal = _resolve_internal_jump(run, shape)
253 if is_internal:
254 return internal
255 try:
256 addr = run.hyperlink.address
257 except AttributeError:
258 return None
259 if _is_supported_url(addr or ""):
260 return _encode_md_url(addr)
261 return None
262
263
264 def _paragraph_to_markdown(
265 paragraph: object,
266 shape: object,
267 *,
268 use_shape_click_action: bool = True,
269 ) -> str:
270 """Render one paragraph, merging consecutive runs that share a URL.
271
272 Run text is concatenated verbatim — including the spaces between runs — and
273 normalized only once over the assembled paragraph, so a link in the middle
274 of a sentence does not swallow its surrounding spaces. A link group's own
275 leading / trailing whitespace is kept outside the ``[...]`` so it separates
276 words rather than padding the anchor text.
277 """
278 parts = []
279 current_url = None
280 current_text = ""
281
282 def flush():
283 if not current_text:
284 return
285 if current_url is None:
286 parts.append(current_text)
287 return
288 lead = current_text[: len(current_text) - len(current_text.lstrip())]
289 trail = current_text[len(current_text.rstrip()):]
290 core = current_text.strip()
291 display = _escape_md_link_text(core) if core else current_url
292 parts.append(f"{lead}[{display}]({current_url}){trail}")
293
294 has_run_hyperlink = False
295 for run in paragraph.runs:
296 url = _run_url(run, shape)
297 if url:
298 has_run_hyperlink = True
299 if url != current_url:
300 flush()
301 current_text = ""
302 current_url = url
303 current_text += run.text or ""
304 flush()
305
306 text = normalize_text("".join(parts))
307
308 # Shape-level click_action only matters when no run carried its own link.
309 if (
310 use_shape_click_action
311 and not has_run_hyperlink
312 and shape is not None
313 ):
314 text = _apply_shape_click_action(text, shape)
315 return text
316
317
318 def _apply_shape_click_action(text: str, shape: object) -> str:
319 """Wrap paragraph text in a link from the shape's click_action, if any."""
320 target = _shape_click_target(shape)
321 if target is None:
322 return text
323 return f"[{_escape_md_link_text(text)}]({target})"
324
325
326 def _shape_click_target(shape: object) -> str | None:
327 """Return one Markdown-ready whole-shape click target, if supported."""
328 try:
329 action = shape.click_action
330 if action.action == PP_ACTION.HYPERLINK:
331 url = action.hyperlink.address or ""
332 if _is_supported_url(url):
333 return _encode_md_url(url)
334 elif action.action == PP_ACTION.NAMED_SLIDE:
335 target = action.target_slide
336 if target is not None:
337 prs = shape.part.slide.part.package.presentation_part.presentation
338 idx = list(prs.slides).index(target) + 1
339 return f"#slide-{idx}"
340 except (AttributeError, ValueError):
341 print("[WARN] ppt_to_md: could not process shape click_action", file=sys.stderr)
342 return None
343
344
345 def _paragraph_has_hyperlink(paragraph: object) -> bool:
346 """True if any run carries an external URL or an internal slide jump."""
347 for run in paragraph.runs:
348 try:
349 if run.hyperlink.address:
350 return True
351 except AttributeError:
352 pass
353 try:
354 rpr = run._r.find(qn("a:rPr"))
355 if rpr is not None and rpr.find(qn("a:hlinkClick")) is not None:
356 return True
357 except AttributeError:
358 continue
359 return False
360
361
362 def text_frame_to_markdown(
363 text_frame: object,
364 shape: object = None,
365 *,
366 use_shape_click_action: bool = True,
367 ) -> str:
368 """Convert a PowerPoint text frame into Markdown, preserving hyperlinks.
369
370 Run-level external URLs and slide-internal jumps are emitted as
371 ``[text](url)`` / ``[text](#slide-N)``; consecutive runs sharing a URL are
372 merged. When no run carries a link, the shape's ``click_action`` is used as
373 a paragraph-level fallback. Pass ``shape`` to enable hyperlink extraction;
374 without it the frame degrades to plain text.
375 """
376 visible_paragraphs = [
377 paragraph for paragraph in text_frame.paragraphs
378 if normalize_text(paragraph.text) or _paragraph_has_hyperlink(paragraph)
379 ]
380 if not visible_paragraphs:
381 return ""
382
383 list_like = any(paragraph.level > 0 for paragraph in visible_paragraphs)
384 if not list_like:
385 list_like = len(visible_paragraphs) > 1
386
387 paragraphs = []
388 for paragraph in visible_paragraphs:
389 text = _escape_readback_control_lines(
390 _paragraph_to_markdown(
391 paragraph,
392 shape,
393 use_shape_click_action=use_shape_click_action,
394 )
395 )
396 if not text:
397 continue
398 if list_like:
399 indent = " " * max(paragraph.level, 0)
400 paragraphs.append(f"{indent}- {text}")
401 else:
402 paragraphs.append(text)
403
404 if list_like:
405 return "\n".join(paragraphs)
406 return "\n\n".join(paragraphs)
407
408
409 def table_to_markdown(table: object, shape: object = None) -> str:
410 """Convert a PowerPoint table to a Markdown table."""
411 rows = []
412 for row in table.rows:
413 cells = [
414 escape_table_cell(
415 text_frame_to_markdown(
416 cell.text_frame,
417 shape,
418 use_shape_click_action=False,
419 )
420 )
421 for cell in row.cells
422 ]
423 rows.append(cells)
424
425 if not rows:
426 return ""
427
428 column_count = max(len(row) for row in rows)
429 normalized_rows = [row + [" "] * (column_count - len(row)) for row in rows]
430 header = normalized_rows[0]
431 separator = ["---"] * column_count
432 body = normalized_rows[1:]
433
434 lines = [
435 "| " + " | ".join(header) + " |",
436 "| " + " | ".join(separator) + " |",
437 ]
438 for row in body:
439 lines.append("| " + " | ".join(row) + " |")
440 return "\n".join(lines)
441
442
443 def _format_chart_value(value: object) -> str:
444 """Render a chart data point, trimming whole-number floats."""
445 if value is None:
446 return ""
447 if isinstance(value, float) and value.is_integer():
448 return str(int(value))
449 return str(value)
450
451
452 def _chart_header(chart: object, name: str) -> tuple[str, str]:
453 """Return the Markdown chart header and its best-effort type label."""
454 try:
455 chart_type = str(chart.chart_type)
456 except (ValueError, AttributeError, KeyError):
457 chart_type = ""
458 raw_name = "" if name is None else str(name)
459 chart_name = normalize_text(raw_name).replace("\n", " ") or "Chart"
460 header = f"> [Chart] {chart_name}" + (f" — {chart_type}" if chart_type else "")
461 return header, chart_type
462
463
464 def _chart_warning_lines(warnings: list[str]) -> list[str]:
465 """Return stable, de-duplicated Markdown warning blocks."""
466 lines: list[str] = []
467 seen: set[str] = set()
468 for warning in warnings:
469 normalized = normalize_text(warning).replace("\n", " ") or "unknown warning"
470 if normalized in seen:
471 continue
472 seen.add(normalized)
473 lines.append(f"> [Chart data warning: {normalized}]")
474 return lines
475
476
477 def _chart_data_unavailable(
478 header: str,
479 reason: str,
480 *,
481 warnings: list[str] | None = None,
482 ) -> str:
483 """Attach an explicit data-read failure to a chart heading."""
484 normalized_reason = normalize_text(reason).replace("\n", " ") or "unknown reason"
485 lines = [header]
486 lines.extend(_chart_warning_lines(warnings or []))
487 lines.append(f"> [Chart data unavailable: {normalized_reason}]")
488 return "\n".join(lines)
489
490
491 def _chart_value_cell(value: object) -> str:
492 """Return one chart table cell while keeping missing values visibly empty."""
493 rendered = _format_chart_value(value)
494 return escape_table_cell(rendered) if rendered else ""
495
496
497 def _chart_series_element(series: object) -> object | None:
498 """Return python-pptx's series XML carrier when its public API is insufficient."""
499 element = getattr(series, "_element", None)
500 if element is not None:
501 return element
502 return getattr(series, "_ser", None)
503
504
505 def _chart_series_name(series: object, index: int) -> str:
506 """Return a stable, Markdown-safe series name."""
507 try:
508 raw_name = series.name
509 except (ValueError, AttributeError, KeyError):
510 raw_name = None
511 label = str(raw_name) if raw_name not in (None, "") else f"Series {index}"
512 return escape_table_cell(label)
513
514
515 def _chart_numeric_cache_values(
516 parent: object | None,
517 ) -> tuple[list[object | None] | None, str | None]:
518 """Read one XY display cache through python-pptx's OOXML value helpers."""
519 if parent is None:
520 return None, "missing numeric value container"
521 try:
522 point_count = int(parent.ptCount_val)
523 values = [parent.pt_v(index) for index in range(point_count)]
524 except (AttributeError, IndexError, TypeError, ValueError):
525 return None, "invalid or unavailable numeric display cache"
526 if point_count <= 0 or all(value is None for value in values):
527 return None, "numeric display cache contains no values"
528 return values, None
529
530
531 def _chart_family(chart_type: str, series: list[object]) -> str:
532 """Classify category, scatter, and bubble charts without misreading XY as category."""
533 type_key = chart_type.upper()
534 if "BUBBLE" in type_key:
535 return "bubble"
536 if "SCATTER" in type_key:
537 return "scatter"
538 for item in series:
539 element = _chart_series_element(item)
540 if element is None:
541 continue
542 if element.find(qn("c:bubbleSize")) is not None:
543 return "bubble"
544 if element.find(qn("c:xVal")) is not None or element.find(qn("c:yVal")) is not None:
545 return "scatter"
546 return "category"
547
548
549 def _xy_chart_to_markdown(
550 series: list[object],
551 *,
552 family: str,
553 header: str,
554 ) -> str:
555 """Render scatter/bubble series as typed per-point X/Y[/size] rows."""
556 table_header = ["Series", "Point", "X", "Y"]
557 if family == "bubble":
558 table_header.append("Size")
559 rows: list[list[str]] = []
560 warnings: list[str] = []
561
562 for series_index, item in enumerate(series, start=1):
563 series_name = _chart_series_name(item, series_index)
564 element = _chart_series_element(item)
565 if element is None:
566 x_values = None
567 warnings.append(f"{series_name}: series XML is unavailable for X data")
568 else:
569 x_values, x_error = _chart_numeric_cache_values(
570 element.find(qn("c:xVal"))
571 )
572 if x_error:
573 warnings.append(f"{series_name}: X data {x_error}")
574 try:
575 y_values = list(item.values)
576 except (ValueError, TypeError, AttributeError, KeyError):
577 y_values = []
578 warnings.append(f"{series_name}: Y values are unavailable")
579
580 size_values: list[object | None] | None = None
581 if family == "bubble":
582 if element is None:
583 size_error = "missing series XML"
584 else:
585 size_values, size_error = _chart_numeric_cache_values(
586 element.find(qn("c:bubbleSize"))
587 )
588 if size_error:
589 warnings.append(f"{series_name}: bubble sizes {size_error}")
590
591 x_values = x_values or []
592 point_count = max(
593 len(x_values),
594 len(y_values),
595 len(size_values or []),
596 )
597 if point_count == 0:
598 warnings.append(f"{series_name}: no readable points")
599 continue
600
601 point_counts = {len(x_values), len(y_values)}
602 if family == "bubble":
603 point_counts.add(len(size_values or []))
604 if len(point_counts) > 1:
605 dimensions = "X/Y/size" if family == "bubble" else "X/Y"
606 warnings.append(
607 f"{series_name}: {dimensions} point counts differ; "
608 "missing cells are blank"
609 )
610
611 for point_index in range(point_count):
612 x_value = x_values[point_index] if point_index < len(x_values) else None
613 y_value = y_values[point_index] if point_index < len(y_values) else None
614 row = [
615 series_name,
616 str(point_index + 1),
617 _chart_value_cell(x_value),
618 _chart_value_cell(y_value),
619 ]
620 if family == "bubble":
621 size_value = (
622 size_values[point_index]
623 if size_values is not None and point_index < len(size_values)
624 else None
625 )
626 row.append(_chart_value_cell(size_value))
627 rows.append(row)
628
629 if not rows:
630 return _chart_data_unavailable(
631 header,
632 "chart has no readable XY points",
633 warnings=warnings,
634 )
635 lines = [header]
636 lines.extend(_chart_warning_lines(warnings))
637 lines.extend([
638 "",
639 "| " + " | ".join(table_header) + " |",
640 "| " + " | ".join(["---"] * len(table_header)) + " |",
641 ])
642 lines.extend("| " + " | ".join(row) + " |" for row in rows)
643 return "\n".join(lines)
644
645
646 def _category_chart_to_markdown(chart: object, series: list[object], header: str) -> str:
647 """Render a conventional category chart through python-pptx's public API."""
648 categories: list[str] = []
649 warnings: list[str] = []
650 has_category_xml = any(
651 (element := _chart_series_element(item)) is not None
652 and element.find(qn("c:cat")) is not None
653 for item in series
654 )
655 try:
656 plots = list(chart.plots)
657 if plots:
658 categories = [
659 escape_table_cell(str(category)) if category is not None else ""
660 for category in plots[0].categories
661 ]
662 except (ValueError, TypeError, IndexError, AttributeError, KeyError):
663 if has_category_xml:
664 warnings.append("chart categories are unavailable; using point numbers")
665 if not has_category_xml and not categories:
666 warnings.append("chart categories are missing; using point numbers")
667 elif has_category_xml and not categories:
668 warnings.append("chart categories are empty; using point numbers")
669
670 series_data: list[tuple[str, list[object]]] = []
671 for index, item in enumerate(series, start=1):
672 series_name = _chart_series_name(item, index)
673 try:
674 values = list(item.values)
675 except (ValueError, TypeError, AttributeError, KeyError):
676 warnings.append(f"{series_name}: series values are unavailable")
677 continue
678 series_data.append((series_name, values))
679
680 row_count = max(
681 len(categories),
682 max((len(values) for _, values in series_data), default=0),
683 )
684 if not series_data or row_count == 0:
685 return _chart_data_unavailable(
686 header,
687 "chart has no readable category-series data",
688 warnings=warnings,
689 )
690 point_counts = {len(values) for _, values in series_data}
691 if categories:
692 point_counts.add(len(categories))
693 if len(point_counts) > 1:
694 warnings.append("category/series point counts differ; missing cells are blank")
695
696 table_header = (["Category"] if categories else ["#"]) + [
697 series_name for series_name, _ in series_data
698 ]
699 lines = [header]
700 lines.extend(_chart_warning_lines(warnings))
701 lines.extend([
702 "",
703 "| " + " | ".join(table_header) + " |",
704 "| " + " | ".join(["---"] * len(table_header)) + " |",
705 ])
706 for row_index in range(row_count):
707 if categories:
708 label = categories[row_index] if row_index < len(categories) else ""
709 else:
710 label = str(row_index + 1)
711 cells = [label]
712 for _, values in series_data:
713 value = values[row_index] if row_index < len(values) else None
714 cells.append(_chart_value_cell(value))
715 lines.append("| " + " | ".join(cells) + " |")
716 return "\n".join(lines)
717
718
719 def chart_to_markdown(chart: object, name: str) -> str:
720 """Render category, scatter, and bubble data without flattening chart semantics.
721
722 A native PowerPoint chart stores its data in embedded XML, not in any text
723 frame. Public python-pptx APIs cover category values and scatter/bubble Y
724 values, but not XY X coordinates or bubble sizes. Read only those missing
725 display caches from the series XML. Preserve every readable value and emit
726 explicit warnings for missing dimensions or series rather than discarding
727 the chart's remaining content.
728 """
729 header, chart_type = _chart_header(chart, name)
730 try:
731 series = list(chart.series)
732 except (ValueError, TypeError, AttributeError, KeyError):
733 return _chart_data_unavailable(header, "chart series are unavailable")
734 if not series:
735 return _chart_data_unavailable(header, "chart has no readable series")
736
737 family = _chart_family(chart_type, series)
738 if family in {"scatter", "bubble"}:
739 return _xy_chart_to_markdown(series, family=family, header=header)
740 return _category_chart_to_markdown(chart, series, header)
741
742
743 def _chart_reference_id(element: object) -> str | None:
744 """Return the first chart relationship id carried by an OOXML shape subtree."""
745 for descendant in element.iter():
746 if descendant.tag.rsplit("}", 1)[-1] != "chart":
747 continue
748 relationship_id = descendant.get(f"{{{RELATIONSHIP_NS}}}id")
749 if relationship_id:
750 return relationship_id
751 return None
752
753
754 def _unexposed_chartex_markdown(
755 slide: object,
756 emitted_relationship_ids: set[str],
757 ) -> list[str]:
758 """Report ChartEx objects omitted from ``slide.shapes`` by python-pptx."""
759 blocks: list[str] = []
760 seen_relationship_ids: set[str] = set()
761 slide_element = getattr(slide, "element", None)
762 if slide_element is None:
763 slide_element = getattr(slide, "_element", None)
764 if slide_element is None:
765 return blocks
766 for graphic_data in slide_element.iter(f"{{{DRAWINGML_NS}}}graphicData"):
767 if graphic_data.get("uri") != CHARTEX_URI:
768 continue
769 relationship_id = _chart_reference_id(graphic_data)
770 if relationship_id and (
771 relationship_id in emitted_relationship_ids
772 or relationship_id in seen_relationship_ids
773 ):
774 continue
775 if relationship_id:
776 seen_relationship_ids.add(relationship_id)
777
778 name = "ChartEx chart"
779 current = graphic_data
780 while current is not None:
781 name_element = current.find(f".//{{{PRESENTATIONML_NS}}}cNvPr")
782 if name_element is not None and name_element.get("name"):
783 name = name_element.get("name")
784 break
785 current = current.getparent() if hasattr(current, "getparent") else None
786 chart_name = normalize_text(str(name)).replace("\n", " ") or "ChartEx chart"
787 header = f"> [Chart] {chart_name} — ChartEx"
788 blocks.append(_chart_data_unavailable(header, "unsupported ChartEx data model"))
789 return blocks
790
791
792 def _image_part_for_shape(shape: object) -> tuple[object | None, str | None]:
793 """Return the first referenced image part plus any resolution failure."""
794 element = getattr(shape, "element", None)
795 if element is None:
796 return None, "shape XML is unavailable while inspecting image references"
797
798 try:
799 blips = element.xpath(".//a:blip")
800 except Exception as exc:
801 return None, (
802 "image reference scan failed "
803 f"({type(exc).__name__}: {exc})"
804 )
805
806 part = getattr(shape, "part", None)
807 failures: list[str] = []
808 for blip in blips:
809 rel_id = blip.get(qn("r:embed")) or blip.get(qn("r:link"))
810 if not rel_id:
811 continue
812 try:
813 return part.related_part(rel_id), None
814 except Exception as exc:
815 failures.append(f"{rel_id} ({type(exc).__name__}: {exc})")
816 if failures:
817 return None, "image relationship resolution failed: " + "; ".join(failures)
818 return None, None
819
820
821 def _image_size_from_bytes(blob: bytes) -> tuple[int | None, int | None]:
822 """Return bitmap dimensions when Pillow can decode the bytes."""
823 try:
824 from PIL import Image
825 except ImportError:
826 return None, None
827 try:
828 with Image.open(BytesIO(blob)) as img:
829 return img.width, img.height
830 except (OSError, ValueError):
831 return None, None
832
833
834 def _shape_emu(shape: object, attr: str) -> int:
835 value = getattr(shape, attr, 0) or 0
836 return int(value)
837
838
839 def _shape_occurrence(
840 shape: object,
841 slide_index: int,
842 ) -> dict[str, object]:
843 """Return slide-specific image placement metadata."""
844 display_width_emu = _shape_emu(shape, "width")
845 display_height_emu = _shape_emu(shape, "height")
846 display_ratio = (
847 display_width_emu / display_height_emu
848 if display_width_emu > 0 and display_height_emu > 0
849 else None
850 )
851 return {
852 "slide_index": slide_index,
853 "shape_name": str(getattr(shape, "name", "")),
854 "display_left_emu": _shape_emu(shape, "left"),
855 "display_top_emu": _shape_emu(shape, "top"),
856 "display_width_emu": display_width_emu,
857 "display_height_emu": display_height_emu,
858 "display_width_in": round(display_width_emu / EMU_PER_INCH, 4) if display_width_emu else None,
859 "display_height_in": round(display_height_emu / EMU_PER_INCH, 4) if display_height_emu else None,
860 "display_ratio": round(display_ratio, 6) if display_ratio else None,
861 }
862
863
864 def _update_manifest_usage(entry: dict[str, object]) -> None:
865 """Refresh aggregate fields after adding an occurrence."""
866 occurrences = entry.get("occurrences")
867 if not isinstance(occurrences, list):
868 occurrences = []
869 entry["usage_count"] = len(occurrences)
870 ratios = sorted({
871 occurrence.get("display_ratio")
872 for occurrence in occurrences
873 if isinstance(occurrence, dict)
874 and isinstance(occurrence.get("display_ratio"), (int, float))
875 })
876 if ratios:
877 entry["display_ratio_variants"] = ratios
878 if entry.get("display_ratio") is None:
879 entry["display_ratio"] = ratios[0]
880
881
882 def _manifest_entry(
883 *,
884 index: int,
885 filename: str,
886 image_part: object,
887 ext: str,
888 blob: bytes,
889 occurrence: dict[str, object],
890 ) -> dict[str, object]:
891 """Build image_manifest.json metadata for one unique PowerPoint media part."""
892 pixel_width, pixel_height = _image_size_from_bytes(blob)
893 pixel_ratio = (
894 pixel_width / pixel_height
895 if pixel_width and pixel_height
896 else None
897 )
898 is_office_vector = ext in OFFICE_VECTOR_EXTENSIONS
899 partname = str(getattr(image_part, "partname", ""))
900 content_type = str(getattr(image_part, "content_type", ""))
901
902 entry: dict[str, object] = {
903 "index": index,
904 "filename": filename,
905 "original_filename": filename,
906 "asset_kind": "office_vector" if is_office_vector else "bitmap",
907 "svg_renderable": not is_office_vector,
908 "pptx_native_supported": True,
909 "source_kind": "pptx_picture",
910 "source_ext": f".{ext}",
911 "source_target": partname.lstrip("/"),
912 "content_type": content_type,
913 "display_left_emu": occurrence.get("display_left_emu"),
914 "display_top_emu": occurrence.get("display_top_emu"),
915 "display_width_emu": occurrence.get("display_width_emu"),
916 "display_height_emu": occurrence.get("display_height_emu"),
917 "display_width_in": occurrence.get("display_width_in"),
918 "display_height_in": occurrence.get("display_height_in"),
919 "display_ratio": occurrence.get("display_ratio"),
920 "pixel_width": pixel_width,
921 "pixel_height": pixel_height,
922 "pixel_ratio": round(pixel_ratio, 6) if pixel_ratio else None,
923 "occurrences": [occurrence],
924 }
925 if entry["display_ratio"] is None and pixel_ratio:
926 entry["display_ratio"] = round(pixel_ratio, 6)
927 _update_manifest_usage(entry)
928 return entry
929
930
931 def _asset_cache_key(image_part: object, blob: bytes) -> str:
932 """Return a stable key for deduplicating repeated PPTX media references."""
933 partname = str(getattr(image_part, "partname", ""))
934 if partname:
935 return partname
936 return hashlib.sha256(blob).hexdigest()
937
938
939 def _asset_filename(
940 image_part: object,
941 ext: str,
942 asset_index: int,
943 used_filenames: set[str],
944 ) -> str:
945 """Return a unique asset filename, preferring the PPTX media basename."""
946 partname = str(getattr(image_part, "partname", ""))
947 base = sanitize_filename(Path(partname).name) if partname else f"image_{asset_index:03d}.{ext}"
948 if "." not in base:
949 base = f"{base}.{ext}"
950 if base not in used_filenames:
951 used_filenames.add(base)
952 return base
953
954 path = Path(base)
955 stem = path.stem
956 suffix = path.suffix or f".{ext}"
957 counter = 2
958 while True:
959 candidate = f"{stem}_{counter}{suffix}"
960 if candidate not in used_filenames:
961 used_filenames.add(candidate)
962 return candidate
963 counter += 1
964
965
966 def save_picture(
967 shape: object,
968 image_part: object,
969 asset_dir: Path,
970 slide_index: int,
971 asset_index: int,
972 asset_cache: dict[str, SavedPicture],
973 used_filenames: set[str],
974 ) -> SavedPicture | None:
975 """Persist a shape image to the output asset directory."""
976 content_type = getattr(image_part, "content_type", None)
977 part_ext = getattr(getattr(image_part, "partname", None), "ext", None)
978 ext = normalize_ext(part_ext, content_type)
979 blob = bytes(getattr(image_part, "blob", b""))
980 if not blob:
981 return None
982
983 occurrence = _shape_occurrence(shape, slide_index)
984 cache_key = _asset_cache_key(image_part, blob)
985 cached = asset_cache.get(cache_key)
986 if cached is not None:
987 occurrences = cached.manifest_entry.setdefault("occurrences", [])
988 if isinstance(occurrences, list):
989 occurrences.append(occurrence)
990 _update_manifest_usage(cached.manifest_entry)
991 return SavedPicture(
992 filename=cached.filename,
993 manifest_entry=cached.manifest_entry,
994 is_new_asset=False,
995 )
996
997 filename = _asset_filename(image_part, ext, asset_index, used_filenames)
998 output_path = asset_dir / filename
999 output_path.write_bytes(blob)
1000 saved = SavedPicture(
1001 filename=filename,
1002 manifest_entry=_manifest_entry(
1003 index=asset_index,
1004 filename=filename,
1005 image_part=image_part,
1006 ext=ext,
1007 blob=blob,
1008 occurrence=occurrence,
1009 ),
1010 is_new_asset=True,
1011 )
1012 asset_cache[cache_key] = saved
1013 return saved
1014
1015
1016 def _reset_generated_asset_dir(asset_dir: Path) -> None:
1017 """Remove a previously generated asset directory."""
1018 if not asset_dir.exists():
1019 return
1020 if not (asset_dir / "image_manifest.json").is_file():
1021 for path in asset_dir.iterdir():
1022 if path.is_file() and LEGACY_GENERATED_IMAGE_RE.match(path.name):
1023 path.unlink()
1024 return
1025 shutil.rmtree(asset_dir)
1026
1027
1028 def extract_notes(slide: object) -> tuple[str, str | None]:
1029 """Extract speaker notes text plus any notes-slide access failure."""
1030 try:
1031 notes_slide = slide.notes_slide
1032 except Exception as exc:
1033 return "", f"speaker notes read failed ({type(exc).__name__}: {exc})"
1034
1035 blocks = []
1036 for item in iter_leaf_shapes(notes_slide.shapes):
1037 shape = item.shape
1038 if not getattr(shape, "has_text_frame", False):
1039 continue
1040 text = text_frame_to_markdown(shape.text_frame, shape)
1041 if text:
1042 blocks.append(text)
1043
1044 return "\n\n".join(blocks).strip(), None
1045
1046
1047 def convert_presentation_to_markdown(
1048 input_path: str,
1049 output_path: str | None = None,
1050 ) -> str:
1051 """Convert a supported PowerPoint file to Markdown."""
1052 input_file = Path(input_path)
1053 if not input_file.exists():
1054 print(f"[ERROR] File not found: {input_path}")
1055 return ""
1056
1057 suffix = input_file.suffix.lower()
1058 if suffix not in SUPPORTED_FORMATS:
1059 supported = ", ".join(sorted(SUPPORTED_FORMATS.keys()))
1060 print(f"[ERROR] Unsupported format: {suffix}")
1061 print(f" Supported: {supported}")
1062 print(" Legacy .ppt files should be resaved as .pptx or exported to PDF first.")
1063 return ""
1064
1065 print(f"[INFO] Converting {SUPPORTED_FORMATS[suffix]}: {input_file.name}")
1066
1067 if output_path:
1068 out_file = Path(output_path)
1069 else:
1070 out_file = input_file.with_suffix(".md")
1071
1072 out_file.parent.mkdir(parents=True, exist_ok=True)
1073 asset_dir = out_file.parent / f"{out_file.stem}_files"
1074 _reset_generated_asset_dir(asset_dir)
1075
1076 presentation = Presentation(str(input_file))
1077 conversion_warnings: list[str] = []
1078 diagrams_by_slide: dict[int, list[dict[str, object]]] = {}
1079 diagram_scan_failures: dict[int, str] = {}
1080 try:
1081 with zipfile.ZipFile(input_file) as package:
1082 for slide_index, slide in enumerate(presentation.slides, 1):
1083 slide_part = str(slide.part.partname).lstrip("/")
1084 try:
1085 diagrams = read_smartart_diagrams(package, slide_part, slide_index)
1086 except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
1087 diagrams_by_slide[slide_index] = []
1088 diagram_scan_failures[slide_index] = str(exc)
1089 conversion_warnings.append(
1090 f"Slide {slide_index}: SmartArt scan failed: {exc}"
1091 )
1092 continue
1093 diagrams_by_slide[slide_index] = diagrams
1094 for diagram in diagrams:
1095 issues = [str(item) for item in diagram.get("warnings", []) if item]
1096 if diagram.get("status") != "ok":
1097 issues.insert(0, f"status={diagram.get('status')}")
1098 if not issues:
1099 continue
1100 conversion_warnings.append(
1101 f"Slide {slide_index}, {diagram.get('shape_name') or diagram.get('diagram_id')}: "
1102 f"SmartArt content {'; '.join(issues)}"
1103 )
1104 except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc:
1105 conversion_warnings.append(f"SmartArt package scan failed: {exc}")
1106 for slide_index in range(1, len(presentation.slides) + 1):
1107 diagrams_by_slide.setdefault(slide_index, [])
1108 diagram_scan_failures.setdefault(slide_index, str(exc))
1109
1110 lines = [
1111 f"# {input_file.stem}",
1112 "",
1113 f"- Source: `{input_file.name}`",
1114 f"- Total slides: {len(presentation.slides)}",
1115 "",
1116 ]
1117
1118 image_count = 0
1119 image_ref_count = 0
1120 asset_dir_used = False
1121 image_manifest: list[dict[str, object]] = []
1122 asset_cache: dict[str, SavedPicture] = {}
1123 used_filenames: set[str] = set()
1124
1125 for slide_index, slide in enumerate(presentation.slides, 1):
1126 lines.append(f"## Slide {slide_index}")
1127 lines.append("")
1128
1129 blocks = []
1130 slide_diagrams = diagrams_by_slide.get(slide_index, [])
1131 diagrams_by_shape_id = {
1132 str(diagram.get("shape_id")): diagram
1133 for diagram in slide_diagrams
1134 if diagram.get("shape_id") is not None
1135 }
1136 emitted_diagram_ids: set[str] = set()
1137 emitted_chart_relationship_ids: set[str] = set()
1138 for item in iter_leaf_shapes(slide.shapes):
1139 shape = item.shape
1140
1141 if getattr(shape, "has_table", False):
1142 table_md = table_to_markdown(shape.table, shape)
1143 if table_md:
1144 blocks.append(table_md)
1145 continue
1146
1147 shape_id = str(getattr(shape, "shape_id", ""))
1148 diagram = diagrams_by_shape_id.get(shape_id)
1149 if diagram is not None:
1150 blocks.append(smartart_to_markdown(diagram))
1151 emitted_diagram_ids.add(str(diagram.get("diagram_id")))
1152 continue
1153
1154 is_picture_shape = shape.shape_type in {
1155 MSO_SHAPE_TYPE.PICTURE,
1156 MSO_SHAPE_TYPE.LINKED_PICTURE,
1157 }
1158 image_part, image_error = _image_part_for_shape(shape)
1159 if image_error is not None:
1160 shape_name = getattr(shape, "name", "") or "unnamed shape"
1161 warning = f"Slide {slide_index}, {shape_name}: {image_error}"
1162 conversion_warnings.append(warning)
1163 print(f"[WARN] ppt_to_md: {warning}", file=sys.stderr)
1164 has_shape_image = is_picture_shape or image_part is not None
1165 if has_shape_image:
1166 image_ref_count += 1
1167 next_image_index = image_count + 1
1168 asset_dir.mkdir(parents=True, exist_ok=True)
1169 saved_picture = (
1170 save_picture(
1171 shape,
1172 image_part,
1173 asset_dir,
1174 slide_index,
1175 next_image_index,
1176 asset_cache,
1177 used_filenames,
1178 )
1179 if image_part is not None
1180 else None
1181 )
1182 if saved_picture is None:
1183 if is_picture_shape:
1184 blocks.append(f"> [Image] {getattr(shape, 'name', 'Picture')}")
1185 continue
1186 else:
1187 if saved_picture.is_new_asset:
1188 image_count = next_image_index
1189 image_manifest.append(saved_picture.manifest_entry)
1190 asset_dir_used = True
1191 image_markdown = (
1192 f"![Slide {slide_index} Image {image_ref_count}]"
1193 f"({asset_dir.name}/{saved_picture.filename})"
1194 )
1195 image_link = _shape_click_target(shape)
1196 if image_link is not None:
1197 image_markdown = f"[{image_markdown}]({image_link})"
1198 blocks.append(image_markdown)
1199 if is_picture_shape:
1200 continue
1201
1202 if getattr(shape, "has_text_frame", False):
1203 text_md = text_frame_to_markdown(shape.text_frame, shape)
1204 if text_md:
1205 blocks.append(text_md)
1206 continue
1207
1208 if getattr(shape, "has_chart", False):
1209 shape_element = getattr(shape, "element", None)
1210 if shape_element is None:
1211 shape_element = getattr(shape, "_element", None)
1212 relationship_id = (
1213 _chart_reference_id(shape_element)
1214 if shape_element is not None
1215 else None
1216 )
1217 if relationship_id:
1218 emitted_chart_relationship_ids.add(relationship_id)
1219 try:
1220 blocks.append(chart_to_markdown(shape.chart, getattr(shape, "name", "Chart")))
1221 except (ValueError, TypeError, AttributeError, KeyError) as exc:
1222 raw_name = getattr(shape, "name", "Chart")
1223 name = normalize_text("" if raw_name is None else str(raw_name)) or "Chart"
1224 blocks.append(
1225 _chart_data_unavailable(
1226 f"> [Chart] {name}",
1227 f"chart read failed ({type(exc).__name__})",
1228 )
1229 )
1230
1231 blocks.extend(
1232 _unexposed_chartex_markdown(slide, emitted_chart_relationship_ids)
1233 )
1234
1235 for diagram in slide_diagrams:
1236 if str(diagram.get("diagram_id")) in emitted_diagram_ids:
1237 continue
1238 blocks.append(smartart_to_markdown(diagram))
1239 if slide_index in diagram_scan_failures:
1240 blocks.append(
1241 f"> [SmartArt scan unavailable: {diagram_scan_failures[slide_index]}]"
1242 )
1243
1244 if blocks:
1245 lines.append("\n\n".join(blocks))
1246 lines.append("")
1247 else:
1248 lines.append("_No extractable text content._")
1249 lines.append("")
1250
1251 notes_md, notes_error = extract_notes(slide)
1252 if notes_error is not None:
1253 warning = f"Slide {slide_index}: {notes_error}"
1254 conversion_warnings.append(warning)
1255 print(f"[WARN] ppt_to_md: {warning}", file=sys.stderr)
1256 if notes_md:
1257 lines.append("### Speaker Notes")
1258 lines.append("")
1259 lines.append(notes_md)
1260 lines.append("")
1261
1262 markdown_content = "\n".join(lines).strip() + "\n"
1263 out_file.write_text(markdown_content, encoding="utf-8")
1264 if image_manifest:
1265 (asset_dir / "image_manifest.json").write_text(
1266 json.dumps(image_manifest, ensure_ascii=False, indent=2) + "\n",
1267 encoding="utf-8",
1268 )
1269 profile_path = write_conversion_profile_best_effort(
1270 input_path=str(input_file),
1271 markdown_path=out_file,
1272 converter="ppt_to_md.py",
1273 conversion_type=suffix.lstrip("."),
1274 asset_dir=asset_dir,
1275 warnings=conversion_warnings,
1276 )
1277
1278 print(f"[OK] Saved Markdown to: {out_file}")
1279 if profile_path:
1280 print(f" Wrote conversion profile -> {profile_path}")
1281 if asset_dir_used:
1282 media_files = [
1283 path for path in asset_dir.iterdir()
1284 if path.is_file() and path.name != "image_manifest.json"
1285 ]
1286 print(f" Extracted {len(media_files)} image file(s) -> {asset_dir}")
1287 if image_ref_count != len(media_files):
1288 print(
1289 f" Deduplicated {image_ref_count} image reference(s) "
1290 f"into {len(media_files)} asset file(s)"
1291 )
1292 print(f" Wrote image manifest -> {asset_dir / 'image_manifest.json'}")
1293
1294 return markdown_content
1295
1296
1297 def main() -> int:
1298 """Run the CLI entry point."""
1299 parser = argparse.ArgumentParser(
1300 description="Convert PowerPoint files to Markdown",
1301 formatter_class=argparse.RawDescriptionHelpFormatter,
1302 epilog="""
1303 Examples:
1304 python ppt_to_md.py slides.pptx
1305 python ppt_to_md.py slides.pptx appendix.pptx
1306 python ppt_to_md.py ./decks -o ./markdown
1307 python ppt_to_md.py slides.pptx -o output.md
1308 python ppt_to_md.py deck.ppsx -o notes/deck.md
1309
1310 Supported formats:
1311 .pptx .pptm .ppsx .ppsm .potx .potm
1312
1313 Legacy .ppt is not parsed directly. Resave it as .pptx or export it to PDF first.
1314 """,
1315 )
1316 parser.add_argument("inputs", nargs="+", help="Input PowerPoint file(s) or directories")
1317 parser.add_argument(
1318 "-o",
1319 "--output",
1320 help="Output Markdown file for one input, or output directory for multiple inputs/directories",
1321 )
1322
1323 args = parser.parse_args()
1324
1325 return run_path_batch(
1326 args.inputs,
1327 set(SUPPORTED_FORMATS),
1328 args.output,
1329 lambda source, output: bool(convert_presentation_to_markdown(str(source), str(output))),
1330 )
1331
1332
1333 if __name__ == "__main__":
1334 raise SystemExit(main())
1335
1335 lines PYTHON