| 1 | #!/usr/bin/env python3 |
| 2 | """CLI entry: convert a .pptx file to one SVG per slide. |
| 3 | |
| 4 | Usage: |
| 5 | python3 pptx_to_svg.py <pptx_file> [-o <output_dir>] [--embed-images] |
| 6 | [--media-subdir <name>] [--keep-hidden] |
| 7 | [--inheritance-mode {both,layered,flat}] |
| 8 | [--strict] |
| 9 | |
| 10 | Output structure (default --inheritance-mode both): |
| 11 | <output_dir>/ |
| 12 | svg/ layered machine input: masters/layouts/slides |
| 13 | svg-flat/ self-contained visual preview slides |
| 14 | <media_subdir>/ (default: assets/) |
| 15 | image1.png |
| 16 | image2.png |
| 17 | ... |
| 18 | |
| 19 | If -o is omitted, writes alongside the source file as <pptx_stem>_pptx_to_svg/. |
| 20 | |
| 21 | This is the reverse of svg_to_pptx.py: it reads OOXML directly and emits |
| 22 | shape-level SVG without going through PowerPoint or PDF rendering. |
| 23 | """ |
| 24 | |
| 25 | from __future__ import annotations |
| 26 | |
| 27 | import argparse |
| 28 | import sys |
| 29 | from pathlib import Path |
| 30 | from xml.etree import ElementTree as ET |
| 31 | from zipfile import BadZipFile |
| 32 | |
| 33 | # Allow running this script from anywhere |
| 34 | sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| 35 | |
| 36 | from console_encoding import configure_utf8_stdio |
| 37 | from pptx_to_svg import convert_pptx_to_svg |
| 38 | from pptx_to_svg.converter import ConvertOptions |
| 39 | |
| 40 | configure_utf8_stdio() |
| 41 | |
| 42 | |
| 43 | def _reconstruction_only_graphics(result: object) -> list[tuple[int, str]]: |
| 44 | """Return slide/object labels for generated placeholders.""" |
| 45 | artifacts = getattr(result, "flat_slides", None) or getattr(result, "slides", []) |
| 46 | diagnostics: list[tuple[int, str]] = [] |
| 47 | for artifact in artifacts: |
| 48 | try: |
| 49 | root = ET.fromstring(artifact.svg) |
| 50 | except ET.ParseError: |
| 51 | continue |
| 52 | for elem in root.iter(): |
| 53 | fallback_kind = ( |
| 54 | elem.get("data-pptx-fallback-kind") |
| 55 | or elem.get("data-pptx-visual-status") |
| 56 | ) |
| 57 | if fallback_kind != "placeholder": |
| 58 | continue |
| 59 | marker_id = elem.get("id") or elem.get("data-name") or "<unnamed>" |
| 60 | diagnostics.append((artifact.index, marker_id)) |
| 61 | return diagnostics |
| 62 | |
| 63 | |
| 64 | def parse_args() -> argparse.Namespace: |
| 65 | parser = argparse.ArgumentParser( |
| 66 | description="Convert .pptx to per-slide SVG by reading OOXML directly.", |
| 67 | ) |
| 68 | parser.add_argument("pptx_file", help="Path to the source .pptx file") |
| 69 | parser.add_argument( |
| 70 | "-o", |
| 71 | "--output", |
| 72 | help="Output directory (default: <pptx_stem>_pptx_to_svg beside source)", |
| 73 | ) |
| 74 | parser.add_argument( |
| 75 | "--media-subdir", |
| 76 | default="assets", |
| 77 | help="Subdirectory for extracted media (default: assets)", |
| 78 | ) |
| 79 | parser.add_argument( |
| 80 | "--embed-images", |
| 81 | action="store_true", |
| 82 | help="Base64-embed images inline instead of writing files", |
| 83 | ) |
| 84 | parser.add_argument( |
| 85 | "--keep-hidden", |
| 86 | action="store_true", |
| 87 | help='Include shapes marked hidden="1"', |
| 88 | ) |
| 89 | parser.add_argument( |
| 90 | "--inheritance-mode", |
| 91 | choices=("both", "layered", "flat"), |
| 92 | default="both", |
| 93 | help=( |
| 94 | "How to render inheritance. 'both' (default) writes layered SVGs " |
| 95 | "under svg/ and complete preview slides under svg-flat/. " |
| 96 | "'layered' writes only svg/ plus inheritance.json. 'flat' writes " |
| 97 | "self-contained slides under svg/ for backward compatibility." |
| 98 | ), |
| 99 | ) |
| 100 | parser.add_argument( |
| 101 | "--strict", |
| 102 | action="store_true", |
| 103 | help=( |
| 104 | "Stop on the first unsupported/malformed source construct instead " |
| 105 | "of the default tolerant conversion with diagnostics" |
| 106 | ), |
| 107 | ) |
| 108 | return parser.parse_args() |
| 109 | |
| 110 | |
| 111 | def main() -> int: |
| 112 | args = parse_args() |
| 113 | pptx_path = Path(args.pptx_file).expanduser().resolve() |
| 114 | if not pptx_path.exists(): |
| 115 | print(f"Error: file does not exist: {pptx_path}", file=sys.stderr) |
| 116 | return 1 |
| 117 | if pptx_path.suffix.lower() != ".pptx": |
| 118 | print(f"Error: expected a .pptx file, got: {pptx_path.name}", file=sys.stderr) |
| 119 | return 1 |
| 120 | |
| 121 | output_dir = ( |
| 122 | Path(args.output).expanduser().resolve() |
| 123 | if args.output |
| 124 | else pptx_path.with_name(f"{pptx_path.stem}_pptx_to_svg") |
| 125 | ) |
| 126 | |
| 127 | options = ConvertOptions( |
| 128 | media_subdir=args.media_subdir, |
| 129 | embed_images=args.embed_images, |
| 130 | keep_hidden=args.keep_hidden, |
| 131 | inheritance_mode=args.inheritance_mode, |
| 132 | strict=args.strict, |
| 133 | ) |
| 134 | |
| 135 | try: |
| 136 | result = convert_pptx_to_svg(pptx_path, output_dir, options) |
| 137 | except (BadZipFile, ET.ParseError, OSError, RuntimeError, ValueError) as exc: |
| 138 | print(f"Error: PPTX-to-SVG conversion failed: {exc}", file=sys.stderr) |
| 139 | return 1 |
| 140 | |
| 141 | print(f"Source: {pptx_path.name}") |
| 142 | print(f"Canvas: {result.canvas_px[0]:.0f} x {result.canvas_px[1]:.0f} px") |
| 143 | if result.theme_colors: |
| 144 | scheme = ", ".join(f"{k}={v}" for k, v in sorted(result.theme_colors.items())) |
| 145 | print(f"Theme colors: {scheme}") |
| 146 | if result.theme_fonts: |
| 147 | fonts = ", ".join(f"{k}={v}" for k, v in result.theme_fonts.items()) |
| 148 | print(f"Theme fonts: {fonts}") |
| 149 | print(f"Slides converted: {len(result.slides)}") |
| 150 | if result.diagnostics: |
| 151 | print( |
| 152 | f"Warning: {len(result.diagnostics)} source construct(s) were " |
| 153 | "normalized, omitted, or replaced; see conversion-report.json.", |
| 154 | file=sys.stderr, |
| 155 | ) |
| 156 | for item in result.diagnostics[:20]: |
| 157 | location = ( |
| 158 | f"slide {item.slide_index}" |
| 159 | if item.slide_index |
| 160 | else item.part_path |
| 161 | ) |
| 162 | shape = item.shape_name or item.shape_id |
| 163 | if shape: |
| 164 | location = f"{location}, {shape}" if location else shape |
| 165 | print( |
| 166 | f" {location or 'package'}: {item.code}: {item.message}", |
| 167 | file=sys.stderr, |
| 168 | ) |
| 169 | if len(result.diagnostics) > 20: |
| 170 | print( |
| 171 | f" ... and {len(result.diagnostics) - 20} more", |
| 172 | file=sys.stderr, |
| 173 | ) |
| 174 | reconstruction_only = _reconstruction_only_graphics(result) |
| 175 | if reconstruction_only: |
| 176 | print( |
| 177 | "Warning: chart placeholder(s) without a baked preview are " |
| 178 | "reconstruction-only. Default export keeps the placeholder; " |
| 179 | "--native-charts-and-tables may reconstruct entries with a valid " |
| 180 | "replacement marker:", |
| 181 | file=sys.stderr, |
| 182 | ) |
| 183 | for slide_index, marker_id in reconstruction_only[:20]: |
| 184 | print(f" slide {slide_index}: {marker_id}", file=sys.stderr) |
| 185 | if len(reconstruction_only) > 20: |
| 186 | print( |
| 187 | f" ... and {len(reconstruction_only) - 20} more", |
| 188 | file=sys.stderr, |
| 189 | ) |
| 190 | print(f"Output: {output_dir}") |
| 191 | print(f"Conversion report: {output_dir / 'conversion-report.json'}") |
| 192 | return 0 |
| 193 | |
| 194 | |
| 195 | if __name__ == "__main__": |
| 196 | raise SystemExit(main()) |
| 197 |