#!/usr/bin/env python3 """ PowerPoint to Markdown Converter Extracts slide text, tables, SmartArt node structure, speaker notes, and embedded pictures from Open XML PowerPoint files into Markdown. Primary use case: PPTX source decks -> Markdown for PPT generation input. Hyperlinks present in the source deck are preserved: run-level external URLs and slide-internal jumps are emitted as ``[text](url)`` / ``[text](#slide-N)``, with a shape-level ``click_action`` fallback. Dependency: pip install python-pptx API stability note: Detecting slide-internal jumps (``ppaction://hlinksldjump``) reads ``run._r`` (the CT_TextRun lxml element) because python-pptx exposes no public API to distinguish an internal jump from an external URL. XY chart extraction likewise reads ``series._element`` for X values and bubble sizes, which the public chart API does not expose. Keep these private accesses localized here and covered by conversion smoke tests. """ from __future__ import annotations import argparse import hashlib import json import re import shutil import sys import zipfile from dataclasses import dataclass from io import BytesIO from pathlib import Path from urllib.parse import quote from xml.etree import ElementTree as ET _SCRIPTS_DIR = Path(__file__).resolve().parents[1] if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from console_encoding import configure_utf8_stdio # noqa: E402 from _batch import run_path_batch # noqa: E402 from _conversion_profile import write_conversion_profile_best_effort # noqa: E402 from template_fill_pptx.diagram_read import ( # noqa: E402 read_smartart_diagrams, smartart_to_markdown, ) from pptx import Presentation from pptx.enum.action import PP_ACTION from pptx.enum.shapes import MSO_SHAPE_TYPE from pptx.oxml.ns import qn configure_utf8_stdio() EMU_PER_INCH = 914400 DRAWINGML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" PRESENTATIONML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" RELATIONSHIP_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex" OFFICE_VECTOR_EXTENSIONS = {"emf", "wmf"} IMAGE_EXT_BY_CONTENT_TYPE = { "image/bmp": "bmp", "image/gif": "gif", "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/svg+xml": "svg", "image/tiff": "tiff", "image/x-emf": "emf", "image/x-wmf": "wmf", } LEGACY_GENERATED_IMAGE_RE = re.compile(r"^slide_\d{2}_image_\d{2}\.[A-Za-z0-9]+$") _READBACK_SLIDE_HEADING_RE = re.compile(r"^## Slide\s+\d+\s*$") _READBACK_NOTES_HEADING_RE = re.compile(r"^### Speaker Notes\s*$") # Hyperlink schemes dropped during extraction (a blacklist of known-dangerous # schemes). PowerPoint also rejects unrecognized schemes at open time, so the # residual risk from schemes not listed here is low. UNSUPPORTED_URL_SCHEMES = ("javascript:", "data:", "vbscript:", "file:") SUPPORTED_FORMATS = { ".pptx": "PowerPoint Presentation", ".pptm": "Macro-enabled PowerPoint Presentation", ".ppsx": "PowerPoint Slide Show", ".ppsm": "Macro-enabled PowerPoint Slide Show", ".potx": "PowerPoint Template", ".potm": "Macro-enabled PowerPoint Template", } @dataclass class LeafShape: """Flattened leaf shape with stable position ordering.""" shape: object top: int left: int @dataclass class SavedPicture: """Extracted image asset plus manifest metadata.""" filename: str manifest_entry: dict[str, object] is_new_asset: bool def normalize_text(value: str) -> str: """Collapse whitespace while preserving paragraph boundaries elsewhere.""" value = value.replace("\r\n", "\n").replace("\r", "\n") lines = [re.sub(r"\s+", " ", line).strip() for line in value.split("\n")] lines = [line for line in lines if line] return "\n".join(lines) def _escape_readback_control_lines(value: str) -> str: """Escape ordinary text lines that collide with converter section markers.""" lines = value.split("\n") return "\n".join( f"\\{line}" if ( _READBACK_SLIDE_HEADING_RE.fullmatch(line) or _READBACK_NOTES_HEADING_RE.fullmatch(line) ) else line for line in lines ) def normalize_ext(ext: str | None, content_type: str | None = None) -> str: """Return a lowercase extension without a leading dot.""" if ext: ext = ext.lower().lstrip(".") if ext == "jpeg": return "jpg" return ext if content_type: return IMAGE_EXT_BY_CONTENT_TYPE.get(content_type.lower(), "bin") return "bin" def sanitize_filename(value: str) -> str: """Return a filesystem-safe basename.""" value = re.sub(r"[^\w.\-]+", "_", value, flags=re.UNICODE) return value.strip("._") or "asset" def escape_table_cell(value: str) -> str: """Escape Markdown table syntax inside a cell.""" normalized = value.replace("\r\n", "\n").replace("\r", "\n") lines = [re.sub(r"\s+", " ", line).strip() for line in normalized.split("\n")] with_breaks = "
".join(lines) return with_breaks.replace("|", r"\|") or " " def _safe_position(shape: object, attr: str) -> int: """Read a shape's ``top`` / ``left`` EMU, tolerating broken inheritance. A placeholder with no explicit position resolves it by walking up to its master. A deck that ships notesSlides without a notesMaster (or any other partial inheritance chain) makes python-pptx raise on that lookup, so treat an unresolvable position as 0 rather than aborting the whole conversion. """ try: return int(getattr(shape, attr, 0) or 0) except Exception: return 0 def iter_leaf_shapes(shapes: object) -> list[LeafShape]: """Return a flattened, reading-order list of shapes.""" items: list[LeafShape] = [] for shape in shapes: if shape.shape_type == MSO_SHAPE_TYPE.GROUP: items.extend(iter_leaf_shapes(shape.shapes)) continue items.append( LeafShape( shape=shape, top=_safe_position(shape, "top"), left=_safe_position(shape, "left"), ) ) items.sort(key=lambda item: (item.top, item.left)) return items def _is_supported_url(url: str) -> bool: """Reject empty URLs and the known-dangerous schemes.""" return bool(url) and not any( url.lower().startswith(scheme) for scheme in UNSUPPORTED_URL_SCHEMES ) def _escape_md_link_text(text: str) -> str: """Backslash-escape characters that would break a Markdown link label. A stray ``]`` in anchor text would otherwise close the ``[...]`` early. """ return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") def _encode_md_url(url: str) -> str: """Percent-encode a URL so Markdown link syntax stays unambiguous. Notably encodes ``(`` / ``)`` to ``%28`` / ``%29`` so a parenthesised URL does not terminate the ``[text](url)`` form early. """ return quote(url, safe="/:?=&%#@!$'*+,;") def _resolve_internal_jump(run: object, shape: object) -> str | None: """Return ``#slide-N`` for a run carrying a slide-internal jump, else None. Reads ``run._r`` (private python-pptx API) because the public ``run.hyperlink.address`` cannot tell an internal jump apart from an external URL — see the module docstring's API stability note. """ r_id = "" try: rpr = run._r.find(qn("a:rPr")) if rpr is None: return None hlink = rpr.find(qn("a:hlinkClick")) if hlink is None or "hlinksldjump" not in (hlink.get("action", "") or ""): return None r_id = hlink.get(qn("r:id"), "") if not r_id: return None target_slide = shape.part.related_part(r_id).slide prs = shape.part.slide.part.package.presentation_part.presentation return f"#slide-{list(prs.slides).index(target_slide) + 1}" except (KeyError, ValueError, AttributeError): print(f"[WARN] ppt_to_md: could not resolve slide jump rId={r_id}", file=sys.stderr) return None def _run_url(run: object, shape: object) -> str | None: """Resolve a run's hyperlink target to a markdown-ready URL, or None.""" if shape is not None: internal = _resolve_internal_jump(run, shape) if internal: return internal try: addr = run.hyperlink.address except AttributeError: return None if _is_supported_url(addr or ""): return _encode_md_url(addr) return None def _paragraph_to_markdown(paragraph: object, shape: object) -> str: """Render one paragraph, merging consecutive runs that share a URL. Run text is concatenated verbatim — including the spaces between runs — and normalized only once over the assembled paragraph, so a link in the middle of a sentence does not swallow its surrounding spaces. A link group's own leading / trailing whitespace is kept outside the ``[...]`` so it separates words rather than padding the anchor text. """ parts = [] current_url = None current_text = "" def flush(): if not current_text: return if current_url is None: parts.append(current_text) return lead = current_text[: len(current_text) - len(current_text.lstrip())] trail = current_text[len(current_text.rstrip()):] core = current_text.strip() display = _escape_md_link_text(core) if core else current_url parts.append(f"{lead}[{display}]({current_url}){trail}") has_run_hyperlink = False for run in paragraph.runs: url = _run_url(run, shape) if url: has_run_hyperlink = True if url != current_url: flush() current_text = "" current_url = url current_text += run.text or "" flush() text = normalize_text("".join(parts)) # Shape-level click_action only matters when no run carried its own link. if not has_run_hyperlink and shape is not None: text = _apply_shape_click_action(text, shape) return text def _apply_shape_click_action(text: str, shape: object) -> str: """Wrap paragraph text in a link from the shape's click_action, if any.""" try: action = shape.click_action if action.action == PP_ACTION.HYPERLINK: url = action.hyperlink.address or "" if _is_supported_url(url): return f"[{_escape_md_link_text(text)}]({_encode_md_url(url)})" elif action.action == PP_ACTION.NAMED_SLIDE: target = action.target_slide if target is not None: prs = shape.part.slide.part.package.presentation_part.presentation idx = list(prs.slides).index(target) + 1 return f"[{_escape_md_link_text(text)}](#slide-{idx})" except (AttributeError, ValueError): print("[WARN] ppt_to_md: could not process shape click_action", file=sys.stderr) return text def _paragraph_has_hyperlink(paragraph: object) -> bool: """True if any run carries an external URL or an internal slide jump.""" for run in paragraph.runs: try: if run.hyperlink.address: return True except AttributeError: pass try: rpr = run._r.find(qn("a:rPr")) if rpr is not None and rpr.find(qn("a:hlinkClick")) is not None: return True except AttributeError: continue return False def text_frame_to_markdown(text_frame: object, shape: object = None) -> str: """Convert a PowerPoint text frame into Markdown, preserving hyperlinks. Run-level external URLs and slide-internal jumps are emitted as ``[text](url)`` / ``[text](#slide-N)``; consecutive runs sharing a URL are merged. When no run carries a link, the shape's ``click_action`` is used as a paragraph-level fallback. Pass ``shape`` to enable hyperlink extraction; without it the frame degrades to plain text. """ visible_paragraphs = [ paragraph for paragraph in text_frame.paragraphs if normalize_text(paragraph.text) or _paragraph_has_hyperlink(paragraph) ] if not visible_paragraphs: return "" list_like = any(paragraph.level > 0 for paragraph in visible_paragraphs) if not list_like: list_like = len(visible_paragraphs) > 1 paragraphs = [] for paragraph in visible_paragraphs: text = _escape_readback_control_lines( _paragraph_to_markdown(paragraph, shape) ) if not text: continue if list_like: indent = " " * max(paragraph.level, 0) paragraphs.append(f"{indent}- {text}") else: paragraphs.append(text) if list_like: return "\n".join(paragraphs) return "\n\n".join(paragraphs) def table_to_markdown(table: object) -> str: """Convert a PowerPoint table to a Markdown table.""" rows = [] for row in table.rows: cells = [escape_table_cell(cell.text) for cell in row.cells] rows.append(cells) if not rows: return "" column_count = max(len(row) for row in rows) normalized_rows = [row + [" "] * (column_count - len(row)) for row in rows] header = normalized_rows[0] separator = ["---"] * column_count body = normalized_rows[1:] lines = [ "| " + " | ".join(header) + " |", "| " + " | ".join(separator) + " |", ] for row in body: lines.append("| " + " | ".join(row) + " |") return "\n".join(lines) def _format_chart_value(value: object) -> str: """Render a chart data point, trimming whole-number floats.""" if value is None: return "" if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(value) def _chart_header(chart: object, name: str) -> tuple[str, str]: """Return the Markdown chart header and its best-effort type label.""" try: chart_type = str(chart.chart_type) except (ValueError, AttributeError, KeyError): chart_type = "" raw_name = "" if name is None else str(name) chart_name = normalize_text(raw_name).replace("\n", " ") or "Chart" header = f"> [Chart] {chart_name}" + (f" — {chart_type}" if chart_type else "") return header, chart_type def _chart_warning_lines(warnings: list[str]) -> list[str]: """Return stable, de-duplicated Markdown warning blocks.""" lines: list[str] = [] seen: set[str] = set() for warning in warnings: normalized = normalize_text(warning).replace("\n", " ") or "unknown warning" if normalized in seen: continue seen.add(normalized) lines.append(f"> [Chart data warning: {normalized}]") return lines def _chart_data_unavailable( header: str, reason: str, *, warnings: list[str] | None = None, ) -> str: """Attach an explicit data-read failure to a chart heading.""" normalized_reason = normalize_text(reason).replace("\n", " ") or "unknown reason" lines = [header] lines.extend(_chart_warning_lines(warnings or [])) lines.append(f"> [Chart data unavailable: {normalized_reason}]") return "\n".join(lines) def _chart_value_cell(value: object) -> str: """Return one chart table cell while keeping missing values visibly empty.""" rendered = _format_chart_value(value) return escape_table_cell(rendered) if rendered else "" def _chart_series_element(series: object) -> object | None: """Return python-pptx's series XML carrier when its public API is insufficient.""" element = getattr(series, "_element", None) if element is not None: return element return getattr(series, "_ser", None) def _chart_series_name(series: object, index: int) -> str: """Return a stable, Markdown-safe series name.""" try: raw_name = series.name except (ValueError, AttributeError, KeyError): raw_name = None label = str(raw_name) if raw_name not in (None, "") else f"Series {index}" return escape_table_cell(label) def _chart_numeric_cache_values( parent: object | None, ) -> tuple[list[object | None] | None, str | None]: """Read one XY display cache through python-pptx's OOXML value helpers.""" if parent is None: return None, "missing numeric value container" try: point_count = int(parent.ptCount_val) values = [parent.pt_v(index) for index in range(point_count)] except (AttributeError, IndexError, TypeError, ValueError): return None, "invalid or unavailable numeric display cache" if point_count <= 0 or all(value is None for value in values): return None, "numeric display cache contains no values" return values, None def _chart_family(chart_type: str, series: list[object]) -> str: """Classify category, scatter, and bubble charts without misreading XY as category.""" type_key = chart_type.upper() if "BUBBLE" in type_key: return "bubble" if "SCATTER" in type_key: return "scatter" for item in series: element = _chart_series_element(item) if element is None: continue if element.find(qn("c:bubbleSize")) is not None: return "bubble" if element.find(qn("c:xVal")) is not None or element.find(qn("c:yVal")) is not None: return "scatter" return "category" def _xy_chart_to_markdown( series: list[object], *, family: str, header: str, ) -> str: """Render scatter/bubble series as typed per-point X/Y[/size] rows.""" table_header = ["Series", "Point", "X", "Y"] if family == "bubble": table_header.append("Size") rows: list[list[str]] = [] warnings: list[str] = [] for series_index, item in enumerate(series, start=1): series_name = _chart_series_name(item, series_index) element = _chart_series_element(item) if element is None: x_values = None warnings.append(f"{series_name}: series XML is unavailable for X data") else: x_values, x_error = _chart_numeric_cache_values( element.find(qn("c:xVal")) ) if x_error: warnings.append(f"{series_name}: X data {x_error}") try: y_values = list(item.values) except (ValueError, TypeError, AttributeError, KeyError): y_values = [] warnings.append(f"{series_name}: Y values are unavailable") size_values: list[object | None] | None = None if family == "bubble": if element is None: size_error = "missing series XML" else: size_values, size_error = _chart_numeric_cache_values( element.find(qn("c:bubbleSize")) ) if size_error: warnings.append(f"{series_name}: bubble sizes {size_error}") x_values = x_values or [] point_count = max( len(x_values), len(y_values), len(size_values or []), ) if point_count == 0: warnings.append(f"{series_name}: no readable points") continue point_counts = {len(x_values), len(y_values)} if family == "bubble": point_counts.add(len(size_values or [])) if len(point_counts) > 1: dimensions = "X/Y/size" if family == "bubble" else "X/Y" warnings.append( f"{series_name}: {dimensions} point counts differ; " "missing cells are blank" ) for point_index in range(point_count): x_value = x_values[point_index] if point_index < len(x_values) else None y_value = y_values[point_index] if point_index < len(y_values) else None row = [ series_name, str(point_index + 1), _chart_value_cell(x_value), _chart_value_cell(y_value), ] if family == "bubble": size_value = ( size_values[point_index] if size_values is not None and point_index < len(size_values) else None ) row.append(_chart_value_cell(size_value)) rows.append(row) if not rows: return _chart_data_unavailable( header, "chart has no readable XY points", warnings=warnings, ) lines = [header] lines.extend(_chart_warning_lines(warnings)) lines.extend([ "", "| " + " | ".join(table_header) + " |", "| " + " | ".join(["---"] * len(table_header)) + " |", ]) lines.extend("| " + " | ".join(row) + " |" for row in rows) return "\n".join(lines) def _category_chart_to_markdown(chart: object, series: list[object], header: str) -> str: """Render a conventional category chart through python-pptx's public API.""" categories: list[str] = [] warnings: list[str] = [] has_category_xml = any( (element := _chart_series_element(item)) is not None and element.find(qn("c:cat")) is not None for item in series ) try: plots = list(chart.plots) if plots: categories = [ escape_table_cell(str(category)) if category is not None else "" for category in plots[0].categories ] except (ValueError, TypeError, IndexError, AttributeError, KeyError): if has_category_xml: warnings.append("chart categories are unavailable; using point numbers") if not has_category_xml and not categories: warnings.append("chart categories are missing; using point numbers") elif has_category_xml and not categories: warnings.append("chart categories are empty; using point numbers") series_data: list[tuple[str, list[object]]] = [] for index, item in enumerate(series, start=1): series_name = _chart_series_name(item, index) try: values = list(item.values) except (ValueError, TypeError, AttributeError, KeyError): warnings.append(f"{series_name}: series values are unavailable") continue series_data.append((series_name, values)) row_count = max( len(categories), max((len(values) for _, values in series_data), default=0), ) if not series_data or row_count == 0: return _chart_data_unavailable( header, "chart has no readable category-series data", warnings=warnings, ) point_counts = {len(values) for _, values in series_data} if categories: point_counts.add(len(categories)) if len(point_counts) > 1: warnings.append("category/series point counts differ; missing cells are blank") table_header = (["Category"] if categories else ["#"]) + [ series_name for series_name, _ in series_data ] lines = [header] lines.extend(_chart_warning_lines(warnings)) lines.extend([ "", "| " + " | ".join(table_header) + " |", "| " + " | ".join(["---"] * len(table_header)) + " |", ]) for row_index in range(row_count): if categories: label = categories[row_index] if row_index < len(categories) else "" else: label = str(row_index + 1) cells = [label] for _, values in series_data: value = values[row_index] if row_index < len(values) else None cells.append(_chart_value_cell(value)) lines.append("| " + " | ".join(cells) + " |") return "\n".join(lines) def chart_to_markdown(chart: object, name: str) -> str: """Render category, scatter, and bubble data without flattening chart semantics. A native PowerPoint chart stores its data in embedded XML, not in any text frame. Public python-pptx APIs cover category values and scatter/bubble Y values, but not XY X coordinates or bubble sizes. Read only those missing display caches from the series XML. Preserve every readable value and emit explicit warnings for missing dimensions or series rather than discarding the chart's remaining content. """ header, chart_type = _chart_header(chart, name) try: series = list(chart.series) except (ValueError, TypeError, AttributeError, KeyError): return _chart_data_unavailable(header, "chart series are unavailable") if not series: return _chart_data_unavailable(header, "chart has no readable series") family = _chart_family(chart_type, series) if family in {"scatter", "bubble"}: return _xy_chart_to_markdown(series, family=family, header=header) return _category_chart_to_markdown(chart, series, header) def _chart_reference_id(element: object) -> str | None: """Return the first chart relationship id carried by an OOXML shape subtree.""" for descendant in element.iter(): if descendant.tag.rsplit("}", 1)[-1] != "chart": continue relationship_id = descendant.get(f"{{{RELATIONSHIP_NS}}}id") if relationship_id: return relationship_id return None def _unexposed_chartex_markdown( slide: object, emitted_relationship_ids: set[str], ) -> list[str]: """Report ChartEx objects omitted from ``slide.shapes`` by python-pptx.""" blocks: list[str] = [] seen_relationship_ids: set[str] = set() slide_element = getattr(slide, "element", None) if slide_element is None: slide_element = getattr(slide, "_element", None) if slide_element is None: return blocks for graphic_data in slide_element.iter(f"{{{DRAWINGML_NS}}}graphicData"): if graphic_data.get("uri") != CHARTEX_URI: continue relationship_id = _chart_reference_id(graphic_data) if relationship_id and ( relationship_id in emitted_relationship_ids or relationship_id in seen_relationship_ids ): continue if relationship_id: seen_relationship_ids.add(relationship_id) name = "ChartEx chart" current = graphic_data while current is not None: name_element = current.find(f".//{{{PRESENTATIONML_NS}}}cNvPr") if name_element is not None and name_element.get("name"): name = name_element.get("name") break current = current.getparent() if hasattr(current, "getparent") else None chart_name = normalize_text(str(name)).replace("\n", " ") or "ChartEx chart" header = f"> [Chart] {chart_name} — ChartEx" blocks.append(_chart_data_unavailable(header, "unsupported ChartEx data model")) return blocks def _image_part_for_shape(shape: object) -> object | None: """Return the first embedded image part referenced by a shape.""" element = getattr(shape, "element", None) part = getattr(shape, "part", None) if element is None or part is None: return None try: blips = element.xpath(".//a:blip") except Exception: return None for blip in blips: rel_id = blip.get(qn("r:embed")) or blip.get(qn("r:link")) if not rel_id: continue try: return part.related_part(rel_id) except Exception: continue return None def _image_size_from_bytes(blob: bytes) -> tuple[int | None, int | None]: """Return bitmap dimensions when Pillow can decode the bytes.""" try: from PIL import Image except ImportError: return None, None try: with Image.open(BytesIO(blob)) as img: return img.width, img.height except (OSError, ValueError): return None, None def _shape_emu(shape: object, attr: str) -> int: value = getattr(shape, attr, 0) or 0 return int(value) def _shape_occurrence( shape: object, slide_index: int, ) -> dict[str, object]: """Return slide-specific image placement metadata.""" display_width_emu = _shape_emu(shape, "width") display_height_emu = _shape_emu(shape, "height") display_ratio = ( display_width_emu / display_height_emu if display_width_emu > 0 and display_height_emu > 0 else None ) return { "slide_index": slide_index, "shape_name": str(getattr(shape, "name", "")), "display_left_emu": _shape_emu(shape, "left"), "display_top_emu": _shape_emu(shape, "top"), "display_width_emu": display_width_emu, "display_height_emu": display_height_emu, "display_width_in": round(display_width_emu / EMU_PER_INCH, 4) if display_width_emu else None, "display_height_in": round(display_height_emu / EMU_PER_INCH, 4) if display_height_emu else None, "display_ratio": round(display_ratio, 6) if display_ratio else None, } def _update_manifest_usage(entry: dict[str, object]) -> None: """Refresh aggregate fields after adding an occurrence.""" occurrences = entry.get("occurrences") if not isinstance(occurrences, list): occurrences = [] entry["usage_count"] = len(occurrences) ratios = sorted({ occurrence.get("display_ratio") for occurrence in occurrences if isinstance(occurrence, dict) and isinstance(occurrence.get("display_ratio"), (int, float)) }) if ratios: entry["display_ratio_variants"] = ratios if entry.get("display_ratio") is None: entry["display_ratio"] = ratios[0] def _manifest_entry( *, index: int, filename: str, image_part: object, ext: str, blob: bytes, occurrence: dict[str, object], ) -> dict[str, object]: """Build image_manifest.json metadata for one unique PowerPoint media part.""" pixel_width, pixel_height = _image_size_from_bytes(blob) pixel_ratio = ( pixel_width / pixel_height if pixel_width and pixel_height else None ) is_office_vector = ext in OFFICE_VECTOR_EXTENSIONS partname = str(getattr(image_part, "partname", "")) content_type = str(getattr(image_part, "content_type", "")) entry: dict[str, object] = { "index": index, "filename": filename, "original_filename": filename, "asset_kind": "office_vector" if is_office_vector else "bitmap", "svg_renderable": not is_office_vector, "pptx_native_supported": True, "source_kind": "pptx_picture", "source_ext": f".{ext}", "source_target": partname.lstrip("/"), "content_type": content_type, "display_left_emu": occurrence.get("display_left_emu"), "display_top_emu": occurrence.get("display_top_emu"), "display_width_emu": occurrence.get("display_width_emu"), "display_height_emu": occurrence.get("display_height_emu"), "display_width_in": occurrence.get("display_width_in"), "display_height_in": occurrence.get("display_height_in"), "display_ratio": occurrence.get("display_ratio"), "pixel_width": pixel_width, "pixel_height": pixel_height, "pixel_ratio": round(pixel_ratio, 6) if pixel_ratio else None, "occurrences": [occurrence], } if entry["display_ratio"] is None and pixel_ratio: entry["display_ratio"] = round(pixel_ratio, 6) _update_manifest_usage(entry) return entry def _asset_cache_key(image_part: object, blob: bytes) -> str: """Return a stable key for deduplicating repeated PPTX media references.""" partname = str(getattr(image_part, "partname", "")) if partname: return partname return hashlib.sha256(blob).hexdigest() def _asset_filename( image_part: object, ext: str, asset_index: int, used_filenames: set[str], ) -> str: """Return a unique asset filename, preferring the PPTX media basename.""" partname = str(getattr(image_part, "partname", "")) base = sanitize_filename(Path(partname).name) if partname else f"image_{asset_index:03d}.{ext}" if "." not in base: base = f"{base}.{ext}" if base not in used_filenames: used_filenames.add(base) return base path = Path(base) stem = path.stem suffix = path.suffix or f".{ext}" counter = 2 while True: candidate = f"{stem}_{counter}{suffix}" if candidate not in used_filenames: used_filenames.add(candidate) return candidate counter += 1 def save_picture( shape: object, asset_dir: Path, slide_index: int, asset_index: int, asset_cache: dict[str, SavedPicture], used_filenames: set[str], ) -> SavedPicture | None: """Persist a shape image to the output asset directory.""" image_part = _image_part_for_shape(shape) if image_part is None: return None content_type = getattr(image_part, "content_type", None) part_ext = getattr(getattr(image_part, "partname", None), "ext", None) ext = normalize_ext(part_ext, content_type) blob = bytes(getattr(image_part, "blob", b"")) if not blob: return None occurrence = _shape_occurrence(shape, slide_index) cache_key = _asset_cache_key(image_part, blob) cached = asset_cache.get(cache_key) if cached is not None: occurrences = cached.manifest_entry.setdefault("occurrences", []) if isinstance(occurrences, list): occurrences.append(occurrence) _update_manifest_usage(cached.manifest_entry) return SavedPicture( filename=cached.filename, manifest_entry=cached.manifest_entry, is_new_asset=False, ) filename = _asset_filename(image_part, ext, asset_index, used_filenames) output_path = asset_dir / filename output_path.write_bytes(blob) saved = SavedPicture( filename=filename, manifest_entry=_manifest_entry( index=asset_index, filename=filename, image_part=image_part, ext=ext, blob=blob, occurrence=occurrence, ), is_new_asset=True, ) asset_cache[cache_key] = saved return saved def _reset_generated_asset_dir(asset_dir: Path) -> None: """Remove a previously generated asset directory.""" if not asset_dir.exists(): return if not (asset_dir / "image_manifest.json").is_file(): for path in asset_dir.iterdir(): if path.is_file() and LEGACY_GENERATED_IMAGE_RE.match(path.name): path.unlink() return shutil.rmtree(asset_dir) def extract_notes(slide: object) -> str: """Extract speaker notes text from a slide, if available.""" try: notes_slide = slide.notes_slide except Exception: return "" blocks = [] for item in iter_leaf_shapes(notes_slide.shapes): shape = item.shape if not getattr(shape, "has_text_frame", False): continue text = text_frame_to_markdown(shape.text_frame, shape) if text: blocks.append(text) return "\n\n".join(blocks).strip() def convert_presentation_to_markdown( input_path: str, output_path: str | None = None, ) -> str: """Convert a supported PowerPoint file to Markdown.""" input_file = Path(input_path) if not input_file.exists(): print(f"[ERROR] File not found: {input_path}") return "" suffix = input_file.suffix.lower() if suffix not in SUPPORTED_FORMATS: supported = ", ".join(sorted(SUPPORTED_FORMATS.keys())) print(f"[ERROR] Unsupported format: {suffix}") print(f" Supported: {supported}") print(" Legacy .ppt files should be resaved as .pptx or exported to PDF first.") return "" print(f"[INFO] Converting {SUPPORTED_FORMATS[suffix]}: {input_file.name}") if output_path: out_file = Path(output_path) else: out_file = input_file.with_suffix(".md") out_file.parent.mkdir(parents=True, exist_ok=True) asset_dir = out_file.parent / f"{out_file.stem}_files" _reset_generated_asset_dir(asset_dir) presentation = Presentation(str(input_file)) conversion_warnings: list[str] = [] diagrams_by_slide: dict[int, list[dict[str, object]]] = {} diagram_scan_failures: dict[int, str] = {} try: with zipfile.ZipFile(input_file) as package: for slide_index, slide in enumerate(presentation.slides, 1): slide_part = str(slide.part.partname).lstrip("/") try: diagrams = read_smartart_diagrams(package, slide_part, slide_index) except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc: diagrams_by_slide[slide_index] = [] diagram_scan_failures[slide_index] = str(exc) conversion_warnings.append( f"Slide {slide_index}: SmartArt scan failed: {exc}" ) continue diagrams_by_slide[slide_index] = diagrams for diagram in diagrams: issues = [str(item) for item in diagram.get("warnings", []) if item] if diagram.get("status") != "ok": issues.insert(0, f"status={diagram.get('status')}") if not issues: continue conversion_warnings.append( f"Slide {slide_index}, {diagram.get('shape_name') or diagram.get('diagram_id')}: " f"SmartArt content {'; '.join(issues)}" ) except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError) as exc: conversion_warnings.append(f"SmartArt package scan failed: {exc}") for slide_index in range(1, len(presentation.slides) + 1): diagrams_by_slide.setdefault(slide_index, []) diagram_scan_failures.setdefault(slide_index, str(exc)) lines = [ f"# {input_file.stem}", "", f"- Source: `{input_file.name}`", f"- Total slides: {len(presentation.slides)}", "", ] image_count = 0 image_ref_count = 0 asset_dir_used = False image_manifest: list[dict[str, object]] = [] asset_cache: dict[str, SavedPicture] = {} used_filenames: set[str] = set() for slide_index, slide in enumerate(presentation.slides, 1): lines.append(f"## Slide {slide_index}") lines.append("") blocks = [] slide_diagrams = diagrams_by_slide.get(slide_index, []) diagrams_by_shape_id = { str(diagram.get("shape_id")): diagram for diagram in slide_diagrams if diagram.get("shape_id") is not None } emitted_diagram_ids: set[str] = set() emitted_chart_relationship_ids: set[str] = set() for item in iter_leaf_shapes(slide.shapes): shape = item.shape if getattr(shape, "has_table", False): table_md = table_to_markdown(shape.table) if table_md: blocks.append(table_md) continue shape_id = str(getattr(shape, "shape_id", "")) diagram = diagrams_by_shape_id.get(shape_id) if diagram is not None: blocks.append(smartart_to_markdown(diagram)) emitted_diagram_ids.add(str(diagram.get("diagram_id"))) continue is_picture_shape = shape.shape_type in { MSO_SHAPE_TYPE.PICTURE, MSO_SHAPE_TYPE.LINKED_PICTURE, } has_shape_image = is_picture_shape or _image_part_for_shape(shape) is not None if has_shape_image: image_ref_count += 1 next_image_index = image_count + 1 asset_dir.mkdir(parents=True, exist_ok=True) saved_picture = save_picture( shape, asset_dir, slide_index, next_image_index, asset_cache, used_filenames, ) if saved_picture is None: if is_picture_shape: blocks.append(f"> [Image] {getattr(shape, 'name', 'Picture')}") continue else: if saved_picture.is_new_asset: image_count = next_image_index image_manifest.append(saved_picture.manifest_entry) asset_dir_used = True blocks.append( f"![Slide {slide_index} Image {image_ref_count}]" f"({asset_dir.name}/{saved_picture.filename})" ) if is_picture_shape: continue if getattr(shape, "has_text_frame", False): text_md = text_frame_to_markdown(shape.text_frame, shape) if text_md: blocks.append(text_md) continue if getattr(shape, "has_chart", False): shape_element = getattr(shape, "element", None) if shape_element is None: shape_element = getattr(shape, "_element", None) relationship_id = ( _chart_reference_id(shape_element) if shape_element is not None else None ) if relationship_id: emitted_chart_relationship_ids.add(relationship_id) try: blocks.append(chart_to_markdown(shape.chart, getattr(shape, "name", "Chart"))) except (ValueError, TypeError, AttributeError, KeyError) as exc: raw_name = getattr(shape, "name", "Chart") name = normalize_text("" if raw_name is None else str(raw_name)) or "Chart" blocks.append( _chart_data_unavailable( f"> [Chart] {name}", f"chart read failed ({type(exc).__name__})", ) ) blocks.extend( _unexposed_chartex_markdown(slide, emitted_chart_relationship_ids) ) for diagram in slide_diagrams: if str(diagram.get("diagram_id")) in emitted_diagram_ids: continue blocks.append(smartart_to_markdown(diagram)) if slide_index in diagram_scan_failures: blocks.append( f"> [SmartArt scan unavailable: {diagram_scan_failures[slide_index]}]" ) if blocks: lines.append("\n\n".join(blocks)) lines.append("") else: lines.append("_No extractable text content._") lines.append("") notes_md = extract_notes(slide) if notes_md: lines.append("### Speaker Notes") lines.append("") lines.append(notes_md) lines.append("") markdown_content = "\n".join(lines).strip() + "\n" out_file.write_text(markdown_content, encoding="utf-8") if image_manifest: (asset_dir / "image_manifest.json").write_text( json.dumps(image_manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) profile_path = write_conversion_profile_best_effort( input_path=str(input_file), markdown_path=out_file, converter="ppt_to_md.py", conversion_type=suffix.lstrip("."), asset_dir=asset_dir, warnings=conversion_warnings, ) print(f"[OK] Saved Markdown to: {out_file}") if profile_path: print(f" Wrote conversion profile -> {profile_path}") if asset_dir_used: media_files = [ path for path in asset_dir.iterdir() if path.is_file() and path.name != "image_manifest.json" ] print(f" Extracted {len(media_files)} image file(s) -> {asset_dir}") if image_ref_count != len(media_files): print( f" Deduplicated {image_ref_count} image reference(s) " f"into {len(media_files)} asset file(s)" ) print(f" Wrote image manifest -> {asset_dir / 'image_manifest.json'}") return markdown_content def main() -> int: """Run the CLI entry point.""" parser = argparse.ArgumentParser( description="Convert PowerPoint files to Markdown", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python ppt_to_md.py slides.pptx python ppt_to_md.py slides.pptx appendix.pptx python ppt_to_md.py ./decks -o ./markdown python ppt_to_md.py slides.pptx -o output.md python ppt_to_md.py deck.ppsx -o notes/deck.md Supported formats: .pptx .pptm .ppsx .ppsm .potx .potm Legacy .ppt is not parsed directly. Resave it as .pptx or export it to PDF first. """, ) parser.add_argument("inputs", nargs="+", help="Input PowerPoint file(s) or directories") parser.add_argument( "-o", "--output", help="Output Markdown file for one input, or output directory for multiple inputs/directories", ) args = parser.parse_args() return run_path_batch( args.inputs, set(SUPPORTED_FORMATS), args.output, lambda source, output: bool(convert_presentation_to_markdown(str(source), str(output))), ) if __name__ == "__main__": raise SystemExit(main())