197 lines
6.9 KiB
Python
197 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""CLI entry: convert a .pptx file to one SVG per slide.
|
|
|
|
Usage:
|
|
python3 pptx_to_svg.py <pptx_file> [-o <output_dir>] [--embed-images]
|
|
[--media-subdir <name>] [--keep-hidden]
|
|
[--inheritance-mode {both,layered,flat}]
|
|
[--strict]
|
|
|
|
Output structure (default --inheritance-mode both):
|
|
<output_dir>/
|
|
svg/ layered machine input: masters/layouts/slides
|
|
svg-flat/ self-contained visual preview slides
|
|
<media_subdir>/ (default: assets/)
|
|
image1.png
|
|
image2.png
|
|
...
|
|
|
|
If -o is omitted, writes alongside the source file as <pptx_stem>_pptx_to_svg/.
|
|
|
|
This is the reverse of svg_to_pptx.py: it reads OOXML directly and emits
|
|
shape-level SVG without going through PowerPoint or PDF rendering.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
from xml.etree import ElementTree as ET
|
|
from zipfile import BadZipFile
|
|
|
|
# Allow running this script from anywhere
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from console_encoding import configure_utf8_stdio
|
|
from pptx_to_svg import convert_pptx_to_svg
|
|
from pptx_to_svg.converter import ConvertOptions
|
|
|
|
configure_utf8_stdio()
|
|
|
|
|
|
def _reconstruction_only_graphics(result: object) -> list[tuple[int, str]]:
|
|
"""Return slide/object labels for generated placeholders."""
|
|
artifacts = getattr(result, "flat_slides", None) or getattr(result, "slides", [])
|
|
diagnostics: list[tuple[int, str]] = []
|
|
for artifact in artifacts:
|
|
try:
|
|
root = ET.fromstring(artifact.svg)
|
|
except ET.ParseError:
|
|
continue
|
|
for elem in root.iter():
|
|
fallback_kind = (
|
|
elem.get("data-pptx-fallback-kind")
|
|
or elem.get("data-pptx-visual-status")
|
|
)
|
|
if fallback_kind != "placeholder":
|
|
continue
|
|
marker_id = elem.get("id") or elem.get("data-name") or "<unnamed>"
|
|
diagnostics.append((artifact.index, marker_id))
|
|
return diagnostics
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Convert .pptx to per-slide SVG by reading OOXML directly.",
|
|
)
|
|
parser.add_argument("pptx_file", help="Path to the source .pptx file")
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
help="Output directory (default: <pptx_stem>_pptx_to_svg beside source)",
|
|
)
|
|
parser.add_argument(
|
|
"--media-subdir",
|
|
default="assets",
|
|
help="Subdirectory for extracted media (default: assets)",
|
|
)
|
|
parser.add_argument(
|
|
"--embed-images",
|
|
action="store_true",
|
|
help="Base64-embed images inline instead of writing files",
|
|
)
|
|
parser.add_argument(
|
|
"--keep-hidden",
|
|
action="store_true",
|
|
help='Include shapes marked hidden="1"',
|
|
)
|
|
parser.add_argument(
|
|
"--inheritance-mode",
|
|
choices=("both", "layered", "flat"),
|
|
default="both",
|
|
help=(
|
|
"How to render inheritance. 'both' (default) writes layered SVGs "
|
|
"under svg/ and complete preview slides under svg-flat/. "
|
|
"'layered' writes only svg/ plus inheritance.json. 'flat' writes "
|
|
"self-contained slides under svg/ for backward compatibility."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help=(
|
|
"Stop on the first unsupported/malformed source construct instead "
|
|
"of the default tolerant conversion with diagnostics"
|
|
),
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
pptx_path = Path(args.pptx_file).expanduser().resolve()
|
|
if not pptx_path.exists():
|
|
print(f"Error: file does not exist: {pptx_path}", file=sys.stderr)
|
|
return 1
|
|
if pptx_path.suffix.lower() != ".pptx":
|
|
print(f"Error: expected a .pptx file, got: {pptx_path.name}", file=sys.stderr)
|
|
return 1
|
|
|
|
output_dir = (
|
|
Path(args.output).expanduser().resolve()
|
|
if args.output
|
|
else pptx_path.with_name(f"{pptx_path.stem}_pptx_to_svg")
|
|
)
|
|
|
|
options = ConvertOptions(
|
|
media_subdir=args.media_subdir,
|
|
embed_images=args.embed_images,
|
|
keep_hidden=args.keep_hidden,
|
|
inheritance_mode=args.inheritance_mode,
|
|
strict=args.strict,
|
|
)
|
|
|
|
try:
|
|
result = convert_pptx_to_svg(pptx_path, output_dir, options)
|
|
except (BadZipFile, ET.ParseError, OSError, RuntimeError, ValueError) as exc:
|
|
print(f"Error: PPTX-to-SVG conversion failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"Source: {pptx_path.name}")
|
|
print(f"Canvas: {result.canvas_px[0]:.0f} x {result.canvas_px[1]:.0f} px")
|
|
if result.theme_colors:
|
|
scheme = ", ".join(f"{k}={v}" for k, v in sorted(result.theme_colors.items()))
|
|
print(f"Theme colors: {scheme}")
|
|
if result.theme_fonts:
|
|
fonts = ", ".join(f"{k}={v}" for k, v in result.theme_fonts.items())
|
|
print(f"Theme fonts: {fonts}")
|
|
print(f"Slides converted: {len(result.slides)}")
|
|
if result.diagnostics:
|
|
print(
|
|
f"Warning: {len(result.diagnostics)} source construct(s) were "
|
|
"normalized, omitted, or replaced; see conversion-report.json.",
|
|
file=sys.stderr,
|
|
)
|
|
for item in result.diagnostics[:20]:
|
|
location = (
|
|
f"slide {item.slide_index}"
|
|
if item.slide_index
|
|
else item.part_path
|
|
)
|
|
shape = item.shape_name or item.shape_id
|
|
if shape:
|
|
location = f"{location}, {shape}" if location else shape
|
|
print(
|
|
f" {location or 'package'}: {item.code}: {item.message}",
|
|
file=sys.stderr,
|
|
)
|
|
if len(result.diagnostics) > 20:
|
|
print(
|
|
f" ... and {len(result.diagnostics) - 20} more",
|
|
file=sys.stderr,
|
|
)
|
|
reconstruction_only = _reconstruction_only_graphics(result)
|
|
if reconstruction_only:
|
|
print(
|
|
"Warning: chart placeholder(s) without a baked preview are "
|
|
"reconstruction-only. Default export keeps the placeholder; "
|
|
"--native-charts-and-tables may reconstruct entries with a valid "
|
|
"replacement marker:",
|
|
file=sys.stderr,
|
|
)
|
|
for slide_index, marker_id in reconstruction_only[:20]:
|
|
print(f" slide {slide_index}: {marker_id}", file=sys.stderr)
|
|
if len(reconstruction_only) > 20:
|
|
print(
|
|
f" ... and {len(reconstruction_only) - 20} more",
|
|
file=sys.stderr,
|
|
)
|
|
print(f"Output: {output_dir}")
|
|
print(f"Conversion report: {output_dir / 'conversion-report.json'}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|