Files

1275 lines
45 KiB
Python

#!/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 = "<br>".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())