1275 lines
45 KiB
Python
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())
|